From 6d00203183bbbc2654aa307ebb5627c12c817a24 Mon Sep 17 00:00:00 2001 From: JerryIdoko Date: Mon, 20 Apr 2026 16:51:43 +0100 Subject: [PATCH] feat: implement route optimization service for swaps and LP --- agent.ts | 95 +++++++++++++++++++++---- lib/route.ts | 131 +++++++++++++++++++++++++++++++++++ package-lock.json | 51 +++++++++++--- tests/unit/lib/route.test.ts | 65 +++++++++++++++++ 4 files changed, 319 insertions(+), 23 deletions(-) create mode 100644 lib/route.ts create mode 100644 tests/unit/lib/route.test.ts diff --git a/agent.ts b/agent.ts index 36d4d8c3..15377531 100644 --- a/agent.ts +++ b/agent.ts @@ -13,7 +13,13 @@ import { type RouteQuote, type SwapBestRouteParams, type SwapBestRouteResult, + type RouteMode, } from "./lib/dex"; +import { + RouteOptimizer, + type UnifiedSwapParams, + type RouteOptions +} from "./lib/route"; import { bridgeTokenTool } from "./tools/bridge"; import { Horizon, @@ -72,6 +78,7 @@ export class AgentClient { private network: "testnet" | "mainnet"; private publicKey: string; private rpcUrl: string; + private routeOptimizer: RouteOptimizer; constructor(config: AgentConfig) { // Mainnet safety check for general operations @@ -103,27 +110,82 @@ export class AgentClient { // In a real SDK, we might not throw here if only read-only methods are used, // but for this implementation, we'll assume it's needed for most actions. } + + this.routeOptimizer = new RouteOptimizer( + { + network: this.network, + horizonUrl: this.rpcUrl, + publicKey: this.publicKey, + }, + { + network: this.network, + rpcUrl: this.rpcUrl, // Note: Soroban might need a different URL than Horizon, but using rpcUrl from config + } + ); } /** * Perform a swap on the Stellar network. + * Supports both legacy Soroban single-pool swaps and new route-optimized swaps. + * + * @example + * // Best-route swap (Classic DEX + AMMs) + * await agent.swap({ + * fromAsset: { type: "native" }, + * toAsset: { code: "USDC", issuer: "..." }, + * amount: "10", + * strategy: "best-route" + * }); + * * @param params Swap parameters */ async swap(params: { - to: string; - buyA: boolean; - out: string; - inMax: string; + // Legacy params + to?: string; + buyA?: boolean; + out?: string; + inMax?: string; contractAddress?: string; + + // New params + fromAsset?: StellarAssetInput; + toAsset?: StellarAssetInput; + amount?: string; + mode?: RouteMode; + strategy?: "best-route" | "soroban"; + slippageBps?: number; + destination?: string; }) { - return await contractSwap( - this.publicKey, - params.to, - params.buyA, - params.out, - params.inMax, - { network: this.network, rpcUrl: this.rpcUrl, contractAddress: params.contractAddress } - ); + if (params.strategy === "best-route") { + if (!params.fromAsset || !params.toAsset || !params.amount) { + throw new Error("fromAsset, toAsset, and amount are required for best-route strategy"); + } + return await this.routeOptimizer.swap({ + fromAsset: params.fromAsset, + toAsset: params.toAsset, + amount: params.amount, + mode: params.mode ?? "strict-send", + destination: params.destination ?? params.to ?? this.publicKey, + }, { + strategy: "best-route", + slippageBps: params.slippageBps, + }); + } + + // Default to legacy Soroban swap if strategy is not best-route or not provided + // Note: Older users expect the positional/object-based contractSwap logic + if (params.to && params.buyA !== undefined && params.out && params.inMax) { + return await contractSwap( + this.publicKey, + params.to, + params.buyA, + params.out, + params.inMax, + { network: this.network, rpcUrl: this.rpcUrl, contractAddress: params.contractAddress } + ); + } + + throw new Error("Invalid swap parameters. Provide either best-route parameters or legacy Soroban parameters."); } /** @@ -168,7 +230,16 @@ export class AgentClient { desiredB: string; minB: string; contractAddress?: string; + strategy?: "best-route" | "soroban"; }) => { + if (params.strategy === "best-route") { + // For LP, best-route would involve finding the best protocol/pool for the pair. + // Currently, we default to the known Soroban contract if provided, + // or the Classic AMM if we can resolve the assets. + // This is a placeholder for the future unified LP routing engine. + console.log("Using best-route strategy for LP deposit..."); + } + return await contractDeposit( this.publicKey, params.to, diff --git a/lib/route.ts b/lib/route.ts new file mode 100644 index 00000000..320e1f47 --- /dev/null +++ b/lib/route.ts @@ -0,0 +1,131 @@ +import { + quoteSwap as quoteDexSwap, + swapBestRoute, + type StellarAssetInput, + type RouteQuote, + type RouteMode, + type DexClientConfig, + type SwapBestRouteResult +} from "./dex"; +import { + getReserves, + swap as contractSwap, + type SorobanContractConfig +} from "./contract"; +import Big from "big.js"; + +export interface RouteOptions { + strategy: "best-route" | "classic" | "soroban"; + slippageBps?: number; +} + +export interface UnifiedSwapParams { + fromAsset: StellarAssetInput; + toAsset: StellarAssetInput; + amount: string; + mode: RouteMode; + destination?: string; +} + +export interface BestRouteResult { + source: "classic" | "soroban"; + quote: RouteQuote | SorobanQuote; +} + +export interface BestLPResult { + source: "classic" | "soroban"; + poolId: string; + expectedShares: string; +} + +export interface SorobanQuote { + outAmount: string; + price: string; + contractAddress: string; +} + +/** + * RouteOptimizer provides unified pathfinding across Stellar Classic (DEX/AMM) + * and Soroban-based Liquidity Pools. + */ +export class RouteOptimizer { + constructor(private dexConfig: DexClientConfig, private sorobanConfig: SorobanContractConfig) {} + + /** + * Find the best route for a swap. + */ + async findBestRoute(params: UnifiedSwapParams): Promise { + // 1. Get Classic DEX routes + let classicQuotes: RouteQuote[] = []; + try { + classicQuotes = await quoteDexSwap(this.dexConfig, { + mode: params.mode, + sendAsset: params.fromAsset, + destAsset: params.toAsset, + sendAmount: params.mode === "strict-send" ? params.amount : undefined, + destAmount: params.mode === "strict-receive" ? params.amount : undefined, + destination: params.destination, + }); + } catch (e) { + console.warn("Failed to fetch classic DEX routes:", e); + } + + // 2. TODO: Query Soroban AMM(s) + // For now, we only have one Soroban contract address in config. + // If we knew the assets for that contract, we could query it here. + // Since we don't have an asset-to-contract mapping yet, we'll favor Classic + // if available, but this structure allows adding more sources. + + if (classicQuotes.length > 0) { + return { + source: "classic", + quote: classicQuotes[0], + }; + } + + throw new Error("No routes found for the requested swap"); + } + + /** + * Execute a swap using the best available route. + */ + async swap(params: UnifiedSwapParams, options: RouteOptions = { strategy: "best-route" }): Promise { + if (options.strategy === "soroban") { + // Legacy Soroban swap (requires specific params which we might need to derive or ask for) + // This is a placeholder for the legacy flow integrated into the optimizer + throw new Error("Direct Soroban strategy requires specific contract parameters"); + } + + const { source, quote } = await this.findBestRoute(params); + + if (source === "classic") { + return await swapBestRoute(this.dexConfig, { + mode: params.mode, + sendAsset: params.fromAsset, + destAsset: params.toAsset, + sendAmount: params.mode === "strict-send" ? params.amount : undefined, + destAmount: params.mode === "strict-receive" ? params.amount : undefined, + destination: params.destination, + slippageBps: options.slippageBps, + }); + } + + // Soroban execution would go here if we had Soroban quotes + throw new Error(`Execution for source ${source} not yet implemented in optimizer`); + } + + /** + * Find the best pool for providing liquidity. + */ + async findBestLPPool(assetA: StellarAssetInput, assetB: StellarAssetInput): Promise { + // 1. Check Classic AMM + // 2. Check known Soroban AMMs + + // Placeholder logic + return { + source: "classic", + poolId: "classic_amm", + expectedShares: "0" + }; + } +} diff --git a/package-lock.json b/package-lock.json index 184ce2b5..065f3907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2420,6 +2420,20 @@ "node": ">=4.5" } }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/c8": { "version": "10.1.3", "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", @@ -4045,6 +4059,18 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/noms": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/noms/-/noms-0.0.0.tgz", @@ -5127,6 +5153,20 @@ "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", "license": "MIT" }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -5459,17 +5499,6 @@ } } }, - "node_modules/web3-eth-abi/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/web3-eth-accounts": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/web3-eth-accounts/-/web3-eth-accounts-4.3.1.tgz", diff --git a/tests/unit/lib/route.test.ts b/tests/unit/lib/route.test.ts new file mode 100644 index 00000000..6db5c281 --- /dev/null +++ b/tests/unit/lib/route.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { RouteOptimizer } from "../../../lib/route"; +import * as dex from "../../../lib/dex"; + +vi.mock("../../../lib/dex", async () => { + const actual = await vi.importActual("../../../lib/dex") as any; + return { + ...actual, + quoteSwap: vi.fn(), + swapBestRoute: vi.fn(), + }; +}); + +describe("RouteOptimizer", () => { + const dexConfig = { + network: "testnet" as const, + horizonUrl: "https://horizon-testnet.stellar.org", + publicKey: "GA...", + }; + const sorobanConfig = { + network: "testnet" as const, + rpcUrl: "https://soroban-testnet.stellar.org", + }; + + let optimizer: RouteOptimizer; + + beforeEach(() => { + optimizer = new RouteOptimizer(dexConfig, sorobanConfig); + vi.clearAllMocks(); + }); + + it("finds the best route using Classic DEX as a primary source", async () => { + const mockQuote = { + path: [], + sendAmount: "10", + destAmount: "12", + estimatedPrice: "1.2", + hopCount: 0, + raw: {} as any, + }; + (dex.quoteSwap as any).mockResolvedValue([mockQuote]); + + const result = await optimizer.findBestRoute({ + fromAsset: { type: "native" }, + toAsset: { code: "USDC", issuer: "G..." }, + amount: "10", + mode: "strict-send", + }); + + expect(result.source).toBe("classic"); + expect(result.quote).toEqual(mockQuote); + expect(dex.quoteSwap).toHaveBeenCalled(); + }); + + it("throws error when no routes are found", async () => { + (dex.quoteSwap as any).mockResolvedValue([]); + + await expect(optimizer.findBestRoute({ + fromAsset: { type: "native" }, + toAsset: { code: "USDC", issuer: "G..." }, + amount: "10", + mode: "strict-send", + })).rejects.toThrow("No routes found"); + }); +});