From b1c3ddc7ae5393804530641dd4785a09cf220140 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Tue, 5 Nov 2024 16:29:38 -0500 Subject: [PATCH 01/13] chore: upgrade packages and normalize error logging --- package-lock.json | 27 +++++++-------------------- package.json | 6 +++--- src/bidder_submitter.ts | 10 ++++++---- src/collector.ts | 3 ++- src/pool_event_handler.ts | 10 +++++----- src/utils/json.ts | 23 +++++++++++++++++++++++ src/work_handler.ts | 2 +- src/work_submitter.ts | 24 ++++++++++++++---------- test/pool_event_handler.test.ts | 21 ++++++++++++--------- 9 files changed, 73 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index ca839a4..0872a48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@blend-capital/blend-sdk": "^2.0.3", - "@stellar/stellar-sdk": "^12.3.0", + "@blend-capital/blend-sdk": "2.1.1", + "@stellar/stellar-sdk": "12.3.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", "winston-daily-rotate-file": "^5.0.0" @@ -548,29 +548,16 @@ "dev": true }, "node_modules/@blend-capital/blend-sdk": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.0.3.tgz", - "integrity": "sha512-KdtHfTNA+9RVuL9nXixLYsyNw8zieKXESPyaVPE7tQ+OX5fxOKXCXZXuS3z9ohiJJBzwARoMgI6IfgEw01JJhQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.1.1.tgz", + "integrity": "sha512-cEP6rwXKl79rrjNb8jTYHhsL2gxHqBoVtHjj3oBo0+jEa0BnMUqDqd6vAr+eNBaydtRUqi+r6YCPE+kOfRn3UQ==", + "license": "MIT", "dependencies": { - "@stellar/stellar-sdk": "12.2.0", + "@stellar/stellar-sdk": "12.3.0", "buffer": "6.0.3", "follow-redirects": ">=1.15.6" } }, - "node_modules/@blend-capital/blend-sdk/node_modules/@stellar/stellar-sdk": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.2.0.tgz", - "integrity": "sha512-Wy5sDOqb5JvAC76f4sQIV6Pe3JNyZb0PuyVNjwt3/uWsjtxRkFk6s2yTHTefBLWoR+mKxDjO7QfzhycF1v8FXQ==", - "dependencies": { - "@stellar/stellar-base": "^12.1.0", - "axios": "^1.7.2", - "bignumber.js": "^9.1.2", - "eventsource": "^2.0.2", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", diff --git a/package.json b/package.json index 6cc5bb0..b530bf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auctioneer-bot", - "version": "0.0.0", + "version": "0.2.0", "main": "index.js", "type": "module", "scripts": { @@ -25,8 +25,8 @@ "typescript": "^5.5.4" }, "dependencies": { - "@blend-capital/blend-sdk": "^2.0.3", - "@stellar/stellar-sdk": "^12.3.0", + "@blend-capital/blend-sdk": "2.1.1", + "@stellar/stellar-sdk": "12.3.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", "winston-daily-rotate-file": "^5.0.0" diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 9b2b718..ebaf02d 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -8,7 +8,7 @@ import { } from './auction.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; -import { stringify } from './utils/json.js'; +import { serializeError, stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; @@ -160,9 +160,11 @@ export class BidderSubmitter extends SubmissionQueue { `Error submitting fill for auction\n` + `Type: ${auctionBid.auctionEntry.auction_type}\n` + `User: ${auctionBid.auctionEntry.user_id}\n` + - `Filler: ${auctionBid.filler.name}\nError: ${e}\n`; - await sendSlackNotification(`` + logMessage); - logger.error(logMessage); + `Filler: ${auctionBid.filler.name}`; + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); + logger.error(logMessage, e); return false; } } diff --git a/src/collector.ts b/src/collector.ts index 319b139..e82d720 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -100,7 +100,8 @@ export async function runCollector( // Handles the case where the rpc server is restarted and no longer has events from the start ledger we requested if (e.code === -32600) { logger.error( - `Error fetching events at start ledger: ${start_ledger}, retrying with latest ledger ${latestLedger} Error: ${e}` + `Error fetching events at start ledger: ${start_ledger}, retrying with latest ledger ${latestLedger}`, + e ); events = await rpc._getEvents({ startLedger: latestLedger, diff --git a/src/pool_event_handler.ts b/src/pool_event_handler.ts index 43a1134..45ff339 100644 --- a/src/pool_event_handler.ts +++ b/src/pool_event_handler.ts @@ -1,4 +1,5 @@ import { PoolEventType } from '@blend-capital/blend-sdk'; +import { ChildProcess } from 'child_process'; import { canFillerBid } from './auction.js'; import { EventType, PoolEventEvent } from './events.js'; import { updateUser } from './user.js'; @@ -10,7 +11,6 @@ import { deadletterEvent, sendEvent } from './utils/messages.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission } from './work_submitter.js'; -import { ChildProcess } from 'child_process'; const MAX_RETRIES = 2; const RETRY_DELAY = 200; @@ -43,17 +43,17 @@ export class PoolEventHandler { await this.handlePoolEvent(poolEvent); logger.info(`Successfully processed event. ${poolEvent.event.id}`); return; - } catch (error) { + } catch (error: any) { retries++; if (retries >= MAX_RETRIES) { try { await deadletterEvent(poolEvent); - } catch (error) { - logger.error(`Error sending event to dead letter queue. Error: ${error}`); + } catch (error: any) { + logger.error(`Error sending event to dead letter queue.`, error); } return; } - logger.warn(`Error processing event. ${poolEvent.event.id} Error: ${error}`); + logger.warn(`Error processing event. ${poolEvent.event.id}.`, error); logger.warn( `Retry ${retries + 1}/${MAX_RETRIES}. Waiting ${RETRY_DELAY}ms before next attempt.` ); diff --git a/src/utils/json.ts b/src/utils/json.ts index 8ccaab7..3d62430 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -1,3 +1,5 @@ +import { ContractError, ContractErrorType } from '@blend-capital/blend-sdk'; + function replacer(_: any, value: any): any { if (typeof value === 'bigint') { return { type: 'bigint', value: value.toString() }; @@ -46,3 +48,24 @@ export function stringify(value: any, space?: string | number): string { export function parse(jsonString: string): T { return JSON.parse(jsonString, reviver) as T; } + +/** + * Safely serialize an error object to a JSON object that is safe to stringify. This does not + * include the stack trace to make it safe for alerts and external logs. + * @param error - The thrown error + * @returns The object representation of the error + */ +export function serializeError(error: any): any { + if (error instanceof ContractError) { + return { + type: 'ContractError', + message: ContractErrorType[error.type], + }; + } else { + return { + type: 'Error', + message: error?.message, + name: error?.name, + }; + } +} diff --git a/src/work_handler.ts b/src/work_handler.ts index 44d5305..c38b070 100644 --- a/src/work_handler.ts +++ b/src/work_handler.ts @@ -52,7 +52,7 @@ export class WorkHandler { await deadletterEvent(appEvent); return false; } - logger.warn(`Error processing event. Error: ${error}`); + logger.warn(`Error processing event.`, error); logger.warn( `Retry ${retries + 1}/${MAX_RETRIES}. Waiting ${RETRY_DELAY}ms before next attempt.` ); diff --git a/src/work_submitter.ts b/src/work_submitter.ts index f2f968d..66010e4 100644 --- a/src/work_submitter.ts +++ b/src/work_submitter.ts @@ -1,7 +1,7 @@ import { ContractError, ContractErrorType, PoolContract } from '@blend-capital/blend-sdk'; import { APP_CONFIG } from './utils/config.js'; import { AuctionType } from './utils/db.js'; -import { stringify } from './utils/json.js'; +import { serializeError, stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; @@ -94,10 +94,11 @@ export class WorkSubmitter extends SubmissionQueue { const logMessage = `Error creating user liquidation\n` + `User: ${userLiquidation.user}\n` + - `Liquidation Percent: ${userLiquidation.liquidationPercent}\nError: ${stringify(e)}\n` + - `Error: ${e}\n`; - logger.error(logMessage); - await sendSlackNotification(`` + logMessage); + `Liquidation Percent: ${userLiquidation.liquidationPercent}`; + logger.error(logMessage, e); + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); return false; } } @@ -115,10 +116,11 @@ export class WorkSubmitter extends SubmissionQueue { logger.info(logMessage); return true; } catch (e: any) { - const logMessage = - `Error transfering bad debt\n` + `User: ${badDebtTransfer.user}\n` + `Error: ${e}\n`; - logger.error(logMessage); - await sendSlackNotification(`` + logMessage); + const logMessage = `Error transfering bad debt\n` + `User: ${badDebtTransfer.user}`; + logger.error(logMessage, e); + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); return false; } } @@ -142,7 +144,9 @@ export class WorkSubmitter extends SubmissionQueue { } catch (e: any) { const logMessage = `Error creating bad debt auction\n` + `Error: ${e}\n`; logger.error(logMessage); - await sendSlackNotification(`` + logMessage); + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); return false; } } diff --git a/test/pool_event_handler.test.ts b/test/pool_event_handler.test.ts index 1999e7b..1005821 100644 --- a/test/pool_event_handler.test.ts +++ b/test/pool_event_handler.test.ts @@ -11,10 +11,10 @@ import { PoolEventHandler } from '../src/pool_event_handler.js'; import { updateUser } from '../src/user.js'; import { APP_CONFIG, AppConfig } from '../src/utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db.js'; -import { SorobanHelper } from '../src/utils/soroban_helper.js'; -import { inMemoryAuctioneerDb, mockedPool } from './helpers/mocks.js'; import { logger } from '../src/utils/logger.js'; import { deadletterEvent, sendEvent } from '../src/utils/messages.js'; +import { SorobanHelper } from '../src/utils/soroban_helper.js'; +import { inMemoryAuctioneerDb, mockedPool } from './helpers/mocks.js'; jest.mock('../src/user.js'); jest.mock('../src/utils/soroban_helper.js'); @@ -143,14 +143,14 @@ describe('poolEventHandler', () => { }, }, }; - mockedSorobanHelper.loadPool - .mockRejectedValueOnce(new Error('Temporary error')) - .mockResolvedValue(mockedPool); + let error = new Error('Temporary error'); + mockedSorobanHelper.loadPool.mockRejectedValueOnce(error).mockResolvedValue(mockedPool); await poolEventHandler.processEventWithRetryAndDeadLetter(poolEvent); expect(mockedSorobanHelper.loadPool).toHaveBeenCalledTimes(2); expect(logger.warn).toHaveBeenCalledWith( - `Error processing event. ${poolEvent.event.id} Error: Error: Temporary error` + `Error processing event. ${poolEvent.event.id}.`, + error ); expect(logger.info).toHaveBeenCalledWith(`Successfully processed event. ${poolEvent.event.id}`); }); @@ -203,9 +203,11 @@ describe('poolEventHandler', () => { }, }; - mockedSorobanHelper.loadPool.mockRejectedValue(new Error('Permanent error')); + let error = new Error('Permanent error'); + mockedSorobanHelper.loadPool.mockRejectedValue(error); + let mocked_error = new Error('Mocked error'); const mockDeadLetterEvent = deadletterEvent as jest.MockedFunction; - mockDeadLetterEvent.mockRejectedValue(new Error('Mocked error')); + mockDeadLetterEvent.mockRejectedValue(mocked_error); await poolEventHandler.processEventWithRetryAndDeadLetter(poolEvent); @@ -213,7 +215,8 @@ describe('poolEventHandler', () => { expect(deadletterEvent).toHaveBeenCalledWith(poolEvent); expect(logger.error).toHaveBeenNthCalledWith( 1, - `Error sending event to dead letter queue. Error: Error: Mocked error` + `Error sending event to dead letter queue.`, + mocked_error ); }); From ddd4ec5f7feaae3d7f18f99d4da457e24a28e76d Mon Sep 17 00:00:00 2001 From: mootz12 Date: Tue, 12 Nov 2024 16:43:51 -0500 Subject: [PATCH 02/13] feat: add position unwinding --- README.md | 2 + example.config.json | 2 + src/auction.ts | 215 +++-- src/bidder_submitter.ts | 72 +- src/filler.ts | 165 ++++ src/utils/config.ts | 5 + src/utils/soroban_helper.ts | 27 + test/auction.test.ts | 1399 +++++++++++++++------------------ test/bidder_submitter.test.ts | 118 ++- test/filler.test.ts | 323 ++++++++ test/utils/config.test.ts | 6 + 11 files changed, 1437 insertions(+), 897 deletions(-) create mode 100644 src/filler.ts create mode 100644 test/filler.test.ts diff --git a/README.md b/README.md index 44ce641..502055a 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,10 @@ The `fillers` array contains configurations for individual filler accounts. The |-------|-------------| | `name` | A unique name for this filler account. Used in logs and slack notifications. | | `keypair` | The secret key for this filler account. **Keep this secret and secure!** | +| `primaryAsset` | The primary asset the filler will use as collateral in the pool. | | `minProfitPct` | The minimum profit percentage required for the filler to bid on an auction. | | `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions. | +| `minPrimaryCollateral` | The minimum amount of the primary asset the Filler will maintain as collateral in the pool. | | `forceFill` | Boolean flag to indicate if the bot should force fill auctions even if profit expectations aren't met to ensure pool health. | | `supportedBid` | An array of asset addresses that this filler bot is allowed to bid with. Bids are taken as additional liabilities (dTokens) for liquidation and bad debt auctions, and tokens for interest auctions. Must include the `backstopTokenAddress` to bid on interest auctions. | | `supportedLot` | An array of asset addresses that this filler bot is allowed to receive. Lots are given as collateral (bTokens) for liquidation auctions and tokens for interest and bad debt auctions. The filler should have trustlines to all assets that are Stellar assets. Must include `backstopTokenAddress` to bid on bad debt auctions. | diff --git a/example.config.json b/example.config.json index 9ffd4f9..cf05f9b 100644 --- a/example.config.json +++ b/example.config.json @@ -15,6 +15,8 @@ "minProfitPct": 0.10, "minHealthFactor": 1.5, "forceFill": true, + "primaryAsset": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "minPrimaryCollateral": "10000000000", "supportedBid": [ "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM", "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", diff --git a/src/auction.ts b/src/auction.ts index 00fe6b7..cd48bea 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -176,11 +176,11 @@ export function scaleAuction( } /** - * Build requests to fill the auction and clear the filler's position. + * Build requests to fill the auction and repay the liabilities. * @param auctionBid - The auction to build the fill requests for - * @param auctionData - The auction data to build the fill requests for + * @param auctionData - The scaled auction data to build the fill requests for * @param fillPercent - The percent to fill the auction - * @param sorobanHelper - The soroban helper to use for loading ledger data + * @param sorobanHelper - The soroban helper to use for the calculation * @returns */ export async function buildFillRequests( @@ -208,82 +208,46 @@ export async function buildFillRequests( amount: BigInt(fillPercent), }); - const poolOracle = await sorobanHelper.loadPoolOracle(); - // Interest auctions transfer underlying assets - if (auctionBid.auctionEntry.auction_type !== AuctionType.Interest) { - let { estimate: fillerPositionEstimates, user: fillerPositions } = - await sorobanHelper.loadUserPositionEstimate(auctionBid.auctionEntry.filler); - const reserves = (await sorobanHelper.loadPool()).reserves; - - for (const [assetId, amount] of auctionData.bid) { - const oraclePrice = poolOracle.getPriceFloat(assetId); - // Skip assets without an oracle price - // TODO: determine what to do with assets without an oracle price - // (e.g. just repay debt if wallet balance is sufficient or check if asset is in price db) - if (oraclePrice === undefined) { - continue; - } - const reserve = reserves.get(assetId); - let fillerBalance = await sorobanHelper.simBalance(assetId, auctionBid.auctionEntry.filler); + if (auctionBid.auctionEntry.auction_type === AuctionType.Interest) { + return fillRequests; + } - // Ensure the filler has XLM to pay for the transaction + // attempt to repay any liabilities the filler has took on from the bids + // if this fails for some reason, still continue with the fill + try { + for (const [assetId] of auctionData.bid) { + let tokenBalance = await sorobanHelper.simBalance( + assetId, + auctionBid.filler.keypair.publicKey() + ); if (assetId === Asset.native().contractId(APP_CONFIG.networkPassphrase)) { - fillerBalance = - fillerBalance > FixedMath.toFixed(100, 7) - ? fillerBalance - FixedMath.toFixed(100, 7) - : 0n; + tokenBalance = + tokenBalance > FixedMath.toFixed(50, 7) ? tokenBalance - FixedMath.toFixed(50, 7) : 0n; } - if (reserve !== undefined) { - const liabilityLeft = amount - fillerBalance > 0 ? amount - fillerBalance : 0n; - const effectiveLiabilityIncrease = - reserve.toEffectiveAssetFromDTokenFloat(liabilityLeft) * oraclePrice; - fillerPositionEstimates.totalEffectiveLiabilities += effectiveLiabilityIncrease; - if (fillerBalance > 0) { - fillRequests.push({ - request_type: RequestType.Repay, - address: assetId, - amount: BigInt(fillerBalance), - }); - } - } - } - - for (const [assetId, amount] of auctionData.lot) { - const reserve = reserves.get(assetId); - const oraclePrice = poolOracle.getPriceFloat(assetId); - if ( - reserve !== undefined && - !fillerPositions.positions.collateral.has(reserve.config.index) && - oraclePrice !== undefined - ) { - const effectiveCollateralIncrease = - reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; - const newHF = - fillerPositionEstimates.totalEffectiveCollateral / - fillerPositionEstimates.totalEffectiveLiabilities; - if (newHF > auctionBid.filler.minHealthFactor) { - fillRequests.push({ - request_type: RequestType.WithdrawCollateral, - address: assetId, - // Use I64 max value to withdraw all collateral - amount: BigInt('9223372036854775807'), - }); - } else { - fillerPositionEstimates.totalEffectiveCollateral += effectiveCollateralIncrease; - } + if (tokenBalance > 0) { + fillRequests.push({ + request_type: RequestType.Repay, + address: assetId, + amount: BigInt(tokenBalance), + }); } } + } catch (e: any) { + logger.error(`Error attempting to repay dToken bids for filler: ${auctionBid.filler.name}`, e); } return fillRequests; } /** * Calculate the effective collateral, lot value, effective liabilities, and bid value for an auction. + * + * If this function encounters an error, it will return 0 for all values. + * * @param auctionType - The type of auction to calculate the values for * @param auctionData - The auction data to calculate the values for * @param sorobanHelper - A helper to use for loading ledger data * @param db - The database to use for fetching asset prices - * @returns + * @returns The calculated values, or 0 for all values if it is unable to calculate them */ export async function calculateAuctionValue( auctionType: AuctionType, @@ -291,73 +255,78 @@ export async function calculateAuctionValue( sorobanHelper: SorobanHelper, db: AuctioneerDatabase ): Promise { - let effectiveCollateral = 0; - let lotValue = 0; - let effectiveLiabilities = 0; - let bidValue = 0; - const reserves = (await sorobanHelper.loadPool()).reserves; - const poolOracle = await sorobanHelper.loadPoolOracle(); - for (const [assetId, amount] of auctionData.lot) { - const reserve = reserves.get(assetId); - if (reserve !== undefined) { - const oraclePrice = poolOracle.getPriceFloat(assetId); - const dbPrice = db.getPriceEntry(assetId)?.price; - if (oraclePrice === undefined) { - throw new Error(`Failed to get oracle price for asset: ${assetId}`); - } + try { + let effectiveCollateral = 0; + let lotValue = 0; + let effectiveLiabilities = 0; + let bidValue = 0; + const reserves = (await sorobanHelper.loadPool()).reserves; + const poolOracle = await sorobanHelper.loadPoolOracle(); + for (const [assetId, amount] of auctionData.lot) { + const reserve = reserves.get(assetId); + if (reserve !== undefined) { + const oraclePrice = poolOracle.getPriceFloat(assetId); + const dbPrice = db.getPriceEntry(assetId)?.price; + if (oraclePrice === undefined) { + throw new Error(`Failed to get oracle price for asset: ${assetId}`); + } - if (auctionType !== AuctionType.Interest) { - effectiveCollateral += reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; - // TODO: change this to use the price in the db - lotValue += reserve.toAssetFromBTokenFloat(amount) * (dbPrice ?? oraclePrice); - } - // Interest auctions are in underlying assets - else { - lotValue += - (Number(amount) / 10 ** reserve.tokenMetadata.decimals) * (dbPrice ?? oraclePrice); - } - } else if (assetId === APP_CONFIG.backstopTokenAddress) { - // Simulate singled sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); - if (lpTokenValue !== undefined) { - lotValue += FixedMath.toFloat(lpTokenValue, 7); - } - // Approximate the value of the comet tokens if simulation fails - else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - lotValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; + if (auctionType !== AuctionType.Interest) { + effectiveCollateral += reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; + // TODO: change this to use the price in the db + lotValue += reserve.toAssetFromBTokenFloat(amount) * (dbPrice ?? oraclePrice); + } + // Interest auctions are in underlying assets + else { + lotValue += + (Number(amount) / 10 ** reserve.tokenMetadata.decimals) * (dbPrice ?? oraclePrice); + } + } else if (assetId === APP_CONFIG.backstopTokenAddress) { + // Simulate singled sided withdraw to USDC + const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); + if (lpTokenValue !== undefined) { + lotValue += FixedMath.toFloat(lpTokenValue, 7); + } + // Approximate the value of the comet tokens if simulation fails + else { + const backstopToken = await sorobanHelper.loadBackstopToken(); + lotValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; + } + } else { + throw new Error(`Failed to value lot asset: ${assetId}`); } - } else { - throw new Error(`Failed to value lot asset: ${assetId}`); } - } - for (const [assetId, amount] of auctionData.bid) { - const reserve = reserves.get(assetId); - const dbPrice = db.getPriceEntry(assetId)?.price; + for (const [assetId, amount] of auctionData.bid) { + const reserve = reserves.get(assetId); + const dbPrice = db.getPriceEntry(assetId)?.price; - if (reserve !== undefined) { - const oraclePrice = poolOracle.getPriceFloat(assetId); - if (oraclePrice === undefined) { - throw new Error(`Failed to get oracle price for asset: ${assetId}`); - } + if (reserve !== undefined) { + const oraclePrice = poolOracle.getPriceFloat(assetId); + if (oraclePrice === undefined) { + throw new Error(`Failed to get oracle price for asset: ${assetId}`); + } - effectiveLiabilities += reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; - // TODO: change this to use the price in the db - bidValue += reserve.toAssetFromDTokenFloat(amount) * (dbPrice ?? oraclePrice); - } else if (assetId === APP_CONFIG.backstopTokenAddress) { - // Simulate singled sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); - if (lpTokenValue !== undefined) { - bidValue += FixedMath.toFloat(lpTokenValue, 7); + effectiveLiabilities += reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; + // TODO: change this to use the price in the db + bidValue += reserve.toAssetFromDTokenFloat(amount) * (dbPrice ?? oraclePrice); + } else if (assetId === APP_CONFIG.backstopTokenAddress) { + // Simulate singled sided withdraw to USDC + const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); + if (lpTokenValue !== undefined) { + bidValue += FixedMath.toFloat(lpTokenValue, 7); + } else { + const backstopToken = await sorobanHelper.loadBackstopToken(); + bidValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; + } } else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - bidValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; + throw new Error(`Failed to value bid asset: ${assetId}`); } - } else { - throw new Error(`Failed to value bid asset: ${assetId}`); } - } - return { effectiveCollateral, effectiveLiabilities, lotValue, bidValue }; + return { effectiveCollateral, effectiveLiabilities, lotValue, bidValue }; + } catch (e: any) { + logger.error(`Error calculating auction value`, e); + return { effectiveCollateral: 0, effectiveLiabilities: 0, lotValue: 0, bidValue: 0 }; + } } diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index ebaf02d..f6df101 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -6,6 +6,7 @@ import { calculateBlockFillAndPercent, scaleAuction, } from './auction.js'; +import { managePositions } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; import { serializeError, stringify } from './utils/json.js'; @@ -104,8 +105,12 @@ export class BidderSubmitter extends SubmissionQueue { ); if (currLedger + 1 >= fillCalculation.fillBlock) { - let scaledAuction = scaleAuction(auctionData, currLedger, fillCalculation.fillPercent); - const requests = await buildFillRequests( + const scaledAuction = scaleAuction( + auctionData, + currLedger + 1, + fillCalculation.fillPercent + ); + const request = await buildFillRequests( auctionBid, scaledAuction, fillCalculation.fillPercent, @@ -118,7 +123,7 @@ export class BidderSubmitter extends SubmissionQueue { from: auctionBid.auctionEntry.filler, spender: auctionBid.auctionEntry.filler, to: auctionBid.auctionEntry.filler, - requests: requests, + requests: request, }), auctionBid.filler.keypair ); @@ -152,6 +157,7 @@ export class BidderSubmitter extends SubmissionQueue { fill_block: result.ledger, timestamp: result.latestLedgerCloseTime, }); + this.addSubmission({ type: BidderSubmissionType.UNWIND, filler: auctionBid.filler }, 2); return true; } return true; @@ -170,7 +176,65 @@ export class BidderSubmitter extends SubmissionQueue { } async submitUnwind(sorobanHelper: SorobanHelper, fillerUnwind: FillerUnwind): Promise { - logger.warn('Filler unwind is not implemented.'); + const filler_pubkey = fillerUnwind.filler.keypair.publicKey(); + const filler_tokens = [ + ...new Set([ + fillerUnwind.filler.primaryAsset, + ...fillerUnwind.filler.supportedBid, + ...fillerUnwind.filler.supportedLot, + ]), + ]; + const pool = await sorobanHelper.loadPool(); + const poolOracle = await sorobanHelper.loadPoolOracle(); + const filler_user = await sorobanHelper.loadUser(filler_pubkey); + const filler_balances = await sorobanHelper.loadBalances(filler_pubkey, filler_tokens); + + // Unwind the filler one step at a time. If the filler is not unwound, place another `FillerUnwind` event on the submission queue. + // To unwind the filler, the following actions will be taken in order: + // 1. Unwind the filler's pool position by paying off all liabilities with current balances and withdrawing all possible collateral, + // down to either the min_collateral or min_health_factor. + // TODO: Add trading functionality for 2, 3 + // 2. If no positions can be modified, and the filler still has outstanding liabilities, attempt to purchase the liability tokens + // with USDC. + // 3. If there are no liabilities, attempt to sell un-needed tokens for USDC + // 4. If this case is reached, stop sending unwind events for the filler. + + // 1 + let requests = managePositions( + fillerUnwind.filler, + pool, + poolOracle, + filler_user.positions, + filler_balances + ); + if (requests.length > 0) { + // some positions to manage - submit the transaction + const pool_contract = new PoolContract(APP_CONFIG.poolAddress); + const result = await sorobanHelper.submitTransaction( + pool_contract.submit({ + from: filler_pubkey, + spender: filler_pubkey, + to: filler_pubkey, + requests: requests, + }), + fillerUnwind.filler.keypair + ); + logger.info( + `Successful unwind for filler: ${fillerUnwind.filler.name}\n` + + `Ledger: ${result.ledger}\n` + + `Hash: ${result.txHash}` + ); + this.addSubmission({ type: BidderSubmissionType.UNWIND, filler: fillerUnwind.filler }, 2); + return true; + } + + if (filler_user.positions.liabilities.size > 0) { + const logMessage = + `Filler has liabilities that cannot be removed\n` + + `Filler: ${fillerUnwind.filler.name}\n` + + `Positions: ${stringify(filler_user.positions, 2)}`; + await sendSlackNotification(logMessage); + } return true; } diff --git a/src/filler.ts b/src/filler.ts new file mode 100644 index 0000000..4a7d3c6 --- /dev/null +++ b/src/filler.ts @@ -0,0 +1,165 @@ +import { + FixedMath, + Pool, + PoolOracle, + Positions, + PositionsEstimate, + Request, + RequestType, + Reserve, +} from '@blend-capital/blend-sdk'; +import { Asset } from '@stellar/stellar-sdk'; +import { APP_CONFIG, Filler } from './utils/config.js'; +import { stringify } from './utils/json.js'; +import { logger } from './utils/logger.js'; + +/** + * Manage a filler's positions in the pool. Returns an array of requests to be submitted to the network. This function + * will attempt to repay liabilities with the filler's assets, and withdraw any unnecessary collateral, up to either the min + * collateral balance or the min health factor. + * + * Note - some buffer is applied to ensure that subsequent calls to "managePositions" does not create dust. + * + * @param filler - The filler + * @param pool - The pool + * @param poolOracle - The pool's oracle object + * @param poolUser - The filler's pool user object + * @param balances - The filler's balances + * @returns An array of requests to be submitted to the network, or an empty array if no actions are required + */ +export function managePositions( + filler: Filler, + pool: Pool, + poolOracle: PoolOracle, + positions: Positions, + balances: Map +): Request[] { + let requests: Request[] = []; + const positionsEst = PositionsEstimate.build(pool, poolOracle, positions); + let effectiveLiabilities = positionsEst.totalEffectiveLiabilities; + let effectiveCollateral = positionsEst.totalEffectiveCollateral; + + const hasLeftoverLiabilities: number[] = []; + // attempt to repay any liabilities the filler has + for (const [assetIndex, amount] of positions.liabilities) { + const reserve = pool.reserves.get(pool.config.reserveList[assetIndex]); + // this should never happen + if (reserve === undefined) { + logger.error( + `UNEXPECTED: Reserve not found for asset index: ${assetIndex}, positions: ${stringify(positions)}` + ); + continue; + } + // if no price is found, assume 0, so effective liabilities won't change + const oraclePrice = poolOracle.getPriceFloat(reserve.assetId) ?? 0; + const isNative = reserve.assetId === Asset.native().contractId(APP_CONFIG.networkPassphrase); + let tokenBalance = balances.get(reserve.assetId) ?? 0n; + // require that at least 50 XLM is left in the wallet + if (isNative) { + tokenBalance = + tokenBalance > FixedMath.toFixed(50, 7) ? tokenBalance - FixedMath.toFixed(50, 7) : 0n; + } + if (tokenBalance > 0n) { + const balanceAsDTokens = reserve.toDTokensFromAssetFloor(tokenBalance); + const repaidLiability = balanceAsDTokens <= amount ? balanceAsDTokens : amount; + if (balanceAsDTokens <= amount) { + hasLeftoverLiabilities.push(assetIndex); + } + const effectiveLiability = + reserve.toEffectiveAssetFromDTokenFloat(repaidLiability) * oraclePrice; + effectiveLiabilities -= effectiveLiability; + // repay will pull down repayment amount if greater than liabilities + requests.push({ + request_type: RequestType.Repay, + address: reserve.assetId, + amount: tokenBalance, + }); + } + } + + // short circuit collateral withdrawal if close to min hf + // this avoids very small amout of dust collateral being withdrawn and + // causing unwind events to loop + if (filler.minHealthFactor * 1.01 > effectiveCollateral / effectiveLiabilities) { + return requests; + } + + // withdrawing collateral needs to be prioritized + // 1. withdraw from assets where the filler maintains a liability + // 2. withdraw positions completely to minimize # of positions + // 3. if no liabilities, withdraw the pimary asset down to min collateral + + // build list of collateral so we can sort it by size ascending + const collateralList: { reserve: Reserve; price: number; amount: bigint; size: number }[] = []; + + for (const [assetIndex, amount] of positions.collateral) { + const reserve = pool.reserves.get(pool.config.reserveList[assetIndex]); + // this should never happen + if (reserve === undefined) { + logger.error( + `UNEXPECTED: Reserve not found for asset index: ${assetIndex}, positions: ${stringify(positions)}` + ); + continue; + } + const price = poolOracle.getPriceFloat(reserve.assetId) ?? 0; + if (price === 0) { + logger.warn( + `Unable to find price for asset: ${reserve.assetId}, skipping collateral withdrawal.` + ); + continue; + } + // hacky - set size to zero for (1), to ensure they are withdrawn first + if (hasLeftoverLiabilities.includes(assetIndex)) { + collateralList.push({ reserve, price, amount, size: 0 }); + } + // hacky - set size to MAX for (3), to ensure it is withdrawn last + else if (reserve.assetId === filler.primaryAsset) { + collateralList.push({ reserve, price, amount, size: Number.MAX_SAFE_INTEGER }); + } else { + const size = reserve.toEffectiveAssetFromBTokenFloat(amount) * price; + collateralList.push({ reserve, price, amount, size }); + } + } + collateralList.sort((a, b) => a.size - b.size); + + // attempt to withdraw any collateral that is not needed + for (const { reserve, price, amount } of collateralList) { + let withdrawAmount: bigint; + if (hasLeftoverLiabilities.length === 0) { + // no liabilities, withdraw the full position + withdrawAmount = BigInt('9223372036854775807'); + } else { + if (filler.minHealthFactor * 1.005 > effectiveCollateral / effectiveLiabilities) { + // stop withdrawing collateral if close to min health factor + break; + } + const maxWithdraw = + (effectiveCollateral - effectiveLiabilities * filler.minHealthFactor) / + (reserve.getCollateralFactor() * price); + const position = reserve.toAssetFromBTokenFloat(amount); + withdrawAmount = + maxWithdraw > position ? BigInt('9223372036854775807') : FixedMath.toFixed(maxWithdraw, 7); + } + + // if this is not a full withdrawal, and the colleratal is not also a liability, stop + if ( + !hasLeftoverLiabilities.includes(reserve.config.index) && + withdrawAmount !== BigInt('9223372036854775807') + ) { + break; + } + // require the filler to keep at least the min collateral balance of their primary asset + if (reserve.assetId === filler.primaryAsset) { + const toMinPosition = reserve.toAssetFromBToken(amount) - filler.minPrimaryCollateral; + withdrawAmount = withdrawAmount > toMinPosition ? toMinPosition : withdrawAmount; + } + const withdrawnBToken = reserve.toBTokensFromAssetFloor(withdrawAmount); + effectiveCollateral -= reserve.toEffectiveAssetFromBTokenFloat(withdrawnBToken) * price; + requests.push({ + request_type: RequestType.WithdrawCollateral, + address: reserve.assetId, + amount: withdrawAmount, + }); + } + return requests; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index d6e3483..864afd8 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -5,8 +5,10 @@ import { parse } from './json.js'; export interface Filler { name: string; keypair: Keypair; + primaryAsset: string; minProfitPct: number; minHealthFactor: number; + minPrimaryCollateral: bigint; forceFill: boolean; supportedBid: string[]; supportedLot: string[]; @@ -81,12 +83,15 @@ export function validateFiller(filler: any): boolean { typeof filler.minProfitPct === 'number' && typeof filler.minHealthFactor === 'number' && typeof filler.forceFill === 'boolean' && + typeof filler.primaryAsset === 'string' && + typeof filler.minPrimaryCollateral === 'string' && Array.isArray(filler.supportedBid) && filler.supportedBid.every((item: any) => typeof item === 'string') && Array.isArray(filler.supportedLot) && filler.supportedLot.every((item: any) => typeof item === 'string') ) { filler.keypair = Keypair.fromSecret(filler.keypair); + filler.minPrimaryCollateral = BigInt(filler.minPrimaryCollateral); return true; } return false; diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index 8b03017..3fef099 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -133,6 +133,33 @@ export class SorobanHelper { ); } + async loadBalances(userId: string, tokens: string[]): Promise> { + try { + let balances = new Map(); + + // break tokens array into chunks of at most 5 tokens + let concurrency_limit = 5; + let promise_chunks: string[][] = []; + for (let i = 0; i < tokens.length; i += concurrency_limit) { + promise_chunks.push(tokens.slice(i, i + concurrency_limit)); + } + + // fetch each chunk of token balances concurrently + for (const chunk of promise_chunks) { + const chunkResults = await Promise.all( + chunk.map((token) => this.simBalance(token, userId)) + ); + chunk.forEach((token, index) => { + balances.set(token, chunkResults[index]); + }); + } + return balances; + } catch (e) { + logger.error(`Error loading balances: ${e}`); + throw e; + } + } + async simLPTokenToUSDC(amount: bigint): Promise { try { let comet = new Contract(APP_CONFIG.backstopTokenAddress); diff --git a/test/auction.test.ts b/test/auction.test.ts index 774195c..d9bb9cf 100644 --- a/test/auction.test.ts +++ b/test/auction.test.ts @@ -12,30 +12,13 @@ import { AuctioneerDatabase, AuctionType } from '../src/utils/db.js'; import { SorobanHelper } from '../src/utils/soroban_helper.js'; import { inMemoryAuctioneerDb, + mockedPool, mockPoolOracle, mockPoolUser, mockPoolUserEstimate, - mockedPool, } from './helpers/mocks.js'; -jest.mock('../src/utils/soroban_helper.js', () => { - return { - SorobanHelper: jest.fn().mockImplementation(() => { - return { - loadPool: jest.fn().mockReturnValue(mockedPool), - loadUser: jest.fn().mockReturnValue(mockPoolUser), - loadUserPositionEstimate: jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }), - simLPTokenToUSDC: jest.fn().mockImplementation((number: bigint) => { - return (number * 33333n) / 100000n; - }), - loadPoolOracle: jest.fn().mockReturnValue(mockPoolOracle), - }; - }), - }; -}); - +jest.mock('../src/utils/soroban_helper.js'); jest.mock('../src/utils/config.js', () => { return { APP_CONFIG: { @@ -52,794 +35,690 @@ jest.mock('../src/utils/config.js', () => { }; }); -describe('calculateBlockFillAndPercent', () => { +describe('auction', () => { let filler: Filler; - let sorobanHelper: SorobanHelper; + const mockedSorobanHelper = new SorobanHelper() as jest.Mocked; let db: AuctioneerDatabase; + beforeEach(() => { - sorobanHelper = new SorobanHelper(); + jest.resetAllMocks(); + db = inMemoryAuctioneerDb(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue(mockPoolUser); + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + estimate: mockPoolUserEstimate, + user: mockPoolUser, + }); + mockedSorobanHelper.simLPTokenToUSDC.mockImplementation((number: bigint) => { + return Promise.resolve((number * 33333n) / 100000n); + }); filler = { name: 'Tester', keypair: Keypair.random(), minProfitPct: 0.2, minHealthFactor: 1.3, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: true, supportedBid: [], supportedLot: [], }; - db = inMemoryAuctioneerDb(); }); - it('test user liquidation expect fill under 200', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ]), - block: 123, - }; + describe('calculateBlockFillAndPercent', () => { + it('test user liquidation expect fill under 200', async () => { + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], + ]), + block: 123, + }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(312); - expect(fillCalc.fillPercent).toEqual(100); - }); + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Liquidation, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(312); + expect(fillCalc.fillPercent).toEqual(100); + }); - it('test user liquidation expect fill over 200', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(343); - expect(fillCalc.fillPercent).toEqual(100); - }); + it('test user liquidation expect fill over 200', async () => { + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], + ]), + block: 123, + }; + filler.forceFill = false; + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Liquidation, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(343); + expect(fillCalc.fillPercent).toEqual(100); + }); - it('test force fill user liquidations sets fill to 198', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], - ]), - block: 123, - }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(321); - expect(fillCalc.fillPercent).toEqual(100); - }); + it('test force fill user liquidations sets fill to 198', async () => { + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], + ]), + block: 123, + }; + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Liquidation, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(321); + expect(fillCalc.fillPercent).toEqual(100); + }); - it('test user liquidation does not exceed min health factor', async () => { - mockPoolUserEstimate.totalEffectiveLiabilities = 18660; - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 88000_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(339); - expect(fillCalc.fillPercent).toEqual(50); - }); + it('test user liquidation does not exceed min health factor', async () => { + mockPoolUserEstimate.totalEffectiveLiabilities = 18660; + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + estimate: mockPoolUserEstimate, + user: mockPoolUser, + }); + + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 88000_0000000n], + ]), + block: 123, + }; + filler.forceFill = false; + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Liquidation, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(339); + expect(fillCalc.fillPercent).toEqual(50); + }); + + it('test interest auction', async () => { + mockedSorobanHelper.simBalance.mockResolvedValue(5000_0000000n); + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 2000_0000000n], + ]), + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], + ]), + block: 123, + }; - it('test interest auction', async () => { - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - sorobanHelper.simBalance = jest.fn().mockReturnValue(5000_0000000n); - sorobanHelper.simLPTokenToUSDC = jest.fn().mockImplementation((number) => { - return (number * 33333n) / 100000n; + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Interest, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(419); + expect(fillCalc.fillPercent).toEqual(100); }); - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 2000_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], - ]), - block: 123, - }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(419); - expect(fillCalc.fillPercent).toEqual(100); - }); + it('test force fill for interest auction', async () => { + mockedSorobanHelper.simBalance.mockResolvedValue(5000_0000000n); + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1_0000000n], + ]), + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], + ]), + block: 123, + }; - it('test force fill for interest auction', async () => { - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - sorobanHelper.simBalance = jest.fn().mockReturnValue(5000_0000000n); - sorobanHelper.simLPTokenToUSDC = jest.fn().mockImplementation((number) => { - return (number * 33333n) / 100000n; + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Interest, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(473); + expect(fillCalc.fillPercent).toEqual(100); }); - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], - ]), - block: 123, - }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(473); - expect(fillCalc.fillPercent).toEqual(100); - }); - it('test interest auction increases block fill delay to fully fill', async () => { - sorobanHelper.simBalance = jest.fn().mockReturnValue(2000_0000000n); - - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 4242_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(429); - expect(fillCalc.fillPercent).toEqual(100); - }); + it('test interest auction increases block fill delay to fully fill', async () => { + mockedSorobanHelper.simBalance.mockResolvedValue(2000_0000000n); + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], + ]), + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 4242_0000000n], + ]), + block: 123, + }; + filler.forceFill = false; + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.Interest, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(429); + expect(fillCalc.fillPercent).toEqual(100); + }); - it('test bad debt auction', async () => { - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 456_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 456_0000000n], - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 123_0000000n], - ]), - block: 123, - }; + it('test bad debt auction', async () => { + let auctionData = { + lot: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 456_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 456_0000000n], + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 123_0000000n], + ]), + block: 123, + }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.BadDebt, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(380); - expect(fillCalc.fillPercent).toEqual(100); + let fillCalc = await calculateBlockFillAndPercent( + filler, + AuctionType.BadDebt, + auctionData, + mockedSorobanHelper, + db + ); + expect(fillCalc.fillBlock).toEqual(380); + expect(fillCalc.fillPercent).toEqual(100); + }); }); -}); -describe('calculateAuctionValue', () => { - let sorobanHelper = new SorobanHelper(); - let db = inMemoryAuctioneerDb(); - it('test valuing user auction', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + describe('calculateAuctionValue', () => { + it('test valuing user auction', async () => { + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + ]), + block: 123, + }; - let result = await calculateAuctionValue( - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(result.bidValue).toBeCloseTo(562.42); - expect(result.lotValue).toBeCloseTo(1242.24); - expect(result.effectiveCollateral).toBeCloseTo(1180.13); - expect(result.effectiveLiabilities).toBeCloseTo(749.89); - }); + let result = await calculateAuctionValue( + AuctionType.Liquidation, + auctionData, + mockedSorobanHelper, + db + ); + expect(result.bidValue).toBeCloseTo(562.42); + expect(result.lotValue).toBeCloseTo(1242.24); + expect(result.effectiveCollateral).toBeCloseTo(1180.13); + expect(result.effectiveLiabilities).toBeCloseTo(749.89); + }); - it('test valuing interest auction', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - block: 123, - }; + it('test valuing interest auction', async () => { + let auctionData = { + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + ]), + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], + ]), + block: 123, + }; - let result = await calculateAuctionValue(AuctionType.Interest, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(4115184.85); - expect(result.lotValue).toBeCloseTo(1795.72); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(0); - }); + let result = await calculateAuctionValue( + AuctionType.Interest, + auctionData, + mockedSorobanHelper, + db + ); + expect(result.bidValue).toBeCloseTo(4115184.85); + expect(result.lotValue).toBeCloseTo(1795.72); + expect(result.effectiveCollateral).toBeCloseTo(0); + expect(result.effectiveLiabilities).toBeCloseTo(0); + }); - it('test valuing bad debt auction', async () => { - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - bid: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + it('test valuing bad debt auction', async () => { + let auctionData = { + lot: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], + ]), + bid: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + ]), + block: 123, + }; - let result = await calculateAuctionValue(AuctionType.BadDebt, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(1808.6); - expect(result.lotValue).toBeCloseTo(4115184.85); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(2061.66); - }); - it('test valuing lp token when simLPTokenToUSDC is not defined', async () => { - sorobanHelper.simLPTokenToUSDC = jest.fn().mockResolvedValue(undefined); - sorobanHelper.loadBackstopToken = jest - .fn() - .mockResolvedValue(new BackstopToken('id', 100n, 100n, 100n, 100, 100, 1)); - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - bid: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + let result = await calculateAuctionValue( + AuctionType.BadDebt, + auctionData, + mockedSorobanHelper, + db + ); + expect(result.bidValue).toBeCloseTo(1808.6); + expect(result.lotValue).toBeCloseTo(4115184.85); + expect(result.effectiveCollateral).toBeCloseTo(0); + expect(result.effectiveLiabilities).toBeCloseTo(2061.66); + }); - let result = await calculateAuctionValue(AuctionType.BadDebt, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(1808.6); - expect(result.lotValue).toBeCloseTo(12345678); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(2061.66); - - auctionData = { - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + it('test valuing lp token when simLPTokenToUSDC is not defined', async () => { + mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(undefined); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue( + new BackstopToken('id', 100n, 100n, 100n, 100, 100, 1) + ); + let auctionData = { + lot: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], + ]), + bid: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + ]), + block: 123, + }; - result = await calculateAuctionValue(AuctionType.Interest, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(12345678); - expect(result.lotValue).toBeCloseTo(1795.72); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(0); - }); -}); + let result = await calculateAuctionValue( + AuctionType.BadDebt, + auctionData, + mockedSorobanHelper, + db + ); + expect(result.bidValue).toBeCloseTo(1808.6); + expect(result.lotValue).toBeCloseTo(12345678); + expect(result.effectiveCollateral).toBeCloseTo(0); + expect(result.effectiveLiabilities).toBeCloseTo(2061.66); + + auctionData = { + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], + ]), + lot: new Map([ + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + ]), + block: 123, + }; -describe('buildFillRequests', () => { - let sorobanHelper = new SorobanHelper(); - it('test user liquidation auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Interest, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ]), - bid: new Map([ - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillInterestAuction, - address: user.publicKey(), - amount: 100n, - }, - ]; - expect(requests.length).toEqual(1); - expect(requests).toEqual(expectRequests); - }); - it('test interest auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Interest, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 50000_0000000n], - ]), - block: 123, - }; - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillInterestAuction, - address: user.publicKey(), - amount: 100n, - }, - ]; - expect(requests.length).toEqual(1); - expect(requests).toEqual(expectRequests); + result = await calculateAuctionValue( + AuctionType.Interest, + auctionData, + mockedSorobanHelper, + db + ); + expect(result.bidValue).toBeCloseTo(12345678); + expect(result.lotValue).toBeCloseTo(1795.72); + expect(result.effectiveCollateral).toBeCloseTo(0); + expect(result.effectiveLiabilities).toBeCloseTo(0); + }); }); - it('test bad debt auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.BadDebt, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 30000_0000000n], - ]), - bid: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_0000000n; - else return 0; + describe('buildFillRequests', () => { + let sorobanHelper = new SorobanHelper(); + it('test user liquidation auction requests', async () => { + const filler = Keypair.random(); + const user = Keypair.random(); + const auctionBid: AuctionBid = { + type: BidderSubmissionType.BID, + filler: { + name: '', + keypair: filler, + minProfitPct: 0.2, + minHealthFactor: 1.2, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, + forceFill: false, + supportedBid: [], + supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], + }, + auctionEntry: { + user_id: user.publicKey(), + auction_type: AuctionType.Liquidation, + filler: filler.publicKey(), + start_block: 0, + fill_block: 0, + updated: 0, + }, + }; + let auctionData = { + lot: new Map([ + ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], + ]), + bid: new Map([ + ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], + ]), + block: 123, + }; + sorobanHelper.simBalance = jest.fn().mockResolvedValue(0n); + + let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); + let expectRequests: Request[] = [ + { + request_type: RequestType.FillUserLiquidationAuction, + address: user.publicKey(), + amount: 100n, + }, + ]; + expect(requests.length).toEqual(1); + expect(requests).toEqual(expectRequests); }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillBadDebtAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 10000_0000000n, - }, - { - request_type: RequestType.Repay, - address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - amount: 500_0000000n, - }, - ]; - expect(requests.length).toEqual(3); - expect(requests).toEqual(expectRequests); - }); - it('test repay xlm does not use full balance', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 95000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else return 0; + it('test interest auction requests', async () => { + const filler = Keypair.random(); + const user = Keypair.random(); + const auctionBid: AuctionBid = { + type: BidderSubmissionType.BID, + filler: { + name: '', + keypair: filler, + minProfitPct: 0.2, + minHealthFactor: 1.2, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, + forceFill: false, + supportedBid: [], + supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], + }, + auctionEntry: { + user_id: user.publicKey(), + auction_type: AuctionType.Interest, + filler: filler.publicKey(), + start_block: 0, + fill_block: 0, + updated: 0, + }, + }; + let auctionData = { + lot: new Map([ + ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], + ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], + ]), + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 50000_0000000n], + ]), + block: 123, + }; + let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); + let expectRequests: Request[] = [ + { + request_type: RequestType.FillInterestAuction, + address: user.publicKey(), + amount: 100n, + }, + ]; + expect(requests.length).toEqual(1); + expect(requests).toEqual(expectRequests); }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 95000_0000000n - BigInt(100e7), - }, - { - request_type: RequestType.Repay, - address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - amount: 500_0000000n, - }, - { - request_type: RequestType.WithdrawCollateral, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 9223372036854775807n, - }, - ]; - expect(requests.length).toEqual(4); - expect(requests).toEqual(expectRequests); - }); - it('test requests does not withdraw exisiting supplied position', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 800_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 95000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_0000000n; - else return 0n; + it('test bad debt auction requests', async () => { + const filler = Keypair.random(); + const user = Keypair.random(); + const auctionBid: AuctionBid = { + type: BidderSubmissionType.BID, + filler: { + name: '', + keypair: filler, + minProfitPct: 0.2, + minHealthFactor: 1.2, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, + forceFill: false, + supportedBid: [], + supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], + }, + auctionEntry: { + user_id: user.publicKey(), + auction_type: AuctionType.BadDebt, + filler: filler.publicKey(), + start_block: 0, + fill_block: 0, + updated: 0, + }, + }; + let auctionData = { + lot: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 30000_0000000n], + ]), + bid: new Map([ + ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], + ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], + ]), + block: 123, + }; + sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { + if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') + return 500_0000000n; + else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') + return 10000_0000000n; + else return 0; + }); + let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); + let expectRequests: Request[] = [ + { + request_type: RequestType.FillBadDebtAuction, + address: user.publicKey(), + amount: 100n, + }, + { + request_type: RequestType.Repay, + address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', + amount: 10000_0000000n, + }, + { + request_type: RequestType.Repay, + address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + amount: 500_0000000n, + }, + ]; + expect(requests.length).toEqual(3); + expect(requests).toEqual(expectRequests); }); - mockPoolUser.positions.collateral.set( - mockedPool.config.reserveList.indexOf( - 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' - ), - 1n - ); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 10000_0000000n, - }, - ]; - expect(requests.length).toEqual(2); - expect(requests).toEqual(expectRequests); - }); - it('test requests does not withdraw below min health factor', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: [], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - mockPoolUserEstimate.totalEffectiveLiabilities = 15660; - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 8000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 10_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 70000_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 10000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_000000n; + it('test repay xlm does not use full balance', async () => { + const filler = Keypair.random(); + const user = Keypair.random(); + const auctionBid: AuctionBid = { + type: BidderSubmissionType.BID, + filler: { + name: '', + keypair: filler, + minProfitPct: 0.2, + minHealthFactor: 1.2, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, + forceFill: false, + supportedBid: [], + supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], + }, + auctionEntry: { + user_id: user.publicKey(), + auction_type: AuctionType.Liquidation, + filler: filler.publicKey(), + start_block: 0, + fill_block: 0, + updated: 0, + }, + }; + let auctionData = { + lot: new Map([ + ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], + ]), + bid: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], + ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], + ]), + block: 123, + }; + sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { + if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') + return 95000_0000000n; + else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') + return 500_0000000n; + else return 0; + }); + let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); + let expectRequests: Request[] = [ + { + request_type: RequestType.FillUserLiquidationAuction, + address: user.publicKey(), + amount: 100n, + }, + { + request_type: RequestType.Repay, + address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + amount: 95000_0000000n - BigInt(50e7), + }, + { + request_type: RequestType.Repay, + address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', + amount: 500_0000000n, + }, + ]; + expect(requests.length).toEqual(3); + expect(requests).toEqual(expectRequests); }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 9900_0000000n, - }, - { - request_type: RequestType.WithdrawCollateral, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 9223372036854775807n, - }, - ]; - expect(requests.length).toEqual(3); - expect(requests).toEqual(expectRequests); }); - it('test requests does not repay or withdraw without oracle price', async () => {}); -}); - -describe('scaleAuction', () => { - it('test auction scaling', () => { - const auctionData = { - lot: new Map([ - ['asset2', 1_0000000n], - ['asset3', 5_0000001n], - ]), - bid: new Map([ - ['asset1', 100_0000000n], - ['asset2', 200_0000001n], - ]), - block: 123, - }; - let scaledAuction = scaleAuction(auctionData, 123, 100); - expect(scaledAuction.block).toEqual(123); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(0); - - // 100 blocks -> 100 percent, validate lot is rounded down - scaledAuction = scaleAuction(auctionData, 223, 100); - expect(scaledAuction.block).toEqual(223); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(5000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(2_5000000n); - - // 100 blocks -> 50 percent, validate bid is rounded up - scaledAuction = scaleAuction(auctionData, 223, 50); - expect(scaledAuction.block).toEqual(223); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(2500000n); - expect(scaledAuction.lot.get('asset3')).toEqual(1_2500000n); - - // 200 blocks -> 100 percent (is same) - scaledAuction = scaleAuction(auctionData, 323, 100); - expect(scaledAuction.block).toEqual(323); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 200 blocks -> 75 percent, validate bid is rounded up and lot is rounded down - scaledAuction = scaleAuction(auctionData, 323, 75); - expect(scaledAuction.block).toEqual(323); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(75_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(150_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(7500000n); - expect(scaledAuction.lot.get('asset3')).toEqual(3_7500000n); - - // 300 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 423, 100); - expect(scaledAuction.block).toEqual(423); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 400 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 523, 100); - expect(scaledAuction.block).toEqual(523); - expect(scaledAuction.bid.size).toEqual(0); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 500 blocks -> 100 percent (unchanged) - scaledAuction = scaleAuction(auctionData, 623, 100); - expect(scaledAuction.block).toEqual(623); - expect(scaledAuction.bid.size).toEqual(0); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - }); + describe('scaleAuction', () => { + it('test auction scaling', () => { + const auctionData = { + lot: new Map([ + ['asset2', 1_0000000n], + ['asset3', 5_0000001n], + ]), + bid: new Map([ + ['asset1', 100_0000000n], + ['asset2', 200_0000001n], + ]), + block: 123, + }; + let scaledAuction = scaleAuction(auctionData, 123, 100); + expect(scaledAuction.block).toEqual(123); + expect(scaledAuction.bid.size).toEqual(2); + expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); + expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); + expect(scaledAuction.lot.size).toEqual(0); + + // 100 blocks -> 100 percent, validate lot is rounded down + scaledAuction = scaleAuction(auctionData, 223, 100); + expect(scaledAuction.block).toEqual(223); + expect(scaledAuction.bid.size).toEqual(2); + expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); + expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(5000000n); + expect(scaledAuction.lot.get('asset3')).toEqual(2_5000000n); + + // 100 blocks -> 50 percent, validate bid is rounded up + scaledAuction = scaleAuction(auctionData, 223, 50); + expect(scaledAuction.block).toEqual(223); + expect(scaledAuction.bid.size).toEqual(2); + expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); + expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(2500000n); + expect(scaledAuction.lot.get('asset3')).toEqual(1_2500000n); + + // 200 blocks -> 100 percent (is same) + scaledAuction = scaleAuction(auctionData, 323, 100); + expect(scaledAuction.block).toEqual(323); + expect(scaledAuction.bid.size).toEqual(2); + expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); + expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); + expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); + + // 200 blocks -> 75 percent, validate bid is rounded up and lot is rounded down + scaledAuction = scaleAuction(auctionData, 323, 75); + expect(scaledAuction.block).toEqual(323); + expect(scaledAuction.bid.size).toEqual(2); + expect(scaledAuction.bid.get('asset1')).toEqual(75_0000000n); + expect(scaledAuction.bid.get('asset2')).toEqual(150_0000001n); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(7500000n); + expect(scaledAuction.lot.get('asset3')).toEqual(3_7500000n); + + // 300 blocks -> 100 percent + scaledAuction = scaleAuction(auctionData, 423, 100); + expect(scaledAuction.block).toEqual(423); + expect(scaledAuction.bid.size).toEqual(2); + expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); + expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); + expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); + + // 400 blocks -> 100 percent + scaledAuction = scaleAuction(auctionData, 523, 100); + expect(scaledAuction.block).toEqual(523); + expect(scaledAuction.bid.size).toEqual(0); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); + expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); + + // 500 blocks -> 100 percent (unchanged) + scaledAuction = scaleAuction(auctionData, 623, 100); + expect(scaledAuction.block).toEqual(623); + expect(scaledAuction.bid.size).toEqual(0); + expect(scaledAuction.lot.size).toEqual(2); + expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); + expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); + }); - it('test auction scaling with 1 stroop', () => { - const auctionData = { - lot: new Map([['asset2', 1n]]), - bid: new Map([['asset1', 1n]]), - block: 123, - }; - // 1 blocks -> 10 percent - let scaledAuction = scaleAuction(auctionData, 124, 10); - expect(scaledAuction.block).toEqual(124); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(0); - - // 399 blocks -> 10 percent - scaledAuction = scaleAuction(auctionData, 522, 10); - expect(scaledAuction.block).toEqual(522); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(0); - - // 399 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 522, 100); - expect(scaledAuction.block).toEqual(522); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(1); - expect(scaledAuction.lot.get('asset2')).toEqual(1n); + it('test auction scaling with 1 stroop', () => { + const auctionData = { + lot: new Map([['asset2', 1n]]), + bid: new Map([['asset1', 1n]]), + block: 123, + }; + // 1 blocks -> 10 percent + let scaledAuction = scaleAuction(auctionData, 124, 10); + expect(scaledAuction.block).toEqual(124); + expect(scaledAuction.bid.size).toEqual(1); + expect(scaledAuction.bid.get('asset1')).toEqual(1n); + expect(scaledAuction.lot.size).toEqual(0); + + // 399 blocks -> 10 percent + scaledAuction = scaleAuction(auctionData, 522, 10); + expect(scaledAuction.block).toEqual(522); + expect(scaledAuction.bid.size).toEqual(1); + expect(scaledAuction.bid.get('asset1')).toEqual(1n); + expect(scaledAuction.lot.size).toEqual(0); + + // 399 blocks -> 100 percent + scaledAuction = scaleAuction(auctionData, 522, 100); + expect(scaledAuction.block).toEqual(522); + expect(scaledAuction.bid.size).toEqual(1); + expect(scaledAuction.bid.get('asset1')).toEqual(1n); + expect(scaledAuction.lot.size).toEqual(1); + expect(scaledAuction.lot.get('asset2')).toEqual(1n); + }); }); }); diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 3251a1f..7d2fc65 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -1,4 +1,4 @@ -import { RequestType } from '@blend-capital/blend-sdk'; +import { Request, RequestType } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; import { buildFillRequests, @@ -12,18 +12,19 @@ import { BidderSubmitter, FillerUnwind, } from '../src/bidder_submitter'; +import { managePositions } from '../src/filler'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db'; import { logger } from '../src/utils/logger'; import { sendSlackNotification } from '../src/utils/slack_notifier'; import { SorobanHelper } from '../src/utils/soroban_helper'; -import { inMemoryAuctioneerDb } from './helpers/mocks'; +import { inMemoryAuctioneerDb, mockedPool, mockPoolOracle, mockPoolUser } from './helpers/mocks'; // Mock dependencies jest.mock('../src/utils/db'); jest.mock('../src/utils/soroban_helper'); jest.mock('../src/auction'); jest.mock('../src/utils/slack_notifier'); -jest.mock('@blend-capital/blend-sdk'); +jest.mock('../src/filler'); jest.mock('../src/utils/soroban_helper'); jest.mock('@stellar/stellar-sdk', () => { const actual = jest.requireActual('@stellar/stellar-sdk'); @@ -87,14 +88,16 @@ describe('BidderSubmitter', () => { const mockedSendSlackNotif = sendSlackNotification as jest.MockedFunction< typeof sendSlackNotification >; - let mockCalculateBlockFillAndPercent = calculateBlockFillAndPercent as jest.MockedFunction< + const mockCalculateBlockFillAndPercent = calculateBlockFillAndPercent as jest.MockedFunction< typeof calculateBlockFillAndPercent >; - let mockScaleAuction = scaleAuction as jest.MockedFunction; - let mockBuildFillRequests = buildFillRequests as jest.MockedFunction; - let mockCalculateAuctionValue = calculateAuctionValue as jest.MockedFunction< + const mockScaleAuction = scaleAuction as jest.MockedFunction; + const mockBuildFillRequests = buildFillRequests as jest.MockedFunction; + const mockCalculateAuctionValue = calculateAuctionValue as jest.MockedFunction< typeof calculateAuctionValue >; + const mockedManagePositions = managePositions as jest.MockedFunction; + beforeEach(() => { jest.clearAllMocks(); mockDb = inMemoryAuctioneerDb(); @@ -102,6 +105,7 @@ describe('BidderSubmitter', () => { }); it('should submit a bid successfully', async () => { + bidderSubmitter.addSubmission = jest.fn(); mockCalculateBlockFillAndPercent.mockResolvedValue({ fillBlock: 1000, fillPercent: 50 }); mockScaleAuction.mockReturnValue({ bid: new Map([['USD', BigInt(12)]]), @@ -111,8 +115,8 @@ describe('BidderSubmitter', () => { mockBuildFillRequests.mockResolvedValue([ { request_type: RequestType.FillUserLiquidationAuction, - address: '', - amount: 0n, + address: Keypair.random().publicKey(), + amount: 100n, }, ]); mockCalculateAuctionValue.mockResolvedValue({ @@ -129,6 +133,8 @@ describe('BidderSubmitter', () => { keypair: Keypair.random(), minProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], @@ -136,7 +142,7 @@ describe('BidderSubmitter', () => { auctionEntry: { user_id: 'test-user', auction_type: AuctionType.Liquidation, - filler: 'test-filler', + filler: Keypair.random().publicKey(), start_block: 900, fill_block: 1000, } as AuctionEntry, @@ -151,9 +157,14 @@ describe('BidderSubmitter', () => { ); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); expect(mockDb.setFilledAuctionEntry).toHaveBeenCalled(); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( + { type: BidderSubmissionType.UNWIND, filler: submission.filler }, + 2 + ); }); it('should handle auction already filled', async () => { + bidderSubmitter.addSubmission = jest.fn(); mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); mockedSorobanHelperConstructor.mockReturnValue(mockedSorobanHelper); const submission: AuctionBid = { @@ -163,6 +174,8 @@ describe('BidderSubmitter', () => { keypair: Keypair.random(), minProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], @@ -179,6 +192,87 @@ describe('BidderSubmitter', () => { expect(mockDb.deleteAuctionEntry).toHaveBeenCalledWith('test-user', AuctionType.Liquidation); }); + it('should manage positions during unwind', async () => { + const fillerBalance = new Map([['USD', 123n]]); + const unwindRequest: Request[] = [ + { + request_type: RequestType.Repay, + address: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + amount: 123n, + }, + ]; + + bidderSubmitter.addSubmission = jest.fn(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue(mockPoolUser); + mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); + + mockedManagePositions.mockReturnValue(unwindRequest); + + const submission: FillerUnwind = { + type: BidderSubmissionType.UNWIND, + filler: { + name: 'test-filler', + keypair: Keypair.random(), + minProfitPct: 0, + minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + forceFill: false, + supportedBid: ['USD', 'XLM'], + supportedLot: ['EURC', 'XLM'], + }, + }; + let result = await bidderSubmitter.submit(submission); + + expect(result).toBe(true); + expect(mockedSorobanHelper.loadBalances).toHaveBeenCalledWith( + submission.filler.keypair.publicKey(), + ['USD', 'XLM', 'EURC'] + ); + expect(mockedManagePositions).toHaveBeenCalled(); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith(submission, 2); + }); + + it('should stop submitting unwind events when no action is taken', async () => { + const fillerBalance = new Map([['USD', 123n]]); + const unwindRequest: Request[] = []; + + bidderSubmitter.addSubmission = jest.fn(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue(mockPoolUser); + mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); + + mockedManagePositions.mockReturnValue(unwindRequest); + + const submission: FillerUnwind = { + type: BidderSubmissionType.UNWIND, + filler: { + name: 'test-filler', + keypair: Keypair.random(), + minProfitPct: 0, + minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + forceFill: false, + supportedBid: ['USD', 'XLM'], + supportedLot: ['EURC', 'XLM'], + }, + }; + let result = await bidderSubmitter.submit(submission); + + expect(result).toBe(true); + expect(mockedSorobanHelper.loadBalances).toHaveBeenCalledWith( + submission.filler.keypair.publicKey(), + ['USD', 'XLM', 'EURC'] + ); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); + }); + it('should return true if auction is in the queue', () => { const auctionEntry: AuctionEntry = { user_id: 'test-user', @@ -215,6 +309,8 @@ describe('BidderSubmitter', () => { keypair: Keypair.random(), minProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], @@ -256,6 +352,8 @@ describe('BidderSubmitter', () => { keypair: Keypair.random(), minProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], diff --git a/test/filler.test.ts b/test/filler.test.ts new file mode 100644 index 0000000..c2e9401 --- /dev/null +++ b/test/filler.test.ts @@ -0,0 +1,323 @@ +import { + FixedMath, + PoolOracle, + Positions, + PriceData, + Request, + RequestType, +} from '@blend-capital/blend-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; +import { managePositions } from '../src/filler'; +import { Filler } from '../src/utils/config'; +import { mockedPool } from './helpers/mocks'; + +jest.mock('../src/utils/config.js', () => { + return { + APP_CONFIG: { + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }, + }; +}); +jest.mock('../src/utils/logger.js', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +describe('managePositions', () => { + const assets = mockedPool.config.reserveList; + const mockOracle = new PoolOracle( + 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', + new Map([ + [assets[0], { price: BigInt(1e6), timestamp: 1724949300 }], + [assets[1], { price: BigInt(1e7), timestamp: 1724949300 }], + [assets[2], { price: BigInt(1.1e7), timestamp: 1724949300 }], + [assets[3], { price: BigInt(1000e7), timestamp: 1724949300 }], + ]), + 7, + 53255053 + ); + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: assets[1], + minProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: [assets[1], assets[0]], + supportedLot: [assets[1], assets[2], assets[3]], + }; + + it('clears excess liabilities and collateral', () => { + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(100, 7)]]), + // bTokens + new Map([[2, FixedMath.toFixed(500, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(200, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(1234, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: BigInt('9223372036854775807'), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('does not withdraw collateral if a different liability still exists', () => { + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(5000, 7)]]), + // bTokens + new Map([[2, FixedMath.toFixed(4500, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(0, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(3000, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('does not withdraw primary collateral if a different liability still exists', () => { + const positions = new Positions( + // dTokens + new Map([[2, FixedMath.toFixed(4500, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(5000, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(0, 7)], + [assets[2], FixedMath.toFixed(3000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[2], + amount: FixedMath.toFixed(3000, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('can unwind looped positions', () => { + filler.minHealthFactor = 1.1; + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(50000, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(58000, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(5000, 7)], + [assets[2], FixedMath.toFixed(2000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + // return minimum health factor back to 1.5 + filler.minHealthFactor = 1.5; + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(5000, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[1], + amount: BigInt(29372567525), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('keeps XLM balance above min XLM', () => { + const positions = new Positions( + // dTokens + new Map([[0, FixedMath.toFixed(200, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(125, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(75, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[0], + amount: FixedMath.toFixed(25, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('clears collateral with no liabilities and keeps primary collateral above min collateral', () => { + const positions = new Positions( + // dTokens + new Map([]), + // bTokens + new Map([ + [1, FixedMath.toFixed(125, 7)], + [3, FixedMath.toFixed(1, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(575, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.WithdrawCollateral, + address: assets[3], + amount: BigInt('9223372036854775807'), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[1], + amount: 258738051n, + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('clears smallest collateral position first', () => { + const positions = new Positions( + // dTokens + new Map([ + [0, FixedMath.toFixed(1500, 7)], + [3, FixedMath.toFixed(2, 7)], + ]), + // bTokens + new Map([ + [1, FixedMath.toFixed(5000, 7)], + [2, FixedMath.toFixed(500, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(5000, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(0, 7)], + [assets[3], FixedMath.toFixed(1, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[0], + amount: FixedMath.toFixed(4950, 7), + }, + { + request_type: RequestType.Repay, + address: assets[3], + amount: FixedMath.toFixed(1, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: BigInt('9223372036854775807'), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('partially withdraws large collateral first when a liability position is maintained', () => { + const positions = new Positions( + // dTokens + new Map([ + [2, FixedMath.toFixed(1500, 7)], + [3, FixedMath.toFixed(2, 7)], + ]), + // bTokens + new Map([ + [0, FixedMath.toFixed(500, 7)], + [1, FixedMath.toFixed(2500, 7)], + [2, FixedMath.toFixed(3000, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(5000, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(1, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[2], + amount: FixedMath.toFixed(1000, 7), + }, + { + request_type: RequestType.Repay, + address: assets[3], + amount: FixedMath.toFixed(1, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: 14820705895n, + }, + ]; + expect(requests).toEqual(expectedRequests); + }); +}); diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index 50e7be9..04526a3 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -43,6 +43,8 @@ describe('validateAppConfig', () => { keypair: Keypair.random().secret(), minProfitPct: 1, minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], @@ -67,6 +69,8 @@ describe('validateFiller', () => { keypair: 'secret', minProfitPct: 1, minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], supportedLot: 123, // Invalid type @@ -80,6 +84,8 @@ describe('validateFiller', () => { keypair: Keypair.random().secret(), minProfitPct: 1, minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], From f577347ec61064e85a6b7ef028035956d92a2703 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Wed, 13 Nov 2024 14:29:35 -0500 Subject: [PATCH 03/13] feat: add profits config to allow flexible profit pct configuration --- README.md | 17 +- example.config.json | 15 +- src/auction.ts | 33 +- src/filler.ts | 52 +- src/pool_event_handler.ts | 2 +- src/utils/config.ts | 38 +- src/utils/prices.ts | 7 +- test/auction.test.ts | 10 +- test/bidder_handler.test.ts | 4 +- test/bidder_submitter.test.ts | 12 +- test/filler.test.ts | 828 +++++++++++++++++++++----------- test/pool_event_handler.test.ts | 4 +- 12 files changed, 685 insertions(+), 337 deletions(-) diff --git a/README.md b/README.md index 502055a..5d3e4e8 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ For an example config file that is configured to interact with [Blend v1 mainnet | `blndAddress` | The address of the BLND token contract. | | `keypair` | The secret key for the bot's auction creating account. This should be different from the fillers as auction creation and auction bidding can happen simultaneously. **Keep this secret and secure!** | | `fillers` | A list of accounts that will bid and fill on auctions. | -| `priceSources` | A list of assets that will have prices sourced from exchanges instead of the pool oracle. | +| `priceSources` | (Optional) A list of assets that will have prices sourced from exchanges instead of the pool oracle. | +| `profits` | (Optional) A list of auction profits to define different profit percentages used for matching auctions. | `slackWebhook` | (Optional) A slack webhook URL to post updates to (https://hooks.slack.com/services/). Leave undefined if no webhooks are required. | #### Fillers @@ -65,7 +66,7 @@ The `fillers` array contains configurations for individual filler accounts. The | `name` | A unique name for this filler account. Used in logs and slack notifications. | | `keypair` | The secret key for this filler account. **Keep this secret and secure!** | | `primaryAsset` | The primary asset the filler will use as collateral in the pool. | -| `minProfitPct` | The minimum profit percentage required for the filler to bid on an auction. | +| `defaultProfitPct` | The default profit percentage required for the filler to bid on an auction. | | `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions. | | `minPrimaryCollateral` | The minimum amount of the primary asset the Filler will maintain as collateral in the pool. | | `forceFill` | Boolean flag to indicate if the bot should force fill auctions even if profit expectations aren't met to ensure pool health. | @@ -88,6 +89,18 @@ Each price source has the following fields: | `type` | The type of price source (e.g., "coinbase", "binance"). | | `symbol` | The trading symbol used by the price source for this asset. | +#### 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. + +Each profit entry has the following fields: + +| Field | Description | +|-------|-------------| +| `profitPct` | The profit percentage required to bid for the auction. | +| `supportedBid` | An array of asset addresses that the auction bid can contain for this `profitPct` to be used. If any auction bid asset exists outside this list, the `profitPct` will not be used. | +| `supportedLot` | An array of asset addresses that the auction lot can contain for this `profitPct` to be used. If any auction lot asset exists outside this list, the `profitPct` will not be used. | + ## Build If you make modifications to the bot, you can build a new dockerfile by running: diff --git a/example.config.json b/example.config.json index cf05f9b..242b70b 100644 --- a/example.config.json +++ b/example.config.json @@ -12,7 +12,7 @@ { "name": "example-liquidator", "keypair": "S...", - "minProfitPct": 0.10, + "defaultProfitPct": 0.10, "minHealthFactor": 1.5, "forceFill": true, "primaryAsset": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", @@ -29,6 +29,19 @@ ] } ], + "profits": [ + { + "profitPct": 0.05, + "supportedBid": [ + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75" + ], + "supportedLot": [ + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75" + ] + } + ], "priceSources": [ { "assetId": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", diff --git a/src/auction.ts b/src/auction.ts index cd48bea..df2e0af 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -1,6 +1,7 @@ import { AuctionData, FixedMath, Request, RequestType } from '@blend-capital/blend-sdk'; import { Asset } from '@stellar/stellar-sdk'; import { AuctionBid } from './bidder_submitter.js'; +import { getFillerProfitPct } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase, AuctionType } from './utils/db.js'; import { logger } from './utils/logger.js'; @@ -43,14 +44,18 @@ export async function calculateBlockFillAndPercent( logger.info( `Auction Valuation: Effective Collateral: ${effectiveCollateral}, Effective Liabilities: ${effectiveLiabilities}, Lot Value: ${lotValue}, Bid Value: ${bidValue}` ); - if (lotValue >= bidValue * (1 + filler.minProfitPct)) { - const minLotAmount = bidValue * (1 + filler.minProfitPct); + + // find the block delay where the auction meets the required profit percentage + const profitPercent = getFillerProfitPct(filler, APP_CONFIG.profits ?? [], auctionData); + if (lotValue >= bidValue * (1 + profitPercent)) { + const minLotAmount = bidValue * (1 + profitPercent); fillBlockDelay = 200 - (lotValue - minLotAmount) / (lotValue / 200); } else { - const maxBidAmount = lotValue * (1 - filler.minProfitPct); + const maxBidAmount = lotValue * (1 - profitPercent); fillBlockDelay = 200 + (bidValue - maxBidAmount) / (bidValue / 200); } fillBlockDelay = Math.min(Math.max(Math.ceil(fillBlockDelay), 0), 400); + // Ensure the filler can fully fill interest auctions if (auctionType === AuctionType.Interest) { const cometLpTokenBalance = FixedMath.toFloat( @@ -106,28 +111,6 @@ export async function calculateBlockFillAndPercent( return { fillBlock: auctionData.block + fillBlockDelay, fillPercent }; } -/** - * Check if the filler can bid on an auction. - * @param filler - The filler to check - * @param auctionData - The auction data for the auction - * @returns A boolean indicating if the filler cares about the auction. - */ -export function canFillerBid(filler: Filler, auctionData: AuctionData): boolean { - // validate lot - for (const [assetId, _] of auctionData.lot) { - if (!filler.supportedLot.some((address) => assetId === address)) { - return false; - } - } - // validate bid - for (const [assetId, _] of auctionData.bid) { - if (!filler.supportedBid.some((address) => assetId === address)) { - return false; - } - } - return true; -} - /** * Scale an auction to the block the auction is to be filled and the percent which will be filled. * @param auction - The auction to scale diff --git a/src/filler.ts b/src/filler.ts index 4a7d3c6..2faf061 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -1,4 +1,5 @@ import { + AuctionData, FixedMath, Pool, PoolOracle, @@ -9,10 +10,59 @@ import { Reserve, } from '@blend-capital/blend-sdk'; import { Asset } from '@stellar/stellar-sdk'; -import { APP_CONFIG, Filler } from './utils/config.js'; +import { APP_CONFIG, AuctionProfit, Filler } from './utils/config.js'; import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; +/** + * Check if the filler supports bidding on the auction. + * @param filler - The filler to check + * @param auctionData - The auction data for the auction + * @returns A boolean indicating if the filler cares about the auction. + */ +export function canFillerBid(filler: Filler, auctionData: AuctionData): boolean { + // validate lot + for (const [assetId, _] of auctionData.lot) { + if (!filler.supportedLot.some((address) => assetId === address)) { + return false; + } + } + // validate bid + for (const [assetId, _] of auctionData.bid) { + if (!filler.supportedBid.some((address) => assetId === address)) { + return false; + } + } + return true; +} + +/** + * Get the profit percentage the filler should bid at for the auction. + * @param filler - The filler + * @param auctionProfits - The auction profits for the bot + * @param auctionData - The auction data for the auction + * @returns The profit percentage the filler should bid at, as a float where 1.0 is 100% + */ +export function getFillerProfitPct( + filler: Filler, + auctionProfits: AuctionProfit[], + auctionData: AuctionData +): number { + let bidAssets = Array.from(auctionData.bid.keys()); + let lotAssets = Array.from(auctionData.lot.keys()); + for (const profit of auctionProfits) { + if ( + bidAssets.some((address) => !profit.supportedBid.includes(address)) || + lotAssets.some((address) => !profit.supportedLot.includes(address)) + ) { + // either some bid asset or some lot asset is not in the profit's supported assets, skip + continue; + } + return profit.profitPct; + } + return filler.defaultProfitPct; +} + /** * Manage a filler's positions in the pool. Returns an array of requests to be submitted to the network. This function * will attempt to repay liabilities with the filler's assets, and withdraw any unnecessary collateral, up to either the min diff --git a/src/pool_event_handler.ts b/src/pool_event_handler.ts index 45ff339..737d4f4 100644 --- a/src/pool_event_handler.ts +++ b/src/pool_event_handler.ts @@ -1,7 +1,7 @@ import { PoolEventType } from '@blend-capital/blend-sdk'; import { ChildProcess } from 'child_process'; -import { canFillerBid } from './auction.js'; import { EventType, PoolEventEvent } from './events.js'; +import { canFillerBid } from './filler.js'; import { updateUser } from './user.js'; import { APP_CONFIG } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; diff --git a/src/utils/config.ts b/src/utils/config.ts index 864afd8..b635b76 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -6,7 +6,7 @@ export interface Filler { name: string; keypair: Keypair; primaryAsset: string; - minProfitPct: number; + defaultProfitPct: number; minHealthFactor: number; minPrimaryCollateral: bigint; forceFill: boolean; @@ -20,6 +20,12 @@ export interface PriceSource { symbol: string; } +export interface AuctionProfit { + profitPct: number; + supportedBid: string[]; + supportedLot: string[]; +} + export interface AppConfig { name: string; rpcURL: string; @@ -31,7 +37,8 @@ export interface AppConfig { blndAddress: string; keypair: Keypair; fillers: Filler[]; - priceSources: PriceSource[]; + priceSources: PriceSource[] | undefined; + profits: AuctionProfit[] | undefined; slackWebhook: string | undefined; } @@ -61,7 +68,8 @@ export function validateAppConfig(config: any): boolean { typeof config.blndAddress !== 'string' || typeof config.keypair !== 'string' || !Array.isArray(config.fillers) || - !Array.isArray(config.priceSources) || + (config.priceSources !== undefined && !Array.isArray(config.priceSources)) || + (config.profits !== undefined && !Array.isArray(config.profits)) || (config.slackWebhook !== undefined && typeof config.slackWebhook !== 'string') ) { return false; @@ -69,7 +77,11 @@ export function validateAppConfig(config: any): boolean { config.keypair = Keypair.fromSecret(config.keypair); - return config.fillers.every(validateFiller) && config.priceSources.every(validatePriceSource); + return ( + config.fillers.every(validateFiller) && + (config.priceSources === undefined || config.priceSources.every(validatePriceSource)) && + (config.profits === undefined || config.profits.every(validateAuctionProfit)) + ); } export function validateFiller(filler: any): boolean { @@ -112,3 +124,21 @@ export function validatePriceSource(priceSource: any): boolean { return false; } + +export function validateAuctionProfit(profits: any): boolean { + if (typeof profits !== 'object' || profits === null) { + return false; + } + + if ( + typeof profits.profitPct === 'number' && + Array.isArray(profits.supportedBid) && + profits.supportedBid.every((item: any) => typeof item === 'string') && + Array.isArray(profits.supportedLot) && + profits.supportedLot.every((item: any) => typeof item === 'string') + ) { + return true; + } + + return false; +} diff --git a/src/utils/prices.ts b/src/utils/prices.ts index a5c662a..78d961d 100644 --- a/src/utils/prices.ts +++ b/src/utils/prices.ts @@ -15,7 +15,7 @@ interface ExchangePrice { export async function setPrices(db: AuctioneerDatabase): Promise { const coinbaseSymbols: string[] = []; const binanceSymbols: string[] = []; - for (const source of APP_CONFIG.priceSources) { + for (const source of APP_CONFIG.priceSources ?? []) { if (source.type === 'coinbase') { coinbaseSymbols.push(source.symbol); } else if (source.type === 'binance') { @@ -30,11 +30,10 @@ export async function setPrices(db: AuctioneerDatabase): Promise { ]); const exchangePriceResult = coinbasePricesResult.concat(binancePricesResult); + const priceSources = APP_CONFIG.priceSources ?? []; const priceEntries: PriceEntry[] = []; for (const price of exchangePriceResult) { - const assetId = APP_CONFIG.priceSources.find( - (source) => source.symbol === price.symbol - )?.assetId; + const assetId = priceSources.find((source) => source.symbol === price.symbol)?.assetId; if (assetId) { priceEntries.push({ asset_id: assetId, diff --git a/test/auction.test.ts b/test/auction.test.ts index d9bb9cf..ececda1 100644 --- a/test/auction.test.ts +++ b/test/auction.test.ts @@ -56,7 +56,7 @@ describe('auction', () => { filler = { name: 'Tester', keypair: Keypair.random(), - minProfitPct: 0.2, + defaultProfitPct: 0.2, minHealthFactor: 1.3, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -387,7 +387,7 @@ describe('auction', () => { filler: { name: '', keypair: filler, - minProfitPct: 0.2, + defaultProfitPct: 0.2, minHealthFactor: 1.2, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -436,7 +436,7 @@ describe('auction', () => { filler: { name: '', keypair: filler, - minProfitPct: 0.2, + defaultProfitPct: 0.2, minHealthFactor: 1.2, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -484,7 +484,7 @@ describe('auction', () => { filler: { name: '', keypair: filler, - minProfitPct: 0.2, + defaultProfitPct: 0.2, minHealthFactor: 1.2, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -548,7 +548,7 @@ describe('auction', () => { filler: { name: '', keypair: filler, - minProfitPct: 0.2, + defaultProfitPct: 0.2, minHealthFactor: 1.2, primaryAsset: 'USD', minPrimaryCollateral: 0n, diff --git a/test/bidder_handler.test.ts b/test/bidder_handler.test.ts index 1e669ef..7152336 100644 --- a/test/bidder_handler.test.ts +++ b/test/bidder_handler.test.ts @@ -25,7 +25,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler1', keypair: Keypair.random(), - minProfitPct: 0.05, + defaultProfitPct: 0.05, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'BTC', 'LP'], @@ -34,7 +34,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler2', keypair: Keypair.random(), - minProfitPct: 0.08, + defaultProfitPct: 0.08, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'ETH', 'XLM'], diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 7d2fc65..113129a 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -131,7 +131,7 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -172,7 +172,7 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -215,7 +215,7 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, primaryAsset: 'USD', minPrimaryCollateral: 100n, @@ -253,7 +253,7 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, primaryAsset: 'USD', minPrimaryCollateral: 100n, @@ -307,7 +307,7 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, primaryAsset: 'USD', minPrimaryCollateral: 0n, @@ -350,7 +350,7 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, primaryAsset: 'USD', minPrimaryCollateral: 0n, diff --git a/test/filler.test.ts b/test/filler.test.ts index c2e9401..4eb0a38 100644 --- a/test/filler.test.ts +++ b/test/filler.test.ts @@ -1,4 +1,5 @@ import { + AuctionData, FixedMath, PoolOracle, Positions, @@ -7,8 +8,8 @@ import { RequestType, } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; -import { managePositions } from '../src/filler'; -import { Filler } from '../src/utils/config'; +import { canFillerBid, getFillerProfitPct, managePositions } from '../src/filler'; +import { AuctionProfit, Filler } from '../src/utils/config'; import { mockedPool } from './helpers/mocks'; jest.mock('../src/utils/config.js', () => { @@ -26,298 +27,557 @@ jest.mock('../src/utils/logger.js', () => ({ }, })); -describe('managePositions', () => { - const assets = mockedPool.config.reserveList; - const mockOracle = new PoolOracle( - 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', - new Map([ - [assets[0], { price: BigInt(1e6), timestamp: 1724949300 }], - [assets[1], { price: BigInt(1e7), timestamp: 1724949300 }], - [assets[2], { price: BigInt(1.1e7), timestamp: 1724949300 }], - [assets[3], { price: BigInt(1000e7), timestamp: 1724949300 }], - ]), - 7, - 53255053 - ); - const filler: Filler = { - name: 'Teapot', - keypair: Keypair.random(), - primaryAsset: assets[1], - minProfitPct: 0.1, - minHealthFactor: 1.5, - minPrimaryCollateral: FixedMath.toFixed(100, 7), - forceFill: true, - supportedBid: [assets[1], assets[0]], - supportedLot: [assets[1], assets[2], assets[3]], - }; +describe('filler', () => { + describe('canFillerBid', () => { + it('returns true if the filler supports the auction', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET0', 100n], + ['ASSET1', 200n], + ]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + block: 123, + }; - it('clears excess liabilities and collateral', () => { - const positions = new Positions( - // dTokens - new Map([[1, FixedMath.toFixed(100, 7)]]), - // bTokens - new Map([[2, FixedMath.toFixed(500, 7)]]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(0, 7)], - [assets[1], FixedMath.toFixed(1234, 7)], - [assets[2], FixedMath.toFixed(200, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[1], - amount: FixedMath.toFixed(1234, 7), - }, - { - request_type: RequestType.WithdrawCollateral, - address: assets[2], - amount: BigInt('9223372036854775807'), - }, - ]; - expect(requests).toEqual(expectedRequests); - }); + const result = canFillerBid(filler, auctionData); + expect(result).toBe(true); + }); - it('does not withdraw collateral if a different liability still exists', () => { - const positions = new Positions( - // dTokens - new Map([[1, FixedMath.toFixed(5000, 7)]]), - // bTokens - new Map([[2, FixedMath.toFixed(4500, 7)]]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(0, 7)], - [assets[1], FixedMath.toFixed(3000, 7)], - [assets[2], FixedMath.toFixed(0, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[1], - amount: FixedMath.toFixed(3000, 7), - }, - ]; - expect(requests).toEqual(expectedRequests); - }); + it('returns false if the filler does not support the lot', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET0', 100n], + ['ASSET1', 200n], + ]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET0', 200n], + ]), + block: 123, + }; - it('does not withdraw primary collateral if a different liability still exists', () => { - const positions = new Positions( - // dTokens - new Map([[2, FixedMath.toFixed(4500, 7)]]), - // bTokens - new Map([[1, FixedMath.toFixed(5000, 7)]]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(0, 7)], - [assets[1], FixedMath.toFixed(0, 7)], - [assets[2], FixedMath.toFixed(3000, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[2], - amount: FixedMath.toFixed(3000, 7), - }, - ]; - expect(requests).toEqual(expectedRequests); - }); + const result = canFillerBid(filler, auctionData); + expect(result).toBe(false); + }); - it('can unwind looped positions', () => { - filler.minHealthFactor = 1.1; - const positions = new Positions( - // dTokens - new Map([[1, FixedMath.toFixed(50000, 7)]]), - // bTokens - new Map([[1, FixedMath.toFixed(58000, 7)]]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(0, 7)], - [assets[1], FixedMath.toFixed(5000, 7)], - [assets[2], FixedMath.toFixed(2000, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - // return minimum health factor back to 1.5 - filler.minHealthFactor = 1.5; - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[1], - amount: FixedMath.toFixed(5000, 7), - }, - { - request_type: RequestType.WithdrawCollateral, - address: assets[1], - amount: BigInt(29372567525), - }, - ]; - expect(requests).toEqual(expectedRequests); - }); + it('returns false if the filler does not support the bid', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + block: 123, + }; - it('keeps XLM balance above min XLM', () => { - const positions = new Positions( - // dTokens - new Map([[0, FixedMath.toFixed(200, 7)]]), - // bTokens - new Map([[1, FixedMath.toFixed(125, 7)]]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(75, 7)], - [assets[1], FixedMath.toFixed(3000, 7)], - [assets[2], FixedMath.toFixed(1000, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[0], - amount: FixedMath.toFixed(25, 7), - }, - ]; - expect(requests).toEqual(expectedRequests); + const result = canFillerBid(filler, auctionData); + expect(result).toBe(false); + }); }); + describe('getFillerProfitPct', () => { + it('gets profitPct from profit config if available', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const profits: AuctionProfit[] = [ + { + profitPct: 0.2, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET3'], + }, + { + profitPct: 0.3, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET2'], + }, + ]; + const auctionData: AuctionData = { + bid: new Map([['ASSET0', 100n]]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET2', 200n], + ]), + block: 123, + }; - it('clears collateral with no liabilities and keeps primary collateral above min collateral', () => { - const positions = new Positions( - // dTokens - new Map([]), - // bTokens - new Map([ - [1, FixedMath.toFixed(125, 7)], - [3, FixedMath.toFixed(1, 7)], - ]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(575, 7)], - [assets[1], FixedMath.toFixed(3000, 7)], - [assets[2], FixedMath.toFixed(1000, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.WithdrawCollateral, - address: assets[3], - amount: BigInt('9223372036854775807'), - }, - { - request_type: RequestType.WithdrawCollateral, - address: assets[1], - amount: 258738051n, - }, - ]; - expect(requests).toEqual(expectedRequests); - }); + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.3); + }); - it('clears smallest collateral position first', () => { - const positions = new Positions( - // dTokens - new Map([ - [0, FixedMath.toFixed(1500, 7)], - [3, FixedMath.toFixed(2, 7)], - ]), - // bTokens - new Map([ - [1, FixedMath.toFixed(5000, 7)], - [2, FixedMath.toFixed(500, 7)], - ]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(5000, 7)], - [assets[1], FixedMath.toFixed(1234, 7)], - [assets[2], FixedMath.toFixed(0, 7)], - [assets[3], FixedMath.toFixed(1, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[0], - amount: FixedMath.toFixed(4950, 7), - }, - { - request_type: RequestType.Repay, - address: assets[3], - amount: FixedMath.toFixed(1, 7), - }, - { - request_type: RequestType.WithdrawCollateral, - address: assets[2], - amount: BigInt('9223372036854775807'), - }, - ]; - expect(requests).toEqual(expectedRequests); - }); + it('returns first matched profit', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const profits: AuctionProfit[] = [ + { + profitPct: 0.2, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET3'], + }, + { + profitPct: 0.3, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET2'], + }, + ]; + const auctionData: AuctionData = { + bid: new Map([['ASSET0', 100n]]), + lot: new Map([['ASSET1', 100n]]), + block: 123, + }; - it('partially withdraws large collateral first when a liability position is maintained', () => { - const positions = new Positions( - // dTokens - new Map([ - [2, FixedMath.toFixed(1500, 7)], - [3, FixedMath.toFixed(2, 7)], - ]), - // bTokens - new Map([ - [0, FixedMath.toFixed(500, 7)], - [1, FixedMath.toFixed(2500, 7)], - [2, FixedMath.toFixed(3000, 7)], + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.2); + }); + + it('returns default profit if bid does not match', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const profits: AuctionProfit[] = [ + { + profitPct: 0.2, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET3'], + }, + { + profitPct: 0.3, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET2'], + }, + ]; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + lot: new Map([['ASSET1', 100n]]), + block: 123, + }; + + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.1); + }); + + it('returns default profit if lot does not match', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const profits: AuctionProfit[] = [ + { + profitPct: 0.2, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET3'], + }, + { + profitPct: 0.3, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET2'], + }, + ]; + const auctionData: AuctionData = { + bid: new Map([['ASSET0', 100n]]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET0', 200n], + ]), + block: 123, + }; + + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.1); + }); + + it('returns default profit if no auction profits defined', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const profits: AuctionProfit[] = []; + const auctionData: AuctionData = { + bid: new Map([['ASSET0', 100n]]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET0', 200n], + ]), + block: 123, + }; + + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.1); + }); + }); + describe('managePositions', () => { + const assets = mockedPool.config.reserveList; + const mockOracle = new PoolOracle( + 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', + new Map([ + [assets[0], { price: BigInt(1e6), timestamp: 1724949300 }], + [assets[1], { price: BigInt(1e7), timestamp: 1724949300 }], + [assets[2], { price: BigInt(1.1e7), timestamp: 1724949300 }], + [assets[3], { price: BigInt(1000e7), timestamp: 1724949300 }], ]), - new Map([]) + 7, + 53255053 ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(5000, 7)], - [assets[1], FixedMath.toFixed(1234, 7)], - [assets[2], FixedMath.toFixed(1000, 7)], - [assets[3], FixedMath.toFixed(1, 7)], - ]); - - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[2], - amount: FixedMath.toFixed(1000, 7), - }, - { - request_type: RequestType.Repay, - address: assets[3], - amount: FixedMath.toFixed(1, 7), - }, - { - request_type: RequestType.WithdrawCollateral, - address: assets[2], - amount: 14820705895n, - }, - ]; - expect(requests).toEqual(expectedRequests); + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: assets[1], + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: [assets[1], assets[0]], + supportedLot: [assets[1], assets[2], assets[3]], + }; + + it('clears excess liabilities and collateral', () => { + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(100, 7)]]), + // bTokens + new Map([[2, FixedMath.toFixed(500, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(200, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(1234, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: BigInt('9223372036854775807'), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('does not withdraw collateral if a different liability still exists', () => { + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(5000, 7)]]), + // bTokens + new Map([[2, FixedMath.toFixed(4500, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(0, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(3000, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('does not withdraw primary collateral if a different liability still exists', () => { + const positions = new Positions( + // dTokens + new Map([[2, FixedMath.toFixed(4500, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(5000, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(0, 7)], + [assets[2], FixedMath.toFixed(3000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[2], + amount: FixedMath.toFixed(3000, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('can unwind looped positions', () => { + filler.minHealthFactor = 1.1; + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(50000, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(58000, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(5000, 7)], + [assets[2], FixedMath.toFixed(2000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + // return minimum health factor back to 1.5 + filler.minHealthFactor = 1.5; + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(5000, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[1], + amount: BigInt(29372567525), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('keeps XLM balance above min XLM', () => { + const positions = new Positions( + // dTokens + new Map([[0, FixedMath.toFixed(200, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(125, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(75, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[0], + amount: FixedMath.toFixed(25, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('clears collateral with no liabilities and keeps primary collateral above min collateral', () => { + const positions = new Positions( + // dTokens + new Map([]), + // bTokens + new Map([ + [1, FixedMath.toFixed(125, 7)], + [3, FixedMath.toFixed(1, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(575, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.WithdrawCollateral, + address: assets[3], + amount: BigInt('9223372036854775807'), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[1], + amount: 258738051n, + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('clears smallest collateral position first', () => { + const positions = new Positions( + // dTokens + new Map([ + [0, FixedMath.toFixed(1500, 7)], + [3, FixedMath.toFixed(2, 7)], + ]), + // bTokens + new Map([ + [1, FixedMath.toFixed(5000, 7)], + [2, FixedMath.toFixed(500, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(5000, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(0, 7)], + [assets[3], FixedMath.toFixed(1, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[0], + amount: FixedMath.toFixed(4950, 7), + }, + { + request_type: RequestType.Repay, + address: assets[3], + amount: FixedMath.toFixed(1, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: BigInt('9223372036854775807'), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('partially withdraws large collateral first when a liability position is maintained', () => { + const positions = new Positions( + // dTokens + new Map([ + [2, FixedMath.toFixed(1500, 7)], + [3, FixedMath.toFixed(2, 7)], + ]), + // bTokens + new Map([ + [0, FixedMath.toFixed(500, 7)], + [1, FixedMath.toFixed(2500, 7)], + [2, FixedMath.toFixed(3000, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(5000, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(1, 7)], + ]); + + const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[2], + amount: FixedMath.toFixed(1000, 7), + }, + { + request_type: RequestType.Repay, + address: assets[3], + amount: FixedMath.toFixed(1, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: 14820705895n, + }, + ]; + expect(requests).toEqual(expectedRequests); + }); }); }); diff --git a/test/pool_event_handler.test.ts b/test/pool_event_handler.test.ts index 1005821..235548c 100644 --- a/test/pool_event_handler.test.ts +++ b/test/pool_event_handler.test.ts @@ -34,7 +34,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler1', keypair: Keypair.random(), - minProfitPct: 0.05, + defaultProfitPct: 0.05, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'BTC', 'LP'], @@ -43,7 +43,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler2', keypair: Keypair.random(), - minProfitPct: 0.08, + defaultProfitPct: 0.08, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'ETH', 'XLM'], From 8d262c1b4eb242cf6e0343a66c18693463fc4ac7 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Mon, 18 Nov 2024 08:57:31 -0500 Subject: [PATCH 04/13] fix: clean up fill block and percent calculations --- src/auction.ts | 447 ++++++++++++++++++++---------------- src/bidder_handler.ts | 56 ++--- src/bidder_submitter.ts | 85 +++---- src/filler.ts | 25 ++ src/liquidations.ts | 9 +- src/pool_event_handler.ts | 33 ++- src/user.ts | 4 +- src/utils/config.ts | 7 +- src/utils/slack_notifier.ts | 4 + src/utils/soroban_helper.ts | 17 +- 10 files changed, 389 insertions(+), 298 deletions(-) diff --git a/src/auction.ts b/src/auction.ts index df2e0af..4a6292c 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -1,9 +1,14 @@ -import { AuctionData, FixedMath, Request, RequestType } from '@blend-capital/blend-sdk'; -import { Asset } from '@stellar/stellar-sdk'; -import { AuctionBid } from './bidder_submitter.js'; -import { getFillerProfitPct } from './filler.js'; +import { + Auction, + AuctionType, + FixedMath, + Request, + RequestType, + ScaledAuction, +} from '@blend-capital/blend-sdk'; +import { getFillerAvailableBalances, getFillerProfitPct } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; -import { AuctioneerDatabase, AuctionType } from './utils/db.js'; +import { AuctioneerDatabase } from './utils/db.js'; import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; @@ -14,39 +19,109 @@ export interface FillCalculation { fillPercent: number; } +export interface AuctionFill { + // The block number to fill the auction at + block: number; + // The percent of the auction to fill + percent: number; + // The expected lot value paid by the filler + lotValue: number; + // The expected bid value the filler will receive + bidValue: number; + // The requests to fill the auction + requests: Request[]; +} + export interface AuctionValue { effectiveCollateral: number; effectiveLiabilities: number; + repayableLiabilities: number; lotValue: number; bidValue: number; } +export async function calculateAuctionFill( + filler: Filler, + auction: Auction, + nextLedger: number, + sorobanHelper: SorobanHelper, + db: AuctioneerDatabase +): Promise { + try { + const relevant_assets = []; + switch (auction.type) { + case AuctionType.Liquidation: + relevant_assets.push(...Array.from(auction.data.lot.keys())); + relevant_assets.push(...Array.from(auction.data.bid.keys())); + break; + case AuctionType.Interest: + relevant_assets.push(APP_CONFIG.backstopTokenAddress); + break; + case AuctionType.BadDebt: + relevant_assets.push(...Array.from(auction.data.lot.keys())); + relevant_assets.push(APP_CONFIG.backstopTokenAddress); + break; + } + const fillerBalances = await getFillerAvailableBalances( + filler, + [...new Set(relevant_assets)], + sorobanHelper + ); + + const auctionValue = await calculateAuctionValue(auction, fillerBalances, sorobanHelper, db); + const { fillBlock, fillPercent } = await calculateBlockFillAndPercent( + filler, + auction, + auctionValue, + nextLedger, + sorobanHelper + ); + const [scaledAuction] = auction.scale(fillBlock, fillPercent); + const requests = buildFillRequests(scaledAuction, fillPercent, fillerBalances); + // estimate the lot value and bid value on the fill block + const blockDelay = fillBlock - auction.data.block; + const bidScalar = blockDelay <= 200 ? 1 : 1 - Math.max(0, blockDelay - 200) / 200; + const lotScalar = blockDelay < 200 ? blockDelay / 200 : 1; + const fillCalcLotValue = auctionValue.lotValue * lotScalar * (fillPercent / 100); + const fillCalcBidValue = auctionValue.bidValue * bidScalar * (fillPercent / 100); + return { + block: fillBlock, + percent: fillPercent, + lotValue: fillCalcLotValue, + bidValue: fillCalcBidValue, + requests, + }; + } catch (e: any) { + logger.error(`Error calculating auction fill.`, e); + throw e; + } +} + /** * Calculate the block fill and fill percent for a given auction. * * @param filler - The filler to calculate the block fill for - * @param auctionType - The type of auction to calculate the block fill for - * @param auctionData - The auction data to calculate the block fill for + * @param auction - The auction to calculate the fill for + * @param auctionValue - The calculate value of the base auction + * @param nextLedger - The next ledger number * @param sorobanHelper - The soroban helper to use for the calculation */ export async function calculateBlockFillAndPercent( filler: Filler, - auctionType: AuctionType, - auctionData: AuctionData, - sorobanHelper: SorobanHelper, - db: AuctioneerDatabase + auction: Auction, + auctionValue: AuctionValue, + nextLedger: number, + sorobanHelper: SorobanHelper ): Promise { - // Sum the effective collateral and lot value - let { effectiveCollateral, effectiveLiabilities, lotValue, bidValue } = - await calculateAuctionValue(auctionType, auctionData, sorobanHelper, db); + // auction value at block 200, or the base auction, with current prices let fillBlockDelay = 0; let fillPercent = 100; - logger.info( - `Auction Valuation: Effective Collateral: ${effectiveCollateral}, Effective Liabilities: ${effectiveLiabilities}, Lot Value: ${lotValue}, Bid Value: ${bidValue}` - ); + + let { effectiveCollateral, effectiveLiabilities, repayableLiabilities, lotValue, bidValue } = + auctionValue; // find the block delay where the auction meets the required profit percentage - const profitPercent = getFillerProfitPct(filler, APP_CONFIG.profits ?? [], auctionData); + const profitPercent = getFillerProfitPct(filler, APP_CONFIG.profits ?? [], auction.data); if (lotValue >= bidValue * (1 + profitPercent)) { const minLotAmount = bidValue * (1 + profitPercent); fillBlockDelay = 200 - (lotValue - minLotAmount) / (lotValue / 200); @@ -55,126 +130,93 @@ export async function calculateBlockFillAndPercent( fillBlockDelay = 200 + (bidValue - maxBidAmount) / (bidValue / 200); } fillBlockDelay = Math.min(Math.max(Math.ceil(fillBlockDelay), 0), 400); + // apply force fill auction boundries to profit calculations + if (auction.type === AuctionType.Liquidation && filler.forceFill) { + fillBlockDelay = Math.min(fillBlockDelay, 198); + } else if (auction.type === AuctionType.Interest && filler.forceFill) { + fillBlockDelay = Math.min(fillBlockDelay, 350); + } + // if calculated fillBlock has already passed, adjust fillBlock to the next ledger + if (auction.data.block + fillBlockDelay < nextLedger) { + fillBlockDelay = Math.min(nextLedger - auction.data.block, 400); + } + + const bidScalarAtFill = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; + const lotScalarAtFill = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; - // Ensure the filler can fully fill interest auctions - if (auctionType === AuctionType.Interest) { + // require that the filler can fully fill interest auctions + if (auction.type === AuctionType.Interest) { const cometLpTokenBalance = FixedMath.toFloat( await sorobanHelper.simBalance(APP_CONFIG.backstopTokenAddress, filler.keypair.publicKey()), 7 ); - const cometLpBid = - fillBlockDelay <= 200 - ? FixedMath.toFloat(auctionData.bid.get(APP_CONFIG.backstopTokenAddress)!, 7) - : FixedMath.toFloat(auctionData.bid.get(APP_CONFIG.backstopTokenAddress)!, 7) * - (1 - (fillBlockDelay - 200) / 200); - - if (cometLpTokenBalance < cometLpBid) { + const cometLpBidBase = FixedMath.toFloat( + auction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n, + 7 + ); + const cometLpBid = cometLpBidBase * bidScalarAtFill; + if (cometLpBid > cometLpTokenBalance) { const additionalCometLp = cometLpBid - cometLpTokenBalance; - const bidStepSize = - FixedMath.toFloat(auctionData.bid.get(APP_CONFIG.backstopTokenAddress)!, 7) / 200; + const bidStepSize = cometLpBidBase / 200; if (additionalCometLp >= 0 && bidStepSize > 0) { - fillBlockDelay += Math.ceil(additionalCometLp / bidStepSize); + const additionalDelay = Math.ceil(additionalCometLp / bidStepSize); + fillBlockDelay = Math.max(200, fillBlockDelay) + additionalDelay; fillBlockDelay = Math.min(fillBlockDelay, 400); } } - } - // Ensure the filler can maintain their minimum health factor - else { + } else if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) { + // require that filler meets minimum health factor requirements const { estimate: fillerPositionEstimates } = await sorobanHelper.loadUserPositionEstimate( filler.keypair.publicKey() ); - if (fillBlockDelay <= 200) { - effectiveCollateral = effectiveCollateral * (fillBlockDelay / 200); - } else { - effectiveLiabilities = effectiveLiabilities * (1 - (fillBlockDelay - 200) / 200); - } - if (effectiveCollateral < effectiveLiabilities) { - const excessLiabilities = effectiveLiabilities - effectiveCollateral; + // inflate minHealthFactor slightly, to allow for the unwind logic to unwind looped positions safely + const safeHealthFactor = filler.minHealthFactor * 1.1; + const additionalLiabilities = effectiveLiabilities * bidScalarAtFill - repayableLiabilities; + const additionalCollateral = effectiveCollateral * lotScalarAtFill; + const additionalCollateralReq = additionalLiabilities * safeHealthFactor; + if (additionalCollateral < additionalCollateralReq) { + const excessLiabilities = additionalCollateralReq - additionalCollateral; const liabilityLimitToHF = - fillerPositionEstimates.totalEffectiveCollateral / filler.minHealthFactor - + fillerPositionEstimates.totalEffectiveCollateral / safeHealthFactor - fillerPositionEstimates.totalEffectiveLiabilities; - if (excessLiabilities > liabilityLimitToHF) { - fillPercent = Math.min( - fillPercent, - Math.floor((liabilityLimitToHF / excessLiabilities) * 100) + logger.info( + `Auction does not add enough collateral to maintain health factor. Additional Collateral: ${additionalCollateral}, Additional Liabilities: ${additionalLiabilities}, Repaid Liabilities: ${repayableLiabilities}, Excess Liabilities to HF: ${excessLiabilities}, Liability Limit to HF: ${liabilityLimitToHF}` + ); + + if (liabilityLimitToHF <= 0) { + // filler can't take on additional liabilities. Push back fill block until more collateral + // is received than liabilities taken on, or no liabilities are taken on + const liabilityBlockDecrease = + Math.ceil(100 * (excessLiabilities / effectiveLiabilities)) / 0.5; + fillBlockDelay = Math.min(Math.max(200, fillBlockDelay) + liabilityBlockDecrease, 400); + logger.info( + `Unable to fill auction at expected profit due to insufficient collateral, pushing fill block an extra ${liabilityBlockDecrease} back to ${fillBlockDelay}` ); + } else if (excessLiabilities > liabilityLimitToHF) { + // reduce fill percent to the point where the filler can take on the liabilities + fillPercent = Math.floor((liabilityLimitToHF / excessLiabilities) * 100); } } } - - if (auctionType === AuctionType.Liquidation && filler.forceFill) { - fillBlockDelay = Math.min(fillBlockDelay, 198); - } else if (auctionType === AuctionType.Interest && filler.forceFill) { - fillBlockDelay = Math.min(fillBlockDelay, 350); - } - return { fillBlock: auctionData.block + fillBlockDelay, fillPercent }; -} - -/** - * Scale an auction to the block the auction is to be filled and the percent which will be filled. - * @param auction - The auction to scale - * @param fillBlock - The block to scale to - * @param fillPercent - The percent to scale to - * @returns The scaled auction - */ -export function scaleAuction( - auction: AuctionData, - fillBlock: number, - fillPercent: number -): AuctionData { - let scaledAuction: AuctionData = { - block: fillBlock, - bid: new Map(), - lot: new Map(), - }; - let lotModifier; - let bidModifier; - const fillBlockDelta = fillBlock - auction.block; - if (fillBlockDelta <= 200) { - lotModifier = fillBlockDelta / 200; - bidModifier = 1; - } else { - lotModifier = 1; - if (fillBlockDelta < 400) { - bidModifier = 1 - (fillBlockDelta - 200) / 200; - } else { - bidModifier = 0; - } - } - - for (const [assetId, amount] of auction.lot) { - const scaledLot = Math.floor((Number(amount) * lotModifier * fillPercent) / 100); - if (scaledLot > 0) { - scaledAuction.lot.set(assetId, BigInt(scaledLot)); - } - } - for (const [assetId, amount] of auction.bid) { - const scaledBid = Math.ceil((Number(amount) * bidModifier * fillPercent) / 100); - if (scaledBid > 0) { - scaledAuction.bid.set(assetId, BigInt(scaledBid)); - } - } - return scaledAuction; + return { fillBlock: auction.data.block + fillBlockDelay, fillPercent }; } /** * Build requests to fill the auction and repay the liabilities. - * @param auctionBid - The auction to build the fill requests for - * @param auctionData - The scaled auction data to build the fill requests for + * @param scaledAuction - The scaled auction to build the fill requests for * @param fillPercent - The percent to fill the auction * @param sorobanHelper - The soroban helper to use for the calculation * @returns */ -export async function buildFillRequests( - auctionBid: AuctionBid, - auctionData: AuctionData, +export function buildFillRequests( + scaledAuction: ScaledAuction, fillPercent: number, - sorobanHelper: SorobanHelper -): Promise { + fillerBalances: Map +): Request[] { let fillRequests: Request[] = []; let requestType: RequestType; - switch (auctionBid.auctionEntry.auction_type) { + switch (scaledAuction.type) { case AuctionType.Liquidation: requestType = RequestType.FillUserLiquidationAuction; break; @@ -187,36 +229,25 @@ export async function buildFillRequests( } fillRequests.push({ request_type: requestType, - address: auctionBid.auctionEntry.user_id, + address: scaledAuction.user, amount: BigInt(fillPercent), }); - if (auctionBid.auctionEntry.auction_type === AuctionType.Interest) { + if (scaledAuction.type === AuctionType.Interest) { return fillRequests; } // attempt to repay any liabilities the filler has took on from the bids // if this fails for some reason, still continue with the fill - try { - for (const [assetId] of auctionData.bid) { - let tokenBalance = await sorobanHelper.simBalance( - assetId, - auctionBid.filler.keypair.publicKey() - ); - if (assetId === Asset.native().contractId(APP_CONFIG.networkPassphrase)) { - tokenBalance = - tokenBalance > FixedMath.toFixed(50, 7) ? tokenBalance - FixedMath.toFixed(50, 7) : 0n; - } - if (tokenBalance > 0) { - fillRequests.push({ - request_type: RequestType.Repay, - address: assetId, - amount: BigInt(tokenBalance), - }); - } + for (const [assetId] of scaledAuction.data.bid) { + const fillerBalance = fillerBalances.get(assetId) ?? 0n; + if (fillerBalance > 0n) { + fillRequests.push({ + request_type: RequestType.Repay, + address: assetId, + amount: BigInt(fillerBalance), + }); } - } catch (e: any) { - logger.error(`Error attempting to repay dToken bids for filler: ${auctionBid.filler.name}`, e); } return fillRequests; } @@ -224,92 +255,114 @@ export async function buildFillRequests( /** * Calculate the effective collateral, lot value, effective liabilities, and bid value for an auction. * - * If this function encounters an error, it will return 0 for all values. - * - * @param auctionType - The type of auction to calculate the values for - * @param auctionData - The auction data to calculate the values for + * @param auction - The auction to calculate the values for + * @param fillerBalances - The balances of the filler * @param sorobanHelper - A helper to use for loading ledger data * @param db - The database to use for fetching asset prices * @returns The calculated values, or 0 for all values if it is unable to calculate them */ export async function calculateAuctionValue( - auctionType: AuctionType, - auctionData: AuctionData, + auction: Auction, + fillerBalances: Map, sorobanHelper: SorobanHelper, db: AuctioneerDatabase ): Promise { - try { - let effectiveCollateral = 0; - let lotValue = 0; - let effectiveLiabilities = 0; - let bidValue = 0; - const reserves = (await sorobanHelper.loadPool()).reserves; - const poolOracle = await sorobanHelper.loadPoolOracle(); - for (const [assetId, amount] of auctionData.lot) { + let effectiveCollateral = 0; + let lotValue = 0; + let effectiveLiabilities = 0; + let repayableLiabilities = 0; + let bidValue = 0; + const pool = await sorobanHelper.loadPool(); + const poolOracle = await sorobanHelper.loadPoolOracle(); + const reserves = pool.reserves; + for (const [assetId, amount] of auction.data.lot) { + if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.Interest) { const reserve = reserves.get(assetId); - if (reserve !== undefined) { - const oraclePrice = poolOracle.getPriceFloat(assetId); - const dbPrice = db.getPriceEntry(assetId)?.price; - if (oraclePrice === undefined) { - throw new Error(`Failed to get oracle price for asset: ${assetId}`); - } - - if (auctionType !== AuctionType.Interest) { - effectiveCollateral += reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; - // TODO: change this to use the price in the db - lotValue += reserve.toAssetFromBTokenFloat(amount) * (dbPrice ?? oraclePrice); - } - // Interest auctions are in underlying assets - else { - lotValue += - (Number(amount) / 10 ** reserve.tokenMetadata.decimals) * (dbPrice ?? oraclePrice); - } - } else if (assetId === APP_CONFIG.backstopTokenAddress) { - // Simulate singled sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); - if (lpTokenValue !== undefined) { - lotValue += FixedMath.toFloat(lpTokenValue, 7); - } - // Approximate the value of the comet tokens if simulation fails - else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - lotValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; - } + if (reserve === undefined) { + throw new Error(`Unexpected auction. Lot contains asset that is not a reserve: ${assetId}`); + } + const oraclePrice = poolOracle.getPriceFloat(assetId); + const dbPrice = db.getPriceEntry(assetId)?.price; + if (oraclePrice === undefined) { + throw new Error(`Failed to get oracle price for asset: ${assetId}`); + } + if (auction.type === AuctionType.Liquidation) { + // liquidation auction lots are in bTokens + effectiveCollateral += reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; + lotValue += reserve.toAssetFromBTokenFloat(amount) * (dbPrice ?? oraclePrice); } else { - throw new Error(`Failed to value lot asset: ${assetId}`); + lotValue += + (Number(amount) / 10 ** reserve.tokenMetadata.decimals) * (dbPrice ?? oraclePrice); } + } else if (auction.type === AuctionType.BadDebt) { + if (assetId !== APP_CONFIG.backstopTokenAddress) { + throw new Error( + `Unexpected bad debt auction. Lot contains asset other than the backstop token: ${assetId}` + ); + } + bidValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); + } else { + throw new Error(`Failed to value lot asset: ${assetId}`); } + } - for (const [assetId, amount] of auctionData.bid) { + for (const [assetId, amount] of auction.data.bid) { + if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) { const reserve = reserves.get(assetId); + if (reserve === undefined) { + throw new Error(`Unexpected auction. Bid contains asset that is not a reserve: ${assetId}`); + } const dbPrice = db.getPriceEntry(assetId)?.price; - - if (reserve !== undefined) { - const oraclePrice = poolOracle.getPriceFloat(assetId); - if (oraclePrice === undefined) { - throw new Error(`Failed to get oracle price for asset: ${assetId}`); - } - - effectiveLiabilities += reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; - // TODO: change this to use the price in the db - bidValue += reserve.toAssetFromDTokenFloat(amount) * (dbPrice ?? oraclePrice); - } else if (assetId === APP_CONFIG.backstopTokenAddress) { - // Simulate singled sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); - if (lpTokenValue !== undefined) { - bidValue += FixedMath.toFloat(lpTokenValue, 7); - } else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - bidValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; - } - } else { - throw new Error(`Failed to value bid asset: ${assetId}`); + const oraclePrice = poolOracle.getPriceFloat(assetId); + if (oraclePrice === undefined) { + throw new Error(`Failed to get oracle price for asset: ${assetId}`); + } + effectiveLiabilities += reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; + bidValue += reserve.toAssetFromDTokenFloat(amount) * (dbPrice ?? oraclePrice); + const fillerBalance = fillerBalances.get(assetId) ?? 0n; + if (fillerBalance > 0) { + const liabilityAmount = reserve.toAssetFromDToken(amount); + const repaymentAmount = liabilityAmount <= fillerBalance ? liabilityAmount : fillerBalance; + const repayableLiability = + FixedMath.toFloat(repaymentAmount, reserve.config.decimals) * + reserve.getLiabilityFactor() * + oraclePrice; + repayableLiabilities += repayableLiability; + logger.info( + `Filler can repay ${assetId} amount ${FixedMath.toFloat(repaymentAmount)} to cover liabilities: ${repayableLiability}` + ); + } + } else if (auction.type === AuctionType.Interest) { + if (assetId !== APP_CONFIG.backstopTokenAddress) { + throw new Error( + `Unexpected interest auction. Bid contains asset other than the backstop token: ${assetId}` + ); } + bidValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); + } else { + throw new Error(`Failed to value bid asset: ${assetId}`); } + } - return { effectiveCollateral, effectiveLiabilities, lotValue, bidValue }; - } catch (e: any) { - logger.error(`Error calculating auction value`, e); - return { effectiveCollateral: 0, effectiveLiabilities: 0, lotValue: 0, bidValue: 0 }; + return { effectiveCollateral, effectiveLiabilities, repayableLiabilities, lotValue, bidValue }; +} + +/** + * Value an amount of backstop tokens in USDC. + * @param sorobanHelper - The soroban helper to use for the calculation + * @param amount - The amount of backstop tokens to value + * @returns The value of the backstop tokens in USDC + */ +export async function valueBackstopTokenInUSDC( + sorobanHelper: SorobanHelper, + amount: bigint +): Promise { + // attempt to value via a single sided withdraw to USDC + const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); + if (lpTokenValue !== undefined) { + return FixedMath.toFloat(lpTokenValue, 7); + } else { + const backstopToken = await sorobanHelper.loadBackstopToken(); + return FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; } } diff --git a/src/bidder_handler.ts b/src/bidder_handler.ts index 6ea7b7e..5f5c99c 100644 --- a/src/bidder_handler.ts +++ b/src/bidder_handler.ts @@ -1,4 +1,4 @@ -import { calculateBlockFillAndPercent } from './auction.js'; +import { calculateAuctionFill } from './auction.js'; import { AuctionBid, BidderSubmissionType, BidderSubmitter } from './bidder_submitter.js'; import { AppEvent, EventType } from './events.js'; import { APP_CONFIG } from './utils/config.js'; @@ -33,64 +33,66 @@ export class BidderHandler { const nextLedger = appEvent.ledger + 1; const auctions = this.db.getAllAuctionEntries(); - for (let auction of auctions) { + for (let auctionEntry of auctions) { try { const filler = APP_CONFIG.fillers.find( - (f) => f.keypair.publicKey() === auction.filler + (f) => f.keypair.publicKey() === auctionEntry.filler ); if (filler === undefined) { - logger.error(`Filler not found for auction: ${stringify(auction)}`); + logger.error(`Filler not found for auction: ${stringify(auctionEntry)}`); continue; } - if (this.submissionQueue.containsAuction(auction)) { + if (this.submissionQueue.containsAuction(auctionEntry)) { // auction already being bid on continue; } - const ledgersToFill = auction.fill_block - nextLedger; - if (auction.fill_block === 0 || ledgersToFill <= 5 || ledgersToFill % 10 === 0) { + const ledgersToFill = auctionEntry.fill_block - nextLedger; + if (auctionEntry.fill_block === 0 || ledgersToFill <= 5 || ledgersToFill % 10 === 0) { // recalculate the auction - const auctionData = await this.sorobanHelper.loadAuction( - auction.user_id, - auction.auction_type + const auction = await this.sorobanHelper.loadAuction( + auctionEntry.user_id, + auctionEntry.auction_type ); - if (auctionData === undefined) { - this.db.deleteAuctionEntry(auction.user_id, auction.auction_type); + if (auction === undefined) { + logger.info( + `Auction not found. Assuming auction was deleted or filled. Deleting auction: ${auctionEntry.user_id}, ${auctionEntry.auction_type}` + ); + this.db.deleteAuctionEntry(auctionEntry.user_id, auctionEntry.auction_type); continue; } - const fillCalculation = await calculateBlockFillAndPercent( + const fill = await calculateAuctionFill( filler, - auction.auction_type, - auctionData, + auction, + nextLedger, this.sorobanHelper, this.db ); const logMessage = `Auction Calculation\n` + - `Type: ${AuctionType[auction.auction_type]}\n` + - `User: ${auction.user_id}\n` + - `Calculation: ${stringify(fillCalculation, 2)}\n` + - `Ledgers To Fill In: ${fillCalculation.fillBlock - nextLedger}\n`; - if (auction.fill_block === 0) { + `Type: ${AuctionType[auction.type]}\n` + + `User: ${auction.user}\n` + + `Fill: ${stringify(fill, 2)}\n` + + `Ledgers To Fill In: ${fill.block - nextLedger}\n`; + if (auctionEntry.fill_block === 0) { await sendSlackNotification(logMessage); } logger.info(logMessage); - auction.fill_block = fillCalculation.fillBlock; - auction.updated = appEvent.ledger; - this.db.setAuctionEntry(auction); + auctionEntry.fill_block = fill.block; + auctionEntry.updated = appEvent.ledger; + this.db.setAuctionEntry(auctionEntry); } - - if (auction.fill_block <= nextLedger) { + if (auctionEntry.fill_block <= nextLedger) { let submission: AuctionBid = { type: BidderSubmissionType.BID, filler: filler, - auctionEntry: auction, + auctionEntry: auctionEntry, }; this.submissionQueue.addSubmission(submission, 10); } } catch (e: any) { - logger.error(`Error processing block for auction: ${stringify(auction)}`, e); + logger.error(`Error processing block for auction: ${stringify(auctionEntry)}`, e); } } } catch (err) { diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index f6df101..508e2f1 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -1,11 +1,6 @@ import { PoolContract } from '@blend-capital/blend-sdk'; import { SorobanRpc } from '@stellar/stellar-sdk'; -import { - buildFillRequests, - calculateAuctionValue, - calculateBlockFillAndPercent, - scaleAuction, -} from './auction.js'; +import { calculateAuctionFill } from './auction.js'; import { managePositions } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; @@ -75,47 +70,34 @@ export class BidderSubmitter extends SubmissionQueue { async submitBid(sorobanHelper: SorobanHelper, auctionBid: AuctionBid): Promise { try { + logger.info(`Submitting bid for auction ${stringify(auctionBid.auctionEntry, 2)}`); const currLedger = ( await new SorobanRpc.Server( sorobanHelper.network.rpc, sorobanHelper.network.opts ).getLatestLedger() ).sequence; + const nextLedger = currLedger + 1; - const auctionData = await sorobanHelper.loadAuction( + const auction = await sorobanHelper.loadAuction( auctionBid.auctionEntry.user_id, auctionBid.auctionEntry.auction_type ); - // Auction has been filled remove from the database - if (auctionData === undefined) { - this.db.deleteAuctionEntry( - auctionBid.auctionEntry.user_id, - auctionBid.auctionEntry.auction_type - ); + if (auction === undefined) { + // allow bidder handler to re-process the auction entry return true; } - const fillCalculation = await calculateBlockFillAndPercent( + const fill = await calculateAuctionFill( auctionBid.filler, - auctionBid.auctionEntry.auction_type, - auctionData, + auction, + nextLedger, sorobanHelper, this.db ); - if (currLedger + 1 >= fillCalculation.fillBlock) { - const scaledAuction = scaleAuction( - auctionData, - currLedger + 1, - fillCalculation.fillPercent - ); - const request = await buildFillRequests( - auctionBid, - scaledAuction, - fillCalculation.fillPercent, - sorobanHelper - ); + if (nextLedger >= fill.block) { const pool = new PoolContract(APP_CONFIG.poolAddress); const result = await sorobanHelper.submitTransaction( @@ -123,43 +105,38 @@ export class BidderSubmitter extends SubmissionQueue { from: auctionBid.auctionEntry.filler, spender: auctionBid.auctionEntry.filler, to: auctionBid.auctionEntry.filler, - requests: request, + requests: fill.requests, }), auctionBid.filler.keypair ); - const filledAuction = scaleAuction(auctionData, result.ledger, fillCalculation.fillPercent); - const filledAuctionValue = await calculateAuctionValue( - auctionBid.auctionEntry.auction_type, - filledAuction, - sorobanHelper, - this.db - ); - let logMessage = - `Successful bid on auction\n` + - `Type: ${AuctionType[auctionBid.auctionEntry.auction_type]}\n` + - `User: ${auctionBid.auctionEntry.user_id}\n` + - `Filler: ${auctionBid.filler.name}\n` + - `Fill Percent ${fillCalculation.fillPercent}\n` + - `Ledger Fill Delta ${result.ledger - auctionBid.auctionEntry.start_block}\n` + - `Hash ${result.txHash}\n`; - await sendSlackNotification(logMessage); - logger.info(logMessage); + const [scaledAuction] = auction.scale(result.ledger, fill.percent); this.db.setFilledAuctionEntry({ tx_hash: result.txHash, filler: auctionBid.auctionEntry.filler, user_id: auctionBid.auctionEntry.filler, auction_type: auctionBid.auctionEntry.auction_type, - bid: filledAuction.bid, - bid_total: filledAuctionValue.bidValue, - lot: filledAuction.lot, - lot_total: filledAuctionValue.lotValue, - est_profit: filledAuctionValue.lotValue - filledAuctionValue.bidValue, + bid: scaledAuction.data.bid, + bid_total: fill.bidValue, + lot: scaledAuction.data.lot, + lot_total: fill.lotValue, + est_profit: fill.lotValue - fill.bidValue, fill_block: result.ledger, timestamp: result.latestLedgerCloseTime, }); this.addSubmission({ type: BidderSubmissionType.UNWIND, filler: auctionBid.filler }, 2); + let logMessage = + `Successful bid on auction\n` + + `Type: ${AuctionType[auctionBid.auctionEntry.auction_type]}\n` + + `User: ${auctionBid.auctionEntry.user_id}\n` + + `Filler: ${auctionBid.filler.name}\n` + + `Fill Percent ${fill.percent}\n` + + `Ledger Fill Delta ${result.ledger - auctionBid.auctionEntry.start_block}\n` + + `Hash ${result.txHash}\n`; + await sendSlackNotification(logMessage); + logger.info(logMessage); return true; } + // allow bidder handler to re-process the auction entry return true; } catch (e: any) { const logMessage = @@ -176,6 +153,7 @@ export class BidderSubmitter extends SubmissionQueue { } async submitUnwind(sorobanHelper: SorobanHelper, fillerUnwind: FillerUnwind): Promise { + logger.info(`Submitting unwind for filler ${fillerUnwind.filler.keypair.publicKey()}`); const filler_pubkey = fillerUnwind.filler.keypair.publicKey(); const filler_tokens = [ ...new Set([ @@ -208,6 +186,7 @@ export class BidderSubmitter extends SubmissionQueue { filler_balances ); if (requests.length > 0) { + logger.info('Unwind found positions to manage', requests); // some positions to manage - submit the transaction const pool_contract = new PoolContract(APP_CONFIG.poolAddress); const result = await sorobanHelper.submitTransaction( @@ -233,8 +212,12 @@ export class BidderSubmitter extends SubmissionQueue { `Filler has liabilities that cannot be removed\n` + `Filler: ${fillerUnwind.filler.name}\n` + `Positions: ${stringify(filler_user.positions, 2)}`; + logger.info(logMessage); await sendSlackNotification(logMessage); + return true; } + + logger.info(`Filler has no positions to manage, stopping unwind events.`); return true; } diff --git a/src/filler.ts b/src/filler.ts index 2faf061..1126a1c 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -13,6 +13,7 @@ import { Asset } from '@stellar/stellar-sdk'; import { APP_CONFIG, AuctionProfit, Filler } from './utils/config.js'; import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; +import { SorobanHelper } from './utils/soroban_helper.js'; /** * Check if the filler supports bidding on the auction. @@ -63,6 +64,28 @@ export function getFillerProfitPct( return filler.defaultProfitPct; } +/** + * Fetch the available balances for a filler. Takes into account any minimum balances required by the filler. + * @param filler - The filler + * @param assets - The assets to fetch balances for + * @param sorobanHelper - The soroban helper object + */ +export async function getFillerAvailableBalances( + filler: Filler, + assets: string[], + sorobanHelper: SorobanHelper +): Promise> { + const balances = await sorobanHelper.loadBalances(filler.keypair.publicKey(), assets); + const xlm_address = Asset.native().contractId(APP_CONFIG.networkPassphrase); + const xlm_bal = balances.get(xlm_address); + if (xlm_bal !== undefined) { + const safe_xlm_bal = + xlm_bal > FixedMath.toFixed(50, 7) ? xlm_bal - FixedMath.toFixed(50, 7) : 0n; + balances.set(xlm_address, safe_xlm_bal); + } + return balances; +} + /** * Manage a filler's positions in the pool. Returns an array of requests to be submitted to the network. This function * will attempt to repay liabilities with the filler's assets, and withdraw any unnecessary collateral, up to either the min @@ -124,6 +147,8 @@ export function managePositions( address: reserve.assetId, amount: tokenBalance, }); + } else { + hasLeftoverLiabilities.push(assetIndex); } } diff --git a/src/liquidations.ts b/src/liquidations.ts index 8cd5e27..1e21a9f 100644 --- a/src/liquidations.ts +++ b/src/liquidations.ts @@ -1,10 +1,10 @@ import { PositionsEstimate } from '@blend-capital/blend-sdk'; import { updateUser } from './user.js'; -import { AuctioneerDatabase, AuctionType, UserEntry } from './utils/db.js'; +import { APP_CONFIG } from './utils/config.js'; +import { AuctioneerDatabase, AuctionType } from './utils/db.js'; import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission, WorkSubmissionType } from './work_submitter.js'; -import { APP_CONFIG } from './utils/config.js'; /** * Check if a user is liquidatable @@ -15,7 +15,7 @@ export function isLiquidatable(user: PositionsEstimate): boolean { if ( user.totalEffectiveLiabilities > 0 && user.totalEffectiveCollateral > 0 && - user.totalEffectiveCollateral / user.totalEffectiveLiabilities < 0.99 + user.totalEffectiveCollateral / user.totalEffectiveLiabilities < 0.995 ) { return true; } @@ -48,6 +48,9 @@ export function calculateLiquidationPercent(user: PositionsEstimate): bigint { const liqPercent = BigInt( Math.min(Math.round((numerator / denominator / user.totalBorrowed) * 100), 100) ); + logger.info( + `Calculated liquidation percent ${liqPercent} with est incentive ${estIncentive} numerator ${numerator} and denominator ${denominator} for user ${user}.` + ); return liqPercent; } diff --git a/src/pool_event_handler.ts b/src/pool_event_handler.ts index 737d4f4..d8b68e8 100644 --- a/src/pool_event_handler.ts +++ b/src/pool_event_handler.ts @@ -132,6 +132,7 @@ export class PoolEventHandler { case PoolEventType.FillAuction: { const logMessage = `Auction Fill Event\nType ${AuctionType[poolEvent.event.auctionType]}\nFiller: ${poolEvent.event.from}\nUser: ${poolEvent.event.user}\nFill Percent: ${poolEvent.event.fillAmount}\nTx Hash: ${poolEvent.event.txHash}\n`; await sendSlackNotification(logMessage); + logger.info(logMessage); if (poolEvent.event.fillAmount === BigInt(100)) { // auction was fully filled, remove from ongoing auctions let runResult = this.db.deleteAuctionEntry( @@ -139,20 +140,28 @@ export class PoolEventHandler { poolEvent.event.auctionType ); if (runResult.changes !== 0) { - logger.info(logMessage); - } - if (poolEvent.event.auctionType === AuctionType.Liquidation) { - const { estimate: userPositionsEstimate, user } = - await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.user); - updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); - } else if (poolEvent.event.auctionType === AuctionType.BadDebt) { - sendEvent(this.worker, { - type: EventType.CHECK_USER, - timestamp: Date.now(), - userId: APP_CONFIG.backstopAddress, - }); + logger.info( + `Auction Deleted\nType: ${AuctionType[poolEvent.event.auctionType]}\nUser: ${poolEvent.event.user}` + ); } } + if (poolEvent.event.auctionType === AuctionType.Liquidation) { + const { estimate: userPositionsEstimate, user } = + await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.user); + updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); + const { estimate: fillerPositionsEstimate, user: filler } = + await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.from); + updateUser(this.db, pool, filler, fillerPositionsEstimate, poolEvent.event.ledger); + } else if (poolEvent.event.auctionType === AuctionType.BadDebt) { + const { estimate: fillerPositionsEstimate, user: filler } = + await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.from); + updateUser(this.db, pool, filler, fillerPositionsEstimate, poolEvent.event.ledger); + sendEvent(this.worker, { + type: EventType.CHECK_USER, + timestamp: Date.now(), + userId: APP_CONFIG.backstopAddress, + }); + } break; } diff --git a/src/user.ts b/src/user.ts index b7a68ce..5abacc1 100644 --- a/src/user.ts +++ b/src/user.ts @@ -49,7 +49,9 @@ export function updateUser( updated: ledger, }; db.setUserEntry(new_entry); - logger.info(`Updated user entry for ${user.userId} at ledger ${ledger}.`); + logger.info( + `Updated user entry for ${user.userId} at ledger ${ledger} with health factor: ${new_entry.health_factor}.` + ); } else { // user does not have liabilities, remove db entry if it exists db.deleteUserEntry(user.userId); diff --git a/src/utils/config.ts b/src/utils/config.ts index b635b76..1371583 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -72,6 +72,7 @@ export function validateAppConfig(config: any): boolean { (config.profits !== undefined && !Array.isArray(config.profits)) || (config.slackWebhook !== undefined && typeof config.slackWebhook !== 'string') ) { + console.log('Invalid app config'); return false; } @@ -92,7 +93,7 @@ export function validateFiller(filler: any): boolean { if ( typeof filler.name === 'string' && typeof filler.keypair === 'string' && - typeof filler.minProfitPct === 'number' && + typeof filler.defaultProfitPct === 'number' && typeof filler.minHealthFactor === 'number' && typeof filler.forceFill === 'boolean' && typeof filler.primaryAsset === 'string' && @@ -106,6 +107,7 @@ export function validateFiller(filler: any): boolean { filler.minPrimaryCollateral = BigInt(filler.minPrimaryCollateral); return true; } + console.log('Invalid filler', filler); return false; } @@ -121,7 +123,7 @@ export function validatePriceSource(priceSource: any): boolean { ) { return true; } - + console.log('Invalid price source', priceSource); return false; } @@ -140,5 +142,6 @@ export function validateAuctionProfit(profits: any): boolean { return true; } + console.log('Invalid profit', profits); return false; } diff --git a/src/utils/slack_notifier.ts b/src/utils/slack_notifier.ts index f33b5ff..a24553f 100644 --- a/src/utils/slack_notifier.ts +++ b/src/utils/slack_notifier.ts @@ -16,6 +16,10 @@ export async function sendSlackNotification(message: string): Promise { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } + } else { + console.log( + `Bot Name: ${APP_CONFIG.name}\nTimestamp: ${new Date().toISOString()}\nPool Address: ${APP_CONFIG.poolAddress}\n${message}` + ); } } catch (e) { logger.error(`Error sending slack notification: ${e}`); diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index 3fef099..c857b59 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -1,6 +1,8 @@ import { + Auction, AuctionData, BackstopToken, + ContractErrorType, Network, parseError, Pool, @@ -103,7 +105,7 @@ export class SorobanHelper { } } - async loadAuction(userId: string, auctionType: number): Promise { + async loadAuction(userId: string, auctionType: number): Promise { try { let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); const ledgerKey = AuctionData.ledgerKey(APP_CONFIG.poolAddress, { @@ -114,10 +116,10 @@ export class SorobanHelper { if (ledgerData.entries.length === 0) { return undefined; } - let auction = PoolContract.parsers.getAuction( + let auctionData = PoolContract.parsers.getAuction( ledgerData.entries[0].val.contractData().val().toXDR('base64') ); - return auction; + return new Auction(userId, auctionType, auctionData); } catch (e) { logger.error(`Error loading auction: ${e}`); throw e; @@ -133,6 +135,9 @@ export class SorobanHelper { ); } + /** + * @dev WARNING: If loading balances for the filler, use `getFillerAvailableBalances` instead. + */ async loadBalances(userId: string, tokens: string[]): Promise> { try { let balances = new Map(); @@ -234,6 +239,7 @@ export class SorobanHelper { .addOperation(xdr.Operation.fromXDR(operation, 'base64')) .build(); + logger.info(`Attempting to simulate and submit transaction: ${tx.toXDR()}`); const simResult = await rpc.simulateTransaction(tx); if (SorobanRpc.Api.isSimulationSuccess(simResult)) { let assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); @@ -246,7 +252,7 @@ export class SorobanHelper { if (txResponse.status !== 'PENDING') { const error = parseError(txResponse); logger.error( - `Transaction failed to send: Tx Hash: ${txResponse.hash} Error Result XDR: ${txResponse.errorResult?.toXDR('base64')} Parsed Error: ${error}` + `Transaction failed to send: Tx Hash: ${txResponse.hash} Error Result XDR: ${txResponse.errorResult?.toXDR('base64')} Parsed Error: ${ContractErrorType[error.type]}` ); throw error; } @@ -260,7 +266,7 @@ export class SorobanHelper { if (get_tx_response.status !== 'SUCCESS') { const error = parseError(get_tx_response); logger.error( - `Tx Failed: ${error}, Error Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}` + `Tx Failed: ${ContractErrorType[error.type]}, Error Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}` ); throw error; @@ -277,6 +283,7 @@ export class SorobanHelper { return { ...get_tx_response, txHash: txResponse.hash }; } const error = parseError(simResult); + logger.error(`Tx failed to simlate: ${ContractErrorType[error.type]}`); throw error; } } From 31c1d178afd6defd9a8802fa64c5e43ae0a04b83 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Tue, 19 Nov 2024 14:16:29 -0500 Subject: [PATCH 05/13] fix: add restore logic to tx submissions --- src/utils/soroban_helper.ts | 111 +++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 40 deletions(-) diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index c857b59..0657f29 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -17,8 +17,10 @@ import { Contract, Keypair, nativeToScVal, + Operation, scValToNative, SorobanRpc, + Transaction, TransactionBuilder, xdr, } from '@stellar/stellar-sdk'; @@ -229,9 +231,8 @@ export class SorobanHelper { keypair: Keypair ): Promise { const rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); - const curr_time = Date.now(); - const account = await rpc.getAccount(keypair.publicKey()); - const tx = new TransactionBuilder(account, { + let account = await rpc.getAccount(keypair.publicKey()); + let tx = new TransactionBuilder(account, { networkPassphrase: this.network.passphrase, fee: BASE_FEE, timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, @@ -240,50 +241,80 @@ export class SorobanHelper { .build(); logger.info(`Attempting to simulate and submit transaction: ${tx.toXDR()}`); - const simResult = await rpc.simulateTransaction(tx); + let simResult = await rpc.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationRestore(simResult)) { + logger.info('Simulation ran into expired entries. Attempting to restore.'); + account = await rpc.getAccount(keypair.publicKey()); + const fee = Number(simResult.restorePreamble.minResourceFee) + 1000; + const restore_tx = new TransactionBuilder(account, { fee: fee.toString() }) + .setNetworkPassphrase(this.network.passphrase) + .setTimeout(0) + .setSorobanData(simResult.restorePreamble.transactionData.build()) + .addOperation(Operation.restoreFootprint({})) + .build(); + restore_tx.sign(keypair); + let restore_result = await this.sendTransaction(restore_tx); + logger.info(`Successfully restored. Tx Hash: ${restore_result.txHash}`); + account = await rpc.getAccount(keypair.publicKey()); + tx = new TransactionBuilder(account, { + networkPassphrase: this.network.passphrase, + fee: BASE_FEE, + timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, + }) + .addOperation(xdr.Operation.fromXDR(operation, 'base64')) + .build(); + simResult = await rpc.simulateTransaction(tx); + } + if (SorobanRpc.Api.isSimulationSuccess(simResult)) { let assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); assembledTx.sign(keypair); - let txResponse = await rpc.sendTransaction(assembledTx); - while (txResponse.status === 'TRY_AGAIN_LATER' && Date.now() - curr_time < 20000) { - await new Promise((resolve) => setTimeout(resolve, 4000)); - txResponse = await rpc.sendTransaction(assembledTx); - } - if (txResponse.status !== 'PENDING') { - const error = parseError(txResponse); - logger.error( - `Transaction failed to send: Tx Hash: ${txResponse.hash} Error Result XDR: ${txResponse.errorResult?.toXDR('base64')} Parsed Error: ${ContractErrorType[error.type]}` - ); - throw error; - } + return await this.sendTransaction(assembledTx); + } else { + const error = parseError(simResult); + logger.error(`Tx failed to simlate: ${ContractErrorType[error.type]}`); + throw error; + } + } - let get_tx_response = await rpc.getTransaction(txResponse.hash); - while (get_tx_response.status === 'NOT_FOUND') { - await new Promise((resolve) => setTimeout(resolve, 250)); - get_tx_response = await rpc.getTransaction(txResponse.hash); - } + private async sendTransaction( + transaction: Transaction + ): Promise { + const rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + let txResponse = await rpc.sendTransaction(transaction); + if (txResponse.status === 'TRY_AGAIN_LATER') { + await new Promise((resolve) => setTimeout(resolve, 4000)); + txResponse = await rpc.sendTransaction(transaction); + } - if (get_tx_response.status !== 'SUCCESS') { - const error = parseError(get_tx_response); - logger.error( - `Tx Failed: ${ContractErrorType[error.type]}, Error Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}` - ); + if (txResponse.status !== 'PENDING') { + const error = parseError(txResponse); + logger.error( + `Transaction failed to send: Tx Hash: ${txResponse.hash} Error Result XDR: ${txResponse.errorResult?.toXDR('base64')} Parsed Error: ${ContractErrorType[error.type]}` + ); + throw error; + } + let get_tx_response = await rpc.getTransaction(txResponse.hash); + while (get_tx_response.status === 'NOT_FOUND') { + await new Promise((resolve) => setTimeout(resolve, 250)); + get_tx_response = await rpc.getTransaction(txResponse.hash); + } - throw error; - } - logger.info( - 'Transaction successfully submitted: ' + - `Ledger: ${get_tx_response.ledger} ` + - `Latest Ledger Close Time: ${get_tx_response.latestLedgerCloseTime} ` + - `Transaction Result XDR: ${get_tx_response.resultXdr.toXDR('base64')} ` + - `Tx Envelope XDR: ${get_tx_response.envelopeXdr.toXDR('base64')}` + - `Tx Hash: - ${txResponse.hash}` + if (get_tx_response.status !== 'SUCCESS') { + const error = parseError(get_tx_response); + logger.error( + `Tx Failed: ${ContractErrorType[error.type]}, Error Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}` ); - return { ...get_tx_response, txHash: txResponse.hash }; + + throw error; } - const error = parseError(simResult); - logger.error(`Tx failed to simlate: ${ContractErrorType[error.type]}`); - throw error; + logger.info( + 'Transaction successfully submitted: ' + + `Ledger: ${get_tx_response.ledger}\n` + + `Transaction Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}\n` + + `Tx Hash: ${txResponse.hash}` + ); + return { ...get_tx_response, txHash: txResponse.hash }; } } From 6e61486d16b582c429e5404d8d6a7c2a933afc03 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Tue, 19 Nov 2024 14:18:53 -0500 Subject: [PATCH 06/13] chore: use latest blend-sdk --- package-lock.json | 12 ++++++------ package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0872a48..99ca3a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "auctioneer-bot", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auctioneer-bot", - "version": "0.0.0", + "version": "0.2.0", "license": "MIT", "dependencies": { - "@blend-capital/blend-sdk": "2.1.1", + "@blend-capital/blend-sdk": "2.1.2", "@stellar/stellar-sdk": "12.3.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", @@ -548,9 +548,9 @@ "dev": true }, "node_modules/@blend-capital/blend-sdk": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.1.1.tgz", - "integrity": "sha512-cEP6rwXKl79rrjNb8jTYHhsL2gxHqBoVtHjj3oBo0+jEa0BnMUqDqd6vAr+eNBaydtRUqi+r6YCPE+kOfRn3UQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.1.2.tgz", + "integrity": "sha512-/fbyFCA52x5f9EvblgTtmV25NHrboBXkool3vNhnVL40NY181ZkfObs44LxDKFtnZFEz4BNZHIDcqnuW2ZOPhQ==", "license": "MIT", "dependencies": { "@stellar/stellar-sdk": "12.3.0", diff --git a/package.json b/package.json index b530bf7..3a8dff6 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "typescript": "^5.5.4" }, "dependencies": { - "@blend-capital/blend-sdk": "2.1.1", + "@blend-capital/blend-sdk": "2.1.2", "@stellar/stellar-sdk": "12.3.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", From 9547f09b03b73f30415c0096439330a00741e1c9 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Wed, 20 Nov 2024 19:57:24 -0500 Subject: [PATCH 07/13] fix: use more efficient approach to filling auctions --- src/auction.ts | 306 ++++++++++++++++++++++++-------------------- src/filler.ts | 17 ++- src/liquidations.ts | 6 +- src/main.ts | 1 + 4 files changed, 180 insertions(+), 150 deletions(-) diff --git a/src/auction.ts b/src/auction.ts index 4a6292c..a79bc8f 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -2,23 +2,18 @@ import { Auction, AuctionType, FixedMath, + Pool, + PoolOracle, Request, RequestType, - ScaledAuction, } from '@blend-capital/blend-sdk'; import { getFillerAvailableBalances, getFillerProfitPct } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase } from './utils/db.js'; +import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; -export interface FillCalculation { - // The block number to fill the auction at - fillBlock: number; - // The percent of the auction to fill - fillPercent: number; -} - export interface AuctionFill { // The block number to fill the auction at block: number; @@ -48,49 +43,19 @@ export async function calculateAuctionFill( db: AuctioneerDatabase ): Promise { try { - const relevant_assets = []; - switch (auction.type) { - case AuctionType.Liquidation: - relevant_assets.push(...Array.from(auction.data.lot.keys())); - relevant_assets.push(...Array.from(auction.data.bid.keys())); - break; - case AuctionType.Interest: - relevant_assets.push(APP_CONFIG.backstopTokenAddress); - break; - case AuctionType.BadDebt: - relevant_assets.push(...Array.from(auction.data.lot.keys())); - relevant_assets.push(APP_CONFIG.backstopTokenAddress); - break; - } - const fillerBalances = await getFillerAvailableBalances( - filler, - [...new Set(relevant_assets)], - sorobanHelper - ); + const pool = await sorobanHelper.loadPool(); + const poolOracle = await sorobanHelper.loadPoolOracle(); - const auctionValue = await calculateAuctionValue(auction, fillerBalances, sorobanHelper, db); - const { fillBlock, fillPercent } = await calculateBlockFillAndPercent( + const auctionValue = await calculateAuctionValue(auction, pool, poolOracle, sorobanHelper, db); + return await calculateBlockFillAndPercent( filler, auction, auctionValue, + pool, + poolOracle, nextLedger, sorobanHelper ); - const [scaledAuction] = auction.scale(fillBlock, fillPercent); - const requests = buildFillRequests(scaledAuction, fillPercent, fillerBalances); - // estimate the lot value and bid value on the fill block - const blockDelay = fillBlock - auction.data.block; - const bidScalar = blockDelay <= 200 ? 1 : 1 - Math.max(0, blockDelay - 200) / 200; - const lotScalar = blockDelay < 200 ? blockDelay / 200 : 1; - const fillCalcLotValue = auctionValue.lotValue * lotScalar * (fillPercent / 100); - const fillCalcBidValue = auctionValue.bidValue * bidScalar * (fillPercent / 100); - return { - block: fillBlock, - percent: fillPercent, - lotValue: fillCalcLotValue, - bidValue: fillCalcBidValue, - requests, - }; } catch (e: any) { logger.error(`Error calculating auction fill.`, e); throw e; @@ -110,15 +75,41 @@ export async function calculateBlockFillAndPercent( filler: Filler, auction: Auction, auctionValue: AuctionValue, + pool: Pool, + poolOracle: PoolOracle, nextLedger: number, sorobanHelper: SorobanHelper -): Promise { - // auction value at block 200, or the base auction, with current prices +): Promise { let fillBlockDelay = 0; let fillPercent = 100; + let request: Request[] = []; + + // get relevant assets for the auction + let requests: Request[] = []; + const relevant_assets = []; + switch (auction.type) { + case AuctionType.Liquidation: + relevant_assets.push(...Array.from(auction.data.lot.keys())); + relevant_assets.push(...Array.from(auction.data.bid.keys())); + relevant_assets.push(filler.primaryAsset); + break; + case AuctionType.Interest: + relevant_assets.push(APP_CONFIG.backstopTokenAddress); + break; + case AuctionType.BadDebt: + relevant_assets.push(...Array.from(auction.data.lot.keys())); + relevant_assets.push(APP_CONFIG.backstopTokenAddress); + relevant_assets.push(filler.primaryAsset); + break; + } + const fillerBalances = await getFillerAvailableBalances( + filler, + [...new Set(relevant_assets)], + sorobanHelper + ); - let { effectiveCollateral, effectiveLiabilities, repayableLiabilities, lotValue, bidValue } = - auctionValue; + // auction value is the full auction + let { effectiveCollateral, effectiveLiabilities, lotValue, bidValue } = auctionValue; // find the block delay where the auction meets the required profit percentage const profitPercent = getFillerProfitPct(filler, APP_CONFIG.profits ?? [], auction.data); @@ -131,8 +122,11 @@ export async function calculateBlockFillAndPercent( } fillBlockDelay = Math.min(Math.max(Math.ceil(fillBlockDelay), 0), 400); // apply force fill auction boundries to profit calculations - if (auction.type === AuctionType.Liquidation && filler.forceFill) { - fillBlockDelay = Math.min(fillBlockDelay, 198); + if ( + (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) && + filler.forceFill + ) { + fillBlockDelay = Math.min(fillBlockDelay, 250); } else if (auction.type === AuctionType.Interest && filler.forceFill) { fillBlockDelay = Math.min(fillBlockDelay, 350); } @@ -141,80 +135,134 @@ export async function calculateBlockFillAndPercent( fillBlockDelay = Math.min(nextLedger - auction.data.block, 400); } - const bidScalarAtFill = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; - const lotScalarAtFill = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; + const bidScalar = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; + const lotScalar = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; + + const [scaledAuction] = auction.scale(auction.data.block + fillBlockDelay, 100); // require that the filler can fully fill interest auctions if (auction.type === AuctionType.Interest) { - const cometLpTokenBalance = FixedMath.toFloat( - await sorobanHelper.simBalance(APP_CONFIG.backstopTokenAddress, filler.keypair.publicKey()), - 7 - ); - const cometLpBidBase = FixedMath.toFloat( - auction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n, - 7 - ); - const cometLpBid = cometLpBidBase * bidScalarAtFill; + const cometLpTokenBalance = fillerBalances.get(APP_CONFIG.backstopTokenAddress) ?? 0n; + const cometLpBid = scaledAuction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n; if (cometLpBid > cometLpTokenBalance) { - const additionalCometLp = cometLpBid - cometLpTokenBalance; - const bidStepSize = cometLpBidBase / 200; + const additionalCometLp = FixedMath.toFloat(cometLpBid - cometLpTokenBalance, 7); + const bidStepSize = FixedMath.toFloat(cometLpBid, 7) / 200; if (additionalCometLp >= 0 && bidStepSize > 0) { const additionalDelay = Math.ceil(additionalCometLp / bidStepSize); - fillBlockDelay = Math.max(200, fillBlockDelay) + additionalDelay; - fillBlockDelay = Math.min(fillBlockDelay, 400); + fillBlockDelay = Math.min(400, fillBlockDelay + additionalDelay); } } } else if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) { - // require that filler meets minimum health factor requirements const { estimate: fillerPositionEstimates } = await sorobanHelper.loadUserPositionEstimate( filler.keypair.publicKey() ); // inflate minHealthFactor slightly, to allow for the unwind logic to unwind looped positions safely + const additionalLiabilities = effectiveLiabilities * bidScalar; + const additionalCollateral = effectiveCollateral * lotScalar; const safeHealthFactor = filler.minHealthFactor * 1.1; - const additionalLiabilities = effectiveLiabilities * bidScalarAtFill - repayableLiabilities; - const additionalCollateral = effectiveCollateral * lotScalarAtFill; - const additionalCollateralReq = additionalLiabilities * safeHealthFactor; - if (additionalCollateral < additionalCollateralReq) { - const excessLiabilities = additionalCollateralReq - additionalCollateral; - const liabilityLimitToHF = - fillerPositionEstimates.totalEffectiveCollateral / safeHealthFactor - - fillerPositionEstimates.totalEffectiveLiabilities; + let limitToHF = + (fillerPositionEstimates.totalEffectiveCollateral + additionalCollateral) / safeHealthFactor - + (fillerPositionEstimates.totalEffectiveLiabilities + additionalLiabilities); + let liabilitiesRepaid = 0; + let collateralAdded = 0; - logger.info( - `Auction does not add enough collateral to maintain health factor. Additional Collateral: ${additionalCollateral}, Additional Liabilities: ${additionalLiabilities}, Repaid Liabilities: ${repayableLiabilities}, Excess Liabilities to HF: ${excessLiabilities}, Liability Limit to HF: ${liabilityLimitToHF}` - ); + logger.info( + `Auction value: ${stringify(auctionValue)}. Bid scalar: ${bidScalar}. Lot scalar: ${lotScalar}. Limit to HF: ${limitToHF}` + ); + + // attempt to repay any liabilities the filler has took on from the bids + for (const [assetId, amount] of scaledAuction.data.bid) { + const balance = fillerBalances.get(assetId) ?? 0n; + if (balance > 0n) { + const reserve = pool.reserves.get(assetId); + const oraclePrice = poolOracle.getPriceFloat(assetId); + if (reserve !== undefined && oraclePrice !== undefined) { + // 100n prevents dust positions from being created, and is deducted from the repaid liability + const amountAsUnderlying = reserve.toAssetFromDToken(amount) + 100n; + const repaidLiability = amountAsUnderlying <= balance ? amountAsUnderlying : balance; + const effectiveLiability = + FixedMath.toFloat(repaidLiability - 100n, reserve.config.decimals) * + reserve.getLiabilityFactor() * + oraclePrice; + limitToHF += effectiveLiability; + liabilitiesRepaid += effectiveLiability; + fillerBalances.set(assetId, balance - repaidLiability); + requests.push({ + request_type: RequestType.Repay, + address: assetId, + amount: repaidLiability, + }); + } + } + } + + // withdraw any collateral that has no CF to reduce position count + if (auction.type === AuctionType.Liquidation) { + for (const [assetId] of scaledAuction.data.lot) { + const reserve = pool.reserves.get(assetId); + if (reserve !== undefined && reserve.getCollateralFactor() === 0) { + requests.push({ + request_type: RequestType.WithdrawCollateral, + address: assetId, + amount: BigInt('9223372036854775807'), + }); + } + } + } - if (liabilityLimitToHF <= 0) { - // filler can't take on additional liabilities. Push back fill block until more collateral - // is received than liabilities taken on, or no liabilities are taken on - const liabilityBlockDecrease = - Math.ceil(100 * (excessLiabilities / effectiveLiabilities)) / 0.5; - fillBlockDelay = Math.min(Math.max(200, fillBlockDelay) + liabilityBlockDecrease, 400); - logger.info( - `Unable to fill auction at expected profit due to insufficient collateral, pushing fill block an extra ${liabilityBlockDecrease} back to ${fillBlockDelay}` + if (limitToHF < 0) { + // if we still are under the health factor, we need to try and add more of the fillers primary asset as collateral + const primaryBalance = fillerBalances.get(filler.primaryAsset) ?? 0n; + const primaryReserve = pool.reserves.get(filler.primaryAsset); + const primaryOraclePrice = poolOracle.getPriceFloat(filler.primaryAsset); + if (primaryReserve !== undefined && primaryOraclePrice !== undefined && primaryBalance > 0n) { + const primaryCollateralRequired = Math.ceil( + (Math.abs(limitToHF) / (primaryReserve.getCollateralFactor() * primaryOraclePrice)) * + safeHealthFactor ); - } else if (excessLiabilities > liabilityLimitToHF) { - // reduce fill percent to the point where the filler can take on the liabilities - fillPercent = Math.floor((liabilityLimitToHF / excessLiabilities) * 100); + const primaryBalFloat = FixedMath.toFloat(primaryBalance, primaryReserve.config.decimals); + const primaryDeposit = Math.min(primaryBalFloat, primaryCollateralRequired); + const collateral = + primaryDeposit * primaryReserve.getCollateralFactor() * primaryOraclePrice; + limitToHF += collateral / safeHealthFactor; + collateralAdded += collateral; + requests.push({ + request_type: RequestType.SupplyCollateral, + address: filler.primaryAsset, + amount: FixedMath.toFixed(primaryDeposit, primaryReserve.config.decimals), + }); + } + + if (limitToHF < 0) { + const absLimitToHF = Math.abs(limitToHF); + // if we still are under the health factor, we need to either reduce the fill percent or push back the fill block + const preFillLimitToHF = + fillerPositionEstimates.totalEffectiveCollateral / safeHealthFactor - + fillerPositionEstimates.totalEffectiveLiabilities; + if (preFillLimitToHF <= 0) { + // filler can't take on additional liabilities. Push back fill block until more collateral + // is received than liabilities taken on, or no liabilities are taken on + const blockDelay = + Math.ceil(100 * (absLimitToHF / auctionValue.effectiveLiabilities)) / 0.5; + fillBlockDelay = Math.min(fillBlockDelay + blockDelay, 400); + logger.info( + `Unable to fill auction at expected profit due to insufficient health factor. Auction fill exceeds HF borrow limit by $${limitToHF}, adding block delay of ${blockDelay}.` + ); + } else if (preFillLimitToHF < absLimitToHF) { + // reduce fill percent to the point where the filler can take on the liabilities. This can be approximated by + // the ratio of the pre fill borrow limit over the total incoming liabilities. This overapproximates when repayments occur. + fillPercent = Math.floor( + Math.min(1, preFillLimitToHF / (absLimitToHF + preFillLimitToHF)) * 100 + ); + logger.info( + `Unable to fill auction at 100% due to insufficient health factor. Auction fill exceeds HF borrow limit by $${limitToHF}. Dropping fill percent to ${fillPercent}.` + ); + } + // if absLimitToHF > preFillLimitToHF, the account will still maintain the min health factor } } } - return { fillBlock: auction.data.block + fillBlockDelay, fillPercent }; -} -/** - * Build requests to fill the auction and repay the liabilities. - * @param scaledAuction - The scaled auction to build the fill requests for - * @param fillPercent - The percent to fill the auction - * @param sorobanHelper - The soroban helper to use for the calculation - * @returns - */ -export function buildFillRequests( - scaledAuction: ScaledAuction, - fillPercent: number, - fillerBalances: Map -): Request[] { - let fillRequests: Request[] = []; let requestType: RequestType; switch (scaledAuction.type) { case AuctionType.Liquidation: @@ -227,43 +275,36 @@ export function buildFillRequests( requestType = RequestType.FillBadDebtAuction; break; } - fillRequests.push({ + // push the fill request on the front of the list + requests.unshift({ request_type: requestType, - address: scaledAuction.user, + address: auction.user, amount: BigInt(fillPercent), }); - if (scaledAuction.type === AuctionType.Interest) { - return fillRequests; - } - - // attempt to repay any liabilities the filler has took on from the bids - // if this fails for some reason, still continue with the fill - for (const [assetId] of scaledAuction.data.bid) { - const fillerBalance = fillerBalances.get(assetId) ?? 0n; - if (fillerBalance > 0n) { - fillRequests.push({ - request_type: RequestType.Repay, - address: assetId, - amount: BigInt(fillerBalance), - }); - } - } - return fillRequests; + return { + block: auction.data.block + fillBlockDelay, + percent: fillPercent, + requests, + lotValue: lotValue * lotScalar * (fillPercent / 100), + bidValue: bidValue * bidScalar * (fillPercent / 100), + }; } /** * Calculate the effective collateral, lot value, effective liabilities, and bid value for an auction. * * @param auction - The auction to calculate the values for - * @param fillerBalances - The balances of the filler + * @param pool - The pool to use for fetching reserve data + * @param poolOracle - The pool oracle to use for fetching asset prices * @param sorobanHelper - A helper to use for loading ledger data * @param db - The database to use for fetching asset prices * @returns The calculated values, or 0 for all values if it is unable to calculate them */ export async function calculateAuctionValue( auction: Auction, - fillerBalances: Map, + pool: Pool, + poolOracle: PoolOracle, sorobanHelper: SorobanHelper, db: AuctioneerDatabase ): Promise { @@ -272,8 +313,6 @@ export async function calculateAuctionValue( let effectiveLiabilities = 0; let repayableLiabilities = 0; let bidValue = 0; - const pool = await sorobanHelper.loadPool(); - const poolOracle = await sorobanHelper.loadPoolOracle(); const reserves = pool.reserves; for (const [assetId, amount] of auction.data.lot) { if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.Interest) { @@ -300,7 +339,7 @@ export async function calculateAuctionValue( `Unexpected bad debt auction. Lot contains asset other than the backstop token: ${assetId}` ); } - bidValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); + lotValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); } else { throw new Error(`Failed to value lot asset: ${assetId}`); } @@ -319,19 +358,6 @@ export async function calculateAuctionValue( } effectiveLiabilities += reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; bidValue += reserve.toAssetFromDTokenFloat(amount) * (dbPrice ?? oraclePrice); - const fillerBalance = fillerBalances.get(assetId) ?? 0n; - if (fillerBalance > 0) { - const liabilityAmount = reserve.toAssetFromDToken(amount); - const repaymentAmount = liabilityAmount <= fillerBalance ? liabilityAmount : fillerBalance; - const repayableLiability = - FixedMath.toFloat(repaymentAmount, reserve.config.decimals) * - reserve.getLiabilityFactor() * - oraclePrice; - repayableLiabilities += repayableLiability; - logger.info( - `Filler can repay ${assetId} amount ${FixedMath.toFloat(repaymentAmount)} to cover liabilities: ${repayableLiability}` - ); - } } else if (auction.type === AuctionType.Interest) { if (assetId !== APP_CONFIG.backstopTokenAddress) { throw new Error( diff --git a/src/filler.ts b/src/filler.ts index 1126a1c..78b0ddf 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -228,13 +228,16 @@ export function managePositions( const toMinPosition = reserve.toAssetFromBToken(amount) - filler.minPrimaryCollateral; withdrawAmount = withdrawAmount > toMinPosition ? toMinPosition : withdrawAmount; } - const withdrawnBToken = reserve.toBTokensFromAssetFloor(withdrawAmount); - effectiveCollateral -= reserve.toEffectiveAssetFromBTokenFloat(withdrawnBToken) * price; - requests.push({ - request_type: RequestType.WithdrawCollateral, - address: reserve.assetId, - amount: withdrawAmount, - }); + + if (withdrawAmount > 0n) { + const withdrawnBToken = reserve.toBTokensFromAssetFloor(withdrawAmount); + effectiveCollateral -= reserve.toEffectiveAssetFromBTokenFloat(withdrawnBToken) * price; + requests.push({ + request_type: RequestType.WithdrawCollateral, + address: reserve.assetId, + amount: withdrawAmount, + }); + } } return requests; } diff --git a/src/liquidations.ts b/src/liquidations.ts index 1e21a9f..09379ae 100644 --- a/src/liquidations.ts +++ b/src/liquidations.ts @@ -15,7 +15,7 @@ export function isLiquidatable(user: PositionsEstimate): boolean { if ( user.totalEffectiveLiabilities > 0 && user.totalEffectiveCollateral > 0 && - user.totalEffectiveCollateral / user.totalEffectiveLiabilities < 0.995 + user.totalEffectiveCollateral / user.totalEffectiveLiabilities < 0.998 ) { return true; } @@ -43,8 +43,8 @@ export function calculateLiquidationPercent(user: PositionsEstimate): bigint { const avgInverseLF = user.totalEffectiveLiabilities / user.totalBorrowed; const avgCF = user.totalEffectiveCollateral / user.totalSupplied; const estIncentive = 1 + (1 - avgCF / avgInverseLF) / 2; - const numerator = user.totalEffectiveLiabilities * 1.1 - user.totalEffectiveCollateral; - const denominator = avgInverseLF * 1.1 - avgCF * estIncentive; + const numerator = user.totalEffectiveLiabilities * 1.06 - user.totalEffectiveCollateral; + const denominator = avgInverseLF * 1.06 - avgCF * estIncentive; const liqPercent = BigInt( Math.min(Math.round((numerator / denominator / user.totalBorrowed) * 100), 100) ); diff --git a/src/main.ts b/src/main.ts index 4e9e9c8..32d802f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -103,6 +103,7 @@ async function main() { logger.error(`Error in collector`, e); } }, 1000); + console.log('Collector polling for events...'); } main().catch((error) => { From 29ad71a024d7cf43eafa14e91c62deb22b731347 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Fri, 22 Nov 2024 14:43:55 -0500 Subject: [PATCH 08/13] fix: update fill percent accurately during auction calculation --- src/auction.ts | 239 +++++---- test/auction.test.ts | 1178 +++++++++++++++++++++-------------------- test/helpers/mocks.ts | 68 +-- test/helpers/utils.ts | 10 + 4 files changed, 783 insertions(+), 712 deletions(-) create mode 100644 test/helpers/utils.ts diff --git a/src/auction.ts b/src/auction.ts index a79bc8f..afdb267 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -82,10 +82,9 @@ export async function calculateBlockFillAndPercent( ): Promise { let fillBlockDelay = 0; let fillPercent = 100; - let request: Request[] = []; + let requests: Request[] = []; // get relevant assets for the auction - let requests: Request[] = []; const relevant_assets = []; switch (auction.type) { case AuctionType.Liquidation: @@ -126,7 +125,7 @@ export async function calculateBlockFillAndPercent( (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) && filler.forceFill ) { - fillBlockDelay = Math.min(fillBlockDelay, 250); + fillBlockDelay = Math.min(fillBlockDelay, 220); } else if (auction.type === AuctionType.Interest && filler.forceFill) { fillBlockDelay = Math.min(fillBlockDelay, 350); } @@ -135,8 +134,8 @@ export async function calculateBlockFillAndPercent( fillBlockDelay = Math.min(nextLedger - auction.data.block, 400); } - const bidScalar = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; - const lotScalar = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; + let bidScalar = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; + let lotScalar = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; const [scaledAuction] = auction.scale(auction.data.block + fillBlockDelay, 100); @@ -146,7 +145,8 @@ export async function calculateBlockFillAndPercent( const cometLpBid = scaledAuction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n; if (cometLpBid > cometLpTokenBalance) { const additionalCometLp = FixedMath.toFloat(cometLpBid - cometLpTokenBalance, 7); - const bidStepSize = FixedMath.toFloat(cometLpBid, 7) / 200; + const baseCometLpBid = auction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n; + const bidStepSize = FixedMath.toFloat(baseCometLpBid, 7) / 200; if (additionalCometLp >= 0 && bidStepSize > 0) { const additionalDelay = Math.ceil(additionalCometLp / bidStepSize); fillBlockDelay = Math.min(400, fillBlockDelay + additionalDelay); @@ -156,111 +156,153 @@ export async function calculateBlockFillAndPercent( const { estimate: fillerPositionEstimates } = await sorobanHelper.loadUserPositionEstimate( filler.keypair.publicKey() ); - // inflate minHealthFactor slightly, to allow for the unwind logic to unwind looped positions safely - const additionalLiabilities = effectiveLiabilities * bidScalar; - const additionalCollateral = effectiveCollateral * lotScalar; - const safeHealthFactor = filler.minHealthFactor * 1.1; - let limitToHF = - (fillerPositionEstimates.totalEffectiveCollateral + additionalCollateral) / safeHealthFactor - - (fillerPositionEstimates.totalEffectiveLiabilities + additionalLiabilities); - let liabilitiesRepaid = 0; - let collateralAdded = 0; + let canFillWithSafeHF = false; + let iterations = 0; + while (!canFillWithSafeHF && iterations < 5) { + const loopFillerBalances = new Map(fillerBalances); + requests = []; + logger.info( + `Calculating auction fill iteration ${iterations} with delay ${fillBlockDelay} and percent ${fillPercent}` + ); + const [loopScaledAuction] = auction.scale(auction.data.block + fillBlockDelay, fillPercent); + iterations++; + // inflate minHealthFactor slightly, to allow for the unwind logic to unwind looped positions safely + const additionalLiabilities = effectiveLiabilities * bidScalar * (fillPercent / 100); + const additionalCollateral = effectiveCollateral * lotScalar * (fillPercent / 100); + const safeHealthFactor = filler.minHealthFactor * 1.1; + let limitToHF = + (fillerPositionEstimates.totalEffectiveCollateral + additionalCollateral) / + safeHealthFactor - + (fillerPositionEstimates.totalEffectiveLiabilities + additionalLiabilities); + let liabilitiesRepaid = 0; + let collateralAdded = 0; - logger.info( - `Auction value: ${stringify(auctionValue)}. Bid scalar: ${bidScalar}. Lot scalar: ${lotScalar}. Limit to HF: ${limitToHF}` - ); + logger.info( + `Auction value: ${stringify(auctionValue)}. Bid scalar: ${bidScalar}. Lot scalar: ${lotScalar}. Limit to HF: ${limitToHF}` + ); - // attempt to repay any liabilities the filler has took on from the bids - for (const [assetId, amount] of scaledAuction.data.bid) { - const balance = fillerBalances.get(assetId) ?? 0n; - if (balance > 0n) { - const reserve = pool.reserves.get(assetId); - const oraclePrice = poolOracle.getPriceFloat(assetId); - if (reserve !== undefined && oraclePrice !== undefined) { - // 100n prevents dust positions from being created, and is deducted from the repaid liability - const amountAsUnderlying = reserve.toAssetFromDToken(amount) + 100n; - const repaidLiability = amountAsUnderlying <= balance ? amountAsUnderlying : balance; - const effectiveLiability = - FixedMath.toFloat(repaidLiability - 100n, reserve.config.decimals) * - reserve.getLiabilityFactor() * - oraclePrice; - limitToHF += effectiveLiability; - liabilitiesRepaid += effectiveLiability; - fillerBalances.set(assetId, balance - repaidLiability); - requests.push({ - request_type: RequestType.Repay, - address: assetId, - amount: repaidLiability, - }); + // attempt to repay any liabilities the filler has took on from the bids + for (const [assetId, amount] of loopScaledAuction.data.bid) { + const balance = loopFillerBalances.get(assetId) ?? 0n; + if (balance > 0n) { + const reserve = pool.reserves.get(assetId); + const oraclePrice = poolOracle.getPriceFloat(assetId); + if (reserve !== undefined && oraclePrice !== undefined) { + // 100n prevents dust positions from being created, and is deducted from the repaid liability + const amountAsUnderlying = reserve.toAssetFromDToken(amount) + 100n; + const repaidLiability = amountAsUnderlying <= balance ? amountAsUnderlying : balance; + const effectiveLiability = + FixedMath.toFloat(repaidLiability - 100n, reserve.config.decimals) * + reserve.getLiabilityFactor() * + oraclePrice; + limitToHF += effectiveLiability; + liabilitiesRepaid += effectiveLiability; + loopFillerBalances.set(assetId, balance - repaidLiability); + requests.push({ + request_type: RequestType.Repay, + address: assetId, + amount: repaidLiability, + }); + } } } - } - // withdraw any collateral that has no CF to reduce position count - if (auction.type === AuctionType.Liquidation) { - for (const [assetId] of scaledAuction.data.lot) { - const reserve = pool.reserves.get(assetId); - if (reserve !== undefined && reserve.getCollateralFactor() === 0) { - requests.push({ - request_type: RequestType.WithdrawCollateral, - address: assetId, - amount: BigInt('9223372036854775807'), - }); + // withdraw any collateral that has no CF to reduce position count + if (auction.type === AuctionType.Liquidation) { + for (const [assetId] of loopScaledAuction.data.lot) { + const reserve = pool.reserves.get(assetId); + if (reserve !== undefined && reserve.getCollateralFactor() === 0) { + requests.push({ + request_type: RequestType.WithdrawCollateral, + address: assetId, + amount: BigInt('9223372036854775807'), + }); + } } } - } - - if (limitToHF < 0) { - // if we still are under the health factor, we need to try and add more of the fillers primary asset as collateral - const primaryBalance = fillerBalances.get(filler.primaryAsset) ?? 0n; - const primaryReserve = pool.reserves.get(filler.primaryAsset); - const primaryOraclePrice = poolOracle.getPriceFloat(filler.primaryAsset); - if (primaryReserve !== undefined && primaryOraclePrice !== undefined && primaryBalance > 0n) { - const primaryCollateralRequired = Math.ceil( - (Math.abs(limitToHF) / (primaryReserve.getCollateralFactor() * primaryOraclePrice)) * - safeHealthFactor - ); - const primaryBalFloat = FixedMath.toFloat(primaryBalance, primaryReserve.config.decimals); - const primaryDeposit = Math.min(primaryBalFloat, primaryCollateralRequired); - const collateral = - primaryDeposit * primaryReserve.getCollateralFactor() * primaryOraclePrice; - limitToHF += collateral / safeHealthFactor; - collateralAdded += collateral; - requests.push({ - request_type: RequestType.SupplyCollateral, - address: filler.primaryAsset, - amount: FixedMath.toFixed(primaryDeposit, primaryReserve.config.decimals), - }); - } if (limitToHF < 0) { - const absLimitToHF = Math.abs(limitToHF); - // if we still are under the health factor, we need to either reduce the fill percent or push back the fill block - const preFillLimitToHF = - fillerPositionEstimates.totalEffectiveCollateral / safeHealthFactor - - fillerPositionEstimates.totalEffectiveLiabilities; - if (preFillLimitToHF <= 0) { - // filler can't take on additional liabilities. Push back fill block until more collateral - // is received than liabilities taken on, or no liabilities are taken on - const blockDelay = - Math.ceil(100 * (absLimitToHF / auctionValue.effectiveLiabilities)) / 0.5; - fillBlockDelay = Math.min(fillBlockDelay + blockDelay, 400); - logger.info( - `Unable to fill auction at expected profit due to insufficient health factor. Auction fill exceeds HF borrow limit by $${limitToHF}, adding block delay of ${blockDelay}.` + // if we still are under the health factor, we need to try and add more of the fillers primary asset as collateral + const primaryBalance = loopFillerBalances.get(filler.primaryAsset) ?? 0n; + const primaryReserve = pool.reserves.get(filler.primaryAsset); + const primaryOraclePrice = poolOracle.getPriceFloat(filler.primaryAsset); + if ( + primaryReserve !== undefined && + primaryOraclePrice !== undefined && + primaryBalance > 0n + ) { + const primaryCollateralRequired = Math.ceil( + (Math.abs(limitToHF) / (primaryReserve.getCollateralFactor() * primaryOraclePrice)) * + safeHealthFactor ); - } else if (preFillLimitToHF < absLimitToHF) { - // reduce fill percent to the point where the filler can take on the liabilities. This can be approximated by - // the ratio of the pre fill borrow limit over the total incoming liabilities. This overapproximates when repayments occur. - fillPercent = Math.floor( - Math.min(1, preFillLimitToHF / (absLimitToHF + preFillLimitToHF)) * 100 + const primaryBalFloat = FixedMath.toFloat(primaryBalance, primaryReserve.config.decimals); + const primaryDeposit = Math.min(primaryBalFloat, primaryCollateralRequired); + const collateral = + primaryDeposit * primaryReserve.getCollateralFactor() * primaryOraclePrice; + limitToHF += collateral / safeHealthFactor; + collateralAdded += collateral; + requests.push({ + request_type: RequestType.SupplyCollateral, + address: filler.primaryAsset, + amount: FixedMath.toFixed(primaryDeposit, primaryReserve.config.decimals), + }); + } + + if (limitToHF < 0) { + const preBorrowLimit = Math.max( + (fillerPositionEstimates.totalEffectiveCollateral + collateralAdded) / + safeHealthFactor - + (fillerPositionEstimates.totalEffectiveLiabilities - liabilitiesRepaid), + 0 ); - logger.info( - `Unable to fill auction at 100% due to insufficient health factor. Auction fill exceeds HF borrow limit by $${limitToHF}. Dropping fill percent to ${fillPercent}.` + const incomingLiabilities = + additionalLiabilities - additionalCollateral / safeHealthFactor; + const adjustedFillPercent = Math.floor( + Math.min(1, preBorrowLimit / incomingLiabilities) * fillPercent ); + if (adjustedFillPercent < 1) { + // filler can't take on additional liabilities even with reduced fill percent. Push back fill block until + // more collateral is received than liabilities taken on, or no liabilities are taken on + const excessLiabilitiesAtBlock200 = + fillerPositionEstimates.totalEffectiveLiabilities + + auctionValue.effectiveLiabilities - + liabilitiesRepaid - + (fillerPositionEstimates.totalEffectiveCollateral + + auctionValue.effectiveCollateral + + collateralAdded) / + safeHealthFactor; + const blockDelay = + Math.ceil( + 100 * (Math.abs(excessLiabilitiesAtBlock200) / auctionValue.effectiveLiabilities) + ) / 0.5; + fillBlockDelay = Math.min(200 + blockDelay, 400); + logger.info( + `Unable to fill auction at expected profit due to insufficient health factor. Auction fill at block 200 exceeds HF borrow limit by $${excessLiabilitiesAtBlock200}, adding block delay of ${blockDelay}.` + ); + canFillWithSafeHF = true; + continue; + } else if (adjustedFillPercent < fillPercent) { + fillPercent = adjustedFillPercent; + logger.info( + `Unable to fill auction at 100% due to insufficient health factor. Auction fill exceeds HF borrow limit by $${limitToHF}. Dropping fill percent to ${fillPercent}.` + ); + } else { + canFillWithSafeHF = true; + continue; + } + } else { + canFillWithSafeHF = true; + continue; } - // if absLimitToHF > preFillLimitToHF, the account will still maintain the min health factor + } else { + canFillWithSafeHF = true; + continue; } } + if (!canFillWithSafeHF) { + logger.error(`Unable to determine auction fill with a safe HF.`); + throw new Error('Unable to determine auction fill with a safe HF.'); + } } let requestType: RequestType; @@ -282,6 +324,8 @@ export async function calculateBlockFillAndPercent( amount: BigInt(fillPercent), }); + bidScalar = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; + lotScalar = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; return { block: auction.data.block + fillBlockDelay, percent: fillPercent, @@ -330,8 +374,7 @@ export async function calculateAuctionValue( effectiveCollateral += reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; lotValue += reserve.toAssetFromBTokenFloat(amount) * (dbPrice ?? oraclePrice); } else { - lotValue += - (Number(amount) / 10 ** reserve.tokenMetadata.decimals) * (dbPrice ?? oraclePrice); + lotValue += FixedMath.toFloat(amount, reserve.config.decimals) * (dbPrice ?? oraclePrice); } } else if (auction.type === AuctionType.BadDebt) { if (assetId !== APP_CONFIG.backstopTokenAddress) { diff --git a/test/auction.test.ts b/test/auction.test.ts index ececda1..efa1b30 100644 --- a/test/auction.test.ts +++ b/test/auction.test.ts @@ -1,24 +1,35 @@ -import { BackstopToken, Request, RequestType } from '@blend-capital/blend-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; import { - buildFillRequests, - calculateAuctionValue, - calculateBlockFillAndPercent, - scaleAuction, -} from '../src/auction.js'; -import { AuctionBid, BidderSubmissionType } from '../src/bidder_submitter.js'; + Auction, + AuctionType, + BackstopToken, + FixedMath, + PoolUser, + PositionsEstimate, + Request, +} from '@blend-capital/blend-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; +import { calculateAuctionFill, valueBackstopTokenInUSDC } from '../src/auction.js'; +import { getFillerAvailableBalances, getFillerProfitPct } from '../src/filler.js'; import { Filler } from '../src/utils/config.js'; -import { AuctioneerDatabase, AuctionType } from '../src/utils/db.js'; +import { AuctioneerDatabase } from '../src/utils/db.js'; import { SorobanHelper } from '../src/utils/soroban_helper.js'; import { + AQUA, + BACKSTOP, + BACKSTOP_TOKEN, + EURC, inMemoryAuctioneerDb, - mockedPool, + MOCK_LEDGER, + MOCK_TIMESTAMP, + mockPool, mockPoolOracle, - mockPoolUser, - mockPoolUserEstimate, + USDC, + XLM, } from './helpers/mocks.js'; +import { expectRelApproxEqual } from './helpers/utils.js'; jest.mock('../src/utils/soroban_helper.js'); +jest.mock('../src/filler.js'); jest.mock('../src/utils/config.js', () => { return { APP_CONFIG: { @@ -35,690 +46,727 @@ jest.mock('../src/utils/config.js', () => { }; }); -describe('auction', () => { +describe('auctions', () => { let filler: Filler; const mockedSorobanHelper = new SorobanHelper() as jest.Mocked; let db: AuctioneerDatabase; + let positionEstimate: PositionsEstimate; + + const mockedGetFilledAvailableBalances = getFillerAvailableBalances as jest.MockedFunction< + typeof getFillerAvailableBalances + >; + const mockedGetFillerProfitPct = getFillerProfitPct as jest.MockedFunction< + typeof getFillerProfitPct + >; beforeEach(() => { jest.resetAllMocks(); db = inMemoryAuctioneerDb(); - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); - mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); - mockedSorobanHelper.loadUser.mockResolvedValue(mockPoolUser); - mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ - estimate: mockPoolUserEstimate, - user: mockPoolUser, - }); - mockedSorobanHelper.simLPTokenToUSDC.mockImplementation((number: bigint) => { - return Promise.resolve((number * 33333n) / 100000n); - }); filler = { name: 'Tester', keypair: Keypair.random(), - defaultProfitPct: 0.2, - minHealthFactor: 1.3, - primaryAsset: 'USD', + defaultProfitPct: 0.1, + minHealthFactor: 1.2, + primaryAsset: USDC, minPrimaryCollateral: 0n, forceFill: true, supportedBid: [], supportedLot: [], }; + positionEstimate = { + totalBorrowed: 0, + totalSupplied: 0, + // only effective numbers used + totalEffectiveLiabilities: 0, + totalEffectiveCollateral: 4750, + borrowCap: 0, + borrowLimit: 0, + netApr: 0, + supplyApr: 0, + borrowApr: 0, + }; + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + estimate: positionEstimate, + user: {} as PoolUser, + }); + mockedSorobanHelper.simLPTokenToUSDC.mockImplementation((number: bigint) => { + // 0.5 USDC per LP token + return Promise.resolve((number * 5000000n) / 10000000n); + }); }); - describe('calculateBlockFillAndPercent', () => { - it('test user liquidation expect fill under 200', async () => { - let auctionData = { + describe('calcAuctionFill', () => { + // *** Interest Auctions *** + + it('calcs fill for interest auction', async () => { + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + [XLM, FixedMath.toFixed(120)], + [USDC, FixedMath.toFixed(210)], + [EURC, FixedMath.toFixed(34)], + [AQUA, FixedMath.toFixed(2500)], ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ]), - block: 123, - }; + bid: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(728.01456)]]), + block: MOCK_LEDGER, + }); - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - mockedSorobanHelper, - db + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) ); - expect(fillCalc.fillBlock).toEqual(312); - expect(fillCalc.fillPercent).toEqual(100); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 272); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill.bidValue, 233.4726912, 0.005); }); - it('test user liquidation expect fill over 200', async () => { - let auctionData = { + it('calcs fill for interest auction and delays block to fully fill', async () => { + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + [XLM, FixedMath.toFixed(120)], + [USDC, FixedMath.toFixed(210)], + [EURC, FixedMath.toFixed(34)], + [AQUA, FixedMath.toFixed(2500)], ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - mockedSorobanHelper, - db + bid: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(728.01456)]]), + block: MOCK_LEDGER, + }); + + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(400)]]) ); - expect(fillCalc.fillBlock).toEqual(343); - expect(fillCalc.fillPercent).toEqual(100); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 272 + 19); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill.bidValue, 198.8165886, 0.005); }); - it('test force fill user liquidations sets fill to 198', async () => { - let auctionData = { + it('calcs fill for interest auction at next ledger if past target block', async () => { + let nextLedger = MOCK_LEDGER + 280; + let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], + [XLM, FixedMath.toFixed(120)], + [USDC, FixedMath.toFixed(210)], + [EURC, FixedMath.toFixed(34)], + [AQUA, FixedMath.toFixed(2500)], ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], - ]), - block: 123, - }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - mockedSorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(321); - expect(fillCalc.fillPercent).toEqual(100); - }); - - it('test user liquidation does not exceed min health factor', async () => { - mockPoolUserEstimate.totalEffectiveLiabilities = 18660; - mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ - estimate: mockPoolUserEstimate, - user: mockPoolUser, + bid: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(728.01456)]]), + block: MOCK_LEDGER, }); - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 88000_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - mockedSorobanHelper, - db + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) ); - expect(fillCalc.fillBlock).toEqual(339); - expect(fillCalc.fillPercent).toEqual(50); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(nextLedger); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill.bidValue, 218.880648, 0.005); }); - it('test interest auction', async () => { - mockedSorobanHelper.simBalance.mockResolvedValue(5000_0000000n); - let auctionData = { + it('calcs fill for interest auction uses db prices when possible', async () => { + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 2000_0000000n], + [XLM, FixedMath.toFixed(120)], + [USDC, FixedMath.toFixed(210)], + [EURC, FixedMath.toFixed(34)], + [AQUA, FixedMath.toFixed(2500)], ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], - ]), - block: 123, - }; + bid: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(728.01456)]]), + block: MOCK_LEDGER, + }); - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - mockedSorobanHelper, - db + db.setPriceEntries([ + { + asset_id: XLM, + price: 0.3, + timestamp: MOCK_TIMESTAMP - 100, + }, + ]); + + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) ); - expect(fillCalc.fillBlock).toEqual(419); - expect(fillCalc.fillPercent).toEqual(100); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 260); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 284.6922, 0.005); + expectRelApproxEqual(fill.bidValue, 254.805096, 0.005); }); - it('test force fill for interest auction', async () => { - mockedSorobanHelper.simBalance.mockResolvedValue(5000_0000000n); - let auctionData = { + it('calcs fill for interest auction respects force fill setting', async () => { + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1_0000000n], + [XLM, FixedMath.toFixed(120)], + [USDC, FixedMath.toFixed(210)], + [EURC, FixedMath.toFixed(34)], + [AQUA, FixedMath.toFixed(2500)], ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], - ]), - block: 123, - }; + bid: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(2500)]]), + block: MOCK_LEDGER, + }); - let fillCalc = await calculateBlockFillAndPercent( + mockedGetFillerProfitPct.mockReturnValue(0.2); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + ); + + filler.forceFill = true; + let fill_force = await calculateAuctionFill( filler, - AuctionType.Interest, - auctionData, + auction, + nextLedger, mockedSorobanHelper, db ); - expect(fillCalc.fillBlock).toEqual(473); - expect(fillCalc.fillPercent).toEqual(100); - }); - it('test interest auction increases block fill delay to fully fill', async () => { - mockedSorobanHelper.simBalance.mockResolvedValue(2000_0000000n); - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 4242_0000000n], - ]), - block: 123, - }; filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( + let fill_no_force = await calculateAuctionFill( filler, - AuctionType.Interest, - auctionData, + auction, + nextLedger, mockedSorobanHelper, db ); - expect(fillCalc.fillBlock).toEqual(429); - expect(fillCalc.fillPercent).toEqual(100); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill_force.block).toEqual(MOCK_LEDGER + 350); + expect(fill_force.percent).toEqual(100); + expect(fill_force.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill_force.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill_force.bidValue, 312.5, 0.005); + + expect(fill_no_force.block).toEqual(MOCK_LEDGER + 367); + expect(fill_no_force.percent).toEqual(100); + expect(fill_no_force.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill_no_force.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill_no_force.bidValue, 206.25, 0.005); }); - it('test bad debt auction', async () => { - let auctionData = { + // *** Liquidation Auctions *** + + it('calcs fill for liquidation auction', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 456_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 456_0000000n], - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 123_0000000n], + [USDC, FixedMath.toFixed(15.93)], + [EURC, FixedMath.toFixed(16.211)], ]), - block: 123, - }; + bid: new Map([[XLM, FixedMath.toFixed(300.21)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.BadDebt, - auctionData, - mockedSorobanHelper, - db + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[USDC, FixedMath.toFixed(100)]]) ); - expect(fillCalc.fillBlock).toEqual(380); - expect(fillCalc.fillPercent).toEqual(100); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 194); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 32.8213, 0.005); + expectRelApproxEqual(fill.bidValue, 29.73769976, 0.005); }); - }); - describe('calculateAuctionValue', () => { - it('test valuing user auction', async () => { - let auctionData = { + it('calcs fill for liquidation auction respects force fill setting', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + [USDC, FixedMath.toFixed(15.93)], + [EURC, FixedMath.toFixed(16.211)], ]), - block: 123, - }; + bid: new Map([[XLM, FixedMath.toFixed(400.21)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; - let result = await calculateAuctionValue( - AuctionType.Liquidation, - auctionData, + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[USDC, FixedMath.toFixed(100)]]) + ); + + filler.forceFill = true; + let fill_force = await calculateAuctionFill( + filler, + auction, + nextLedger, mockedSorobanHelper, db ); - expect(result.bidValue).toBeCloseTo(562.42); - expect(result.lotValue).toBeCloseTo(1242.24); - expect(result.effectiveCollateral).toBeCloseTo(1180.13); - expect(result.effectiveLiabilities).toBeCloseTo(749.89); - }); - - it('test valuing interest auction', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - block: 123, - }; - let result = await calculateAuctionValue( - AuctionType.Interest, - auctionData, + filler.forceFill = false; + let fill_no_force = await calculateAuctionFill( + filler, + auction, + nextLedger, mockedSorobanHelper, db ); - expect(result.bidValue).toBeCloseTo(4115184.85); - expect(result.lotValue).toBeCloseTo(1795.72); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(0); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + ]; + expect(fill_force.block).toEqual(MOCK_LEDGER + 220); + expect(fill_force.percent).toEqual(100); + expect(fill_force.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill_force.lotValue, 33.8364, 0.005); + expectRelApproxEqual(fill_force.bidValue, 35.67899917, 0.005); + + expect(fill_no_force.block).toEqual(MOCK_LEDGER + 247); + expect(fill_no_force.percent).toEqual(100); + expect(fill_no_force.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill_no_force.lotValue, 33.8364, 0.005); + expectRelApproxEqual(fill_no_force.bidValue, 30.32714929, 0.005); }); - it('test valuing bad debt auction', async () => { - let auctionData = { + it('calcs fill for liquidation auction and repays incoming liabilties and withdraws 0 CF collateral', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], + [USDC, FixedMath.toFixed(15.93)], + [EURC, FixedMath.toFixed(16.211)], + [AQUA, FixedMath.toFixed(750)], ]), - bid: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + bid: new Map([[XLM, FixedMath.toFixed(300.21)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; - let result = await calculateAuctionValue( - AuctionType.BadDebt, - auctionData, - mockedSorobanHelper, - db + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [USDC, FixedMath.toFixed(100)], + [XLM, FixedMath.toFixed(500)], + ]) ); - expect(result.bidValue).toBeCloseTo(1808.6); - expect(result.lotValue).toBeCloseTo(4115184.85); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(2061.66); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + { + request_type: 5, + address: XLM, + amount: 3003808157n, + }, + { + request_type: 3, + address: AQUA, + amount: BigInt('9223372036854775807'), + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 191); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 32.7722, 0.005); + expectRelApproxEqual(fill.bidValue, 29.73769976, 0.005); }); - it('test valuing lp token when simLPTokenToUSDC is not defined', async () => { - mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(undefined); - mockedSorobanHelper.loadBackstopToken.mockResolvedValue( - new BackstopToken('id', 100n, 100n, 100n, 100, 100, 1) - ); - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), + it('calcs fill for liquidation auction adds primary collateral', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 186; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), bid: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], + [USDC, FixedMath.toFixed(100)], + [EURC, FixedMath.toFixed(7500)], ]), - block: 123, - }; + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; - let result = await calculateAuctionValue( - AuctionType.BadDebt, - auctionData, - mockedSorobanHelper, - db + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [USDC, FixedMath.toFixed(5000)], + [XLM, FixedMath.toFixed(500)], + ]) ); - expect(result.bidValue).toBeCloseTo(1808.6); - expect(result.lotValue).toBeCloseTo(12345678); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(2061.66); - auctionData = { - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); - result = await calculateAuctionValue( - AuctionType.Interest, - auctionData, - mockedSorobanHelper, - db - ); - expect(result.bidValue).toBeCloseTo(12345678); - expect(result.lotValue).toBeCloseTo(1795.72); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(0); + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + // repays any incoming primary liabilities first + { + request_type: 5, + address: USDC, + amount: 101_0182653n, + }, + // adds additional primary collateral to reach min HF + { + request_type: 2, + address: USDC, + amount: FixedMath.toFixed(4420), + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 187); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 9257.3115, 0.005); + expectRelApproxEqual(fill.bidValue, 8378.033243, 0.005); }); - }); - describe('buildFillRequests', () => { - let sorobanHelper = new SorobanHelper(); - it('test user liquidation auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - defaultProfitPct: 0.2, - minHealthFactor: 1.2, - primaryAsset: 'USD', - minPrimaryCollateral: 0n, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, + it('calcs fill for liquidation auction scales fill percent down', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 188; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([[XLM, FixedMath.toFixed(85000)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue(new Map([])); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 12n, }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ]), - bid: new Map([ - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockResolvedValue(0n); + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 188); + expect(fill.percent).toEqual(12); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 1116.8179, 0.005); + expectRelApproxEqual(fill.bidValue, 1010.37453, 0.005); + }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ + it('calcs fill for liquidation auction delays fill block if filler not healthy', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 123; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([[XLM, FixedMath.toFixed(85000)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 750; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue(new Map([])); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), + request_type: 6, + address: user, amount: 100n, }, ]; - expect(requests.length).toEqual(1); - expect(requests).toEqual(expectRequests); + expect(fill.block).toEqual(MOCK_LEDGER + 300); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 9900.8679, 0.005); + expectRelApproxEqual(fill.bidValue, 4209.893874, 0.005); }); - it('test interest auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - defaultProfitPct: 0.2, - minHealthFactor: 1.2, - primaryAsset: 'USD', - minPrimaryCollateral: 0n, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], + it('calcs fill for liquidation auction with repayment, additional collateral, and scaling minor', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 123; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([[XLM, FixedMath.toFixed(85000)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [XLM, FixedMath.toFixed(15000)], + [USDC, FixedMath.toFixed(4000)], + ]) + ); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 94n, }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Interest, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, + { + request_type: 5, + address: XLM, + amount: FixedMath.toFixed(15000), }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 50000_0000000n], - ]), - block: 123, - }; - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ { - request_type: RequestType.FillInterestAuction, - address: user.publicKey(), - amount: 100n, + request_type: 2, + address: USDC, + amount: FixedMath.toFixed(3954), }, ]; - expect(requests.length).toEqual(1); - expect(requests).toEqual(expectRequests); + expect(fill.block).toEqual(MOCK_LEDGER + 188); + expect(fill.percent).toEqual(94); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 8748.4069, 0.005); + expectRelApproxEqual(fill.bidValue, 7914.600483, 0.005); }); - it('test bad debt auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - defaultProfitPct: 0.2, - minHealthFactor: 1.2, - primaryAsset: 'USD', - minPrimaryCollateral: 0n, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.BadDebt, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 30000_0000000n], - ]), + it('calcs fill for liquidation auction with repayment, additional collateral, and scaling large', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[EURC, FixedMath.toFixed(9100)]]), bid: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], + [USDC, FixedMath.toFixed(500)], + [XLM, FixedMath.toFixed(85000)], ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_0000000n; - else return 0; + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 700; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [XLM, FixedMath.toFixed(2000)], + [USDC, FixedMath.toFixed(600)], + ]) + ); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ { - request_type: RequestType.FillBadDebtAuction, - address: user.publicKey(), - amount: 100n, + request_type: 6, + address: user, + amount: 15n, }, { - request_type: RequestType.Repay, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 10000_0000000n, + request_type: 5, + address: USDC, + amount: 757637015n, }, { - request_type: RequestType.Repay, - address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - amount: 500_0000000n, + request_type: 5, + address: XLM, + amount: FixedMath.toFixed(2000), + }, + { + request_type: 2, + address: USDC, + amount: FixedMath.toFixed(495), }, ]; - expect(requests.length).toEqual(3); - expect(requests).toEqual(expectRequests); + expect(fill.block).toEqual(MOCK_LEDGER + 197); + expect(fill.percent).toEqual(15); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 1476.3058, 0.005); + expectRelApproxEqual(fill.bidValue, 1338.709125, 0.005); }); - it('test repay xlm does not use full balance', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - defaultProfitPct: 0.2, - minHealthFactor: 1.2, - primaryAsset: 'USD', - minPrimaryCollateral: 0n, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ]), + // *** Bad Debt Auctions *** + + it('calcs fill for bad debt auction', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.BadDebt, { + lot: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(4200)]]), bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], + [XLM, FixedMath.toFixed(10000)], + [USDC, FixedMath.toFixed(500)], ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 95000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else return 0; + block: MOCK_LEDGER, }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [USDC, FixedMath.toFixed(4200)], + [XLM, FixedMath.toFixed(5000)], + ]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), + request_type: 7, + address: user, amount: 100n, }, { - request_type: RequestType.Repay, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 95000_0000000n - BigInt(50e7), + request_type: 5, + address: XLM, + amount: FixedMath.toFixed(5000), }, { - request_type: RequestType.Repay, - address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - amount: 500_0000000n, + request_type: 5, + address: USDC, + amount: 5050912865n, }, ]; - expect(requests.length).toEqual(3); - expect(requests).toEqual(expectRequests); + expect(fill.block).toEqual(MOCK_LEDGER + 157); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 1648.5, 0.005); + expectRelApproxEqual(fill.bidValue, 1495.503014, 0.005); }); }); - describe('scaleAuction', () => { - it('test auction scaling', () => { - const auctionData = { - lot: new Map([ - ['asset2', 1_0000000n], - ['asset3', 5_0000001n], - ]), - bid: new Map([ - ['asset1', 100_0000000n], - ['asset2', 200_0000001n], - ]), - block: 123, - }; - let scaledAuction = scaleAuction(auctionData, 123, 100); - expect(scaledAuction.block).toEqual(123); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(0); - - // 100 blocks -> 100 percent, validate lot is rounded down - scaledAuction = scaleAuction(auctionData, 223, 100); - expect(scaledAuction.block).toEqual(223); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(5000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(2_5000000n); - - // 100 blocks -> 50 percent, validate bid is rounded up - scaledAuction = scaleAuction(auctionData, 223, 50); - expect(scaledAuction.block).toEqual(223); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(2500000n); - expect(scaledAuction.lot.get('asset3')).toEqual(1_2500000n); - - // 200 blocks -> 100 percent (is same) - scaledAuction = scaleAuction(auctionData, 323, 100); - expect(scaledAuction.block).toEqual(323); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 200 blocks -> 75 percent, validate bid is rounded up and lot is rounded down - scaledAuction = scaleAuction(auctionData, 323, 75); - expect(scaledAuction.block).toEqual(323); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(75_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(150_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(7500000n); - expect(scaledAuction.lot.get('asset3')).toEqual(3_7500000n); - - // 300 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 423, 100); - expect(scaledAuction.block).toEqual(423); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 400 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 523, 100); - expect(scaledAuction.block).toEqual(523); - expect(scaledAuction.bid.size).toEqual(0); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 500 blocks -> 100 percent (unchanged) - scaledAuction = scaleAuction(auctionData, 623, 100); - expect(scaledAuction.block).toEqual(623); - expect(scaledAuction.bid.size).toEqual(0); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); + describe('valueBackstopTokenInUSDC', () => { + it('values from sim', async () => { + let lpTokenToUSDC = 0.5; + mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(FixedMath.toFixed(lpTokenToUSDC)); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue({ + lpTokenPrice: 1.25, + } as BackstopToken); + + let value = await valueBackstopTokenInUSDC(mockedSorobanHelper, FixedMath.toFixed(2)); + + expect(value).toEqual(lpTokenToUSDC); + expect(mockedSorobanHelper.loadBackstopToken).toHaveBeenCalledTimes(0); }); - it('test auction scaling with 1 stroop', () => { - const auctionData = { - lot: new Map([['asset2', 1n]]), - bid: new Map([['asset1', 1n]]), - block: 123, - }; - // 1 blocks -> 10 percent - let scaledAuction = scaleAuction(auctionData, 124, 10); - expect(scaledAuction.block).toEqual(124); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(0); - - // 399 blocks -> 10 percent - scaledAuction = scaleAuction(auctionData, 522, 10); - expect(scaledAuction.block).toEqual(522); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(0); - - // 399 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 522, 100); - expect(scaledAuction.block).toEqual(522); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(1); - expect(scaledAuction.lot.get('asset2')).toEqual(1n); + it('values from spot price if sim fails', async () => { + mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(undefined); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue({ + lpTokenPrice: 1.25, + } as BackstopToken); + + let value = await valueBackstopTokenInUSDC(mockedSorobanHelper, FixedMath.toFixed(2)); + + expect(value).toEqual(1.25 * 2); + expect(mockedSorobanHelper.loadBackstopToken).toHaveBeenCalledTimes(1); }); }); }); diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 0fff3ef..91fd27f 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -1,13 +1,4 @@ -import { - Pool, - PoolOracle, - PoolUser, - PoolUserEmissionData, - PositionsEstimate, - PriceData, - Reserve, -} from '@blend-capital/blend-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; +import { Pool, PoolOracle, PriceData, Reserve } from '@blend-capital/blend-sdk'; import Database from 'better-sqlite3'; import * as fs from 'fs'; import * as path from 'path'; @@ -33,56 +24,35 @@ pool.reserves.forEach((reserve, assetId, map) => { ) ); }); -export let mockedPool = pool; +export let mockPool = pool; -export let mockedReserves = pool.config.reserveList; +export const MOCK_LEDGER = pool.config.latestLedger; +export const MOCK_TIMESTAMP = pool.timestamp; -export let mockPoolUser = new PoolUser( - Keypair.random().publicKey(), - { - liabilities: new Map(), - collateral: new Map(), - supply: new Map(), - }, - new Map() -); +export const XLM = 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA'; +export const XLM_ID = 0; +export const USDC = 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; +export const USDC_ID = 1; +export const EURC = 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV'; +export const EURC_ID = 2; +export const AQUA = 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK'; +export const AQUA_ID = 3; + +export const BACKSTOP = 'CAO3AGAMZVRMHITL36EJ2VZQWKYRPWMQAPDQD5YEOF3GIF7T44U4JAL3'; +export const BACKSTOP_TOKEN = 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM'; export let mockPoolOracle = new PoolOracle( 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', new Map([ - [ - 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - { price: BigInt(9899585234193), timestamp: 1724949300 }, - ], - [ - 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', - { price: BigInt(99969142646062), timestamp: 1724949300 }, - ], - [ - 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - { price: BigInt(109278286319197), timestamp: 1724949300 }, - ], - [ - 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - { price: BigInt(64116899991), timestamp: 1724950800 }, - ], + [XLM, { price: BigInt(9899585234193), timestamp: 1724949300 }], + [USDC, { price: BigInt(99969142646062), timestamp: 1724949300 }], + [EURC, { price: BigInt(109278286319197), timestamp: 1724949300 }], + [AQUA, { price: BigInt(64116899991), timestamp: 1724950800 }], ]), 14, 53255053 ); -export let mockPoolUserEstimate: PositionsEstimate = { - totalBorrowed: 0, - totalSupplied: 0, - totalEffectiveLiabilities: 1000, - totalEffectiveCollateral: 25000, - borrowCap: 0, - borrowLimit: 0, - netApr: 0, - supplyApr: 0, - borrowApr: 0, -}; - export function inMemoryAuctioneerDb(): AuctioneerDatabase { let db = new Database(':memory:'); db.exec(fs.readFileSync(path.resolve(__dirname, '../../init_db.sql'), 'utf8')); diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts new file mode 100644 index 0000000..c1fc545 --- /dev/null +++ b/test/helpers/utils.ts @@ -0,0 +1,10 @@ +/** + * Assert that a and b are approximately equal, relative to the smaller of the two, + * within epsilon as a percentage. + * @param a + * @param b + * @param epsilon - The max allowed difference between a and b as a percentage of the smaller of the two + */ +export function expectRelApproxEqual(a: number, b: number, epsilon = 0.001) { + expect(Math.abs(a - b) / Math.min(a, b)).toBeLessThanOrEqual(epsilon); +} From 88a6266f1dc372b191c6c86fc8b66174198e8851 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Mon, 25 Nov 2024 09:46:31 -0500 Subject: [PATCH 09/13] test: fix unit tests for position management changes --- src/bidder_submitter.ts | 2 +- test/bidder_handler.test.ts | 165 ++++++++++++++++++-------------- test/bidder_submitter.test.ts | 140 ++++++++++++++------------- test/filler.test.ts | 20 ++-- test/liquidations.test.ts | 107 +++++++++------------ test/pool_event_handler.test.ts | 62 ++++++------ test/user.test.ts | 18 ++-- test/utils/config.test.ts | 38 +++++++- test/work_submitter.test.ts | 26 ++--- 9 files changed, 312 insertions(+), 266 deletions(-) diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 508e2f1..1353185 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -113,7 +113,7 @@ export class BidderSubmitter extends SubmissionQueue { this.db.setFilledAuctionEntry({ tx_hash: result.txHash, filler: auctionBid.auctionEntry.filler, - user_id: auctionBid.auctionEntry.filler, + user_id: auctionBid.auctionEntry.user_id, auction_type: auctionBid.auctionEntry.auction_type, bid: scaledAuction.data.bid, bid_total: fill.bidValue, diff --git a/test/bidder_handler.test.ts b/test/bidder_handler.test.ts index 7152336..20dce7c 100644 --- a/test/bidder_handler.test.ts +++ b/test/bidder_handler.test.ts @@ -1,6 +1,6 @@ -import { AuctionData } from '@blend-capital/blend-sdk'; +import { Auction } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; -import { calculateBlockFillAndPercent, FillCalculation } from '../src/auction'; +import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { BidderHandler } from '../src/bidder_handler'; import { AuctionBid, BidderSubmissionType, BidderSubmitter } from '../src/bidder_submitter'; import { AppEvent, EventType, LedgerEvent } from '../src/events'; @@ -58,8 +58,8 @@ describe('BidderHandler', () => { let mockedBidderSubmitter: jest.Mocked; let mockedSorobanHelper: jest.Mocked = new SorobanHelper() as jest.Mocked; - let mockedCalcBlockAndFillPercent = calculateBlockFillAndPercent as jest.MockedFunction< - typeof calculateBlockFillAndPercent + let mockedCalcAuctionFill = calculateAuctionFill as jest.MockedFunction< + typeof calculateAuctionFill >; let mockedSendSlackNotif = sendSlackNotification as jest.MockedFunction< typeof sendSlackNotification @@ -92,17 +92,21 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc: AuctionFill = { + block: 1200, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc: FillCalculation = { - fillBlock: 1200, - fillPercent: 50, - }; - mockedCalcBlockAndFillPercent.mockResolvedValue(fill_calc); + mockedCalcAuctionFill.mockResolvedValue(fill_calc); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); @@ -115,7 +119,7 @@ describe('BidderHandler', () => { expect(new_auction_2?.auction_type).toEqual(auction_2.auction_type); expect(new_auction_2?.filler).toEqual(auction_2.filler); expect(new_auction_2?.start_block).toEqual(auction_2.start_block); - expect(new_auction_2?.fill_block).toEqual(fill_calc.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc.block); expect(new_auction_2?.updated).toEqual(ledger); expect(mockedSendSlackNotif).toHaveBeenCalledTimes(1); @@ -145,35 +149,40 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc_1: FillCalculation = { - fillBlock: 1200, - fillPercent: 50, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_1: AuctionFill = { + block: 1200, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - let fill_calc_2: FillCalculation = { - fillBlock: 1002, - fillPercent: 60, + let fill_calc_2: AuctionFill = { + block: 1002, + percent: 60, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent - .mockResolvedValueOnce(fill_calc_1) - .mockResolvedValueOnce(fill_calc_2); + mockedCalcAuctionFill.mockResolvedValueOnce(fill_calc_1).mockResolvedValueOnce(fill_calc_2); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); // validate auction 1 is updated let new_auction_1 = db.getAuctionEntry(auction_1.user_id, auction_1.auction_type); - expect(new_auction_1?.fill_block).toEqual(fill_calc_1.fillBlock); + expect(new_auction_1?.fill_block).toEqual(fill_calc_1.block); expect(new_auction_1?.updated).toEqual(ledger); // validate auction 2 is updated let new_auction_2 = db.getAuctionEntry(auction_2.user_id, auction_2.auction_type); - expect(new_auction_2?.fill_block).toEqual(fill_calc_2.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc_2.block); expect(new_auction_2?.updated).toEqual(ledger); }); @@ -197,30 +206,36 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc_1: FillCalculation = { - fillBlock: 1001, - fillPercent: 50, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_1: AuctionFill = { + block: 1001, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - let fill_calc_2: FillCalculation = { - fillBlock: 995, - fillPercent: 60, + + let fill_calc_2: AuctionFill = { + block: 995, + percent: 60, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent - .mockResolvedValueOnce(fill_calc_1) - .mockResolvedValueOnce(fill_calc_2); + mockedCalcAuctionFill.mockResolvedValueOnce(fill_calc_1).mockResolvedValueOnce(fill_calc_2); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); // validate auction 1 is placed on submission queue let new_auction_1 = db.getAuctionEntry(auction_1.user_id, auction_1.auction_type); - expect(new_auction_1?.fill_block).toEqual(fill_calc_1.fillBlock); + expect(new_auction_1?.fill_block).toEqual(fill_calc_1.block); expect(new_auction_1?.updated).toEqual(ledger); let submission_1: AuctionBid = { @@ -232,7 +247,7 @@ describe('BidderHandler', () => { // validate auction 2 is placed on submission queue let new_auction_2 = db.getAuctionEntry(auction_2.user_id, auction_2.auction_type); - expect(new_auction_2?.fill_block).toEqual(fill_calc_2.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc_2.block); expect(new_auction_2?.updated).toEqual(ledger); let submission_2: AuctionBid = { @@ -254,17 +269,21 @@ describe('BidderHandler', () => { updated: ledger - 1, }; db.setAuctionEntry(auction_1); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc_1: FillCalculation = { - fillBlock: 1001, - fillPercent: 50, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_1: AuctionFill = { + block: 1001, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent.mockResolvedValue(fill_calc_1); + mockedCalcAuctionFill.mockResolvedValue(fill_calc_1); mockedBidderSubmitter.containsAuction.mockReturnValue(true); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; @@ -297,19 +316,23 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; mockedSorobanHelper.loadAuction - .mockRejectedValueOnce(new Error('Teapot')) - .mockResolvedValueOnce(auction_data); - let fill_calc_2: FillCalculation = { - fillBlock: 1002, - fillPercent: 60, + .mockRejectedValueOnce(new Error('teapot')) + .mockResolvedValueOnce( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_2: AuctionFill = { + block: 1002, + percent: 60, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent.mockResolvedValue(fill_calc_2); + mockedCalcAuctionFill.mockResolvedValue(fill_calc_2); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); @@ -322,7 +345,7 @@ describe('BidderHandler', () => { // validate auction 2 is updated let new_auction_2 = db.getAuctionEntry(auction_2.user_id, auction_2.auction_type); - expect(new_auction_2?.fill_block).toEqual(fill_calc_2.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc_2.block); expect(new_auction_2?.updated).toEqual(ledger); }); }); diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 113129a..5a17d1b 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -1,11 +1,6 @@ -import { Request, RequestType } from '@blend-capital/blend-sdk'; +import { Auction, PoolUser, Positions, Request, RequestType } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; -import { - buildFillRequests, - calculateAuctionValue, - calculateBlockFillAndPercent, - scaleAuction, -} from '../src/auction'; +import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { AuctionBid, BidderSubmissionType, @@ -13,11 +8,12 @@ import { FillerUnwind, } from '../src/bidder_submitter'; import { managePositions } from '../src/filler'; -import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db'; +import { Filler } from '../src/utils/config'; +import { AuctioneerDatabase, AuctionEntry, AuctionType, FilledAuctionEntry } from '../src/utils/db'; import { logger } from '../src/utils/logger'; import { sendSlackNotification } from '../src/utils/slack_notifier'; import { SorobanHelper } from '../src/utils/soroban_helper'; -import { inMemoryAuctioneerDb, mockedPool, mockPoolOracle, mockPoolUser } from './helpers/mocks'; +import { inMemoryAuctioneerDb, mockPool, mockPoolOracle } from './helpers/mocks'; // Mock dependencies jest.mock('../src/utils/db'); @@ -68,16 +64,6 @@ describe('BidderSubmitter', () => { let mockDb: AuctioneerDatabase; let mockedSorobanHelper = new SorobanHelper() as jest.Mocked; let mockedSorobanHelperConstructor = SorobanHelper as jest.MockedClass; - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), - block: 500, - }); - mockedSorobanHelper.submitTransaction.mockResolvedValue({ - ledger: 1000, - txHash: 'mock-tx-hash', - latestLedgerCloseTime: 123, - } as any); mockedSorobanHelper.network = { rpc: 'test-rpc', passphrase: 'test-pass', @@ -88,13 +74,8 @@ describe('BidderSubmitter', () => { const mockedSendSlackNotif = sendSlackNotification as jest.MockedFunction< typeof sendSlackNotification >; - const mockCalculateBlockFillAndPercent = calculateBlockFillAndPercent as jest.MockedFunction< - typeof calculateBlockFillAndPercent - >; - const mockScaleAuction = scaleAuction as jest.MockedFunction; - const mockBuildFillRequests = buildFillRequests as jest.MockedFunction; - const mockCalculateAuctionValue = calculateAuctionValue as jest.MockedFunction< - typeof calculateAuctionValue + const mockedCalcAuctionFill = calculateAuctionFill as jest.MockedFunction< + typeof calculateAuctionFill >; const mockedManagePositions = managePositions as jest.MockedFunction; @@ -106,43 +87,52 @@ describe('BidderSubmitter', () => { it('should submit a bid successfully', async () => { bidderSubmitter.addSubmission = jest.fn(); - mockCalculateBlockFillAndPercent.mockResolvedValue({ fillBlock: 1000, fillPercent: 50 }); - mockScaleAuction.mockReturnValue({ - bid: new Map([['USD', BigInt(12)]]), - lot: new Map([['USD', BigInt(34)]]), - block: 500, - }); - mockBuildFillRequests.mockResolvedValue([ - { - request_type: RequestType.FillUserLiquidationAuction, - address: Keypair.random().publicKey(), - amount: 100n, - }, - ]); - mockCalculateAuctionValue.mockResolvedValue({ - bidValue: 123, - effectiveLiabilities: 456, - lotValue: 987, - effectiveCollateral: 654, - }); + let auction = new Auction(Keypair.random().publicKey(), AuctionType.Liquidation, { + bid: new Map([['USD', BigInt(1000)]]), + lot: new Map([['USD', BigInt(2000)]]), + block: 800, + }); + mockedSorobanHelper.loadAuction.mockResolvedValue(auction); + let auction_fill: AuctionFill = { + percent: 50, + block: 1000, + bidValue: 1234, + lotValue: 2345, + requests: [ + { + request_type: RequestType.FillUserLiquidationAuction, + address: auction.user, + amount: 50n, + }, + ], + }; + mockedCalcAuctionFill.mockResolvedValue(auction_fill); + let submissionResult: any = { + ledger: 1000, + txHash: 'mock-tx-hash', + latestLedgerCloseTime: Date.now(), + }; + mockedSorobanHelper.submitTransaction.mockResolvedValue(submissionResult); + + const filler: Filler = { + name: 'test-filler', + keypair: Keypair.random(), + defaultProfitPct: 0, + minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, + forceFill: false, + supportedBid: [], + supportedLot: [], + }; const submission: AuctionBid = { type: BidderSubmissionType.BID, - filler: { - name: 'test-filler', - keypair: Keypair.random(), - defaultProfitPct: 0, - minHealthFactor: 0, - primaryAsset: 'USD', - minPrimaryCollateral: 0n, - forceFill: false, - supportedBid: [], - supportedLot: [], - }, + filler, auctionEntry: { - user_id: 'test-user', + user_id: auction.user, auction_type: AuctionType.Liquidation, - filler: Keypair.random().publicKey(), + filler: filler.keypair.publicKey(), start_block: 900, fill_block: 1000, } as AuctionEntry, @@ -150,20 +140,33 @@ describe('BidderSubmitter', () => { const result = await bidderSubmitter.submit(submission); + const expectedFillEntry: FilledAuctionEntry = { + tx_hash: 'mock-tx-hash', + filler: submission.auctionEntry.filler, + user_id: auction.user, + auction_type: submission.auctionEntry.auction_type, + bid: new Map([['USD', BigInt(500)]]), + bid_total: auction_fill.bidValue, + lot: new Map([['USD', BigInt(1000)]]), + lot_total: auction_fill.lotValue, + est_profit: auction_fill.lotValue - auction_fill.bidValue, + fill_block: submissionResult.ledger, + timestamp: submissionResult.latestLedgerCloseTime, + }; expect(result).toBe(true); expect(mockedSorobanHelper.loadAuction).toHaveBeenCalledWith( - 'test-user', - AuctionType.Liquidation + submission.auctionEntry.user_id, + submission.auctionEntry.auction_type ); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); - expect(mockDb.setFilledAuctionEntry).toHaveBeenCalled(); + expect(mockDb.setFilledAuctionEntry).toHaveBeenCalledWith(expectedFillEntry); expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( { type: BidderSubmissionType.UNWIND, filler: submission.filler }, 2 ); }); - it('should handle auction already filled', async () => { + it('returns true if auction is undefined to return auction entry to handler', async () => { bidderSubmitter.addSubmission = jest.fn(); mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); mockedSorobanHelperConstructor.mockReturnValue(mockedSorobanHelper); @@ -189,7 +192,6 @@ describe('BidderSubmitter', () => { const result = await bidderSubmitter.submit(submission); expect(result).toBe(true); - expect(mockDb.deleteAuctionEntry).toHaveBeenCalledWith('test-user', AuctionType.Liquidation); }); it('should manage positions during unwind', async () => { @@ -203,9 +205,11 @@ describe('BidderSubmitter', () => { ]; bidderSubmitter.addSubmission = jest.fn(); - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); - mockedSorobanHelper.loadUser.mockResolvedValue(mockPoolUser); + mockedSorobanHelper.loadUser.mockResolvedValue( + new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) + ); mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); mockedManagePositions.mockReturnValue(unwindRequest); @@ -241,9 +245,11 @@ describe('BidderSubmitter', () => { const unwindRequest: Request[] = []; bidderSubmitter.addSubmission = jest.fn(); - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); - mockedSorobanHelper.loadUser.mockResolvedValue(mockPoolUser); + mockedSorobanHelper.loadUser.mockResolvedValue( + new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) + ); mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); mockedManagePositions.mockReturnValue(unwindRequest); diff --git a/test/filler.test.ts b/test/filler.test.ts index 4eb0a38..e0cf59c 100644 --- a/test/filler.test.ts +++ b/test/filler.test.ts @@ -10,7 +10,7 @@ import { import { Keypair } from '@stellar/stellar-sdk'; import { canFillerBid, getFillerProfitPct, managePositions } from '../src/filler'; import { AuctionProfit, Filler } from '../src/utils/config'; -import { mockedPool } from './helpers/mocks'; +import { mockPool } from './helpers/mocks'; jest.mock('../src/utils/config.js', () => { return { @@ -286,7 +286,7 @@ describe('filler', () => { }); }); describe('managePositions', () => { - const assets = mockedPool.config.reserveList; + const assets = mockPool.config.reserveList; const mockOracle = new PoolOracle( 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', new Map([ @@ -325,7 +325,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -357,7 +357,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -384,7 +384,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -412,7 +412,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); // return minimum health factor back to 1.5 filler.minHealthFactor = 1.5; @@ -446,7 +446,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -476,7 +476,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -514,7 +514,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(1, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -558,7 +558,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(1, 7)], ]); - const requests = managePositions(filler, mockedPool, mockOracle, positions, balances); + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { diff --git a/test/liquidations.test.ts b/test/liquidations.test.ts index 5f768df..481bc28 100644 --- a/test/liquidations.test.ts +++ b/test/liquidations.test.ts @@ -1,4 +1,5 @@ -import { PoolUser, Positions, PositionsEstimate } from '@blend-capital/blend-sdk'; +import { Auction, PoolUser, Positions, PositionsEstimate } from '@blend-capital/blend-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; import { calculateLiquidationPercent, checkUsersForLiquidationsAndBadDebt, @@ -9,13 +10,8 @@ import { import { APP_CONFIG } from '../src/utils/config.js'; import { AuctioneerDatabase } from '../src/utils/db.js'; import { PoolUserEst, SorobanHelper } from '../src/utils/soroban_helper.js'; -import { UserLiquidation, WorkSubmission, WorkSubmissionType } from '../src/work_submitter.js'; -import { - inMemoryAuctioneerDb, - mockedPool, - mockPoolUser, - mockPoolUserEstimate, -} from './helpers/mocks.js'; +import { WorkSubmissionType } from '../src/work_submitter.js'; +import { inMemoryAuctioneerDb, mockPool } from './helpers/mocks.js'; jest.mock('../src/utils/soroban_helper.js'); jest.mock('../src/utils/logger.js', () => ({ @@ -41,21 +37,19 @@ describe('isLiquidatable', () => { let userEstimate: PositionsEstimate; beforeEach(() => { - userEstimate = mockPoolUserEstimate; - userEstimate.totalEffectiveCollateral = 25000; - userEstimate.totalEffectiveLiabilities = 1000; + userEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); }); - it('returns true if the userEstimate health factor is below .99', async () => { + it('returns true if the userEstimate health factor is lt .998', async () => { userEstimate.totalEffectiveCollateral = 1000; - userEstimate.totalEffectiveLiabilities = 1011; + userEstimate.totalEffectiveLiabilities = 1003; const result = isLiquidatable(userEstimate); expect(result).toBe(true); }); - it('returns false if the userEstimate health facotr is above .99', async () => { + it('returns false if the userEstimate health facotr is gte to .998', async () => { userEstimate.totalEffectiveCollateral = 1000; - userEstimate.totalEffectiveLiabilities = 1010; + userEstimate.totalEffectiveLiabilities = 1002; const result = isLiquidatable(userEstimate); expect(result).toBe(false); }); @@ -65,9 +59,7 @@ describe('isBadDebt', () => { let userEstimate: PositionsEstimate; beforeEach(() => { - userEstimate = mockPoolUserEstimate; - userEstimate.totalEffectiveCollateral = 25000; - userEstimate.totalEffectiveLiabilities = 1000; + userEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); }); it('should return true when totalEffectiveCollateral is 0 and totalEffectiveLiabilities is greater than 0', () => { userEstimate.totalEffectiveCollateral = 0; @@ -98,11 +90,7 @@ describe('calculateLiquidationPercent', () => { let userEstimate: PositionsEstimate; beforeEach(() => { - userEstimate = mockPoolUserEstimate; - userEstimate.totalEffectiveCollateral = 0; - userEstimate.totalEffectiveLiabilities = 0; - userEstimate.totalBorrowed = 0; - userEstimate.totalSupplied = 0; + userEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); }); it('should calculate the correct liquidation percent for typical values', () => { userEstimate.totalEffectiveCollateral = 1000; @@ -110,7 +98,7 @@ describe('calculateLiquidationPercent', () => { userEstimate.totalBorrowed = 1500; userEstimate.totalSupplied = 2000; const result = calculateLiquidationPercent(userEstimate); - expect(Number(result)).toBe(62); + expect(Number(result)).toBe(56); }); it('should calculate max of 100 percent liquidation size', () => { @@ -130,7 +118,7 @@ describe('calculateLiquidationPercent', () => { userEstimate.totalSupplied = 10000000000000; const result = calculateLiquidationPercent(userEstimate); - expect(Number(result)).toBe(9); + expect(Number(result)).toBe(6); }); }); @@ -139,16 +127,24 @@ describe('scanUsers', () => { let mockedSorobanHelper: jest.Mocked; let mockBackstopPositions: PoolUser; let mockBackstopPositionsEstimate: PositionsEstimate; + let mockPoolUserEstimate: PositionsEstimate; + let mockPoolUser: PoolUser; beforeEach(() => { db = inMemoryAuctioneerDb(); mockedSorobanHelper = new SorobanHelper() as jest.Mocked; - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockBackstopPositions = new PoolUser( 'backstopAddress', new Positions(new Map(), new Map(), new Map()), new Map() ); mockBackstopPositionsEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); + mockPoolUserEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); + mockPoolUser = new PoolUser( + Keypair.random().publicKey(), + new Positions(new Map(), new Map(), new Map()), + new Map() + ); }); it('should create a work submission for liquidatable users', async () => { @@ -213,11 +209,7 @@ describe('scanUsers', () => { } return Promise.resolve({ estimate: {}, user: {} } as PoolUserEst); }); - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map(), - lot: new Map(), - block: 123, - }); + mockedSorobanHelper.loadAuction.mockResolvedValue({ user: 'exists' } as Auction); let liquidations = await scanUsers(db, mockedSorobanHelper); expect(liquidations.length).toBe(0); @@ -257,15 +249,19 @@ describe('checkUsersForLiquidationsAndBadDebt', () => { beforeEach(() => { db = inMemoryAuctioneerDb(); mockedSorobanHelper = new SorobanHelper() as jest.Mocked; - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockBackstopPositions = new PoolUser( 'backstopAddress', new Positions(new Map(), new Map(), new Map()), new Map() ); mockBackstopPositionsEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); - mockUser = mockPoolUser; - mockUserEstimate = mockPoolUserEstimate; + mockUserEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); + mockUser = new PoolUser( + Keypair.random().publicKey(), + new Positions(new Map(), new Map(), new Map()), + new Map() + ); }); it('should return an empty array when user_ids is empty', async () => { @@ -275,68 +271,55 @@ describe('checkUsersForLiquidationsAndBadDebt', () => { it('should handle backstop address user correctly', async () => { const user_ids = [APP_CONFIG.backstopAddress]; - (mockedSorobanHelper.loadPool as jest.Mock).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockBackstopPositionsEstimate.totalEffectiveLiabilities = 1000; mockBackstopPositionsEstimate.totalEffectiveCollateral = 0; - (mockedSorobanHelper.loadUserPositionEstimate as jest.Mock).mockResolvedValue({ + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ estimate: mockBackstopPositionsEstimate, user: mockBackstopPositions, }); - (mockedSorobanHelper.loadAuction as jest.Mock).mockResolvedValue(undefined); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); const result = await checkUsersForLiquidationsAndBadDebt(db, mockedSorobanHelper, user_ids); expect(result).toEqual([{ type: WorkSubmissionType.BadDebtAuction }]); }); - it('should handle users with liquidations correctly', async () => { + it('should handle liquidatable users correctly', async () => { const user_ids = ['user1']; - (mockedSorobanHelper.loadPool as jest.Mock).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockUserEstimate.totalEffectiveCollateral = 1000; mockUserEstimate.totalEffectiveLiabilities = 1100; - (mockedSorobanHelper.loadUserPositionEstimate as jest.Mock).mockResolvedValue({ + mockUserEstimate.totalBorrowed = 1500; + mockUserEstimate.totalSupplied = 2000; + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ estimate: mockUserEstimate, user: mockUser, }); - (mockedSorobanHelper.loadAuction as jest.Mock).mockResolvedValue(undefined); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); const result = await checkUsersForLiquidationsAndBadDebt(db, mockedSorobanHelper, user_ids); expect(result.length).toBe(1); - expect(result[0].type).toBe(WorkSubmissionType.LiquidateUser); - - // Type Guard Function - function isUserLiquidation(workSubmission: WorkSubmission): workSubmission is UserLiquidation { - return 'user' in workSubmission && 'liquidationPercent' in workSubmission; - } - // Test Case - const workSubmission = result[0] as WorkSubmission; - - if (isUserLiquidation(workSubmission)) { - expect(workSubmission.user).toBe('user1'); - expect(Number(workSubmission.liquidationPercent)).toBe(62); - } else { - throw new Error('Expected workSubmission to be of type LiquidateUser'); - } + expect(result).toEqual([ + { type: WorkSubmissionType.LiquidateUser, user: 'user1', liquidationPercent: 56n }, + ]); }); it('should handle users with bad debt correctly', async () => { const user_ids = ['user1']; - (mockedSorobanHelper.loadPool as jest.Mock).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockUserEstimate.totalEffectiveCollateral = 0; mockUserEstimate.totalEffectiveLiabilities = 1100; - (mockedSorobanHelper.loadUserPositionEstimate as jest.Mock).mockResolvedValue({ + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ estimate: mockUserEstimate, user: mockUser, }); - (mockedSorobanHelper.loadAuction as jest.Mock).mockResolvedValue(undefined); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); const result = await checkUsersForLiquidationsAndBadDebt(db, mockedSorobanHelper, user_ids); expect(result.length).toBe(1); - expect(result[0].type).toBe(WorkSubmissionType.BadDebtTransfer); - - // Type Guard Function expect(result).toEqual([{ type: WorkSubmissionType.BadDebtTransfer, user: 'user1' }]); }); }); diff --git a/test/pool_event_handler.test.ts b/test/pool_event_handler.test.ts index 235548c..a911175 100644 --- a/test/pool_event_handler.test.ts +++ b/test/pool_event_handler.test.ts @@ -14,7 +14,7 @@ import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db.j import { logger } from '../src/utils/logger.js'; import { deadletterEvent, sendEvent } from '../src/utils/messages.js'; import { SorobanHelper } from '../src/utils/soroban_helper.js'; -import { inMemoryAuctioneerDb, mockedPool } from './helpers/mocks.js'; +import { inMemoryAuctioneerDb, mockPool } from './helpers/mocks.js'; jest.mock('../src/user.js'); jest.mock('../src/utils/soroban_helper.js'); @@ -71,7 +71,7 @@ describe('poolEventHandler', () => { beforeEach(() => { jest.clearAllMocks(); db = inMemoryAuctioneerDb(); - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); poolEventHandler = new PoolEventHandler(db, mockedSorobanHelper, mockedWorkerProcess); const fixedTimestamp = 1609459200000; Date.now = jest.fn(() => fixedTimestamp); @@ -104,7 +104,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -129,7 +129,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -144,7 +144,7 @@ describe('poolEventHandler', () => { }, }; let error = new Error('Temporary error'); - mockedSorobanHelper.loadPool.mockRejectedValueOnce(error).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockRejectedValueOnce(error).mockResolvedValue(mockPool); await poolEventHandler.processEventWithRetryAndDeadLetter(poolEvent); expect(mockedSorobanHelper.loadPool).toHaveBeenCalledTimes(2); @@ -161,7 +161,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -188,7 +188,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -227,13 +227,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.SupplyCollateral, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), bTokensMinted: BigInt(900), @@ -242,7 +242,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('updates user data for withdraw collateral event', async () => { @@ -252,13 +252,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.WithdrawCollateral, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), bTokensBurned: BigInt(900), @@ -267,7 +267,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('updates user data for borrow event', async () => { @@ -277,13 +277,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.Borrow, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), dTokensMinted: BigInt(900), @@ -292,7 +292,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('updates user data for repay event', async () => { @@ -302,13 +302,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.Repay, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), dTokensBurned: BigInt(900), @@ -317,7 +317,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('finds filler and tracks auction for new liquidation event', async () => { @@ -328,7 +328,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -364,7 +364,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -400,7 +400,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -438,7 +438,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -490,7 +490,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -537,7 +537,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -560,7 +560,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -579,7 +579,7 @@ describe('poolEventHandler', () => { expect(entries.length).toEqual(1); let deletedAuction = db.getAuctionEntry(pool_user, AuctionType.Liquidation); expect(deletedAuction).toBeUndefined(); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, 12350); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, 12350); }); it('deletes fill auction for other fill auction event', async () => { @@ -611,7 +611,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -639,7 +639,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -673,7 +673,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -701,7 +701,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', diff --git a/test/user.test.ts b/test/user.test.ts index 3b2e21c..9409ab5 100644 --- a/test/user.test.ts +++ b/test/user.test.ts @@ -1,7 +1,7 @@ import { PoolUser, Positions, PositionsEstimate } from '@blend-capital/blend-sdk'; import { updateUser } from '../src/user.js'; import { AuctioneerDatabase, UserEntry } from '../src/utils/db.js'; -import { inMemoryAuctioneerDb, mockedPool } from './helpers/mocks.js'; +import { inMemoryAuctioneerDb, mockPool } from './helpers/mocks.js'; describe('updateUser', () => { let db: AuctioneerDatabase; @@ -27,26 +27,26 @@ describe('updateUser', () => { ), new Map() ); - updateUser(db, mockedPool, user, user_estimate); + updateUser(db, mockPool, user, user_estimate); let user_entry = db.getUserEntry('GPUBKEY1'); expect(user_entry).toBeDefined(); expect(user_entry?.user_id).toEqual('GPUBKEY1'); expect(user_entry?.health_factor).toEqual(2); expect(user_entry?.liabilities.size).toEqual(2); - expect(user_entry?.liabilities.get(mockedPool.config.reserveList[0])).toEqual(BigInt(12345)); - expect(user_entry?.liabilities.get(mockedPool.config.reserveList[1])).toEqual(BigInt(54321)); + expect(user_entry?.liabilities.get(mockPool.config.reserveList[0])).toEqual(BigInt(12345)); + expect(user_entry?.liabilities.get(mockPool.config.reserveList[1])).toEqual(BigInt(54321)); expect(user_entry?.collateral.size).toEqual(1); - expect(user_entry?.collateral.get(mockedPool.config.reserveList[3])).toEqual(BigInt(789)); - expect(user_entry?.updated).toEqual(mockedPool.config.latestLedger); + expect(user_entry?.collateral.get(mockPool.config.reserveList[3])).toEqual(BigInt(789)); + expect(user_entry?.updated).toEqual(mockPool.config.latestLedger); }); it('deletes existing user without liabilities', async () => { let user_entry: UserEntry = { user_id: 'GPUBKEY1', health_factor: 2, - collateral: new Map([[mockedPool.config.reserveList[3], BigInt(789)]]), - liabilities: new Map([[mockedPool.config.reserveList[2], BigInt(789)]]), + collateral: new Map([[mockPool.config.reserveList[3], BigInt(789)]]), + liabilities: new Map([[mockPool.config.reserveList[2], BigInt(789)]]), updated: 123, }; db.setUserEntry(user_entry); @@ -60,7 +60,7 @@ describe('updateUser', () => { new Positions(new Map(), new Map([[3, BigInt(789)]]), new Map([[2, BigInt(111)]])), new Map() ); - updateUser(db, mockedPool, user, user_estimate); + updateUser(db, mockPool, user, user_estimate); let new_user_entry = db.getUserEntry('GPUBKEY1'); expect(new_user_entry).toBeUndefined(); diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index 04526a3..d4d2fec 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -1,6 +1,11 @@ // config.test.ts import { Keypair } from '@stellar/stellar-sdk'; -import { validateAppConfig, validateFiller, validatePriceSource } from '../../src/utils/config'; +import { + validateAppConfig, + validateAuctionProfit, + validateFiller, + validatePriceSource, +} from '../../src/utils/config'; describe('validateAppConfig', () => { it('should return false for non-object config', () => { @@ -41,7 +46,7 @@ describe('validateAppConfig', () => { { name: 'filler', keypair: Keypair.random().secret(), - minProfitPct: 1, + defaultProfitPct: 1, minHealthFactor: 1, primaryAsset: 'asset', minPrimaryCollateral: '100', @@ -67,7 +72,7 @@ describe('validateFiller', () => { const invalidFiller = { name: 'filler', keypair: 'secret', - minProfitPct: 1, + defaultProfitPct: 1, minHealthFactor: 1, primaryAsset: 'asset', minPrimaryCollateral: '100', @@ -82,7 +87,7 @@ describe('validateFiller', () => { const validFiller = { name: 'filler', keypair: Keypair.random().secret(), - minProfitPct: 1, + defaultProfitPct: 1, minHealthFactor: 1, primaryAsset: 'asset', minPrimaryCollateral: '100', @@ -118,3 +123,28 @@ describe('validatePriceSource', () => { expect(validatePriceSource(validPriceSource)).toBe(true); }); }); + +describe('validateAuctionProfit', () => { + it('should return false for non-object profits', () => { + expect(validateAuctionProfit(null)).toBe(false); + expect(validateAuctionProfit('string')).toBe(false); + }); + + it('should return false for profits with missing or incorrect properties', () => { + const invalidProfits = { + profitPct: 1, + supportedBid: ['asset1', 'asset2'], + supportedLot: 'asset2', // Invalid type + }; + expect(validateAuctionProfit(invalidProfits)).toBe(false); + }); + + it('should return true for valid profits', () => { + const validProfits = { + profitPct: 1, + supportedBid: ['asset1', 'asset2'], + supportedLot: ['asset2'], + }; + expect(validateAuctionProfit(validProfits)).toBe(true); + }); +}); diff --git a/test/work_submitter.test.ts b/test/work_submitter.test.ts index 8c7716e..ce031a0 100644 --- a/test/work_submitter.test.ts +++ b/test/work_submitter.test.ts @@ -1,4 +1,4 @@ -import { ContractError, ContractErrorType } from '@blend-capital/blend-sdk'; +import { Auction, ContractError, ContractErrorType } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; import { AppConfig } from '../src/utils/config'; import { AuctionType } from '../src/utils/db'; @@ -63,11 +63,13 @@ describe('WorkSubmitter', () => { }); it('should not submit if auction already exists', async () => { - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), - block: 500, - }); + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('user1', AuctionType.Liquidation, { + bid: new Map([['USD', BigInt(123)]]), + lot: new Map([['USD', BigInt(456)]]), + block: 500, + }) + ); const submission = { type: WorkSubmissionType.LiquidateUser, @@ -228,11 +230,13 @@ describe('WorkSubmitter', () => { }); it('should not submit if auction already exists', async () => { - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), - block: 500, - }); + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('user1', AuctionType.Liquidation, { + bid: new Map([['USD', BigInt(123)]]), + lot: new Map([['USD', BigInt(456)]]), + block: 500, + }) + ); const submission: WorkSubmission = { type: WorkSubmissionType.BadDebtAuction, From 9223305ddfbf28adfc7720a59ab1fb1bcb618339 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Mon, 25 Nov 2024 09:57:15 -0500 Subject: [PATCH 10/13] chore: stagger liquidation scan events based on bot startup time --- README.md | 6 +++--- src/collector.ts | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5d3e4e8..632791b 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ The `fillers` array contains configurations for individual filler accounts. The | `name` | A unique name for this filler account. Used in logs and slack notifications. | | `keypair` | The secret key for this filler account. **Keep this secret and secure!** | | `primaryAsset` | The primary asset the filler will use as collateral in the pool. | -| `defaultProfitPct` | The default profit percentage required for the filler to bid on an auction. | -| `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions. | +| `defaultProfitPct` | The default profit percentage required for the filler to bid on an auction, as a decimal. (e.g. 0.08 = 8%) | +| `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions, as calculated by `collateral / liabilities`. | | `minPrimaryCollateral` | The minimum amount of the primary asset the Filler will maintain as collateral in the pool. | | `forceFill` | Boolean flag to indicate if the bot should force fill auctions even if profit expectations aren't met to ensure pool health. | | `supportedBid` | An array of asset addresses that this filler bot is allowed to bid with. Bids are taken as additional liabilities (dTokens) for liquidation and bad debt auctions, and tokens for interest auctions. Must include the `backstopTokenAddress` to bid on interest auctions. | @@ -97,7 +97,7 @@ Each profit entry has the following fields: | Field | Description | |-------|-------------| -| `profitPct` | The profit percentage required to bid for the auction. | +| `profitPct` | The profit percentage required to bid for the auction, as a decimal. (e.g. 0.08 = 8%) | | `supportedBid` | An array of asset addresses that the auction bid can contain for this `profitPct` to be used. If any auction bid asset exists outside this list, the `profitPct` will not be used. | | `supportedLot` | An array of asset addresses that the auction lot can contain for this `profitPct` to be used. If any auction lot asset exists outside this list, the `profitPct` will not be used. | diff --git a/src/collector.ts b/src/collector.ts index e82d720..02fd654 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -16,6 +16,8 @@ import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendEvent } from './utils/messages.js'; +let startup_ledger = 0; + export async function runCollector( worker: ChildProcess, bidder: ChildProcess, @@ -40,8 +42,15 @@ export async function runCollector( }; sendEvent(bidder, ledger_event); + // determine ledgers since bot was started to send long running work events + // this staggers the events from different bots running on the same pool + if (startup_ledger === 0) { + startup_ledger = latestLedger; + } + const ledgersProcessed = latestLedger - startup_ledger; + // send long running work events to worker - if (latestLedger % 10 === 0) { + if (ledgersProcessed % 10 === 0) { // approx every minute const event: PriceUpdateEvent = { type: EventType.PRICE_UPDATE, @@ -49,7 +58,7 @@ export async function runCollector( }; sendEvent(worker, event); } - if (latestLedger % 60 === 0) { + if (ledgersProcessed % 60 === 0) { // approx every 5m // send an oracle scan event const event: OracleScanEvent = { @@ -58,7 +67,7 @@ export async function runCollector( }; sendEvent(worker, event); } - if (latestLedger % 1203 === 0) { + if (ledgersProcessed % 1203 === 0) { // approx every 2hr // send a user update event to update any users that have not been updated in ~2 weeks const event: UserRefreshEvent = { @@ -68,7 +77,7 @@ export async function runCollector( }; sendEvent(worker, event); } - if (latestLedger % 1207 === 0) { + if (ledgersProcessed % 1207 === 0) { // approx every 2hr // send a liq scan event const event: LiqScanEvent = { From 16ddaa7a62a506fbb328f6067e540998da32e83c Mon Sep 17 00:00:00 2001 From: mootz12 Date: Wed, 27 Nov 2024 09:00:30 -0500 Subject: [PATCH 11/13] chore: update deps to p22 --- package-lock.json | 115 +++++++++++++++++++++++----------- package.json | 4 +- src/bidder_submitter.ts | 4 +- src/collector.ts | 14 ++--- src/main.ts | 6 +- src/utils/soroban_helper.ts | 54 ++++++++-------- test/bidder_submitter.test.ts | 4 +- 7 files changed, 122 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 99ca3a7..f3b5739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "0.2.0", "license": "MIT", "dependencies": { - "@blend-capital/blend-sdk": "2.1.2", - "@stellar/stellar-sdk": "12.3.0", + "@blend-capital/blend-sdk": "2.2.0", + "@stellar/stellar-sdk": "13.0.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", "winston-daily-rotate-file": "^5.0.0" @@ -548,12 +548,12 @@ "dev": true }, "node_modules/@blend-capital/blend-sdk": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.1.2.tgz", - "integrity": "sha512-/fbyFCA52x5f9EvblgTtmV25NHrboBXkool3vNhnVL40NY181ZkfObs44LxDKFtnZFEz4BNZHIDcqnuW2ZOPhQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.2.0.tgz", + "integrity": "sha512-S2P7D1Y45IKBk381gvPWt7rv587B2FUY9Vd8KyXpxkpcB8/IgFeCItoVBidqIW5vbsAG3T1fwHJbcI3bUfFlpw==", "license": "MIT", "dependencies": { - "@stellar/stellar-sdk": "12.3.0", + "@stellar/stellar-sdk": "13.0.0", "buffer": "6.0.3", "follow-redirects": ">=1.15.6" } @@ -954,12 +954,14 @@ "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==" + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" }, "node_modules/@stellar/stellar-base": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", - "integrity": "sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.0.1.tgz", + "integrity": "sha512-Xbd12mc9Oj/130Tv0URmm3wXG77XMshZtZ2yNCjqX5ZbMD5IYpbBs3DVCteLU/4SLj/Fnmhh1dzhrQXnk4r+pQ==", + "license": "Apache-2.0", "dependencies": { "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", @@ -969,18 +971,20 @@ "tweetnacl": "^1.0.3" }, "optionalDependencies": { - "sodium-native": "^4.1.1" + "sodium-native": "^4.3.0" } }, "node_modules/@stellar/stellar-sdk": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.3.0.tgz", - "integrity": "sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.0.0.tgz", + "integrity": "sha512-+wvmKi+XWwu27nLYTM17EgBdpbKohEkOfCIK4XKfsI4WpMXAqvnqSm98i9h5dAblNB+w8BJqzGs1JY0PtTGm4A==", + "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^12.1.1", + "@stellar/stellar-base": "^13.0.1", "axios": "^1.7.7", "bignumber.js": "^9.1.2", "eventsource": "^2.0.2", + "feaxios": "^0.0.20", "randombytes": "^2.1.0", "toml": "^3.0.0", "urijs": "^1.19.1" @@ -1192,12 +1196,13 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.8.tgz", + "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1322,6 +1327,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -1359,6 +1365,7 @@ "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", "engines": { "node": "*" } @@ -1497,6 +1504,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -1688,6 +1696,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -1808,6 +1817,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -1934,6 +1944,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", "engines": { "node": ">=12.0.0" } @@ -2009,6 +2020,15 @@ "bser": "2.1.1" } }, + "node_modules/feaxios": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.20.tgz", + "integrity": "sha512-g3hm2YDNffNxA3Re3Hd8ahbpmDee9Fv1Pb1C/NoWsjY7mtD8nyNeJytUzn+DK0Hyl9o6HppeWOrtnqgmhOYfWA==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -2088,15 +2108,16 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -2107,9 +2128,10 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -2385,6 +2407,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3252,6 +3286,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3260,6 +3295,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -3359,9 +3395,10 @@ } }, "node_modules/node-gyp-build": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", - "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", + "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", @@ -3664,7 +3701,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" }, "node_modules/pump": { "version": "3.0.0", @@ -3695,6 +3733,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -3836,6 +3875,7 @@ "version": "2.4.11", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "license": "(MIT AND BSD-3-Clause)", "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" @@ -3943,10 +3983,10 @@ } }, "node_modules/sodium-native": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.2.0.tgz", - "integrity": "sha512-rdJRAf/RE/IRFUUoUsz10slNAQDTGz5ChpIeR1Ti0BtGYstl6Uok4hHALPBdnFcLml6qXJ2pDd0/De09mPa6mg==", - "hasInstallScript": true, + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.1.tgz", + "integrity": "sha512-YdP64gAdpIKHfL4ttuX4aIfjeunh9f+hNeQJpE9C8UMndB3zkgZ7YmmGT4J2+v6Ibyp6Wem8D1TcSrtdW0bqtg==", + "license": "MIT", "optional": true, "dependencies": { "node-gyp-build": "^4.8.0" @@ -4173,7 +4213,8 @@ "node_modules/toml": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" }, "node_modules/triple-beam": { "version": "1.4.1", @@ -4257,7 +4298,8 @@ "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" }, "node_modules/type-detect": { "version": "4.0.8", @@ -4332,7 +4374,8 @@ "node_modules/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", diff --git a/package.json b/package.json index 3a8dff6..1436ad3 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "typescript": "^5.5.4" }, "dependencies": { - "@blend-capital/blend-sdk": "2.1.2", - "@stellar/stellar-sdk": "12.3.0", + "@blend-capital/blend-sdk": "2.2.0", + "@stellar/stellar-sdk": "13.0.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", "winston-daily-rotate-file": "^5.0.0" diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 1353185..6bc324b 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -1,5 +1,5 @@ import { PoolContract } from '@blend-capital/blend-sdk'; -import { SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; import { calculateAuctionFill } from './auction.js'; import { managePositions } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; @@ -72,7 +72,7 @@ export class BidderSubmitter extends SubmissionQueue { try { logger.info(`Submitting bid for auction ${stringify(auctionBid.auctionEntry, 2)}`); const currLedger = ( - await new SorobanRpc.Server( + await new rpc.Server( sorobanHelper.network.rpc, sorobanHelper.network.opts ).getLatestLedger() diff --git a/src/collector.ts b/src/collector.ts index 02fd654..776d7b7 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -1,5 +1,5 @@ import { poolEventFromEventResponse } from '@blend-capital/blend-sdk'; -import { SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; import { ChildProcess } from 'child_process'; import { EventType, @@ -22,7 +22,7 @@ export async function runCollector( worker: ChildProcess, bidder: ChildProcess, db: AuctioneerDatabase, - rpc: SorobanRpc.Server, + stellarRpc: rpc.Server, poolAddress: string, poolEventHandler: PoolEventHandler ) { @@ -31,7 +31,7 @@ export async function runCollector( if (!statusEntry) { statusEntry = { name: 'collector', latest_ledger: 0 }; } - const latestLedger = (await rpc.getLatestLedger()).sequence; + const latestLedger = (await stellarRpc.getLatestLedger()).sequence; if (latestLedger > statusEntry.latest_ledger) { logger.info(`Processing ledger ${latestLedger}`); // new ledger detected @@ -93,9 +93,9 @@ export async function runCollector( statusEntry.latest_ledger === 0 ? latestLedger : statusEntry.latest_ledger + 1; // if we are too far behind, start from 17270 ledgers ago (default max ledger history is 17280) start_ledger = Math.max(start_ledger, latestLedger - 17270); - let events: SorobanRpc.Api.RawGetEventsResponse; + let events: rpc.Api.RawGetEventsResponse; try { - events = await rpc._getEvents({ + events = await stellarRpc._getEvents({ startLedger: start_ledger, filters: [ { @@ -112,7 +112,7 @@ export async function runCollector( `Error fetching events at start ledger: ${start_ledger}, retrying with latest ledger ${latestLedger}`, e ); - events = await rpc._getEvents({ + events = await stellarRpc._getEvents({ startLedger: latestLedger, filters: [ { @@ -142,7 +142,7 @@ export async function runCollector( } } cursor = events.events[events.events.length - 1].pagingToken; - events = await rpc._getEvents({ + events = await stellarRpc._getEvents({ cursor: cursor, filters: [ { diff --git a/src/main.ts b/src/main.ts index 32d802f..8dadbb8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; import { fork } from 'child_process'; import { runCollector } from './collector.js'; import { EventType, OracleScanEvent, PriceUpdateEvent, UserRefreshEvent } from './events.js'; @@ -17,7 +17,7 @@ async function main() { let collectorInterval: NodeJS.Timeout | null = null; const db = AuctioneerDatabase.connect(); - const rpc = new SorobanRpc.Server(APP_CONFIG.rpcURL, { allowHttp: true }); + const stellarRpc = new rpc.Server(APP_CONFIG.rpcURL, { allowHttp: true }); function shutdown(fromChild: boolean = false) { console.log('Shutting down auctioneer...'); @@ -98,7 +98,7 @@ async function main() { try { let sorobanHelper = new SorobanHelper(); let poolEventHandler = new PoolEventHandler(db, sorobanHelper, worker); - await runCollector(worker, bidder, db, rpc, APP_CONFIG.poolAddress, poolEventHandler); + await runCollector(worker, bidder, db, stellarRpc, APP_CONFIG.poolAddress, poolEventHandler); } catch (e: any) { logger.error(`Error in collector`, e); } diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index 0657f29..bba1554 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -18,8 +18,8 @@ import { Keypair, nativeToScVal, Operation, + rpc, scValToNative, - SorobanRpc, Transaction, TransactionBuilder, xdr, @@ -51,8 +51,8 @@ export class SorobanHelper { async loadLatestLedger(): Promise { try { - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); - let ledger = await rpc.getLatestLedger(); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + let ledger = await stellarRpc.getLatestLedger(); return ledger.sequence; } catch (e) { logger.error(`Error loading latest ledger: ${e}`); @@ -109,12 +109,12 @@ export class SorobanHelper { async loadAuction(userId: string, auctionType: number): Promise { try { - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); const ledgerKey = AuctionData.ledgerKey(APP_CONFIG.poolAddress, { auct_type: auctionType, user: userId, }); - let ledgerData = await rpc.getLedgerEntries(ledgerKey); + const ledgerData = await stellarRpc.getLedgerEntries(ledgerKey); if (ledgerData.entries.length === 0) { return undefined; } @@ -187,10 +187,10 @@ export class SorobanHelper { }) .addOperation(op) .build(); - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); - let result = await rpc.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationSuccess(result) && result.result?.retval) { + let result = await stellarRpc.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { return scValToNative(result.result.retval); } return undefined; @@ -212,10 +212,10 @@ export class SorobanHelper { }) .addOperation(op) .build(); - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); - let result = await rpc.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationSuccess(result) && result.result?.retval) { + let result = await stellarRpc.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { return scValToNative(result.result.retval); } else { return 0n; @@ -229,9 +229,9 @@ export class SorobanHelper { async submitTransaction( operation: string, keypair: Keypair - ): Promise { - const rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); - let account = await rpc.getAccount(keypair.publicKey()); + ): Promise { + const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + let account = await stellarRpc.getAccount(keypair.publicKey()); let tx = new TransactionBuilder(account, { networkPassphrase: this.network.passphrase, fee: BASE_FEE, @@ -241,11 +241,11 @@ export class SorobanHelper { .build(); logger.info(`Attempting to simulate and submit transaction: ${tx.toXDR()}`); - let simResult = await rpc.simulateTransaction(tx); + let simResult = await stellarRpc.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationRestore(simResult)) { + if (rpc.Api.isSimulationRestore(simResult)) { logger.info('Simulation ran into expired entries. Attempting to restore.'); - account = await rpc.getAccount(keypair.publicKey()); + account = await stellarRpc.getAccount(keypair.publicKey()); const fee = Number(simResult.restorePreamble.minResourceFee) + 1000; const restore_tx = new TransactionBuilder(account, { fee: fee.toString() }) .setNetworkPassphrase(this.network.passphrase) @@ -256,7 +256,7 @@ export class SorobanHelper { restore_tx.sign(keypair); let restore_result = await this.sendTransaction(restore_tx); logger.info(`Successfully restored. Tx Hash: ${restore_result.txHash}`); - account = await rpc.getAccount(keypair.publicKey()); + account = await stellarRpc.getAccount(keypair.publicKey()); tx = new TransactionBuilder(account, { networkPassphrase: this.network.passphrase, fee: BASE_FEE, @@ -264,11 +264,11 @@ export class SorobanHelper { }) .addOperation(xdr.Operation.fromXDR(operation, 'base64')) .build(); - simResult = await rpc.simulateTransaction(tx); + simResult = await stellarRpc.simulateTransaction(tx); } - if (SorobanRpc.Api.isSimulationSuccess(simResult)) { - let assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); + if (rpc.Api.isSimulationSuccess(simResult)) { + let assembledTx = rpc.assembleTransaction(tx, simResult).build(); assembledTx.sign(keypair); return await this.sendTransaction(assembledTx); } else { @@ -280,12 +280,12 @@ export class SorobanHelper { private async sendTransaction( transaction: Transaction - ): Promise { - const rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); - let txResponse = await rpc.sendTransaction(transaction); + ): Promise { + const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + let txResponse = await stellarRpc.sendTransaction(transaction); if (txResponse.status === 'TRY_AGAIN_LATER') { await new Promise((resolve) => setTimeout(resolve, 4000)); - txResponse = await rpc.sendTransaction(transaction); + txResponse = await stellarRpc.sendTransaction(transaction); } if (txResponse.status !== 'PENDING') { @@ -295,10 +295,10 @@ export class SorobanHelper { ); throw error; } - let get_tx_response = await rpc.getTransaction(txResponse.hash); + let get_tx_response = await stellarRpc.getTransaction(txResponse.hash); while (get_tx_response.status === 'NOT_FOUND') { await new Promise((resolve) => setTimeout(resolve, 250)); - get_tx_response = await rpc.getTransaction(txResponse.hash); + get_tx_response = await stellarRpc.getTransaction(txResponse.hash); } if (get_tx_response.status !== 'SUCCESS') { diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 5a17d1b..e5b85de 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -26,8 +26,8 @@ jest.mock('@stellar/stellar-sdk', () => { const actual = jest.requireActual('@stellar/stellar-sdk'); return { ...actual, - SorobanRpc: { - ...actual.SorobanRpc, + rpc: { + ...actual.rpc, Server: jest.fn().mockImplementation(() => ({ getLatestLedger: jest.fn().mockResolvedValue({ sequence: 999 }), })), From 60c6af4884638f6e24080511503cff349dcd8970 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Wed, 27 Nov 2024 16:20:15 -0500 Subject: [PATCH 12/13] fix: patch relevant asset list and have safer force fill --- src/auction.ts | 11 ++----- test/auction.test.ts | 73 +++++++++----------------------------------- 2 files changed, 18 insertions(+), 66 deletions(-) diff --git a/src/auction.ts b/src/auction.ts index afdb267..bdb5d3c 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -96,8 +96,7 @@ export async function calculateBlockFillAndPercent( relevant_assets.push(APP_CONFIG.backstopTokenAddress); break; case AuctionType.BadDebt: - relevant_assets.push(...Array.from(auction.data.lot.keys())); - relevant_assets.push(APP_CONFIG.backstopTokenAddress); + relevant_assets.push(...Array.from(auction.data.bid.keys())); relevant_assets.push(filler.primaryAsset); break; } @@ -121,14 +120,10 @@ export async function calculateBlockFillAndPercent( } fillBlockDelay = Math.min(Math.max(Math.ceil(fillBlockDelay), 0), 400); // apply force fill auction boundries to profit calculations - if ( - (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) && - filler.forceFill - ) { - fillBlockDelay = Math.min(fillBlockDelay, 220); - } else if (auction.type === AuctionType.Interest && filler.forceFill) { + if (filler.forceFill) { fillBlockDelay = Math.min(fillBlockDelay, 350); } + // if calculated fillBlock has already passed, adjust fillBlock to the next ledger if (auction.data.block + fillBlockDelay < nextLedger) { fillBlockDelay = Math.min(nextLedger - auction.data.block, 400); diff --git a/test/auction.test.ts b/test/auction.test.ts index efa1b30..73aa280 100644 --- a/test/auction.test.ts +++ b/test/auction.test.ts @@ -132,6 +132,12 @@ describe('auctions', () => { expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); expectRelApproxEqual(fill.bidValue, 233.4726912, 0.005); + + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + filler, + [BACKSTOP_TOKEN], + mockedSorobanHelper + ); }); it('calcs fill for interest auction and delays block to fully fill', async () => { @@ -339,67 +345,12 @@ describe('auctions', () => { expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 32.8213, 0.005); expectRelApproxEqual(fill.bidValue, 29.73769976, 0.005); - }); - - it('calcs fill for liquidation auction respects force fill setting', async () => { - let user = Keypair.random().publicKey(); - let nextLedger = MOCK_LEDGER + 1; - let auction = new Auction(user, AuctionType.Liquidation, { - lot: new Map([ - [USDC, FixedMath.toFixed(15.93)], - [EURC, FixedMath.toFixed(16.211)], - ]), - bid: new Map([[XLM, FixedMath.toFixed(400.21)]]), - block: MOCK_LEDGER, - }); - positionEstimate.totalEffectiveLiabilities = 0; - positionEstimate.totalEffectiveCollateral = 1000; - - mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ - user: {} as PoolUser, - estimate: positionEstimate, - }); - mockedGetFillerProfitPct.mockReturnValue(0.1); - mockedGetFilledAvailableBalances.mockResolvedValue( - new Map([[USDC, FixedMath.toFixed(100)]]) - ); - - filler.forceFill = true; - let fill_force = await calculateAuctionFill( - filler, - auction, - nextLedger, - mockedSorobanHelper, - db - ); - filler.forceFill = false; - let fill_no_force = await calculateAuctionFill( + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( filler, - auction, - nextLedger, - mockedSorobanHelper, - db + [USDC, EURC, XLM], + mockedSorobanHelper ); - - let expectedRequests: Request[] = [ - { - request_type: 6, - address: user, - amount: 100n, - }, - ]; - expect(fill_force.block).toEqual(MOCK_LEDGER + 220); - expect(fill_force.percent).toEqual(100); - expect(fill_force.requests).toEqual(expectedRequests); - expectRelApproxEqual(fill_force.lotValue, 33.8364, 0.005); - expectRelApproxEqual(fill_force.bidValue, 35.67899917, 0.005); - - expect(fill_no_force.block).toEqual(MOCK_LEDGER + 247); - expect(fill_no_force.percent).toEqual(100); - expect(fill_no_force.requests).toEqual(expectedRequests); - expectRelApproxEqual(fill_no_force.lotValue, 33.8364, 0.005); - expectRelApproxEqual(fill_no_force.bidValue, 30.32714929, 0.005); }); it('calcs fill for liquidation auction and repays incoming liabilties and withdraws 0 CF collateral', async () => { @@ -740,6 +691,12 @@ describe('auctions', () => { expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 1648.5, 0.005); expectRelApproxEqual(fill.bidValue, 1495.503014, 0.005); + + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + filler, + [XLM, USDC], + mockedSorobanHelper + ); }); }); From 8b2993b4f1378a28e80e0aa614e9f1c9b72c0a90 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Thu, 5 Dec 2024 15:11:54 -0500 Subject: [PATCH 13/13] chore: use correct get filler balance function during unwind --- src/auction.ts | 4 +--- src/bidder_submitter.ts | 8 ++++++-- src/filler.ts | 9 ++------- test/bidder_submitter.test.ts | 19 ++++++++++++------- test/filler.test.ts | 29 +---------------------------- 5 files changed, 22 insertions(+), 47 deletions(-) diff --git a/src/auction.ts b/src/auction.ts index bdb5d3c..3beeafe 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -30,7 +30,6 @@ export interface AuctionFill { export interface AuctionValue { effectiveCollateral: number; effectiveLiabilities: number; - repayableLiabilities: number; lotValue: number; bidValue: number; } @@ -350,7 +349,6 @@ export async function calculateAuctionValue( let effectiveCollateral = 0; let lotValue = 0; let effectiveLiabilities = 0; - let repayableLiabilities = 0; let bidValue = 0; const reserves = pool.reserves; for (const [assetId, amount] of auction.data.lot) { @@ -408,7 +406,7 @@ export async function calculateAuctionValue( } } - return { effectiveCollateral, effectiveLiabilities, repayableLiabilities, lotValue, bidValue }; + return { effectiveCollateral, effectiveLiabilities, lotValue, bidValue }; } /** diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 6bc324b..99ef5ce 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -1,7 +1,7 @@ import { PoolContract } from '@blend-capital/blend-sdk'; import { rpc } from '@stellar/stellar-sdk'; import { calculateAuctionFill } from './auction.js'; -import { managePositions } from './filler.js'; +import { getFillerAvailableBalances, managePositions } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; import { serializeError, stringify } from './utils/json.js'; @@ -165,7 +165,11 @@ export class BidderSubmitter extends SubmissionQueue { const pool = await sorobanHelper.loadPool(); const poolOracle = await sorobanHelper.loadPoolOracle(); const filler_user = await sorobanHelper.loadUser(filler_pubkey); - const filler_balances = await sorobanHelper.loadBalances(filler_pubkey, filler_tokens); + const filler_balances = await getFillerAvailableBalances( + fillerUnwind.filler, + filler_tokens, + sorobanHelper + ); // Unwind the filler one step at a time. If the filler is not unwound, place another `FillerUnwind` event on the submission queue. // To unwind the filler, the following actions will be taken in order: diff --git a/src/filler.ts b/src/filler.ts index 78b0ddf..12ec671 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -97,7 +97,8 @@ export async function getFillerAvailableBalances( * @param pool - The pool * @param poolOracle - The pool's oracle object * @param poolUser - The filler's pool user object - * @param balances - The filler's balances + * @param balances - The filler's balances. This should be fetched from `getFillerAvailableBalances` to ensure + * minimum balances are respected. * @returns An array of requests to be submitted to the network, or an empty array if no actions are required */ export function managePositions( @@ -125,13 +126,7 @@ export function managePositions( } // if no price is found, assume 0, so effective liabilities won't change const oraclePrice = poolOracle.getPriceFloat(reserve.assetId) ?? 0; - const isNative = reserve.assetId === Asset.native().contractId(APP_CONFIG.networkPassphrase); let tokenBalance = balances.get(reserve.assetId) ?? 0n; - // require that at least 50 XLM is left in the wallet - if (isNative) { - tokenBalance = - tokenBalance > FixedMath.toFixed(50, 7) ? tokenBalance - FixedMath.toFixed(50, 7) : 0n; - } if (tokenBalance > 0n) { const balanceAsDTokens = reserve.toDTokensFromAssetFloor(tokenBalance); const repaidLiability = balanceAsDTokens <= amount ? balanceAsDTokens : amount; diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index e5b85de..cb32a85 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -7,7 +7,7 @@ import { BidderSubmitter, FillerUnwind, } from '../src/bidder_submitter'; -import { managePositions } from '../src/filler'; +import { getFillerAvailableBalances, managePositions } from '../src/filler'; import { Filler } from '../src/utils/config'; import { AuctioneerDatabase, AuctionEntry, AuctionType, FilledAuctionEntry } from '../src/utils/db'; import { logger } from '../src/utils/logger'; @@ -78,6 +78,9 @@ describe('BidderSubmitter', () => { typeof calculateAuctionFill >; const mockedManagePositions = managePositions as jest.MockedFunction; + const mockedGetFilledAvailableBalances = getFillerAvailableBalances as jest.MockedFunction< + typeof getFillerAvailableBalances + >; beforeEach(() => { jest.clearAllMocks(); @@ -231,9 +234,10 @@ describe('BidderSubmitter', () => { let result = await bidderSubmitter.submit(submission); expect(result).toBe(true); - expect(mockedSorobanHelper.loadBalances).toHaveBeenCalledWith( - submission.filler.keypair.publicKey(), - ['USD', 'XLM', 'EURC'] + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + submission.filler, + ['USD', 'XLM', 'EURC'], + mockedSorobanHelper ); expect(mockedManagePositions).toHaveBeenCalled(); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); @@ -271,9 +275,10 @@ describe('BidderSubmitter', () => { let result = await bidderSubmitter.submit(submission); expect(result).toBe(true); - expect(mockedSorobanHelper.loadBalances).toHaveBeenCalledWith( - submission.filler.keypair.publicKey(), - ['USD', 'XLM', 'EURC'] + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + submission.filler, + ['USD', 'XLM', 'EURC'], + mockedSorobanHelper ); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); diff --git a/test/filler.test.ts b/test/filler.test.ts index e0cf59c..8811d53 100644 --- a/test/filler.test.ts +++ b/test/filler.test.ts @@ -431,33 +431,6 @@ describe('filler', () => { expect(requests).toEqual(expectedRequests); }); - it('keeps XLM balance above min XLM', () => { - const positions = new Positions( - // dTokens - new Map([[0, FixedMath.toFixed(200, 7)]]), - // bTokens - new Map([[1, FixedMath.toFixed(125, 7)]]), - new Map([]) - ); - const balances = new Map([ - [assets[0], FixedMath.toFixed(75, 7)], - [assets[1], FixedMath.toFixed(3000, 7)], - [assets[2], FixedMath.toFixed(1000, 7)], - [assets[3], FixedMath.toFixed(0, 7)], - ]); - - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); - - const expectedRequests: Request[] = [ - { - request_type: RequestType.Repay, - address: assets[0], - amount: FixedMath.toFixed(25, 7), - }, - ]; - expect(requests).toEqual(expectedRequests); - }); - it('clears collateral with no liabilities and keeps primary collateral above min collateral', () => { const positions = new Positions( // dTokens @@ -520,7 +493,7 @@ describe('filler', () => { { request_type: RequestType.Repay, address: assets[0], - amount: FixedMath.toFixed(4950, 7), + amount: FixedMath.toFixed(5000, 7), }, { request_type: RequestType.Repay,