diff --git a/docs/defi/aggregator-guide.md b/docs/defi/aggregator-guide.md index 34892b3..d8fa898 100644 --- a/docs/defi/aggregator-guide.md +++ b/docs/defi/aggregator-guide.md @@ -1,50 +1,55 @@ # DEX Aggregator Guide ## Overview -`DexAggregatorService` queries multiple liquidity sources (SDEX via Horizon, Soroswap AMM) simultaneously to find the best execution price for a given asset pair. It currently supports routing and price comparison, culminating in an unsigned XDR transaction for client-side signing. +`DexAggregatorService` in `@galaxy-kj/core-defi-protocols` queries Soroswap and SDEX simultaneously to find the best execution price for a given asset pair. It also supports explicit split execution, returning a route breakdown plus savings versus the best single venue. ## Routing Strategies and Quote Flow -When `getAggregatedQuote` is called: +When `getBestQuote` is called: 1. **Request:** The service takes an `assetIn`, `assetOut`, and `amountIn`. -2. **Route:** It concurrently queries all configured `LiquiditySource`s (`sdex`, `soroswap`). -3. **Selection:** Results are filtered and sorted best-first (highest `amountOut`), returning an `AggregatedQuote`. +2. **Route:** It concurrently queries Soroswap and SDEX. +3. **Split evaluation:** It evaluates default split candidates across both venues. +4. **Selection:** It returns the execution plan with the highest `totalAmountOut`. ### Quote Example ```typescript -import { DexAggregatorService } from '@galaxy-kj/core-defi'; +import { DexAggregatorService } from '@galaxy-kj/core-defi-protocols'; -const aggregator = new DexAggregatorService(horizonServer, soroswapConfig); +const aggregator = new DexAggregatorService(soroswapConfig); -const quote = await aggregator.getAggregatedQuote({ - assetIn: { code: 'XLM', type: 'native' }, - assetOut: { code: 'USDC', issuer: 'GA...', type: 'credit_alphanum4' }, - amountIn: '100', -}); +const quote = await aggregator.getBestQuote( + { code: 'XLM', type: 'native' }, + { code: 'USDC', issuer: 'GA...', type: 'credit_alphanum4' }, + '100' +); -console.log('Best source:', quote.bestRoute.source); -console.log('Expected out:', quote.bestRoute.amountOut); +console.log('Expected out:', quote.totalAmountOut); +console.log('Execution routes:', quote.routes); ``` -## Executing a Swap -From a quote, you can construct an unsigned transaction using `executeAggregatedSwap`. +## Explicit Split Quotes +Use `getSplitQuote` when you want to force an allocation across venues. ```typescript -const swapResult = await aggregator.executeAggregatedSwap({ - signerPublicKey: 'GA...', - assetIn: { code: 'XLM', type: 'native' }, - assetOut: { code: 'USDC', issuer: 'GA...', type: 'credit_alphanum4' }, - amountIn: '100', - minAmountOut: '95', // Slippage protection -}); - -console.log('XDR to sign:', swapResult.xdr); +const quote = await aggregator.getSplitQuote( + { code: 'XLM', type: 'native' }, + { code: 'USDC', issuer: 'GA...', type: 'credit_alphanum4' }, + '100', + [60, 40] +); + +console.log(quote.routes); ``` -### Smart Wallet Integration -The resulting `xdr` is left unsigned by design. It can be passed to the Smart Wallet integration (via passkey signatures) to authorize the swap transaction safely on behalf of the user. +## REST API + +`GET /api/v1/defi/aggregator/quote?assetIn=XLM&assetOut=USDC:GA...&amountIn=100` + +Optionally, force a split: + +`GET /api/v1/defi/aggregator/quote?assetIn=XLM&assetOut=USDC:GA...&amountIn=100&splits=60,40` ## Error Handling The aggregator manages several constraints: -- **Price Impact:** `highImpactWarning` is flagged if the chosen route has a price impact >= 5%. -- **Slippage Exceeded:** Controlled by passing `minAmountOut` during execution. If the actual return is lower, the transaction fails on-chain. -- **Insufficient Liquidity / Network Errors:** Individual source quote failures are swallowed during aggregation, allowing the service to fallback to the remaining functioning sources. If no sources succeed, it throws an error. +- **Price Impact:** Soroswap price impact is estimated from the constant-product reserve curve. +- **Split validation:** Split weights must contain exactly two non-negative values, in `[soroswap, sdex]` order. +- **Insufficient Liquidity / Network Errors:** Individual source failures are tolerated during best-route discovery when another venue still returns a quote. If no venue succeeds, the service throws an error. diff --git a/packages/api/rest/src/routes/defi.routes.test.ts b/packages/api/rest/src/routes/defi.routes.test.ts index db7262c..902aa15 100644 --- a/packages/api/rest/src/routes/defi.routes.test.ts +++ b/packages/api/rest/src/routes/defi.routes.test.ts @@ -1,13 +1,54 @@ import request from 'supertest'; import express from 'express'; import { setupDefiRoutes } from './defi.routes'; -import { ProtocolFactory } from '@galaxy-kj/core-defi-protocols'; +import { DexAggregatorService } from '@galaxy-kj/core-defi-protocols'; // Mock ProtocolFactory and the protocols jest.mock('@galaxy-kj/core-defi-protocols', () => { const originalModule = jest.requireActual('@galaxy-kj/core-defi-protocols'); return { ...originalModule, + DexAggregatorService: jest.fn().mockImplementation(() => ({ + getBestQuote: jest.fn().mockResolvedValue({ + assetIn: { code: 'XLM', type: 'native' }, + assetOut: { code: 'USDC', issuer: 'GA5Z...', type: 'credit_alphanum4' }, + amountIn: '100', + routes: [{ + venue: 'soroswap', + amountIn: '100', + amountOut: '98', + priceImpact: 0.5, + path: ['native', 'USDC:GA5Z...'] + }], + totalAmountOut: '98', + effectivePrice: 0.98, + savingsVsBestSingle: 0 + }), + getSplitQuote: jest.fn().mockResolvedValue({ + assetIn: { code: 'XLM', type: 'native' }, + assetOut: { code: 'USDC', issuer: 'GA5Z...', type: 'credit_alphanum4' }, + amountIn: '100', + routes: [ + { + venue: 'soroswap', + amountIn: '60', + amountOut: '59', + priceImpact: 0.4, + path: ['native', 'USDC:GA5Z...'] + }, + { + venue: 'sdex', + amountIn: '40', + amountOut: '40', + priceImpact: 0, + path: [] + } + ], + totalAmountOut: '99', + effectivePrice: 0.99, + savingsVsBestSingle: 1.02 + }) + })), ProtocolFactory: { getInstance: jest.fn().mockReturnValue({ createProtocol: jest.fn().mockImplementation((config) => { @@ -108,6 +149,37 @@ describe('DeFi Routes', () => { }); }); + describe('Aggregator Routes', () => { + it('GET /api/v1/defi/aggregator/quote should return best quote', async () => { + const response = await request(app) + .get('/api/v1/defi/aggregator/quote') + .query({ assetIn: 'XLM', assetOut: 'USDC', amountIn: '100' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('totalAmountOut', '98'); + expect(DexAggregatorService).toHaveBeenCalled(); + }); + + it('GET /api/v1/defi/aggregator/quote should support explicit splits', async () => { + const response = await request(app) + .get('/api/v1/defi/aggregator/quote') + .query({ assetIn: 'XLM', assetOut: 'USDC', amountIn: '100', splits: '60,40' }); + + expect(response.status).toBe(200); + expect(response.body.routes).toHaveLength(2); + expect(response.body.totalAmountOut).toBe('99'); + }); + + it('GET /api/v1/defi/aggregator/quote should validate malformed splits', async () => { + const response = await request(app) + .get('/api/v1/defi/aggregator/quote') + .query({ assetIn: 'XLM', assetOut: 'USDC', amountIn: '100', splits: '100' }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe('VALIDATION_ERROR'); + }); + }); + describe('Blend Routes', () => { it('GET /api/v1/defi/blend/position/:publicKey should return position', async () => { const response = await request(app) diff --git a/packages/api/rest/src/routes/defi.routes.ts b/packages/api/rest/src/routes/defi.routes.ts index 704655e..6b70471 100644 --- a/packages/api/rest/src/routes/defi.routes.ts +++ b/packages/api/rest/src/routes/defi.routes.ts @@ -6,7 +6,7 @@ import express, { Request, Response, NextFunction } from 'express'; import { authenticate } from '../middleware/auth'; -import { ProtocolFactory, ProtocolConfig, Asset, SwapQuote } from '@galaxy-kj/core-defi-protocols'; +import { ProtocolFactory, ProtocolConfig, Asset, DexAggregatorService } from '@galaxy-kj/core-defi-protocols'; // Default configuration for protocols (can be moved to a config file or env vars) const defaultConfig: Omit = { @@ -53,6 +53,25 @@ function parseAsset(assetStr: string): Asset { }; } +function parseSplits(value: Request['query']['splits']): number[] | null { + if (!value) { + return null; + } + + const raw = Array.isArray(value) ? value.join(',') : String(value); + const splits = raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => Number(item)); + + if (splits.length !== 2 || splits.some((split) => !Number.isFinite(split) || split < 0)) { + throw new Error('splits must contain exactly two non-negative numeric weights'); + } + + return splits; +} + export function setupDefiRoutes(): express.Router { const router = express.Router(); @@ -95,6 +114,50 @@ export function setupDefiRoutes(): express.Router { } }); + /** + * @route GET /api/v1/defi/aggregator/quote + * @description Get the best aggregated quote across Soroswap and SDEX + */ + router.get('/aggregator/quote', async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { assetIn, assetOut, amountIn, splits } = req.query; + + if (!assetIn || !assetOut || !amountIn) { + res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: 'assetIn, assetOut, and amountIn are required query parameters', + details: {}, + }, + }); + return; + } + + const tokenIn = parseAsset(assetIn as string); + const tokenOut = parseAsset(assetOut as string); + const aggregator = new DexAggregatorService({ ...defaultConfig, protocolId: 'soroswap' }); + const parsedSplits = parseSplits(splits); + const quote = parsedSplits + ? await aggregator.getSplitQuote(tokenIn, tokenOut, amountIn as string, parsedSplits) + : await aggregator.getBestQuote(tokenIn, tokenOut, amountIn as string); + + res.json(quote); + } catch (error) { + if (error instanceof Error && error.message.includes('splits')) { + res.status(400).json({ + error: { + code: 'VALIDATION_ERROR', + message: error.message, + details: {}, + }, + }); + return; + } + + next(error); + } + }); + // Route: Soroswap swap // POST /api/v1/defi/swap /** diff --git a/packages/core/defi-protocols/README.md b/packages/core/defi-protocols/README.md index 80ed02b..30a06ff 100644 --- a/packages/core/defi-protocols/README.md +++ b/packages/core/defi-protocols/README.md @@ -219,6 +219,38 @@ try { ### Main Methods +#### DexAggregatorService + +`DexAggregatorService` compares Soroswap and SDEX quotes and can evaluate explicit split execution across both venues. + +```typescript +import { DexAggregatorService } from '@galaxy-kj/core-defi-protocols'; + +const aggregator = new DexAggregatorService({ + protocolId: 'soroswap', + name: 'Soroswap', + network: TESTNET_CONFIG, + contractAddresses: { + router: 'CA_ROUTER', + factory: 'CA_FACTORY', + }, + metadata: {}, +}); + +const bestQuote = await aggregator.getBestQuote( + { code: 'XLM', type: 'native' }, + { code: 'USDC', issuer: 'GAUS...', type: 'credit_alphanum4' }, + '100' +); + +const splitQuote = await aggregator.getSplitQuote( + { code: 'XLM', type: 'native' }, + { code: 'USDC', issuer: 'GAUS...', type: 'credit_alphanum4' }, + '100', + [60, 40] +); +``` + #### initialize() Initialize protocol connection and validate configuration. diff --git a/packages/core/defi-protocols/__tests__/aggregator/DexAggregator.test.ts b/packages/core/defi-protocols/__tests__/aggregator/DexAggregator.test.ts new file mode 100644 index 0000000..505891e --- /dev/null +++ b/packages/core/defi-protocols/__tests__/aggregator/DexAggregator.test.ts @@ -0,0 +1,458 @@ +import { DexAggregatorService } from '../../src/aggregator/DexAggregatorService'; +import { Asset, ProtocolConfig } from '../../src/types/defi-types'; + +const XLM: Asset = { code: 'XLM', type: 'native' }; +const USDC: Asset = { + code: 'USDC', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + type: 'credit_alphanum4', +}; + +const config: ProtocolConfig = { + protocolId: 'soroswap', + name: 'Soroswap', + network: { + network: 'testnet', + horizonUrl: 'https://horizon-testnet.stellar.org', + sorobanRpcUrl: 'https://soroban-testnet.stellar.org', + passphrase: 'Test SDF Network ; September 2015', + }, + contractAddresses: { + router: 'CA_ROUTER_MOCK', + factory: 'CA_FACTORY_MOCK', + }, + metadata: {}, +}; + +describe('DexAggregatorService', () => { + const protocolFactory = { + createProtocol: jest.fn(), + }; + + const horizonServer = { + serverURL: 'https://horizon-testnet.stellar.org', + }; + + const fetchImpl = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + protocolFactory.createProtocol.mockReturnValue({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockImplementation((_assetIn, _assetOut, amountIn: string) => ({ + tokenIn: XLM, + tokenOut: USDC, + amountIn, + amountOut: amountIn === '100' ? '95.0000000' : '8.0000000', + priceImpact: '0.5', + minimumReceived: '0', + path: ['native', 'USDC:GA5Z...'], + validUntil: new Date(), + })), + }); + + fetchImpl.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + const amount = new URL(url).searchParams.get('source_amount'); + const destinationAmount = amount === '100' ? '93.5000000' : '84.0000000'; + + return { + ok: true, + json: async () => ({ + _embedded: { + records: [ + { + destination_amount: destinationAmount, + path: [], + }, + ], + }, + }), + } as Response; + }); + }); + + it('returns the better single-venue quote when no split improves execution', async () => { + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const quote = await aggregator.getBestQuote(XLM, USDC, '100'); + + expect(quote.routes).toHaveLength(1); + expect(quote.routes[0].venue).toBe('soroswap'); + expect(quote.totalAmountOut).toBe('95.0000000'); + expect(quote.savingsVsBestSingle).toBe(0); + }); + + it('returns a split quote with both route legs and savings against the best single venue', async () => { + protocolFactory.createProtocol.mockReturnValue({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockImplementation((_assetIn, _assetOut, amountIn: string) => ({ + tokenIn: XLM, + tokenOut: USDC, + amountIn, + amountOut: amountIn === '60.0000000' ? '61.0000000' : '95.0000000', + priceImpact: '0.5', + minimumReceived: '0', + path: ['native', 'USDC:GA5Z...'], + validUntil: new Date(), + })), + }); + + fetchImpl.mockImplementation(async () => ({ + ok: true, + json: async () => ({ + _embedded: { + records: [ + { + destination_amount: '41.5000000', + path: [], + }, + ], + }, + }), + })); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const quote = await aggregator.getSplitQuote(XLM, USDC, '100', [60, 40]); + + expect(quote.routes).toHaveLength(2); + expect(quote.routes.map((route) => route.venue)).toEqual(['soroswap', 'sdex']); + expect(quote.totalAmountOut).toBe('102.5000000'); + expect(quote.savingsVsBestSingle).toBeGreaterThan(0); + }); + + it('allows getBestQuote to recommend a better split automatically', async () => { + protocolFactory.createProtocol.mockReturnValue({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockImplementation((_assetIn, _assetOut, amountIn: string) => ({ + tokenIn: XLM, + tokenOut: USDC, + amountIn, + amountOut: + amountIn === '100' + ? '95.0000000' + : amountIn === '40.0000000' + ? '45.0000000' + : '8.0000000', + priceImpact: '0.5', + minimumReceived: '0', + path: ['native', 'USDC:GA5Z...'], + validUntil: new Date(), + })), + }); + + fetchImpl.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + const amount = new URL(url).searchParams.get('source_amount'); + const destinationAmount = amount === '60.0000000' ? '57.0000000' : '93.5000000'; + + return { + ok: true, + json: async () => ({ + _embedded: { + records: [ + { + destination_amount: destinationAmount, + path: [], + }, + ], + }, + }), + } as Response; + }); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const quote = await aggregator.getBestQuote(XLM, USDC, '100'); + + expect(quote.routes).toHaveLength(2); + expect(quote.totalAmountOut).toBe('102.0000000'); + expect(quote.savingsVsBestSingle).toBeGreaterThan(0); + }); + + it('rejects invalid split weights', async () => { + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getSplitQuote(XLM, USDC, '100', [100])).rejects.toThrow( + 'Split quotes require exactly two weights' + ); + }); + + it('throws when no venue can produce a quote', async () => { + protocolFactory.createProtocol.mockReturnValue({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockRejectedValue(new Error('No Soroswap pool')), + }); + + fetchImpl.mockImplementation(async () => ({ + ok: true, + json: async () => ({ + _embedded: { + records: [], + }, + }), + })); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getBestQuote(XLM, USDC, '100')).rejects.toThrow( + 'No aggregator routes are available' + ); + }); + + it('returns the surviving venue when the other venue fails during best-quote discovery', async () => { + fetchImpl.mockRejectedValue(new Error('SDEX unavailable')); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const quote = await aggregator.getBestQuote(XLM, USDC, '100'); + + expect(quote.routes).toHaveLength(1); + expect(quote.routes[0].venue).toBe('soroswap'); + }); + + it('skips zero-allocation legs in split quotes', async () => { + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const quote = await aggregator.getSplitQuote(XLM, USDC, '100', [100, 0]); + + expect(quote.routes).toHaveLength(1); + expect(quote.routes[0].venue).toBe('soroswap'); + }); + + it('rejects negative split weights', async () => { + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getSplitQuote(XLM, USDC, '100', [50, -50])).rejects.toThrow( + 'Split weights must be finite positive numbers' + ); + }); + + it('rejects zero-valued split weights', async () => { + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getSplitQuote(XLM, USDC, '100', [0, 0])).rejects.toThrow( + 'Split weights must add up to more than zero' + ); + }); + + it('rejects invalid assets and amounts', async () => { + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect( + aggregator.getBestQuote({ code: '', type: 'native' }, USDC, '100') + ).rejects.toThrow('Asset code is required'); + await expect( + aggregator.getBestQuote({ code: 'USDC', type: 'credit_alphanum4' }, XLM, '100') + ).rejects.toThrow('Issuer is required for asset USDC'); + await expect(aggregator.getBestQuote(XLM, USDC, '0')).rejects.toThrow( + 'Amount must be a positive number' + ); + }); + + it('throws when the Soroswap protocol does not expose getSwapQuote', async () => { + protocolFactory.createProtocol.mockReturnValue({ + initialize: jest.fn().mockResolvedValue(undefined), + }); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getSplitQuote(XLM, USDC, '100', [100, 0])).rejects.toThrow( + 'Soroswap protocol does not implement getSwapQuote' + ); + }); + + it('throws when SDEX returns a non-success response', async () => { + fetchImpl.mockResolvedValue({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + }); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getSplitQuote(XLM, USDC, '100', [0, 100])).rejects.toThrow( + 'SDEX quote failed with 503 Service Unavailable' + ); + }); + + it('throws when SDEX returns no viable paths for the requested split', async () => { + fetchImpl.mockResolvedValue({ + ok: true, + json: async () => ({ records: [] }), + }); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + await expect(aggregator.getSplitQuote(XLM, USDC, '100', [0, 100])).rejects.toThrow( + 'SDEX did not return a viable path' + ); + }); + + it('maps non-native SDEX hops and normalizes priceImpact fallbacks', async () => { + protocolFactory.createProtocol.mockReturnValue({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockResolvedValue({ + tokenIn: XLM, + tokenOut: USDC, + amountIn: '100', + amountOut: '94.0000000', + priceImpact: 'not-a-number', + minimumReceived: '0', + path: [], + validUntil: new Date(), + }), + }); + + fetchImpl.mockResolvedValue({ + ok: true, + json: async () => ({ + records: [ + { + destination_amount: '96.0000000', + path: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'AQUA', + asset_issuer: 'GAQUA', + }, + ], + }, + ], + }), + }); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer: { serverURL: new URL('https://horizon-testnet.stellar.org') }, + protocolFactory, + }); + + const quote = await aggregator.getSplitQuote(XLM, USDC, '100', [0, 100]); + + expect(quote.routes[0].venue).toBe('sdex'); + expect(quote.routes[0].path).toEqual(['AQUA:GAQUA']); + expect(quote.savingsVsBestSingle).toBe(0); + }); + + it('picks the best SDEX record when multiple paths are returned', async () => { + fetchImpl.mockResolvedValue({ + ok: true, + json: async () => ({ + records: [ + { destination_amount: '92.0000000', path: [] }, + { destination_amount: '97.0000000', path: [] }, + ], + }), + }); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const quote = await aggregator.getSplitQuote(XLM, USDC, '100', [0, 100]); + + expect(quote.routes[0].venue).toBe('sdex'); + expect(quote.totalAmountOut).toBe('97.0000000'); + }); + + it('handles numeric and undefined Soroswap price impacts', async () => { + protocolFactory.createProtocol + .mockReturnValueOnce({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockResolvedValue({ + tokenIn: XLM, + tokenOut: USDC, + amountIn: '100', + amountOut: '99.0000000', + priceImpact: 1.25, + minimumReceived: '0', + path: [], + validUntil: new Date(), + }), + }) + .mockReturnValueOnce({ + initialize: jest.fn().mockResolvedValue(undefined), + getSwapQuote: jest.fn().mockResolvedValue({ + tokenIn: XLM, + tokenOut: USDC, + amountIn: '100', + amountOut: '99.0000000', + priceImpact: undefined, + minimumReceived: '0', + path: [], + validUntil: new Date(), + }), + }); + + fetchImpl.mockRejectedValue(new Error('SDEX unavailable')); + + const aggregator = new DexAggregatorService(config, { + fetchImpl, + horizonServer, + protocolFactory, + }); + + const numericImpactQuote = await aggregator.getBestQuote(XLM, USDC, '100'); + const undefinedImpactQuote = await aggregator.getBestQuote(XLM, USDC, '100'); + + expect(numericImpactQuote.routes[0].priceImpact).toBe(1.25); + expect(undefinedImpactQuote.routes[0].priceImpact).toBe(0); + }); +}); diff --git a/packages/core/defi-protocols/__tests__/protocols/soroswap-protocol.test.ts b/packages/core/defi-protocols/__tests__/protocols/soroswap-protocol.test.ts index 880ded1..c75a93a 100644 --- a/packages/core/defi-protocols/__tests__/protocols/soroswap-protocol.test.ts +++ b/packages/core/defi-protocols/__tests__/protocols/soroswap-protocol.test.ts @@ -73,6 +73,7 @@ jest.mock('@stellar/stellar-sdk', () => { }, StrKey: { isValidEd25519PublicKey: jest.fn().mockReturnValue(true), + decodeContract: jest.fn().mockReturnValue(new Uint8Array()), }, Horizon: { Server: jest.fn(), @@ -463,6 +464,7 @@ describe('SoroswapProtocol', () => { expect(quote.tokenIn).toEqual(tokenA); expect(quote.tokenOut).toEqual(tokenB); expect(parseFloat(quote.amountOut)).toBeGreaterThan(0); + expect(parseFloat(quote.priceImpact)).toBeGreaterThan(0); expect(quote.path).toHaveLength(2); expect(quote.validUntil).toBeInstanceOf(Date); }); @@ -585,6 +587,21 @@ describe('SoroswapProtocol', () => { soroswapProtocol.swap('', testPrivateKey, tokenA, tokenB, '10', '9') ).rejects.toThrow(/Invalid wallet address/); }); + + it('should support contract addresses in swap source account fallback', async () => { + mockHorizonServer.loadAccount.mockRejectedValueOnce(new Error('Account not found')); + + const result = await soroswapProtocol.swap( + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + testPrivateKey, + tokenA, + tokenB, + '10', + '9' + ); + + expect(result.status).toBe('pending'); + }); }); describe('addLiquidity()', () => { @@ -656,6 +673,21 @@ describe('SoroswapProtocol', () => { uninitProtocol.addLiquidity(testAddress, testPrivateKey, tokenA, tokenB, '100', '200') ).rejects.toThrow(/not initialized/); }); + + it('should support contract addresses in addLiquidity source account fallback', async () => { + mockHorizonServer.loadAccount.mockRejectedValueOnce(new Error('Account not found')); + + const result = await soroswapProtocol.addLiquidity( + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + testPrivateKey, + tokenA, + tokenB, + '100', + '200' + ); + + expect(result.status).toBe('pending'); + }); }); // ========================================== @@ -739,6 +771,21 @@ describe('SoroswapProtocol', () => { uninitProtocol.removeLiquidity(testAddress, testPrivateKey, tokenA, tokenB, poolAddress, '50') ).rejects.toThrow(/not initialized/); }); + + it('should support contract addresses in removeLiquidity source account fallback', async () => { + mockHorizonServer.loadAccount.mockRejectedValueOnce(new Error('Account not found')); + + const result = await soroswapProtocol.removeLiquidity( + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + testPrivateKey, + tokenA, + tokenB, + poolAddress, + '50' + ); + + expect(result.status).toBe('pending'); + }); }); describe('getLiquidityPool()', () => { @@ -808,6 +855,14 @@ describe('SoroswapProtocol', () => { await expect(soroswapProtocol.getLiquidityPool(tokenA, tokenB)).rejects.toThrow('Contract error'); }); + + it('should throw if factory contract is missing', async () => { + (soroswapProtocol as any).factoryContract = null; + + await expect(soroswapProtocol.getLiquidityPool(tokenA, tokenB)).rejects.toThrow( + 'Factory contract not initialized' + ); + }); }); // ========================================== diff --git a/packages/core/defi-protocols/src/aggregator/DexAggregatorService.ts b/packages/core/defi-protocols/src/aggregator/DexAggregatorService.ts new file mode 100644 index 0000000..515e33a --- /dev/null +++ b/packages/core/defi-protocols/src/aggregator/DexAggregatorService.ts @@ -0,0 +1,347 @@ +import BigNumber from 'bignumber.js'; +import { Asset as StellarAsset, Horizon } from '@stellar/stellar-sdk'; + +import { Asset, ProtocolConfig, SwapQuote } from '../types/defi-types.js'; +import { ProtocolFactory } from '../services/protocol-factory.js'; +import { AggregatorQuote, AggregatorRoute, AggregatorVenue } from './types.js'; + +const DEFAULT_SPLIT_PERCENTAGES = [10, 20, 30, 40, 50, 60, 70, 80, 90]; +const DISPLAY_DECIMALS = 7; + +interface SoroswapQuoteProtocol { + initialize(): Promise; + getSwapQuote?(tokenIn: Asset, tokenOut: Asset, amountIn: string): Promise; +} + +interface AggregatorProtocolFactory { + createProtocol(config: ProtocolConfig): SoroswapQuoteProtocol; +} + +interface HorizonServerLike { + serverURL?: string | URL; +} + +interface DexAggregatorDependencies { + fetchImpl?: typeof fetch; + horizonServer?: HorizonServerLike; + protocolFactory?: AggregatorProtocolFactory; +} + +export class DexAggregatorService { + private readonly soroswapConfig: ProtocolConfig; + private readonly fetchImpl: typeof fetch; + private readonly horizonServer: HorizonServerLike; + private readonly protocolFactory: AggregatorProtocolFactory; + + constructor(config: ProtocolConfig, dependencies: DexAggregatorDependencies = {}) { + this.soroswapConfig = { + ...config, + protocolId: 'soroswap', + }; + this.fetchImpl = dependencies.fetchImpl ?? fetch; + this.horizonServer = + dependencies.horizonServer ?? + ((new Horizon.Server(this.soroswapConfig.network.horizonUrl) as unknown) as HorizonServerLike); + this.protocolFactory = dependencies.protocolFactory ?? ProtocolFactory.getInstance(); + } + + async getBestQuote(assetIn: Asset, assetOut: Asset, amountIn: string): Promise { + this.validateAsset(assetIn); + this.validateAsset(assetOut); + this.validateAmount(amountIn); + + const singleRoutes = await this.fetchSingleVenueRoutes(assetIn, assetOut, amountIn); + const bestSingleQuote = this.buildQuote(assetIn, assetOut, amountIn, [ + this.getBestRoute(singleRoutes), + ]); + + if (singleRoutes.length < 2) { + return bestSingleQuote; + } + + const splitQuotes = await Promise.all( + DEFAULT_SPLIT_PERCENTAGES.map(async (soroswapPercentage) => + this.getSplitQuote(assetIn, assetOut, amountIn, [soroswapPercentage, 100 - soroswapPercentage]) + ) + ); + + return splitQuotes.reduce( + (best, candidate) => + this.compareAmount(candidate.totalAmountOut, best.totalAmountOut) > 0 ? candidate : best, + bestSingleQuote + ); + } + + async getSplitQuote( + assetIn: Asset, + assetOut: Asset, + amountIn: string, + splits: number[] + ): Promise { + this.validateAsset(assetIn); + this.validateAsset(assetOut); + this.validateAmount(amountIn); + + const normalizedSplits = this.normalizeSplits(splits); + const allocations = this.allocateAmounts(amountIn, normalizedSplits); + + const routes = ( + await Promise.all( + ( + [ + ['soroswap', allocations[0]], + ['sdex', allocations[1]], + ] as Array<[AggregatorVenue, string]> + ).map(async ([venue, allocatedAmount]) => { + if (new BigNumber(allocatedAmount).isZero()) { + return null; + } + + return this.fetchRouteFromVenue(venue, assetIn, assetOut, allocatedAmount); + }) + ) + ).filter((route): route is AggregatorRoute => route !== null); + + if (routes.length === 0) { + throw new Error('Split quote did not produce any executable routes'); + } + + const bestSingleRoutes = await this.fetchSingleVenueRoutes(assetIn, assetOut, amountIn); + const bestSingleRoute = this.getBestRoute(bestSingleRoutes); + + return this.buildQuote(assetIn, assetOut, amountIn, routes, bestSingleRoute.amountOut); + } + + private async fetchSingleVenueRoutes( + assetIn: Asset, + assetOut: Asset, + amountIn: string + ): Promise { + const settled = await Promise.allSettled([ + this.fetchRouteFromVenue('soroswap', assetIn, assetOut, amountIn), + this.fetchRouteFromVenue('sdex', assetIn, assetOut, amountIn), + ]); + + const routes = settled + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value); + + if (routes.length === 0) { + throw new Error('No aggregator routes are available for the requested swap'); + } + + return routes; + } + + private async fetchRouteFromVenue( + venue: AggregatorVenue, + assetIn: Asset, + assetOut: Asset, + amountIn: string + ): Promise { + if (venue === 'soroswap') { + return this.fetchSoroswapRoute(assetIn, assetOut, amountIn); + } + + return this.fetchSdexRoute(assetIn, assetOut, amountIn); + } + + private async fetchSoroswapRoute( + assetIn: Asset, + assetOut: Asset, + amountIn: string + ): Promise { + const protocol = this.protocolFactory.createProtocol(this.soroswapConfig); + await protocol.initialize(); + + if (typeof protocol.getSwapQuote !== 'function') { + throw new Error('Soroswap protocol does not implement getSwapQuote'); + } + + const quote = await protocol.getSwapQuote(assetIn, assetOut, amountIn); + + return { + venue: 'soroswap', + amountIn, + amountOut: quote.amountOut, + priceImpact: this.toNumber(quote.priceImpact), + path: quote.path ?? [], + }; + } + + private async fetchSdexRoute( + assetIn: Asset, + assetOut: Asset, + amountIn: string + ): Promise { + const base = this.horizonServer.serverURL; + const baseUrl = typeof base === 'string' ? base : base?.toString?.() ?? this.soroswapConfig.network.horizonUrl; + const assetInParams = this.toHorizonAssetParams(this.toStellarAsset(assetIn), 'source'); + const assetOutParams = this.toHorizonAssetParams(this.toStellarAsset(assetOut), 'destination'); + + const query = new URLSearchParams({ + ...assetInParams, + source_amount: amountIn, + ...assetOutParams, + limit: '10', + }); + + const response = await this.fetchImpl( + `${baseUrl.replace(/\/$/, '')}/paths/strict-send?${query.toString()}` + ); + + if (!response.ok) { + throw new Error(`SDEX quote failed with ${response.status} ${response.statusText}`); + } + + const payload = await response.json(); + const records: Array> = payload._embedded?.records ?? payload.records ?? []; + + if (records.length === 0) { + throw new Error('SDEX did not return a viable path'); + } + + const bestRecord = records.reduce((best, current) => + this.compareAmount(current.destination_amount, best.destination_amount) > 0 ? current : best + ); + + return { + venue: 'sdex', + amountIn, + amountOut: bestRecord.destination_amount, + priceImpact: 0, + path: (bestRecord.path ?? []).map((hop: Record) => + hop.asset_type === 'native' ? 'native' : `${hop.asset_code}:${hop.asset_issuer}` + ), + }; + } + + private buildQuote( + assetIn: Asset, + assetOut: Asset, + amountIn: string, + routes: AggregatorRoute[], + bestSingleAmountOut?: string + ): AggregatorQuote { + const totalAmountOut = routes + .reduce((total, route) => total.plus(route.amountOut), new BigNumber(0)) + .toFixed(DISPLAY_DECIMALS); + + const bestSingle = bestSingleAmountOut ?? this.getBestRoute(routes).amountOut; + const effectivePrice = new BigNumber(totalAmountOut) + .dividedBy(amountIn) + .decimalPlaces(DISPLAY_DECIMALS) + .toNumber(); + const savingsVsBestSingle = new BigNumber(bestSingle).isZero() + ? 0 + : new BigNumber(totalAmountOut) + .minus(bestSingle) + .dividedBy(bestSingle) + .multipliedBy(100) + .decimalPlaces(4) + .toNumber(); + + return { + assetIn, + assetOut, + amountIn, + routes, + totalAmountOut, + effectivePrice, + savingsVsBestSingle, + }; + } + + private getBestRoute(routes: AggregatorRoute[]): AggregatorRoute { + return routes.reduce((best, current) => + this.compareAmount(current.amountOut, best.amountOut) > 0 ? current : best + ); + } + + private allocateAmounts(amountIn: string, normalizedSplits: number[]): [string, string] { + const total = new BigNumber(amountIn); + const soroswapAmount = total + .multipliedBy(normalizedSplits[0]) + .dividedBy(100) + .decimalPlaces(DISPLAY_DECIMALS, BigNumber.ROUND_DOWN); + const sdexAmount = total.minus(soroswapAmount); + + return [soroswapAmount.toFixed(DISPLAY_DECIMALS), sdexAmount.toFixed(DISPLAY_DECIMALS)]; + } + + private normalizeSplits(splits: number[]): [number, number] { + if (splits.length !== 2) { + throw new Error('Split quotes require exactly two weights: [soroswap, sdex]'); + } + + if (splits.some((split) => !Number.isFinite(split) || split < 0)) { + throw new Error('Split weights must be finite positive numbers'); + } + + const totalWeight = splits[0] + splits[1]; + if (totalWeight <= 0) { + throw new Error('Split weights must add up to more than zero'); + } + + return [ + (splits[0] / totalWeight) * 100, + (splits[1] / totalWeight) * 100, + ]; + } + + private toStellarAsset(asset: Asset): StellarAsset { + if (asset.type === 'native') { + return StellarAsset.native(); + } + + return new StellarAsset(asset.code, asset.issuer!); + } + + private toHorizonAssetParams( + asset: StellarAsset, + prefix: 'source' | 'destination' + ): Record { + if (asset.isNative()) { + return { [`${prefix}_asset_type`]: 'native' }; + } + + return { + [`${prefix}_asset_type`]: 'credit_alphanum4', + [`${prefix}_asset_code`]: asset.getCode(), + [`${prefix}_asset_issuer`]: asset.getIssuer(), + }; + } + + private validateAsset(asset: Asset): void { + if (!asset.code) { + throw new Error('Asset code is required'); + } + + if (asset.type !== 'native' && !asset.issuer) { + throw new Error(`Issuer is required for asset ${asset.code}`); + } + } + + private validateAmount(amount: string): void { + if (!new BigNumber(amount).isFinite() || new BigNumber(amount).lte(0)) { + throw new Error('Amount must be a positive number'); + } + } + + private compareAmount(left: string, right: string): number { + return new BigNumber(left).comparedTo(right) ?? 0; + } + + private toNumber(value: string | number | undefined): number { + if (typeof value === 'number') { + return value; + } + + if (value === undefined) { + return 0; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } +} diff --git a/packages/core/defi-protocols/src/aggregator/types.ts b/packages/core/defi-protocols/src/aggregator/types.ts new file mode 100644 index 0000000..37e9f21 --- /dev/null +++ b/packages/core/defi-protocols/src/aggregator/types.ts @@ -0,0 +1,21 @@ +import { Asset } from '../types/defi-types.js'; + +export type AggregatorVenue = 'soroswap' | 'sdex'; + +export interface AggregatorRoute { + venue: AggregatorVenue; + amountIn: string; + amountOut: string; + priceImpact: number; + path: string[]; +} + +export interface AggregatorQuote { + assetIn: Asset; + assetOut: Asset; + amountIn: string; + routes: AggregatorRoute[]; + totalAmountOut: string; + effectivePrice: number; + savingsVsBestSingle: number; +} diff --git a/packages/core/defi-protocols/src/index.ts b/packages/core/defi-protocols/src/index.ts index 236834a..b2ef4c9 100644 --- a/packages/core/defi-protocols/src/index.ts +++ b/packages/core/defi-protocols/src/index.ts @@ -14,6 +14,10 @@ export * from './types/operations.js'; // Base Protocol export { BaseProtocol } from './protocols/base-protocol.js'; +// Aggregator +export { DexAggregatorService } from './aggregator/DexAggregatorService.js'; +export * from './aggregator/types.js'; + // Protocol Implementations export * from './protocols/blend/index.js'; export * from './protocols/soroswap/index.js'; diff --git a/packages/core/defi-protocols/src/protocols/soroswap/soroswap-protocol.ts b/packages/core/defi-protocols/src/protocols/soroswap/soroswap-protocol.ts index 9cb0332..7d67194 100644 --- a/packages/core/defi-protocols/src/protocols/soroswap/soroswap-protocol.ts +++ b/packages/core/defi-protocols/src/protocols/soroswap/soroswap-protocol.ts @@ -16,6 +16,7 @@ import { BASE_FEE, rpc } from '@stellar/stellar-sdk'; +import BigNumber from 'bignumber.js'; import { BaseProtocol } from '../base-protocol.js'; import { @@ -539,17 +540,28 @@ export class SoroswapProtocol extends BaseProtocol { if (!Array.isArray(amounts) || amounts.length < 2) { throw new Error('Unexpected return value from get_amounts_out'); } - + const amountOutRaw = amounts[1]; - const amountOut = (Number(amountOutRaw) / 1e7).toString(); - const minimumReceived = (parseFloat(amountOut) * (1 - SoroswapProtocol.SLIPPAGE_TOLERANCE)).toFixed(7); + const amountOut = new BigNumber(amountOutRaw.toString()).dividedBy(1e7).toFixed(7); + const minimumReceived = new BigNumber(amountOut) + .multipliedBy(1 - SoroswapProtocol.SLIPPAGE_TOLERANCE) + .toFixed(7); + const liquidityPool = await this.getLiquidityPool(tokenIn, tokenOut); + const reserveIn = new BigNumber(liquidityPool.reserveA).dividedBy(1e7); + const priceImpact = reserveIn.isZero() + ? '0' + : new BigNumber(amountIn) + .dividedBy(reserveIn.plus(amountIn)) + .multipliedBy(100) + .decimalPlaces(4) + .toFixed(); return { tokenIn, tokenOut, amountIn, amountOut, - priceImpact: '0', // Simplified + priceImpact, minimumReceived, path: [tokenInAddress, tokenOutAddress], validUntil: new Date(Date.now() + 60000) // 1 minute validity