Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 180 additions & 2 deletions src/components/FeeEstimator.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -10,13 +16,19 @@ 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 {
useSorokit: () => ({
client: getClient(),
isConnected: true,
address: "GTEST",
network: sorokitState.network,
}),
};
});
Expand Down Expand Up @@ -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(<FeeEstimator refreshInterval={30_000} />);
await waitFor(() => expect(screen.getByText("111")).toBeInTheDocument());

sorokitState.network = MAINNET;
rerender(<FeeEstimator refreshInterval={30_000} />);

// 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(<FeeEstimator />);
await waitFor(() => expect(estimateFee).toHaveBeenCalledTimes(1));

sorokitState.network = MAINNET;
rerender(<FeeEstimator />);
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(<FeeEstimator />);
await waitFor(() => expect(screen.getByText("200")).toBeInTheDocument());

sorokitState.network = { ...TESTNET };
rerender(<FeeEstimator />);
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(<FeeEstimator />);

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(<FeeEstimator />);

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(<FeeEstimator compact />);

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(<FeeEstimator />);

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(<FeeEstimator onMultiplierChange={onMultiplierChange} />);

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(<FeeEstimator onMultiplierChange={onMultiplierChange} />);

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");
});
});
});
119 changes: 118 additions & 1 deletion src/components/FeeEstimator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -37,6 +65,7 @@ export function FeeEstimator({
onFeeLoad,
customFee: customFeeProp,
onCustomFeeChange,
onMultiplierChange,
}: FeeEstimatorProps) {
const { registerWatcher } = useSorokit();
const [containerRef, isVisible] = useIsVisible<HTMLDivElement>();
Expand Down Expand Up @@ -120,6 +149,22 @@ export function FeeEstimator({
};
}, [registerWatcher]);

const [multiplier, setMultiplier] = useState<FeeMultiplier | null>(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;
Expand Down Expand Up @@ -152,7 +197,14 @@ export function FeeEstimator({
</button>
</span>
) : fee ? (
<span className="text-[11px] text-ink">{compactContent}</span>
<span className="inline-flex items-center gap-2 text-[11px] text-ink">
{compactContent}
{isSurge && (
<span role="alert">
<Badge variant="warning">Surge pricing</Badge>
</span>
)}
</span>
) : null}
</div>
) : (
Expand Down Expand Up @@ -206,6 +258,7 @@ export function FeeEstimator({
</div>
) : fee ? (
<>
{isSurge && <SurgeWarning baseFee={baseFeeStroops} />}
<div className="flex items-center gap-4">
<FeeCell
label="Base Fee"
Expand All @@ -224,6 +277,47 @@ export function FeeEstimator({
}
/>
</div>
<div className="mt-4 pt-4 border-t border-line">
<div className="flex items-center justify-between gap-3">
<span
id="fee-multiplier-label"
className="text-[11px] font-medium text-ink-2"
>
Speed Multiplier
</span>
<div
role="group"
aria-labelledby="fee-multiplier-label"
className="flex items-center gap-1.5"
>
{FEE_MULTIPLIERS.map((m) => (
<button
key={m}
type="button"
aria-pressed={multiplier === m}
onClick={() => handleMultiplierToggle(m)}
className={cn(
"px-2.5 py-1 text-[11px] font-medium rounded-lg border transition-colors",
multiplier === m
? "border-brand bg-brand-dim text-brand"
: "border-line bg-surface-2 text-ink-2 hover:text-ink",
)}
>
{m.toFixed(1)}x
</button>
))}
</div>
</div>
{multipliedFee !== null && (
<p
data-testid="multiplied-fee"
className="text-[11px] text-ink-3 mt-1.5"
>
Priority fee: <span className="text-ink font-semibold">{multipliedFee}</span>{" "}
stroops (≈ {toXLM(multipliedFee)} XLM) for faster inclusion
</p>
)}
</div>
<div className="mt-4 pt-4 border-t border-line">
<label
htmlFor="custom-fee-input"
Expand Down Expand Up @@ -271,6 +365,29 @@ export function FeeEstimator({
);
}

function SurgeWarning({ baseFee }: { baseFee: number }) {
const ratio = (baseFee / MIN_NETWORK_BASE_FEE).toFixed(1);
return (
<div
role="alert"
data-testid="surge-warning"
className="mb-4 rounded-lg border border-warning-dim bg-warning-dim px-3 py-2"
>
<div className="flex items-center gap-2">
<Badge variant="warning">Surge pricing</Badge>
<span className="text-[12px] font-semibold text-ink">
Network fees are elevated ({ratio}x the minimum)
</span>
</div>
<p className="text-[11px] text-ink-2 mt-1">
The base fee is {baseFee} stroops. Ledgers are at capacity, so
transactions are competing on fee for inclusion. Pay at least the
recommended fee, or wait for congestion to ease.
</p>
</div>
);
}

export function FeeCell({
label,
value,
Expand Down
Loading
Loading