diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 0e7f116..907947f 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -1,4 +1,4 @@ -import { ContractErrorType, PoolContractV2 } from '@blend-capital/blend-sdk'; +import { FixedMath, PoolContractV2 } from '@blend-capital/blend-sdk'; import { Address, Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; import { calculateAuctionFill } from './auction.js'; import { getFillerAvailableBalances, managePositions } from './filler.js'; @@ -266,6 +266,22 @@ export class BidderSubmitter extends SubmissionQueue { return true; } + // notify slack if the filler supports interest auctions and has low backstop token balance + if (fillerUnwind.filler.supportedBid.includes(APP_CONFIG.backstopTokenAddress)) { + const backstopTokenBalance = filler_balances.get(APP_CONFIG.backstopTokenAddress); + const backstopToken = await sorobanHelper.loadBackstopToken(); + const tokenBalanceFloat = FixedMath.toFloat(backstopTokenBalance ?? BigInt(0)); + if (tokenBalanceFloat * backstopToken.lpTokenPrice < 300) { + const logMessage = + `Filler has low balance of backstop tokens\n` + + `Filler: ${fillerUnwind.filler.name}\n` + + `Backstop Token Balance: ${tokenBalanceFloat}`; + logger.info(logMessage); + await sendSlackNotification(logMessage); + } + } + + // notify slack if the filler has any remaining liabilities if (filler_user.positions.liabilities.size > 0) { const logMessage = `Filler has liabilities that cannot be removed\n` + diff --git a/src/collector.ts b/src/collector.ts index 5c84e07..59d73fe 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -2,6 +2,7 @@ import { poolEventV2FromEventResponse } from '@blend-capital/blend-sdk'; import { rpc } from '@stellar/stellar-sdk'; import { ChildProcess } from 'child_process'; import { + CheckInterestEvent, EventType, LedgerEvent, LiqScanEvent, @@ -71,7 +72,8 @@ export async function runCollector( sendEvent(worker, event); } - if (ledgersProcessed % 1203 === 0) { + // offset allows for staggered events + if ((ledgersProcessed + 10) % 1200 === 0) { // approx every 2hr // send a user update event to update any users that have not been updated in ~2 weeks const event: UserRefreshEvent = { @@ -82,7 +84,7 @@ export async function runCollector( sendEvent(worker, event); } - if (ledgersProcessed % 1207 === 0) { + if ((ledgersProcessed + 5) % 1200 === 0) { // approx every 2hr // send a liq scan event const event: LiqScanEvent = { @@ -92,6 +94,16 @@ export async function runCollector( sendEvent(worker, event); } + if (ledgersProcessed % 7250 === 0) { + // approx every 12hr + // send a check interest event + const event: CheckInterestEvent = { + type: EventType.CHECK_INTEREST, + timestamp: Date.now(), + }; + sendEvent(worker, event); + } + // fetch events from last ledger and paging token // start from the ledger after the last one we processed let start_ledger = diff --git a/src/events.ts b/src/events.ts index eaa0a2e..ab56886 100644 --- a/src/events.ts +++ b/src/events.ts @@ -8,6 +8,7 @@ export enum EventType { POOL_EVENT = 'pool_event', USER_REFRESH = 'user_refresh', CHECK_USER = 'check_user', + CHECK_INTEREST = 'check_interest', } // ********* Shared ********** @@ -20,7 +21,8 @@ export type AppEvent = | LiqScanEvent | PoolEventEvent | UserRefreshEvent - | CheckUserEvent; + | CheckUserEvent + | CheckInterestEvent; /** * Base interface for all events. @@ -68,7 +70,7 @@ export interface OracleScanEvent extends BaseEvent { } /** - * Event to scan for liquidations for the given pool. + * Event to scan for liquidations in tracked pools. */ export interface LiqScanEvent extends BaseEvent { type: EventType.LIQ_SCAN; @@ -96,3 +98,10 @@ export interface CheckUserEvent extends BaseEvent { */ userId: string; } + +/** + * Event to check for interest auctions. + */ +export interface CheckInterestEvent extends BaseEvent { + type: EventType.CHECK_INTEREST; +} diff --git a/src/filler.ts b/src/filler.ts index 3534bf6..aabb9a0 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -24,24 +24,26 @@ const MAX_WITHDRAW = BigInt('9223372036854775807'); * @returns A boolean indicating if the filler cares about the auction. */ export function canFillerBid(filler: Filler, poolId: string, auctionData: AuctionData): boolean { - // validate lot + return checkFillerSupport(filler, poolId, Array.from(auctionData.bid.keys()), Array.from(auctionData.lot.keys())); +} +/** + * Check if the filler supports the pool and assets for the auction. + * @param filler - The filler to check + * @param poolId - The pool ID + * @param bid - The bid assets + * @param lot - The lot assets + * @returns A boolean indicating if the filler supports the pool and assets + */ +export function checkFillerSupport(filler: Filler, poolId: string, bid: string[], lot: string[]): boolean { if (filler.supportedPools.find((pool) => pool.poolAddress === poolId) === undefined) { return false; } - - 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; - } + if (bid.every((address) => filler.supportedBid.includes(address)) && + lot.every((address) => filler.supportedLot.includes(address))) { + return true; } - return true; + return false; } /** diff --git a/src/interest.ts b/src/interest.ts new file mode 100644 index 0000000..263cbbe --- /dev/null +++ b/src/interest.ts @@ -0,0 +1,86 @@ +import { SorobanHelper } from './utils/soroban_helper.js'; +import { WorkSubmission, WorkSubmissionType } from './work_submitter.js'; +import { logger } from './utils/logger.js'; +import { FixedMath, AuctionType } from '@blend-capital/blend-sdk'; +import { checkFillerSupport } from './filler.js'; +import { APP_CONFIG } from './utils/config.js'; + +export async function checkPoolForInterestAuction( + sorobanHelper: SorobanHelper, + poolId: string +): Promise { + try { + const pool = await sorobanHelper.loadPool(poolId); + const poolOracle = await sorobanHelper.loadPoolOracle(poolId); + + // use the pools max auction lot size or at most 3 lot assets + let maxLotAssets = Math.min(pool.metadata.maxPositions - 1, 3); + let totalInterest = 0; + let lotAssets = []; + let backstopCredit: [string, number][] = []; + for (const [assetId, reserve] of pool.reserves) { + const assetPrice = poolOracle.getPrice(assetId) ?? BigInt(0); + const priceFloat = FixedMath.toFloat(assetPrice, poolOracle.decimals); + const creditFloat = FixedMath.toFloat(reserve.data.backstopCredit, reserve.config.decimals); + backstopCredit.push([assetId, priceFloat * creditFloat]); + } + // sort by highest backstop credit first + backstopCredit.sort((a, b) => b[1] - a[1]); + for (let i = 0; i < backstopCredit.length; i++) { + const [assetId, credit] = backstopCredit[i]; + if (credit < 10 || i >= maxLotAssets) { + break; + } + totalInterest += credit; + lotAssets.push(assetId); + } + if (totalInterest > 300) { + const bid = [APP_CONFIG.backstopTokenAddress]; + const lot = lotAssets; + + // validate the expected filler has enough backstop tokens to fill + for (const filler of APP_CONFIG.fillers) { + if (checkFillerSupport(filler, poolId, bid, lot)) { + // found a filler - ensure it has enough backstop tokens to make the auction + const backstopToken = await sorobanHelper.loadBackstopToken(); + const backstopTokenBalance = await sorobanHelper.simBalance( + APP_CONFIG.backstopTokenAddress, + filler.keypair.publicKey() + ); + const bidValue = FixedMath.toFloat(backstopTokenBalance) * backstopToken.lpTokenPrice; + + if (bidValue > totalInterest) { + logger.info( + `Creating backstop interest auction for pool ${poolId}, value: ${totalInterest}, lot assets: ${lotAssets}` + ); + return { + type: WorkSubmissionType.AuctionCreation, + poolId, + user: APP_CONFIG.backstopAddress, + auctionType: AuctionType.Interest, + auctionPercent: 100, + bid: [APP_CONFIG.backstopTokenAddress], + lot: lotAssets, + }; + } else { + const logMessage = + `Filler does not have enough backstop tokens to create backstop interest auction.\n` + + `User: ${filler.keypair.publicKey()}\n` + + `Balance: ${FixedMath.toFloat(backstopTokenBalance)}\n` + + `Required: ${totalInterest / backstopToken.lpTokenPrice}`; + logger.error(logMessage); + return undefined; + } + } + } + } else { + logger.info( + `No backstop interest auction needed for pool ${poolId}, value: ${totalInterest}` + ); + return undefined; + } + } catch (e) { + logger.error(`Error checking backstop interest in pool ${poolId}: ${e}`); + return undefined; + } +} diff --git a/src/work_handler.ts b/src/work_handler.ts index 483b028..7a2187f 100644 --- a/src/work_handler.ts +++ b/src/work_handler.ts @@ -1,15 +1,18 @@ +import { FixedMath } from '@blend-capital/blend-sdk'; import { AppEvent, EventType } from './events.js'; import { checkUsersForLiquidationsAndBadDebt, scanUsers } from './liquidations.js'; import { OracleHistory } from './oracle_history.js'; import { updateUser } from './user.js'; import { APP_CONFIG } from './utils/config.js'; -import { AuctioneerDatabase } from './utils/db.js'; +import { AuctioneerDatabase, AuctionType } from './utils/db.js'; import { logger } from './utils/logger.js'; import { deadletterEvent } from './utils/messages.js'; import { setPrices } from './utils/prices.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; -import { WorkSubmitter } from './work_submitter.js'; +import { WorkSubmissionType, WorkSubmitter } from './work_submitter.js'; +import { canFillerBid, checkFillerSupport, getFillerAvailableBalances } from './filler.js'; +import { checkPoolForInterestAuction } from './interest.js'; const MAX_RETRIES = 3; const RETRY_DELAY = 1000; @@ -181,6 +184,16 @@ export class WorkHandler { } break; } + case EventType.CHECK_INTEREST: { + for (const poolId of APP_CONFIG.pools) { + const submission = await checkPoolForInterestAuction(this.sorobanHelper, poolId); + if (submission) { + this.submissionQueue.addSubmission(submission, 2); + // only submit one interest auction at a time + return; + } + } + } default: logger.error(`Unhandled event type: ${appEvent.type}`); break; diff --git a/src/work_submitter.ts b/src/work_submitter.ts index 4210b54..0804158 100644 --- a/src/work_submitter.ts +++ b/src/work_submitter.ts @@ -91,16 +91,6 @@ export class WorkSubmitter extends SubmissionQueue { await sendSlackNotification(logMessage); return true; } catch (e: any) { - // if pool throws a "LIQ_TOO_SMALL" or "LIQ_TOO_LARGE" error, adjust the fill percentage - // by 1 percentage point before retrying. - if (e instanceof ContractError) { - if (e.type === ContractErrorType.InvalidLiqTooSmall && auction.auctionPercent < 100) { - auction.auctionPercent += 1; - } else if (e.type === ContractErrorType.InvalidLiqTooLarge && auction.auctionPercent > 1) { - auction.auctionPercent -= 1; - } - } - const logMessage = `Error creating auction\n` + `Auction Type: ${AuctionType[auction.auctionType]}\n` + @@ -112,6 +102,16 @@ export class WorkSubmitter extends SubmissionQueue { `Error: ${stringify(serializeError(e))}\n`; logger.error(logMessage); await sendSlackNotification(`\n` + logMessage); + + // if pool throws a "LIQ_TOO_SMALL" or "LIQ_TOO_LARGE" error, adjust the fill percentage + // by 1 percentage point before retrying. + if (e instanceof ContractError) { + if (e.type === ContractErrorType.InvalidLiqTooSmall && auction.auctionPercent < 100) { + auction.auctionPercent += 1; + } else if (e.type === ContractErrorType.InvalidLiqTooLarge && auction.auctionPercent > 1) { + auction.auctionPercent -= 1; + } + } return false; } } diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 40e1b40..9020493 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -1,4 +1,11 @@ -import { Auction, PoolUser, Positions, Request, RequestType } from '@blend-capital/blend-sdk'; +import { + Auction, + BackstopToken, + PoolUser, + Positions, + Request, + RequestType, +} from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { @@ -15,6 +22,7 @@ import { logger } from '../src/utils/logger'; import { sendSlackNotification } from '../src/utils/slack_notifier'; import { SorobanHelper } from '../src/utils/soroban_helper'; import { inMemoryAuctioneerDb, mockPool, mockPoolOracle } from './helpers/mocks'; +import { stringify } from '../src/utils/json'; // Mock dependencies jest.mock('../src/utils/db'); @@ -227,7 +235,7 @@ describe('BidderSubmitter', () => { mockedSorobanHelper.loadUser.mockResolvedValue( new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) ); - mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); + mockedGetFilledAvailableBalances.mockResolvedValue(fillerBalance); mockedManagePositions.mockReturnValue(unwindRequest); @@ -274,7 +282,7 @@ describe('BidderSubmitter', () => { mockedSorobanHelper.loadUser.mockResolvedValue( new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) ); - mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); + mockedGetFilledAvailableBalances.mockResolvedValue(fillerBalance); mockedManagePositions.mockReturnValue(unwindRequest); @@ -310,6 +318,117 @@ describe('BidderSubmitter', () => { expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); }); + it('should stop submitting unwind events and send slack notification when liabilities remain', async () => { + const fillerBalance = new Map([['USD', 123n]]); + const unwindRequest: Request[] = []; + const fillerPositions = new Positions(new Map([[0, 123n]]), new Map([[1, 123n]]), new Map()); + + bidderSubmitter.addSubmission = jest.fn(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue( + new PoolUser('test-user', fillerPositions, new Map()) + ); + mockedGetFilledAvailableBalances.mockResolvedValue(fillerBalance); + + mockedManagePositions.mockReturnValue(unwindRequest); + + const submission: FillerUnwind = { + type: BidderSubmissionType.UNWIND, + poolId: mockPool.id, + filler: { + name: 'test-filler', + keypair: Keypair.random(), + defaultProfitPct: 0, + supportedPools: [ + { + poolAddress: mockPool.id, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + minHealthFactor: 0, + forceFill: false, + }, + ], + supportedBid: ['USD', 'XLM'], + supportedLot: ['EURC', 'XLM'], + }, + }; + let result = await bidderSubmitter.submit(submission); + + expect(result).toBe(true); + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + submission.filler, + ['USD', 'XLM', 'EURC'], + mockedSorobanHelper + ); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); + expect(mockedSendSlackNotif).toHaveBeenCalledWith( + `Filler has liabilities that cannot be removed\n` + + `Filler: ${submission.filler.name}\n` + + `Pool: ${submission.poolId}\n` + + `Positions: ${stringify(fillerPositions, 2)}` + ); + }); + + it('should stop submitting unwind events and send slack notification when backstop credit low', async () => { + const fillerBalance = new Map([ + ['USD', 123n], + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', BigInt(500e7)], + ]); + const unwindRequest: Request[] = []; + const fillerPositions = new Positions(new Map(), new Map([[1, 123n]]), new Map()); + + bidderSubmitter.addSubmission = jest.fn(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue( + new PoolUser('test-user', fillerPositions, new Map()) + ); + mockedGetFilledAvailableBalances.mockResolvedValue(fillerBalance); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue({ + lpTokenPrice: 0.5, + } as BackstopToken); + + mockedManagePositions.mockReturnValue(unwindRequest); + + const submission: FillerUnwind = { + type: BidderSubmissionType.UNWIND, + poolId: mockPool.id, + filler: { + name: 'test-filler', + keypair: Keypair.random(), + defaultProfitPct: 0, + supportedPools: [ + { + poolAddress: mockPool.id, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + minHealthFactor: 0, + forceFill: false, + }, + ], + supportedBid: ['USD', 'XLM', 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM'], + supportedLot: ['EURC', 'XLM'], + }, + }; + let result = await bidderSubmitter.submit(submission); + + expect(result).toBe(true); + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + submission.filler, + ['USD', 'XLM', 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 'EURC'], + mockedSorobanHelper + ); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); + expect(mockedSendSlackNotif).toHaveBeenCalledWith( + `Filler has low balance of backstop tokens\n` + + `Filler: ${submission.filler.name}\n` + + `Backstop Token Balance: ${500}` + ); + }); + it('should return true if auction is in the queue', () => { const auctionEntry: AuctionEntry = { pool_id: mockPool.id, diff --git a/test/filler.test.ts b/test/filler.test.ts index 0465dcd..7f44469 100644 --- a/test/filler.test.ts +++ b/test/filler.test.ts @@ -61,7 +61,6 @@ describe('filler', () => { const result = canFillerBid(filler, mockPool.id, auctionData); expect(result).toBe(true); }); - it('returns false if the filler does not support the lot', () => { const filler: Filler = { name: 'Teapot', @@ -127,41 +126,39 @@ describe('filler', () => { const result = canFillerBid(filler, mockPool.id, auctionData); expect(result).toBe(false); }); - }); - - it('returns false if the filler does not support the pool', () => { - const filler: Filler = { - name: 'Teapot', - keypair: Keypair.random(), - defaultProfitPct: 0.1, - supportedPools: [ - { - poolAddress: mockPool.id, - primaryAsset: 'ASSET1', - minPrimaryCollateral: BigInt(100), - minHealthFactor: 1.5, - 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('returns false if the filler does not support the pool', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + defaultProfitPct: 0.1, + supportedPools: [ + { + poolAddress: mockPool.id, + primaryAsset: 'ASSET1', + minPrimaryCollateral: BigInt(100), + minHealthFactor: 1.5, + 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, + }; - const result = canFillerBid(filler, 'UNKNOWN POOL', auctionData); - expect(result).toBe(false); + const result = canFillerBid(filler, 'UNKNOWN POOL', auctionData); + expect(result).toBe(false); + }); }); - describe('getFillerProfitPct', () => { it('gets profitPct from profit config if available', () => { const filler: Filler = { diff --git a/test/interest.test.ts b/test/interest.test.ts new file mode 100644 index 0000000..fe07547 --- /dev/null +++ b/test/interest.test.ts @@ -0,0 +1,279 @@ +import { + AuctionType, + BackstopToken, + FixedMath, + Network, + PoolMetadata, + PoolOracle, + PoolV2, + PriceData, + Reserve, + ReserveData, +} from '@blend-capital/blend-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; +import { SorobanHelper } from '../src/utils/soroban_helper.js'; +import { WorkSubmissionType } from '../src/work_submitter.js'; +import { ReserveConfig } from '@blend-capital/blend-sdk'; +import { checkPoolForInterestAuction } from '../src/interest.js'; + +jest.mock('../src/utils/soroban_helper.js'); +jest.mock('../src/utils/logger.js', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + }, +})); +jest.mock('../src/utils/config.js', () => { + return { + APP_CONFIG: { + backstopAddress: 'backstopAddress', + backstopTokenAddress: 'backstopTokenAddress', + pools: ['pool1', 'pool2'], + fillers: [ + { + name: 'filler1', + keypair: Keypair.random(), + defaultProfitPct: 0.05, + supportedPools: [ + { + poolAddress: 'pool1', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + primaryAsset: 'USD', + minHealthFactor: 1.1, + forceFill: true, + }, + ], + supportedBid: ['asset1', 'asset2', 'asset3', 'backstopTokenAddress'], + supportedLot: ['asset1', 'asset2', 'asset3'], + }, + { + name: 'filler2', + keypair: Keypair.random(), + defaultProfitPct: 0.08, + + supportedPools: [ + { + poolAddress: 'pool2', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + primaryAsset: 'USD', + minHealthFactor: 1.1, + forceFill: true, + }, + ], + supportedBid: ['asset1', 'asset2', 'asset3', 'asset4', 'backstopTokenAddress'], + supportedLot: ['asset1', 'asset2', 'asset3', 'asset4'], + }, + ], + }, + }; +}); + +describe('checkPoolForInterestAuction', () => { + let mockedSorobanHelper: jest.Mocked; + let mockBackstopToken: BackstopToken; + + beforeEach(() => { + mockedSorobanHelper = new SorobanHelper() as jest.Mocked; + mockBackstopToken = { + lpTokenPrice: 0.5, + } as BackstopToken; + }); + + it('returns interest auction creation submission happy path', async () => { + const assets = ['asset1', 'asset2', 'asset3']; + + const backstopCredit = [BigInt(100e7), BigInt(2e7), BigInt(300e7)]; + const decimals = [7, 7, 7]; + const pool = buildPoolObject('pool1', assets, backstopCredit, decimals); + + const prices = [BigInt(1e7), BigInt(4e7), BigInt(0.75e7)]; + const poolOracle = buildPoolOracleObject(assets, prices, 7); + + mockedSorobanHelper.loadPool.mockResolvedValue(pool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); + + // backstop token balance for filler + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); + + const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool1'); + + expect(result).toEqual({ + type: WorkSubmissionType.AuctionCreation, + poolId: 'pool1', + auctionType: AuctionType.Interest, + user: 'backstopAddress', + auctionPercent: 100, + bid: ['backstopTokenAddress'], + lot: ['asset3', 'asset1'], + }); + }); + it('returns undefined if filler does not have enough backstop tokens', async () => { + const assets = ['asset1', 'asset2', 'asset3']; + + const backstopCredit = [BigInt(100e7), BigInt(2e7), BigInt(300e7)]; + const decimals = [7, 7, 7]; + const pool = buildPoolObject('pool1', assets, backstopCredit, decimals); + + const prices = [BigInt(1e7), BigInt(4e7), BigInt(0.67e7)]; + const poolOracle = buildPoolOracleObject(assets, prices, 7); + + mockedSorobanHelper.loadPool.mockResolvedValue(pool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); + + // backstop token balance for filler + // -> auctionv val is ~325, need 650 LP tokens at 0.5 price + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(600e7)); + + const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool1'); + + expect(result).toBeUndefined(); + }); + it('returns undefined if no filler supports included assets', async () => { + const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + + const backstopCredit = [BigInt(100e7), BigInt(2e7), BigInt(300e7), BigInt(100e7)]; + const decimals = [7, 7, 7, 7]; + const pool = buildPoolObject('pool1', assets, backstopCredit, decimals); + + const prices = [BigInt(1e7), BigInt(4e7), BigInt(0.67e7), BigInt(2e7)]; + const poolOracle = buildPoolOracleObject(assets, prices, 7); + + mockedSorobanHelper.loadPool.mockResolvedValue(pool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); + + // backstop token balance for filler + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(5000e7)); + + const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool1'); + + expect(result).toBeUndefined(); + }); + it('returns interest auction creation submission max 3 assets', async () => { + const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + + const backstopCredit = [BigInt(105e7), BigInt(10e7), BigInt(200e7), BigInt(100e7)]; + const decimals = [7, 7, 7, 7]; + const pool = buildPoolObject('pool2', assets, backstopCredit, decimals); + + const prices = [BigInt(1e7), BigInt(4e7), BigInt(0.5e7), BigInt(2e7)]; + const poolOracle = buildPoolOracleObject(assets, prices, 7); + + mockedSorobanHelper.loadPool.mockResolvedValue(pool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); + + // backstop token balance for filler + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); + + const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool2'); + + expect(result).toEqual({ + type: WorkSubmissionType.AuctionCreation, + poolId: 'pool2', + auctionType: AuctionType.Interest, + user: 'backstopAddress', + auctionPercent: 100, + bid: ['backstopTokenAddress'], + lot: ['asset4', 'asset1', 'asset3'], + }); + }); + it('returns interest auction creation submission respects pool max positions', async () => { + const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + + const backstopCredit = [BigInt(105e7), BigInt(10e7), BigInt(200e7), BigInt(100e7)]; + const decimals = [7, 7, 7, 7]; + const pool = buildPoolObject('pool2', assets, backstopCredit, decimals); + pool.metadata.maxPositions = 3; + + const prices = [BigInt(1e7), BigInt(4e7), BigInt(0.5e7), BigInt(2e7)]; + const poolOracle = buildPoolOracleObject(assets, prices, 7); + + mockedSorobanHelper.loadPool.mockResolvedValue(pool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); + + // backstop token balance for filler + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); + + const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool2'); + + expect(result).toEqual({ + type: WorkSubmissionType.AuctionCreation, + poolId: 'pool2', + auctionType: AuctionType.Interest, + user: 'backstopAddress', + auctionPercent: 100, + bid: ['backstopTokenAddress'], + lot: ['asset4', 'asset1'], + }); + }); + it('returns undefined respects pool max positions', async () => { + const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + + const backstopCredit = [BigInt(105e7), BigInt(10e7), BigInt(200e7), BigInt(60e7)]; + const decimals = [7, 7, 7, 7]; + const pool = buildPoolObject('pool2', assets, backstopCredit, decimals); + pool.metadata.maxPositions = 3; + + const prices = [BigInt(1e7), BigInt(4e7), BigInt(0.5e7), BigInt(2e7)]; + const poolOracle = buildPoolOracleObject(assets, prices, 7); + + mockedSorobanHelper.loadPool.mockResolvedValue(pool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); + + // backstop token balance for filler + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); + + const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool2'); + + expect(result).toBeUndefined(); + }); +}); + +function buildPoolObject( + id: string, + assets: string[], + backstopCredit: bigint[], + decimals: number[] +): PoolV2 { + const pool = new PoolV2( + {} as Network, + id, + { + maxPositions: 6, + oracle: 'oracleId', + } as PoolMetadata, + new Map( + assets.map((asset, index) => [ + asset, + { + config: { decimals: decimals[index] } as ReserveConfig, + data: { + bRate: BigInt(1_000_000_000_000), + backstopCredit: backstopCredit[index], + } as ReserveData, + } as Reserve, + ]) + ), + 1723578353 + ); + + return pool; +} + +function buildPoolOracleObject(assets: string[], prices: bigint[], decimals: number): PoolOracle { + const poolOracle = new PoolOracle( + 'pool1', + new Map( + assets.map((asset, index) => [asset, { price: prices[index], timestamp: 1724950800 }]) + ), + decimals, + 53255053 + ); + + return poolOracle; +} diff --git a/test/work_submitter.test.ts b/test/work_submitter.test.ts index fa4c3a9..84643b6 100644 --- a/test/work_submitter.test.ts +++ b/test/work_submitter.test.ts @@ -228,15 +228,16 @@ describe('WorkSubmitter', () => { while (workSubmitter.processing) { await new Promise((resolve) => setTimeout(resolve, 50)); } - // 3 retries plus the final increment before dropping + // 3 retries before dropping and the last increment expect(submission.auctionPercent).toBe(54); + // log is of the last attempted retry expect(logger.error).toHaveBeenCalledWith( expect.stringContaining( 'Error creating auction\n' + `Auction Type: ${AuctionType[submission.auctionType]}\n` + `Pool: ${mockPool.id}\n` + `User: ${submission.user}\n` + - `Auction Percent: ${submission.auctionPercent}\n` + + `Auction Percent: ${53}\n` + `Bid: ${stringify(submission.bid)}\n` + `Lot: ${stringify(submission.lot)}\n` + `Error: ${stringify(serializeError(new ContractError(ContractErrorType.InvalidLiqTooSmall)))}\n`