Skip to content
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

feat: add dex price source support #29

Merged
merged 2 commits into from
Dec 31, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ docker run --restart always -d -v /path/on/host:/app/data script3/auctioneer-bot

The auctioneer bot requires access to a Soroban RPC server, and is fairly chatty. We recommend running an Soroban RPC server on the same host to avoid issues with rate limiting / usage. Please see the Stellar documentation for running a [Soroban RPC](https://developers.stellar.org/docs/data/rpc). We recommend running the [Soroban RPC as a docker container](https://developers.stellar.org/docs/data/rpc/admin-guide#docker-image). The Auctioneer bot itself is not very resource intensive, and should work fine alongside an RPC with the suggested hardware requirements for the Soroban RPC server.

The auctioneer bot also (optionally) uses Horizon to fetch prices from the DEX. If you use this, the bot keeps requests fairly quite (up to 1 request a minute per asset), so using a public Horizon endpoint sgould be OK. However, it is recommended to use a Horizon hosting solution to ensure uptime (e.g. Blockdaemon or Quicknode).
Copy link
Collaborator

Choose a reason for hiding this comment

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

typo quiet* and should*


Auctions filled by the bot will have an entry populated in the `filled_auctions` table of the DB, with the bots estimated profit on the trade included.

## Important Info
Expand Down Expand Up @@ -77,18 +79,34 @@ The `fillers` array contains configurations for individual filler accounts. The

The `priceSources` array defines the additional sources for price data. If an asset has a price source, the oracle prices will not be used when calculating profit, and instead the price fetched from the price source will be.

##### Exchange Price Sources

Prices are fetched from the following endpoints:
* Coinbase: https://api.coinbase.com/api/v3/brokerage/market/products?product_ids=SYMBOL
* Binance: https://api.binance.com/api/v3/ticker/price?symbols=[SYMBOL]

Each price source has the following fields:
Each exchange price source has the following fields:

| Field | Description |
|-------|-------------|
| `assetId` | The address of the asset for which this price source provides data. |
| `type` | The type of price source (e.g., "coinbase", "binance"). |
| `symbol` | The trading symbol used by the price source for this asset. |

##### DEX Prices Sources

DEX prices sources are calculated via Horizon's "find strict receive payment path" endpoint. The specified `destAmount` will be received via the `sourceAsset` asset, and the price will be computed such that `sourceAmount / destAmount`, or the average price for the full path payment operation on that block. This can be useful to fetch prices for assets that are not on centralized exchanges.

Each DEX price source has the following fields:

| Field | Description |
|-------|-------------|
| `assetId` | The address of the asset for which this price source provides data. |
| `type` | The type of price source (e.g., "dex"). |
| `sourceAsset` | The Stellar Asset being used as the source asset. Should align with the contract address in `assetId`. The string is formatted as `code:issuer` or `XLM:native`. |
| `destAsset` | The Stellar Asset being used as the destination asset. This should be what the oracle is reporting in (e.g. USDC). The string is formatted as `code:issuer` or `XLM:native`.
| `destAmount` | The amount of `destAsset` that must be received to calculate the final price of `sourceAsset` |

#### Profits

The `profits` list defines target profit percentages based on the assets that make up the bid and lot of a given auction. This allows fillers to have flexability in the profit they target. The profit percentage chosen will be the first entry in the `profits` list that supports all bid and lot assets in the auction. If no profit entry is found, the `defaultProfitPct` value defined by the filler will be used.
Expand Down
8 changes: 8 additions & 0 deletions example.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"name": "Example Bot",
"rpcURL": "http://localhost:8000",
"networkPassphrase": "Public Global Stellar Network ; September 2015",
"horizonURL": "http://horizon:8000",
"poolAddress": "C...",
"backstopAddress": "CAO3AGAMZVRMHITL36EJ2VZQWKYRPWMQAPDQD5YEOF3GIF7T44U4JAL3",
"backstopTokenAddress": "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM",
Expand Down Expand Up @@ -52,6 +53,13 @@
"assetId": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
"type": "binance",
"symbol": "XLMUSDT"
},
{
"assetId": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA",
"type": "dex",
"sourceAsset": "XLM:native",
"destAsset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
"destAmount": "1000"
}
]
}
11 changes: 6 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "auctioneer-bot",
"version": "0.2.0",
"version": "0.2.1",
"main": "index.js",
"type": "module",
"scripts": {
Expand Down
60 changes: 51 additions & 9 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,31 @@ export interface Filler {
supportedLot: string[];
}

export interface PriceSource {
export enum PriceSourceType {
BINANCE = 'binance',
COINBASE = 'coinbase',
DEX = 'dex',
}

export interface PriceSourceBase {
assetId: string;
type: 'coinbase' | 'binance';
type: PriceSourceType;
}

export interface ExchangePriceSource extends PriceSourceBase {
type: PriceSourceType.BINANCE | PriceSourceType.COINBASE;
symbol: string;
}

export interface DexPriceSource extends PriceSourceBase {
type: PriceSourceType.DEX;
sourceAsset: string;
destAsset: string;
destAmount: string;
}

export type PriceSource = ExchangePriceSource | DexPriceSource;

export interface AuctionProfit {
profitPct: number;
supportedBid: string[];
Expand All @@ -37,6 +56,7 @@ export interface AppConfig {
blndAddress: string;
keypair: Keypair;
fillers: Filler[];
horizonURL: string | undefined;
priceSources: PriceSource[] | undefined;
profits: AuctionProfit[] | undefined;
slackWebhook: string | undefined;
Expand Down Expand Up @@ -68,6 +88,7 @@ export function validateAppConfig(config: any): boolean {
typeof config.blndAddress !== 'string' ||
typeof config.keypair !== 'string' ||
!Array.isArray(config.fillers) ||
(config.horizonURL !== undefined && typeof config.horizonURL !== 'string') ||
(config.priceSources !== undefined && !Array.isArray(config.priceSources)) ||
(config.profits !== undefined && !Array.isArray(config.profits)) ||
(config.slackWebhook !== undefined && typeof config.slackWebhook !== 'string')
Expand Down Expand Up @@ -112,17 +133,38 @@ export function validateFiller(filler: any): boolean {
}

export function validatePriceSource(priceSource: any): boolean {
if (typeof priceSource !== 'object' || priceSource === null) {
if (
typeof priceSource !== 'object' ||
priceSource === null ||
priceSource.type === undefined ||
typeof priceSource.assetId !== 'string'
) {
return false;
}

if (
typeof priceSource.assetId === 'string' &&
(priceSource.type === 'binance' || priceSource.type === 'coinbase') &&
typeof priceSource.symbol === 'string'
) {
return true;
switch (priceSource.type) {
case PriceSourceType.BINANCE:
case PriceSourceType.COINBASE:
if (typeof priceSource.symbol === 'string') {
return true;
}
break;
case PriceSourceType.DEX:
if (
typeof priceSource.sourceAsset === 'string' &&
priceSource.sourceAsset.includes(':') &&
typeof priceSource.destAsset === 'string' &&
priceSource.destAsset.includes(':') &&
typeof priceSource.destAmount === 'string'
) {
return true;
}
break;
default:
console.log('Invalid price source (unkown type)', priceSource);
return false;
}

console.log('Invalid price source', priceSource);
return false;
}
Expand Down
62 changes: 62 additions & 0 deletions src/utils/horizon_helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { PoolUser, PositionsEstimate } from '@blend-capital/blend-sdk';
import { Asset, Horizon } from '@stellar/stellar-sdk';
import { APP_CONFIG } from './config.js';
import { logger } from './logger.js';

export interface PoolUserEst {
estimate: PositionsEstimate;
user: PoolUser;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this being used somewhere?


export class HorizonHelper {
url: string;

constructor() {
this.url = APP_CONFIG.horizonURL ?? '';
}

/**
* Fetch a price based on a strict receive path payment
* @param soureAsset - A stellar asset as a string
* @param destAsset - A stellar asset as a string
* @param destAmount - The amount of the destination asset (as a whole number, 0 decimals)
* @returns The price of the destination asset in terms of the source asset
* @panics If no path exists or if Horizon throws an error
*/
async loadStrictReceivePrice(
soureAsset: string,
destAsset: string,
destAmount: string
): Promise<number> {
try {
let horizon = new Horizon.Server(this.url, {
allowHttp: true,
});
let result = await horizon
.strictReceivePaths(
[this.toAssetFromString(soureAsset)],
this.toAssetFromString(destAsset),
destAmount
)
.call();
if (result.records.length === 0) {
throw new Error('No paths found');
}
let firstRecord = result.records[0];
return Number(firstRecord.destination_amount) / Number(firstRecord.source_amount);
} catch (e) {
logger.error(`Error loading latest ledger: ${e}`);
throw e;
}
}

/**
* Construct an asset from it's string representation
* @param asset - 'assetCode:issuer' or 'XLM:native'
* @returns The asset object
*/
toAssetFromString(asset: string): Asset {
let parts = asset.split(':');
return parts[0] === 'XLM' ? Asset.native() : new Asset(parts[0], parts[1]);
}
}
83 changes: 77 additions & 6 deletions src/utils/prices.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { APP_CONFIG } from './config.js';
import { APP_CONFIG, DexPriceSource, ExchangePriceSource } from './config.js';
import { AuctioneerDatabase, PriceEntry } from './db.js';
import { HorizonHelper } from './horizon_helper.js';
import { stringify } from './json.js';
import { logger } from './logger.js';

Expand All @@ -13,27 +14,97 @@ interface ExchangePrice {
* @param db - The database to set prices in
*/
export async function setPrices(db: AuctioneerDatabase): Promise<void> {
const exchangePrices: ExchangePriceSource[] = [];
const dexPrices: DexPriceSource[] = [];

for (const source of APP_CONFIG.priceSources ?? []) {
switch (source.type) {
case 'binance':
case 'coinbase':
exchangePrices.push(source as ExchangePriceSource);
break;
case 'dex':
dexPrices.push(source as DexPriceSource);
break;
}
}

const [exchangePricesResult, dexPricesResult] = await Promise.all([
getExchangePrices(exchangePrices),
getDexPrices(dexPrices),
]);

const priceEntries = exchangePricesResult.concat(dexPricesResult);
if (priceEntries.length !== 0) {
db.setPriceEntries(exchangePricesResult.concat(dexPricesResult));
logger.info(`Set ${priceEntries.length} prices in the database.`);
} else {
logger.info('No prices set.');
}
}

/**
* Fetch prices via path payments on the Stellar DEX.
* @param priceSources - The DEX price sources to fetch prices for
* @returns An array of price entries. If a price cannot be fetched, it is not included in the array.
*/
export async function getDexPrices(priceSources: DexPriceSource[]): Promise<PriceEntry[]> {
// process DEX prices one at a time to avoid strict Horizon rate limits on the public
// Horizon instance
const priceEntries: PriceEntry[] = [];
const timestamp = Math.floor(Date.now() / 1000);
const horizonHelper = new HorizonHelper();
for (const priceSource of priceSources) {
try {
const price = await horizonHelper.loadStrictReceivePrice(
priceSource.sourceAsset,
priceSource.destAsset,
priceSource.destAmount
);

priceEntries.push({
asset_id: priceSource.assetId,
price: price,
timestamp: timestamp,
});
} catch (e) {
logger.error(`Error fetching dex price for ${priceSource}: ${e}`);
continue;
}
}
return priceEntries;
}

/**
* Fetch exchange prices.
* @param exchangePriceSources - The exchange price sources to fetch prices for
* @returns An array of price entries. If a price cannot be fetched, it is not included in the array.
*/
export async function getExchangePrices(
exchangePriceSources: ExchangePriceSource[]
): Promise<PriceEntry[]> {
const timestamp = Math.floor(Date.now() / 1000);

const coinbaseSymbols: string[] = [];
const binanceSymbols: string[] = [];
for (const source of APP_CONFIG.priceSources ?? []) {
for (const source of exchangePriceSources) {
if (source.type === 'coinbase') {
coinbaseSymbols.push(source.symbol);
} else if (source.type === 'binance') {
binanceSymbols.push(source.symbol);
}
}

const timestamp = Math.floor(Date.now() / 1000);
// If these API calls fail, it is assumed the functions return an empty array
const [coinbasePricesResult, binancePricesResult] = await Promise.all([
coinbasePrices(coinbaseSymbols),
binancePrices(binanceSymbols),
]);
const exchangePriceResult = coinbasePricesResult.concat(binancePricesResult);

const priceSources = APP_CONFIG.priceSources ?? [];
const priceEntries: PriceEntry[] = [];
for (const price of exchangePriceResult) {
const assetId = priceSources.find((source) => source.symbol === price.symbol)?.assetId;
const assetId = exchangePriceSources.find((source) => source.symbol === price.symbol)?.assetId;
if (assetId) {
priceEntries.push({
asset_id: assetId,
Expand All @@ -42,7 +113,7 @@ export async function setPrices(db: AuctioneerDatabase): Promise<void> {
});
}
}
db.setPriceEntries(priceEntries);
return priceEntries;
}

/**
Expand Down
Loading
Loading