-
Notifications
You must be signed in to change notification settings - Fork 26
feat(defi): add Soroswap and SDEX quote aggregation with split-route support #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ProtocolConfig, 'protocolId'> = { | ||
|
|
@@ -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<void> => { | ||
| 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; | ||
| } | ||
|
Comment on lines
+125
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate At Line 125, only presence is checked. Invalid inputs like Proposed fix if (!assetIn || !assetOut || !amountIn) {
res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'assetIn, assetOut, and amountIn are required query parameters',
details: {},
},
});
return;
}
+
+ const amountInStr = String(amountIn).trim();
+ if (!/^\d+(\.\d+)?$/.test(amountInStr) || Number(amountInStr) <= 0) {
+ res.status(400).json({
+ error: {
+ code: 'VALIDATION_ERROR',
+ message: 'amountIn must be a positive numeric string',
+ 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);
+ ? await aggregator.getSplitQuote(tokenIn, tokenOut, amountInStr, parsedSplits)
+ : await aggregator.getBestQuote(tokenIn, tokenOut, amountInStr);Also applies to: 140-142 🤖 Prompt for AI Agents |
||
|
|
||
| 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 | ||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
splitsvalidation can be bypassed and error classification is brittleAt Line 57,
if (!value)treats?splits=as “not provided”, so malformed input can silently fall back to best-route mode. At Line 68,[0,0]is also accepted (invalid zero-allocation). At Line 146,message.includes('splits')is too fragile for classifying validation failures.Proposed fix
Also applies to: 146-147
🤖 Prompt for AI Agents