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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/bidder_submitter.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -266,6 +266,22 @@ export class BidderSubmitter extends SubmissionQueue<BidderSubmission> {
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` +
Expand Down
16 changes: 14 additions & 2 deletions src/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 =
Expand Down
13 changes: 11 additions & 2 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export enum EventType {
POOL_EVENT = 'pool_event',
USER_REFRESH = 'user_refresh',
CHECK_USER = 'check_user',
CHECK_INTEREST = 'check_interest',
}

// ********* Shared **********
Expand All @@ -20,7 +21,8 @@ export type AppEvent =
| LiqScanEvent
| PoolEventEvent
| UserRefreshEvent
| CheckUserEvent;
| CheckUserEvent
| CheckInterestEvent;

/**
* Base interface for all events.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
28 changes: 15 additions & 13 deletions src/filler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
86 changes: 86 additions & 0 deletions src/interest.ts
Original file line number Diff line number Diff line change
@@ -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<WorkSubmission | undefined> {
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;
}
}
17 changes: 15 additions & 2 deletions src/work_handler.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 10 additions & 10 deletions src/work_submitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,6 @@ export class WorkSubmitter extends SubmissionQueue<WorkSubmission> {
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` +
Expand All @@ -112,6 +102,16 @@ export class WorkSubmitter extends SubmissionQueue<WorkSubmission> {
`Error: ${stringify(serializeError(e))}\n`;
logger.error(logMessage);
await sendSlackNotification(`<!channel>\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;
}
}
Expand Down
Loading