Skip to content

Commit f06bd07

Browse files
authored
Merge pull request #999 from AdEmOnD07/fix/issue-922
fix: add hop liquidity depth and circular route detection in SwapRoute
2 parents 01fc288 + d8a1b00 commit f06bd07

4 files changed

Lines changed: 691 additions & 51 deletions

File tree

‎src/components/SwapRoute.test.tsx‎

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
33

44
import type { SwapSuggestion } from "@/lib/rebalancer";
55

6-
import { SwapRoute } from "./SwapRoute";
6+
import {
7+
detectCircularRoutes,
8+
isCircularRoute,
9+
SwapRoute,
10+
validateSwapRoute,
11+
} from "./SwapRoute";
712

813
const SWAP: SwapSuggestion = {
914
from: "XLM",
@@ -107,4 +112,185 @@ describe("SwapRoute", () => {
107112
// The swap fee uses a different format so the two are not conflated.
108113
expect(screen.getByText("0.30%")).toBeInTheDocument();
109114
});
115+
116+
// ─── Issue #922 acceptance criteria ─────────────────────────────────────────
117+
118+
describe("Multi-hop routes with fee tiers and liquidity depth (Issue #922)", () => {
119+
it("displays fee tiers and pool liquidity depth for intermediate hops", () => {
120+
const multiHopSwap: SwapSuggestion = {
121+
...SWAP,
122+
from: "XLM",
123+
to: "BTC",
124+
hops: [
125+
{
126+
asset: "USDC",
127+
feeTier: "0.3%",
128+
liquidityDepth: "$1.2M",
129+
},
130+
],
131+
};
132+
133+
render(<SwapRoute swaps={[multiHopSwap]} />);
134+
135+
// Asset pills for source, intermediate hop, and destination
136+
expect(screen.getAllByText("XLM").length).toBeGreaterThan(0);
137+
expect(screen.getAllByText("USDC").length).toBeGreaterThan(0);
138+
expect(screen.getAllByText("BTC").length).toBeGreaterThan(0);
139+
140+
// Fee tier badge for intermediate hop
141+
expect(screen.getByText("0.3%")).toBeInTheDocument();
142+
expect(screen.getByTestId("fee-tier-badge")).toHaveTextContent("0.3%");
143+
144+
// Pool liquidity depth indicator
145+
expect(screen.getByText("$1.2M")).toBeInTheDocument();
146+
expect(screen.getByTestId("liquidity-indicator")).toHaveTextContent("$1.2M");
147+
});
148+
149+
it("displays multiple intermediate hops with their respective fee tiers and depths", () => {
150+
const twoHopSwap: SwapSuggestion = {
151+
...SWAP,
152+
from: "XLM",
153+
to: "BTC",
154+
hops: [
155+
{ asset: "USDC", feeTier: 0.05, poolDepth: 500000 },
156+
{ asset: "EURC", feeTier: "0.3%", liquidityDepth: "$2.5M" },
157+
],
158+
};
159+
160+
render(<SwapRoute swaps={[twoHopSwap]} />);
161+
162+
// Both intermediate nodes rendered
163+
expect(screen.getAllByTestId("intermediate-hop-node")).toHaveLength(2);
164+
expect(screen.getByText("0.05%")).toBeInTheDocument();
165+
expect(screen.getByText("$500k")).toBeInTheDocument();
166+
expect(screen.getByText("0.3%")).toBeInTheDocument();
167+
expect(screen.getByText("$2.5M")).toBeInTheDocument();
168+
});
169+
170+
it("renders responsive vertical arrow connectors for small viewports (<640px) and horizontal connectors for desktop", () => {
171+
const multiHopSwap: SwapSuggestion = {
172+
...SWAP,
173+
from: "XLM",
174+
to: "BTC",
175+
hops: [{ asset: "USDC", feeTier: "0.3%", liquidityDepth: "$1.2M" }],
176+
};
177+
178+
render(<SwapRoute swaps={[multiHopSwap]} />);
179+
180+
const connectors = screen.getAllByTestId("route-connector");
181+
// 2 connectors: XLM -> USDC and USDC -> BTC
182+
expect(connectors).toHaveLength(2);
183+
184+
// Verify vertical connectors (block sm:hidden) for mobile viewports under 640px
185+
const verticalConnectors = screen.getAllByTestId("vertical-connector");
186+
expect(verticalConnectors).toHaveLength(2);
187+
verticalConnectors.forEach((vc) => {
188+
expect(vc).toHaveClass("block");
189+
expect(vc).toHaveClass("sm:hidden");
190+
});
191+
192+
// Verify horizontal connectors (hidden sm:block) for desktop viewports
193+
const horizontalConnectors = screen.getAllByTestId("horizontal-connector");
194+
expect(horizontalConnectors).toHaveLength(2);
195+
horizontalConnectors.forEach((hc) => {
196+
expect(hc).toHaveClass("hidden");
197+
expect(hc).toHaveClass("sm:block");
198+
});
199+
});
200+
});
201+
202+
describe("Circular route detection and rejection (Issue #922)", () => {
203+
it("detects circular multi-hop route (e.g. XLM -> USDC -> XLM)", () => {
204+
const circularSwap: SwapSuggestion = {
205+
...SWAP,
206+
from: "XLM",
207+
to: "XLM",
208+
hops: [{ asset: "USDC", feeTier: "0.3%", liquidityDepth: "$1.2M" }],
209+
};
210+
211+
expect(isCircularRoute(circularSwap)).toBe(true);
212+
213+
const validation = validateSwapRoute(circularSwap);
214+
expect(validation.isValid).toBe(false);
215+
expect(validation.isCircular).toBe(true);
216+
expect(validation.error).toMatch(/circular route rejected/i);
217+
expect(validation.path).toEqual(["XLM", "USDC", "XLM"]);
218+
});
219+
220+
it("detects circular route when destination matches origin directly (e.g. XLM -> XLM)", () => {
221+
const directCircularSwap: SwapSuggestion = {
222+
...SWAP,
223+
from: "XLM",
224+
to: "XLM",
225+
};
226+
227+
expect(isCircularRoute(directCircularSwap)).toBe(true);
228+
expect(validateSwapRoute(directCircularSwap).isValid).toBe(false);
229+
});
230+
231+
it("detects circular route defined via path array (e.g. ['XLM', 'USDC', 'XLM'])", () => {
232+
expect(isCircularRoute(["XLM", "USDC", "XLM"])).toBe(true);
233+
expect(isCircularRoute(["XLM", "USDC", "BTC"])).toBe(false);
234+
235+
const pathSwap: SwapSuggestion = {
236+
...SWAP,
237+
from: "XLM",
238+
to: "BTC",
239+
path: ["XLM", "USDC", "XLM", "BTC"],
240+
};
241+
expect(isCircularRoute(pathSwap)).toBe(true);
242+
});
243+
244+
it("detects circular cycles across multiple swaps (e.g. XLM -> USDC and USDC -> XLM)", () => {
245+
const swap1: SwapSuggestion = { ...SWAP, from: "XLM", to: "USDC" };
246+
const swap2: SwapSuggestion = { ...SWAP, from: "USDC", to: "XLM" };
247+
248+
const cycleResult = detectCircularRoutes([swap1, swap2]);
249+
expect(cycleResult.hasCycle).toBe(true);
250+
expect(cycleResult.circularIndices).toContain(0);
251+
expect(cycleResult.circularIndices).toContain(1);
252+
});
253+
254+
it("validates that a valid non-circular route is accepted", () => {
255+
const validSwap: SwapSuggestion = {
256+
...SWAP,
257+
from: "XLM",
258+
to: "BTC",
259+
hops: [{ asset: "USDC", feeTier: "0.3%", liquidityDepth: "$1.2M" }],
260+
};
261+
262+
expect(isCircularRoute(validSwap)).toBe(false);
263+
const validation = validateSwapRoute(validSwap);
264+
expect(validation.isValid).toBe(true);
265+
expect(validation.isCircular).toBe(false);
266+
expect(validation.error).toBeUndefined();
267+
});
268+
269+
it("rejects circular routes in the UI by displaying rejection banner and rejected badge", () => {
270+
const circularSwap: SwapSuggestion = {
271+
...SWAP,
272+
from: "XLM",
273+
to: "XLM",
274+
hops: [{ asset: "USDC", feeTier: "0.3%", liquidityDepth: "$1.2M" }],
275+
};
276+
277+
render(<SwapRoute swaps={[circularSwap]} statuses={["success"]} />);
278+
279+
// Alert banner is rendered
280+
expect(screen.getByRole("alert")).toHaveTextContent(/circular route detected and rejected/i);
281+
282+
// Badge displays "Rejected" rather than "Done"
283+
expect(screen.getByText("Rejected")).toBeInTheDocument();
284+
expect(screen.queryByText("Done")).not.toBeInTheDocument();
285+
286+
// Error message explains circular route rejection
287+
expect(screen.getByText(/circular route rejected.*XLM.*USDC.*XLM/i)).toBeInTheDocument();
288+
289+
// Row aria-label marks circular route as rejected
290+
expect(
291+
screen.getByRole("listitem", { name: /circular route rejected/i }),
292+
).toBeInTheDocument();
293+
});
294+
});
110295
});
296+

0 commit comments

Comments
 (0)