diff --git a/README.md b/README.md index 31e09b8..dcc537e 100644 --- a/README.md +++ b/README.md @@ -60,39 +60,29 @@ For auctioneers that were started before multi-pool functionality a db migration | `backstopTokenAddress` | The address of the Blend backstop token contract. | | `usdcAddress` | The address of the USDC token contract. | | `blndAddress` | The address of the BLND token contract. | -| `keypair` | The secret key for the bot's auction creating account. This should be different from the fillers as auction creation and auction bidding can happen simultaneously. **Keep this secret and secure!** | -| `pools` | A list of pool addresses that dicate what pools are monitored | -| `fillers` | A list of accounts that will bid and fill on auctions. | +| `interestFillerAddress` | A contract address used to help fill interest auctions. | +| `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!** | +| `pools` | A list of pool configs that dictates what pools are monitored | | `priceSources` | (Optional) A list of assets that will have prices sourced from exchanges instead of the pool oracle. | | `profits` | (Optional) A list of auction profits to define different profit percentages used for matching auctions. | `slackWebhook` | (Optional) A slack webhook URL to post updates to (https://hooks.slack.com/services/). Leave undefined if no webhooks are required. | | `discordWebhook` | (Optional) A Discord webhook URL to post updates to. Leave undefined if no webhooks are required. | +#### Pool Config -#### Fillers - -The `fillers` array contains configurations for individual filler accounts. The account chosen to fill an auction is the first filler in the list that supports all bid and lot assets in the auction. Each filler has the following properties: - -| Field | Description | -|-------|-------------| -| `name` | A unique name for this filler account. Used in logs and slack notifications. | -| `keypair` | The secret key for this filler account. **Keep this secret and secure!** | -| `primaryAsset` | The primary asset the filler will use as collateral in the pool. | -| `defaultProfitPct` | The default profit percentage required for the filler to bid on an auction, as a decimal. (e.g. 0.08 = 8%) | -| `supportedPools` | An array of configs that control what pools the filler can interact | -| `supportedBid` | An array of asset addresses that this filler bot is allowed to bid with. Bids are taken as additional liabilities (dTokens) for liquidation and bad debt auctions, and tokens for interest auctions. Must include the `backstopTokenAddress` to bid on interest auctions. | -| `supportedLot` | An array of asset addresses that this filler bot is allowed to receive. Lots are given as collateral (bTokens) for liquidation auctions and tokens for interest and bad debt auctions. The filler should have trustlines to all assets that are Stellar assets. Must include `backstopTokenAddress` to bid on bad debt auctions. | - -#### Pool Filler Configs -The `PoolFillerConfig` array contains configurations for pools that are to be monitored. +The `pools` array contains configurations for individual pools to monitor. Each pool configuration has the following properties: | Field | Description | |-------|-------------| -| `poolAddress` | The address of the pool | -| `primaryAsset` | The primary asset that will be used as collateral in the pool. | +| `poolAddress` | The address of the pool to monitor. | | `minPrimaryCollateral` | The minimum amount of the primary asset that is maintained as collateral in the pool. | -| `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions, as calculated by `collateral / liabilities`. | +| `primaryAsset` | The primary asset the bot will use as collateral in the pool. | +| `minHealthFactor` | The minimum health factor the bot will take on during liquidation and bad debt auctions, as calculated by `collateral / liabilities`. | +| `defaultProfitPct` | The default profit percentage required to bid on an auction, as a decimal. (e.g. 0.08 = 8%) | | `forceFill` | Boolean flag to indicate if the bot should force fill auctions even if profit expectations aren't met to ensure pool health. | +| `supportedBid` | An array of asset addresses that the bot is allowed to bid with. Bids are taken as additional liabilities (dTokens) for liquidation and bad debt auctions, and tokens for interest auctions. Must include the `backstopTokenAddress` to bid on interest auctions. | +| `supportedLot` | An array of asset addresses that the bot is allowed to receive. Lots are given as collateral (bTokens) for liquidation auctions and tokens for interest and bad debt auctions. The bot should have trustlines to all assets that are Stellar assets. Must include `backstopTokenAddress` to bid on bad debt auctions. | #### Price Sources diff --git a/example.config.json b/example.config.json index 2d10da9..5a40e2e 100644 --- a/example.config.json +++ b/example.config.json @@ -7,22 +7,17 @@ "backstopTokenAddress": "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM", "usdcAddress": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", "blndAddress": "CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY", - "keypair": "S...", - "pools": ["C....", "C...."], - "fillers": [ + "interestFillerAddress": "C...", + "workerKeypair": "S...", + "fillerKeypair": "S...", + "pools": [ { - "name": "example-liquidator", - "keypair": "S...", + "poolAddress": "CDVQVKOY2YSXS2IC7KN6MNASSHPAO7UN2UR2ON4OI2SKMFJNVAMDX6DP", + "primaryAsset": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "minPrimaryCollateral": "1000000000000", + "minHealthFactor": 1.5, "defaultProfitPct": 0.1, - "supportedPools": [ - { - "poolAddress": "CDVQVKOY2YSXS2IC7KN6MNASSHPAO7UN2UR2ON4OI2SKMFJNVAMDX6DP", - "primaryAsset": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", - "minPrimaryCollateral": "1000000000000", - "minHealthFactor": 1.5, - "forceFill": true - } - ], + "forceFill": true, "supportedBid": [ "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM", "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", @@ -39,13 +34,9 @@ { "profitPct": 0.05, "supportedBid": [ - "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", - "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75" + "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM" ], - "supportedLot": [ - "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", - "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75" - ] + "supportedLot": ["*"] } ], "priceSources": [ diff --git a/package-lock.json b/package-lock.json index 72d0ac3..4fe31e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "auctioneer-bot", - "version": "2.1.0-beta", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auctioneer-bot", - "version": "2.1.0-beta", + "version": "3.0.0", "license": "MIT", "dependencies": { "@blend-capital/blend-sdk": "3.2.1", - "@stellar/stellar-sdk": "14.1.1", - "better-sqlite3": "^11.1.2", - "winston": "^3.13.1", + "@stellar/stellar-sdk": "14.3.2", + "better-sqlite3": "^12.4.1", + "winston": "^3.18.3", "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { "@types/amqplib": "^0.10.5", - "@types/better-sqlite3": "^7.6.11", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.12", "jest": "^29.7.0", "prettier": "^3.3.3", @@ -472,6 +472,25 @@ "follow-redirects": ">=1.15.6" } }, + "node_modules/@blend-capital/blend-sdk/node_modules/@stellar/stellar-sdk": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.1.1.tgz", + "integrity": "sha512-yu9E9fENEOgt26U/YaApQUUn6TRRhnEzzEOey3y43Nf4l08nbUmlzWYLMl9lcEzEilM68D3ENnEWxBuPylKLQQ==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.0.0", + "axios": "^1.8.4", + "bignumber.js": "^9.3.1", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -481,11 +500,12 @@ } }, "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", "dependencies": { - "colorspace": "1.1.x", + "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } @@ -892,6 +912,16 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", @@ -899,9 +929,9 @@ "license": "Apache-2.0" }, "node_modules/@stellar/stellar-base": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.0.0.tgz", - "integrity": "sha512-CM84WNbj1GoB4FSWof4In60I6+m5ja0jbUFGKFmpYxabbgiU3Nmf29k9ZM9rkFwdyApgG2kFrB5WEwwoOHSmVA==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.0.2.tgz", + "integrity": "sha512-2/zQLw3kwHOn4jka7pZDwu24++MDvW0gthuPINF2gCNl7V8LwjmkLTuZ/eUMJjwfo6uDpujgrCkSy9JFrgdVzg==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.6", @@ -916,13 +946,14 @@ } }, "node_modules/@stellar/stellar-sdk": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.1.1.tgz", - "integrity": "sha512-yu9E9fENEOgt26U/YaApQUUn6TRRhnEzzEOey3y43Nf4l08nbUmlzWYLMl9lcEzEilM68D3ENnEWxBuPylKLQQ==", + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.3.2.tgz", + "integrity": "sha512-w2l4cy9hNLX7KeBTzluBioBVJzuekjBNdTi97ldFoQuv9A7rYxx18YFJOqQBrLuuBjowk2WeF1cmSn/90qEiow==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^14.0.0", - "axios": "^1.8.4", + "@stellar/stellar-base": "^14.0.2", + "axios": "^1.12.2", "bignumber.js": "^9.3.1", "eventsource": "^2.0.2", "feaxios": "^0.0.23", @@ -985,10 +1016,11 @@ } }, "node_modules/@types/better-sqlite3": { - "version": "7.6.11", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.11.tgz", - "integrity": "sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==", + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -1159,9 +1191,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1311,13 +1343,17 @@ ] }, "node_modules/better-sqlite3": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.1.2.tgz", - "integrity": "sha512-gujtFwavWU4MSPT+h9B+4pkvZdyOUkH54zgLdIrMmmmd4ZqiBIrRNBzNzYVFO417xo882uP5HBu4GjOfaSrIQw==", + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", + "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, + "license": "MIT", "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" } }, "node_modules/bignumber.js": { @@ -1643,12 +1679,16 @@ "dev": true }, "node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", + "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", + "license": "MIT", "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "color-convert": "^3.0.1", + "color-string": "^2.0.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/color-convert": { @@ -1666,37 +1706,49 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", + "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", + "license": "MIT", "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", + "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "license": "MIT", + "engines": { + "node": ">=12.20" } }, "node_modules/color/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", + "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", + "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" } }, "node_modules/color/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", + "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "license": "MIT", + "engines": { + "node": ">=12.20" } }, "node_modules/combined-stream": { @@ -1929,7 +1981,8 @@ "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" }, "node_modules/end-of-stream": { "version": "1.4.4", @@ -3327,10 +3380,11 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -3381,7 +3435,8 @@ "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" }, "node_modules/leven": { "version": "3.1.0", @@ -3417,9 +3472,10 @@ "dev": true }, "node_modules/logform": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.1.tgz", - "integrity": "sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", @@ -4205,19 +4261,6 @@ "simple-concat": "^1.0.0" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -4380,9 +4423,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -4423,7 +4466,8 @@ "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", @@ -4432,9 +4476,9 @@ "dev": true }, "node_modules/to-buffer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.1.tgz", - "integrity": "sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "license": "MIT", "dependencies": { "isarray": "^2.0.5", @@ -4697,21 +4741,22 @@ } }, "node_modules/winston": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.13.1.tgz", - "integrity": "sha512-SvZit7VFNvXRzbqGHsv5KSmgbEYR5EiQfDAL9gxYkRqa934Hnk++zze0wANKtMHcy/gI4W/3xmSDwlhf865WGw==", + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", + "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", + "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", - "logform": "^2.6.0", + "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", - "winston-transport": "^4.7.0" + "winston-transport": "^4.9.0" }, "engines": { "node": ">= 12.0.0" @@ -4735,11 +4780,12 @@ } }, "node_modules/winston-transport": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.1.tgz", - "integrity": "sha512-wQCXXVgfv/wUPOfb2x0ruxzwkcZfxcktz6JIMUaPLmcNhO4bZTwA/WtDWK74xV3F2dKu8YadrFv0qhwYjVEwhA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", "dependencies": { - "logform": "^2.6.1", + "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" }, diff --git a/package.json b/package.json index 7cb22a3..382cde1 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "auctioneer-bot", - "version": "2.1.0-beta", + "version": "3.0.0", "main": "index.js", "type": "module", "scripts": { "build": "tsc", - "build:docker-arm": "npm run build && docker buildx build --platform=linux/arm64 -t auctioneer-bot-v2-arm .", - "build:docker-x86": "npm run build && docker buildx build --platform=linux/amd64 -t auctioneer-bot-v2-x86 .", + "build:docker-arm": "npm run build && docker buildx build --platform=linux/arm64 -t auctioneer-bot-v${npm_package_version}-arm .", + "build:docker-x86": "npm run build && docker buildx build --platform=linux/amd64 -t auctioneer-bot-v${npm_package_version}-x86 .", "test": "jest --config jest.config.cjs" }, "author": "gm@script3.io", @@ -17,7 +17,7 @@ }, "devDependencies": { "@types/amqplib": "^0.10.5", - "@types/better-sqlite3": "^7.6.11", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.12", "jest": "^29.7.0", "prettier": "^3.3.3", @@ -26,9 +26,9 @@ }, "dependencies": { "@blend-capital/blend-sdk": "3.2.1", - "@stellar/stellar-sdk": "14.1.1", - "better-sqlite3": "^11.1.2", - "winston": "^3.13.1", + "@stellar/stellar-sdk": "14.3.2", + "better-sqlite3": "^12.4.1", + "winston": "^3.18.3", "winston-daily-rotate-file": "^5.0.0" } } diff --git a/src/auction.ts b/src/auction.ts index 838d500..2b6ed90 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -8,7 +8,7 @@ import { RequestType, } from '@blend-capital/blend-sdk'; import { getFillerAvailableBalances, getFillerProfitPct } from './filler.js'; -import { APP_CONFIG, Filler } from './utils/config.js'; +import { APP_CONFIG, PoolConfig } from './utils/config.js'; import { AuctioneerDatabase } from './utils/db.js'; import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; @@ -19,9 +19,9 @@ export interface AuctionFill { block: number; // The percent of the auction to fill percent: number; - // The expected lot value paid by the filler + // The expected lot value the filler will receive lotValue: number; - // The expected bid value the filler will receive + // The expected bid value the filler will pay bidValue: number; // The requests to fill the auction requests: Request[]; @@ -35,20 +35,19 @@ export interface AuctionValue { } export async function calculateAuctionFill( - poolId: string, - filler: Filler, + poolConfig: PoolConfig, auction: Auction, nextLedger: number, sorobanHelper: SorobanHelper, db: AuctioneerDatabase ): Promise { try { - const pool = await sorobanHelper.loadPool(poolId); - const poolOracle = await sorobanHelper.loadPoolOracle(poolId); + const pool = await sorobanHelper.loadPool(poolConfig.poolAddress); + const poolOracle = await sorobanHelper.loadPoolOracle(poolConfig.poolAddress); const auctionValue = await calculateAuctionValue(auction, pool, poolOracle, sorobanHelper, db); return await calculateBlockFillAndPercent( - filler, + poolConfig, auction, auctionValue, pool, @@ -65,14 +64,14 @@ export async function calculateAuctionFill( /** * Calculate the block fill and fill percent for a given auction. * - * @param filler - The filler to calculate the block fill for + * @param poolConfig - The pool configuration to calculate the block fill for * @param auction - The auction to calculate the fill for * @param auctionValue - The calculate value of the base auction * @param nextLedger - The next ledger number * @param sorobanHelper - The soroban helper to use for the calculation */ export async function calculateBlockFillAndPercent( - filler: Filler, + poolConfig: PoolConfig, auction: Auction, auctionValue: AuctionValue, pool: Pool, @@ -83,11 +82,6 @@ export async function calculateBlockFillAndPercent( let fillBlockDelay = 0; let fillPercent = 100; let requests: Request[] = []; - const fillerConfig = filler.supportedPools.find((config) => config.poolAddress === pool.id); - if (fillerConfig === undefined) { - logger.error(`Unable to find filler config for pool: ${pool.id}`); - throw new Error(`Unable to find filler config for pool: ${pool.id}`); - } // get relevant assets for the auction const relevant_assets = []; @@ -95,18 +89,17 @@ export async function calculateBlockFillAndPercent( case AuctionType.Liquidation: relevant_assets.push(...Array.from(auction.data.lot.keys())); relevant_assets.push(...Array.from(auction.data.bid.keys())); - relevant_assets.push(fillerConfig.primaryAsset); + relevant_assets.push(poolConfig.primaryAsset); break; case AuctionType.Interest: - relevant_assets.push(APP_CONFIG.backstopTokenAddress); + relevant_assets.push(APP_CONFIG.usdcAddress); break; case AuctionType.BadDebt: relevant_assets.push(...Array.from(auction.data.bid.keys())); - relevant_assets.push(fillerConfig.primaryAsset); + relevant_assets.push(poolConfig.primaryAsset); break; } const fillerBalances = await getFillerAvailableBalances( - filler, [...new Set(relevant_assets)], sorobanHelper ); @@ -115,7 +108,8 @@ export async function calculateBlockFillAndPercent( let { effectiveCollateral, effectiveLiabilities, lotValue, bidValue } = auctionValue; // find the block delay where the auction meets the required profit percentage - const profitPercent = getFillerProfitPct(filler, APP_CONFIG.profits ?? [], auction.data); + const profitPercent = getFillerProfitPct(poolConfig, auction.data); + if (lotValue >= bidValue * (1 + profitPercent)) { const minLotAmount = bidValue * (1 + profitPercent); fillBlockDelay = 200 - (lotValue - minLotAmount) / (lotValue / 200); @@ -125,7 +119,7 @@ export async function calculateBlockFillAndPercent( } fillBlockDelay = Math.min(Math.max(Math.ceil(fillBlockDelay), 0), 400); // apply force fill auction boundries to profit calculations - if (fillerConfig.forceFill) { + if (poolConfig.forceFill) { fillBlockDelay = Math.min(fillBlockDelay, 350); } @@ -139,23 +133,22 @@ export async function calculateBlockFillAndPercent( const [scaledAuction] = auction.scale(auction.data.block + fillBlockDelay, 100); - // require that the filler can fully fill interest auctions + // filler waits until they can fully fill the interest auction if (auction.type === AuctionType.Interest) { - const cometLpTokenBalance = fillerBalances.get(APP_CONFIG.backstopTokenAddress) ?? 0n; - const cometLpBid = scaledAuction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n; - if (cometLpBid > cometLpTokenBalance) { - const additionalCometLp = FixedMath.toFloat(cometLpBid - cometLpTokenBalance, 7); - const baseCometLpBid = auction.data.bid.get(APP_CONFIG.backstopTokenAddress) ?? 0n; - const bidStepSize = FixedMath.toFloat(baseCometLpBid, 7) / 200; - if (additionalCometLp >= 0 && bidStepSize > 0) { - const additionalDelay = Math.ceil(additionalCometLp / bidStepSize); + const usdcBalance = FixedMath.toFloat(fillerBalances.get(APP_CONFIG.usdcAddress) ?? 0n, 7); + const usdcMaxIn = auctionValue.bidValue * bidScalar * 1.01; // allow for 1% slippage from mint price + if (usdcMaxIn > usdcBalance) { + const additionalUsdcNeeded = usdcMaxIn - usdcBalance; + const usdcStepSize = auctionValue.bidValue / 200; + if (additionalUsdcNeeded >= 0 && usdcStepSize > 0) { + const additionalDelay = Math.ceil(additionalUsdcNeeded / usdcStepSize); fillBlockDelay = Math.min(400, fillBlockDelay + additionalDelay); } } } else if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) { const { estimate: fillerPositionEstimates } = await sorobanHelper.loadUserPositionEstimate( pool.id, - filler.keypair.publicKey() + APP_CONFIG.fillerKeypair.publicKey() ); let canFillWithSafeHF = false; let iterations = 0; @@ -170,7 +163,7 @@ export async function calculateBlockFillAndPercent( // inflate minHealthFactor slightly, to allow for the unwind logic to unwind looped positions safely const additionalLiabilities = effectiveLiabilities * bidScalar * (fillPercent / 100); const additionalCollateral = effectiveCollateral * lotScalar * (fillPercent / 100); - const safeHealthFactor = fillerConfig.minHealthFactor * 1.1; + const safeHealthFactor = poolConfig.minHealthFactor * 1.1; let limitToHF = (fillerPositionEstimates.totalEffectiveCollateral + additionalCollateral) / safeHealthFactor - @@ -224,9 +217,9 @@ export async function calculateBlockFillAndPercent( if (limitToHF < 0) { // if we still are under the health factor, we need to try and add more of the fillers primary asset as collateral - const primaryBalance = loopFillerBalances.get(fillerConfig.primaryAsset) ?? 0n; - const primaryReserve = pool.reserves.get(fillerConfig.primaryAsset); - const primaryOraclePrice = poolOracle.getPriceFloat(fillerConfig.primaryAsset); + const primaryBalance = loopFillerBalances.get(poolConfig.primaryAsset) ?? 0n; + const primaryReserve = pool.reserves.get(poolConfig.primaryAsset); + const primaryOraclePrice = poolOracle.getPriceFloat(poolConfig.primaryAsset); if ( primaryReserve !== undefined && primaryOraclePrice !== undefined && @@ -244,7 +237,7 @@ export async function calculateBlockFillAndPercent( collateralAdded += collateral; requests.push({ request_type: RequestType.SupplyCollateral, - address: fillerConfig.primaryAsset, + address: poolConfig.primaryAsset, amount: FixedMath.toFixed(primaryDeposit, primaryReserve.config.decimals), }); } @@ -382,7 +375,14 @@ export async function calculateAuctionValue( `Unexpected bad debt auction. Lot contains asset other than the backstop token: ${assetId}` ); } - lotValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); + const lpTokenValue = await sorobanHelper.simLPTokensToUSDC(amount); + if (lpTokenValue !== undefined) { + lotValue += FixedMath.toFloat(lpTokenValue, 7); + } else { + // assume 2% slippage on spot price + const backstopToken = await sorobanHelper.loadBackstopToken(); + lotValue += FixedMath.toFloat(amount, 7) * (backstopToken.lpTokenPrice * 0.98); + } } else { throw new Error(`Failed to value lot asset: ${assetId}`); } @@ -407,7 +407,14 @@ export async function calculateAuctionValue( `Unexpected interest auction. Bid contains asset other than the backstop token: ${assetId}` ); } - bidValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); + const lpTokenValue = await sorobanHelper.simLPTokensGetUSDCIn(amount); + if (lpTokenValue !== undefined) { + bidValue += FixedMath.toFloat(lpTokenValue, 7); + } else { + // assume 2% slippage on deposit + const backstopToken = await sorobanHelper.loadBackstopToken(); + bidValue += FixedMath.toFloat(amount, 7) * (backstopToken.lpTokenPrice * 1.02); + } } else { throw new Error(`Failed to value bid asset: ${assetId}`); } @@ -415,23 +422,3 @@ export async function calculateAuctionValue( return { effectiveCollateral, effectiveLiabilities, lotValue, bidValue }; } - -/** - * Value an amount of backstop tokens in USDC. - * @param sorobanHelper - The soroban helper to use for the calculation - * @param amount - The amount of backstop tokens to value - * @returns The value of the backstop tokens in USDC - */ -export async function valueBackstopTokenInUSDC( - sorobanHelper: SorobanHelper, - amount: bigint -): Promise { - // attempt to value via a single sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(APP_CONFIG.backstopAddress, amount); - if (lpTokenValue !== undefined) { - return FixedMath.toFloat(lpTokenValue, 7); - } else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - return FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; - } -} diff --git a/src/bidder_handler.ts b/src/bidder_handler.ts index 211c95a..fa62c0f 100644 --- a/src/bidder_handler.ts +++ b/src/bidder_handler.ts @@ -1,10 +1,5 @@ import { calculateAuctionFill } from './auction.js'; -import { - AddAllowance, - AuctionBid, - BidderSubmissionType, - BidderSubmitter, -} from './bidder_submitter.js'; +import { AuctionBid, BidderSubmissionType, BidderSubmitter } from './bidder_submitter.js'; import { AppEvent, EventType } from './events.js'; import { APP_CONFIG } from './utils/config.js'; import { AuctioneerDatabase, AuctionType } from './utils/db.js'; @@ -40,36 +35,28 @@ export class BidderHandler { for (let auctionEntry of auctions) { try { - const filler = APP_CONFIG.fillers.find( - (f) => f.keypair.publicKey() === auctionEntry.filler - ); - if (filler === undefined) { - logger.error(`Filler not found for auction: ${stringify(auctionEntry)}`); + if (this.submissionQueue.containsAuction(auctionEntry)) { + // auction already being bid on continue; } - if (this.submissionQueue.containsAuction(auctionEntry)) { - // auction already being bid on + const poolConfig = APP_CONFIG.pools.find( + (p) => p.poolAddress === auctionEntry.pool_id + ); + if (!poolConfig) { + logger.error( + `Pool config not found for auction entry: ${stringify(auctionEntry)}. Deleting auction: ${stringify(auctionEntry)}` + ); + this.db.deleteAuctionEntry( + auctionEntry.pool_id, + auctionEntry.user_id, + auctionEntry.auction_type + ); continue; } const ledgersToFill = auctionEntry.fill_block - nextLedger; if (auctionEntry.fill_block === 0 || ledgersToFill <= 5 || ledgersToFill % 10 === 0) { - // Check if the filler has an active allowance for backstop token - if ( - auctionEntry.auction_type === AuctionType.Interest && - auctionEntry.fill_block === 0 - ) { - let allowanceCheck: AddAllowance = { - type: BidderSubmissionType.ADD_ALLOWANCE, - filler: filler, - assetId: APP_CONFIG.backstopTokenAddress, - spender: APP_CONFIG.backstopAddress, - currLedger: appEvent.ledger, - }; - this.submissionQueue.addSubmission(allowanceCheck, 4); - } - // recalculate the auction const auction = await this.sorobanHelper.loadAuction( auctionEntry.pool_id, @@ -88,8 +75,7 @@ export class BidderHandler { continue; } const fill = await calculateAuctionFill( - auctionEntry.pool_id, - filler, + poolConfig, auction, nextLedger, this.sorobanHelper, @@ -97,7 +83,7 @@ export class BidderHandler { ); const logMessage = `Auction Calculation\n` + - `Filler: ${filler.name}\n` + + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + `Type: ${AuctionType[auction.type]}\n` + `Pool: ${auctionEntry.pool_id}\n` + `User: ${auction.user}\n` + @@ -114,7 +100,6 @@ export class BidderHandler { if (auctionEntry.fill_block <= nextLedger) { let submission: AuctionBid = { type: BidderSubmissionType.BID, - filler: filler, auctionEntry: auctionEntry, }; this.submissionQueue.addSubmission(submission, 10); diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index dca2e79..eed313d 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -1,21 +1,21 @@ -import { FixedMath, PoolContractV2 } from '@blend-capital/blend-sdk'; -import { Address, Contract, nativeToScVal, rpc } from '@stellar/stellar-sdk'; +import { FixedMath, PoolContractV2, ScaledAuction } from '@blend-capital/blend-sdk'; +import { rpc, scValToNative } from '@stellar/stellar-sdk'; import { calculateAuctionFill } from './auction.js'; import { getFillerAvailableBalances, managePositions } from './filler.js'; -import { APP_CONFIG, Filler } from './utils/config.js'; +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 { SorobanHelper } from './utils/soroban_helper.js'; import { SubmissionQueue } from './utils/submission_queue.js'; +import { InterestFillerContract } from './utils/interest_filler.js'; -export type BidderSubmission = AuctionBid | FillerUnwind | AddAllowance; +export type BidderSubmission = AuctionBid | FillerUnwind; export enum BidderSubmissionType { BID = 'bid', UNWIND = 'unwind', - ADD_ALLOWANCE = 'add_allowance', } export interface BaseBidderSubmission { @@ -24,25 +24,13 @@ export interface BaseBidderSubmission { export interface AuctionBid extends BaseBidderSubmission { type: BidderSubmissionType.BID; - filler: Filler; auctionEntry: AuctionEntry; } export interface FillerUnwind extends BaseBidderSubmission { type: BidderSubmissionType.UNWIND; poolId: string; - filler: Filler; -} - -/** - * Event to check for allowance updates. - */ -export interface AddAllowance extends BaseBidderSubmission { - type: BidderSubmissionType.ADD_ALLOWANCE; - filler: Filler; - assetId: string; - spender: string; - currLedger: number; + filledAuction: ScaledAuction; } export class BidderSubmitter extends SubmissionQueue { @@ -78,8 +66,6 @@ export class BidderSubmitter extends SubmissionQueue { return this.submitBid(sorobanHelper, submission); case BidderSubmissionType.UNWIND: return this.submitUnwind(sorobanHelper, submission); - case BidderSubmissionType.ADD_ALLOWANCE: - return this.submitAddAllowance(sorobanHelper, submission); default: logger.error(`Invalid submission type: ${stringify(submission)}`); // consume the submission @@ -109,9 +95,16 @@ export class BidderSubmitter extends SubmissionQueue { return true; } + const poolConfig = APP_CONFIG.pools.find( + (p) => p.poolAddress === auctionBid.auctionEntry.pool_id + ); + if (!poolConfig) { + // allow bidder handler to re-process the auction entry + return true; + } + const fill = await calculateAuctionFill( - auctionBid.auctionEntry.pool_id, - auctionBid.filler, + poolConfig, auction, nextLedger, sorobanHelper, @@ -121,21 +114,41 @@ 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 + // include high inclusion fee if the estimated 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({ - from: auctionBid.auctionEntry.filler, - spender: auctionBid.auctionEntry.filler, - to: auctionBid.auctionEntry.filler, - requests: fill.requests, - }), - auctionBid.filler.keypair - ); + let result; + // use the interest auction filler if it exists and the auction is an interest auction + if ( + APP_CONFIG.interestFillerAddress !== undefined && + APP_CONFIG.interestFillerAddress !== '' && + auctionBid.auctionEntry.auction_type == AuctionType.Interest && + fill.percent === 100 + ) { + logger.info(`Using interest auction filler contract ${APP_CONFIG.interestFillerAddress}`); + const filler_contract = new InterestFillerContract(APP_CONFIG.interestFillerAddress); + result = await sorobanHelper.submitTransaction( + filler_contract.fill_interest( + auctionBid.auctionEntry.filler, + auctionBid.auctionEntry.pool_id, + fill.percent, + FixedMath.toFixed(fill.bidValue * 1.01) + ), + APP_CONFIG.fillerKeypair + ); + } else { + result = await sorobanHelper.submitTransaction( + pool.submit({ + from: auctionBid.auctionEntry.filler, + spender: auctionBid.auctionEntry.filler, + to: auctionBid.auctionEntry.filler, + requests: fill.requests, + }), + APP_CONFIG.fillerKeypair + ); + } const [scaledAuction] = auction.scale(result.ledger, fill.percent); this.db.setFilledAuctionEntry({ tx_hash: result.txHash, @@ -154,8 +167,8 @@ export class BidderSubmitter extends SubmissionQueue { this.addSubmission( { type: BidderSubmissionType.UNWIND, - filler: auctionBid.filler, poolId: auctionBid.auctionEntry.pool_id, + filledAuction: scaledAuction, }, 2 ); @@ -164,7 +177,7 @@ export class BidderSubmitter extends SubmissionQueue { `Type: ${AuctionType[auctionBid.auctionEntry.auction_type]}\n` + `Pool: ${auctionBid.auctionEntry.pool_id}\n` + `User: ${auctionBid.auctionEntry.user_id}\n` + - `Filler: ${auctionBid.filler.name}\n` + + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + `Fill Percent ${fill.percent}\n` + `Ledger Fill Delta ${result.ledger - auctionBid.auctionEntry.start_block}\n` + `Hash ${result.txHash}\n`; @@ -174,10 +187,10 @@ export class BidderSubmitter extends SubmissionQueue { } else { logger.info( `Fill ledger not reached for auction bid\n` + - `Type: ${auctionBid.auctionEntry.auction_type}\n` + - `Pool: ${auctionBid.auctionEntry.pool_id}\n` + - `User: ${auctionBid.auctionEntry.user_id}\n` + - `Fill Ledger: ${fill.block} Next Ledger: ${nextLedger}` + `Type: ${auctionBid.auctionEntry.auction_type}\n` + + `Pool: ${auctionBid.auctionEntry.pool_id}\n` + + `User: ${auctionBid.auctionEntry.user_id}\n` + + `Fill Ledger: ${fill.block} Next Ledger: ${nextLedger}` ); } // allow bidder handler to re-process the auction entry @@ -188,7 +201,7 @@ export class BidderSubmitter extends SubmissionQueue { `Type: ${AuctionType[auctionBid.auctionEntry.auction_type]}\n` + `Pool: ${auctionBid.auctionEntry.pool_id}\n` + `User: ${auctionBid.auctionEntry.user_id}\n` + - `Filler: ${auctionBid.filler.name}\n` + + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + `Error: ${stringify(serializeError(e))}`; await sendNotification(logMessage, true); logger.error(logMessage, e); @@ -197,155 +210,122 @@ export class BidderSubmitter extends SubmissionQueue { } async submitUnwind(sorobanHelper: SorobanHelper, fillerUnwind: FillerUnwind): Promise { - logger.info(`Submitting unwind for filler ${fillerUnwind.filler.keypair.publicKey()}`); - const filler_pubkey = fillerUnwind.filler.keypair.publicKey(); - const fillerPrimaryAsset = fillerUnwind.filler.supportedPools.find( - (pool) => pool.poolAddress === fillerUnwind.poolId - )?.primaryAsset; - if (!fillerPrimaryAsset) { - logger.error( - `Filler ${fillerUnwind.filler.name} does not support pool: ${fillerUnwind.poolId}` - ); - return false; - } - const filler_tokens = [ - ...new Set([ - fillerPrimaryAsset, - ...fillerUnwind.filler.supportedBid, - ...fillerUnwind.filler.supportedLot, - ]), - ]; - const pool = await sorobanHelper.loadPool(fillerUnwind.poolId); - const poolOracle = await sorobanHelper.loadPoolOracle(fillerUnwind.poolId); - const filler_user = await sorobanHelper.loadUser(fillerUnwind.poolId, filler_pubkey); - const filler_balances = await getFillerAvailableBalances( - fillerUnwind.filler, - filler_tokens, - sorobanHelper + logger.info( + `Submitting unwind for filler ${APP_CONFIG.fillerKeypair.publicKey()} for auction type ${AuctionType[fillerUnwind.filledAuction.type]} in pool ${fillerUnwind.poolId}` ); - // Unwind the filler one step at a time. If the filler is not unwound, place another `FillerUnwind` event on the submission queue. - // To unwind the filler, the following actions will be taken in order: - // 1. Unwind the filler's pool position by paying off all liabilities with current balances and withdrawing all possible collateral, - // down to either the min_collateral or min_health_factor. - // TODO: Add trading functionality for 2, 3 - // 2. If no positions can be modified, and the filler still has outstanding liabilities, attempt to purchase the liability tokens - // with USDC. - // 3. If there are no liabilities, attempt to sell un-needed tokens for USDC - // 4. If this case is reached, stop sending unwind events for the filler. - - // 1 - let requests = managePositions( - fillerUnwind.filler, - pool, - poolOracle, - filler_user.positions, - filler_balances - ); - if (requests.length > 0) { - logger.info('Unwind found positions to manage', requests); - // some positions to manage - submit the transaction - const pool = new PoolContractV2(fillerUnwind.poolId); - const result = await sorobanHelper.submitTransaction( - pool.submit({ - from: filler_pubkey, - spender: filler_pubkey, - to: filler_pubkey, - requests: requests, - }), - fillerUnwind.filler.keypair - ); - logger.info( - `Successful unwind for filler: ${fillerUnwind.filler.name}\n` + - `Pool: ${fillerUnwind.poolId}\n` + - `Ledger: ${result.ledger}\n` + - `Hash: ${result.txHash}` - ); - this.addSubmission( - { - type: BidderSubmissionType.UNWIND, - filler: fillerUnwind.filler, - poolId: fillerUnwind.poolId, - }, - 2 - ); - 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 sendNotification(logMessage); + switch (fillerUnwind.filledAuction.type) { + case AuctionType.Interest: { + // claim tokens from the interest auction filler contract + const lot_tokens = Array.from(fillerUnwind.filledAuction.data.lot.keys()); + const interest_filler_contract = new InterestFillerContract( + APP_CONFIG.interestFillerAddress + ); + const op = interest_filler_contract.claim(APP_CONFIG.fillerKeypair.publicKey(), lot_tokens); + const result = await sorobanHelper.submitTransaction(op, APP_CONFIG.fillerKeypair); + let returnVal = undefined; + if (result.returnValue !== undefined) { + returnVal = scValToNative(result.returnValue); + } + logger.info( + `Successful claim from interest filler contract for filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + + `Pool: ${fillerUnwind.poolId}\n` + + `Ledger: ${result.ledger}\n` + + `Hash: ${result.txHash}\n` + + `Return Value: ${stringify(returnVal)}` + ); + break; } - } + case AuctionType.Liquidation: + case AuctionType.BadDebt: { + const filler_pubkey = APP_CONFIG.fillerKeypair.publicKey(); + const poolConfig = APP_CONFIG.pools.find( + (pool) => pool.poolAddress === fillerUnwind.poolId + ); + if (!poolConfig) { + logger.error( + `Filler ${APP_CONFIG.fillerKeypair.publicKey()} does not support pool: ${fillerUnwind.poolId}` + ); + return false; + } + const pool = await sorobanHelper.loadPool(fillerUnwind.poolId); + const poolOracle = await sorobanHelper.loadPoolOracle(fillerUnwind.poolId); + const filler_user = await sorobanHelper.loadUser(fillerUnwind.poolId, filler_pubkey); + const filler_tokens = [...new Set([poolConfig.primaryAsset, ...pool.metadata.reserveList])]; + const filler_balances = await getFillerAvailableBalances(filler_tokens, sorobanHelper); - // 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` + - `Filler: ${fillerUnwind.filler.name}\n` + - `Pool: ${fillerUnwind.poolId}\n` + - `Positions: ${stringify(filler_user.positions, 2)}`; - logger.info(logMessage); - await sendNotification(logMessage); - return true; - } + // Unwind the filler one step at a time. If the filler is not unwound, place another `FillerUnwind` event on the submission queue. + // To unwind the filler, the following actions will be taken in order: + // 1. Unwind the filler's pool position by paying off all liabilities with current balances and withdrawing all possible collateral, + // down to either the min_collateral or min_health_factor. + // TODO: Add trading functionality for 2, 3 + // 2. If no positions can be modified, and the filler still has outstanding liabilities, attempt to purchase the liability tokens + // with USDC. + // 3. If there are no liabilities, attempt to sell un-needed tokens for USDC + // 4. If this case is reached, stop sending unwind events for the filler. - logger.info(`Filler has no positions to manage, stopping unwind events.`); - return true; - } + // 1 + let requests = managePositions( + poolConfig, + pool, + poolOracle, + filler_user.positions, + filler_balances + ); + if (requests.length > 0) { + logger.info('Unwind found positions to manage', requests); + // some positions to manage - submit the transaction + const pool = new PoolContractV2(fillerUnwind.poolId); + const result = await sorobanHelper.submitTransaction( + pool.submit({ + from: filler_pubkey, + spender: filler_pubkey, + to: filler_pubkey, + requests: requests, + }), + APP_CONFIG.fillerKeypair + ); + logger.info( + `Successful unwind for filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + + `Pool: ${fillerUnwind.poolId}\n` + + `Ledger: ${result.ledger}\n` + + `Hash: ${result.txHash}` + ); + this.addSubmission( + { + type: BidderSubmissionType.UNWIND, + poolId: fillerUnwind.poolId, + filledAuction: fillerUnwind.filledAuction, + }, + 2 + ); + return true; + } - async submitAddAllowance( - sorobanHelper: SorobanHelper, - allowance: AddAllowance - ): Promise { - try { - const allowanceData = await sorobanHelper.loadAllowance( - allowance.assetId, - allowance.filler.keypair.publicKey(), - allowance.spender - ); - if ( - allowanceData.amount < BigInt(100_000e7) || - allowanceData.expiration_ledger < allowance.currLedger + 17368 * 7 - ) { - const assetContract = new Contract(allowance.assetId); - const op = assetContract - .call( - 'approve', - ...[ - Address.fromString(allowance.filler.keypair.publicKey()).toScVal(), - Address.fromString(allowance.spender).toScVal(), - nativeToScVal(BigInt('18446744073709551615'), { type: 'i128' }), - nativeToScVal(allowance.currLedger + 17368 * 30 * 5, { type: 'u32' }), - ] - ) - .toXDR('base64'); - await sorobanHelper.submitTransaction(op, allowance.filler.keypair); + // 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` + + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + + `Pool: ${fillerUnwind.poolId}\n` + + `Positions: ${stringify(filler_user.positions, 2)}`; + logger.info(logMessage); + await sendNotification(logMessage); + return true; + } - const logMessage = - `Successfully updated allowance\n` + - `Filler: ${allowance.filler.name}\n` + - `Spender: ${allowance.spender}\n` + - `Asset: ${allowance.assetId}\n`; - logger.info(logMessage); - return true; // TODO: Check for error in response + logger.info(`Filler has no positions to manage, stopping unwind events.`); + break; } - return true; - } catch (e) { - return false; + default: + logger.error(`Invalid auction for unwind: ${stringify(fillerUnwind.filledAuction)}`); + return true; } + return true; } + async onDrop(submission: BidderSubmission): Promise { - let logMessage: string; + let logMessage: string = ''; switch (submission.type) { case BidderSubmissionType.BID: logMessage = @@ -355,22 +335,14 @@ export class BidderSubmitter extends SubmissionQueue { `User: ${submission.auctionEntry.user_id}\n` + `Start Block: ${submission.auctionEntry.start_block}\n` + `Fill Block: ${submission.auctionEntry.fill_block}\n` + - `Filler: ${submission.filler.name}\n`; + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n`; break; case BidderSubmissionType.UNWIND: logMessage = `Dropped filler unwind\n` + - `Filler: ${submission.filler.name}\n` + + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + `Pool: ${submission.poolId}`; break; - case BidderSubmissionType.ADD_ALLOWANCE: - logMessage = - `Dropped allowance check\n` + - `Filler: ${submission.filler.name}\n` + - `Spender: ${submission.spender}\n` + - `Asset: ${submission.assetId}\n` + - `Ledger: ${submission.currLedger}`; - break; } logger.error(logMessage); await sendNotification(logMessage); diff --git a/src/collector.ts b/src/collector.ts index f35ee78..9568fb5 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -17,7 +17,7 @@ import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendEvent } from './utils/messages.js'; import { Api } from '@stellar/stellar-sdk/rpc'; -import { APP_CONFIG } from './utils/config.js'; +import { APP_CONFIG, PoolConfig } from './utils/config.js'; let startup_ledger = 0; @@ -110,11 +110,15 @@ export async function runCollector( statusEntry.latest_ledger === 0 ? latestLedger : statusEntry.latest_ledger + 1; // if we are too far behind, start from 17270 ledgers ago (default max ledger history is 17280) start_ledger = Math.max(start_ledger, latestLedger - 17270); + if (start_ledger != latestLedger - 1) { + logger.info(`Missing ledgers detected. Processing from ${start_ledger} to ${latestLedger}`); + } let events: rpc.Api.RawGetEventsResponse; const filters = createFilter(APP_CONFIG.pools); try { events = await stellarRpc._getEvents({ startLedger: start_ledger, + endLedger: latestLedger + 1, filters: filters, limit: 100, }); @@ -127,6 +131,7 @@ export async function runCollector( ); events = await stellarRpc._getEvents({ startLedger: latestLedger, + endLedger: latestLedger + 1, filters: filters, limit: 100, }); @@ -166,12 +171,14 @@ export async function runCollector( } } -export function createFilter(pools: string[]) { +export function createFilter(pools: PoolConfig[]) { let filter: Api.EventFilter[] = []; - for (let i = 0; i < pools.length; i += 5) { + + let poolIds = pools.map((p) => p.poolAddress); + for (let i = 0; i < poolIds.length; i += 5) { filter.push({ type: 'contract', - contractIds: pools.slice(i, i + 5), + contractIds: poolIds.slice(i, i + 5), }); } return filter; diff --git a/src/events.ts b/src/events.ts index ab56886..ce03bf7 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,6 +1,6 @@ import { PoolV2Event } from '@blend-capital/blend-sdk'; export enum EventType { - VALIDATE_POOLS = 'validate_pools', + VALIDATE = 'validate', LEDGER = 'ledger', PRICE_UPDATE = 'price_update', ORACLE_SCAN = 'oracle_scan', @@ -14,7 +14,6 @@ export enum EventType { // ********* Shared ********** export type AppEvent = - | ValidatePoolsEvent | LedgerEvent | PriceUpdateEvent | OracleScanEvent @@ -50,9 +49,8 @@ export interface PoolEventEvent extends BaseEvent { // ********** Work Queue Only ********** -export interface ValidatePoolsEvent extends BaseEvent { - type: EventType.VALIDATE_POOLS; - pools: string[]; +export interface ValidateEvent extends BaseEvent { + type: EventType.VALIDATE; } /** diff --git a/src/filler.ts b/src/filler.ts index aabb9a0..8c6aa52 100644 --- a/src/filler.ts +++ b/src/filler.ts @@ -10,7 +10,7 @@ import { Reserve, } from '@blend-capital/blend-sdk'; import { Asset } from '@stellar/stellar-sdk'; -import { APP_CONFIG, AuctionProfit, Filler } from './utils/config.js'; +import { APP_CONFIG, AuctionProfit, PoolConfig } from './utils/config.js'; import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; @@ -19,58 +19,73 @@ const MAX_WITHDRAW = BigInt('9223372036854775807'); /** * Check if the filler supports bidding on the auction. - * @param filler - The filler to check + * @param poolId - The pool ID * @param auctionData - The auction data for the auction * @returns A boolean indicating if the filler cares about the auction. */ -export function canFillerBid(filler: Filler, poolId: string, auctionData: AuctionData): boolean { - return checkFillerSupport(filler, poolId, Array.from(auctionData.bid.keys()), Array.from(auctionData.lot.keys())); +export function canFillerBid(poolId: string, auctionData: AuctionData): boolean { + let poolConfig = APP_CONFIG.pools.find((p) => p.poolAddress === poolId); + if (!poolConfig) { + return false; + } + return checkFillerSupport( + poolConfig, + 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 poolConfig - The pool configuration to check * @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) { +export function checkFillerSupport(poolConfig: PoolConfig, bid: string[], lot: string[]): boolean { + // assert bid is either a wildcard or all bid assets are supported + if ( + !poolConfig.supportedBid.includes('*') && + bid.some((address) => !poolConfig.supportedBid.includes(address)) + ) { return false; } - if (bid.every((address) => filler.supportedBid.includes(address)) && - lot.every((address) => filler.supportedLot.includes(address))) { - return true; + + // assert lot is either a wildcard or all lot assets are supported + if ( + !poolConfig.supportedLot.includes('*') && + lot.some((address) => !poolConfig.supportedLot.includes(address)) + ) { + return false; } - return false; + + return true; } /** * Get the profit percentage the filler should bid at for the auction. - * @param filler - The filler + * @param poolConfig - The pool configuration for the filler * @param auctionProfits - The auction profits for the bot * @param auctionData - The auction data for the auction * @returns The profit percentage the filler should bid at, as a float where 1.0 is 100% */ -export function getFillerProfitPct( - filler: Filler, - auctionProfits: AuctionProfit[], - auctionData: AuctionData -): number { +export function getFillerProfitPct(poolConfig: PoolConfig, auctionData: AuctionData): number { let bidAssets = Array.from(auctionData.bid.keys()); let lotAssets = Array.from(auctionData.lot.keys()); + let auctionProfits = APP_CONFIG.profits ?? []; for (const profit of auctionProfits) { if ( - bidAssets.some((address) => !profit.supportedBid.includes(address)) || - lotAssets.some((address) => !profit.supportedLot.includes(address)) + (!bidAssets.includes('*') && + bidAssets.some((address) => !profit.supportedBid.includes(address))) || + (!lotAssets.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 continue; } return profit.profitPct; } - return filler.defaultProfitPct; + return poolConfig.defaultProfitPct; } /** @@ -80,11 +95,10 @@ export function getFillerProfitPct( * @param sorobanHelper - The soroban helper object */ export async function getFillerAvailableBalances( - filler: Filler, assets: string[], sorobanHelper: SorobanHelper ): Promise> { - const balances = await sorobanHelper.loadBalances(filler.keypair.publicKey(), assets); + const balances = await sorobanHelper.loadBalances(APP_CONFIG.fillerKeypair.publicKey(), assets); const xlm_address = Asset.native().contractId(APP_CONFIG.networkPassphrase); const xlm_bal = balances.get(xlm_address); if (xlm_bal !== undefined) { @@ -102,7 +116,7 @@ export async function getFillerAvailableBalances( * * Note - some buffer is applied to ensure that subsequent calls to "managePositions" does not create dust. * - * @param filler - The filler + * @param poolConfig - The pool configuration for the filler * @param pool - The pool * @param poolOracle - The pool's oracle object * @param poolUser - The filler's pool user object @@ -111,7 +125,7 @@ export async function getFillerAvailableBalances( * @returns An array of requests to be submitted to the network, or an empty array if no actions are required */ export function managePositions( - filler: Filler, + poolConfig: PoolConfig, pool: Pool, poolOracle: PoolOracle, positions: Positions, @@ -121,11 +135,6 @@ export function managePositions( const positionsEst = PositionsEstimate.build(pool, poolOracle, positions); let effectiveLiabilities = positionsEst.totalEffectiveLiabilities; let effectiveCollateral = positionsEst.totalEffectiveCollateral; - const fillerConfig = filler.supportedPools.find((config) => config.poolAddress === pool.id); - if (fillerConfig === undefined) { - logger.error(`${filler.name} filler unable to find filler config for pool: ${pool.id}`); - return requests; - } const hasLeftoverLiabilities: number[] = []; // attempt to repay any liabilities the filler has for (const [assetIndex, amount] of positions.liabilities) { @@ -163,7 +172,7 @@ 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 (fillerConfig.minHealthFactor * 1.01 > effectiveCollateral / effectiveLiabilities) { + if (poolConfig.minHealthFactor * 1.01 > effectiveCollateral / effectiveLiabilities) { return requests; } @@ -196,7 +205,7 @@ export function managePositions( collateralList.push({ reserve, price, amount, size: 0 }); } // hacky - set size to MAX for (3), to ensure it is withdrawn last - else if (reserve.assetId === fillerConfig.primaryAsset) { + else if (reserve.assetId === poolConfig.primaryAsset) { collateralList.push({ reserve, price, amount, size: Number.MAX_SAFE_INTEGER }); } else { const size = reserve.toEffectiveAssetFromBTokenFloat(amount) * price; @@ -212,12 +221,12 @@ export function managePositions( // no liabilities, withdraw the full position withdrawAmount = MAX_WITHDRAW; } else { - if (fillerConfig.minHealthFactor * 1.005 > effectiveCollateral / effectiveLiabilities) { + if (poolConfig.minHealthFactor * 1.005 > effectiveCollateral / effectiveLiabilities) { // stop withdrawing collateral if close to min health factor break; } const maxWithdraw = - (effectiveCollateral - effectiveLiabilities * fillerConfig.minHealthFactor) / + (effectiveCollateral - effectiveLiabilities * poolConfig.minHealthFactor) / (reserve.getCollateralFactor() * price); const position = reserve.toAssetFromBTokenFloat(amount); withdrawAmount = maxWithdraw > position ? MAX_WITHDRAW : FixedMath.toFixed(maxWithdraw, 7); @@ -228,12 +237,12 @@ export function managePositions( break; } // require the filler to keep at least the min collateral balance of their primary asset - if (reserve.assetId === fillerConfig.primaryAsset) { - const toMinPosition = reserve.toAssetFromBToken(amount) - fillerConfig.minPrimaryCollateral; + if (reserve.assetId === poolConfig.primaryAsset) { + const toMinPosition = reserve.toAssetFromBToken(amount) - poolConfig.minPrimaryCollateral; withdrawAmount = withdrawAmount > toMinPosition ? toMinPosition : withdrawAmount; // if withdrawAmount is less than 1% of the minPrimaryCollateral stop // this prevents dust withdraws from looping unwind events due to interest accrual - if (withdrawAmount < fillerConfig.minPrimaryCollateral / 100n) { + if (withdrawAmount < poolConfig.minPrimaryCollateral / 100n) { break; } } diff --git a/src/interest.ts b/src/interest.ts index 70262a3..ebbf330 100644 --- a/src/interest.ts +++ b/src/interest.ts @@ -3,24 +3,24 @@ 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'; +import { APP_CONFIG, PoolConfig } from './utils/config.js'; export async function checkPoolForInterestAuction( sorobanHelper: SorobanHelper, - poolId: string + poolConfig: PoolConfig ): Promise { try { - const pool = await sorobanHelper.loadPool(poolId); - const poolOracle = await sorobanHelper.loadPoolOracle(poolId); + const pool = await sorobanHelper.loadPool(poolConfig.poolAddress); + const poolOracle = await sorobanHelper.loadPoolOracle(poolConfig.poolAddress); // check if there is an existing interest auction const interestAuction = await sorobanHelper.loadAuction( - poolId, + pool.id, APP_CONFIG.backstopAddress, AuctionType.Interest ); if (interestAuction !== undefined) { - logger.info(`Interest auction already exists for pool ${poolId}`); + logger.info(`Interest auction already exists for pool ${pool.id}`); return undefined; } @@ -50,48 +50,45 @@ export async function checkPoolForInterestAuction( 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 (checkFillerSupport(poolConfig, bid, lot)) { + // found a filler - ensure it has enough backstop tokens to make the auction + const usdcBalance = await sorobanHelper.simBalance( + APP_CONFIG.usdcAddress, + APP_CONFIG.fillerKeypair.publicKey() + ); + const bidValue = FixedMath.toFloat(usdcBalance); - 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; - } + if (bidValue > totalInterest) { + logger.info( + `Creating backstop interest auction for pool ${pool.id}, value: ${totalInterest}, lot assets: ${lotAssets}` + ); + return { + type: WorkSubmissionType.AuctionCreation, + poolId: pool.id, + user: APP_CONFIG.backstopAddress, + auctionType: AuctionType.Interest, + auctionPercent: 100, + bid: [APP_CONFIG.backstopTokenAddress], + lot: lotAssets, + }; + } else { + const logMessage = + `Filler does not have enough USDC to create backstop interest auction.\n` + + `User: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + + `Balance: ${FixedMath.toFloat(usdcBalance)}\n` + + `Required: ${totalInterest}`; + logger.error(logMessage); + return undefined; } } } else { logger.info( - `No backstop interest auction needed for pool ${poolId}, value: ${totalInterest}` + `No backstop interest auction needed for pool ${pool.id}, value: ${totalInterest}` ); return undefined; } } catch (e) { - logger.error(`Error checking backstop interest in pool ${poolId}: ${e}`); + logger.error(`Error checking backstop interest in pool ${poolConfig.poolAddress}: ${e}`); return undefined; } } diff --git a/src/liquidations.ts b/src/liquidations.ts index 35e9e06..7932e8c 100644 --- a/src/liquidations.ts +++ b/src/liquidations.ts @@ -239,12 +239,26 @@ export async function scanUsers( } let submissions: WorkSubmission[] = []; - for (const pool of APP_CONFIG.pools) { - const users = userPoolMap.get(pool) || []; + for (const poolConfig of APP_CONFIG.pools) { + const users = userPoolMap.get(poolConfig.poolAddress) || []; users.push(APP_CONFIG.backstopAddress); - submissions.push( - ...(await checkUsersForLiquidationsAndBadDebt(db, sorobanHelper, pool, users)) - ); + try { + logger.info( + `Scanning ${users.length} users for liquidations in pool: ${poolConfig.poolAddress}` + ); + submissions.push( + ...(await checkUsersForLiquidationsAndBadDebt( + db, + sorobanHelper, + poolConfig.poolAddress, + users + )) + ); + } catch (e) { + const errorLog = `Error scanning for liquidations: ${poolConfig.poolAddress}\nError: ${e}`; + logger.error(errorLog); + sendNotification(errorLog); + } } return submissions; } @@ -263,6 +277,7 @@ export async function checkUsersForLiquidationsAndBadDebt( user_ids: string[] ): Promise { const pool = await sorobanHelper.loadPool(poolId); + const oracle = await sorobanHelper.loadPoolOracle(poolId); logger.info(`Checking ${user_ids.length} users for liquidations..`); let submissions: WorkSubmission[] = []; for (let user of user_ids) { @@ -296,7 +311,6 @@ export async function checkUsersForLiquidationsAndBadDebt( ) { const { estimate: poolUserEstimate, user: poolUser } = await sorobanHelper.loadUserPositionEstimate(poolId, user); - const oracle = await sorobanHelper.loadPoolOracle(poolId); updateUser(db, pool, poolUser, poolUserEstimate); if (isLiquidatable(poolUserEstimate)) { const newLiq = calculateLiquidation(pool, poolUser.positions, poolUserEstimate, oracle); @@ -324,7 +338,6 @@ export async function checkUsersForLiquidationsAndBadDebt( `User: ${user}\n` + `Error: ${e}`; logger.error(errorLog); - sendNotification(errorLog); } } return submissions; diff --git a/src/main.ts b/src/main.ts index 239cc3d..28bf401 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,14 @@ import { rpc } from '@stellar/stellar-sdk'; import { fork } from 'child_process'; import { runCollector } from './collector.js'; -import { - EventType, - OracleScanEvent, - PriceUpdateEvent, - UserRefreshEvent, - ValidatePoolsEvent, -} from './events.js'; +import { EventType, OracleScanEvent, PriceUpdateEvent, UserRefreshEvent } from './events.js'; import { PoolEventHandler } from './pool_event_handler.js'; import { APP_CONFIG } from './utils/config.js'; import { AuctioneerDatabase } from './utils/db.js'; import { logger } from './utils/logger.js'; import { sendEvent } from './utils/messages.js'; import { SorobanHelper } from './utils/soroban_helper.js'; +import { validateBot } from './validation.js'; async function main() { // spawn child processes @@ -80,13 +75,11 @@ async function main() { console.log('Auctioneer started successfully.'); - // validate pool configs on startup - const validatePoolsEvent: ValidatePoolsEvent = { - type: EventType.VALIDATE_POOLS, - timestamp: Date.now(), - pools: APP_CONFIG.pools, - }; - sendEvent(worker, validatePoolsEvent); + console.log('Validating bot against rpc...'); + + await validateBot(new SorobanHelper()); + + console.log('Bot validation complete.'); // update price on startup const priceEvent: PriceUpdateEvent = { @@ -109,7 +102,8 @@ async function main() { }; sendEvent(worker, userEvent); - collectorInterval = setInterval(async () => { + console.log('Collector polling for events...'); + while (!shutdownExpected) { try { let sorobanHelper = new SorobanHelper(); let poolEventHandler = new PoolEventHandler(db, sorobanHelper, worker); @@ -117,8 +111,10 @@ async function main() { } catch (e: any) { logger.error(`Error in collector`, e); } - }, 1000); - console.log('Collector polling for events...'); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + console.log('Collector stopped!'); } main().catch((error) => { diff --git a/src/pool_event_handler.ts b/src/pool_event_handler.ts index 1c8d28d..a6213f4 100644 --- a/src/pool_event_handler.ts +++ b/src/pool_event_handler.ts @@ -11,6 +11,7 @@ import { deadletterEvent, sendEvent } from './utils/messages.js'; import { sendNotification } from './utils/notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission } from './work_submitter.js'; + const MAX_RETRIES = 2; const RETRY_DELAY = 200; @@ -67,13 +68,13 @@ export class PoolEventHandler { * @param poolEvent - The pool event to handle */ async handlePoolEvent(poolEvent: PoolEventEvent): Promise { - const poolId = APP_CONFIG.pools.find((pool) => pool === poolEvent.event.contractId); - if (!poolId) { + const poolConfig = APP_CONFIG.pools.find((c) => c.poolAddress === poolEvent.event.contractId); + if (!poolConfig) { logger.error(`Received event from an unsupported pool: ${stringify(poolEvent.event)}`); return; } - const pool = await this.sorobanHelper.loadPool(poolId); + const pool = await this.sorobanHelper.loadPool(poolConfig.poolAddress); switch (poolEvent.event.eventType) { case PoolEventType.SupplyCollateral: case PoolEventType.WithdrawCollateral: @@ -82,65 +83,60 @@ export class PoolEventHandler { case PoolEventType.Repay: { // update the user in the db const { estimate: userPositionsEstimate, user } = - await this.sorobanHelper.loadUserPositionEstimate(poolId, poolEvent.event.from); + await this.sorobanHelper.loadUserPositionEstimate( + poolConfig.poolAddress, + poolEvent.event.from + ); updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); break; } case PoolEventType.NewAuction: { // check if the auction should be bid on by an auctioneer - let fillerFound = false; - for (const filler of APP_CONFIG.fillers) { - // check if filler should try and bid on the auction - if (!canFillerBid(filler, poolId, poolEvent.event.auctionData)) { - continue; - } - let auctionEntry: AuctionEntry = { - pool_id: poolId, - user_id: poolEvent.event.user, - auction_type: poolEvent.event.auctionType, - filler: filler.keypair.publicKey(), - start_block: poolEvent.event.auctionData.block, - fill_block: 0, - updated: poolEvent.event.ledger, - }; - this.db.setAuctionEntry(auctionEntry); - - const logMessage = - `New auction\n` + - `Type: ${AuctionType[poolEvent.event.auctionType]}\n` + - `Filler: ${filler.name}\n` + - `Pool: ${poolId}\n` + - `User: ${poolEvent.event.user}\n` + - `Auction Data: ${stringify(poolEvent.event.auctionData, 2)}\n`; - await sendNotification(logMessage); - logger.info(logMessage); - fillerFound = true; - break; - } - if (!fillerFound) { + if (!canFillerBid(pool.id, poolEvent.event.auctionData)) { const logMessage = `Auction Ignored\n` + `Type: ${AuctionType[poolEvent.event.auctionType]}\n` + - `Pool: ${poolId}\n` + + `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}\n` + `Auction Data: ${stringify(poolEvent.event.auctionData, 2)}\n`; await sendNotification(logMessage); logger.info(logMessage); + return; } + let auctionEntry: AuctionEntry = { + pool_id: pool.id, + user_id: poolEvent.event.user, + auction_type: poolEvent.event.auctionType, + filler: APP_CONFIG.fillerKeypair.publicKey(), + start_block: poolEvent.event.auctionData.block, + fill_block: 0, + updated: poolEvent.event.ledger, + }; + this.db.setAuctionEntry(auctionEntry); + + const logMessage = + `New auction\n` + + `Type: ${AuctionType[poolEvent.event.auctionType]}\n` + + `Filler: ${APP_CONFIG.fillerKeypair.publicKey()}\n` + + `Pool: ${pool.id}\n` + + `User: ${poolEvent.event.user}\n` + + `Auction Data: ${stringify(poolEvent.event.auctionData, 2)}\n`; + await sendNotification(logMessage); + logger.info(logMessage); break; } case PoolEventType.DeleteLiquidationAuction: { // user position is now healthy and user deleted their liquidation auction let runResult = this.db.deleteAuctionEntry( - poolId, + pool.id, poolEvent.event.user, AuctionType.Liquidation ); if (runResult.changes !== 0) { const logMessage = `Liquidation Auction Deleted\n` + - `Pool: ${poolId}\n` + + `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}\n`; await sendNotification(logMessage); logger.info(logMessage); @@ -153,7 +149,7 @@ export class PoolEventHandler { `Auction Fill Event\n` + `Type ${AuctionType[poolEvent.event.auctionType]}\n` + `Filler: ${fillerAddress}\n` + - `Pool: ${poolId}\n` + + `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}\n` + `Fill Percent: ${poolEvent.event.fillAmount}\n` + `Tx Hash: ${poolEvent.event.txHash}\n`; @@ -162,7 +158,7 @@ export class PoolEventHandler { if (poolEvent.event.fillAmount === BigInt(100)) { // auction was fully filled, remove from ongoing auctions let runResult = this.db.deleteAuctionEntry( - poolId, + pool.id, poolEvent.event.user, poolEvent.event.auctionType ); @@ -170,26 +166,26 @@ export class PoolEventHandler { logger.info( `Auction Deleted\n` + `Type: ${AuctionType[poolEvent.event.auctionType]}\n` + - `Pool: ${poolId}\n` + + `Pool: ${pool.id}\n` + `User: ${poolEvent.event.user}` ); } } if (poolEvent.event.auctionType === AuctionType.Liquidation) { const { estimate: userPositionsEstimate, user } = - await this.sorobanHelper.loadUserPositionEstimate(poolId, poolEvent.event.user); + await this.sorobanHelper.loadUserPositionEstimate(pool.id, poolEvent.event.user); updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); const { estimate: fillerPositionsEstimate, user: filler } = - await this.sorobanHelper.loadUserPositionEstimate(poolId, fillerAddress); + await this.sorobanHelper.loadUserPositionEstimate(pool.id, fillerAddress); updateUser(this.db, pool, filler, fillerPositionsEstimate, poolEvent.event.ledger); } else if (poolEvent.event.auctionType === AuctionType.BadDebt) { const { estimate: fillerPositionsEstimate, user: filler } = - await this.sorobanHelper.loadUserPositionEstimate(poolId, fillerAddress); + await this.sorobanHelper.loadUserPositionEstimate(pool.id, fillerAddress); updateUser(this.db, pool, filler, fillerPositionsEstimate, poolEvent.event.ledger); sendEvent(this.worker, { type: EventType.CHECK_USER, timestamp: Date.now(), - poolId, + poolId: pool.id, userId: APP_CONFIG.backstopAddress, }); } @@ -199,12 +195,12 @@ export class PoolEventHandler { case PoolEventType.BadDebt: { // user has transferred bad debt to the backstop address const { estimate: userPositionsEstimate, user } = - await this.sorobanHelper.loadUserPositionEstimate(poolId, poolEvent.event.user); + await this.sorobanHelper.loadUserPositionEstimate(pool.id, poolEvent.event.user); updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); sendEvent(this.worker, { type: EventType.CHECK_USER, timestamp: Date.now(), - poolId, + poolId: pool.id, userId: APP_CONFIG.backstopAddress, }); break; @@ -212,12 +208,12 @@ export class PoolEventHandler { case PoolEventType.DeleteAuction: { const user = poolEvent.event.user; const auctionType = poolEvent.event.auctionType; - let runResult = this.db.deleteAuctionEntry(poolId, user, auctionType); + let runResult = this.db.deleteAuctionEntry(pool.id, user, auctionType); if (runResult.changes !== 0) { const logMessage = `Stale Auction Deleted\n` + `Type: ${AuctionType[auctionType]}\n` + - `Pool: ${poolId}\n` + + `Pool: ${pool.id}\n` + `User: ${user}`; await sendNotification(logMessage); logger.info(logMessage); diff --git a/src/utils/config.ts b/src/utils/config.ts index 4e46375..e38d140 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,15 +2,6 @@ import { Keypair } from '@stellar/stellar-sdk'; import { readFileSync } from 'fs'; import { parse } from './json.js'; -export interface Filler { - name: string; - keypair: Keypair; - defaultProfitPct: number; - supportedPools: PoolFillerConfig[]; - supportedBid: string[]; - supportedLot: string[]; -} - export enum PriceSourceType { BINANCE = 'binance', COINBASE = 'coinbase', @@ -42,12 +33,15 @@ export interface AuctionProfit { supportedLot: string[]; } -export interface PoolFillerConfig { +export interface PoolConfig { poolAddress: string; minPrimaryCollateral: bigint; primaryAsset: string; minHealthFactor: number; + defaultProfitPct: number; forceFill: boolean; + supportedBid: string[]; + supportedLot: string[]; } export interface AppConfig { @@ -58,9 +52,11 @@ export interface AppConfig { backstopAddress: string; usdcAddress: string; blndAddress: string; - keypair: Keypair; - fillers: Filler[]; - pools: string[]; + interestFillerAddress: string; + workerKeypair: Keypair; + fillerKeypair: Keypair; + pools: PoolConfig[]; + // optional fields horizonURL: string | undefined; priceSources: PriceSource[] | undefined; profits: AuctionProfit[] | undefined; @@ -78,6 +74,7 @@ if (process.env.NODE_ENV !== 'test') { throw new Error('Invalid config file'); } } + export { APP_CONFIG }; export function validateAppConfig(config: any): boolean { @@ -93,9 +90,11 @@ export function validateAppConfig(config: any): boolean { typeof config.backstopTokenAddress !== 'string' || typeof config.usdcAddress !== 'string' || typeof config.blndAddress !== 'string' || - typeof config.keypair !== 'string' || - !Array.isArray(config.fillers) || + typeof config.interestFillerAddress !== 'string' || + typeof config.workerKeypair !== 'string' || + typeof config.fillerKeypair !== 'string' || !Array.isArray(config.pools) || + // optional fields (config.horizonURL !== undefined && typeof config.horizonURL !== 'string') || (config.priceSources !== undefined && !Array.isArray(config.priceSources)) || (config.profits !== undefined && !Array.isArray(config.profits)) || @@ -108,36 +107,36 @@ export function validateAppConfig(config: any): boolean { return false; } - config.keypair = Keypair.fromSecret(config.keypair); + config.workerKeypair = Keypair.fromSecret(config.workerKeypair); + config.fillerKeypair = Keypair.fromSecret(config.fillerKeypair); return ( - config.fillers.every(validateFiller) && - config.pools.every((item: any) => typeof item === 'string') && + config.pools.every(validatePoolConfig) && (config.priceSources === undefined || config.priceSources.every(validatePriceSource)) && (config.profits === undefined || config.profits.every(validateAuctionProfit)) ); } -export function validateFiller(filler: any): boolean { - if (typeof filler !== 'object' || filler === null) { +export function validatePoolConfig(config: any): boolean { + if (typeof config !== 'object' || config === null) { return false; } if ( - typeof filler.name === 'string' && - typeof filler.keypair === 'string' && - typeof filler.defaultProfitPct === 'number' && - Array.isArray(filler.supportedPools) && - filler.supportedPools.every(validatePoolFillerConfig) && - Array.isArray(filler.supportedBid) && - filler.supportedBid.every((item: any) => typeof item === 'string') && - Array.isArray(filler.supportedLot) && - filler.supportedLot.every((item: any) => typeof item === 'string') + typeof config.poolAddress === 'string' && + typeof config.minPrimaryCollateral === 'string' && + typeof config.primaryAsset === 'string' && + typeof config.minHealthFactor === 'number' && + typeof config.defaultProfitPct === 'number' && + typeof config.forceFill === 'boolean' && + Array.isArray(config.supportedBid) && + config.supportedBid.every((item: any) => typeof item === 'string') && + Array.isArray(config.supportedLot) && + config.supportedLot.every((item: any) => typeof item === 'string') ) { - filler.keypair = Keypair.fromSecret(filler.keypair); return true; } - console.log('Invalid filler', filler); + console.log('Invalid pool config', config); return false; } @@ -196,21 +195,3 @@ export function validateAuctionProfit(profits: any): boolean { console.log('Invalid profit', profits); return false; } - -export function validatePoolFillerConfig(config: any): boolean { - if (typeof config !== 'object' || config === null) { - return false; - } - - if ( - typeof config.poolAddress !== 'string' || - typeof config.minPrimaryCollateral !== 'string' || - typeof config.primaryAsset !== 'string' || - typeof config.minHealthFactor !== 'number' || - typeof config.forceFill !== 'boolean' - ) { - return false; - } - config.minPrimaryCollateral = BigInt(config.minPrimaryCollateral); - return true; -} diff --git a/src/utils/interest_filler.ts b/src/utils/interest_filler.ts new file mode 100644 index 0000000..d1d6ddb --- /dev/null +++ b/src/utils/interest_filler.ts @@ -0,0 +1,75 @@ +import { Contract, nativeToScVal, xdr } from '@stellar/stellar-sdk'; + +export class InterestFillerContract extends Contract { + constructor(address: string) { + super(address); + } + + /** + * Create operation for "fill_interest" + * + * Fill an interest auction. + * + * @param from - The address filling the interest auction + * @param pool - The pool address for the auction being filled + * @param fill_percent - The fill percentage for the auction + * @param max_usdc_in - The maximum USDC input allowed for the fill + * @returns base64 encoded XDR of the operation + */ + public fill_interest( + from: string, + pool: string, + fill_percent: number, + max_usdc_in: bigint + ): string { + const invokeArgs = { + method: 'fill_interest', + args: [ + nativeToScVal(from, { type: 'address' }), + nativeToScVal(pool, { type: 'address' }), + nativeToScVal(fill_percent, { type: 'u32' }), + nativeToScVal(max_usdc_in, { type: 'i128' }), + ], + }; + const operation = this.call(invokeArgs.method, ...invokeArgs.args); + + return operation.toXDR('base64'); + } + + /** + * Create operation for "claim" + * + * (Only Owner) Claim all tokens of specified assets from the contract to a specified address. + * + * // to: Address, assets: Vec
+ * @param to - The address to receive the claimed tokens + * @param assets - The list of asset addresses to claim + * @returns base64 encoded XDR of the operation + */ + public claim(to: string, assets: string[]): string { + const invokeArgs = { + method: 'claim', + args: [nativeToScVal(to, { type: 'address' }), nativeToScVal(assets, { type: 'address' })], + }; + const operation = this.call(invokeArgs.method, ...invokeArgs.args); + + return operation.toXDR('base64'); + } + + /** + * Create operation for "get_owner" + * + * Get the owner address + * + * @returns base64 encoded XDR of the operation + */ + public get_owner(): string { + const invokeArgs = { + method: 'get_owner', + args: [], + }; + const operation = this.call(invokeArgs.method, ...invokeArgs.args); + + return operation.toXDR('base64'); + } +} diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index 3572c89..f068243 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -259,16 +259,71 @@ export class SorobanHelper { } } - async simLPTokenToUSDC(backstopAddress: string, amount: bigint): Promise { + /** + * Fetch the amount of USDC required to mint "lp_tokens" of LP tokens + * @param lp_tokens The amount of LP tokens to simulate minting + * @returns The amount of USDC required to mint the LP tokens, or undefined if the simulation failed + */ + async simLPTokensGetUSDCIn(lp_tokens: bigint): Promise { + try { + let comet = new Contract(APP_CONFIG.backstopTokenAddress); + let op = comet.call( + 'dep_lp_tokn_amt_out_get_tokn_in', + ...[ + nativeToScVal(APP_CONFIG.usdcAddress, { type: 'address' }), + nativeToScVal(lp_tokens, { type: 'i128' }), + nativeToScVal(1_000_000_0000000, { type: 'i128' }), + nativeToScVal(APP_CONFIG.backstopTokenAddress, { + type: 'address', + }), + ] + ); + let account = new Account(Keypair.random().publicKey(), '123'); + let tx = new TransactionBuilder(account, { + networkPassphrase: this.network.passphrase, + fee: BASE_FEE, + timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, + }) + .addOperation(op) + .build(); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + + let result = await stellarRpc.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { + return scValToNative(result.result.retval); + } else if (rpc.Api.isSimulationError(result)) { + logger.error( + `Simulation failed for simLPTokensGetUSDCIn with lp_tokens: ${lp_tokens}`, + result?.error + ); + return undefined; + } else { + logger.error( + `Unknown simulation result for simLPTokensGetUSDCIn with lp_tokens: ${lp_tokens}` + ); + return undefined; + } + } catch (e) { + logger.error(`Error calculating comet token value: ${e}`); + return undefined; + } + } + + /** + * Fetch the amount of USDC that would be received by withdrawing "lp_tokens" of LP tokens + * @param lp_tokens The amount of LP tokens withdrawing + * @returns The amount of USDC that would be received by withdrawing the LP tokens, or undefined if the simulation failed + */ + async simLPTokensToUSDC(lp_tokens: bigint): Promise { try { let comet = new Contract(APP_CONFIG.backstopTokenAddress); let op = comet.call( 'wdr_tokn_amt_in_get_lp_tokns_out', ...[ nativeToScVal(APP_CONFIG.usdcAddress, { type: 'address' }), - nativeToScVal(amount, { type: 'i128' }), + nativeToScVal(lp_tokens, { type: 'i128' }), nativeToScVal(0, { type: 'i128' }), - nativeToScVal(backstopAddress, { type: 'address' }), + nativeToScVal(APP_CONFIG.backstopAddress, { type: 'address' }), ] ); let account = new Account(Keypair.random().publicKey(), '123'); @@ -284,8 +339,18 @@ export class SorobanHelper { let result = await stellarRpc.simulateTransaction(tx); if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { return scValToNative(result.result.retval); + } else if (rpc.Api.isSimulationError(result)) { + logger.error( + `Simulation failed for simLPTokensToUSDC with lp_tokens: ${lp_tokens}`, + result?.error + ); + return undefined; + } else { + logger.error( + `Unknown simulation result for simLPTokensToUSDC with lp_tokens: ${lp_tokens}` + ); + return undefined; } - return undefined; } catch (e) { logger.error(`Error calculating comet token value: ${e}`); return undefined; @@ -318,6 +383,36 @@ export class SorobanHelper { } } + /** + * Fetch the owner of the Interest Filler contract + * @returns The owner address or undefined if the simulation failed + */ + async simInterestFillerOwner(): Promise { + try { + let contract = new Contract(APP_CONFIG.interestFillerAddress); + let op = contract.call('get_owner', ...[]); + let account = new Account(Keypair.random().publicKey(), '123'); + let tx = new TransactionBuilder(account, { + networkPassphrase: this.network.passphrase, + fee: BASE_FEE, + timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, + }) + .addOperation(op) + .build(); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + + let result = await stellarRpc.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { + return scValToNative(result.result.retval); + } else { + return undefined; + } + } catch (e) { + logger.error(`Error fetching interest filler owner: ${e}`); + return undefined; + } + } + async submitTransaction( operation: string, keypair: Keypair diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..9116046 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,38 @@ +import { APP_CONFIG } from './utils/config.js'; +import { SorobanHelper } from './utils/soroban_helper.js'; + +/** + * Validates that the bot is mostly correctly configured. Attempts to catch any common misconfigurations + * and error out if found to ensure the bot does not run in a broken state. + * + * @param sorobanHelper + * + * @throws Will throw an error if validation fails. + */ +export async function validateBot(sorobanHelper: SorobanHelper): Promise { + // validate the worker and filler keypairs are not the same + if (APP_CONFIG.workerKeypair.publicKey() === APP_CONFIG.fillerKeypair.publicKey()) { + throw new Error('Worker and filler keypairs must be different'); + } + + // validate the interest filler is a valid address and the auctioneer is the owner + const owner = await sorobanHelper.simInterestFillerOwner(); + if (owner !== APP_CONFIG.fillerKeypair.publicKey()) { + throw new Error( + `Interest filler contract owner mismatch. Expected: ${APP_CONFIG.fillerKeypair.publicKey()}, Got: ${owner}` + ); + } + + // validate the backstop token loads correctly + await sorobanHelper.loadBackstopToken(); + + // validate each pool loads and that each pool has the same backstop + for (const poolConfig of APP_CONFIG.pools) { + const pool = await sorobanHelper.loadPool(poolConfig.poolAddress); + if (pool.metadata.backstop !== APP_CONFIG.backstopAddress) { + throw new Error( + `Pool backstop mismatch for pool ${pool.id}. Expected: ${APP_CONFIG.backstopTokenAddress}, Got: ${pool.metadata.backstop}` + ); + } + } +} diff --git a/src/work_handler.ts b/src/work_handler.ts index bc39a58..261213a 100644 --- a/src/work_handler.ts +++ b/src/work_handler.ts @@ -4,14 +4,13 @@ import { checkUsersForLiquidationsAndBadDebt, scanUsers } from './liquidations.j import { OracleHistory } from './oracle_history.js'; import { updateUser } from './user.js'; import { APP_CONFIG } from './utils/config.js'; -import { AuctioneerDatabase, AuctionType } from './utils/db.js'; +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 { SorobanHelper } from './utils/soroban_helper.js'; -import { WorkSubmissionType, WorkSubmitter } from './work_submitter.js'; -import { canFillerBid, checkFillerSupport, getFillerAvailableBalances } from './filler.js'; +import { WorkSubmitter } from './work_submitter.js'; import { checkPoolForInterestAuction } from './interest.js'; const MAX_RETRIES = 3; @@ -54,9 +53,6 @@ export class WorkHandler { } catch (error) { retries++; if (retries >= MAX_RETRIES) { - if (appEvent.type === EventType.VALIDATE_POOLS) { - throw error; - } await deadletterEvent(appEvent); return false; } @@ -80,42 +76,30 @@ export class WorkHandler { */ async processEvent(appEvent: AppEvent): Promise { switch (appEvent.type) { - case EventType.VALIDATE_POOLS: { - for (const poolId of appEvent.pools) { - try { - let pool = await this.sorobanHelper.loadPool(poolId); - if (pool.metadata.backstop !== APP_CONFIG.backstopAddress) { - throw new Error( - `Backstop address for pool: ${poolId} is not the expected address: ${APP_CONFIG.backstopAddress}` - ); - } - } catch (error) { - throw new Error( - `Failed to load pool: ${poolId} please check that the address is correct and the pool is version 1. Error: ${error}` - ); - } - } - break; - } - case EventType.PRICE_UPDATE: { await setPrices(this.db); break; } case EventType.ORACLE_SCAN: { - for (const poolId of APP_CONFIG.pools) { + for (const poolConfig of APP_CONFIG.pools) { let usersToCheck = new Set(); - const poolOracle = await this.sorobanHelper.loadPoolOracle(poolId); + const poolOracle = await this.sorobanHelper.loadPoolOracle(poolConfig.poolAddress); const priceChanges = this.oracleHistory.getSignificantPriceChanges(poolOracle); // @dev: Insert into a set to ensure uniqueness for (const assetId of priceChanges.up) { - const usersWithLiability = this.db.getUserEntriesWithLiability(poolId, assetId); + const usersWithLiability = this.db.getUserEntriesWithLiability( + poolConfig.poolAddress, + assetId + ); for (const user of usersWithLiability) { usersToCheck.add(user.user_id); } } for (const assetId of priceChanges.down) { - const usersWithCollateral = this.db.getUserEntriesWithCollateral(poolId, assetId); + const usersWithCollateral = this.db.getUserEntriesWithCollateral( + poolConfig.poolAddress, + assetId + ); for (const user of usersWithCollateral) { usersToCheck.add(user.user_id); } @@ -123,7 +107,7 @@ export class WorkHandler { const liquidations = await checkUsersForLiquidationsAndBadDebt( this.db, this.sorobanHelper, - poolId, + poolConfig.poolAddress, Array.from(usersToCheck) ); for (const liquidation of liquidations) { @@ -140,10 +124,13 @@ export class WorkHandler { break; } case EventType.USER_REFRESH: { - for (const poolId of APP_CONFIG.pools) { + for (const poolConfig of APP_CONFIG.pools) { try { - const pool = await this.sorobanHelper.loadPool(poolId); - const oldUsers = this.db.getUserEntriesUpdatedBefore(poolId, appEvent.cutoff); + const pool = await this.sorobanHelper.loadPool(poolConfig.poolAddress); + const oldUsers = this.db.getUserEntriesUpdatedBefore( + poolConfig.poolAddress, + appEvent.cutoff + ); for (const user of oldUsers) { try { @@ -151,21 +138,24 @@ export class WorkHandler { if (user.updated < Math.max(appEvent.cutoff - 17280 * 14, 0)) { const logMessage = `Warning user has not been updated since ledger ${appEvent.cutoff}\n` + - `Pool: ${poolId}\n` + + `Pool: ${poolConfig.poolAddress}\n` + `User: ${user.user_id}`; logger.error(logMessage); await sendNotification(logMessage); } const { estimate: poolUserEstimate, user: poolUser } = - await this.sorobanHelper.loadUserPositionEstimate(poolId, user.user_id); + await this.sorobanHelper.loadUserPositionEstimate( + poolConfig.poolAddress, + user.user_id + ); updateUser(this.db, pool, poolUser, poolUserEstimate); } catch (e) { logger.error(`Error refreshing user ${user.user_id} in pool ${user.pool_id}: ${e}`); } } } catch (e) { - logger.error(`Error refreshing users in pool ${poolId}: ${e}`); + logger.error(`Error refreshing users in pool ${poolConfig.poolAddress}: ${e}`); continue; } } @@ -185,8 +175,8 @@ export class WorkHandler { break; } case EventType.CHECK_INTEREST: { - for (const poolId of APP_CONFIG.pools) { - const submission = await checkPoolForInterestAuction(this.sorobanHelper, poolId); + for (const poolConfig of APP_CONFIG.pools) { + const submission = await checkPoolForInterestAuction(this.sorobanHelper, poolConfig); if (submission) { this.submissionQueue.addSubmission(submission, 2); // only submit one interest auction at a time diff --git a/src/work_submitter.ts b/src/work_submitter.ts index 2d19d49..494579a 100644 --- a/src/work_submitter.ts +++ b/src/work_submitter.ts @@ -1,5 +1,5 @@ import { ContractError, ContractErrorType, PoolContractV2 } from '@blend-capital/blend-sdk'; -import { APP_CONFIG, Filler } from './utils/config.js'; +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'; @@ -79,7 +79,7 @@ export class WorkSubmitter extends SubmissionQueue { ); return true; } - await sorobanHelper.submitTransaction(op, APP_CONFIG.keypair); + await sorobanHelper.submitTransaction(op, APP_CONFIG.workerKeypair); const logMessage = `Successfully created auction\n` + `Auction Type: ${AuctionType[auction.auctionType]}\n` + @@ -128,7 +128,7 @@ export class WorkSubmitter extends SubmissionQueue { ); const pool = new PoolContractV2(badDebtTransfer.poolId); const op = pool.badDebt(badDebtTransfer.user); - await sorobanHelper.submitTransaction(op, APP_CONFIG.keypair); + await sorobanHelper.submitTransaction(op, APP_CONFIG.workerKeypair); const logMessage = `Successfully transferred bad debt to backstop\n` + `Pool: ${badDebtTransfer.poolId}\n` + diff --git a/src/worker.ts b/src/worker.ts index d9134e7..0be43fd 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -27,10 +27,6 @@ async function main() { `Finished: ${message?.data} in ${Date.now() - timer}ms with delay ${timer - appEvent.timestamp}ms` ); } catch (err) { - if (appEvent.type === EventType.VALIDATE_POOLS) { - logger.error(err); - throw err; - } logger.error(`Unexpected error in worker for ${message?.data}`, err); await deadletterEvent(appEvent); } diff --git a/test/auction.test.ts b/test/auction.test.ts index 039d032..8b5a9de 100644 --- a/test/auction.test.ts +++ b/test/auction.test.ts @@ -7,10 +7,10 @@ import { PositionsEstimate, Request, } from '@blend-capital/blend-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; -import { calculateAuctionFill, valueBackstopTokenInUSDC } from '../src/auction.js'; +import { Keypair, xdr } from '@stellar/stellar-sdk'; +import { calculateAuctionFill } from '../src/auction.js'; import { getFillerAvailableBalances, getFillerProfitPct } from '../src/filler.js'; -import { Filler } from '../src/utils/config.js'; +import { PoolConfig } from '../src/utils/config.js'; import { AuctioneerDatabase } from '../src/utils/db.js'; import { SorobanHelper } from '../src/utils/soroban_helper.js'; import { @@ -40,14 +40,14 @@ jest.mock('../src/utils/config.js', () => { backstopTokenAddress: 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', usdcAddress: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', blndAddress: 'CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY', - keypair: '', - fillers: [], + interestFillerAddress: 'CDMPO7TQH2CJIOARTKAUY2TNZNXMA3BJ2TP3JYUH7HNUMRAZMYUH4FOB', + fillerKeypair: Keypair.random(), }, }; }); describe('auctions', () => { - let filler: Filler; + let poolConfig: PoolConfig; const mockedSorobanHelper = new SorobanHelper() as jest.Mocked; let db: AuctioneerDatabase; let positionEstimate: PositionsEstimate; @@ -62,23 +62,16 @@ describe('auctions', () => { beforeEach(() => { jest.resetAllMocks(); db = inMemoryAuctioneerDb(); - filler = { - name: 'Tester', - keypair: Keypair.random(), + poolConfig = { defaultProfitPct: 0.1, - supportedPools: [ - { - poolAddress: mockPool.id, - primaryAsset: USDC, - minPrimaryCollateral: 100n, - minHealthFactor: 1.2, - forceFill: true, - }, - ], + poolAddress: mockPool.id, + primaryAsset: USDC, + minPrimaryCollateral: 100n, + minHealthFactor: 1.2, + forceFill: true, supportedBid: [], supportedLot: [], }; - positionEstimate = { totalBorrowed: 0, totalSupplied: 0, @@ -97,17 +90,19 @@ describe('auctions', () => { estimate: positionEstimate, user: {} as PoolUser, }); - mockedSorobanHelper.simLPTokenToUSDC.mockImplementation( - (backstopId: string, number: bigint) => { - // 0.5 USDC per LP token - return Promise.resolve((number * 5000000n) / 10000000n); - } - ); + mockedSorobanHelper.simLPTokensToUSDC.mockImplementation((number: bigint) => { + // 0.50 USDC out per LP token in + return Promise.resolve((number * 5000000n) / 10000000n); + }); + mockedSorobanHelper.simLPTokensGetUSDCIn.mockImplementation((number: bigint) => { + // 0.55 USDC in per LP token out + return Promise.resolve((number * 5500000n) / 10000000n); + }); }); describe('calcAuctionFill', () => { // *** Interest Auctions *** - it('calcs fill for interest auction', async () => { + it('calcs fill for interest auction happy path', async () => { let nextLedger = MOCK_LEDGER + 1; let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ @@ -122,12 +117,11 @@ describe('auctions', () => { mockedGetFillerProfitPct.mockReturnValue(0.1); mockedGetFilledAvailableBalances.mockResolvedValue( - new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + new Map([[USDC, FixedMath.toFixed(1000)]]) ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -141,17 +135,13 @@ describe('auctions', () => { amount: 100n, }, ]; - expect(fill.block).toEqual(MOCK_LEDGER + 272); + expect(fill.block).toEqual(MOCK_LEDGER + 283); expect(fill.percent).toEqual(100); expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); - expectRelApproxEqual(fill.bidValue, 233.4726912, 0.005); + expectRelApproxEqual(fill.bidValue, 234.2387, 0.005); - expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - filler, - [BACKSTOP_TOKEN], - mockedSorobanHelper - ); + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith([USDC], mockedSorobanHelper); }); it('calcs fill for interest auction and delays block to fully fill', async () => { @@ -169,12 +159,11 @@ describe('auctions', () => { mockedGetFillerProfitPct.mockReturnValue(0.1); mockedGetFilledAvailableBalances.mockResolvedValue( - new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(400)]]) + new Map([[USDC, FixedMath.toFixed(200)]]) ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -188,15 +177,58 @@ describe('auctions', () => { amount: 100n, }, ]; - expect(fill.block).toEqual(MOCK_LEDGER + 272 + 19); + expect(fill.block).toEqual(MOCK_LEDGER + 283 + 19); expect(fill.percent).toEqual(100); expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); - expectRelApproxEqual(fill.bidValue, 198.8165886, 0.005); + expectRelApproxEqual(fill.bidValue, 196.1999, 0.005); + }); + + it('calcs fill for interest auction and delays tell block 400 if no usdc', async () => { + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(BACKSTOP, AuctionType.Interest, { + lot: new Map([ + [XLM, FixedMath.toFixed(120)], + [USDC, FixedMath.toFixed(210)], + [EURC, FixedMath.toFixed(34)], + [AQUA, FixedMath.toFixed(2500)], + ]), + bid: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(728.01456)]]), + block: MOCK_LEDGER, + }); + + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[USDC, FixedMath.toFixed(0)]]) + ); + + poolConfig.forceFill = true; + let fill = await calculateAuctionFill( + poolConfig, + auction, + nextLedger, + mockedSorobanHelper, + db + ); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 400); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); + expect(fill.bidValue).toEqual(0); + + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith([USDC], mockedSorobanHelper); }); it('calcs fill for interest auction at next ledger if past target block', async () => { - let nextLedger = MOCK_LEDGER + 280; + let nextLedger = MOCK_LEDGER + 290; let auction = new Auction(BACKSTOP, AuctionType.Interest, { lot: new Map([ [XLM, FixedMath.toFixed(120)], @@ -210,12 +242,11 @@ describe('auctions', () => { mockedGetFillerProfitPct.mockReturnValue(0.1); mockedGetFilledAvailableBalances.mockResolvedValue( - new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + new Map([[USDC, FixedMath.toFixed(1000)]]) ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -233,7 +264,7 @@ describe('auctions', () => { expect(fill.percent).toEqual(100); expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); - expectRelApproxEqual(fill.bidValue, 218.880648, 0.005); + expectRelApproxEqual(fill.bidValue, 220.2244, 0.005); }); it('calcs fill for interest auction uses db prices when possible', async () => { @@ -259,12 +290,11 @@ describe('auctions', () => { mockedGetFillerProfitPct.mockReturnValue(0.1); mockedGetFilledAvailableBalances.mockResolvedValue( - new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + new Map([[USDC, FixedMath.toFixed(1000)]]) ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -278,11 +308,11 @@ describe('auctions', () => { amount: 100n, }, ]; - expect(fill.block).toEqual(MOCK_LEDGER + 260); + expect(fill.block).toEqual(MOCK_LEDGER + 273); expect(fill.percent).toEqual(100); expect(fill.requests).toEqual(expectedRequests); expectRelApproxEqual(fill.lotValue, 284.6922, 0.005); - expectRelApproxEqual(fill.bidValue, 254.805096, 0.005); + expectRelApproxEqual(fill.bidValue, 254.2590851, 0.005); }); it('calcs fill for interest auction respects force fill setting', async () => { @@ -300,23 +330,21 @@ describe('auctions', () => { mockedGetFillerProfitPct.mockReturnValue(0.2); mockedGetFilledAvailableBalances.mockResolvedValue( - new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + new Map([[USDC, FixedMath.toFixed(1000)]]) ); - filler.supportedPools[0].forceFill = true; + poolConfig.forceFill = true; let fill_force = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, db ); - filler.supportedPools[0].forceFill = false; + poolConfig.forceFill = false; let fill_no_force = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -334,9 +362,9 @@ describe('auctions', () => { expect(fill_force.percent).toEqual(100); expect(fill_force.requests).toEqual(expectedRequests); expectRelApproxEqual(fill_force.lotValue, 260.5722, 0.005); - expectRelApproxEqual(fill_force.bidValue, 312.5, 0.005); + expectRelApproxEqual(fill_force.bidValue, 343.75, 0.005); - expect(fill_no_force.block).toEqual(MOCK_LEDGER + 367); + expect(fill_no_force.block).toEqual(MOCK_LEDGER + 370); expect(fill_no_force.percent).toEqual(100); expect(fill_no_force.requests).toEqual(expectedRequests); expectRelApproxEqual(fill_no_force.lotValue, 260.5722, 0.005); @@ -369,8 +397,7 @@ describe('auctions', () => { ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -391,7 +418,6 @@ describe('auctions', () => { expectRelApproxEqual(fill.bidValue, 29.73769976, 0.005); expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - filler, [USDC, EURC, XLM], mockedSorobanHelper ); @@ -425,8 +451,7 @@ describe('auctions', () => { ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -484,8 +509,7 @@ describe('auctions', () => { ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -537,8 +561,7 @@ describe('auctions', () => { mockedGetFilledAvailableBalances.mockResolvedValue(new Map([])); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -578,8 +601,7 @@ describe('auctions', () => { mockedGetFilledAvailableBalances.mockResolvedValue(new Map([])); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -624,8 +646,7 @@ describe('auctions', () => { ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -683,8 +704,7 @@ describe('auctions', () => { ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -749,8 +769,7 @@ describe('auctions', () => { ); let fill = await calculateAuctionFill( - mockPool.id, - filler, + poolConfig, auction, nextLedger, mockedSorobanHelper, @@ -781,37 +800,9 @@ describe('auctions', () => { expectRelApproxEqual(fill.bidValue, 1495.503014, 0.005); expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - filler, [XLM, USDC], mockedSorobanHelper ); }); }); - - describe('valueBackstopTokenInUSDC', () => { - it('values from sim', async () => { - let lpTokenToUSDC = 0.5; - mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(FixedMath.toFixed(lpTokenToUSDC)); - mockedSorobanHelper.loadBackstopToken.mockResolvedValue({ - lpTokenPrice: 1.25, - } as BackstopToken); - - let value = await valueBackstopTokenInUSDC(mockedSorobanHelper, FixedMath.toFixed(2)); - - expect(value).toEqual(lpTokenToUSDC); - expect(mockedSorobanHelper.loadBackstopToken).toHaveBeenCalledTimes(0); - }); - - it('values from spot price if sim fails', async () => { - mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(undefined); - mockedSorobanHelper.loadBackstopToken.mockResolvedValue({ - lpTokenPrice: 1.25, - } as BackstopToken); - - let value = await valueBackstopTokenInUSDC(mockedSorobanHelper, FixedMath.toFixed(2)); - - expect(value).toEqual(1.25 * 2); - expect(mockedSorobanHelper.loadBackstopToken).toHaveBeenCalledTimes(1); - }); - }); }); diff --git a/test/bidder_handler.test.ts b/test/bidder_handler.test.ts index 5ea33ee..05e28fb 100644 --- a/test/bidder_handler.test.ts +++ b/test/bidder_handler.test.ts @@ -2,14 +2,9 @@ import { Auction } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { BidderHandler } from '../src/bidder_handler'; -import { - AddAllowance, - AuctionBid, - BidderSubmissionType, - BidderSubmitter, -} from '../src/bidder_submitter'; +import { AuctionBid, BidderSubmissionType, BidderSubmitter } from '../src/bidder_submitter'; import { AppEvent, EventType, LedgerEvent } from '../src/events'; -import { APP_CONFIG, AppConfig } from '../src/utils/config'; +import { APP_CONFIG, AppConfig, PoolConfig } from '../src/utils/config'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db'; import { logger } from '../src/utils/logger'; import { sendNotification } from '../src/utils/notifier'; @@ -27,52 +22,29 @@ jest.mock('../src/utils/config.js', () => { let config: AppConfig = { backstopAddress: 'backstopAddress', backstopTokenAddress: 'backstopTokenAddress', - fillers: [ + interestFillerAddress: 'filler', + workerKeypair: Keypair.random(), + fillerKeypair: Keypair.random(), + pools: [ { - name: 'filler1', - keypair: Keypair.random(), + poolAddress: 'pool1', + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + minHealthFactor: 1.1, defaultProfitPct: 0.05, - supportedPools: [ - { - poolAddress: 'pool1', - primaryAsset: 'USD', - minPrimaryCollateral: 100n, - minHealthFactor: 1.1, - forceFill: true, - }, - { - poolAddress: 'pool2', - primaryAsset: 'USD', - minPrimaryCollateral: 100n, - minHealthFactor: 1.1, - forceFill: true, - }, - ], - supportedBid: ['USD', 'BTC', 'LP'], + forceFill: true, + supportedBid: ['USD', 'BTC', 'LP', 'XLM'], supportedLot: ['USD', 'BTC', 'ETH'], }, { - name: 'filler2', - keypair: Keypair.random(), - supportedPools: [ - { - poolAddress: 'pool1', - primaryAsset: 'USD', - minPrimaryCollateral: 100n, - minHealthFactor: 1.1, - forceFill: true, - }, - { - poolAddress: 'pool2', - primaryAsset: 'USD', - minPrimaryCollateral: 100n, - minHealthFactor: 1.1, - forceFill: true, - }, - ], + poolAddress: 'pool2', + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + minHealthFactor: 1.1, + defaultProfitPct: 0.05, forceFill: true, - supportedBid: ['USD', 'ETH', 'XLM'], - supportedLot: ['USD', 'ETH', 'XLM'], + supportedBid: ['USD', 'BTC', 'LP', 'XLM'], + supportedLot: ['USD', 'BTC', 'ETH'], }, ], } as AppConfig; @@ -95,9 +67,7 @@ describe('BidderHandler', () => { let mockedCalcAuctionFill = calculateAuctionFill as jest.MockedFunction< typeof calculateAuctionFill >; - let mockedSendSlackNotif = sendNotification as jest.MockedFunction< - typeof sendNotification - >; + let mockedSendSlackNotif = sendNotification as jest.MockedFunction; beforeEach(() => { jest.resetAllMocks(); @@ -112,7 +82,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'user1', auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 15, fill_block: ledger + 180, updated: ledger - 14, @@ -121,7 +91,7 @@ describe('BidderHandler', () => { pool_id: 'pool2', user_id: 'user2', auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[1].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 1, fill_block: 0, updated: 0, @@ -187,7 +157,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'user1', auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 15, fill_block: ledger + 1 + 150, // to fill in 150 blocks from next ledger updated: ledger - 10, @@ -196,7 +166,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'backstop', auction_type: AuctionType.Interest, - filler: APP_CONFIG.fillers[1].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 240, fill_block: ledger + 5, updated: ledger - 5, @@ -257,7 +227,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'user1', auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 150, fill_block: ledger + 3, updated: ledger - 1, @@ -266,7 +236,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'backstop', auction_type: AuctionType.Interest, - filler: APP_CONFIG.fillers[1].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 240, fill_block: ledger - 5, updated: ledger - 2, @@ -314,7 +284,6 @@ describe('BidderHandler', () => { let submission_1: AuctionBid = { type: BidderSubmissionType.BID, - filler: APP_CONFIG.fillers[0], auctionEntry: new_auction_1 as AuctionEntry, }; expect(mockedBidderSubmitter.addSubmission).toHaveBeenCalledWith(submission_1, 10); @@ -330,7 +299,6 @@ describe('BidderHandler', () => { let submission_2: AuctionBid = { type: BidderSubmissionType.BID, - filler: APP_CONFIG.fillers[0], auctionEntry: new_auction_1 as AuctionEntry, }; expect(mockedBidderSubmitter.addSubmission).toHaveBeenCalledWith(submission_2, 10); @@ -342,7 +310,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'user1', auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 150, fill_block: ledger, updated: ledger - 1, @@ -385,7 +353,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'user1', auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 15, fill_block: ledger + 1 + 150, updated: ledger - 10, @@ -394,7 +362,7 @@ describe('BidderHandler', () => { pool_id: 'pool1', user_id: 'backstop', auction_type: AuctionType.Interest, - filler: APP_CONFIG.fillers[1].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: ledger - 240, fill_block: ledger + 5, updated: ledger - 5, @@ -445,10 +413,10 @@ describe('BidderHandler', () => { expect(new_auction_2?.updated).toEqual(ledger); }); - it('should skip an auction if unable to find filler', async () => { + it('should delete an auction if unable to find pool config', async () => { let ledger = 1000; // nextLedger is 1001 let auction_1: AuctionEntry = { - pool_id: 'pool1', + pool_id: 'pool3', user_id: 'user1', auction_type: AuctionType.Liquidation, filler: Keypair.random().publicKey(), @@ -478,63 +446,6 @@ describe('BidderHandler', () => { auction_1.user_id, auction_1.auction_type ); - expect(new_auction_1?.fill_block).toEqual(auction_1.fill_block); - expect(new_auction_1?.updated).toEqual(auction_1.updated); - expect(logger.error).toHaveBeenCalledTimes(1); - expect(mockedSorobanHelper.loadAuction).not.toHaveBeenCalled(); - }); - - it('Checks for allowance if interest auction', async () => { - let ledger = 1000; // nextLedger is 1001 - let auction_1: AuctionEntry = { - pool_id: 'pool1', - user_id: APP_CONFIG.backstopAddress, - auction_type: AuctionType.Interest, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), - start_block: ledger - 150, - fill_block: 0, - updated: 0, - }; - - db.setAuctionEntry(auction_1); - mockedSorobanHelper.loadAuction.mockResolvedValue( - new Auction('teapot', AuctionType.Interest, { - bid: new Map(), - lot: new Map(), - block: ledger - 1, - }) - ); - - let fill_calc_1: AuctionFill = { - block: 1001, - percent: 50, - lotValue: 1000, - bidValue: 900, - requests: [], - }; - mockedCalcAuctionFill.mockResolvedValueOnce(fill_calc_1); - - const appEvent: AppEvent = { - type: EventType.LEDGER, - ledger, - } as LedgerEvent; - await bidderHandler.processEvent(appEvent); - - let new_auction_1 = db.getAuctionEntry( - auction_1.pool_id, - auction_1.user_id, - auction_1.auction_type - ); - expect(new_auction_1?.fill_block).toEqual(fill_calc_1.block); - expect(new_auction_1?.updated).toEqual(ledger); - - let submission_1: AddAllowance = { - filler: APP_CONFIG.fillers[0], - type: BidderSubmissionType.ADD_ALLOWANCE, - assetId: APP_CONFIG.backstopTokenAddress, - spender: APP_CONFIG.backstopAddress, - currLedger: appEvent.ledger, - }; - expect(mockedBidderSubmitter.addSubmission).toHaveBeenCalledWith(submission_1, 4); + expect(new_auction_1).toBe(undefined); }); }); diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index ab1f230..45fff3a 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -1,30 +1,32 @@ import { Auction, - BackstopToken, + FixedMath, PoolUser, Positions, Request, RequestType, + ScaledAuction, } from '@blend-capital/blend-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; +import { Keypair, nativeToScVal, xdr } from '@stellar/stellar-sdk'; import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { - AddAllowance, AuctionBid, BidderSubmissionType, BidderSubmitter, FillerUnwind, } from '../src/bidder_submitter'; import { getFillerAvailableBalances, managePositions } from '../src/filler'; -import { APP_CONFIG, Filler } from '../src/utils/config'; +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'; import { SorobanHelper } from '../src/utils/soroban_helper'; import { inMemoryAuctioneerDb, mockPool, mockPoolOracle } from './helpers/mocks'; import { stringify } from '../src/utils/json'; +import { Api } from '@stellar/stellar-sdk/rpc'; // Mock dependencies + jest.mock('../src/utils/db'); jest.mock('../src/utils/soroban_helper'); jest.mock('../src/auction'); @@ -44,6 +46,7 @@ jest.mock('@stellar/stellar-sdk', () => { }; }); +const fillerPubkey = 'GDMDYVZ7CQC2HLP3VONMAXQPGIQDNDQHQWLJZ7YTDGXUM32DVKOQHYGT'; jest.mock('../src/utils/config.js', () => { return { APP_CONFIG: { @@ -53,8 +56,22 @@ jest.mock('../src/utils/config.js', () => { backstopAddress: 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', usdcAddress: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', blndAddress: 'CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY', - keypair: '', - fillers: [], + interestFillerAddress: 'CDMPO7TQH2CJIOARTKAUY2TNZNXMA3BJ2TP3JYUH7HNUMRAZMYUH4FOB', + fillerKeypair: Keypair.fromPublicKey( + 'GDMDYVZ7CQC2HLP3VONMAXQPGIQDNDQHQWLJZ7YTDGXUM32DVKOQHYGT' + ), + pools: [ + { + poolAddress: 'CBP7NO6F7FRDHSOFQBT2L2UWYIZ2PU76JKVRYAQTG3KZSQLYAOKIF2WB', // mockPool.id + primaryAsset: 'USD', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + minHealthFactor: 1.1, + defaultProfitPct: 0.05, + forceFill: true, + supportedBid: ['*'], + supportedLot: ['*'], + }, + ], }, }; }); @@ -79,9 +96,7 @@ describe('BidderSubmitter', () => { }; mockedSorobanHelperConstructor.mockReturnValue(mockedSorobanHelper); - const mockedSendSlackNotif = sendNotification as jest.MockedFunction< - typeof sendNotification - >; + const mockedSendSlackNotif = sendNotification as jest.MockedFunction; const mockedCalcAuctionFill = calculateAuctionFill as jest.MockedFunction< typeof calculateAuctionFill >; @@ -126,31 +141,14 @@ describe('BidderSubmitter', () => { }; 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, + filler: fillerPubkey, + start_block: 800, fill_block: 1000, } as AuctionEntry, }; @@ -181,7 +179,22 @@ describe('BidderSubmitter', () => { expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); expect(mockDb.setFilledAuctionEntry).toHaveBeenCalledWith(expectedFillEntry); expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( - { type: BidderSubmissionType.UNWIND, filler: submission.filler, poolId: mockPool.id }, + { + type: BidderSubmissionType.UNWIND, + poolId: mockPool.id, + filledAuction: { + type: submission.auctionEntry.auction_type, + user: submission.auctionEntry.user_id, + scaleBlock: auction_fill.block, + filled: false, + fillHash: undefined, + data: { + bid: expectedFillEntry.bid, + lot: expectedFillEntry.lot, + block: submission.auctionEntry.start_block, + }, + } as ScaledAuction, + }, 2 ); }); @@ -216,31 +229,14 @@ describe('BidderSubmitter', () => { }; 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, + filler: fillerPubkey, + start_block: 800, fill_block: 1000, } as AuctionEntry, }; @@ -271,7 +267,22 @@ describe('BidderSubmitter', () => { expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); expect(mockDb.setFilledAuctionEntry).toHaveBeenCalledWith(expectedFillEntry); expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( - { type: BidderSubmissionType.UNWIND, filler: submission.filler, poolId: mockPool.id }, + { + type: BidderSubmissionType.UNWIND, + poolId: mockPool.id, + filledAuction: { + type: submission.auctionEntry.auction_type, + user: submission.auctionEntry.user_id, + scaleBlock: auction_fill.block, + filled: false, + fillHash: undefined, + data: { + bid: expectedFillEntry.bid, + lot: expectedFillEntry.lot, + block: submission.auctionEntry.start_block, + }, + } as ScaledAuction, + }, 2 ); }); @@ -282,22 +293,6 @@ describe('BidderSubmitter', () => { mockedSorobanHelperConstructor.mockReturnValue(mockedSorobanHelper); const submission: AuctionBid = { type: BidderSubmissionType.BID, - filler: { - name: 'test-filler', - keypair: Keypair.random(), - defaultProfitPct: 0, - supportedPools: [ - { - poolAddress: mockPool.id, - primaryAsset: 'USD', - minPrimaryCollateral: 100n, - minHealthFactor: 1, - forceFill: false, - }, - ], - supportedBid: [], - supportedLot: [], - }, auctionEntry: { pool_id: mockPool.id, user_id: 'test-user', @@ -326,6 +321,10 @@ describe('BidderSubmitter', () => { mockedSorobanHelper.loadUser.mockResolvedValue( new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) ); + mockedSorobanHelper.submitTransaction.mockResolvedValue({ + ledger: 12345, + txHash: 'mock-tx-hash', + } as Api.GetSuccessfulTransactionResponse); mockedGetFilledAvailableBalances.mockResolvedValue(fillerBalance); mockedManagePositions.mockReturnValue(unwindRequest); @@ -333,29 +332,24 @@ describe('BidderSubmitter', () => { 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: 1, - forceFill: false, - }, - ], - supportedBid: ['USD', 'XLM'], - supportedLot: ['EURC', 'XLM'], + filledAuction: { + type: AuctionType.Liquidation, + user: Keypair.random().publicKey(), + data: { + bid: new Map(), + lot: new Map(), + block: 0, + }, + scaleBlock: 0, + filled: true, + fillHash: 'hash', }, }; let result = await bidderSubmitter.submit(submission); expect(result).toBe(true); expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - submission.filler, - ['USD', 'XLM', 'EURC'], + ['USD', ...Array.from(mockPool.reserves.keys())], mockedSorobanHelper ); expect(mockedManagePositions).toHaveBeenCalled(); @@ -380,29 +374,24 @@ describe('BidderSubmitter', () => { 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'], + filledAuction: { + type: AuctionType.BadDebt, + user: Keypair.random().publicKey(), + data: { + bid: new Map(), + lot: new Map(), + block: 0, + }, + scaleBlock: 0, + filled: true, + fillHash: 'hash', }, }; let result = await bidderSubmitter.submit(submission); expect(result).toBe(true); expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - submission.filler, - ['USD', 'XLM', 'EURC'], + ['USD', ...Array.from(mockPool.reserves.keys())], mockedSorobanHelper ); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); @@ -427,97 +416,78 @@ describe('BidderSubmitter', () => { 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'], + filledAuction: { + type: AuctionType.Liquidation, + user: Keypair.random().publicKey(), + data: { + bid: new Map(), + lot: new Map(), + block: 0, + }, + scaleBlock: 0, + filled: true, + fillHash: 'hash', }, }; let result = await bidderSubmitter.submit(submission); expect(result).toBe(true); expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - submission.filler, - ['USD', 'XLM', 'EURC'], + ['USD', ...Array.from(mockPool.reserves.keys())], 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` + + `Filler: ${fillerPubkey}\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()); - + it('should invoke claim for unwind events from interest auctions', async () => { 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); + mockedSorobanHelper.submitTransaction.mockResolvedValue({ + ledger: 12345, + txHash: 'mock-tx-hash', + returnValue: nativeToScVal(12345n), + } as Api.GetSuccessfulTransactionResponse); 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'], + filledAuction: { + type: AuctionType.Interest, + user: fillerPubkey, + data: { + bid: new Map([ + ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', BigInt(100e7)], + ]), + lot: new Map([ + ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', BigInt(2000)], + ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', BigInt(0)], + [ + 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', + BigInt('500000000000000000000000000000'), + ], + ]), + block: 0, + }, + scaleBlock: 0, + filled: true, + fillHash: 'hash', }, }; let result = await bidderSubmitter.submit(submission); expect(result).toBe(true); - expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( - submission.filler, - ['USD', 'XLM', 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 'EURC'], - mockedSorobanHelper + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledWith( + 'AAAAAAAAABgAAAAAAAAAAdj3fnA+hJQ4EZqBTGpty27AbCnU37Tih/nbRkQZZih+AAAABWNsYWltAAAAAAAAAgAAABIAAAAAAAAAANg8Vz8UBaOt+6uawF4PMiA2jgeFlpz/ExmvRm9Dqp0DAAAAEAAAAAEAAAADAAAAEgAAAAEltPzYWa7C+mNIQ4xImzw8EMmLbSG+T9PLMMtolT75dwAAABIAAAABre/OWa7lKWj3YGHUlMJSW3Vln6QpamX0me8p5WR35JYAAAASAAAAAean2et1IwBqRpqnSDrREHJHRDwNguYnY95nCEjE6XyQAAAAAA==', + Keypair.fromPublicKey(fillerPubkey) ); - expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(1); 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', () => { @@ -550,132 +520,9 @@ describe('BidderSubmitter', () => { expect(bidderSubmitter.containsAuction(auctionEntry)).toBe(false); }); - it('add allowance checks if amount is below threshold', async () => { - bidderSubmitter.addSubmission = jest.fn(); - mockedSorobanHelper.loadAllowance.mockResolvedValue({ - amount: BigInt(100000e7 - 1), - expiration_ledger: 1000 + 17368 * 7, - }); - - const submission: AddAllowance = { - type: BidderSubmissionType.ADD_ALLOWANCE, - 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'], - }, - assetId: APP_CONFIG.backstopTokenAddress, - spender: APP_CONFIG.backstopAddress, - currLedger: 1000, - }; - let result = await bidderSubmitter.submit(submission); - - expect(result).toBe(true); - expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(1); - expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); - }); - it('add allowance checks if expiration is below threshold', async () => { - bidderSubmitter.addSubmission = jest.fn(); - mockedSorobanHelper.loadAllowance.mockResolvedValue({ - amount: BigInt('18446744073709551615'), - expiration_ledger: 1000 + 17368 * 7 - 1, - }); - - const submission: AddAllowance = { - type: BidderSubmissionType.ADD_ALLOWANCE, - 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'], - }, - assetId: APP_CONFIG.backstopTokenAddress, - spender: APP_CONFIG.backstopAddress, - currLedger: 1000, - }; - let result = await bidderSubmitter.submit(submission); - - expect(result).toBe(true); - expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(1); - expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); - }); - - it('add allowance valid allowance no action taken', async () => { - bidderSubmitter.addSubmission = jest.fn(); - mockedSorobanHelper.loadAllowance.mockResolvedValue({ - amount: BigInt('18446744073709551615'), - expiration_ledger: 1000 + 17368 * 7, - }); - - const submission: AddAllowance = { - type: BidderSubmissionType.ADD_ALLOWANCE, - 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'], - }, - assetId: APP_CONFIG.backstopTokenAddress, - spender: APP_CONFIG.backstopAddress, - currLedger: 1000, - }; - let result = await bidderSubmitter.submit(submission); - - expect(result).toBe(true); - expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); - expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); - }); - it('should handle dropped bid', async () => { const submission: AuctionBid = { type: BidderSubmissionType.BID, - filler: { - name: 'test-filler', - keypair: Keypair.random(), - defaultProfitPct: 0, - supportedPools: [ - { - poolAddress: mockPool.id, - primaryAsset: 'USD', - minPrimaryCollateral: 100n, - minHealthFactor: 1, - forceFill: false, - }, - ], - supportedBid: [], - supportedLot: [], - }, auctionEntry: { user_id: 'test-user', pool_id: mockPool.id, @@ -695,7 +542,7 @@ describe('BidderSubmitter', () => { `User: ${submission.auctionEntry.user_id}\n` + `Start Block: ${submission.auctionEntry.start_block}\n` + `Fill Block: ${submission.auctionEntry.fill_block}\n` + - `Filler: ${submission.filler.name}\n` + `Filler: ${fillerPubkey}\n` ); expect(mockedSendSlackNotif).toHaveBeenCalledWith( `Dropped auction bid\n` + @@ -704,7 +551,7 @@ describe('BidderSubmitter', () => { `User: ${submission.auctionEntry.user_id}\n` + `Start Block: ${submission.auctionEntry.start_block}\n` + `Fill Block: ${submission.auctionEntry.fill_block}\n` + - `Filler: ${submission.filler.name}\n` + `Filler: ${fillerPubkey}\n` ); }); @@ -712,73 +559,27 @@ describe('BidderSubmitter', () => { 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: [], - supportedLot: [], - }, - }; - - await bidderSubmitter.onDrop(submission); - - expect(logger.error).toHaveBeenCalledWith( - `Dropped filler unwind\n` + `Filler: ${submission.filler.name}\n` + `Pool: ${mockPool.id}` - ); - expect(mockedSendSlackNotif).toHaveBeenCalledWith( - `Dropped filler unwind\n` + `Filler: ${submission.filler.name}\n` + `Pool: ${mockPool.id}` - ); - }); - - it('should handle dropped add allowance', async () => { - const submission: AddAllowance = { - type: BidderSubmissionType.ADD_ALLOWANCE, - 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'], + filledAuction: { + type: AuctionType.Liquidation, + user: fillerPubkey, + data: { + bid: new Map(), + lot: new Map(), + block: 0, + }, + scaleBlock: 0, + filled: true, + fillHash: 'hash', }, - assetId: APP_CONFIG.backstopTokenAddress, - spender: APP_CONFIG.backstopAddress, - currLedger: 1000, }; await bidderSubmitter.onDrop(submission); expect(logger.error).toHaveBeenCalledWith( - `Dropped allowance check\n` + - `Filler: ${submission.filler.name}\n` + - `Spender: ${submission.spender}\n` + - `Asset: ${submission.assetId}\n` + - `Ledger: ${submission.currLedger}` + `Dropped filler unwind\n` + `Filler: ${fillerPubkey}\n` + `Pool: ${mockPool.id}` ); expect(mockedSendSlackNotif).toHaveBeenCalledWith( - `Dropped allowance check\n` + - `Filler: ${submission.filler.name}\n` + - `Spender: ${submission.spender}\n` + - `Asset: ${submission.assetId}\n` + - `Ledger: ${submission.currLedger}` + `Dropped filler unwind\n` + `Filler: ${fillerPubkey}\n` + `Pool: ${mockPool.id}` ); }); }); diff --git a/test/collector.test.ts b/test/collector.test.ts index 158b75b..25a260e 100644 --- a/test/collector.test.ts +++ b/test/collector.test.ts @@ -1,4 +1,5 @@ import { createFilter } from '../src/collector'; +import { PoolConfig } from '../src/utils/config'; describe('createFilter', () => { it('should return an empty array when no pool ids are provided', () => { @@ -8,7 +9,7 @@ describe('createFilter', () => { }); it('should create a single filter with one contract ID when one pool config is provided', () => { - const poolConfigs: string[] = ['pool1']; + const poolConfigs: PoolConfig[] = [{ poolAddress: 'pool1' } as PoolConfig]; const expected = [ { @@ -22,7 +23,13 @@ describe('createFilter', () => { }); it('should create a single filter when pool configs are less than or equal to 5', () => { - const poolConfigs: string[] = ['pool1', 'pool2', 'pool3', 'pool4', 'pool5']; + const poolConfigs: PoolConfig[] = [ + { poolAddress: 'pool1' } as PoolConfig, + { poolAddress: 'pool2' } as PoolConfig, + { poolAddress: 'pool3' } as PoolConfig, + { poolAddress: 'pool4' } as PoolConfig, + { poolAddress: 'pool5' } as PoolConfig, + ]; const expected = [ { @@ -36,7 +43,15 @@ describe('createFilter', () => { }); it('should create multiple filters when pool configs are more than 5', () => { - const poolConfigs: string[] = ['pool1', 'pool2', 'pool3', 'pool4', 'pool5', 'pool6', 'pool7']; + const poolConfigs: PoolConfig[] = [ + { poolAddress: 'pool1' } as PoolConfig, + { poolAddress: 'pool2' } as PoolConfig, + { poolAddress: 'pool3' } as PoolConfig, + { poolAddress: 'pool4' } as PoolConfig, + { poolAddress: 'pool5' } as PoolConfig, + { poolAddress: 'pool6' } as PoolConfig, + { poolAddress: 'pool7' } as PoolConfig, + ]; const expected = [ { @@ -54,7 +69,10 @@ describe('createFilter', () => { }); it('should create exactly three filters for 11 pool configs', () => { - const poolConfigs: string[] = Array.from({ length: 11 }, (_, i) => `pool${i + 1}`); + const poolConfigs: PoolConfig[] = Array.from( + { length: 11 }, + (_, i) => ({ poolAddress: `pool${i + 1}` }) as PoolConfig + ); const result = createFilter(poolConfigs); diff --git a/test/filler.test.ts b/test/filler.test.ts index 7f44469..6f7ae78 100644 --- a/test/filler.test.ts +++ b/test/filler.test.ts @@ -9,16 +9,9 @@ import { } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; import { canFillerBid, getFillerProfitPct, managePositions } from '../src/filler'; -import { AuctionProfit, Filler } from '../src/utils/config'; +import { AppConfig, AuctionProfit, PoolConfig } from '../src/utils/config'; import { mockPool } from './helpers/mocks'; -jest.mock('../src/utils/config.js', () => { - return { - APP_CONFIG: { - networkPassphrase: 'Public Global Stellar Network ; September 2015', - }, - }; -}); jest.mock('../src/utils/logger.js', () => ({ logger: { error: jest.fn(), @@ -27,25 +20,63 @@ jest.mock('../src/utils/logger.js', () => ({ }, })); -describe('filler', () => { - describe('canFillerBid', () => { - it('returns true if the filler supports the auction', () => { - const filler: Filler = { - name: 'Teapot', - keypair: Keypair.random(), +jest.mock('../src/utils/config.js', () => { + let config: AppConfig = { + networkPassphrase: 'Public Global Stellar Network ; September 2015', + fillerKeypair: Keypair.random(), + pools: [ + { + poolAddress: 'POOL1', + primaryAsset: 'ASSET1', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + minHealthFactor: 1.5, defaultProfitPct: 0.1, - supportedPools: [ - { - poolAddress: mockPool.id, - primaryAsset: 'ASSET1', - minPrimaryCollateral: BigInt(100), - minHealthFactor: 1.5, - forceFill: true, - }, - ], + forceFill: true, supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], - }; + }, + { + poolAddress: 'POOL2', + primaryAsset: 'ASSET1', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + minHealthFactor: 1.5, + defaultProfitPct: 0.1, + forceFill: true, + supportedBid: ['ASSET3'], + supportedLot: ['ASSET0'], + }, + { + poolAddress: 'POOL3', + primaryAsset: 'ASSET1', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + minHealthFactor: 1.5, + defaultProfitPct: 0.1, + forceFill: true, + supportedBid: ['*'], + supportedLot: ['*'], + }, + ], + profits: [ + { + profitPct: 0.2, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET3'], + }, + { + profitPct: 0.3, + supportedBid: ['ASSET0', 'ASSET1'], + supportedLot: ['ASSET1', 'ASSET2'], + }, + ], + } as AppConfig; + return { + APP_CONFIG: config, + }; +}); + +describe('filler', () => { + describe('canFillerBid', () => { + it('returns true if the filler supports the auction', () => { const auctionData: AuctionData = { bid: new Map([ ['ASSET0', 100n], @@ -58,26 +89,11 @@ describe('filler', () => { block: 123, }; - const result = canFillerBid(filler, mockPool.id, auctionData); + const result = canFillerBid('POOL1', auctionData); expect(result).toBe(true); }); + it('returns false if the filler does not support the lot', () => { - 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([ ['ASSET0', 100n], @@ -90,27 +106,11 @@ describe('filler', () => { block: 123, }; - const result = canFillerBid(filler, mockPool.id, auctionData); + const result = canFillerBid('POOL1', auctionData); expect(result).toBe(false); }); it('returns false if the filler does not support the bid', () => { - 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], @@ -123,26 +123,11 @@ describe('filler', () => { block: 123, }; - const result = canFillerBid(filler, mockPool.id, auctionData); + const result = canFillerBid('POOL1', 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], @@ -155,41 +140,40 @@ describe('filler', () => { block: 123, }; - const result = canFillerBid(filler, 'UNKNOWN POOL', auctionData); + const result = canFillerBid('UNKNOWN POOL', auctionData); expect(result).toBe(false); }); + + it('returns true with wildcards', () => { + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET12345', 100n], + ['ASSET67890', 200n], + ]), + lot: new Map([ + ['ASSET12345', 100n], + ['ASSET67890', 200n], + ]), + block: 123, + }; + + const result = canFillerBid('POOL3', auctionData); + expect(result).toBe(true); + }); }); describe('getFillerProfitPct', () => { - it('gets profitPct from profit config if available', () => { - const filler: Filler = { - name: 'Teapot', - keypair: Keypair.random(), + 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'], + }; - 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 profits: AuctionProfit[] = [ - { - 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 = { bid: new Map([['ASSET0', 100n]]), lot: new Map([ @@ -198,79 +182,22 @@ describe('filler', () => { ]), block: 123, }; - - const result = getFillerProfitPct(filler, profits, auctionData); + const result = getFillerProfitPct(poolConfig, auctionData); expect(result).toBe(0.3); }); it('returns first matched profit', () => { - 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 profits: AuctionProfit[] = [ - { - profitPct: 0.2, - supportedBid: ['ASSET0', 'ASSET1'], - supportedLot: ['ASSET1', 'ASSET3'], - }, - { - profitPct: 0.3, - supportedBid: ['ASSET0', 'ASSET1'], - supportedLot: ['ASSET1', 'ASSET2'], - }, - ]; const auctionData: AuctionData = { bid: new Map([['ASSET0', 100n]]), lot: new Map([['ASSET1', 100n]]), block: 123, }; - const result = getFillerProfitPct(filler, profits, auctionData); + const result = getFillerProfitPct(poolConfig, auctionData); expect(result).toBe(0.2); }); it('returns default profit if bid does not match', () => { - 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 profits: AuctionProfit[] = [ - { - profitPct: 0.2, - supportedBid: ['ASSET0', 'ASSET1'], - supportedLot: ['ASSET1', 'ASSET3'], - }, - { - profitPct: 0.3, - supportedBid: ['ASSET0', 'ASSET1'], - supportedLot: ['ASSET1', 'ASSET2'], - }, - ]; const auctionData: AuctionData = { bid: new Map([ ['ASSET1', 100n], @@ -280,39 +207,11 @@ describe('filler', () => { block: 123, }; - const result = getFillerProfitPct(filler, profits, auctionData); + const result = getFillerProfitPct(poolConfig, auctionData); expect(result).toBe(0.1); }); it('returns default profit if lot does not match', () => { - 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 profits: AuctionProfit[] = [ - { - profitPct: 0.2, - supportedBid: ['ASSET0', 'ASSET1'], - supportedLot: ['ASSET1', 'ASSET3'], - }, - { - profitPct: 0.3, - supportedBid: ['ASSET0', 'ASSET1'], - supportedLot: ['ASSET1', 'ASSET2'], - }, - ]; const auctionData: AuctionData = { bid: new Map([['ASSET0', 100n]]), lot: new Map([ @@ -322,39 +221,7 @@ describe('filler', () => { block: 123, }; - const result = getFillerProfitPct(filler, profits, auctionData); - expect(result).toBe(0.1); - }); - - it('returns default profit if no auction profits defined', () => { - const filler: Filler = { - name: 'Teapot', - keypair: Keypair.random(), - - 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 profits: AuctionProfit[] = []; - const auctionData: AuctionData = { - bid: new Map([['ASSET0', 100n]]), - lot: new Map([ - ['ASSET1', 100n], - ['ASSET0', 200n], - ]), - block: 123, - }; - - const result = getFillerProfitPct(filler, profits, auctionData); + const result = getFillerProfitPct(poolConfig, auctionData); expect(result).toBe(0.1); }); }); @@ -371,19 +238,13 @@ describe('filler', () => { 7, 53255053 ); - const filler: Filler = { - name: 'Teapot', - keypair: Keypair.random(), + const poolConfig: PoolConfig = { defaultProfitPct: 0.1, - supportedPools: [ - { - poolAddress: mockPool.id, - primaryAsset: assets[1], - minPrimaryCollateral: FixedMath.toFixed(100, 7), - minHealthFactor: 1.5, - forceFill: true, - }, - ], + poolAddress: mockPool.id, + primaryAsset: assets[1], + minPrimaryCollateral: FixedMath.toFixed(100, 7), + minHealthFactor: 1.5, + forceFill: true, supportedBid: [assets[1], assets[0]], supportedLot: [assets[1], assets[2], assets[3]], }; @@ -403,7 +264,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -435,7 +296,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -462,7 +323,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -475,7 +336,7 @@ describe('filler', () => { }); it('can unwind looped positions', () => { - filler.supportedPools[0].minHealthFactor = 1.1; + poolConfig.minHealthFactor = 1.1; const positions = new Positions( // dTokens new Map([[1, FixedMath.toFixed(50000, 7)]]), @@ -490,9 +351,9 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); // return minimum health factor back to 1.5 - filler.supportedPools[0].minHealthFactor = 1.5; + poolConfig.minHealthFactor = 1.5; const expectedRequests: Request[] = [ { @@ -527,7 +388,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -559,7 +420,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = []; expect(requests).toEqual(expectedRequests); @@ -580,7 +441,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(0, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = []; expect(requests).toEqual(expectedRequests); @@ -607,7 +468,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(1, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { @@ -651,7 +512,7 @@ describe('filler', () => { [assets[3], FixedMath.toFixed(1, 7)], ]); - const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + const requests = managePositions(poolConfig, mockPool, mockOracle, positions, balances); const expectedRequests: Request[] = [ { diff --git a/test/interest.test.ts b/test/interest.test.ts index fe07547..96f3692 100644 --- a/test/interest.test.ts +++ b/test/interest.test.ts @@ -15,6 +15,7 @@ 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'; +import { PoolConfig } from '../src/utils/config.js'; jest.mock('../src/utils/soroban_helper.js'); jest.mock('../src/utils/logger.js', () => ({ @@ -28,42 +29,7 @@ jest.mock('../src/utils/config.js', () => { 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'], - }, - ], + fillerKeypair: Keypair.random(), }, }; }); @@ -71,12 +37,23 @@ jest.mock('../src/utils/config.js', () => { describe('checkPoolForInterestAuction', () => { let mockedSorobanHelper: jest.Mocked; let mockBackstopToken: BackstopToken; + let poolConfig: PoolConfig; beforeEach(() => { mockedSorobanHelper = new SorobanHelper() as jest.Mocked; mockBackstopToken = { lpTokenPrice: 0.5, } as BackstopToken; + poolConfig = { + poolAddress: 'pool1', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + primaryAsset: 'USD', + minHealthFactor: 1.1, + defaultProfitPct: 0.05, + forceFill: true, + supportedBid: ['asset1', 'asset2', 'asset3', 'backstopTokenAddress'], + supportedLot: ['asset1', 'asset2', 'asset3'], + }; }); it('returns interest auction creation submission happy path', async () => { @@ -93,10 +70,10 @@ describe('checkPoolForInterestAuction', () => { mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); - // backstop token balance for filler + // usdc balance for filler mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); - const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool1'); + const result = await checkPoolForInterestAuction(mockedSorobanHelper, poolConfig); expect(result).toEqual({ type: WorkSubmissionType.AuctionCreation, @@ -108,7 +85,8 @@ describe('checkPoolForInterestAuction', () => { lot: ['asset3', 'asset1'], }); }); - it('returns undefined if filler does not have enough backstop tokens', async () => { + + it('returns undefined if filler does not have enough usdc', async () => { const assets = ['asset1', 'asset2', 'asset3']; const backstopCredit = [BigInt(100e7), BigInt(2e7), BigInt(300e7)]; @@ -122,15 +100,16 @@ describe('checkPoolForInterestAuction', () => { 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)); + // usdc balance for filler + // -> auctionv val is ~301 + mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(300e7)); - const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool1'); + const result = await checkPoolForInterestAuction(mockedSorobanHelper, poolConfig); expect(result).toBeUndefined(); }); - it('returns undefined if no filler supports included assets', async () => { + + it('returns undefined if config does not support included assets', async () => { const assets = ['asset1', 'asset2', 'asset3', 'asset4']; const backstopCredit = [BigInt(100e7), BigInt(2e7), BigInt(300e7), BigInt(100e7)]; @@ -144,16 +123,20 @@ describe('checkPoolForInterestAuction', () => { mockedSorobanHelper.loadPoolOracle.mockResolvedValue(poolOracle); mockedSorobanHelper.loadBackstopToken.mockResolvedValue(mockBackstopToken); - // backstop token balance for filler + // usdc balance for filler mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(5000e7)); - const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool1'); + const result = await checkPoolForInterestAuction(mockedSorobanHelper, poolConfig); expect(result).toBeUndefined(); }); + it('returns interest auction creation submission max 3 assets', async () => { const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + poolConfig.poolAddress = 'pool2'; + poolConfig.supportedLot = assets; + const backstopCredit = [BigInt(105e7), BigInt(10e7), BigInt(200e7), BigInt(100e7)]; const decimals = [7, 7, 7, 7]; const pool = buildPoolObject('pool2', assets, backstopCredit, decimals); @@ -168,7 +151,7 @@ describe('checkPoolForInterestAuction', () => { // backstop token balance for filler mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); - const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool2'); + const result = await checkPoolForInterestAuction(mockedSorobanHelper, poolConfig); expect(result).toEqual({ type: WorkSubmissionType.AuctionCreation, @@ -180,9 +163,13 @@ describe('checkPoolForInterestAuction', () => { lot: ['asset4', 'asset1', 'asset3'], }); }); + it('returns interest auction creation submission respects pool max positions', async () => { const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + poolConfig.poolAddress = 'pool2'; + poolConfig.supportedLot = assets; + const backstopCredit = [BigInt(105e7), BigInt(10e7), BigInt(200e7), BigInt(100e7)]; const decimals = [7, 7, 7, 7]; const pool = buildPoolObject('pool2', assets, backstopCredit, decimals); @@ -198,7 +185,7 @@ describe('checkPoolForInterestAuction', () => { // backstop token balance for filler mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); - const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool2'); + const result = await checkPoolForInterestAuction(mockedSorobanHelper, poolConfig); expect(result).toEqual({ type: WorkSubmissionType.AuctionCreation, @@ -210,9 +197,13 @@ describe('checkPoolForInterestAuction', () => { lot: ['asset4', 'asset1'], }); }); + it('returns undefined respects pool max positions', async () => { const assets = ['asset1', 'asset2', 'asset3', 'asset4']; + poolConfig.poolAddress = 'pool2'; + poolConfig.supportedLot = assets; + const backstopCredit = [BigInt(105e7), BigInt(10e7), BigInt(200e7), BigInt(60e7)]; const decimals = [7, 7, 7, 7]; const pool = buildPoolObject('pool2', assets, backstopCredit, decimals); @@ -228,7 +219,7 @@ describe('checkPoolForInterestAuction', () => { // backstop token balance for filler mockedSorobanHelper.simBalance.mockResolvedValue(BigInt(1000e7)); - const result = await checkPoolForInterestAuction(mockedSorobanHelper, 'pool2'); + const result = await checkPoolForInterestAuction(mockedSorobanHelper, poolConfig); expect(result).toBeUndefined(); }); diff --git a/test/liquidations.test.ts b/test/liquidations.test.ts index 9e615ec..8b9b128 100644 --- a/test/liquidations.test.ts +++ b/test/liquidations.test.ts @@ -44,7 +44,14 @@ jest.mock('../src/utils/config.js', () => { APP_CONFIG: { backstopAddress: 'backstopAddress', backstopTokenAddress: 'backstopTokenAddress', - pools: ['pool1', 'pool2'], + pools: [ + { + poolAddress: 'pool1', + }, + { + poolAddress: 'pool2', + }, + ], }, }; }); diff --git a/test/pool_event_handler.test.ts b/test/pool_event_handler.test.ts index 3249dc2..f3ec12d 100644 --- a/test/pool_event_handler.test.ts +++ b/test/pool_event_handler.test.ts @@ -33,40 +33,17 @@ jest.mock('../src/utils/logger.js', () => ({ jest.mock('../src/utils/config.js', () => { let config: AppConfig = { backstopAddress: Keypair.random().publicKey(), - pools: ['mockPoolId'], - fillers: [ + fillerKeypair: Keypair.random(), + pools: [ { - name: 'filler1', - keypair: Keypair.random(), + poolAddress: 'CBP7NO6F7FRDHSOFQBT2L2UWYIZ2PU76JKVRYAQTG3KZSQLYAOKIF2WB', + primaryAsset: 'USD', + minPrimaryCollateral: FixedMath.toFixed(100, 7), + minHealthFactor: 1.1, defaultProfitPct: 0.05, - supportedPools: [ - { - poolAddress: 'mockPoolId', - minPrimaryCollateral: FixedMath.toFixed(100, 7), - primaryAsset: 'USD', - minHealthFactor: 1.1, - forceFill: true, - }, - ], - supportedBid: ['USD', 'BTC', 'LP'], - supportedLot: ['USD', 'BTC', 'ETH'], - }, - { - name: 'filler2', - keypair: Keypair.random(), - defaultProfitPct: 0.08, - - supportedPools: [ - { - poolAddress: 'mockPoolId', - minPrimaryCollateral: FixedMath.toFixed(100, 7), - primaryAsset: 'USD', - minHealthFactor: 1.1, - forceFill: true, - }, - ], - supportedBid: ['USD', 'ETH', 'XLM'], - supportedLot: ['USD', 'ETH', 'XLM'], + forceFill: true, + supportedBid: ['USD', 'BTC', 'ETH', 'XLM', 'LP'], + supportedLot: ['USD', 'ETH', 'XLM', 'LP'], }, ], } as AppConfig; @@ -125,7 +102,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -152,7 +129,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -186,7 +163,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -215,7 +192,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -256,7 +233,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -281,7 +258,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -306,7 +283,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -331,7 +308,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -349,7 +326,7 @@ describe('poolEventHandler', () => { expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); - it('finds filler and tracks auction for new liquidation event', async () => { + it('tracks auction for new liquidation event', async () => { let user = Keypair.random().publicKey(); let ledger = 12345; let poolEvent: PoolEventEvent = { @@ -357,7 +334,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -376,26 +353,26 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - let auctionEntry = db.getAuctionEntry('mockPoolId', user, AuctionType.Liquidation); + let auctionEntry = db.getAuctionEntry(mockPool.id, user, AuctionType.Liquidation); if (auctionEntry === undefined) { - fail('Auction entry not inserted'); + throw new Error('Auction entry not inserted'); } expect(auctionEntry.user_id).toEqual(user); expect(auctionEntry.auction_type).toEqual(AuctionType.Liquidation); - expect(auctionEntry.filler).toEqual(APP_CONFIG.fillers[1].keypair.publicKey()); + expect(auctionEntry.filler).toEqual(APP_CONFIG.fillerKeypair.publicKey()); expect(auctionEntry.start_block).toEqual(500); expect(auctionEntry.fill_block).toEqual(0); expect(auctionEntry.updated).toEqual(ledger); }); - it('finds filler and tracks auction for new interest auction event', async () => { + it('tracks auction for new interest auction event', async () => { let ledger = 12345; let poolEvent: PoolEventEvent = { timestamp: 777, type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -415,29 +392,29 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); let auctionEntry = db.getAuctionEntry( - 'mockPoolId', + mockPool.id, APP_CONFIG.backstopAddress, AuctionType.Interest ); if (auctionEntry === undefined) { - fail('Auction entry not inserted'); + throw new Error('Auction entry not inserted'); } expect(auctionEntry.user_id).toEqual(APP_CONFIG.backstopAddress); expect(auctionEntry.auction_type).toEqual(AuctionType.Interest); - expect(auctionEntry.filler).toEqual(APP_CONFIG.fillers[0].keypair.publicKey()); + expect(auctionEntry.filler).toEqual(APP_CONFIG.fillerKeypair.publicKey()); expect(auctionEntry.start_block).toEqual(500); expect(auctionEntry.fill_block).toEqual(0); expect(auctionEntry.updated).toEqual(ledger); }); - it('finds filler and tracks auction for new bad debt auction event', async () => { + it('tracks auction for new bad debt auction event', async () => { let ledger = 12345; let poolEvent: PoolEventEvent = { timestamp: 777, type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -445,7 +422,7 @@ describe('poolEventHandler', () => { eventType: PoolEventType.NewAuction, auctionData: { bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), + lot: new Map([['LP', BigInt(456)]]), block: 500, }, auctionType: AuctionType.BadDebt, @@ -457,23 +434,22 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); let auctionEntry = db.getAuctionEntry( - 'mockPoolId', + mockPool.id, APP_CONFIG.backstopAddress, AuctionType.BadDebt ); if (auctionEntry === undefined) { - fail('Auction entry not inserted'); + throw new Error('Auction entry not inserted'); } expect(auctionEntry.user_id).toEqual(APP_CONFIG.backstopAddress); expect(auctionEntry.auction_type).toEqual(AuctionType.BadDebt); - // prioritize the first filler - expect(auctionEntry.filler).toEqual(APP_CONFIG.fillers[0].keypair.publicKey()); + expect(auctionEntry.filler).toEqual(APP_CONFIG.fillerKeypair.publicKey()); expect(auctionEntry.start_block).toEqual(500); expect(auctionEntry.fill_block).toEqual(0); expect(auctionEntry.updated).toEqual(ledger); }); - it('ignores new auction event if no eligible filler is found', async () => { + it('ignores new auction event if bid and lot is not supported', async () => { let user = Keypair.random().publicKey(); let ledger = 12345; let poolEvent: PoolEventEvent = { @@ -481,7 +457,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -508,19 +484,19 @@ describe('poolEventHandler', () => { let other_user = Keypair.random().publicKey(); let user = Keypair.random().publicKey(); let auction: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: other_user, auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 500, fill_block: 650, updated: 12345, }; let auction_to_be_deleted: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: user, auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 600, fill_block: 800, updated: 12344, @@ -537,7 +513,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -551,26 +527,26 @@ describe('poolEventHandler', () => { let auctionEntries = db.getAllAuctionEntries(); expect(auctionEntries.length).toEqual(1); - let deletedAuction = db.getAuctionEntry('mockPoolId', user, AuctionType.Liquidation); + let deletedAuction = db.getAuctionEntry(mockPool.id, user, AuctionType.Liquidation); expect(deletedAuction).toBeUndefined(); }); it('deletes fill auction and updates user safely for liquidation fill auction event', async () => { let other_user = Keypair.random().publicKey(); let other_auction: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: other_user, auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 700, fill_block: 850, updated: 12345, }; let auction_to_be_filled: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: pool_user, auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 600, fill_block: 800, updated: 12344, @@ -586,7 +562,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -610,7 +586,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -628,7 +604,7 @@ describe('poolEventHandler', () => { let entries = db.getAllAuctionEntries(); expect(entries.length).toEqual(1); - let deletedAuction = db.getAuctionEntry('mockPoolId', pool_user, AuctionType.Liquidation); + let deletedAuction = db.getAuctionEntry(mockPool.id, pool_user, AuctionType.Liquidation); expect(deletedAuction).toBeUndefined(); expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, 12350); }); @@ -636,19 +612,19 @@ describe('poolEventHandler', () => { it('deletes fill auction for other fill auction event', async () => { let other_user = Keypair.random().publicKey(); let other_auction: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: other_user, auction_type: AuctionType.Liquidation, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 700, fill_block: 850, updated: 12345, }; let auction_to_be_filled: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: APP_CONFIG.backstopAddress, auction_type: AuctionType.Interest, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 600, fill_block: 800, updated: 12344, @@ -664,7 +640,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -683,7 +659,7 @@ describe('poolEventHandler', () => { let entries = db.getAllAuctionEntries(); expect(entries.length).toEqual(1); let deletedAuction = db.getAuctionEntry( - 'mockPoolId', + mockPool.id, APP_CONFIG.backstopAddress, AuctionType.Interest ); @@ -697,7 +673,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -717,10 +693,10 @@ describe('poolEventHandler', () => { it('Sends check user event for backstop on bad debt fills', async () => { let auction_to_be_filled: AuctionEntry = { - pool_id: 'mockPoolId', + pool_id: mockPool.id, user_id: APP_CONFIG.backstopAddress, auction_type: AuctionType.BadDebt, - filler: APP_CONFIG.fillers[0].keypair.publicKey(), + filler: APP_CONFIG.fillerKeypair.publicKey(), start_block: 600, fill_block: 800, updated: 12344, @@ -732,7 +708,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -751,7 +727,7 @@ describe('poolEventHandler', () => { expect(mockedSendEvent).toHaveBeenCalledWith(mockedWorkerProcess, { type: EventType.CHECK_USER, timestamp: Date.now(), - poolId: 'mockPoolId', + poolId: mockPool.id, userId: APP_CONFIG.backstopAddress, }); }); @@ -762,7 +738,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockPoolId', + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -779,7 +755,7 @@ describe('poolEventHandler', () => { expect(mockedSendEvent).toHaveBeenCalledWith(mockedWorkerProcess, { type: EventType.CHECK_USER, timestamp: Date.now(), - poolId: 'mockPoolId', + poolId: mockPool.id, userId: APP_CONFIG.backstopAddress, }); }); diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index 8118bc4..e97b5d5 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -3,8 +3,7 @@ import { Keypair } from '@stellar/stellar-sdk'; import { validateAppConfig, validateAuctionProfit, - validateFiller, - validatePoolFillerConfig, + validatePoolConfig, validatePriceSource, } from '../../src/utils/config'; @@ -41,22 +40,17 @@ describe('validateAppConfig', () => { backstopTokenAddress: 'token', usdcAddress: 'usdc', blndAddress: 'blnd', - keypair: Keypair.random().secret(), - pools: ['pool'], - fillers: [ + interestFillerAddress: 'filler', + workerKeypair: Keypair.random().secret(), + fillerKeypair: Keypair.random().secret(), + pools: [ { - name: 'filler', - keypair: Keypair.random().secret(), + poolAddress: 'pool', defaultProfitPct: 1, - supportedPools: [ - { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: '100', - minHealthFactor: 1, - forceFill: true, - }, - ], + minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', + forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], }, @@ -84,22 +78,17 @@ describe('validateAppConfig', () => { backstopTokenAddress: 'token', usdcAddress: 'usdc', blndAddress: 'blnd', - keypair: Keypair.random().secret(), - pools: ['pool'], - fillers: [ + interestFillerAddress: 'filler', + workerKeypair: Keypair.random().secret(), + fillerKeypair: Keypair.random().secret(), + pools: [ { - name: 'filler', - keypair: Keypair.random().secret(), + poolAddress: 'pool', defaultProfitPct: 1, - supportedPools: [ - { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: '100', - minHealthFactor: 1, - forceFill: true, - }, - ], + minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', + forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], }, @@ -120,22 +109,17 @@ describe('validateAppConfig', () => { backstopTokenAddress: 'token', usdcAddress: 'usdc', blndAddress: 'blnd', - keypair: Keypair.random().secret(), - pools: ['pool'], - fillers: [ + interestFillerAddress: 'filler', + workerKeypair: Keypair.random().secret(), + fillerKeypair: Keypair.random().secret(), + pools: [ { - name: 'filler', - keypair: Keypair.random().secret(), + poolAddress: 'pool', defaultProfitPct: 1, - supportedPools: [ - { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: '100', - minHealthFactor: 1, - forceFill: true, - }, - ], + minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', + forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], }, @@ -150,55 +134,38 @@ describe('validateAppConfig', () => { }); }); -describe('validateFiller', () => { - it('should return false for non-object filler', () => { - expect(validateFiller(null)).toBe(false); - expect(validateFiller('string')).toBe(false); +describe('validatePoolConfig', () => { + it('should return false for non-object pool config', () => { + expect(validatePoolConfig(null)).toBe(false); + expect(validatePoolConfig('string')).toBe(false); }); - it('should return false for filler with missing or incorrect properties', () => { - const invalidFiller = { - name: 'filler', - keypair: 'secret', - defaultProfitPct: 1, - supportedPools: [ - { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: '100', - minHealthFactor: 1, - forceFill: true, - }, - ], + it('should return false for pool config with missing or incorrect properties', () => { + const invalidPoolConfig = { + poolAddress: 'pool', + defaultProfitPct: '1', // Invalid type + minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], - supportedLot: 123, // Invalid type + supportedLot: ['lot'], }; - expect(validateFiller(invalidFiller)).toBe(false); + expect(validatePoolConfig(invalidPoolConfig)).toBe(false); }); it('should return true for valid filler', () => { - const validFiller = { - name: 'filler', - keypair: Keypair.random().secret(), + const validPoolConfig = { + poolAddress: 'pool', defaultProfitPct: 1, minHealthFactor: 1, primaryAsset: 'asset', minPrimaryCollateral: '100', forceFill: true, - supportedPools: [ - { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: '100', - minHealthFactor: 1, - forceFill: true, - }, - ], supportedBid: ['bid'], supportedLot: ['lot'], }; - expect(validateFiller(validFiller)).toBe(true); + expect(validatePoolConfig(validPoolConfig)).toBe(true); }); }); @@ -273,32 +240,3 @@ describe('validateAuctionProfit', () => { expect(validateAuctionProfit(validProfits)).toBe(true); }); }); - -describe('validatePoolConfig', () => { - it('should return false for non-object config', () => { - expect(validateAppConfig(null)).toBe(false); - expect(validateAppConfig('string')).toBe(false); - }); - - it('should return false for config with missing or incorrect properties', () => { - const invalidConfig = { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: 100, // Invalid type - minHealthFactor: 1, - forceFill: true, - }; - expect(validatePoolFillerConfig(invalidConfig)).toBe(false); - }); - - it('should return true for valid config', () => { - const validConfig = { - poolAddress: 'pool', - primaryAsset: 'asset', - minPrimaryCollateral: '100', - minHealthFactor: 1, - forceFill: true, - }; - expect(validatePoolFillerConfig(validConfig)).toBe(true); - }); -}); diff --git a/test/work_handler.test.ts b/test/work_handler.test.ts index 165c8b0..9c924ac 100644 --- a/test/work_handler.test.ts +++ b/test/work_handler.test.ts @@ -6,7 +6,7 @@ import { AuctioneerDatabase, AuctionType, UserEntry } from '../src/utils/db'; import { SorobanHelper } from '../src/utils/soroban_helper'; import { WorkHandler } from '../src/work_handler'; import { WorkSubmission, WorkSubmissionType, WorkSubmitter } from '../src/work_submitter'; -import { AppConfig, APP_CONFIG } from '../src/utils/config'; +import { AppConfig, APP_CONFIG, PoolConfig } from '../src/utils/config'; jest.mock('../src/utils/prices'); jest.mock('../src/liquidations'); @@ -16,12 +16,20 @@ jest.mock('../src/utils/logger'); jest.mock('../src/utils/soroban_helper'); jest.mock('../src/utils/config.js', () => { let config: AppConfig = { - pools: ['pool1', 'pool2'], + pools: [ + { + poolAddress: 'pool1', + } as PoolConfig, + { + poolAddress: 'pool2', + } as PoolConfig, + ], } as AppConfig; return { APP_CONFIG: config, }; }); + describe('WorkHandler', () => { let db: jest.Mocked; let submissionQueue: jest.Mocked; @@ -81,7 +89,7 @@ describe('WorkHandler', () => { ]; const liquidations: WorkSubmission[] = [ { - poolId: APP_CONFIG.pools[0], + poolId: APP_CONFIG.pools[0].poolAddress, user: 'user1', type: WorkSubmissionType.AuctionCreation, auctionType: AuctionType.Liquidation, @@ -90,7 +98,7 @@ describe('WorkHandler', () => { auctionPercent: 10, }, { - poolId: APP_CONFIG.pools[1], + poolId: APP_CONFIG.pools[1].poolAddress, user: 'user1', type: WorkSubmissionType.AuctionCreation, auctionType: AuctionType.Liquidation, @@ -107,13 +115,16 @@ describe('WorkHandler', () => { await workHandler.processEvent(appEvent); expect(oracleHistory.getSignificantPriceChanges).toHaveBeenCalledWith(poolOracle); - for (const poolId of APP_CONFIG.pools) { - expect(sorobanHelper.loadPoolOracle).toHaveBeenCalledWith(poolId); - expect(db.getUserEntriesWithLiability).toHaveBeenCalledWith(poolId, 'asset1'); - expect(db.getUserEntriesWithCollateral).toHaveBeenCalledWith(poolId, 'asset2'); - expect(checkUsersForLiquidationsAndBadDebt).toHaveBeenCalledWith(db, sorobanHelper, poolId, [ - usersWithCollateral[0].user_id, - ]); + for (const { poolAddress } of APP_CONFIG.pools) { + expect(sorobanHelper.loadPoolOracle).toHaveBeenCalledWith(poolAddress); + expect(db.getUserEntriesWithLiability).toHaveBeenCalledWith(poolAddress, 'asset1'); + expect(db.getUserEntriesWithCollateral).toHaveBeenCalledWith(poolAddress, 'asset2'); + expect(checkUsersForLiquidationsAndBadDebt).toHaveBeenCalledWith( + db, + sorobanHelper, + poolAddress, + [usersWithCollateral[0].user_id] + ); } expect(submissionQueue.addSubmission).toHaveBeenCalledWith(liquidations[0], 3); expect(submissionQueue.addSubmission).toHaveBeenCalledWith(liquidations[1], 3);