diff --git a/README.md b/README.md index 092b51d..be5ddbb 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ For an example config file that is configured to interact with [Blend 2 mainnet **It is highly recommended to create a backup of the database file before attempting any migration.** -For auctioneers that were started before multi-pool functionality a db migration will be neccessary. On startup of the updated auctioneer bot a command line argument will be required to be inputed with `-p` or `--prev-pool-id` followed by the pool id that the single pool auctioneer bot was using. The migration will update the database to the new schema and will populate the pool id column with the one provided. +For auctioneers that were started before multi-pool functionality a db migration will be necessary. On startup of the updated auctioneer bot a command line argument will be required to be inputted with `-p` or `--prev-pool-id` followed by the pool id that the single pool auctioneer bot was using. The migration will update the database to the new schema and will populate the pool id column with the one provided. #### General Settings @@ -64,10 +64,13 @@ For auctioneers that were started before multi-pool functionality a db migration | `blndAddress` | The address of the BLND token contract. | | `interestFillerAddress` | A contract address used to help fill interest auctions. Must implement the same interface as [this example](https://github.com/script3/interest-auction-filler). | | `workerKeypair` | The secret key for the bot's auction creating account. This should be different from the filler as auction creation and auction bidding can happen simultaneously. **Keep this secret and secure!** | -| `fillerKeypair` | The securet key for the bot's auction filler account. This should be different from the worker as auction creation and auction bidding can happen simultaneously. **Keep this secret and secure!** | +| `fillerKeypair` | The secret key for the bot's auction filler account. This should be different from the worker as auction creation and auction bidding can happen simultaneously. **Keep this secret and secure!** | | `pools` | A list of pool configs that dictates what pools are monitored | +| `notificationLevel` | (Default - `med`) The severity level where notifications are sent to either the console or a webhook, if present. Can be one of `low`, `med`, or `high`. High notifications includes dropped actions, bad debt auctions, and critical errors. Med adds all successful auction fills, liquidation auctions, and retried errors. Low adds additional info notifications and interest auctions. | +| `baseFee` | (Default - `5000`) The minimum inclusion fee that will be specified for a normal transaction, otherwise feeStats p70. | +| `highBaseFee` | (Default - `10000`) The minimum inclusion fee that will be specified for a high priority transaction, otherwise feeStats p90. This is considered any auction fill where the estimated profit is over 10 oracle units (almost always $10). | | `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. +| `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. | | `discordWebhook` | (Optional) A Discord webhook URL to post updates to. Leave undefined if no webhooks are required. | @@ -120,7 +123,7 @@ Each DEX price source has the following fields: #### 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. +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 flexibility 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: diff --git a/example.config.json b/example.config.json index 5a40e2e..117f4bd 100644 --- a/example.config.json +++ b/example.config.json @@ -3,6 +3,9 @@ "rpcURL": "http://localhost:8000", "networkPassphrase": "Public Global Stellar Network ; September 2015", "horizonURL": "http://horizon:8000", + "notificationLevel": "med", + "baseFee": 5000, + "highBaseFee": 10000, "backstopAddress": "CAO3AGAMZVRMHITL36EJ2VZQWKYRPWMQAPDQD5YEOF3GIF7T44U4JAL3", "backstopTokenAddress": "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM", "usdcAddress": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", diff --git a/example.env b/example.env deleted file mode 100644 index 9f30b2e..0000000 --- a/example.env +++ /dev/null @@ -1,5 +0,0 @@ -AUCTIONEER_KEY=S... -RPC_URL=http://localhost:8000/rpc -NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -POOL_ADDRESS=C... -BACKSTOP_ADDRESS=C... \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f3cb461..e9dc918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "auctioneer-bot", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auctioneer-bot", - "version": "3.0.1", + "version": "3.1.0", "license": "MIT", "dependencies": { "@blend-capital/blend-sdk": "3.2.2", diff --git a/package.json b/package.json index 796b8c2..b053540 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auctioneer-bot", - "version": "3.0.1", + "version": "3.1.0", "main": "index.js", "type": "module", "scripts": { diff --git a/src/bidder_handler.ts b/src/bidder_handler.ts index fa62c0f..6c095b8 100644 --- a/src/bidder_handler.ts +++ b/src/bidder_handler.ts @@ -5,7 +5,7 @@ import { APP_CONFIG } from './utils/config.js'; import { AuctioneerDatabase, AuctionType } from './utils/db.js'; import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; -import { sendNotification } from './utils/notifier.js'; +import { getNotificationLevelForAuction, sendNotification } from './utils/notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; export class BidderHandler { @@ -90,7 +90,10 @@ export class BidderHandler { `Fill: ${stringify(fill, 2)}\n` + `Ledgers To Fill In: ${fill.block - nextLedger}\n`; if (auctionEntry.fill_block === 0) { - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(auction.type, false) + ); } logger.info(logMessage); auctionEntry.fill_block = fill.block; diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index eed313d..8a24c0e 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -6,7 +6,11 @@ import { APP_CONFIG } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; import { serializeError, stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; -import { sendNotification } from './utils/notifier.js'; +import { + getNotificationLevelForAuction, + sendNotification, + NotificationLevel, +} from './utils/notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { SubmissionQueue } from './utils/submission_queue.js'; import { InterestFillerContract } from './utils/interest_filler.js'; @@ -181,7 +185,10 @@ export class BidderSubmitter extends SubmissionQueue { `Fill Percent ${fill.percent}\n` + `Ledger Fill Delta ${result.ledger - auctionBid.auctionEntry.start_block}\n` + `Hash ${result.txHash}\n`; - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(auctionBid.auctionEntry.auction_type, true) + ); logger.info(logMessage); return true; } else { @@ -203,7 +210,10 @@ export class BidderSubmitter extends SubmissionQueue { `User: ${auctionBid.auctionEntry.user_id}\n` + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + `Error: ${stringify(serializeError(e))}`; - await sendNotification(logMessage, true); + await sendNotification( + logMessage, + getNotificationLevelForAuction(auctionBid.auctionEntry.auction_type, false) + ); logger.error(logMessage, e); return false; } @@ -310,7 +320,7 @@ export class BidderSubmitter extends SubmissionQueue { `Pool: ${fillerUnwind.poolId}\n` + `Positions: ${stringify(filler_user.positions, 2)}`; logger.info(logMessage); - await sendNotification(logMessage); + await sendNotification(logMessage, NotificationLevel.HIGH); return true; } @@ -345,6 +355,6 @@ export class BidderSubmitter extends SubmissionQueue { break; } logger.error(logMessage); - await sendNotification(logMessage); + await sendNotification(logMessage, NotificationLevel.HIGH); } } diff --git a/src/filler.ts b/src/filler.ts index 8c6aa52..c6d9a86 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -75,9 +75,9 @@ export function getFillerProfitPct(poolConfig: PoolConfig, auctionData: AuctionD let auctionProfits = APP_CONFIG.profits ?? []; for (const profit of auctionProfits) { if ( - (!bidAssets.includes('*') && + (!profit.supportedBid.includes('*') && bidAssets.some((address) => !profit.supportedBid.includes(address))) || - (!lotAssets.includes('*') && + (!profit.supportedLot.includes('*') && lotAssets.some((address) => !profit.supportedLot.includes(address))) ) { // either some bid asset or some lot asset is not in the profit's supported assets, skip @@ -172,7 +172,10 @@ export function managePositions( // 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 (poolConfig.minHealthFactor * 1.01 > effectiveCollateral / effectiveLiabilities) { + if ( + effectiveLiabilities != 0 && + poolConfig.minHealthFactor * 1.01 > effectiveCollateral / effectiveLiabilities + ) { return requests; } diff --git a/src/liquidations.ts b/src/liquidations.ts index 7932e8c..abb18b5 100644 --- a/src/liquidations.ts +++ b/src/liquidations.ts @@ -5,8 +5,7 @@ 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 { sendNotification } from './utils/notifier.js'; -import { stringify } from './utils/json.js'; +import { sendNotification, NotificationLevel } from './utils/notifier.js'; /** * A representation of a position taking into account the oracle price. @@ -257,7 +256,7 @@ export async function scanUsers( } catch (e) { const errorLog = `Error scanning for liquidations: ${poolConfig.poolAddress}\nError: ${e}`; logger.error(errorLog); - sendNotification(errorLog); + sendNotification(errorLog, NotificationLevel.MED); } } return submissions; diff --git a/src/pool_event_handler.ts b/src/pool_event_handler.ts index a6213f4..f55e644 100644 --- a/src/pool_event_handler.ts +++ b/src/pool_event_handler.ts @@ -8,7 +8,7 @@ import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { deadletterEvent, sendEvent } from './utils/messages.js'; -import { sendNotification } from './utils/notifier.js'; +import { getNotificationLevelForAuction, sendNotification } from './utils/notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission } from './work_submitter.js'; @@ -100,7 +100,10 @@ export class PoolEventHandler { `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}\n` + `Auction Data: ${stringify(poolEvent.event.auctionData, 2)}\n`; - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(poolEvent.event.auctionType, false) + ); logger.info(logMessage); return; } @@ -122,7 +125,10 @@ export class PoolEventHandler { `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}\n` + `Auction Data: ${stringify(poolEvent.event.auctionData, 2)}\n`; - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(poolEvent.event.auctionType, false) + ); logger.info(logMessage); break; } @@ -138,7 +144,10 @@ export class PoolEventHandler { `Liquidation Auction Deleted\n` + `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}\n`; - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(AuctionType.Liquidation, false) + ); logger.info(logMessage); } break; @@ -153,7 +162,10 @@ export class PoolEventHandler { `User: ${poolEvent.event.user}\n` + `Fill Percent: ${poolEvent.event.fillAmount}\n` + `Tx Hash: ${poolEvent.event.txHash}\n`; - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(poolEvent.event.auctionType, false) + ); logger.info(logMessage); if (poolEvent.event.fillAmount === BigInt(100)) { // auction was fully filled, remove from ongoing auctions @@ -211,11 +223,14 @@ export class PoolEventHandler { let runResult = this.db.deleteAuctionEntry(pool.id, user, auctionType); if (runResult.changes !== 0) { const logMessage = - `Stale Auction Deleted\n` + + `Auction Deleted Before Fill\n` + `Type: ${AuctionType[auctionType]}\n` + `Pool: ${pool.id}\n` + `User: ${user}`; - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(poolEvent.event.auctionType, false) + ); logger.info(logMessage); } } diff --git a/src/utils/config.ts b/src/utils/config.ts index 0849159..2bbbe95 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,7 @@ import { Keypair } from '@stellar/stellar-sdk'; import { readFileSync } from 'fs'; import { parse } from './json.js'; +import { NotificationLevel } from './notifier.js'; export enum PriceSourceType { BINANCE = 'binance', @@ -57,6 +58,7 @@ export interface AppConfig { fillerKeypair: Keypair; pools: PoolConfig[]; // optional fields + notificationLevel: NotificationLevel | undefined; horizonURL: string | undefined; priceSources: PriceSource[] | undefined; profits: AuctionProfit[] | undefined; @@ -94,6 +96,9 @@ export function validateAppConfig(config: any): boolean { typeof config.workerKeypair !== 'string' || typeof config.fillerKeypair !== 'string' || !Array.isArray(config.pools) || + // default fields + (config.notificationLevel !== undefined && + !Object.values(NotificationLevel).includes(config.notificationLevel)) || // optional fields (config.horizonURL !== undefined && typeof config.horizonURL !== 'string') || (config.priceSources !== undefined && !Array.isArray(config.priceSources)) || diff --git a/src/utils/notifier.ts b/src/utils/notifier.ts index 3cfecac..8715ed3 100644 --- a/src/utils/notifier.ts +++ b/src/utils/notifier.ts @@ -1,7 +1,25 @@ +import { AuctionType } from '@blend-capital/blend-sdk'; import { APP_CONFIG } from './config.js'; import { logger } from './logger.js'; -async function sendSlackNotification(message: string, tag: boolean = false): Promise { +/** + * Notification levels indicating the severity of the message. Intended to filter notifications sent + * to external services like Slack or Discord. + * + * - HIGH: Important notifications that may require immediate attention. Includes things like + * dropping auction fill events, bad debt auctions, or critical system errors. + * - MED: Notifications of moderate importance that normally don't require attention. Includes + * successful auction fills, system warnings, and liquidation auctions. + * - LOW: Informational messages that do not require immediate action. Includes interest auctions + * and debug information. + */ +export enum NotificationLevel { + HIGH = 'high', + MED = 'med', + LOW = 'low', +} + +async function sendSlackNotification(message: string, tag: boolean): Promise { try { if (APP_CONFIG.slackWebhook) { const taggedMessage = tag ? ` ${message}` : message; @@ -23,7 +41,7 @@ async function sendSlackNotification(message: string, tag: boolean = false): Pro } } -async function sendDiscordNotification(message: string, tag: boolean = false): Promise { +async function sendDiscordNotification(message: string, tag: boolean): Promise { try { if (APP_CONFIG.discordWebhook) { const taggedMessage = tag ? `@everyone ${message}` : message; @@ -45,12 +63,35 @@ async function sendDiscordNotification(message: string, tag: boolean = false): P } } -export async function sendNotification(message: string, tag: boolean = false): Promise { +/** + * Send a notification message to configured external services (Slack, Discord). If no services + * are configured, logs the message to the console as a fallback. Will not send any notification if + * the notification level is below the configured threshold. + * + * If no notification level is set in the config, defaults to MED. + * + * @param message - The notification message to send + * @param level - The severity level of the notification + */ +export async function sendNotification(message: string, level: NotificationLevel): Promise { + // Determine if the notification should be sent based on the configured level. + switch (APP_CONFIG.notificationLevel) { + case NotificationLevel.HIGH: + if (level !== NotificationLevel.HIGH) { + return; + } + break; + case NotificationLevel.MED: + case undefined: + if (level === NotificationLevel.LOW) { + return; + } + break; + } + // If no webhooks are configured, log to console as fallback if (!APP_CONFIG.slackWebhook && !APP_CONFIG.discordWebhook) { - console.log( - `Bot Name: ${APP_CONFIG.name}\nTimestamp: ${new Date().toISOString()}\n${message}` - ); + console.log(`Bot Name: ${APP_CONFIG.name}\nTimestamp: ${new Date().toISOString()}\n${message}`); return; } @@ -58,11 +99,11 @@ export async function sendNotification(message: string, tag: boolean = false): P const notifications = []; if (APP_CONFIG.slackWebhook) { - notifications.push(sendSlackNotification(message, tag)); + notifications.push(sendSlackNotification(message, level === NotificationLevel.HIGH)); } if (APP_CONFIG.discordWebhook) { - notifications.push(sendDiscordNotification(message, tag)); + notifications.push(sendDiscordNotification(message, level === NotificationLevel.HIGH)); } try { @@ -70,4 +111,40 @@ export async function sendNotification(message: string, tag: boolean = false): P } catch (error) { logger.error(`Error sending notifications: ${error}`); } -} \ No newline at end of file +} + +/** + * Get the appropriate notification level for an auction based on its type and whether the bot successfully + * filled it. + * @param auctionType - The auction type to determine the notification level for + * @param isBotFill - Whether the bot successfully filled the auction + * @returns The notification level to use for the given auction type and fill status. + */ +export function getNotificationLevelForAuction( + auctionType: AuctionType, + isBotFill: boolean +): NotificationLevel { + if (isBotFill) { + switch (auctionType) { + case 0: // Liquidation + return NotificationLevel.MED; + case 1: // Bad Debt + return NotificationLevel.HIGH; + case 2: // Interest + return NotificationLevel.MED; + default: + return NotificationLevel.MED; + } + } else { + switch (auctionType) { + case 0: // Liquidation + return NotificationLevel.LOW; + case 1: // Bad Debt + return NotificationLevel.HIGH; + case 2: // Interest + return NotificationLevel.LOW; + default: + return NotificationLevel.LOW; + } + } +} diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index f068243..f4898a5 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -484,13 +484,13 @@ export class SorobanHelper { transaction: Transaction ): Promise { logger.info(`Submitting transaction: ${transaction.hash().toString('hex')}`); - let submitStartTime = Date.now(); 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 stellarRpc.sendTransaction(transaction); } + let submitStartTime = Date.now(); if (txResponse.status !== 'PENDING') { const error = parseError(txResponse); @@ -500,7 +500,7 @@ export class SorobanHelper { throw error; } let get_tx_response = await stellarRpc.getTransaction(txResponse.hash); - while (get_tx_response.status === 'NOT_FOUND' && Date.now() - submitStartTime < 6000) { + while (get_tx_response.status === 'NOT_FOUND' && Date.now() - submitStartTime < 12000) { await new Promise((resolve) => setTimeout(resolve, 500)); get_tx_response = await stellarRpc.getTransaction(txResponse.hash); } diff --git a/src/work_handler.ts b/src/work_handler.ts index 261213a..a903801 100644 --- a/src/work_handler.ts +++ b/src/work_handler.ts @@ -1,4 +1,3 @@ -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'; @@ -8,7 +7,7 @@ import { AuctioneerDatabase } from './utils/db.js'; import { logger } from './utils/logger.js'; import { deadletterEvent } from './utils/messages.js'; import { setPrices } from './utils/prices.js'; -import { sendNotification } from './utils/notifier.js'; +import { sendNotification, NotificationLevel } from './utils/notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmitter } from './work_submitter.js'; import { checkPoolForInterestAuction } from './interest.js'; @@ -141,7 +140,7 @@ export class WorkHandler { `Pool: ${poolConfig.poolAddress}\n` + `User: ${user.user_id}`; logger.error(logMessage); - await sendNotification(logMessage); + await sendNotification(logMessage, NotificationLevel.MED); } const { estimate: poolUserEstimate, user: poolUser } = diff --git a/src/work_submitter.ts b/src/work_submitter.ts index 494579a..f6e9e5b 100644 --- a/src/work_submitter.ts +++ b/src/work_submitter.ts @@ -3,10 +3,13 @@ import { APP_CONFIG } from './utils/config.js'; import { AuctionType } from './utils/db.js'; import { serializeError, stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; -import { sendNotification } from './utils/notifier.js'; +import { + getNotificationLevelForAuction, + sendNotification, + NotificationLevel, +} from './utils/notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { SubmissionQueue } from './utils/submission_queue.js'; -import { Address, Contract, nativeToScVal } from '@stellar/stellar-sdk'; export type WorkSubmission = AuctionCreation | BadDebtTransfer; @@ -90,7 +93,10 @@ export class WorkSubmitter extends SubmissionQueue { `Lot: ${stringify(auction.lot)}\n`; logger.info(logMessage); - await sendNotification(logMessage); + await sendNotification( + logMessage, + getNotificationLevelForAuction(auction.auctionType, false) + ); return true; } catch (e: any) { const logMessage = @@ -103,7 +109,10 @@ export class WorkSubmitter extends SubmissionQueue { `Lot: ${stringify(auction.lot)}\n` + `Error: ${stringify(serializeError(e))}\n`; logger.error(logMessage); - await sendNotification(logMessage, true); + await sendNotification( + logMessage, + getNotificationLevelForAuction(auction.auctionType, false) + ); // if pool throws a "LIQ_TOO_SMALL" or "LIQ_TOO_LARGE" error, adjust the fill percentage // by 1 percentage point before retrying. @@ -133,23 +142,23 @@ export class WorkSubmitter extends SubmissionQueue { `Successfully transferred bad debt to backstop\n` + `Pool: ${badDebtTransfer.poolId}\n` + `User: ${badDebtTransfer.user}`; - await sendNotification(logMessage); + await sendNotification(logMessage, NotificationLevel.HIGH); logger.info(logMessage); return true; } catch (e: any) { const logMessage = - `Error transfering bad debt\n` + + `Error transferring bad debt\n` + `Pool: ${badDebtTransfer.poolId}\n` + - `User: ${badDebtTransfer.user}` + + `User: ${badDebtTransfer.user}\n` + `Error: ${stringify(serializeError(e))}\n`; logger.error(logMessage); - await sendNotification(logMessage, true); + // will log a high severity notification if it fails the retry limit + await sendNotification(logMessage, NotificationLevel.MED); return false; } } async onDrop(submission: WorkSubmission): Promise { - // TODO: Send slack alert for dropped submission let logMessage: string; switch (submission.type) { case WorkSubmissionType.AuctionCreation: @@ -167,6 +176,6 @@ export class WorkSubmitter extends SubmissionQueue { break; } logger.error(logMessage); - await sendNotification(logMessage); + await sendNotification(logMessage, NotificationLevel.HIGH); } } diff --git a/start.sh b/start.sh index cf53b7d..89d9aba 100755 --- a/start.sh +++ b/start.sh @@ -30,8 +30,6 @@ fi echo "Env file found." - - # Set up the database ./db/setup_db.sh -p $POOL_ID if [ $? -ne 0 ]; then diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 45fff3a..8baf010 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -16,7 +16,6 @@ import { FillerUnwind, } from '../src/bidder_submitter'; import { getFillerAvailableBalances, managePositions } from '../src/filler'; -import { APP_CONFIG } from '../src/utils/config'; import { AuctioneerDatabase, AuctionEntry, AuctionType, FilledAuctionEntry } from '../src/utils/db'; import { logger } from '../src/utils/logger'; import { sendNotification } from '../src/utils/notifier'; @@ -442,7 +441,8 @@ describe('BidderSubmitter', () => { `Filler has liabilities that cannot be removed\n` + `Filler: ${fillerPubkey}\n` + `Pool: ${submission.poolId}\n` + - `Positions: ${stringify(fillerPositions, 2)}` + `Positions: ${stringify(fillerPositions, 2)}`, + 'high' ); }); @@ -551,7 +551,8 @@ describe('BidderSubmitter', () => { `User: ${submission.auctionEntry.user_id}\n` + `Start Block: ${submission.auctionEntry.start_block}\n` + `Fill Block: ${submission.auctionEntry.fill_block}\n` + - `Filler: ${fillerPubkey}\n` + `Filler: ${fillerPubkey}\n`, + 'high' ); }); @@ -579,7 +580,8 @@ describe('BidderSubmitter', () => { `Dropped filler unwind\n` + `Filler: ${fillerPubkey}\n` + `Pool: ${mockPool.id}` ); expect(mockedSendSlackNotif).toHaveBeenCalledWith( - `Dropped filler unwind\n` + `Filler: ${fillerPubkey}\n` + `Pool: ${mockPool.id}` + `Dropped filler unwind\n` + `Filler: ${fillerPubkey}\n` + `Pool: ${mockPool.id}`, + 'high' ); }); }); diff --git a/test/filler.test.ts b/test/filler.test.ts index 6f7ae78..a1b2702 100644 --- a/test/filler.test.ts +++ b/test/filler.test.ts @@ -11,6 +11,7 @@ import { Keypair } from '@stellar/stellar-sdk'; import { canFillerBid, getFillerProfitPct, managePositions } from '../src/filler'; import { AppConfig, AuctionProfit, PoolConfig } from '../src/utils/config'; import { mockPool } from './helpers/mocks'; +import { APP_CONFIG } from '../src/utils/config'; jest.mock('../src/utils/logger.js', () => ({ logger: { @@ -162,16 +163,25 @@ describe('filler', () => { }); }); describe('getFillerProfitPct', () => { - const poolConfig: PoolConfig = { - poolAddress: 'POOL1', - primaryAsset: 'ASSET1', - minPrimaryCollateral: FixedMath.toFixed(100, 7), - minHealthFactor: 1.5, - defaultProfitPct: 0.1, - forceFill: true, - supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], - supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], - }; + let poolConfig: PoolConfig; + + beforeEach(() => { + poolConfig = { + defaultProfitPct: 0.1, + } as PoolConfig; + (APP_CONFIG as any).profits = [ + { + profitPct: 0.2, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET3'], + }, + { + profitPct: 0.3, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET2'], + }, + ]; + }); it('gets profitPct from profit config if available', () => { const auctionData: AuctionData = { @@ -224,6 +234,58 @@ describe('filler', () => { const result = getFillerProfitPct(poolConfig, auctionData); expect(result).toBe(0.1); }); + + it('returns first matched profit with wildcards in bid', () => { + (APP_CONFIG as any).profits = [ + { + 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(poolConfig, auctionData); + expect(result).toBe(0.3); + }); + + it('returns first matched profit with wildcards in lot', () => { + (APP_CONFIG as any).profits = [ + { + 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(poolConfig, auctionData); + expect(result).toBe(0.3); + }); }); describe('managePositions', () => { const assets = mockPool.metadata.reserveList; diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index e97b5d5..ce92338 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -132,6 +132,44 @@ describe('validateAppConfig', () => { }; expect(validateAppConfig(validConfig)).toBe(true); }); + + it('should validate notification level', () => { + let config = { + name: 'App', + rpcURL: 'http://localhost', + networkPassphrase: 'Test', + backstopAddress: 'backstop', + backstopTokenAddress: 'token', + usdcAddress: 'usdc', + blndAddress: 'blnd', + interestFillerAddress: 'filler', + workerKeypair: Keypair.random().secret(), + fillerKeypair: Keypair.random().secret(), + pools: [ + { + poolAddress: 'pool', + defaultProfitPct: 1, + minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', + forceFill: true, + supportedBid: ['bid'], + supportedLot: ['lot'], + }, + ], + priceSources: [{ assetId: 'asset', type: 'binance', symbol: 'symbol' }], + slackWebhook: 'http://webhook', + horizonURL: 'http://horizon', + notificationLevel: 'medium', + }; + expect(validateAppConfig(config)).toBe(false); + + config.notificationLevel = 1 as any; + expect(validateAppConfig(config)).toBe(false); + + config.notificationLevel = 'low'; + expect(validateAppConfig(config)).toBe(true); + }); }); describe('validatePoolConfig', () => { diff --git a/test/utils/notifier.test.ts b/test/utils/notifier.test.ts new file mode 100644 index 0000000..d1ceb0d --- /dev/null +++ b/test/utils/notifier.test.ts @@ -0,0 +1,373 @@ +import { AuctionType } from '@blend-capital/blend-sdk'; +import { sendNotification, getNotificationLevelForAuction } from '../../src/utils/notifier'; +import { APP_CONFIG } from '../../src/utils/config'; +import { NotificationLevel } from '../../src/utils/notifier'; +import { logger } from '../../src/utils/logger'; + +// Mock dependencies +jest.mock('../../src/utils/config', () => ({ + APP_CONFIG: { + name: 'TestBot', + slackWebhook: undefined, + discordWebhook: undefined, + notificationLevel: undefined, + }, + NotificationLevel: { + LOW: 'low', + MED: 'med', + HIGH: 'high', + }, +})); + +jest.mock('../../src/utils/logger', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + }, +})); + +// Mock global fetch +global.fetch = jest.fn(); + +describe('sendNotification', () => { + let consoleLogSpy: jest.SpyInstance; + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + mockFetch = global.fetch as jest.MockedFunction; + + // Reset APP_CONFIG to default state + (APP_CONFIG as any).slackWebhook = undefined; + (APP_CONFIG as any).discordWebhook = undefined; + (APP_CONFIG as any).notificationLevel = NotificationLevel.MED; + (APP_CONFIG as any).name = 'TestBot'; + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + describe('notification level filtering', () => { + beforeEach(() => { + (APP_CONFIG as any).slackWebhook = 'https://hooks.slack.com/test'; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + }); + + it('should send HIGH level notifications when config is set to HIGH', async () => { + (APP_CONFIG as any).notificationLevel = NotificationLevel.HIGH; + + await sendNotification('High priority message', NotificationLevel.HIGH); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hooks.slack.com/test', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + ); + }); + + it('should NOT send MED level notifications when config is set to HIGH', async () => { + (APP_CONFIG as any).notificationLevel = NotificationLevel.HIGH; + + await sendNotification('Medium priority message', NotificationLevel.MED); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should NOT send LOW level notifications when config is set to HIGH', async () => { + (APP_CONFIG as any).notificationLevel = NotificationLevel.HIGH; + + await sendNotification('Low priority message', NotificationLevel.LOW); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should send HIGH and MED notifications when config is set to MED', async () => { + (APP_CONFIG as any).notificationLevel = NotificationLevel.MED; + + await sendNotification('High priority message', NotificationLevel.HIGH); + expect(mockFetch).toHaveBeenCalledTimes(1); + + mockFetch.mockClear(); + + await sendNotification('Medium priority message', NotificationLevel.MED); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should NOT send LOW notifications when config is set to MED', async () => { + (APP_CONFIG as any).notificationLevel = NotificationLevel.MED; + + await sendNotification('Low priority message', NotificationLevel.LOW); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should default to MED when config is undefined', async () => { + (APP_CONFIG as any).notificationLevel = undefined; + + await sendNotification('High priority message', NotificationLevel.HIGH); + expect(mockFetch).toHaveBeenCalledTimes(1); + + mockFetch.mockClear(); + + await sendNotification('Medium priority message', NotificationLevel.MED); + expect(mockFetch).toHaveBeenCalledTimes(1); + + mockFetch.mockClear(); + + await sendNotification('Low priority', NotificationLevel.LOW); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should send all notifications when config level is LOW', async () => { + (APP_CONFIG as any).notificationLevel = NotificationLevel.LOW; + + await sendNotification('High priority', NotificationLevel.HIGH); + expect(mockFetch).toHaveBeenCalledTimes(1); + + mockFetch.mockClear(); + await sendNotification('Medium priority', NotificationLevel.MED); + expect(mockFetch).toHaveBeenCalledTimes(1); + + mockFetch.mockClear(); + await sendNotification('Low priority', NotificationLevel.LOW); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('fallback to console when no webhooks configured', () => { + it('should log to console when both webhooks are undefined', async () => { + (APP_CONFIG as any).slackWebhook = undefined; + (APP_CONFIG as any).discordWebhook = undefined; + + await sendNotification('Test message', NotificationLevel.MED); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Bot Name: TestBot')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Test message')); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should include timestamp in console fallback', async () => { + const dateSpy = jest + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2024-01-01T00:00:00.000Z'); + + await sendNotification('Test message', NotificationLevel.MED); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('2024-01-01T00:00:00.000Z') + ); + + dateSpy.mockRestore(); + }); + }); + + describe('Slack notifications', () => { + beforeEach(() => { + (APP_CONFIG as any).slackWebhook = 'https://hooks.slack.com/test'; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + }); + + it('should send notification to Slack with correct format', async () => { + await sendNotification('Test notification', NotificationLevel.MED); + + expect(mockFetch).toHaveBeenCalledWith('https://hooks.slack.com/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: '*Bot Name*: TestBot\nTest notification', + }), + }); + }); + + it('should add tag for HIGH level notifications', async () => { + await sendNotification('High priority alert', NotificationLevel.HIGH); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1]?.body as string); + expect(body.text).toContain(''); + expect(body.text).toContain('High priority alert'); + }); + + it('should NOT add tag for MED level notifications', async () => { + await sendNotification('Medium priority alert', NotificationLevel.MED); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1]?.body as string); + expect(body.text).not.toContain(''); + expect(body.text).toContain('Medium priority alert'); + }); + + it('should log error when Slack webhook fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + await sendNotification('Test message', NotificationLevel.MED); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error sending Slack notification') + ); + }); + + it('should log error when Slack webhook throws exception', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await sendNotification('Test message', NotificationLevel.MED); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error sending Slack notification') + ); + }); + }); + + describe('Discord notifications', () => { + beforeEach(() => { + (APP_CONFIG as any).discordWebhook = 'https://discord.com/api/webhooks/test'; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + }); + + it('should send notification to Discord with correct format', async () => { + await sendNotification('Test notification', NotificationLevel.MED); + + expect(mockFetch).toHaveBeenCalledWith('https://discord.com/api/webhooks/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: '**TestBot**\nTest notification', + }), + }); + }); + + it('should add @everyone tag for HIGH level notifications', async () => { + await sendNotification('High priority alert', NotificationLevel.HIGH); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1]?.body as string); + expect(body.content).toContain('@everyone'); + expect(body.content).toContain('High priority alert'); + }); + + it('should NOT add @everyone tag for MED level notifications', async () => { + await sendNotification('Medium priority alert', NotificationLevel.MED); + + const callArgs = mockFetch.mock.calls[0]; + const body = JSON.parse(callArgs[1]?.body as string); + expect(body.content).not.toContain('@everyone'); + expect(body.content).toContain('Medium priority alert'); + }); + + it('should log error when Discord webhook fails', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + } as Response); + + await sendNotification('Test message', NotificationLevel.MED); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error sending Discord notification') + ); + }); + + it('should log error when Discord webhook throws exception', async () => { + mockFetch.mockRejectedValue(new Error('Connection timeout')); + + await sendNotification('Test message', NotificationLevel.MED); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Error sending Discord notification') + ); + }); + }); + + describe('multiple webhooks', () => { + beforeEach(() => { + (APP_CONFIG as any).slackWebhook = 'https://hooks.slack.com/test'; + (APP_CONFIG as any).discordWebhook = 'https://discord.com/api/webhooks/test'; + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + }); + + it('should send to both Slack and Discord when both are configured', async () => { + await sendNotification('Multi-platform message', NotificationLevel.MED); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith('https://hooks.slack.com/test', expect.any(Object)); + expect(mockFetch).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/test', + expect.any(Object) + ); + }); + }); +}); + +describe('getNotificationLevelForAuction', () => { + describe('when bot successfully fills auction', () => { + it('should return MED for Liquidation auctions', () => { + const level = getNotificationLevelForAuction(AuctionType.Liquidation, true); + expect(level).toBe(NotificationLevel.MED); + }); + + it('should return HIGH for BadDebt auctions', () => { + const level = getNotificationLevelForAuction(AuctionType.BadDebt, true); + expect(level).toBe(NotificationLevel.HIGH); + }); + + it('should return MED for Interest auctions', () => { + const level = getNotificationLevelForAuction(AuctionType.Interest, true); + expect(level).toBe(NotificationLevel.MED); + }); + }); + + describe('when bot does NOT fill auction', () => { + it('should return LOW for Liquidation auctions', () => { + const level = getNotificationLevelForAuction(AuctionType.Liquidation, false); + expect(level).toBe(NotificationLevel.LOW); + }); + + it('should return HIGH for BadDebt auctions', () => { + const level = getNotificationLevelForAuction(AuctionType.BadDebt, false); + expect(level).toBe(NotificationLevel.HIGH); + }); + + it('should return LOW for Interest auctions', () => { + const level = getNotificationLevelForAuction(AuctionType.Interest, false); + expect(level).toBe(NotificationLevel.LOW); + }); + }); + + describe('edge cases', () => { + it('should handle numeric auction type values correctly', () => { + expect(getNotificationLevelForAuction(0 as AuctionType, true)).toBe(NotificationLevel.MED); + expect(getNotificationLevelForAuction(1 as AuctionType, true)).toBe(NotificationLevel.HIGH); + expect(getNotificationLevelForAuction(2 as AuctionType, true)).toBe(NotificationLevel.MED); + }); + + it('should default to MED for unknown auction types when bot fills', () => { + const level = getNotificationLevelForAuction(999 as AuctionType, true); + expect(level).toBe(NotificationLevel.MED); + }); + + it('should default to LOW for unknown auction types when bot does not fill', () => { + const level = getNotificationLevelForAuction(999 as AuctionType, false); + expect(level).toBe(NotificationLevel.LOW); + }); + }); +});