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)."}
+
+
+
+
+ |
+ Resource
+ |
+
+ Usage
+ |
+
+ Fee (stroops)
+ |
+
+
+
+ {costs.map((cost) => (
+
+ | {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 (
{
- 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);
}
/**