Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 34 additions & 29 deletions docs/defi/aggregator-guide.md
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.
74 changes: 73 additions & 1 deletion packages/api/rest/src/routes/defi.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 64 additions & 1 deletion packages/api/rest/src/routes/defi.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'> = {
Expand Down Expand Up @@ -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;
}
Comment on lines +56 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

splits validation can be bypassed and error classification is brittle

At 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
+class SplitsValidationError extends Error {}
+
 function parseSplits(value: Request['query']['splits']): number[] | null {
-    if (!value) {
+    if (value === undefined) {
         return null;
     }

     const raw = Array.isArray(value) ? value.join(',') : String(value);
+    if (!raw.trim()) {
+        throw new SplitsValidationError('splits must contain exactly two non-negative numeric weights');
+    }
+
     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');
+    const totalWeight = splits.reduce((sum, split) => sum + split, 0);
+    if (
+        splits.length !== 2 ||
+        splits.some((split) => !Number.isFinite(split) || split < 0) ||
+        totalWeight <= 0
+    ) {
+        throw new SplitsValidationError('splits must contain exactly two non-negative numeric weights');
     }

     return splits;
 }
...
-            if (error instanceof Error && error.message.includes('splits')) {
+            if (error instanceof SplitsValidationError) {
                 res.status(400).json({

Also applies to: 146-147

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/rest/src/routes/defi.routes.ts` around lines 56 - 73,
parseSplits currently treats an empty query param (?splits=) as "not provided",
accepts [0,0], and relies on brittle message.includes('splits') elsewhere to
classify the error; fix by (1) changing the presence check to value ===
undefined so empty-string inputs are parsed as provided, (2) validate that the
parsed array contains exactly two finite non-negative numbers and reject the
all-zero case (e.g., ensure !(splits[0] === 0 && splits[1] === 0)), (3) throw a
specific ValidationError (create class ValidationError extends Error or attach a
stable code/name like 'SPLITS_VALIDATION_ERROR') with a clear constant message
from parseSplits, and (4) replace any downstream message.includes('splits')
checks with a reliable instance/type or code check against
ValidationError/SPLITS_VALIDATION_ERROR; reference parseSplits and the
error-handling logic that currently inspects message.includes('splits').


export function setupDefiRoutes(): express.Router {
const router = express.Router();

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate amountIn format/value before calling the aggregator

At Line 125, only presence is checked. Invalid inputs like amountIn=abc or negative values can propagate to protocol calls and return avoidable 5xx errors instead of a clean 400.

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
Verify each finding against the current code and only fix it if needed.

In `@packages/api/rest/src/routes/defi.routes.ts` around lines 125 - 134, The
handler currently only checks presence of assetIn, assetOut, and amountIn;
validate amountIn's format and value before calling the aggregator by parsing it
(e.g., Number/parseFloat or BigNumber), ensuring it's a finite number greater
than zero and rejecting non-numeric or non-positive values with a 400
VALIDATION_ERROR and clear message; update the validation blocks around the
existing checks (variables assetIn, assetOut, amountIn in the route handler) and
apply the same numeric validation to the other occurrence mentioned (the later
check at ~140-142) so invalid amountIn inputs like "abc" or negative numbers
return a 400 instead of propagating to protocol calls.


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
/**
Expand Down
32 changes: 32 additions & 0 deletions packages/core/defi-protocols/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading