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
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ jobs:
run: npm ci

- name: Run unit + snapshot tests
run: npx vitest run --ci
run: npx vitest run
113 changes: 0 additions & 113 deletions public/sw.js

This file was deleted.

1 change: 0 additions & 1 deletion public/sw.js.map

This file was deleted.

2 changes: 0 additions & 2 deletions public/workbox-f1770938.js

This file was deleted.

1 change: 0 additions & 1 deletion public/workbox-f1770938.js.map

This file was deleted.

38 changes: 37 additions & 1 deletion src/__tests__/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest";
import { SUPPORTED_CHAINS, estimateBridgeFee } from "@/lib/bridge";
import { SUPPORTED_CHAINS, estimateBridgeFee, optimizeBridgeRoute } from "@/lib/bridge";

// ─── SUPPORTED_CHAINS ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -89,6 +89,42 @@ describe("estimateBridgeFee — ethereum", () => {
test("net amount never goes negative", () => {
expect(parseFloat(estimateBridgeFee("ethereum", "-50").netAmount)).toBeGreaterThanOrEqual(0);
});

test("rounds fees up to the nearest micro-USDC", () => {
expect(estimateBridgeFee("ethereum", "0.000001").estimatedUsdcFee).toBe("0.000001");
});
});

describe("optimizeBridgeRoute", () => {
test("selects the lowest-cost route and reports savings and fallbacks", () => {
const plan = optimizeBridgeRoute("100");

expect(plan?.selected.chain).toBe("solana");
expect(plan?.fallbackRoutes.map((route) => route.chain)).toEqual(["ethereum"]);
expect(plan?.savingsVsEthereum).toBe("0.200000");
expect(plan?.savingsPercent).toBe(66.66);
expect(plan?.costAboveCheapest).toBe("0.000000");
});

test("respects a manual override and reports its added cost", () => {
const plan = optimizeBridgeRoute("100", { preferredChain: "ethereum" });

expect(plan?.selected.chain).toBe("ethereum");
expect(plan?.fallbackRoutes.map((route) => route.chain)).toEqual(["solana"]);
expect(plan?.savingsVsEthereum).toBe("0.000000");
expect(plan?.costAboveCheapest).toBe("0.200000");
});

test("uses an available fallback when a route is unavailable", () => {
const plan = optimizeBridgeRoute("100", { unavailableChains: ["solana"] });

expect(plan?.selected.chain).toBe("ethereum");
expect(plan?.fallbackRoutes).toEqual([]);
});

test("returns null when no routes are available", () => {
expect(optimizeBridgeRoute("100", { unavailableChains: ["ethereum", "solana"] })).toBeNull();
});
});

// ─── estimateBridgeFee — Solana ───────────────────────────────────────────────
Expand Down
72 changes: 67 additions & 5 deletions src/components/CrossChainPayment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import {
SUPPORTED_CHAINS,
estimateBridgeFee,
optimizeBridgeRoute,
connectWalletForChain,
buildBridgePayment,
pollBridgeStatus,
Expand All @@ -19,25 +20,41 @@ import type {
/** Chain selector tabs. */
function ChainSelector({
selected,
automatic,
onSelect,
onAutomatic,
disabled,
}: {
selected: SupportedChain;
automatic: boolean;
onSelect: (chain: SupportedChain) => void;
onAutomatic: () => void;
disabled: boolean;
}) {
return (
<div role="radiogroup" aria-label="Source chain" className="flex gap-2">
<div role="radiogroup" aria-label="Source chain" className="flex flex-wrap gap-2">
<button
type="button"
role="radio"
aria-checked={automatic}
disabled={disabled}
onClick={onAutomatic}
className={`flex-1 min-w-32 px-4 py-2.5 rounded-lg text-sm font-semibold border transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed ${
automatic ? "bg-indigo-600 border-indigo-500 text-white" : "bg-gray-800 border-gray-700 text-gray-300 hover:bg-gray-700"
}`}
>
Lowest USDC fee
</button>
{SUPPORTED_CHAINS.map((chain) => (
<button
key={chain.id}
type="button"
role="radio"
aria-checked={selected === chain.id}
aria-checked={!automatic && selected === chain.id}
disabled={disabled}
onClick={() => onSelect(chain.id)}
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-semibold border transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed ${
selected === chain.id
!automatic && selected === chain.id
? "bg-indigo-600 border-indigo-500 text-white"
: "bg-gray-800 border-gray-700 text-gray-300 hover:bg-gray-700"
}`}
Expand Down Expand Up @@ -267,8 +284,12 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
const [step, setStep] = useState<Step>("select");

// Chain & amount
const [chain, setChain] = useState<SupportedChain>("ethereum");
const [amount, setAmount] = useState("");
const [manualChain, setManualChain] = useState<SupportedChain | null>(null);
const routePlan = amount && Number(amount) > 0
? optimizeBridgeRoute(amount, { preferredChain: manualChain ?? undefined })
: null;
const chain = routePlan?.selected.chain ?? manualChain ?? "solana";
const [estimate, setEstimate] = useState<FeeEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState(false);
Expand Down Expand Up @@ -331,7 +352,12 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
}, [chain]);

const handleChainSelect = (c: SupportedChain) => {
setChain(c);
setManualChain(c);
setStep("select");
};

const handleAutomaticRoute = () => {
setManualChain(null);
setStep("select");
};

Expand Down Expand Up @@ -384,6 +410,7 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
const handleReset = () => {
setStep("select");
setAmount("");
setManualChain(null);
setEstimate(null);
setWalletAddress(null);
setWalletError(null);
Expand Down Expand Up @@ -435,7 +462,9 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
</p>
<ChainSelector
selected={chain}
automatic={manualChain === null}
onSelect={handleChainSelect}
onAutomatic={handleAutomaticRoute}
disabled={false}
/>
</div>
Expand Down Expand Up @@ -471,6 +500,39 @@ export default function CrossChainPayment({ invoiceId, stellarDestination }: Pro
loading={estimateLoading}
error={estimateError}
/>
{routePlan && (
<div className="rounded-lg border border-gray-700 bg-gray-800/70 p-4 text-sm" aria-live="polite">
<p className="font-semibold text-white">
{manualChain ? "Manual route" : "Lowest USDC-fee route"}: {routePlan.selected.chain === "ethereum" ? "Ethereum" : "Solana"}
</p>
{!manualChain && routePlan.savingsVsEthereum !== "0.000000" && (
<p className="mt-1 text-green-300">
Saves {routePlan.savingsVsEthereum} USDC in bridge fees ({routePlan.savingsPercent.toFixed(2)}%) versus Ethereum.
</p>
)}
{manualChain && routePlan.costAboveCheapest !== "0.000000" && (
<p className="mt-1 text-amber-300">
This override adds {routePlan.costAboveCheapest} USDC in bridge fees versus the lowest-fee route.
</p>
)}
<p className="mt-1 text-xs text-gray-400">Native-token gas fees are shown separately and are not included in this comparison.</p>
{routePlan.fallbackRoutes.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-400">Fallback routes:</span>
{routePlan.fallbackRoutes.map((route) => (
<button
key={route.chain}
type="button"
onClick={() => handleChainSelect(route.chain)}
className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:border-gray-400"
>
{route.chain === "ethereum" ? "Ethereum" : "Solana"} · {route.estimate.estimatedUsdcFee} USDC fee
</button>
))}
</div>
)}
</div>
)}
</>
)}

Expand Down
Loading