Skip to content
Open
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
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
}
],
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^0.0.0-beta.0",
"@creit.tech/stellar-wallets-kit": "^2.5.0",
"@stellar/stellar-sdk": "^13.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
Expand All @@ -62,11 +62,18 @@
}
},
"devDependencies": {
"@radix-ui/react-slot": "^2.0.0",
"@size-limit/file": "^11.2.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"@hugeicons/react": "^0.5.0",
"@hugeicons/core-free-icons": "^0.5.0",
"clsx": "^1.2.0",
"size-limit": "^11.2.0",
"tailwind-merge": "^2.0.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
Expand Down
10 changes: 6 additions & 4 deletions src/components/ClaimableBalanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,26 +83,28 @@ export function ClaimableBalanceCard() {
useEffect(() => {
if (!address) return;

let active = true;
let cancelled = false;
const timerId = window.setTimeout(() => {
setLoading(true);
setError(null);
getClient()
.account.getClaimableBalances(address)
.then(({ data, error: err }) => {
if (!active) return;
if (cancelled) return;
if (err) {
setError(err);
return;
}
setBalances(data ?? []);
})
.finally(() => {
if (active) setLoading(false);
if (cancelled) return;
setLoading(false);
});
}, 0);

return () => {
active = false;
cancelled = true;
window.clearTimeout(timerId);
};
}, [address]);
Expand Down
72 changes: 72 additions & 0 deletions src/components/FeeEstimator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,76 @@ describe("FeeEstimator", () => {
await waitFor(() => expect(liveRegion).toHaveTextContent(/100/));
});
});

// ── XLM conversion and high-fee badge (#185) ───────────────────────────────
describe("XLM conversion and high-fee badge", () => {
it("converts stroops to XLM using 10_000_000 divisor", async () => {
mockEstimateFee({
data: { baseFee: "10000000", recommended: "20000000" },
error: null,
});
render(<FeeEstimator />);

await waitFor(() => {
// 10000000 stroops = 1 XLM
expect(screen.getByText(/1\.0000000 XLM/)).toBeInTheDocument();
// 20000000 stroops = 2 XLM
expect(screen.getByText(/2\.0000000 XLM/)).toBeInTheDocument();
});
});

it("shows high-fee badge when recommended > 2x base fee", async () => {
mockEstimateFee({
data: { baseFee: "100", recommended: "250" }, // 250 > 2*100
error: null,
});
render(<FeeEstimator />);

await waitFor(() => {
expect(screen.getByText("High fee")).toBeInTheDocument();
});
});

it("does not show high-fee badge when recommended <= 2x base fee", async () => {
mockEstimateFee({
data: { baseFee: "100", recommended: "200" }, // 200 = 2*100
error: null,
});
render(<FeeEstimator />);

await waitFor(() => {
expect(screen.getByText("100")).toBeInTheDocument();
});

// High fee badge should not be present
expect(screen.queryByText("High fee")).not.toBeInTheDocument();
});

it("shows high-fee badge only when recommended is strictly greater than 2x base", async () => {
mockEstimateFee({
data: { baseFee: "100", recommended: "201" }, // 201 > 2*100
error: null,
});
render(<FeeEstimator />);

await waitFor(() => {
expect(screen.getByText("High fee")).toBeInTheDocument();
});
});

it("handles fractional stroops correctly in XLM conversion", async () => {
mockEstimateFee({
data: { baseFee: "123456", recommended: "654321" },
error: null,
});
render(<FeeEstimator />);

await waitFor(() => {
// 123456 / 10000000 = 0.0123456 XLM
expect(screen.getByText(/0\.0123456 XLM/)).toBeInTheDocument();
// 654321 / 10000000 = 0.0654321 XLM
expect(screen.getByText(/0\.0654321 XLM/)).toBeInTheDocument();
});
});
});
});
177 changes: 131 additions & 46 deletions src/components/FeeEstimator.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,136 @@
/**
* FeeEstimator Component
*
* Calculates and displays estimated transaction fees based on network conditions,
* operation complexity, and current Stellar network state.
*
* @component
* @example
* ```tsx
* import { FeeEstimator } from 'sorokit-ui';
*
* export function TransactionForm() {
* return (
* <div>
* <FeeEstimator
* operations={5}
* network="testnet"
* />
* </div>
* );
* }
* ```
*
* @param props - Component props
* @param props.operations - Number of operations in transaction (default: 1)
* @param props.network - Network to estimate fees for ('testnet' | 'public')
* @param props.onEstimate - Callback when fee is calculated
*
* @returns The rendered FeeEstimator component
*
* @remarks
* - Updates every 10 seconds with latest network fees
* - Shows breakdown of base fee + operations fee
* - Includes estimated stroops
* - Requires SorokitProvider context
*
* @see {@link SorokitProvider} for setup
*/
export function FeeEstimator({
operations = 1,
network,
onEstimate
}: FeeEstimatorProps) {
// Component implementation
}
import { useEffect, useState } from "react";
import { getClient } from "@/lib/client";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { cn } from "@/lib/utils";
import { RefreshCwIcon } from "@hugeicons/react";

const XLM_STROOPS = 10_000_000;
const HIGH_FEE_THRESHOLD = 2;

export interface FeeEstimatorProps {
operations?: number;
network: 'testnet' | 'public';
network?: string;
onEstimate?: (fee: string) => void;
}

interface FeeData {
baseFee: string;
recommended: string;
}

export function FeeEstimator({ operations = 1, network, onEstimate }: FeeEstimatorProps) {
const [feeData, setFeeData] = useState<FeeData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchFees = async () => {
setLoading(true);
setError(null);
try {
const { data, error: err } = await getClient().transaction.estimateFee({
operations,
});
if (err) {
setError(err);
setFeeData(null);
} else if (data) {
setFeeData(data);
onEstimate?.(data.recommended);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to estimate fee");
setFeeData(null);
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchFees();
const interval = setInterval(fetchFees, 10000);
return () => clearInterval(interval);
}, [operations]);

const isHighFee =
feeData &&
parseFloat(feeData.recommended) > parseFloat(feeData.baseFee) * HIGH_FEE_THRESHOLD;

const recommendedXlm = feeData
? (parseFloat(feeData.recommended) / XLM_STROOPS).toFixed(7)
: null;

const baseXlm = feeData
? (parseFloat(feeData.baseFee) / XLM_STROOPS).toFixed(7)
: null;

return (
<div className="rounded-xl border border-line bg-surface overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
<h3 className="text-[14px] font-semibold text-ink">Network Fee</h3>
<Button
size="sm"
variant="ghost"
onClick={fetchFees}
disabled={loading}
aria-label="Refresh fee estimate"
title="Refresh"
>
<RefreshCwIcon size={16} color="currentColor" />
</Button>
</div>

{loading && !feeData ? (
<div className="px-6 py-5 flex flex-col gap-3">
<div className="h-4 w-24 rounded bg-surface-2 animate-pulse" />
<div className="h-4 w-32 rounded bg-surface-2 animate-pulse" />
</div>
) : error ? (
<p className="text-[13px] text-red text-center py-6">{error}</p>
) : feeData ? (
<div
className="px-6 py-5"
role="region"
aria-live="polite"
aria-atomic="true"
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[12px] text-ink-3 uppercase font-semibold">
Base Fee
</span>
<div className="flex items-center gap-2">
<span className="text-[13px] font-mono text-ink">
{feeData.baseFee}
</span>
<span className="text-[11px] text-ink-3">
({baseXlm} XLM)
</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[12px] text-ink-3 uppercase font-semibold">
Recommended
</span>
{isHighFee && (
<Badge variant="warning" size="xs">
High fee
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[13px] font-mono text-ink">
{feeData.recommended}
</span>
<span className="text-[11px] text-ink-3">
({recommendedXlm} XLM)
</span>
</div>
</div>
</div>
</div>
) : null}
</div>
);
}
Loading