diff --git a/src/components/FeeEstimator.test.tsx b/src/components/FeeEstimator.test.tsx index a50eb056..72343d69 100644 --- a/src/components/FeeEstimator.test.tsx +++ b/src/components/FeeEstimator.test.tsx @@ -1,7 +1,13 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach,describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { FeeCell, FeeEstimator, MIN_NETWORK_BASE_FEE } from "./FeeEstimator"; +import { + applyFeeMultiplier, + FeeCell, + FeeEstimator, + MIN_NETWORK_BASE_FEE, + SURGE_FEE_THRESHOLD, +} from "./FeeEstimator"; vi.mock("@/lib/client", () => ({ getClient: vi.fn(), @@ -10,6 +16,11 @@ vi.mock("@/lib/client", () => ({ // Issue #442 / context refactor: the components read their client from // SorokitContext, so the hook is routed at the same `getClient` mock every test // below configures. Without this the mocked client never reaches the component. +// `network` is mutable so the #958 tests can simulate a network switch. +const sorokitState = vi.hoisted(() => ({ + network: null as { name: string; passphrase: string } | null, +})); + vi.mock("@/context/useSorokit", async () => { const { getClient } = await import("@/lib/client"); return { @@ -17,6 +28,7 @@ vi.mock("@/context/useSorokit", async () => { client: getClient(), isConnected: true, address: "GTEST", + network: sorokitState.network, }), }; }); @@ -593,3 +605,169 @@ describe("FeeEstimator — issue #670", () => { }); }); + +// ── Issue #958: network switch, surge pricing, speed multipliers ───────────── +describe("FeeEstimator — issue #958", () => { + const TESTNET = { name: "testnet", passphrase: "Test SDF Network ; September 2015" }; + const MAINNET = { name: "mainnet", passphrase: "Public Global Stellar Network ; September 2015" }; + + beforeEach(() => { + vi.clearAllMocks(); + sorokitState.network = TESTNET; + }); + + afterEach(() => { + sorokitState.network = null; + }); + + describe("network switch", () => { + it("clears the previous network's fees immediately and fetches fresh ones", async () => { + let resolveMainnet: (v: unknown) => void = () => {}; + const estimateFee = vi + .fn() + .mockResolvedValueOnce({ data: { baseFee: "111", recommended: "222" }, error: null }) + .mockReturnValueOnce(new Promise((r) => { resolveMainnet = r; })); + vi.mocked(getClient).mockReturnValue({ + transaction: { estimateFee }, + } as unknown as SorokitClient); + + const { rerender, container } = render(); + await waitFor(() => expect(screen.getByText("111")).toBeInTheDocument()); + + sorokitState.network = MAINNET; + rerender(); + + // Stale testnet values are gone at once — no waiting for the 30s poll. + await waitFor(() => expect(screen.queryByText("111")).not.toBeInTheDocument()); + expect(container.querySelector(".animate-pulse")).toBeInTheDocument(); + expect(estimateFee).toHaveBeenCalledTimes(2); + + await act(async () => { + resolveMainnet({ data: { baseFee: "150", recommended: "450" }, error: null }); + }); + expect(await screen.findByText("150")).toBeInTheDocument(); + expect(screen.getByText("450")).toBeInTheDocument(); + }); + + it("discards a response from the old network that resolves after the switch", async () => { + let resolveTestnet: (v: unknown) => void = () => {}; + const estimateFee = vi + .fn() + .mockReturnValueOnce(new Promise((r) => { resolveTestnet = r; })) + .mockResolvedValueOnce({ data: { baseFee: "150", recommended: "450" }, error: null }); + vi.mocked(getClient).mockReturnValue({ + transaction: { estimateFee }, + } as unknown as SorokitClient); + + const { rerender } = render(); + await waitFor(() => expect(estimateFee).toHaveBeenCalledTimes(1)); + + sorokitState.network = MAINNET; + rerender(); + expect(await screen.findByText("150")).toBeInTheDocument(); + + // The testnet request finally answers — it must not overwrite mainnet. + await act(async () => { + resolveTestnet({ data: { baseFee: "999", recommended: "9999" }, error: null }); + }); + expect(screen.queryByText("999")).not.toBeInTheDocument(); + expect(screen.getByText("150")).toBeInTheDocument(); + }); + + it("does not refetch when the network object is re-created with the same values", async () => { + mockEstimateFee({ data: { baseFee: "100", recommended: "200" }, error: null }); + const { rerender } = render(); + await waitFor(() => expect(screen.getByText("200")).toBeInTheDocument()); + + sorokitState.network = { ...TESTNET }; + rerender(); + const estimateFee = vi.mocked(getClient)().transaction.estimateFee; + expect(estimateFee).toHaveBeenCalledTimes(1); + }); + }); + + describe("surge pricing warning", () => { + it("treats more than 3x the minimum base fee as surge pricing", () => { + expect(SURGE_FEE_THRESHOLD).toBe(300); + }); + + it("shows a prominent alert when the base fee exceeds 300 stroops", async () => { + mockEstimateFee({ data: { baseFee: "450", recommended: "900" }, error: null }); + render(); + + const banner = await screen.findByTestId("surge-warning"); + expect(banner).toHaveAttribute("role", "alert"); + expect(banner).toHaveTextContent("Surge pricing"); + expect(banner).toHaveTextContent("4.5x the minimum"); + expect(banner).toHaveTextContent(/competing on fee/); + }); + + it("does not warn at exactly 300 stroops", async () => { + mockEstimateFee({ data: { baseFee: "300", recommended: "600" }, error: null }); + render(); + + await screen.findByText("600"); + expect(screen.queryByTestId("surge-warning")).not.toBeInTheDocument(); + }); + + it("shows a surge badge in the compact variant", async () => { + mockEstimateFee({ data: { baseFee: "301", recommended: "700" }, error: null }); + render(); + + expect(await screen.findByRole("alert")).toHaveTextContent("Surge pricing"); + }); + }); + + describe("speed multipliers", () => { + it("renders 1.2x, 1.5x and 2.0x toggle buttons, none selected by default", async () => { + mockEstimateFee({ data: { baseFee: "100", recommended: "500" }, error: null }); + render(); + + for (const label of ["1.2x", "1.5x", "2.0x"]) { + expect(await screen.findByRole("button", { name: label })).toHaveAttribute( + "aria-pressed", + "false", + ); + } + expect(screen.queryByTestId("multiplied-fee")).not.toBeInTheDocument(); + }); + + it("applies the selected multiplier to the recommended fee", async () => { + const onMultiplierChange = vi.fn(); + mockEstimateFee({ data: { baseFee: "100", recommended: "500" }, error: null }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "1.2x" })); + expect(screen.getByRole("button", { name: "1.2x" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByTestId("multiplied-fee")).toHaveTextContent("600 stroops"); + expect(onMultiplierChange).toHaveBeenLastCalledWith(1.2, "600"); + + fireEvent.click(screen.getByRole("button", { name: "2.0x" })); + expect(screen.getByRole("button", { name: "1.2x" })).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByTestId("multiplied-fee")).toHaveTextContent("1000 stroops"); + expect(onMultiplierChange).toHaveBeenLastCalledWith(2, "1000"); + }); + + it("clears the multiplier when the active button is clicked again", async () => { + const onMultiplierChange = vi.fn(); + mockEstimateFee({ data: { baseFee: "100", recommended: "500" }, error: null }); + render(); + + const button = await screen.findByRole("button", { name: "1.5x" }); + fireEvent.click(button); + expect(screen.getByTestId("multiplied-fee")).toHaveTextContent("750 stroops"); + fireEvent.click(button); + expect(button).toHaveAttribute("aria-pressed", "false"); + expect(screen.queryByTestId("multiplied-fee")).not.toBeInTheDocument(); + expect(onMultiplierChange).toHaveBeenLastCalledWith(null, null); + }); + + it("applyFeeMultiplier rounds up without floating-point artifacts", () => { + // 500 * 1.2 is 600.0000000000001 as a float — must not become 601. + expect(applyFeeMultiplier("500", 1.2)).toBe("600"); + expect(applyFeeMultiplier("101", 1.5)).toBe("152"); + expect(applyFeeMultiplier("100", 2)).toBe("200"); + expect(applyFeeMultiplier("abc", 2)).toBe("abc"); + }); + }); +}); diff --git a/src/components/FeeEstimator.tsx b/src/components/FeeEstimator.tsx index 4426c49e..3b5a5a68 100644 --- a/src/components/FeeEstimator.tsx +++ b/src/components/FeeEstimator.tsx @@ -11,6 +11,29 @@ import { cn, toXLM } from "@/lib/utils"; export const MIN_NETWORK_BASE_FEE = 100; +/** + * Base fee (stroops) above which the network is treated as surge pricing: + * more than 3x the 100-stroop minimum means ledgers are full and + * transactions are bidding against each other for inclusion. + */ +export const SURGE_FEE_THRESHOLD = MIN_NETWORK_BASE_FEE * 3; + +/** Speed multipliers a user can apply on top of the recommended fee. */ +export const FEE_MULTIPLIERS = [1.2, 1.5, 2] as const; +export type FeeMultiplier = (typeof FEE_MULTIPLIERS)[number]; + +/** + * Recommended fee scaled by `multiplier`, rounded up to a whole stroop. + * Computed in tenths to avoid float artifacts (500 * 1.2 is + * 600.0000000000001 in floating point, which would round up to 601). + */ +export function applyFeeMultiplier(fee: string, multiplier: number): string { + const stroops = parseInt(fee, 10); + if (Number.isNaN(stroops)) return fee; + const tenths = Math.round(multiplier * 10); + return String(Math.ceil((stroops * tenths) / 10)); +} + export interface FeeData { baseFee: string; recommended: string; @@ -28,6 +51,11 @@ interface FeeEstimatorProps { customFee?: string; /** Callback fired when custom fee input changes. */ onCustomFeeChange?: (customFee: string) => void; + /** + * Callback fired when the user toggles a speed multiplier. Receives the + * multiplier (null when cleared) and the resulting fee in stroops. + */ + onMultiplierChange?: (multiplier: FeeMultiplier | null, fee: string | null) => void; } export function FeeEstimator({ @@ -37,6 +65,7 @@ export function FeeEstimator({ onFeeLoad, customFee: customFeeProp, onCustomFeeChange, + onMultiplierChange, }: FeeEstimatorProps) { const { registerWatcher } = useSorokit(); const [containerRef, isVisible] = useIsVisible(); @@ -120,6 +149,22 @@ export function FeeEstimator({ }; }, [registerWatcher]); + const [multiplier, setMultiplier] = useState(null); + const multipliedFee = + fee && multiplier !== null ? applyFeeMultiplier(fee.recommended, multiplier) : null; + + const handleMultiplierToggle = (value: FeeMultiplier) => { + const next = multiplier === value ? null : value; + setMultiplier(next); + onMultiplierChange?.( + next, + fee && next !== null ? applyFeeMultiplier(fee.recommended, next) : null, + ); + }; + + const baseFeeStroops = fee ? parseInt(fee.baseFee, 10) : NaN; + const isSurge = !Number.isNaN(baseFeeStroops) && baseFeeStroops > SURGE_FEE_THRESHOLD; + const compactContent = fee ? `Base: ${fee.baseFee} stroops · Recommended: ${fee.recommended} stroops` : null; @@ -152,7 +197,14 @@ export function FeeEstimator({ ) : fee ? ( - {compactContent} + + {compactContent} + {isSurge && ( + + Surge pricing + + )} + ) : null} ) : ( @@ -206,6 +258,7 @@ export function FeeEstimator({ ) : fee ? ( <> + {isSurge && }
+
+
+ + Speed Multiplier + +
+ {FEE_MULTIPLIERS.map((m) => ( + + ))} +
+
+ {multipliedFee !== null && ( +

+ Priority fee: {multipliedFee}{" "} + stroops (≈ {toXLM(multipliedFee)} XLM) for faster inclusion +

+ )} +
+ ); +} + export function FeeCell({ label, value, diff --git a/src/components/GasOptimizer.test.tsx b/src/components/GasOptimizer.test.tsx index b9049a65..0a9d2dca 100644 --- a/src/components/GasOptimizer.test.tsx +++ b/src/components/GasOptimizer.test.tsx @@ -1,13 +1,17 @@ import { act,fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parseContractPanic } from "@/lib/diagnostics"; + import { + computeResourceCosts, GAS_PRESETS, GasOptimizer, MAX_CPU_INSTRUCTIONS, MAX_MEMORY_BYTES, MIN_CPU_INSTRUCTIONS, MIN_MEMORY_BYTES, + RESOURCE_FEE_RATES, } from "./GasOptimizer"; vi.mock("@/lib/client", () => ({ @@ -771,4 +775,224 @@ describe("GasOptimizer", () => { vi.unstubAllGlobals(); }); }); -}); \ No newline at end of file +}); +// ── Issue #959: resource breakdown, panic diagnostics, zero-limit cases ────── +function mockFeeResult(result: { data: GasEstimate | null; error: string | null }) { + return mockClient({ + transaction: { + submit: vi.fn(), + getStatus: vi.fn(), + getHistory: vi.fn(), + estimateFee: vi.fn(), + estimateDetailedFee: vi.fn().mockResolvedValue(result), + getFeeScenarios: vi.fn().mockResolvedValue({ + data: result.data?.scenarios ?? [], + error: null, + }), + } as unknown as SorokitClient["transaction"], + }); +} + +async function openResourceBreakdown() { + const toggle = await screen.findByRole("button", { name: /resource breakdown/i }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); +} + +describe("GasOptimizer — issue #959", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Resource Breakdown", () => { + it("is collapsed by default and expands to show each resource", async () => { + mockFeeResult({ + data: { + ...MOCK_GAS_ESTIMATE, + resources: { + cpuInstructions: 2_000_000, + memoryBytes: 3_145_728, + readBytes: 2048, + writeBytes: 1024, + }, + }, + error: null, + }); + render(); + + await screen.findByRole("button", { name: /resource breakdown/i }); + expect(screen.queryByTestId("resource-cpu")).not.toBeInTheDocument(); + await openResourceBreakdown(); + + expect(screen.getByText("Resources consumed in simulation.")).toBeInTheDocument(); + // 2,000,000 instructions * 25 / 10,000 = 5,000 stroops. + expect(screen.getByTestId("resource-cpu")).toHaveTextContent("2,000,000 instructions"); + expect(screen.getByTestId("resource-cpu")).toHaveTextContent("5,000"); + expect(screen.getByTestId("resource-memory")).toHaveTextContent("3,145,728 bytes"); + // 2 KB read * 1,786 = 3,572 stroops; 1 KB written * 11,800 = 11,800. + expect(screen.getByTestId("resource-read")).toHaveTextContent("2,048 bytes"); + expect(screen.getByTestId("resource-read")).toHaveTextContent("3,572"); + expect(screen.getByTestId("resource-write")).toHaveTextContent("1,024 bytes"); + expect(screen.getByTestId("resource-write")).toHaveTextContent("11,800"); + expect(screen.getByTestId("resource-total")).toHaveTextContent("20,372"); + }); + + it("falls back to the configured limits when the estimate has no simulation data", async () => { + mockClient(); + render(); + await openResourceBreakdown(); + + expect(screen.getByText(/configured execution limits/)).toBeInTheDocument(); + expect(screen.getByTestId("resource-cpu")).toHaveTextContent( + `${GAS_PRESETS.Conservative.cpuInstructions.toLocaleString()} instructions`, + ); + expect(screen.getByTestId("resource-read")).toHaveTextContent("0 bytes"); + }); + + it("renders zeros (not NaN) when resource limits are empty", async () => { + mockFeeResult({ + data: { ...MOCK_GAS_ESTIMATE, resources: {} }, + error: null, + }); + render(); + await openResourceBreakdown(); + + for (const key of ["cpu", "memory", "read", "write"]) { + expect(screen.getByTestId(`resource-${key}`)).not.toHaveTextContent("NaN"); + } + expect(screen.getByTestId("resource-cpu")).toHaveTextContent("0 instructions"); + expect(screen.getByTestId("resource-total")).toHaveTextContent("0"); + }); + + it("computeResourceCosts handles missing, zero, negative and non-finite limits", () => { + for (const resources of [ + undefined, + {}, + { cpuInstructions: 0, memoryBytes: 0, readBytes: 0, writeBytes: 0 }, + { cpuInstructions: -5, readBytes: Number.NaN, writeBytes: Number.POSITIVE_INFINITY }, + ]) { + const costs = computeResourceCosts(resources); + expect(costs.map((c) => c.key)).toEqual(["cpu", "memory", "read", "write"]); + expect(costs.every((c) => c.amount === 0 && c.feeStroops === 0)).toBe(true); + } + }); + + it("computeResourceCosts rounds each fee up to a whole stroop", () => { + const costs = computeResourceCosts({ cpuInstructions: 1, readBytes: 1, writeBytes: 1 }); + expect(costs.find((c) => c.key === "cpu")?.feeStroops).toBe(1); + expect(costs.find((c) => c.key === "read")?.feeStroops).toBe( + Math.ceil(RESOURCE_FEE_RATES.feeRead1Kb / 1024), + ); + expect(costs.find((c) => c.key === "write")?.feeStroops).toBe( + Math.ceil(RESOURCE_FEE_RATES.feeWrite1Kb / 1024), + ); + }); + }); + + describe("zero-fee transactions", () => { + it("shows a 0 stroop total without NaN when every operation is free", async () => { + mockFeeResult({ + data: { + totalGasUnits: 0, + breakdown: [ + { operationType: "payment", gasUnits: 0, feeStroops: "0", feeXlm: "0.0000000" }, + ], + scenarios: MOCK_GAS_ESTIMATE.scenarios, + customMultiplier: 1, + }, + error: null, + }); + const { container } = render(); + + expect(await screen.findByText("Estimated Total Fee")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("NaN"); + expect(screen.getAllByText("0.0000000").length).toBeGreaterThan(0); + }); + + it("treats empty fee strings as zero instead of producing NaN", async () => { + mockFeeResult({ + data: { + totalGasUnits: 0, + breakdown: [{ operationType: "payment", gasUnits: 0, feeStroops: "", feeXlm: "" }], + scenarios: MOCK_GAS_ESTIMATE.scenarios, + customMultiplier: 1.5, + }, + error: null, + }); + const { container } = render(); + + await screen.findByText("Estimated Total Fee"); + expect(container).not.toHaveTextContent("NaN"); + }); + }); + + describe("simulation panic diagnostics", () => { + const PANIC_ERROR = [ + "HostError: Error(WasmVm, InvalidAction)", + "", + "Event log (newest first):", + " 0: [Diagnostic Event] contract:CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE, topics:[error, Error(WasmVm, InvalidAction)], data:[\"VM call trapped: UnreachableCodeReached\", transfer]", + " 1: [Diagnostic Event] contract:CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE, topics:[log], data:\"panicked at 'attempt to subtract with overflow', src/lib.rs:42:9\"", + ].join("\n"); + + it("explains a contract panic in plain English instead of raw output", async () => { + mockFeeResult({ data: null, error: PANIC_ERROR }); + render(); + + const panel = await screen.findByTestId("contract-panic"); + expect(panel).toHaveAttribute("role", "alert"); + expect(panel).toHaveTextContent("Contract simulation failed"); + expect(panel).toHaveTextContent("The contract panicked (trapped) while executing."); + expect(panel).toHaveTextContent('Reason: "attempt to subtract with overflow"'); + // Raw diagnostics are kept, but collapsed inside a
element. + const details = panel.querySelector("details"); + expect(details).not.toBeNull(); + expect(details?.open).toBe(false); + }); + + it("describes a custom contract error code", async () => { + mockFeeResult({ + data: null, + error: "HostError: Error(Contract, #3)\nEvent log: [Diagnostic Event] topics:[error, Error(Contract, #3)]", + }); + render(); + + expect(await screen.findByTestId("contract-panic")).toHaveTextContent( + "The contract rejected the call with its own error code 3.", + ); + }); + + it("extracts the panic message from base64 XDR diagnostic events", () => { + // Printable SCVal string bytes inside an XDR blob, framed by binary. + const xdr = btoa( + "\u0000\u0000\u0000\u000e\u0000\u0000\u0000\u0005error" + + "\u0000\u0000\u0000\u000e\u0000\u0000\u0000\u001dbalance is not sufficient\u0000\u0000\u0000", + ); + const panic = parseContractPanic(`simulation failed: ${xdr}`); + expect(panic?.panicMessage).toBe("balance is not sufficient"); + expect(panic?.summary).toContain("balance is not sufficient"); + }); + + it("maps known host errors without a panic message", () => { + expect(parseContractPanic("HostError: Error(Budget, ExceededLimit)")?.summary).toBe( + "The contract ran out of its CPU or memory budget.", + ); + expect(parseContractPanic("Error(Auth, InvalidAction)")?.summary).toMatch(/authorization/); + expect(parseContractPanic("Error(Storage, Unknown)")?.summary).toBe( + "The contract failed with a Storage error (Unknown).", + ); + }); + + it("leaves non-contract errors untouched", async () => { + expect(parseContractPanic("RPC error")).toBeNull(); + expect(parseContractPanic("")).toBeNull(); + expect(parseContractPanic(null)).toBeNull(); + + mockFeeResult({ data: null, error: "Network request failed" }); + render(); + expect(await screen.findByText("Network request failed")).toBeInTheDocument(); + expect(screen.queryByTestId("contract-panic")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/GasOptimizer.tsx b/src/components/GasOptimizer.tsx index 4d04dbc6..58259aaf 100644 --- a/src/components/GasOptimizer.tsx +++ b/src/components/GasOptimizer.tsx @@ -13,7 +13,9 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useSorokit } from "@/context/useSorokit"; -import { cn } from "@/lib/utils"; +import type { SorobanResourceUsage } from "@/lib/client"; +import { parseContractPanic } from "@/lib/diagnostics"; +import { cn, toXLM } from "@/lib/utils"; // Soroban Protocol Execution Limits export const MIN_CPU_INSTRUCTIONS = 40_000; @@ -37,6 +39,75 @@ export const SOROBAN_PROTOCOL_LIMITS = { }, } as const; +/** + * Soroban resource fee rates, in stroops. Defaults mirror the Mainnet + * network configuration (`ConfigSettingContractComputeV0` / + * `ConfigSettingContractLedgerCostV0`); validators can vote to change them, + * so they are an estimate, not a quote. Memory has no separate fee — it is + * bounded by the limit but billed through CPU instructions. + */ +export const RESOURCE_FEE_RATES = { + /** Stroops per 10,000 CPU instructions. */ + feePerInstructionIncrement: 25, + /** Stroops per 1 KB of ledger entries read. */ + feeRead1Kb: 1_786, + /** Stroops per 1 KB of ledger entries written. */ + feeWrite1Kb: 11_800, +} as const; + +export interface ResourceCost { + key: "cpu" | "memory" | "read" | "write"; + label: string; + amount: number; + unit: string; + feeStroops: number; +} + +/** Sanitize a resource count: missing, negative or non-finite becomes 0. */ +function resourceAmount(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0; +} + +/** + * Per-resource fee breakdown. Each fee rounds up like the network does, and + * zero (or missing) resource limits produce zero rows rather than NaN. + */ +export function computeResourceCosts( + resources: SorobanResourceUsage | undefined, + rates: typeof RESOURCE_FEE_RATES = RESOURCE_FEE_RATES, +): ResourceCost[] { + const cpu = resourceAmount(resources?.cpuInstructions); + const memory = resourceAmount(resources?.memoryBytes); + const read = resourceAmount(resources?.readBytes); + const write = resourceAmount(resources?.writeBytes); + return [ + { + key: "cpu", + label: "CPU Instructions", + amount: cpu, + unit: "instructions", + feeStroops: Math.ceil((cpu * rates.feePerInstructionIncrement) / 10_000), + }, + { key: "memory", label: "Memory", amount: memory, unit: "bytes", feeStroops: 0 }, + { + key: "read", + label: "Ledger Read", + amount: read, + unit: "bytes", + feeStroops: Math.ceil((read * rates.feeRead1Kb) / 1024), + }, + { + key: "write", + label: "Ledger Write", + amount: write, + unit: "bytes", + feeStroops: Math.ceil((write * rates.feeWrite1Kb) / 1024), + }, + ]; +} + export type GasPresetName = "Conservative" | "Aggressive" | "Custom"; export interface GasPresetConfig { @@ -122,6 +193,7 @@ interface GasEstimate { breakdown: OperationGasBreakdown[]; scenarios: FeeScenario[]; customMultiplier: number; + resources?: SorobanResourceUsage; } function formatStroops(stroops: string): string { @@ -498,7 +570,7 @@ export function GasOptimizer({
) : error || scenarioError ? ( -

{error || scenarioError}

+ ) : gasPriceData && estimate ? (
@@ -531,6 +603,17 @@ export function GasOptimizer({ onChange={handleMultiplierChange} />
+ {message}

; + } + return ( +
+ Contract simulation failed +

{panic.summary}

+
+ Raw diagnostics +
+          {message}
+        
+
+
+ ); +} + +/** + * Collapsible per-resource cost breakdown (#959). Uses the simulated usage + * when the estimate includes it, otherwise the configured limits — the worst + * case the transaction is allowed to consume. + */ +function ResourceBreakdown({ + resources, + simulated, +}: { + resources: SorobanResourceUsage; + simulated: boolean; +}) { + const [open, setOpen] = useState(false); + const costs = computeResourceCosts(resources); + const totalStroops = costs.reduce((sum, c) => sum + c.feeStroops, 0); + + return ( +
+ + {open && ( +
+

+ {simulated + ? "Resources consumed in simulation." + : "Based on the configured execution limits (no simulation data)."} +

+ + + + + + + + + + {costs.map((cost) => ( + + + + + + ))} + + + + + + +
+ Resource + + Usage + + Fee (stroops) +
{cost.label} + {cost.amount.toLocaleString()} {cost.unit} + + {cost.key === "memory" ? "—" : cost.feeStroops.toLocaleString()} +
Resource fee + {toXLM(String(totalStroops))} XLM + + {totalStroops.toLocaleString()} +
+
+ )} +
+ ); +} + function PresetSelector({ preset, onSelect, @@ -848,10 +1044,10 @@ function OperationBreakdown({ const totalGasUnits = breakdown.reduce((s, b) => s + b.gasUnits, 0); const totalFeeStroops = breakdown.reduce( - (s, b) => s + parseInt(b.feeStroops, 10), + (s, b) => s + (parseInt(b.feeStroops, 10) || 0), 0, ); - const totalFeeXlm = breakdown.reduce((s, b) => s + parseFloat(b.feeXlm), 0); + const totalFeeXlm = breakdown.reduce((s, b) => s + (parseFloat(b.feeXlm) || 0), 0); return (
@@ -996,7 +1192,7 @@ function OptimizerSuggestions({ if (estimate.customMultiplier > 1) { const totalStroops = estimate.breakdown.reduce( - (s, b) => s + parseInt(b.feeStroops, 10), + (s, b) => s + (parseInt(b.feeStroops, 10) || 0), 0, ); const savingsStroops = String( @@ -1030,7 +1226,7 @@ function OptimizerSuggestions({ } const totalStroops = estimate.breakdown.reduce( - (s, b) => s + parseInt(b.feeStroops, 10), + (s, b) => s + (parseInt(b.feeStroops, 10) || 0), 0, ); if (totalStroops > 100000) { @@ -1085,7 +1281,7 @@ function TotalFee({ multiplier: number; }) { const totalStroops = estimate.breakdown.reduce( - (s, b) => s + parseInt(b.feeStroops, 10), + (s, b) => s + (parseInt(b.feeStroops, 10) || 0), 0, ); const adjustedStroops = Math.round(totalStroops * multiplier); diff --git a/src/components/NFTGallery.test.tsx b/src/components/NFTGallery.test.tsx index 4b31a54d..75f22520 100644 --- a/src/components/NFTGallery.test.tsx +++ b/src/components/NFTGallery.test.tsx @@ -229,6 +229,10 @@ describe("NFTCard", () => { img = screen.getByAltText("Cool Cat #1"); expect(img).toHaveAttribute("src", "https://cloudflare-ipfs.com/ipfs/bafytestcid/cat.png"); + fireEvent.error(img); + img = screen.getByAltText("Cool Cat #1"); + expect(img).toHaveAttribute("src", "https://gateway.pinata.cloud/ipfs/bafytestcid/cat.png"); + fireEvent.error(img); img = screen.getByAltText("Cool Cat #1"); expect(img).toHaveAttribute("src", "https://dweb.link/ipfs/bafytestcid/cat.png"); @@ -237,6 +241,74 @@ describe("NFTCard", () => { expect(screen.getByText("No image")).toBeInTheDocument(); }); + it("moves to the next IPFS gateway when an image stalls for 8 seconds (#957)", () => { + vi.useFakeTimers(); + try { + render( + + ); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute( + "src", + "https://ipfs.io/ipfs/bafytestcid/cat.png", + ); + + act(() => { + vi.advanceTimersByTime(7_999); + }); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute( + "src", + "https://ipfs.io/ipfs/bafytestcid/cat.png", + ); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute( + "src", + "https://cloudflare-ipfs.com/ipfs/bafytestcid/cat.png", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps an image that finished loading instead of timing it out (#957)", () => { + vi.useFakeTimers(); + try { + render( + + ); + fireEvent.load(screen.getByAltText("Cool Cat #1")); + act(() => { + vi.advanceTimersByTime(30_000); + }); + expect(screen.getByAltText("Cool Cat #1")).toHaveAttribute( + "src", + "https://ipfs.io/ipfs/bafytestcid/cat.png", + ); + } finally { + vi.useRealTimers(); + } + }); + it("invokes onSend when Send button clicked", () => { const nft = makeNft(); render( diff --git a/src/components/NFTGallery.tsx b/src/components/NFTGallery.tsx index d5c05b6d..da206441 100644 --- a/src/components/NFTGallery.tsx +++ b/src/components/NFTGallery.tsx @@ -21,8 +21,14 @@ import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { Skeleton } from "@/components/ui/Skeleton"; import { useSorokit } from "@/context/useSorokit"; +import { useIsVisible } from "@/hooks/useIsVisible"; import type { Nft, NftCollection } from "@/lib/client"; -import { IPFS_GATEWAY_COUNT, ipfsContentPath, resolveIpfsUrl } from "@/lib/ipfs"; +import { + IPFS_GATEWAY_COUNT, + IPFS_REQUEST_TIMEOUT_MS, + ipfsContentPath, + resolveIpfsUrl, +} from "@/lib/ipfs"; import { cn, validateStellarAddress } from "@/lib/utils"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -88,20 +94,39 @@ function NftImage({ setFailed(false); } + const [loadedSrc, setLoadedSrc] = useState(null); + // `loading="lazy"` defers off-screen images, so the stall timer only runs + // while the image is actually in view (and therefore actually loading). + const [imgRef, inView] = useIsVisible(); + const src = isIpfs ? resolveIpfsUrl(source, attempt) : source; + + const handleFailure = useCallback(() => { + if (isIpfs && attempt + 1 < IPFS_GATEWAY_COUNT) { + setAttempt((current) => current + 1); + return; + } + setFailed(true); + }, [isIpfs, attempt]); + + // An unresponsive gateway can hold a request open indefinitely without ever + // firing `error`. Give each gateway IPFS_REQUEST_TIMEOUT_MS; swapping `src` + // to the next gateway cancels the stalled request in the browser. + useEffect(() => { + if (!source || failed || !inView || loadedSrc === src) return; + const timer = window.setTimeout(handleFailure, IPFS_REQUEST_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [source, failed, inView, loadedSrc, src, handleFailure]); + if (!source || failed) return <>{fallback}; return ( {alt} { - if (isIpfs && attempt + 1 < IPFS_GATEWAY_COUNT) { - setAttempt((current) => current + 1); - return; - } - setFailed(true); - }} + onLoad={() => setLoadedSrc(src)} + onError={handleFailure} className={className} /> ); diff --git a/src/hooks/useFeeData.ts b/src/hooks/useFeeData.ts index c75dcab9..170ce365 100644 --- a/src/hooks/useFeeData.ts +++ b/src/hooks/useFeeData.ts @@ -7,6 +7,11 @@ let globalFeeData: FeeData | null = null; let globalLoading = false; let globalError: string | null = null; const subscribers = new Set<() => void>(); +// Network the cached fee data belongs to, and a generation counter bumped on +// every network switch so a request still in flight for the old network can +// never write its (stale) result into the new network's cache. +let globalNetworkKey: string | null = null; +let globalGeneration = 0; function notify() { subscribers.forEach((cb) => cb()); @@ -15,16 +20,24 @@ function notify() { let activeInterval: number | null = null; let pollingCount = 0; +function networkKey(network: { name?: string; passphrase?: string } | null | undefined): string | null { + if (!network) return null; + return `${network.name ?? ""}|${network.passphrase ?? ""}`; +} + export function useFeeData(refreshInterval = 0, isVisible: boolean = true) { - const { client } = useSorokit(); + const { client, network } = useSorokit(); const [, forceRender] = useState({}); + const currentNetworkKey = networkKey(network); const load = useCallback(async () => { if (!client || globalLoading) return; + const generation = globalGeneration; globalLoading = true; notify(); try { const { data, error } = await client.transaction.estimateFee(); + if (generation !== globalGeneration) return; if (error) { globalError = error; } else if (data) { @@ -35,13 +48,34 @@ export function useFeeData(refreshInterval = 0, isVisible: boolean = true) { globalError = null; } } catch (e) { + if (generation !== globalGeneration) return; globalError = e instanceof Error ? e.message : "Request timed out"; } finally { - globalLoading = false; - notify(); + if (generation === globalGeneration) { + globalLoading = false; + notify(); + } } }, [client]); + // Issue #958: on a network switch, drop the previous network's fees at once + // (instead of showing them until the next polling tick) and fetch fresh + // ones. Several mounted estimators share this cache; the first to see the + // new network key performs the reset, the rest find it already current. + useEffect(() => { + if (currentNetworkKey === globalNetworkKey) return; + const isSwitch = globalNetworkKey !== null && currentNetworkKey !== null; + globalNetworkKey = currentNetworkKey; + if (!isSwitch) return; + + globalGeneration++; + globalFeeData = null; + globalError = null; + globalLoading = false; + notify(); + void load(); + }, [currentNetworkKey, load]); + useEffect(() => { const cb = () => forceRender({}); subscribers.add(cb); @@ -51,6 +85,8 @@ export function useFeeData(refreshInterval = 0, isVisible: boolean = true) { globalFeeData = null; globalLoading = false; globalError = null; + globalNetworkKey = null; + globalGeneration++; } }; }, []); diff --git a/src/lib/client.ts b/src/lib/client.ts index 56127412..16722ad8 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -53,11 +53,21 @@ export type FeeScenario = { savings: string; }; +/** Soroban resources a simulated transaction consumed (from simulateTransaction). */ +export type SorobanResourceUsage = { + cpuInstructions?: number; + memoryBytes?: number; + readBytes?: number; + writeBytes?: number; +}; + export type GasEstimate = { totalGasUnits: number; breakdown: OperationGasBreakdown[]; scenarios: FeeScenario[]; customMultiplier: number; + /** Simulated resource usage; absent for classic (non-Soroban) operations. */ + resources?: SorobanResourceUsage; }; export type SorokitClient = { diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts new file mode 100644 index 00000000..06d024cd --- /dev/null +++ b/src/lib/diagnostics.ts @@ -0,0 +1,128 @@ +/** + * Turn Soroban simulation failures into plain-English explanations. + * + * A failed simulation comes back as a host error string (e.g. + * `HostError: Error(WasmVm, InvalidAction)`) followed by diagnostic events, + * which may be pretty-printed by the RPC client or left as raw base64 XDR. + * Showing either verbatim is useless to most users, so this pulls out the + * contract's panic message and the host error code and describes them. + */ + +export interface ContractPanic { + /** Plain-English summary suitable for display. */ + summary: string; + /** The panic message the contract emitted, when one was found. */ + panicMessage: string | null; + /** Host error type/code, e.g. `{ type: "Contract", code: "#3" }`. */ + hostError: { type: string; code: string } | null; +} + +/** Explanations for the host error codes users actually hit. */ +const HOST_ERROR_DESCRIPTIONS: Record = { + "WasmVm:InvalidAction": "The contract panicked (trapped) while executing.", + "WasmVm:InvalidInput": "The contract was called with arguments it could not decode.", + "Budget:ExceededLimit": "The contract ran out of its CPU or memory budget.", + "Storage:MissingValue": "The contract tried to read a ledger entry that does not exist.", + "Storage:ExceededLimit": "The contract exceeded the ledger read/write footprint.", + "Auth:InvalidAction": "A required authorization signature was missing or invalid.", + "Value:InvalidInput": "The contract received a value of the wrong type.", + "Object:IndexBounds": "The contract accessed a vector or map index that is out of bounds.", + "Object:UnexpectedSize": "The contract received data of an unexpected size.", + "Context:InvalidAction": "The contract made a call that is not allowed in this context.", + "Crypto:InvalidInput": "A signature or hash passed to the contract was malformed.", +}; + +const HOST_ERROR_PATTERN = /Error\(\s*([A-Za-z]+)\s*,\s*(#?\d+|[A-Za-z]+)\s*\)/; + +/** Panic message from Rust's `panic!` / `unwrap` / `expect` in contract code. */ +const PANIC_PATTERNS = [ + /panicked at '([^']+)'/, + /panicked at [^\n:]+(?::\d+){0,2}:\s*\n?\s*([^\n"\]]+)/, + /"(?:caller|contract) panicked[^"]*:\s*([^"]+)"/i, + /\bpanic(?:ked)?:\s*"?([^"\n\]]+)"?/i, +]; + +/** Strings worth inspecting: the text itself plus any decoded base64 blobs. */ +function candidateTexts(raw: string): string[] { + const texts = [raw]; + // Base64-encoded XDR diagnostic events. SCVal strings and symbols are stored + // as length-prefixed UTF-8, so the printable runs inside the decoded bytes + // include the panic message without needing a full XDR decoder. + for (const blob of raw.match(/[A-Za-z0-9+/]{24,}={0,2}/g) ?? []) { + const printable = decodeBase64Printable(blob); + if (printable) texts.push(printable); + } + return texts; +} + +function decodeBase64Printable(blob: string): string | null { + let binary: string; + try { + binary = atob(blob); + } catch { + return null; + } + const runs = binary.match(/[\x20-\x7e]{4,}/g); + return runs ? runs.join("\n") : null; +} + +function findPanicMessage(texts: string[]): string | null { + for (const text of texts) { + for (const pattern of PANIC_PATTERNS) { + const match = pattern.exec(text); + if (match?.[1]?.trim()) return match[1].trim(); + } + } + // Decoded XDR with a "panic"/"error" topic but no `panicked at` wrapper: + // fall back to the longest readable sentence after it. + for (const text of texts.slice(1)) { + const lines = text.split("\n"); + const topic = lines.findIndex((line) => /^(panic|error)$/i.test(line.trim())); + if (topic !== -1) { + const message = lines + .slice(topic + 1) + .find((line) => /[a-z]{2,}\s+[a-z]{2,}/i.test(line)); + if (message) return message.trim(); + } + } + return null; +} + +/** + * Extract a contract panic from a simulation error. Returns `null` when the + * text doesn't look like a contract/host failure at all (e.g. a plain + * network error), so callers can show it as-is. + */ +export function parseContractPanic(raw: string | null | undefined): ContractPanic | null { + if (!raw) return null; + + const texts = candidateTexts(raw); + const panicMessage = findPanicMessage(texts); + + let hostError: ContractPanic["hostError"] = null; + for (const text of texts) { + const match = HOST_ERROR_PATTERN.exec(text); + if (match) { + hostError = { type: match[1], code: match[2] }; + break; + } + } + + if (!panicMessage && !hostError) return null; + + let summary: string; + if (hostError?.type === "Contract") { + const code = hostError.code.replace(/^#/, ""); + summary = `The contract rejected the call with its own error code ${code}.`; + } else if (hostError) { + summary = + HOST_ERROR_DESCRIPTIONS[`${hostError.type}:${hostError.code}`] ?? + `The contract failed with a ${hostError.type} error (${hostError.code}).`; + } else { + summary = HOST_ERROR_DESCRIPTIONS["WasmVm:InvalidAction"]; + } + + if (panicMessage) summary = `${summary} Reason: "${panicMessage}".`; + + return { summary, panicMessage, hostError }; +} diff --git a/src/lib/ipfs.test.ts b/src/lib/ipfs.test.ts new file mode 100644 index 00000000..af0e7e18 --- /dev/null +++ b/src/lib/ipfs.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + fetchIpfsResource, + IPFS_GATEWAYS, + IPFS_REQUEST_TIMEOUT_MS, + ipfsContentPath, + isValidCid, + parseIpfsUri, + resolveIpfsUrl, + resolveIpfsUrls, +} from "./ipfs"; + +const CID_V0 = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"; +const CID_V1 = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; +const CID_V1_RAW = "bafkreidgvpkjawlxz6sffxzwgooowe5yt7i6wsyg236mfoks77nywkptdq"; + +describe("isValidCid", () => { + it("accepts CIDv0 and base32 CIDv1 strings", () => { + expect(isValidCid(CID_V0)).toBe(true); + expect(isValidCid(CID_V1)).toBe(true); + expect(isValidCid(CID_V1_RAW)).toBe(true); + }); + + it("rejects malformed CIDs", () => { + expect(isValidCid("")).toBe(false); + expect(isValidCid("Qm123")).toBe(false); + // '0' and 'l' are not in base58btc. + expect(isValidCid(`Qm0${CID_V0.slice(3)}`)).toBe(false); + // '1' and '8' are not in the base32 alphabet. + expect(isValidCid(`bafy18${CID_V1.slice(6)}`)).toBe(false); + expect(isValidCid("not-a-cid")).toBe(false); + }); +}); + +describe("parseIpfsUri", () => { + it("parses a CIDv1 base32 URI with a nested sub-path", () => { + expect(parseIpfsUri(`ipfs://${CID_V1}/metadata.json`)).toEqual({ + cid: CID_V1, + path: "metadata.json", + }); + expect(parseIpfsUri(`ipfs://${CID_V1}/assets/images/1.png`)).toEqual({ + cid: CID_V1, + path: "assets/images/1.png", + }); + }); + + it("parses CIDv0 URIs with and without a path", () => { + expect(parseIpfsUri(`ipfs://${CID_V0}`)).toEqual({ cid: CID_V0, path: "" }); + expect(parseIpfsUri(`ipfs://${CID_V0}/0.json`)).toEqual({ cid: CID_V0, path: "0.json" }); + }); + + it("handles the legacy ipfs://ipfs/ prefix", () => { + expect(parseIpfsUri(`ipfs://ipfs/${CID_V1}/metadata.json`)).toEqual({ + cid: CID_V1, + path: "metadata.json", + }); + }); + + it("parses existing gateway URLs so they can be re-routed", () => { + expect(parseIpfsUri(`https://gateway.example.com/ipfs/${CID_V1}/a/b.json`)).toEqual({ + cid: CID_V1, + path: "a/b.json", + }); + }); + + it("lowercases an upper-case CIDv1 and strips query strings and trailing slashes", () => { + expect(parseIpfsUri(`ipfs://${CID_V1.toUpperCase()}/meta.json?x=1#top`)).toEqual({ + cid: CID_V1, + path: "meta.json", + }); + expect(parseIpfsUri(`ipfs://${CID_V1}/dir/`)).toEqual({ cid: CID_V1, path: "dir" }); + }); + + it("returns null for non-IPFS URLs and invalid CIDs", () => { + expect(parseIpfsUri("https://example.com/cat.png")).toBeNull(); + expect(parseIpfsUri("ipfs://not-a-cid/meta.json")).toBeNull(); + expect(parseIpfsUri("ipfs://")).toBeNull(); + }); +}); + +describe("resolveIpfsUrl", () => { + it("resolves a CIDv1 base32 URI with a sub-path to a valid gateway endpoint", () => { + expect(resolveIpfsUrl(`ipfs://${CID_V1}/metadata.json`)).toBe( + `https://ipfs.io/ipfs/${CID_V1}/metadata.json`, + ); + }); + + it("walks the gateway list by index and clamps out-of-range indexes", () => { + const uri = `ipfs://${CID_V0}/0.json`; + IPFS_GATEWAYS.forEach((gateway, i) => { + expect(resolveIpfsUrl(uri, i)).toBe(`${gateway}${CID_V0}/0.json`); + }); + expect(resolveIpfsUrl(uri, 99)).toBe(`${IPFS_GATEWAYS.at(-1)}${CID_V0}/0.json`); + expect(resolveIpfsUrl(uri, -1)).toBe(`${IPFS_GATEWAYS[0]}${CID_V0}/0.json`); + }); + + it("leaves non-IPFS URLs untouched", () => { + expect(resolveIpfsUrl("https://example.com/cat.png")).toBe("https://example.com/cat.png"); + }); + + it("includes Cloudflare, ipfs.io and Pinata in the fallback list", () => { + const urls = resolveIpfsUrls(`ipfs://${CID_V1}`); + expect(urls).toHaveLength(IPFS_GATEWAYS.length); + expect(urls.some((u) => u.startsWith("https://cloudflare-ipfs.com/ipfs/"))).toBe(true); + expect(urls.some((u) => u.startsWith("https://ipfs.io/ipfs/"))).toBe(true); + expect(urls.some((u) => u.startsWith("https://gateway.pinata.cloud/ipfs/"))).toBe(true); + }); + + it("passes through a non-CID ipfs:// path rather than failing", () => { + expect(ipfsContentPath("ipfs://bafytestcid/cat.png")).toBe("bafytestcid/cat.png"); + }); +}); + +describe("fetchIpfsResource", () => { + const uri = `ipfs://${CID_V1}/metadata.json`; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the first successful gateway response", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("ok", { status: 200 })); + const res = await fetchIpfsResource(uri, { fetchImpl }); + expect(await res.text()).toBe("ok"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0][0]).toBe(`${IPFS_GATEWAYS[0]}${CID_V1}/metadata.json`); + }); + + it("fails over to the next gateway on HTTP 429 and 504", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response("", { status: 429 })) + .mockResolvedValueOnce(new Response("", { status: 504 })) + .mockResolvedValueOnce(new Response("third", { status: 200 })); + + const res = await fetchIpfsResource(uri, { fetchImpl }); + expect(await res.text()).toBe("third"); + expect(fetchImpl.mock.calls.map((c) => c[0])).toEqual([ + `${IPFS_GATEWAYS[0]}${CID_V1}/metadata.json`, + `${IPFS_GATEWAYS[1]}${CID_V1}/metadata.json`, + `${IPFS_GATEWAYS[2]}${CID_V1}/metadata.json`, + ]); + }); + + it("fails over when a gateway throws a network error", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(new Response("second", { status: 200 })); + + const res = await fetchIpfsResource(uri, { fetchImpl }); + expect(await res.text()).toBe("second"); + }); + + it("aborts a stalled gateway after 8 seconds and moves to the next one", async () => { + expect(IPFS_REQUEST_TIMEOUT_MS).toBe(8_000); + const signals: AbortSignal[] = []; + const fetchImpl = vi.fn((_url: string, init?: RequestInit) => { + const signal = init?.signal as AbortSignal; + signals.push(signal); + if (signals.length === 1) { + // Never responds — only the abort signal can end it. + return new Promise((_, reject) => { + signal.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }); + } + return Promise.resolve(new Response("fallback", { status: 200 })); + }); + + const pending = fetchIpfsResource(uri, { fetchImpl: fetchImpl as unknown as typeof fetch }); + await vi.advanceTimersByTimeAsync(IPFS_REQUEST_TIMEOUT_MS - 1); + expect(signals[0].aborted).toBe(false); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + const res = await pending; + expect(signals[0].aborted).toBe(true); + expect(await res.text()).toBe("fallback"); + }); + + it("rejects with a timeout error once every gateway stalls", async () => { + const fetchImpl = vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + + const pending = fetchIpfsResource(uri, { fetchImpl: fetchImpl as unknown as typeof fetch }); + const assertion = expect(pending).rejects.toThrow(/timed out after 8000ms/); + await vi.advanceTimersByTimeAsync(IPFS_REQUEST_TIMEOUT_MS * IPFS_GATEWAYS.length); + await assertion; + expect(fetchImpl).toHaveBeenCalledTimes(IPFS_GATEWAYS.length); + }); + + it("rejects with the last HTTP error when every gateway fails", async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(new Response("", { status: 429 }))); + await expect(fetchIpfsResource(uri, { fetchImpl })).rejects.toThrow("HTTP 429"); + expect(fetchImpl).toHaveBeenCalledTimes(IPFS_GATEWAYS.length); + }); + + it("stops the fallback chain when the caller aborts", async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("aborted", "AbortError")), + ); + }), + ); + + const pending = fetchIpfsResource(uri, { + fetchImpl: fetchImpl as unknown as typeof fetch, + signal: controller.signal, + }); + const assertion = expect(pending).rejects.toThrow("aborted"); + controller.abort(); + await assertion; + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("fetches non-IPFS URLs once with the same timeout", async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response("img", { status: 200 })); + await fetchIpfsResource("https://example.com/cat.png", { fetchImpl }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0][0]).toBe("https://example.com/cat.png"); + expect(fetchImpl.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/src/lib/ipfs.ts b/src/lib/ipfs.ts index 6c8ebd52..9f792190 100644 --- a/src/lib/ipfs.ts +++ b/src/lib/ipfs.ts @@ -1,16 +1,82 @@ -/** Public gateways tried in order when an `ipfs://` asset fails to load. */ +/** + * Public gateways tried in order when an `ipfs://` asset fails to load. + * A 429 (rate limit) or 504 (gateway timeout) from one gateway says nothing + * about the others, so every request walks this list before giving up. + */ export const IPFS_GATEWAYS = [ "https://ipfs.io/ipfs/", "https://cloudflare-ipfs.com/ipfs/", + "https://gateway.pinata.cloud/ipfs/", "https://dweb.link/ipfs/", ] as const; export const IPFS_GATEWAY_COUNT = IPFS_GATEWAYS.length; +/** Time allowed for a single gateway request before moving to the next one. */ +export const IPFS_REQUEST_TIMEOUT_MS = 8_000; + +/** CIDv0: base58btc multihash, always 46 characters starting with "Qm". */ +const CID_V0 = /^Qm[1-9A-HJ-NP-Za-km-z]{44}$/; +/** + * CIDv1 in its canonical base32 (lowercase, "b" multibase prefix) form, e.g. + * "bafybei…" or "bafkrei…". Gateways also accept the base36 "k…" form used + * for IPNS keys, so that is allowed too. + */ +const CID_V1_BASE32 = /^b[a-z2-7]{58,}$/; +const CID_V1_BASE36 = /^k[0-9a-z]{49,}$/; + +export interface IpfsPath { + cid: string; + /** Sub-path inside the CID's DAG, without a leading slash ("" when none). */ + path: string; +} + +/** True when `value` is a CIDv0 or a base32/base36 CIDv1 string. */ +export function isValidCid(value: string): boolean { + return CID_V0.test(value) || CID_V1_BASE32.test(value) || CID_V1_BASE36.test(value); +} + +/** + * Split an IPFS reference into its CID and nested file path. Accepts + * `ipfs://[/path]`, the legacy `ipfs://ipfs/[/path]` form and + * gateway URLs (`https://host/ipfs/[/path]`). Returns `null` for + * anything else, including a malformed CID. + */ +export function parseIpfsUri(uri: string): IpfsPath | null { + const trimmed = uri.trim(); + let rest: string; + + if (trimmed.toLowerCase().startsWith("ipfs://")) { + rest = trimmed.slice("ipfs://".length).replace(/^\/*ipfs\//i, ""); + } else { + const gatewayMatch = /^https?:\/\/[^/]+\/ipfs\/(.+)$/i.exec(trimmed); + if (!gatewayMatch) return null; + rest = gatewayMatch[1]; + } + + // Drop a query string or fragment; neither is part of the content path. + rest = rest.replace(/[?#].*$/, "").replace(/^\/+/, ""); + const slash = rest.indexOf("/"); + const rawCid = slash === -1 ? rest : rest.slice(0, slash); + const path = slash === -1 ? "" : rest.slice(slash + 1).replace(/\/+$/, ""); + + // CIDv1 base32 is case-insensitive; gateways expect the lowercase form. + const cid = CID_V0.test(rawCid) ? rawCid : rawCid.toLowerCase(); + if (!isValidCid(cid)) return null; + + return { cid, path }; +} + /** CID path from an `ipfs://` URL, or null when the value is not an IPFS URL. */ export function ipfsContentPath(url: string): string | null { const trimmed = url.trim(); if (!trimmed.toLowerCase().startsWith("ipfs://")) return null; + + const parsed = parseIpfsUri(trimmed); + if (parsed) return parsed.path ? `${parsed.cid}/${parsed.path}` : parsed.cid; + + // Not a recognisable CID — pass the raw path through so a gateway can still + // try it rather than failing outright. const path = trimmed.slice("ipfs://".length).replace(/^ipfs\//i, ""); return path.length > 0 ? path : null; } @@ -25,3 +91,58 @@ export function resolveIpfsUrl(url: string, gatewayIndex = 0): string { const index = Math.min(Math.max(gatewayIndex, 0), IPFS_GATEWAYS.length - 1); return `${IPFS_GATEWAYS[index]}${path}`; } + +/** Every gateway URL for an `ipfs://` URL, in fallback order. */ +export function resolveIpfsUrls(url: string): string[] { + if (ipfsContentPath(url) === null) return [url]; + return IPFS_GATEWAYS.map((_, index) => resolveIpfsUrl(url, index)); +} + +export interface FetchIpfsOptions { + /** Per-gateway timeout in ms. Defaults to {@link IPFS_REQUEST_TIMEOUT_MS}. */ + timeoutMs?: number; + /** Aborts the whole fallback chain, e.g. when the component unmounts. */ + signal?: AbortSignal; + /** Injected for tests; defaults to the global `fetch`. */ + fetchImpl?: typeof fetch; +} + +/** + * Fetch an IPFS asset, failing over to the next gateway on a network error, + * a non-2xx response (429 rate limit, 504 gateway timeout, …) or a request + * that exceeds `timeoutMs`. Each attempt gets its own AbortController so a + * stalled gateway is cancelled instead of hanging forever. Non-IPFS URLs are + * fetched once with the same timeout. + */ +export async function fetchIpfsResource( + url: string, + { timeoutMs = IPFS_REQUEST_TIMEOUT_MS, signal, fetchImpl }: FetchIpfsOptions = {}, +): Promise { + const doFetch = fetchImpl ?? fetch; + let lastError: unknown = null; + + for (const candidate of resolveIpfsUrls(url)) { + if (signal?.aborted) throw new DOMException("IPFS request aborted", "AbortError"); + + const controller = new AbortController(); + const onAbort = () => controller.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await doFetch(candidate, { signal: controller.signal }); + if (response.ok) return response; + lastError = new Error(`IPFS gateway responded with HTTP ${response.status}`); + } catch (error) { + if (signal?.aborted) throw error; + lastError = controller.signal.aborted + ? new Error(`IPFS gateway timed out after ${timeoutMs}ms`) + : error; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } + + throw lastError instanceof Error ? lastError : new Error("All IPFS gateways failed"); +} diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 210a400c..c99402ab 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { cn, + decodeMuxedAccount, + formatTokenAmount, + isMuxedAccount, + toXLM, truncateAddress, truncateToUtf8ByteLength, utf8ByteLength, @@ -235,3 +239,146 @@ describe("truncateToUtf8ByteLength (#686)", () => { expect(truncateToUtf8ByteLength("hello", 0)).toBe(""); }); }); + +describe("validateStellarAddress — muxed accounts (#956)", () => { + // Test vectors from SEP-23 (Muxed Account Strkeys): the same base account + // multiplexed with ID 0 and with ID 2^63. + const BASE_ADDRESS = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ"; + const MUXED_ID_0 = + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAACJUQ"; + const MUXED_ID_2_63 = + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK"; + + it("accepts 69-character muxed M-addresses", () => { + expect(MUXED_ID_0).toHaveLength(69); + expect(validateStellarAddress(MUXED_ID_0)).toBe(true); + expect(validateStellarAddress(MUXED_ID_2_63)).toBe(true); + expect(validateStellarAddress(` ${MUXED_ID_0} `)).toBe(true); + }); + + it("still accepts standard G-addresses", () => { + expect(validateStellarAddress(BASE_ADDRESS)).toBe(true); + }); + + it("rejects a muxed address with a corrupted checksum", () => { + const mutated = `${MUXED_ID_2_63.slice(0, -1)}J`; + expect(validateStellarAddress(mutated)).toBe(false); + }); + + it("rejects a muxed address with a mutated payload character", () => { + const mutated = `MB${MUXED_ID_0.slice(2)}`; + expect(validateStellarAddress(mutated)).toBe(false); + }); + + it("rejects muxed addresses of the wrong length or case", () => { + expect(validateStellarAddress(MUXED_ID_0.slice(0, 68))).toBe(false); + expect(validateStellarAddress(`${MUXED_ID_0}A`)).toBe(false); + expect(validateStellarAddress(MUXED_ID_0.toLowerCase())).toBe(false); + }); + + it("rejects a G-address payload re-labelled with an M prefix", () => { + expect(validateStellarAddress(`M${BASE_ADDRESS.slice(1)}`)).toBe(false); + }); + + describe("decodeMuxedAccount", () => { + it("recovers the base G-address and ID 0", () => { + expect(decodeMuxedAccount(MUXED_ID_0)).toEqual({ + baseAddress: BASE_ADDRESS, + id: "0", + }); + }); + + it("decodes a 64-bit ID beyond Number.MAX_SAFE_INTEGER exactly", () => { + expect(decodeMuxedAccount(MUXED_ID_2_63)).toEqual({ + baseAddress: BASE_ADDRESS, + id: "9223372036854775808", + }); + }); + + it("returns null for G-addresses and malformed input", () => { + expect(decodeMuxedAccount(BASE_ADDRESS)).toBeNull(); + expect(decodeMuxedAccount("")).toBeNull(); + expect(decodeMuxedAccount(`${MUXED_ID_0.slice(0, -1)}A`)).toBeNull(); + }); + + it("the decoded base address round-trips through validation", () => { + const decoded = decodeMuxedAccount(MUXED_ID_2_63); + expect(decoded && validateStellarAddress(decoded.baseAddress)).toBe(true); + }); + }); + + it("isMuxedAccount distinguishes M-addresses from G-addresses", () => { + expect(isMuxedAccount(MUXED_ID_0)).toBe(true); + expect(isMuxedAccount(BASE_ADDRESS)).toBe(false); + }); +}); + +describe("formatTokenAmount (#956)", () => { + it("formats stroops to exactly 7 decimal places", () => { + expect(formatTokenAmount("10000000")).toBe("1.0000000"); + expect(formatTokenAmount("12345678")).toBe("1.2345678"); + expect(formatTokenAmount("100")).toBe("0.0000100"); + }); + + it("handles zero and a single stroop", () => { + expect(formatTokenAmount("0")).toBe("0.0000000"); + expect(formatTokenAmount("1")).toBe("0.0000001"); + }); + + it("formats amounts beyond 2^53 without floating-point loss", () => { + // Number("9007199254740993") rounds to ...992 — the old Number division + // path dropped the final stroop. + expect(formatTokenAmount("9007199254740993")).toBe("900719925.4740993"); + // Max int64 — the largest possible classic balance. + expect(formatTokenAmount("9223372036854775807")).toBe("922337203685.4775807"); + }); + + it("formats i128-sized Soroban token amounts", () => { + expect(formatTokenAmount("170141183460469231731687303715884105727")).toBe( + "17014118346046923173168730371588.4105727", + ); + }); + + it("handles negative amounts", () => { + expect(formatTokenAmount("-1")).toBe("-0.0000001"); + expect(formatTokenAmount("-12345678")).toBe("-1.2345678"); + }); + + it("accepts bigint and safe-integer number input", () => { + expect(formatTokenAmount(25_000_000n)).toBe("2.5000000"); + expect(formatTokenAmount(5)).toBe("0.0000005"); + expect(formatTokenAmount(Number.MAX_SAFE_INTEGER + 2)).toBeNull(); + }); + + it("supports other decimal precisions", () => { + expect(formatTokenAmount("123456", 2)).toBe("1234.56"); + expect(formatTokenAmount("5", 0)).toBe("5"); + }); + + it("returns null for non-integer input", () => { + expect(formatTokenAmount("")).toBeNull(); + expect(formatTokenAmount("1.5")).toBeNull(); + expect(formatTokenAmount("abc")).toBeNull(); + expect(formatTokenAmount("1e7")).toBeNull(); + }); + + it("round-trips: formatted output parses back to the original stroops", () => { + for (const stroops of ["1", "999", "10000001", "9007199254740993", "123456789012345678"]) { + const formatted = formatTokenAmount(stroops) as string; + expect(BigInt(formatted.replace(".", ""))).toBe(BigInt(stroops)); + } + }); +}); + +describe("toXLM", () => { + it("formats stroops losslessly", () => { + expect(toXLM("500")).toBe("0.0000500"); + expect(toXLM("9007199254740993")).toBe("900719925.4740993"); + }); + + it("keeps parseInt-style leading-digit parsing and the invalid-input fallback", () => { + expect(toXLM("100abc")).toBe("0.0000100"); + expect(toXLM("abc")).toBe("0.0000000"); + expect(toXLM("")).toBe("0.0000000"); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a7c54f86..1b57db77 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -74,50 +74,186 @@ function crc16xmodem(bytes: Uint8Array): number { return crc; } +/** + * Encodes raw bytes as RFC 4648 base32 without padding — the inverse of + * {@link base32Decode}, used to rebuild a G-address from a muxed account. + */ +function base32Encode(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let output = ""; + + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + output += BASE32_ALPHABET[(value >>> bits) & 0x1f]; + } + value &= (1 << bits) - 1; + } + if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]; + + return output; +} + /** StrKey version byte for an ED25519 public key ('G...' addresses). */ const STRKEY_VERSION_ED25519_PUBLIC_KEY = 6 << 3; // 0x30 +/** StrKey version byte for a muxed ED25519 account ('M...' addresses, SEP-23). */ +const STRKEY_VERSION_MUXED_ACCOUNT = 12 << 3; // 0x60 + +/** Length of a standard G-address StrKey. */ +export const STELLAR_ADDRESS_LENGTH = 56; +/** Length of a muxed M-address StrKey (32-byte key + 8-byte ID). */ +export const MUXED_ADDRESS_LENGTH = 69; + +/** + * Decodes a StrKey and verifies its version byte, total length and + * CRC16/XMODEM checksum. Returns the payload (key bytes, plus the 8-byte ID + * for a muxed account) or `null` when any part of the encoding is invalid. + */ +function decodeStrKey( + encoded: string, + expectedVersion: number, + payloadLength: number, +): Uint8Array | null { + const decoded = base32Decode(encoded); + // version byte + payload + 2 checksum bytes. + if (!decoded || decoded.length !== payloadLength + 3) return null; + if (decoded[0] !== expectedVersion) return null; + + const checksumOffset = payloadLength + 1; + const versionAndPayload = decoded.subarray(0, checksumOffset); + const expectedChecksum = + decoded[checksumOffset] | (decoded[checksumOffset + 1] << 8); // little-endian + if (crc16xmodem(versionAndPayload) !== expectedChecksum) return null; + + return decoded.slice(1, checksumOffset); +} + +/** Encodes `payload` as a StrKey with the given version byte and checksum. */ +function encodeStrKey(version: number, payload: Uint8Array): string { + const versionAndPayload = new Uint8Array(payload.length + 1); + versionAndPayload[0] = version; + versionAndPayload.set(payload, 1); + const checksum = crc16xmodem(versionAndPayload); + const full = new Uint8Array(versionAndPayload.length + 2); + full.set(versionAndPayload); + full[versionAndPayload.length] = checksum & 0xff; + full[versionAndPayload.length + 1] = checksum >> 8; + return base32Encode(full); +} + +export interface MuxedAccount { + /** The underlying G-address the muxed account routes to. */ + baseAddress: string; + /** The 64-bit multiplexing ID, as a decimal string (exceeds Number range). */ + id: string; +} + +/** + * Decode a 69-character muxed account (SEP-23 `M...` address) into its base + * G-address and 64-bit ID. Returns `null` for anything that isn't a valid, + * checksummed muxed account. + */ +export function decodeMuxedAccount(address: string): MuxedAccount | null { + const trimmed = address.trim(); + if (!/^M[A-Z2-7]{68}$/.test(trimmed)) return null; + + // 32-byte ED25519 key followed by a big-endian uint64 ID. + const payload = decodeStrKey(trimmed, STRKEY_VERSION_MUXED_ACCOUNT, 40); + if (!payload) return null; + + let id = 0n; + for (const byte of payload.subarray(32)) id = (id << 8n) | BigInt(byte); + + return { + baseAddress: encodeStrKey( + STRKEY_VERSION_ED25519_PUBLIC_KEY, + payload.subarray(0, 32), + ), + id: id.toString(), + }; +} + +/** True when `address` is a valid, checksummed muxed (`M...`) account. */ +export function isMuxedAccount(address: string): boolean { + return decodeMuxedAccount(address) !== null; +} /** - * Validate a Stellar public address: decodes the full StrKey encoding - * (base32 → version byte → 32-byte raw key → CRC16/XMODEM checksum) rather - * than only checking the surface format. A string that merely matches - * `/^G[A-Z2-7]{55}$/` can still fail this — e.g. a single mistyped - * character produces a different checksum, so a format-only check would - * accept it and let it reach the network, where it fails with an opaque - * error rather than a clear one at input time. + * Validate a Stellar account address — either a standard 56-character + * G-address or a 69-character muxed M-address (SEP-23). Decodes the full + * StrKey encoding (base32 → version byte → raw key [+ 8-byte ID] → + * CRC16/XMODEM checksum) rather than only checking the surface format. A + * string that merely matches `/^G[A-Z2-7]{55}$/` can still fail this — e.g. a + * single mistyped character produces a different checksum, so a format-only + * check would accept it and let it reach the network, where it fails with an + * opaque error rather than a clear one at input time. * * Deliberately re-implemented here (not imported from `@stellar/stellar-sdk` * or another blockchain library) — this package has no blockchain-logic * dependency by design (see README); StrKey decoding is pure data * validation, not chain interaction, so it stays in scope for that - * constraint without pulling in a full SDK for one function. + * constraint without pulling in a full SDK for one function. The decoding + * mirrors `StrKey.isValidEd25519PublicKey` / `StrKey.isValidMed25519PublicKey` + * in stellar/js-stellar-base. */ export function validateStellarAddress(address: string): boolean { const trimmed = address.trim(); - if (!/^G[A-Z2-7]{55}$/.test(trimmed)) return false; + if (/^G[A-Z2-7]{55}$/.test(trimmed)) { + return decodeStrKey(trimmed, STRKEY_VERSION_ED25519_PUBLIC_KEY, 32) !== null; + } + return isMuxedAccount(trimmed); +} + +/** Decimal places of XLM and of most Stellar classic assets. */ +export const STELLAR_DECIMALS = 7; - const decoded = base32Decode(trimmed); - // 1 version byte + 32 key bytes + 2 checksum bytes = 35. - if (!decoded || decoded.length !== 35) return false; +/** + * Convert an integer amount in the smallest unit (stroops for XLM) to a + * token-unit string with exactly `decimals` places, e.g. `"12345678"` → + * `"1.2345678"`. + * + * Works on the decimal string with BigInt rather than `Number(stroops) / 1e7`: + * a double only holds integers exactly up to 2^53, so large balances (and + * i128 Soroban token amounts) picked up rounding artifacts in the last + * digits. Returns `null` when `amount` isn't an integer. + */ +export function formatTokenAmount( + amount: string | bigint | number, + decimals: number = STELLAR_DECIMALS, +): string | null { + let value: bigint; + if (typeof amount === "bigint") { + value = amount; + } else if (typeof amount === "number") { + if (!Number.isSafeInteger(amount)) return null; + value = BigInt(amount); + } else { + const trimmed = amount.trim(); + if (!/^[+-]?\d+$/.test(trimmed)) return null; + value = BigInt(trimmed); + } - const [version] = decoded; - if (version !== STRKEY_VERSION_ED25519_PUBLIC_KEY) return false; + const negative = value < 0n; + const digits = (negative ? -value : value).toString(); + if (decimals <= 0) return `${negative ? "-" : ""}${digits}`; - const payload = decoded.subarray(0, 33); // version byte + key - const expectedChecksum = decoded[33] | (decoded[34] << 8); // little-endian - return crc16xmodem(payload) === expectedChecksum; + const padded = digits.padStart(decimals + 1, "0"); + const whole = padded.slice(0, -decimals); + const fraction = padded.slice(-decimals); + return `${negative ? "-" : ""}${whole}.${fraction}`; } -const STROOPS_PER_XLM = 10_000_000; - /** * Convert a stroop amount (the smallest Stellar unit) to an XLM string * formatted to 7 decimal places. Returns "0.0000000" for invalid input. + * Leading integer digits are used like `parseInt` (e.g. "100abc" → 100). */ export function toXLM(stroops: string): string { - const n = parseInt(stroops, 10); - if (Number.isNaN(n)) return (0).toFixed(7); - return (n / STROOPS_PER_XLM).toFixed(7); + const match = /^\s*([+-]?\d+)/.exec(stroops); + return (match && formatTokenAmount(match[1])) ?? (0).toFixed(7); } /**