diff --git a/package-lock.json b/package-lock.json index ce8ce13..2cdfcc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "auctioneer-bot", - "version": "2.0.0-beta", + "version": "2.0.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auctioneer-bot", - "version": "2.0.0-beta", + "version": "2.0.1-beta", "license": "MIT", "dependencies": { - "@blend-capital/blend-sdk": "3.0.0", + "@blend-capital/blend-sdk": "3.0.1", "@stellar/stellar-sdk": "13.2.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", @@ -462,9 +462,10 @@ "dev": true }, "node_modules/@blend-capital/blend-sdk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-3.0.0.tgz", - "integrity": "sha512-oJELTOOr1YyNaXRtgbPPI/TlbAhTkf2PzJLpZIJbfvxW0gixgDqtv7OG3BjXrlyVAEbnQUGt3GFZX1XHljWVhQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-3.0.1.tgz", + "integrity": "sha512-jqIcvVof0MY3x9+M8FJU9xcYRDeASRGSXrrS2OrSBvmfFvJ5DJ/tkHr0o19pn9Z0aWO98WrriscRQFy2NKmSBg==", + "license": "MIT", "dependencies": { "@stellar/stellar-sdk": "13.2.0", "buffer": "6.0.3", diff --git a/package.json b/package.json index 5071f6c..74333f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auctioneer-bot", - "version": "2.0.0-beta", + "version": "2.0.1-beta", "main": "index.js", "type": "module", "scripts": { @@ -25,7 +25,7 @@ "typescript": "^5.5.4" }, "dependencies": { - "@blend-capital/blend-sdk": "3.0.0", + "@blend-capital/blend-sdk": "3.0.1", "@stellar/stellar-sdk": "13.2.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 907947f..9128778 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -120,6 +120,12 @@ export class BidderSubmitter extends SubmissionQueue { if (nextLedger >= fill.block) { const pool = new PoolContractV2(auctionBid.auctionEntry.pool_id); + const est_profit = fill.lotValue - fill.bidValue; + // include high inclusion fee if the esimated profit is over $10 + if (est_profit > 10) { + // this object gets recreated every time, so no need to reset the fee level + sorobanHelper.setFeeLevel('high'); + } const result = await sorobanHelper.submitTransaction( pool.submit({ diff --git a/src/interest.ts b/src/interest.ts index 263cbbe..70262a3 100644 --- a/src/interest.ts +++ b/src/interest.ts @@ -13,6 +13,17 @@ export async function checkPoolForInterestAuction( const pool = await sorobanHelper.loadPool(poolId); const poolOracle = await sorobanHelper.loadPoolOracle(poolId); + // check if there is an existing interest auction + const interestAuction = await sorobanHelper.loadAuction( + poolId, + APP_CONFIG.backstopAddress, + AuctionType.Interest + ); + if (interestAuction !== undefined) { + logger.info(`Interest auction already exists for pool ${poolId}`); + return undefined; + } + // 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; diff --git a/src/liquidations.ts b/src/liquidations.ts index f275388..89fc17b 100644 --- a/src/liquidations.ts +++ b/src/liquidations.ts @@ -6,6 +6,27 @@ import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission, WorkSubmissionType } from './work_submitter.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; +import { stringify } from './utils/json.js'; + +/** + * A representation of a position taking into account the oracle price. + */ +interface PricedPosition { + assetId: string; + index: number; + effectiveAmount: number; + baseAmount: number; +} + +/** + * The result of a liquidation calculation. + * Contains the auction percent, lot and bid asset ids. + */ +interface LiquidationCalc { + auctionPercent: number; + lot: string[]; + bid: string[]; +} /** * Check if a user is liquidatable @@ -45,15 +66,9 @@ export function calculateLiquidation( user: Positions, estimate: PositionsEstimate, oracle: PoolOracle -): { - auctionPercent: number; - lot: string[]; - bid: string[]; -} { - let effectiveCollaterals: [number, number][] = []; - let rawCollaterals: Map = new Map(); - let effectiveLiabilities: [number, number][] = []; - let rawLiabilities: Map = new Map(); +): LiquidationCalc { + let collateral: PricedPosition[] = []; + let liabilities: PricedPosition[] = []; for (let [index, amount] of user.collateral) { let assetId = pool.metadata.reserveList[index]; @@ -63,9 +78,13 @@ export function calculateLiquidation( continue; } let effectiveAmount = reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; - let rawAmount = reserve.toAssetFromBTokenFloat(amount) * oraclePrice; - effectiveCollaterals.push([index, effectiveAmount]); - rawCollaterals.set(assetId, rawAmount); + let baseAmount = reserve.toAssetFromBTokenFloat(amount) * oraclePrice; + collateral.push({ + assetId, + index, + effectiveAmount, + baseAmount, + }); } for (let [index, amount] of user.liabilities) { let assetId = pool.metadata.reserveList[index]; @@ -75,84 +94,125 @@ export function calculateLiquidation( continue; } let effectiveAmount = reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; - let rawAmount = reserve.toAssetFromDTokenFloat(amount) * oraclePrice; - effectiveLiabilities.push([index, effectiveAmount]); - rawLiabilities.set(assetId, rawAmount); + let baseAmount = reserve.toAssetFromDTokenFloat(amount) * oraclePrice; + liabilities.push({ + assetId, + index, + effectiveAmount, + baseAmount, + }); } - effectiveCollaterals.sort((a, b) => a[1] - b[1]); - effectiveLiabilities.sort((a, b) => a[1] - b[1]); - let firstCollateral = effectiveCollaterals.pop(); - let firstLiability = effectiveLiabilities.pop(); + // sort ascending by effective amount + collateral.sort((a, b) => a.effectiveAmount - b.effectiveAmount); + liabilities.sort((a, b) => a.effectiveAmount - b.effectiveAmount); + let largestCollateral = collateral.pop(); + let largestLiability = liabilities.pop(); - if (firstCollateral === undefined || firstLiability === undefined) { + if (largestCollateral === undefined || largestLiability === undefined) { throw new Error('No collaterals or liabilities found for liquidation calculation'); } - let auction = new Positions( - new Map([[firstLiability[0], user.liabilities.get(firstLiability[0])!]]), - new Map([[firstCollateral[0], user.collateral.get(firstCollateral[0])!]]), - new Map() - ); - let auctionEstimate = PositionsEstimate.build(pool, oracle, auction); - let liabilitesToReduce = Math.max( - 0, - estimate.totalEffectiveLiabilities * 1.06 - estimate.totalEffectiveCollateral + let liabilitesToReduce = + estimate.totalEffectiveLiabilities * 1.06 - estimate.totalEffectiveCollateral; + if (liabilitesToReduce <= 0) { + throw new Error('No liabilities to reduce for liquidation calculation'); + } + + let effectiveCollateral = largestCollateral.effectiveAmount; + let baseCollateral = largestCollateral.baseAmount; + let effectiveLiabilities = largestLiability.effectiveAmount; + let baseLiabilities = largestLiability.baseAmount; + + let bid: string[] = [largestLiability.assetId]; + let lot: string[] = [largestCollateral.assetId]; + let liqPercent = calculateLiqPercent( + effectiveCollateral, + baseCollateral, + effectiveLiabilities, + baseLiabilities, + liabilitesToReduce ); - let liqPercent = calculateLiqPercent(auctionEstimate, liabilitesToReduce); while (liqPercent > 100 || liqPercent === 0) { if (liqPercent > 100) { - let nextLiability = effectiveLiabilities.pop(); + let nextLiability = liabilities.pop(); if (nextLiability === undefined) { - let nextCollateral = effectiveCollaterals.pop(); + let nextCollateral = collateral.pop(); if (nextCollateral === undefined) { + // full liquidation required return { auctionPercent: 100, - lot: Array.from(auction.collateral).map(([index]) => pool.metadata.reserveList[index]), - bid: Array.from(auction.liabilities).map(([index]) => pool.metadata.reserveList[index]), + lot: Array.from(user.collateral).map(([index]) => pool.metadata.reserveList[index]), + bid: Array.from(user.liabilities).map(([index]) => pool.metadata.reserveList[index]), }; } - auction.collateral.set(nextCollateral[0], user.collateral.get(nextCollateral[0])!); + effectiveCollateral += nextCollateral.effectiveAmount; + baseCollateral += nextCollateral.baseAmount; + lot.push(nextCollateral.assetId); } else { - auction.liabilities.set(nextLiability[0], user.liabilities.get(nextLiability[0])!); + effectiveLiabilities += nextLiability.effectiveAmount; + baseLiabilities += nextLiability.baseAmount; + bid.push(nextLiability.assetId); } } else if (liqPercent == 0) { - let nextCollateral = effectiveCollaterals.pop(); + let nextCollateral = collateral.pop(); if (nextCollateral === undefined) { - // No more collaterals to liquidate + // full liquidation required return { auctionPercent: 100, - lot: Array.from(auction.collateral).map(([index]) => pool.metadata.reserveList[index]), - bid: Array.from(auction.liabilities) - .map(([index]) => pool.metadata.reserveList[index]) - .concat(effectiveLiabilities.map(([index]) => pool.metadata.reserveList[index])), + lot: Array.from(user.collateral).map(([index]) => pool.metadata.reserveList[index]), + bid: Array.from(user.liabilities).map(([index]) => pool.metadata.reserveList[index]), }; } - auction.collateral.set(nextCollateral[0], user.collateral.get(nextCollateral[0])!); + effectiveCollateral += nextCollateral.effectiveAmount; + baseCollateral += nextCollateral.baseAmount; + lot.push(nextCollateral.assetId); } - auctionEstimate = PositionsEstimate.build(pool, oracle, auction); - liqPercent = calculateLiqPercent(auctionEstimate, liabilitesToReduce); + liqPercent = calculateLiqPercent( + effectiveCollateral, + baseCollateral, + effectiveLiabilities, + baseLiabilities, + liabilitesToReduce + ); } return { auctionPercent: liqPercent, - lot: Array.from(auction.collateral).map(([index]) => pool.metadata.reserveList[index]), - bid: Array.from(auction.liabilities).map(([index]) => pool.metadata.reserveList[index]), + lot, + bid, }; } -function calculateLiqPercent(positions: PositionsEstimate, excessLiabilities: number) { - let avgCF = positions.totalEffectiveCollateral / positions.totalSupplied; - let avgLF = positions.totalEffectiveLiabilities / positions.totalBorrowed; +/** + * Calculate the liquidation percent to bring the user back to a 1.06 HF + * @param effectiveCollateral - The effective collateral of the position to liquidate, in the pool's oracle denomination + * @param baseCollateral - The base collateral of the position to liquidate, in the pool's oracle denomination + * @param effectiveLiabilities - The effective liabilities of the position to liquidate, in the pool's oracle denomination + * @param baseLiabilities - The base liabilities of the position to liquidate, in the pool's oracle denomination + * @param excessLiabilities - The excess liabilities over the borrow limit, in the pool's oracle denomination + * @returns A percentage of the borrow limit that needs to be liquidated. + * A percentage of 0 means there is not enough collateral to cover the liquidated liabilities. + * A percentage over 100 means there is not enough liabilities being liquidated to cover the excess. + */ +function calculateLiqPercent( + effectiveCollateral: number, + baseCollateral: number, + effectiveLiabilities: number, + baseLiabilities: number, + excessLiabilities: number +) { + let avgCF = effectiveCollateral / baseCollateral; + let avgLF = effectiveLiabilities / baseLiabilities; let estIncentive = 1 + (1 - avgCF / avgLF) / 2; // The factor by which the effective liabilities are reduced per raw liability let borrowLimitFactor = avgLF * 1.06 - estIncentive * avgCF; - let totalBorrowLimitRecovered = borrowLimitFactor * positions.totalBorrowed; + let totalBorrowLimitRecovered = borrowLimitFactor * baseLiabilities; let liqPercent = Math.round((excessLiabilities / totalBorrowLimitRecovered) * 100); - let requiredRawCollateral = (liqPercent / 100) * positions.totalBorrowed * estIncentive; + let requiredBaseCollateral = (liqPercent / 100) * baseLiabilities * estIncentive; - if (requiredRawCollateral > positions.totalSupplied) { + if (requiredBaseCollateral > baseCollateral) { return 0; // Not enough collateral to cover the liquidation } @@ -215,15 +275,19 @@ export async function checkUsersForLiquidationsAndBadDebt( isBadDebt(backstopPostionsEstimate) && (await sorobanHelper.loadAuction(poolId, user, AuctionType.BadDebt)) === undefined ) { + let backstopLiabilities = Array.from(backstop.positions.liabilities.keys()).map( + (index) => pool.metadata.reserveList[index] + ); + if (backstopLiabilities.length >= pool.metadata.maxPositions) { + backstopLiabilities = backstopLiabilities.slice(0, pool.metadata.maxPositions - 1); + } submissions.push({ type: WorkSubmissionType.AuctionCreation, poolId, user: APP_CONFIG.backstopAddress, auctionType: AuctionType.BadDebt, auctionPercent: 100, - bid: Array.from(backstop.positions.liabilities.keys()).map( - (index) => pool.metadata.reserveList[index] - ), + bid: backstopLiabilities, lot: [APP_CONFIG.backstopTokenAddress], }); } @@ -242,12 +306,8 @@ export async function checkUsersForLiquidationsAndBadDebt( user, auctionPercent: newLiq.auctionPercent, auctionType: AuctionType.Liquidation, - bid: Array.from(poolUser.positions.liabilities.keys()).map( - (index) => pool.metadata.reserveList[index] - ), - lot: Array.from(poolUser.positions.collateral.keys()).map( - (index) => pool.metadata.reserveList[index] - ), + bid: newLiq.bid, + lot: newLiq.lot, }); } else if (isBadDebt(poolUserEstimate)) { submissions.push({ diff --git a/src/utils/config.ts b/src/utils/config.ts index bcc5192..d5523b0 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -65,6 +65,8 @@ export interface AppConfig { priceSources: PriceSource[] | undefined; profits: AuctionProfit[] | undefined; slackWebhook: string | undefined; + highBaseFee: number | undefined; + baseFee: number | undefined; } let APP_CONFIG: AppConfig; @@ -96,7 +98,9 @@ export function validateAppConfig(config: any): boolean { (config.horizonURL !== undefined && typeof config.horizonURL !== 'string') || (config.priceSources !== undefined && !Array.isArray(config.priceSources)) || (config.profits !== undefined && !Array.isArray(config.profits)) || - (config.slackWebhook !== undefined && typeof config.slackWebhook !== 'string') + (config.slackWebhook !== undefined && typeof config.slackWebhook !== 'string') || + (config.highBaseFee !== undefined && typeof config.highBaseFee !== 'number') || + (config.baseFee !== undefined && typeof config.baseFee !== 'number') ) { console.log('Invalid app config'); return false; diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index 609cdad..3572c89 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -41,12 +41,13 @@ export interface ErrorTimeout { export class SorobanHelper { network: Network; + feeLevel: 'high' | 'medium'; private pool_cache: Map; // cache for pool users keyed by 'poolId + userId' private user_cache: Map; private oracle_cache: Map; - constructor() { + constructor(feeLevel: 'high' | 'medium' = 'medium') { this.network = { rpc: APP_CONFIG.rpcURL, passphrase: APP_CONFIG.networkPassphrase, @@ -54,11 +55,16 @@ export class SorobanHelper { allowHttp: true, }, }; + this.feeLevel = feeLevel; this.pool_cache = new Map(); this.user_cache = new Map(); this.oracle_cache = new Map(); } + setFeeLevel(feeLevel: 'high' | 'medium') { + this.feeLevel = feeLevel; + } + async loadLatestLedger(): Promise { try { let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); @@ -317,10 +323,23 @@ export class SorobanHelper { keypair: Keypair ): Promise { const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + + let feeStats = await stellarRpc.getFeeStats(); + let fee = + this.feeLevel === 'high' + ? Math.max( + parseInt(feeStats.sorobanInclusionFee.p90), + APP_CONFIG.highBaseFee ?? 10000 + ).toString() + : Math.max( + parseInt(feeStats.sorobanInclusionFee.p70), + APP_CONFIG.baseFee ?? 5000 + ).toString(); + let account = await stellarRpc.getAccount(keypair.publicKey()); let tx = new TransactionBuilder(account, { networkPassphrase: this.network.passphrase, - fee: BASE_FEE, + fee: fee, timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, }) .addOperation(xdr.Operation.fromXDR(operation, 'base64')) diff --git a/src/work_handler.ts b/src/work_handler.ts index 7a2187f..0ce4997 100644 --- a/src/work_handler.ts +++ b/src/work_handler.ts @@ -193,6 +193,7 @@ export class WorkHandler { return; } } + break; } default: logger.error(`Unhandled event type: ${appEvent.type}`); diff --git a/src/work_submitter.ts b/src/work_submitter.ts index 0804158..235808e 100644 --- a/src/work_submitter.ts +++ b/src/work_submitter.ts @@ -57,7 +57,9 @@ export class WorkSubmitter extends SubmissionQueue { async submitAuction(sorobanHelper: SorobanHelper, auction: AuctionCreation): Promise { try { - logger.info(`Creating liquidation for user: ${auction.user} in pool: ${auction.poolId}`); + logger.info( + `Creating auction ${auction.auctionType} for user: ${auction.user} in pool: ${auction.poolId}` + ); const pool = new PoolContractV2(auction.poolId); const op = pool.newAuction({ @@ -69,11 +71,11 @@ export class WorkSubmitter extends SubmissionQueue { }); const auctionExists = - (await sorobanHelper.loadAuction(auction.poolId, auction.user, AuctionType.Liquidation)) !== + (await sorobanHelper.loadAuction(auction.poolId, auction.user, auction.auctionType)) !== undefined; if (auctionExists) { logger.info( - `User liquidation auction already exists for user: ${auction.user} in pool: ${auction.poolId}` + `Auction ${auction.auctionType} already exists for user: ${auction.user} in pool: ${auction.poolId}` ); return true; } diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 9020493..33511f5 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -108,8 +108,8 @@ describe('BidderSubmitter', () => { let auction_fill: AuctionFill = { percent: 50, block: 1000, - bidValue: 1234, - lotValue: 2345, + bidValue: 1.234, + lotValue: 2.345, requests: [ { request_type: RequestType.FillUserLiquidationAuction, @@ -177,6 +177,97 @@ describe('BidderSubmitter', () => { submission.auctionEntry.user_id, submission.auctionEntry.auction_type ); + expect(mockedSorobanHelper.setFeeLevel).toHaveBeenCalledTimes(0); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); + expect(mockDb.setFilledAuctionEntry).toHaveBeenCalledWith(expectedFillEntry); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( + { type: BidderSubmissionType.UNWIND, filler: submission.filler, poolId: mockPool.id }, + 2 + ); + }); + + it('should submit a high includsion fee bid with high profit', async () => { + bidderSubmitter.addSubmission = jest.fn(); + + 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: 12.34, + lotValue: 23.45, + 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, + supportedPools: [ + { + poolAddress: mockPool.id, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + minHealthFactor: 1, + forceFill: false, + }, + ], + supportedBid: [], + supportedLot: [], + }; + const submission: AuctionBid = { + type: BidderSubmissionType.BID, + filler, + auctionEntry: { + pool_id: mockPool.id, + user_id: auction.user, + auction_type: AuctionType.Liquidation, + filler: filler.keypair.publicKey(), + start_block: 900, + fill_block: 1000, + } as AuctionEntry, + }; + + const result = await bidderSubmitter.submit(submission); + + const expectedFillEntry: FilledAuctionEntry = { + tx_hash: 'mock-tx-hash', + pool_id: mockPool.id, + 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( + mockPool.id, + submission.auctionEntry.user_id, + submission.auctionEntry.auction_type + ); + expect(mockedSorobanHelper.setFeeLevel).toHaveBeenCalledWith('high'); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); expect(mockDb.setFilledAuctionEntry).toHaveBeenCalledWith(expectedFillEntry); expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( diff --git a/test/liquidations.test.ts b/test/liquidations.test.ts index 578992b..9e615ec 100644 --- a/test/liquidations.test.ts +++ b/test/liquidations.test.ts @@ -693,6 +693,49 @@ describe('checkUsersForLiquidationsAndBadDebt', () => { ]); }); + it('should respect pool max positions for bad debt auctions', async () => { + const user_ids = [APP_CONFIG.backstopAddress]; + + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockBackstopPositionsEstimate.totalEffectiveLiabilities = 1000; + mockBackstopPositionsEstimate.totalEffectiveCollateral = 0; + // max positions is 4, so at most 3 positions should be used (given 1 is reserved for the lot asset) + mockBackstopPositions.positions = new Positions( + new Map([ + [USDC_ID, 2000n], + [XLM_ID, 3000n], + [EURC_ID, 4000n], + [AQUA_ID, 5000n], + ]), + new Map(), + new Map() + ); + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + estimate: mockBackstopPositionsEstimate, + user: mockBackstopPositions, + }); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); + + const result = await checkUsersForLiquidationsAndBadDebt( + db, + mockedSorobanHelper, + mockPool.id, + user_ids + ); + + expect(result).toEqual([ + { + type: WorkSubmissionType.AuctionCreation, + poolId: mockPool.id, + user: APP_CONFIG.backstopAddress, + auctionType: AuctionType.BadDebt, + bid: [USDC, XLM, EURC], + lot: [APP_CONFIG.backstopTokenAddress], + auctionPercent: 100, + }, + ]); + }); + it('should handle liquidatable users correctly', async () => { const user_ids = ['user1']; mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); @@ -732,6 +775,48 @@ describe('checkUsersForLiquidationsAndBadDebt', () => { ]); }); + it('should handle partial user liquidations', async () => { + const user_ids = ['user1']; + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockUser.positions = new Positions( + new Map([ + [USDC_ID, BigInt(2000e7)], + [EURC_ID, BigInt(1000e7)], + ]), + new Map([ + [XLM_ID, BigInt(38000e7)], + [EURC_ID, BigInt(500e7)], + ]), + new Map([]) + ); + mockUserEstimate = PositionsEstimate.build(mockPool, mockPoolOracle, mockUser.positions); + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + estimate: mockUserEstimate, + user: mockUser, + }); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + const result = await checkUsersForLiquidationsAndBadDebt( + db, + mockedSorobanHelper, + mockPool.id, + user_ids + ); + + expect(result.length).toBe(1); + expect(result).toEqual([ + { + type: WorkSubmissionType.AuctionCreation, + poolId: mockPool.id, + auctionType: AuctionType.Liquidation, + user: 'user1', + auctionPercent: 46, + bid: [USDC], + lot: [XLM], + }, + ]); + }); + it('should handle users with bad debt correctly', async () => { const user_ids = ['user1']; mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index ee0422c..8118bc4 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -32,6 +32,49 @@ describe('validateAppConfig', () => { expect(validateAppConfig(invalidConfig)).toBe(false); }); + it('should return false for non-number base fee', () => { + let invalidConfig = { + name: 'App', + rpcURL: 'http://localhost', + networkPassphrase: 'Test', + backstopAddress: 'backstop', + backstopTokenAddress: 'token', + usdcAddress: 'usdc', + blndAddress: 'blnd', + keypair: Keypair.random().secret(), + pools: ['pool'], + fillers: [ + { + name: 'filler', + keypair: Keypair.random().secret(), + defaultProfitPct: 1, + supportedPools: [ + { + poolAddress: 'pool', + primaryAsset: 'asset', + minPrimaryCollateral: '100', + minHealthFactor: 1, + forceFill: true, + }, + ], + supportedBid: ['bid'], + supportedLot: ['lot'], + }, + ], + priceSources: [{ assetId: 'asset', type: 'binance', symbol: 'symbol' }], + slackWebhook: 'http://webhook', + horizonURL: 'http://horizon', + highBaseFee: 10000, + baseFee: '5000', + }; + expect(validateAppConfig(invalidConfig)).toBe(false); + // @ts-ignore + invalidConfig.baseFee = 5000; // Fixing the base fee type + // @ts-ignore + invalidConfig.highBaseFee = '10000'; // Fixing the high base fee type + expect(validateAppConfig(invalidConfig)).toBe(false); + }); + it('should return true for valid config', () => { const validConfig = { name: 'App', @@ -67,6 +110,44 @@ describe('validateAppConfig', () => { }; expect(validateAppConfig(validConfig)).toBe(true); }); + + it('should return true for valid config with base fees', () => { + const validConfig = { + name: 'App', + rpcURL: 'http://localhost', + networkPassphrase: 'Test', + backstopAddress: 'backstop', + backstopTokenAddress: 'token', + usdcAddress: 'usdc', + blndAddress: 'blnd', + keypair: Keypair.random().secret(), + pools: ['pool'], + fillers: [ + { + name: 'filler', + keypair: Keypair.random().secret(), + defaultProfitPct: 1, + supportedPools: [ + { + poolAddress: 'pool', + primaryAsset: 'asset', + minPrimaryCollateral: '100', + minHealthFactor: 1, + forceFill: true, + }, + ], + supportedBid: ['bid'], + supportedLot: ['lot'], + }, + ], + priceSources: [{ assetId: 'asset', type: 'binance', symbol: 'symbol' }], + slackWebhook: 'http://webhook', + horizonURL: 'http://horizon', + highBaseFee: 10000, + baseFee: 5000, + }; + expect(validateAppConfig(validConfig)).toBe(true); + }); }); describe('validateFiller', () => {