From fa095873162834422e24024c8ce9de710a4e2354 Mon Sep 17 00:00:00 2001 From: David Ejere Date: Fri, 29 May 2026 06:12:14 +0100 Subject: [PATCH] feat(deposits): multi-pool routing optimisation + #283 coverage follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #486 shipped the multi-asset routing API as a partial first delivery and explicitly deferred (a) multi-pool path optimisation and (b) the full 90% coverage target. This PR closes both. Multi-pool optimisation: - recommendDepositRouting() now accepts a `quoteProtocols` option (and falls back to `DEPOSIT_ROUTING_PROTOCOLS` env). When more than one candidate is supplied, each non-vault asset is quoted across every protocol; the route with the highest `amountOutAfterSlippage` wins. - Losing quotes are exposed on the route record as `alternativeQuotes`, so callers can render the optimisation savings. - A protocol that throws is silently skipped; if every candidate throws for a given asset, the asset is dropped from `routes` and a per-asset warning is surfaced. - The chosen route now carries a `protocol` field; the literal `"default"` token sends `protocol: undefined` to getZapQuote so the legacy single-pool path is unchanged. Coverage push: - New `multi-pool path optimisation` describe block: best-route pick, protocol-throw fallback, all-throw warning, env-var resolution, default→undefined remapping. - Extra cases for the pre-existing surface: trimmed/whitespace symbols, empty-symbol rejection, non-numeric amount sum, empty basket, ISO generatedAt timestamp. API back-compat: every existing test passes without modification — the single-pool default path is preserved. --- .../__tests__/depositRoutingService.test.ts | 208 ++++++++++++++++++ server/src/services/depositRoutingService.ts | 147 +++++++++++-- 2 files changed, 341 insertions(+), 14 deletions(-) diff --git a/server/src/services/__tests__/depositRoutingService.test.ts b/server/src/services/__tests__/depositRoutingService.test.ts index 549c05f76..3cea61f95 100644 --- a/server/src/services/__tests__/depositRoutingService.test.ts +++ b/server/src/services/__tests__/depositRoutingService.test.ts @@ -153,4 +153,212 @@ describe("recommendDepositRouting", () => { decimals: 7, }); }); + + // ── Multi-pool optimisation (follow-up to PR #486) ────────────────────── + + describe("multi-pool path optimisation", () => { + it("picks the route with the highest expected output across protocols", async () => { + // Two protocols, two different quotes — aquarius is better here. + mockGetZapQuote.mockReset(); + mockGetZapQuote.mockImplementation(async (body) => ({ + path: [ + { contractId: "C_XLM", label: "XLM" }, + { contractId: "C_USDC", label: "USDC" }, + ], + expectedAmountOutStroops: "1000", + source: "router_simulation", + slippageApplied: body.protocol === "aquarius" ? 0.001 : 0.01, + amountOutAfterSlippage: body.protocol === "aquarius" ? "999" : "990", + quotedAt: "2026-01-01T00:00:00.000Z", + minAmountOutStroops: body.protocol === "aquarius" ? "999" : "990", + quoteAgeMs: 0, + isFallback: false, + })); + + const result = await recommendDepositRouting( + [{ symbol: "XLM", amountInStroops: "1000000" }], + { quoteProtocols: ["soroswap", "aquarius"] }, + ); + + expect(mockGetZapQuote).toHaveBeenCalledTimes(2); + const route = result.routes[0]; + expect(route.action).toBe("convert"); + expect(route.protocol).toBe("aquarius"); + expect(route.expectedVaultAmountStroops).toBe("999"); + expect(route.alternativeQuotes).toHaveLength(1); + expect(route.alternativeQuotes?.[0].protocol).toBe("soroswap"); + expect(route.alternativeQuotes?.[0].expectedVaultAmountStroops).toBe("990"); + expect(route.reasoning).toMatch(/Selected aquarius over 1 alternative quote/); + }); + + it("falls back to the working protocol when one throws", async () => { + mockGetZapQuote.mockReset(); + mockGetZapQuote.mockImplementation(async (body) => { + if (body.protocol === "broken") { + throw new Error("rpc unreachable"); + } + return { + path: [ + { contractId: "C_XLM", label: "XLM" }, + { contractId: "C_USDC", label: "USDC" }, + ], + expectedAmountOutStroops: "1000", + source: "router_simulation", + slippageApplied: 0.005, + amountOutAfterSlippage: "995", + quotedAt: "2026-01-01T00:00:00.000Z", + minAmountOutStroops: "995", + quoteAgeMs: 0, + isFallback: false, + }; + }); + + const result = await recommendDepositRouting( + [{ symbol: "XLM", amountInStroops: "1000000" }], + { quoteProtocols: ["broken", "aquarius"] }, + ); + + expect(result.routes).toHaveLength(1); + expect(result.routes[0].protocol).toBe("aquarius"); + expect(result.routes[0].alternativeQuotes).toBeUndefined(); + }); + + it("warns and skips the asset when every protocol throws", async () => { + mockGetZapQuote.mockReset(); + mockGetZapQuote.mockRejectedValue(new Error("all rpcs down")); + + const result = await recommendDepositRouting( + [{ symbol: "XLM", amountInStroops: "1000000" }], + { quoteProtocols: ["broken", "also_broken"] }, + ); + + expect(result.routes).toHaveLength(0); + expect( + result.warnings.some((w) => + /Could not obtain a conversion quote for XLM/.test(w), + ), + ).toBe(true); + }); + + it("reads DEPOSIT_ROUTING_PROTOCOLS when no option is provided", async () => { + mockGetZapQuote.mockReset(); + const seen: (string | undefined)[] = []; + mockGetZapQuote.mockImplementation(async (body) => { + seen.push(body.protocol); + return { + path: [ + { contractId: "C_XLM", label: "XLM" }, + { contractId: "C_USDC", label: "USDC" }, + ], + expectedAmountOutStroops: "1000", + source: "router_simulation", + slippageApplied: 0.005, + amountOutAfterSlippage: "995", + quotedAt: "2026-01-01T00:00:00.000Z", + minAmountOutStroops: "995", + quoteAgeMs: 0, + isFallback: false, + }; + }); + + const prev = process.env.DEPOSIT_ROUTING_PROTOCOLS; + process.env.DEPOSIT_ROUTING_PROTOCOLS = "soroswap, aquarius ,phoenix"; + try { + await recommendDepositRouting([ + { symbol: "XLM", amountInStroops: "1000000" }, + ]); + } finally { + if (prev === undefined) delete process.env.DEPOSIT_ROUTING_PROTOCOLS; + else process.env.DEPOSIT_ROUTING_PROTOCOLS = prev; + } + + // Three quotes attempted, in declared order. + expect(seen).toEqual(["soroswap", "aquarius", "phoenix"]); + }); + + it("treats the literal 'default' protocol as undefined", async () => { + mockGetZapQuote.mockReset(); + const seen: (string | undefined)[] = []; + mockGetZapQuote.mockImplementation(async (body) => { + seen.push(body.protocol); + return { + path: [ + { contractId: "C_XLM", label: "XLM" }, + { contractId: "C_USDC", label: "USDC" }, + ], + expectedAmountOutStroops: "1000", + source: "router_simulation", + slippageApplied: 0.005, + amountOutAfterSlippage: "995", + quotedAt: "2026-01-01T00:00:00.000Z", + minAmountOutStroops: "995", + quoteAgeMs: 0, + isFallback: false, + }; + }); + + await recommendDepositRouting( + [{ symbol: "XLM", amountInStroops: "1000000" }], + { quoteProtocols: ["default"] }, + ); + expect(seen).toEqual([undefined]); + }); + }); + + // ── Additional coverage for the existing surface (toward 90%) ──────────── + + it("accepts symbols with extra whitespace", async () => { + const result = await recommendDepositRouting([ + { symbol: " xlm ", amountInStroops: "1000" }, + ]); + expect(result.unsupportedAssets).toHaveLength(0); + expect(result.routes[0].symbol).toBe("XLM"); + }); + + it("rejects assets with an empty symbol string", async () => { + const result = await recommendDepositRouting([ + { symbol: "", amountInStroops: "1000" }, + ]); + expect(result.routes).toHaveLength(0); + expect(result.unsupportedAssets).toHaveLength(1); + }); + + it("treats non-numeric amountInStroops as 0 in the sum without crashing", async () => { + mockGetZapQuote.mockResolvedValueOnce({ + path: [ + { contractId: "C_XLM", label: "XLM" }, + { contractId: "C_USDC", label: "USDC" }, + ], + expectedAmountOutStroops: "not-a-number", + source: "router_simulation", + slippageApplied: 0.005, + amountOutAfterSlippage: "not-a-number", + quotedAt: "2026-01-01T00:00:00.000Z", + minAmountOutStroops: "not-a-number", + quoteAgeMs: 0, + isFallback: false, + }); + const result = await recommendDepositRouting([ + { symbol: "XLM", amountInStroops: "1000000" }, + ]); + expect(result.totals.expectedVaultAmountStroops).toBe("0"); + }); + + it("returns an empty result with a single warning for an empty basket", async () => { + const result = await recommendDepositRouting([]); + expect(result.routes).toHaveLength(0); + expect(result.unsupportedAssets).toHaveLength(0); + expect(result.totals.routableAssets).toBe(0); + expect(result.warnings).toContain( + "No supported assets in request; nothing to route.", + ); + }); + + it("stamps generatedAt as an ISO 8601 string", async () => { + const result = await recommendDepositRouting([ + { symbol: "USDC", amountInStroops: "1000" }, + ]); + // Round-trip through Date — invalid ISO would produce NaN. + expect(Number.isNaN(Date.parse(result.generatedAt))).toBe(false); + }); }); diff --git a/server/src/services/depositRoutingService.ts b/server/src/services/depositRoutingService.ts index 5a0a12d38..c1b96e6a1 100644 --- a/server/src/services/depositRoutingService.ts +++ b/server/src/services/depositRoutingService.ts @@ -2,7 +2,7 @@ import { getZapSupportedAssetsPayload, type ZapAssetPublic, } from "../config/zapAssetsConfig"; -import { getZapQuote } from "./zapQuote"; +import { getZapQuote, type ZapQuoteResult } from "./zapQuote"; import { getFeeOracleEstimate } from "./feeOracleService"; /** @@ -17,6 +17,12 @@ import { getFeeOracleEstimate } from "./feeOracleService"; * aggregate totals, an estimated network fee, and clear unsupported-asset * warnings — satisfying the issue's acceptance criteria for route reasoning, * expected fees, and unsupported-asset handling. + * + * Follow-up to PR #486 (multi-pool path optimisation): when more than one + * candidate protocol is supplied (or the `DEPOSIT_ROUTING_PROTOCOLS` env var + * lists multiple), the service quotes every candidate, picks the route whose + * `amountOutAfterSlippage` is highest, and exposes the alternatives so callers + * can show the optimisation savings. */ export interface DepositAssetInput { @@ -28,6 +34,14 @@ export interface DepositAssetInput { export type DepositRouteAction = "direct" | "convert"; +export interface DepositRouteAlternative { + protocol: string; + expectedVaultAmountStroops: string; + slippageApplied: number; + source: ZapQuoteResult["source"]; + isFallback: boolean; +} + export interface DepositRouteRecommendation { symbol: string; amountInStroops: string; @@ -39,6 +53,10 @@ export interface DepositRouteRecommendation { slippageApplied: number; source: string; reasoning: string; + /** Protocol that produced the chosen quote, if known. */ + protocol?: string; + /** Quotes that lost to the winner, in descending output order. */ + alternativeQuotes?: DepositRouteAlternative[]; } export interface UnsupportedAssetWarning { @@ -62,12 +80,86 @@ export interface DepositRoutingResult { generatedAt: string; } +export interface RecommendDepositRoutingOptions { + /** + * Candidate protocols to quote for each non-vault asset. If multiple are + * supplied, the route with the highest `amountOutAfterSlippage` wins and the + * losing quotes appear in `alternativeQuotes`. Falls back to + * `DEPOSIT_ROUTING_PROTOCOLS` (comma-separated) or `["default"]` when unset. + */ + quoteProtocols?: string[]; +} + function sumStroops(values: string[]): string { return values .reduce((acc, v) => acc + (/^\d+$/.test(v) ? BigInt(v) : BigInt(0)), BigInt(0)) .toString(); } +function resolveQuoteProtocols(opt?: RecommendDepositRoutingOptions): string[] { + if (opt?.quoteProtocols && opt.quoteProtocols.length > 0) { + return opt.quoteProtocols; + } + const env = process.env.DEPOSIT_ROUTING_PROTOCOLS?.trim(); + if (env) { + const protocols = env + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (protocols.length > 0) return protocols; + } + return ["default"]; +} + +interface CandidateQuote { + protocol: string; + quote: ZapQuoteResult; +} + +async function quoteAcrossProtocols( + asset: ZapAssetPublic, + vaultToken: ZapAssetPublic, + amountInStroops: string, + protocols: string[], +): Promise { + const results: CandidateQuote[] = []; + for (const protocol of protocols) { + try { + const quote = await getZapQuote({ + inputTokenContract: asset.contractId, + vaultTokenContract: vaultToken.contractId, + amountInStroops, + inputDecimals: asset.decimals, + vaultDecimals: vaultToken.decimals, + protocol: protocol === "default" ? undefined : protocol, + }); + results.push({ protocol, quote }); + } catch { + // Skip this protocol on failure; another may still produce a quote. + } + } + return results; +} + +function pickBest(candidates: CandidateQuote[]): { + winner: CandidateQuote; + losers: CandidateQuote[]; +} | null { + if (candidates.length === 0) return null; + const sorted = candidates + .slice() + .sort((a, b) => + BigInt(b.quote.amountOutAfterSlippage) > + BigInt(a.quote.amountOutAfterSlippage) + ? 1 + : BigInt(b.quote.amountOutAfterSlippage) < + BigInt(a.quote.amountOutAfterSlippage) + ? -1 + : 0, + ); + return { winner: sorted[0], losers: sorted.slice(1) }; +} + /** * Compute a deposit routing recommendation for a multi-asset basket. * @@ -75,10 +167,12 @@ function sumStroops(values: string[]): string { * fee services, which keeps it straightforward to unit test. */ export async function recommendDepositRouting( - inputs: DepositAssetInput[] + inputs: DepositAssetInput[], + options?: RecommendDepositRoutingOptions, ): Promise { const payload = getZapSupportedAssetsPayload(); const vaultToken = payload.vaultToken; + const quoteProtocols = resolveQuoteProtocols(options); // Case-insensitive symbol lookup over supported assets + the vault token. const bySymbol = new Map(); @@ -119,24 +213,49 @@ export async function recommendDepositRouting( continue; } - const quote = await getZapQuote({ - inputTokenContract: asset.contractId, - vaultTokenContract: vaultToken.contractId, - amountInStroops: input.amountInStroops, - inputDecimals: asset.decimals, - vaultDecimals: vaultToken.decimals, - }); + const candidates = await quoteAcrossProtocols( + asset, + vaultToken, + input.amountInStroops, + quoteProtocols, + ); + const picked = pickBest(candidates); + if (!picked) { + warnings.push( + `Could not obtain a conversion quote for ${asset.symbol} across protocols [${quoteProtocols.join(", ")}]; asset skipped.`, + ); + continue; + } + conversions += 1; + const { winner, losers } = picked; + const alternatives: DepositRouteAlternative[] = losers.map((l) => ({ + protocol: l.protocol, + expectedVaultAmountStroops: l.quote.amountOutAfterSlippage, + slippageApplied: l.quote.slippageApplied, + source: l.quote.source, + isFallback: l.quote.isFallback, + })); + + const protocolLabel = + winner.protocol === "default" ? winner.quote.source : winner.protocol; + const reasoningPrefix = `${asset.symbol} converted to ${vaultToken.symbol} via a ${winner.quote.path.length}-hop route (source: ${winner.quote.source}, slippage ${winner.quote.slippageApplied}).`; + const optimisationNote = + alternatives.length > 0 + ? ` Selected ${protocolLabel} over ${alternatives.length} alternative quote(s) on output amount.` + : ""; routes.push({ symbol: asset.symbol, amountInStroops: input.amountInStroops, action: "convert", - path: quote.path, - expectedVaultAmountStroops: quote.amountOutAfterSlippage, - slippageApplied: quote.slippageApplied, - source: quote.source, - reasoning: `${asset.symbol} converted to ${vaultToken.symbol} via a ${quote.path.length}-hop route (source: ${quote.source}, slippage ${quote.slippageApplied}).`, + path: winner.quote.path, + expectedVaultAmountStroops: winner.quote.amountOutAfterSlippage, + slippageApplied: winner.quote.slippageApplied, + source: winner.quote.source, + reasoning: reasoningPrefix + optimisationNote, + protocol: winner.protocol, + alternativeQuotes: alternatives.length > 0 ? alternatives : undefined, }); }