diff --git a/README.md b/README.md index 44ce641..632791b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ For an example config file that is configured to interact with [Blend v1 mainnet | `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!** | | `fillers` | A list of accounts that will bid and fill on auctions. | -| `priceSources` | A list of assets that will have prices sourced from exchanges instead of the pool oracle. | +| `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. | #### Fillers @@ -64,8 +65,10 @@ The `fillers` array contains configurations for individual filler accounts. The |-------|-------------| | `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!** | -| `minProfitPct` | The minimum profit percentage required for the filler to bid on an auction. | -| `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions. | +| `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%) | +| `minHealthFactor` | The minimum health factor the filler will take on during liquidation and bad debt auctions, as calculated by `collateral / liabilities`. | +| `minPrimaryCollateral` | The minimum amount of the primary asset the Filler will maintain as collateral in the pool. | | `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 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. | @@ -86,6 +89,18 @@ Each price source has the following fields: | `type` | The type of price source (e.g., "coinbase", "binance"). | | `symbol` | The trading symbol used by the price source for this asset. | +#### Profits + +The `profits` list defines target profit percentages based on the assets that make up the bid and lot of a given auction. This allows fillers to have flexability in the profit they target. The profit percentage chosen will be the first entry in the `profits` list that supports all bid and lot assets in the auction. If no profit entry is found, the `defaultProfitPct` value defined by the filler will be used. + +Each profit entry has the following fields: + +| Field | Description | +|-------|-------------| +| `profitPct` | The profit percentage required to bid for the auction, as a decimal. (e.g. 0.08 = 8%) | +| `supportedBid` | An array of asset addresses that the auction bid can contain for this `profitPct` to be used. If any auction bid asset exists outside this list, the `profitPct` will not be used. | +| `supportedLot` | An array of asset addresses that the auction lot can contain for this `profitPct` to be used. If any auction lot asset exists outside this list, the `profitPct` will not be used. | + ## Build If you make modifications to the bot, you can build a new dockerfile by running: diff --git a/example.config.json b/example.config.json index 9ffd4f9..242b70b 100644 --- a/example.config.json +++ b/example.config.json @@ -12,9 +12,11 @@ { "name": "example-liquidator", "keypair": "S...", - "minProfitPct": 0.10, + "defaultProfitPct": 0.10, "minHealthFactor": 1.5, "forceFill": true, + "primaryAsset": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "minPrimaryCollateral": "10000000000", "supportedBid": [ "CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM", "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", @@ -27,6 +29,19 @@ ] } ], + "profits": [ + { + "profitPct": 0.05, + "supportedBid": [ + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75" + ], + "supportedLot": [ + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75" + ] + } + ], "priceSources": [ { "assetId": "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", diff --git a/package-lock.json b/package-lock.json index ca839a4..f3b5739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "auctioneer-bot", - "version": "0.0.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "auctioneer-bot", - "version": "0.0.0", + "version": "0.2.0", "license": "MIT", "dependencies": { - "@blend-capital/blend-sdk": "^2.0.3", - "@stellar/stellar-sdk": "^12.3.0", + "@blend-capital/blend-sdk": "2.2.0", + "@stellar/stellar-sdk": "13.0.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", "winston-daily-rotate-file": "^5.0.0" @@ -548,29 +548,16 @@ "dev": true }, "node_modules/@blend-capital/blend-sdk": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.0.3.tgz", - "integrity": "sha512-KdtHfTNA+9RVuL9nXixLYsyNw8zieKXESPyaVPE7tQ+OX5fxOKXCXZXuS3z9ohiJJBzwARoMgI6IfgEw01JJhQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@blend-capital/blend-sdk/-/blend-sdk-2.2.0.tgz", + "integrity": "sha512-S2P7D1Y45IKBk381gvPWt7rv587B2FUY9Vd8KyXpxkpcB8/IgFeCItoVBidqIW5vbsAG3T1fwHJbcI3bUfFlpw==", + "license": "MIT", "dependencies": { - "@stellar/stellar-sdk": "12.2.0", + "@stellar/stellar-sdk": "13.0.0", "buffer": "6.0.3", "follow-redirects": ">=1.15.6" } }, - "node_modules/@blend-capital/blend-sdk/node_modules/@stellar/stellar-sdk": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.2.0.tgz", - "integrity": "sha512-Wy5sDOqb5JvAC76f4sQIV6Pe3JNyZb0PuyVNjwt3/uWsjtxRkFk6s2yTHTefBLWoR+mKxDjO7QfzhycF1v8FXQ==", - "dependencies": { - "@stellar/stellar-base": "^12.1.0", - "axios": "^1.7.2", - "bignumber.js": "^9.1.2", - "eventsource": "^2.0.2", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -967,12 +954,14 @@ "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==" + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" }, "node_modules/@stellar/stellar-base": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.1.tgz", - "integrity": "sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.0.1.tgz", + "integrity": "sha512-Xbd12mc9Oj/130Tv0URmm3wXG77XMshZtZ2yNCjqX5ZbMD5IYpbBs3DVCteLU/4SLj/Fnmhh1dzhrQXnk4r+pQ==", + "license": "Apache-2.0", "dependencies": { "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", @@ -982,18 +971,20 @@ "tweetnacl": "^1.0.3" }, "optionalDependencies": { - "sodium-native": "^4.1.1" + "sodium-native": "^4.3.0" } }, "node_modules/@stellar/stellar-sdk": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.3.0.tgz", - "integrity": "sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.0.0.tgz", + "integrity": "sha512-+wvmKi+XWwu27nLYTM17EgBdpbKohEkOfCIK4XKfsI4WpMXAqvnqSm98i9h5dAblNB+w8BJqzGs1JY0PtTGm4A==", + "license": "Apache-2.0", "dependencies": { - "@stellar/stellar-base": "^12.1.1", + "@stellar/stellar-base": "^13.0.1", "axios": "^1.7.7", "bignumber.js": "^9.1.2", "eventsource": "^2.0.2", + "feaxios": "^0.0.20", "randombytes": "^2.1.0", "toml": "^3.0.0", "urijs": "^1.19.1" @@ -1205,12 +1196,13 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" }, "node_modules/axios": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", - "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.8.tgz", + "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -1335,6 +1327,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -1372,6 +1365,7 @@ "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", "engines": { "node": "*" } @@ -1510,6 +1504,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" @@ -1701,6 +1696,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -1821,6 +1817,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -1947,6 +1944,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", "engines": { "node": ">=12.0.0" } @@ -2022,6 +2020,15 @@ "bser": "2.1.1" } }, + "node_modules/feaxios": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.20.tgz", + "integrity": "sha512-g3hm2YDNffNxA3Re3Hd8ahbpmDee9Fv1Pb1C/NoWsjY7mtD8nyNeJytUzn+DK0Hyl9o6HppeWOrtnqgmhOYfWA==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -2101,15 +2108,16 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -2120,9 +2128,10 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -2398,6 +2407,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3265,6 +3286,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -3273,6 +3295,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -3372,9 +3395,10 @@ } }, "node_modules/node-gyp-build": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.2.tgz", - "integrity": "sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", "optional": true, "bin": { "node-gyp-build": "bin.js", @@ -3677,7 +3701,8 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" }, "node_modules/pump": { "version": "3.0.0", @@ -3708,6 +3733,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -3849,6 +3875,7 @@ "version": "2.4.11", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "license": "(MIT AND BSD-3-Clause)", "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" @@ -3956,10 +3983,10 @@ } }, "node_modules/sodium-native": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.2.0.tgz", - "integrity": "sha512-rdJRAf/RE/IRFUUoUsz10slNAQDTGz5ChpIeR1Ti0BtGYstl6Uok4hHALPBdnFcLml6qXJ2pDd0/De09mPa6mg==", - "hasInstallScript": true, + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.1.tgz", + "integrity": "sha512-YdP64gAdpIKHfL4ttuX4aIfjeunh9f+hNeQJpE9C8UMndB3zkgZ7YmmGT4J2+v6Ibyp6Wem8D1TcSrtdW0bqtg==", + "license": "MIT", "optional": true, "dependencies": { "node-gyp-build": "^4.8.0" @@ -4186,7 +4213,8 @@ "node_modules/toml": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" }, "node_modules/triple-beam": { "version": "1.4.1", @@ -4270,7 +4298,8 @@ "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" }, "node_modules/type-detect": { "version": "4.0.8", @@ -4345,7 +4374,8 @@ "node_modules/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" }, "node_modules/util-deprecate": { "version": "1.0.2", diff --git a/package.json b/package.json index 6cc5bb0..1436ad3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auctioneer-bot", - "version": "0.0.0", + "version": "0.2.0", "main": "index.js", "type": "module", "scripts": { @@ -25,8 +25,8 @@ "typescript": "^5.5.4" }, "dependencies": { - "@blend-capital/blend-sdk": "^2.0.3", - "@stellar/stellar-sdk": "^12.3.0", + "@blend-capital/blend-sdk": "2.2.0", + "@stellar/stellar-sdk": "13.0.0", "better-sqlite3": "^11.1.2", "winston": "^3.13.1", "winston-daily-rotate-file": "^5.0.0" diff --git a/src/auction.ts b/src/auction.ts index 00fe6b7..3beeafe 100644 --- a/src/auction.ts +++ b/src/auction.ts @@ -1,16 +1,30 @@ -import { AuctionData, FixedMath, Request, RequestType } from '@blend-capital/blend-sdk'; -import { Asset } from '@stellar/stellar-sdk'; -import { AuctionBid } from './bidder_submitter.js'; +import { + Auction, + AuctionType, + FixedMath, + Pool, + PoolOracle, + Request, + RequestType, +} from '@blend-capital/blend-sdk'; +import { getFillerAvailableBalances, getFillerProfitPct } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; -import { AuctioneerDatabase, AuctionType } from './utils/db.js'; +import { AuctioneerDatabase } from './utils/db.js'; +import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; -export interface FillCalculation { +export interface AuctionFill { // The block number to fill the auction at - fillBlock: number; + block: number; // The percent of the auction to fill - fillPercent: number; + percent: number; + // The expected lot value paid by the filler + lotValue: number; + // The expected bid value the filler will receive + bidValue: number; + // The requests to fill the auction + requests: Request[]; } export interface AuctionValue { @@ -20,178 +34,273 @@ export interface AuctionValue { bidValue: number; } +export async function calculateAuctionFill( + filler: Filler, + auction: Auction, + nextLedger: number, + sorobanHelper: SorobanHelper, + db: AuctioneerDatabase +): Promise { + try { + const pool = await sorobanHelper.loadPool(); + const poolOracle = await sorobanHelper.loadPoolOracle(); + + const auctionValue = await calculateAuctionValue(auction, pool, poolOracle, sorobanHelper, db); + return await calculateBlockFillAndPercent( + filler, + auction, + auctionValue, + pool, + poolOracle, + nextLedger, + sorobanHelper + ); + } catch (e: any) { + logger.error(`Error calculating auction fill.`, e); + throw e; + } +} + /** * Calculate the block fill and fill percent for a given auction. * * @param filler - The filler to calculate the block fill for - * @param auctionType - The type of auction to calculate the block fill for - * @param auctionData - The auction data 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, - auctionType: AuctionType, - auctionData: AuctionData, - sorobanHelper: SorobanHelper, - db: AuctioneerDatabase -): Promise { - // Sum the effective collateral and lot value - let { effectiveCollateral, effectiveLiabilities, lotValue, bidValue } = - await calculateAuctionValue(auctionType, auctionData, sorobanHelper, db); + auction: Auction, + auctionValue: AuctionValue, + pool: Pool, + poolOracle: PoolOracle, + nextLedger: number, + sorobanHelper: SorobanHelper +): Promise { let fillBlockDelay = 0; let fillPercent = 100; - logger.info( - `Auction Valuation: Effective Collateral: ${effectiveCollateral}, Effective Liabilities: ${effectiveLiabilities}, Lot Value: ${lotValue}, Bid Value: ${bidValue}` + let requests: Request[] = []; + + // get relevant assets for the auction + const relevant_assets = []; + switch (auction.type) { + case AuctionType.Liquidation: + relevant_assets.push(...Array.from(auction.data.lot.keys())); + relevant_assets.push(...Array.from(auction.data.bid.keys())); + relevant_assets.push(filler.primaryAsset); + break; + case AuctionType.Interest: + relevant_assets.push(APP_CONFIG.backstopTokenAddress); + break; + case AuctionType.BadDebt: + relevant_assets.push(...Array.from(auction.data.bid.keys())); + relevant_assets.push(filler.primaryAsset); + break; + } + const fillerBalances = await getFillerAvailableBalances( + filler, + [...new Set(relevant_assets)], + sorobanHelper ); - if (lotValue >= bidValue * (1 + filler.minProfitPct)) { - const minLotAmount = bidValue * (1 + filler.minProfitPct); + + // auction value is the full auction + 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); + if (lotValue >= bidValue * (1 + profitPercent)) { + const minLotAmount = bidValue * (1 + profitPercent); fillBlockDelay = 200 - (lotValue - minLotAmount) / (lotValue / 200); } else { - const maxBidAmount = lotValue * (1 - filler.minProfitPct); + const maxBidAmount = lotValue * (1 - profitPercent); fillBlockDelay = 200 + (bidValue - maxBidAmount) / (bidValue / 200); } fillBlockDelay = Math.min(Math.max(Math.ceil(fillBlockDelay), 0), 400); - // Ensure the filler can fully fill interest auctions - if (auctionType === AuctionType.Interest) { - const cometLpTokenBalance = FixedMath.toFloat( - await sorobanHelper.simBalance(APP_CONFIG.backstopTokenAddress, filler.keypair.publicKey()), - 7 - ); - const cometLpBid = - fillBlockDelay <= 200 - ? FixedMath.toFloat(auctionData.bid.get(APP_CONFIG.backstopTokenAddress)!, 7) - : FixedMath.toFloat(auctionData.bid.get(APP_CONFIG.backstopTokenAddress)!, 7) * - (1 - (fillBlockDelay - 200) / 200); + // apply force fill auction boundries to profit calculations + if (filler.forceFill) { + fillBlockDelay = Math.min(fillBlockDelay, 350); + } + + // if calculated fillBlock has already passed, adjust fillBlock to the next ledger + if (auction.data.block + fillBlockDelay < nextLedger) { + fillBlockDelay = Math.min(nextLedger - auction.data.block, 400); + } + + let bidScalar = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; + let lotScalar = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; - if (cometLpTokenBalance < cometLpBid) { - const additionalCometLp = cometLpBid - cometLpTokenBalance; - const bidStepSize = - FixedMath.toFloat(auctionData.bid.get(APP_CONFIG.backstopTokenAddress)!, 7) / 200; + const [scaledAuction] = auction.scale(auction.data.block + fillBlockDelay, 100); + + // require that the filler can fully fill interest auctions + 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) { - fillBlockDelay += Math.ceil(additionalCometLp / bidStepSize); - fillBlockDelay = Math.min(fillBlockDelay, 400); + const additionalDelay = Math.ceil(additionalCometLp / bidStepSize); + fillBlockDelay = Math.min(400, fillBlockDelay + additionalDelay); } } - } - // Ensure the filler can maintain their minimum health factor - else { + } else if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) { const { estimate: fillerPositionEstimates } = await sorobanHelper.loadUserPositionEstimate( filler.keypair.publicKey() ); - if (fillBlockDelay <= 200) { - effectiveCollateral = effectiveCollateral * (fillBlockDelay / 200); - } else { - effectiveLiabilities = effectiveLiabilities * (1 - (fillBlockDelay - 200) / 200); - } - if (effectiveCollateral < effectiveLiabilities) { - const excessLiabilities = effectiveLiabilities - effectiveCollateral; - const liabilityLimitToHF = - fillerPositionEstimates.totalEffectiveCollateral / filler.minHealthFactor - - fillerPositionEstimates.totalEffectiveLiabilities; + let canFillWithSafeHF = false; + let iterations = 0; + while (!canFillWithSafeHF && iterations < 5) { + const loopFillerBalances = new Map(fillerBalances); + requests = []; + logger.info( + `Calculating auction fill iteration ${iterations} with delay ${fillBlockDelay} and percent ${fillPercent}` + ); + const [loopScaledAuction] = auction.scale(auction.data.block + fillBlockDelay, fillPercent); + iterations++; + // 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 = filler.minHealthFactor * 1.1; + let limitToHF = + (fillerPositionEstimates.totalEffectiveCollateral + additionalCollateral) / + safeHealthFactor - + (fillerPositionEstimates.totalEffectiveLiabilities + additionalLiabilities); + let liabilitiesRepaid = 0; + let collateralAdded = 0; - if (excessLiabilities > liabilityLimitToHF) { - fillPercent = Math.min( - fillPercent, - Math.floor((liabilityLimitToHF / excessLiabilities) * 100) - ); - } - } - } + logger.info( + `Auction value: ${stringify(auctionValue)}. Bid scalar: ${bidScalar}. Lot scalar: ${lotScalar}. Limit to HF: ${limitToHF}` + ); - if (auctionType === AuctionType.Liquidation && filler.forceFill) { - fillBlockDelay = Math.min(fillBlockDelay, 198); - } else if (auctionType === AuctionType.Interest && filler.forceFill) { - fillBlockDelay = Math.min(fillBlockDelay, 350); - } - return { fillBlock: auctionData.block + fillBlockDelay, fillPercent }; -} + // attempt to repay any liabilities the filler has took on from the bids + for (const [assetId, amount] of loopScaledAuction.data.bid) { + const balance = loopFillerBalances.get(assetId) ?? 0n; + if (balance > 0n) { + const reserve = pool.reserves.get(assetId); + const oraclePrice = poolOracle.getPriceFloat(assetId); + if (reserve !== undefined && oraclePrice !== undefined) { + // 100n prevents dust positions from being created, and is deducted from the repaid liability + const amountAsUnderlying = reserve.toAssetFromDToken(amount) + 100n; + const repaidLiability = amountAsUnderlying <= balance ? amountAsUnderlying : balance; + const effectiveLiability = + FixedMath.toFloat(repaidLiability - 100n, reserve.config.decimals) * + reserve.getLiabilityFactor() * + oraclePrice; + limitToHF += effectiveLiability; + liabilitiesRepaid += effectiveLiability; + loopFillerBalances.set(assetId, balance - repaidLiability); + requests.push({ + request_type: RequestType.Repay, + address: assetId, + amount: repaidLiability, + }); + } + } + } -/** - * Check if the filler can bid on an auction. - * @param filler - The filler to check - * @param auctionData - The auction data for the auction - * @returns A boolean indicating if the filler cares about the auction. - */ -export function canFillerBid(filler: Filler, auctionData: AuctionData): boolean { - // validate lot - for (const [assetId, _] of auctionData.lot) { - if (!filler.supportedLot.some((address) => assetId === address)) { - return false; - } - } - // validate bid - for (const [assetId, _] of auctionData.bid) { - if (!filler.supportedBid.some((address) => assetId === address)) { - return false; - } - } - return true; -} + // withdraw any collateral that has no CF to reduce position count + if (auction.type === AuctionType.Liquidation) { + for (const [assetId] of loopScaledAuction.data.lot) { + const reserve = pool.reserves.get(assetId); + if (reserve !== undefined && reserve.getCollateralFactor() === 0) { + requests.push({ + request_type: RequestType.WithdrawCollateral, + address: assetId, + amount: BigInt('9223372036854775807'), + }); + } + } + } -/** - * Scale an auction to the block the auction is to be filled and the percent which will be filled. - * @param auction - The auction to scale - * @param fillBlock - The block to scale to - * @param fillPercent - The percent to scale to - * @returns The scaled auction - */ -export function scaleAuction( - auction: AuctionData, - fillBlock: number, - fillPercent: number -): AuctionData { - let scaledAuction: AuctionData = { - block: fillBlock, - bid: new Map(), - lot: new Map(), - }; - let lotModifier; - let bidModifier; - const fillBlockDelta = fillBlock - auction.block; - if (fillBlockDelta <= 200) { - lotModifier = fillBlockDelta / 200; - bidModifier = 1; - } else { - lotModifier = 1; - if (fillBlockDelta < 400) { - bidModifier = 1 - (fillBlockDelta - 200) / 200; - } else { - bidModifier = 0; - } - } + 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(filler.primaryAsset) ?? 0n; + const primaryReserve = pool.reserves.get(filler.primaryAsset); + const primaryOraclePrice = poolOracle.getPriceFloat(filler.primaryAsset); + if ( + primaryReserve !== undefined && + primaryOraclePrice !== undefined && + primaryBalance > 0n + ) { + const primaryCollateralRequired = Math.ceil( + (Math.abs(limitToHF) / (primaryReserve.getCollateralFactor() * primaryOraclePrice)) * + safeHealthFactor + ); + const primaryBalFloat = FixedMath.toFloat(primaryBalance, primaryReserve.config.decimals); + const primaryDeposit = Math.min(primaryBalFloat, primaryCollateralRequired); + const collateral = + primaryDeposit * primaryReserve.getCollateralFactor() * primaryOraclePrice; + limitToHF += collateral / safeHealthFactor; + collateralAdded += collateral; + requests.push({ + request_type: RequestType.SupplyCollateral, + address: filler.primaryAsset, + amount: FixedMath.toFixed(primaryDeposit, primaryReserve.config.decimals), + }); + } - for (const [assetId, amount] of auction.lot) { - const scaledLot = Math.floor((Number(amount) * lotModifier * fillPercent) / 100); - if (scaledLot > 0) { - scaledAuction.lot.set(assetId, BigInt(scaledLot)); + if (limitToHF < 0) { + const preBorrowLimit = Math.max( + (fillerPositionEstimates.totalEffectiveCollateral + collateralAdded) / + safeHealthFactor - + (fillerPositionEstimates.totalEffectiveLiabilities - liabilitiesRepaid), + 0 + ); + const incomingLiabilities = + additionalLiabilities - additionalCollateral / safeHealthFactor; + const adjustedFillPercent = Math.floor( + Math.min(1, preBorrowLimit / incomingLiabilities) * fillPercent + ); + if (adjustedFillPercent < 1) { + // filler can't take on additional liabilities even with reduced fill percent. Push back fill block until + // more collateral is received than liabilities taken on, or no liabilities are taken on + const excessLiabilitiesAtBlock200 = + fillerPositionEstimates.totalEffectiveLiabilities + + auctionValue.effectiveLiabilities - + liabilitiesRepaid - + (fillerPositionEstimates.totalEffectiveCollateral + + auctionValue.effectiveCollateral + + collateralAdded) / + safeHealthFactor; + const blockDelay = + Math.ceil( + 100 * (Math.abs(excessLiabilitiesAtBlock200) / auctionValue.effectiveLiabilities) + ) / 0.5; + fillBlockDelay = Math.min(200 + blockDelay, 400); + logger.info( + `Unable to fill auction at expected profit due to insufficient health factor. Auction fill at block 200 exceeds HF borrow limit by $${excessLiabilitiesAtBlock200}, adding block delay of ${blockDelay}.` + ); + canFillWithSafeHF = true; + continue; + } else if (adjustedFillPercent < fillPercent) { + fillPercent = adjustedFillPercent; + logger.info( + `Unable to fill auction at 100% due to insufficient health factor. Auction fill exceeds HF borrow limit by $${limitToHF}. Dropping fill percent to ${fillPercent}.` + ); + } else { + canFillWithSafeHF = true; + continue; + } + } else { + canFillWithSafeHF = true; + continue; + } + } else { + canFillWithSafeHF = true; + continue; + } } - } - for (const [assetId, amount] of auction.bid) { - const scaledBid = Math.ceil((Number(amount) * bidModifier * fillPercent) / 100); - if (scaledBid > 0) { - scaledAuction.bid.set(assetId, BigInt(scaledBid)); + if (!canFillWithSafeHF) { + logger.error(`Unable to determine auction fill with a safe HF.`); + throw new Error('Unable to determine auction fill with a safe HF.'); } } - return scaledAuction; -} -/** - * Build requests to fill the auction and clear the filler's position. - * @param auctionBid - The auction to build the fill requests for - * @param auctionData - The auction data to build the fill requests for - * @param fillPercent - The percent to fill the auction - * @param sorobanHelper - The soroban helper to use for loading ledger data - * @returns - */ -export async function buildFillRequests( - auctionBid: AuctionBid, - auctionData: AuctionData, - fillPercent: number, - sorobanHelper: SorobanHelper -): Promise { - let fillRequests: Request[] = []; let requestType: RequestType; - switch (auctionBid.auctionEntry.auction_type) { + switch (scaledAuction.type) { case AuctionType.Liquidation: requestType = RequestType.FillUserLiquidationAuction; break; @@ -202,92 +311,38 @@ export async function buildFillRequests( requestType = RequestType.FillBadDebtAuction; break; } - fillRequests.push({ + // push the fill request on the front of the list + requests.unshift({ request_type: requestType, - address: auctionBid.auctionEntry.user_id, + address: auction.user, amount: BigInt(fillPercent), }); - const poolOracle = await sorobanHelper.loadPoolOracle(); - // Interest auctions transfer underlying assets - if (auctionBid.auctionEntry.auction_type !== AuctionType.Interest) { - let { estimate: fillerPositionEstimates, user: fillerPositions } = - await sorobanHelper.loadUserPositionEstimate(auctionBid.auctionEntry.filler); - const reserves = (await sorobanHelper.loadPool()).reserves; - - for (const [assetId, amount] of auctionData.bid) { - const oraclePrice = poolOracle.getPriceFloat(assetId); - // Skip assets without an oracle price - // TODO: determine what to do with assets without an oracle price - // (e.g. just repay debt if wallet balance is sufficient or check if asset is in price db) - if (oraclePrice === undefined) { - continue; - } - const reserve = reserves.get(assetId); - let fillerBalance = await sorobanHelper.simBalance(assetId, auctionBid.auctionEntry.filler); - - // Ensure the filler has XLM to pay for the transaction - if (assetId === Asset.native().contractId(APP_CONFIG.networkPassphrase)) { - fillerBalance = - fillerBalance > FixedMath.toFixed(100, 7) - ? fillerBalance - FixedMath.toFixed(100, 7) - : 0n; - } - if (reserve !== undefined) { - const liabilityLeft = amount - fillerBalance > 0 ? amount - fillerBalance : 0n; - const effectiveLiabilityIncrease = - reserve.toEffectiveAssetFromDTokenFloat(liabilityLeft) * oraclePrice; - fillerPositionEstimates.totalEffectiveLiabilities += effectiveLiabilityIncrease; - if (fillerBalance > 0) { - fillRequests.push({ - request_type: RequestType.Repay, - address: assetId, - amount: BigInt(fillerBalance), - }); - } - } - } - - for (const [assetId, amount] of auctionData.lot) { - const reserve = reserves.get(assetId); - const oraclePrice = poolOracle.getPriceFloat(assetId); - if ( - reserve !== undefined && - !fillerPositions.positions.collateral.has(reserve.config.index) && - oraclePrice !== undefined - ) { - const effectiveCollateralIncrease = - reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; - const newHF = - fillerPositionEstimates.totalEffectiveCollateral / - fillerPositionEstimates.totalEffectiveLiabilities; - if (newHF > auctionBid.filler.minHealthFactor) { - fillRequests.push({ - request_type: RequestType.WithdrawCollateral, - address: assetId, - // Use I64 max value to withdraw all collateral - amount: BigInt('9223372036854775807'), - }); - } else { - fillerPositionEstimates.totalEffectiveCollateral += effectiveCollateralIncrease; - } - } - } - } - return fillRequests; + bidScalar = fillBlockDelay <= 200 ? 1 : 1 - Math.max(0, fillBlockDelay - 200) / 200; + lotScalar = fillBlockDelay < 200 ? fillBlockDelay / 200 : 1; + return { + block: auction.data.block + fillBlockDelay, + percent: fillPercent, + requests, + lotValue: lotValue * lotScalar * (fillPercent / 100), + bidValue: bidValue * bidScalar * (fillPercent / 100), + }; } /** * Calculate the effective collateral, lot value, effective liabilities, and bid value for an auction. - * @param auctionType - The type of auction to calculate the values for - * @param auctionData - The auction data to calculate the values for + * + * @param auction - The auction to calculate the values for + * @param pool - The pool to use for fetching reserve data + * @param poolOracle - The pool oracle to use for fetching asset prices * @param sorobanHelper - A helper to use for loading ledger data * @param db - The database to use for fetching asset prices - * @returns + * @returns The calculated values, or 0 for all values if it is unable to calculate them */ export async function calculateAuctionValue( - auctionType: AuctionType, - auctionData: AuctionData, + auction: Auction, + pool: Pool, + poolOracle: PoolOracle, sorobanHelper: SorobanHelper, db: AuctioneerDatabase ): Promise { @@ -295,65 +350,57 @@ export async function calculateAuctionValue( let lotValue = 0; let effectiveLiabilities = 0; let bidValue = 0; - const reserves = (await sorobanHelper.loadPool()).reserves; - const poolOracle = await sorobanHelper.loadPoolOracle(); - for (const [assetId, amount] of auctionData.lot) { - const reserve = reserves.get(assetId); - if (reserve !== undefined) { + const reserves = pool.reserves; + for (const [assetId, amount] of auction.data.lot) { + if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.Interest) { + const reserve = reserves.get(assetId); + if (reserve === undefined) { + throw new Error(`Unexpected auction. Lot contains asset that is not a reserve: ${assetId}`); + } const oraclePrice = poolOracle.getPriceFloat(assetId); const dbPrice = db.getPriceEntry(assetId)?.price; if (oraclePrice === undefined) { throw new Error(`Failed to get oracle price for asset: ${assetId}`); } - - if (auctionType !== AuctionType.Interest) { + if (auction.type === AuctionType.Liquidation) { + // liquidation auction lots are in bTokens effectiveCollateral += reserve.toEffectiveAssetFromBTokenFloat(amount) * oraclePrice; - // TODO: change this to use the price in the db lotValue += reserve.toAssetFromBTokenFloat(amount) * (dbPrice ?? oraclePrice); + } else { + lotValue += FixedMath.toFloat(amount, reserve.config.decimals) * (dbPrice ?? oraclePrice); } - // Interest auctions are in underlying assets - else { - lotValue += - (Number(amount) / 10 ** reserve.tokenMetadata.decimals) * (dbPrice ?? oraclePrice); - } - } else if (assetId === APP_CONFIG.backstopTokenAddress) { - // Simulate singled sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); - if (lpTokenValue !== undefined) { - lotValue += FixedMath.toFloat(lpTokenValue, 7); - } - // Approximate the value of the comet tokens if simulation fails - else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - lotValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; + } else if (auction.type === AuctionType.BadDebt) { + if (assetId !== APP_CONFIG.backstopTokenAddress) { + throw new Error( + `Unexpected bad debt auction. Lot contains asset other than the backstop token: ${assetId}` + ); } + lotValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); } else { throw new Error(`Failed to value lot asset: ${assetId}`); } } - for (const [assetId, amount] of auctionData.bid) { - const reserve = reserves.get(assetId); - const dbPrice = db.getPriceEntry(assetId)?.price; - - if (reserve !== undefined) { + for (const [assetId, amount] of auction.data.bid) { + if (auction.type === AuctionType.Liquidation || auction.type === AuctionType.BadDebt) { + const reserve = reserves.get(assetId); + if (reserve === undefined) { + throw new Error(`Unexpected auction. Bid contains asset that is not a reserve: ${assetId}`); + } + const dbPrice = db.getPriceEntry(assetId)?.price; const oraclePrice = poolOracle.getPriceFloat(assetId); if (oraclePrice === undefined) { throw new Error(`Failed to get oracle price for asset: ${assetId}`); } - effectiveLiabilities += reserve.toEffectiveAssetFromDTokenFloat(amount) * oraclePrice; - // TODO: change this to use the price in the db bidValue += reserve.toAssetFromDTokenFloat(amount) * (dbPrice ?? oraclePrice); - } else if (assetId === APP_CONFIG.backstopTokenAddress) { - // Simulate singled sided withdraw to USDC - const lpTokenValue = await sorobanHelper.simLPTokenToUSDC(amount); - if (lpTokenValue !== undefined) { - bidValue += FixedMath.toFloat(lpTokenValue, 7); - } else { - const backstopToken = await sorobanHelper.loadBackstopToken(); - bidValue += FixedMath.toFloat(amount, 7) * backstopToken.lpTokenPrice; + } else if (auction.type === AuctionType.Interest) { + if (assetId !== APP_CONFIG.backstopTokenAddress) { + throw new Error( + `Unexpected interest auction. Bid contains asset other than the backstop token: ${assetId}` + ); } + bidValue += await valueBackstopTokenInUSDC(sorobanHelper, amount); } else { throw new Error(`Failed to value bid asset: ${assetId}`); } @@ -361,3 +408,23 @@ 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(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 6ea7b7e..5f5c99c 100644 --- a/src/bidder_handler.ts +++ b/src/bidder_handler.ts @@ -1,4 +1,4 @@ -import { calculateBlockFillAndPercent } from './auction.js'; +import { calculateAuctionFill } from './auction.js'; import { AuctionBid, BidderSubmissionType, BidderSubmitter } from './bidder_submitter.js'; import { AppEvent, EventType } from './events.js'; import { APP_CONFIG } from './utils/config.js'; @@ -33,64 +33,66 @@ export class BidderHandler { const nextLedger = appEvent.ledger + 1; const auctions = this.db.getAllAuctionEntries(); - for (let auction of auctions) { + for (let auctionEntry of auctions) { try { const filler = APP_CONFIG.fillers.find( - (f) => f.keypair.publicKey() === auction.filler + (f) => f.keypair.publicKey() === auctionEntry.filler ); if (filler === undefined) { - logger.error(`Filler not found for auction: ${stringify(auction)}`); + logger.error(`Filler not found for auction: ${stringify(auctionEntry)}`); continue; } - if (this.submissionQueue.containsAuction(auction)) { + if (this.submissionQueue.containsAuction(auctionEntry)) { // auction already being bid on continue; } - const ledgersToFill = auction.fill_block - nextLedger; - if (auction.fill_block === 0 || ledgersToFill <= 5 || ledgersToFill % 10 === 0) { + const ledgersToFill = auctionEntry.fill_block - nextLedger; + if (auctionEntry.fill_block === 0 || ledgersToFill <= 5 || ledgersToFill % 10 === 0) { // recalculate the auction - const auctionData = await this.sorobanHelper.loadAuction( - auction.user_id, - auction.auction_type + const auction = await this.sorobanHelper.loadAuction( + auctionEntry.user_id, + auctionEntry.auction_type ); - if (auctionData === undefined) { - this.db.deleteAuctionEntry(auction.user_id, auction.auction_type); + if (auction === undefined) { + logger.info( + `Auction not found. Assuming auction was deleted or filled. Deleting auction: ${auctionEntry.user_id}, ${auctionEntry.auction_type}` + ); + this.db.deleteAuctionEntry(auctionEntry.user_id, auctionEntry.auction_type); continue; } - const fillCalculation = await calculateBlockFillAndPercent( + const fill = await calculateAuctionFill( filler, - auction.auction_type, - auctionData, + auction, + nextLedger, this.sorobanHelper, this.db ); const logMessage = `Auction Calculation\n` + - `Type: ${AuctionType[auction.auction_type]}\n` + - `User: ${auction.user_id}\n` + - `Calculation: ${stringify(fillCalculation, 2)}\n` + - `Ledgers To Fill In: ${fillCalculation.fillBlock - nextLedger}\n`; - if (auction.fill_block === 0) { + `Type: ${AuctionType[auction.type]}\n` + + `User: ${auction.user}\n` + + `Fill: ${stringify(fill, 2)}\n` + + `Ledgers To Fill In: ${fill.block - nextLedger}\n`; + if (auctionEntry.fill_block === 0) { await sendSlackNotification(logMessage); } logger.info(logMessage); - auction.fill_block = fillCalculation.fillBlock; - auction.updated = appEvent.ledger; - this.db.setAuctionEntry(auction); + auctionEntry.fill_block = fill.block; + auctionEntry.updated = appEvent.ledger; + this.db.setAuctionEntry(auctionEntry); } - - if (auction.fill_block <= nextLedger) { + if (auctionEntry.fill_block <= nextLedger) { let submission: AuctionBid = { type: BidderSubmissionType.BID, filler: filler, - auctionEntry: auction, + auctionEntry: auctionEntry, }; this.submissionQueue.addSubmission(submission, 10); } } catch (e: any) { - logger.error(`Error processing block for auction: ${stringify(auction)}`, e); + logger.error(`Error processing block for auction: ${stringify(auctionEntry)}`, e); } } } catch (err) { diff --git a/src/bidder_submitter.ts b/src/bidder_submitter.ts index 9b2b718..99ef5ce 100644 --- a/src/bidder_submitter.ts +++ b/src/bidder_submitter.ts @@ -1,14 +1,10 @@ import { PoolContract } from '@blend-capital/blend-sdk'; -import { SorobanRpc } from '@stellar/stellar-sdk'; -import { - buildFillRequests, - calculateAuctionValue, - calculateBlockFillAndPercent, - scaleAuction, -} from './auction.js'; +import { rpc } from '@stellar/stellar-sdk'; +import { calculateAuctionFill } from './auction.js'; +import { getFillerAvailableBalances, managePositions } from './filler.js'; import { APP_CONFIG, Filler } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; -import { stringify } from './utils/json.js'; +import { serializeError, stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; @@ -74,43 +70,34 @@ export class BidderSubmitter extends SubmissionQueue { async submitBid(sorobanHelper: SorobanHelper, auctionBid: AuctionBid): Promise { try { + logger.info(`Submitting bid for auction ${stringify(auctionBid.auctionEntry, 2)}`); const currLedger = ( - await new SorobanRpc.Server( + await new rpc.Server( sorobanHelper.network.rpc, sorobanHelper.network.opts ).getLatestLedger() ).sequence; + const nextLedger = currLedger + 1; - const auctionData = await sorobanHelper.loadAuction( + const auction = await sorobanHelper.loadAuction( auctionBid.auctionEntry.user_id, auctionBid.auctionEntry.auction_type ); - // Auction has been filled remove from the database - if (auctionData === undefined) { - this.db.deleteAuctionEntry( - auctionBid.auctionEntry.user_id, - auctionBid.auctionEntry.auction_type - ); + if (auction === undefined) { + // allow bidder handler to re-process the auction entry return true; } - const fillCalculation = await calculateBlockFillAndPercent( + const fill = await calculateAuctionFill( auctionBid.filler, - auctionBid.auctionEntry.auction_type, - auctionData, + auction, + nextLedger, sorobanHelper, this.db ); - if (currLedger + 1 >= fillCalculation.fillBlock) { - let scaledAuction = scaleAuction(auctionData, currLedger, fillCalculation.fillPercent); - const requests = await buildFillRequests( - auctionBid, - scaledAuction, - fillCalculation.fillPercent, - sorobanHelper - ); + if (nextLedger >= fill.block) { const pool = new PoolContract(APP_CONFIG.poolAddress); const result = await sorobanHelper.submitTransaction( @@ -118,57 +105,123 @@ export class BidderSubmitter extends SubmissionQueue { from: auctionBid.auctionEntry.filler, spender: auctionBid.auctionEntry.filler, to: auctionBid.auctionEntry.filler, - requests: requests, + requests: fill.requests, }), auctionBid.filler.keypair ); - const filledAuction = scaleAuction(auctionData, result.ledger, fillCalculation.fillPercent); - const filledAuctionValue = await calculateAuctionValue( - auctionBid.auctionEntry.auction_type, - filledAuction, - sorobanHelper, - this.db - ); + const [scaledAuction] = auction.scale(result.ledger, fill.percent); + this.db.setFilledAuctionEntry({ + tx_hash: result.txHash, + filler: auctionBid.auctionEntry.filler, + user_id: auctionBid.auctionEntry.user_id, + auction_type: auctionBid.auctionEntry.auction_type, + bid: scaledAuction.data.bid, + bid_total: fill.bidValue, + lot: scaledAuction.data.lot, + lot_total: fill.lotValue, + est_profit: fill.lotValue - fill.bidValue, + fill_block: result.ledger, + timestamp: result.latestLedgerCloseTime, + }); + this.addSubmission({ type: BidderSubmissionType.UNWIND, filler: auctionBid.filler }, 2); let logMessage = `Successful bid on auction\n` + `Type: ${AuctionType[auctionBid.auctionEntry.auction_type]}\n` + `User: ${auctionBid.auctionEntry.user_id}\n` + `Filler: ${auctionBid.filler.name}\n` + - `Fill Percent ${fillCalculation.fillPercent}\n` + + `Fill Percent ${fill.percent}\n` + `Ledger Fill Delta ${result.ledger - auctionBid.auctionEntry.start_block}\n` + `Hash ${result.txHash}\n`; await sendSlackNotification(logMessage); logger.info(logMessage); - this.db.setFilledAuctionEntry({ - tx_hash: result.txHash, - filler: auctionBid.auctionEntry.filler, - user_id: auctionBid.auctionEntry.filler, - auction_type: auctionBid.auctionEntry.auction_type, - bid: filledAuction.bid, - bid_total: filledAuctionValue.bidValue, - lot: filledAuction.lot, - lot_total: filledAuctionValue.lotValue, - est_profit: filledAuctionValue.lotValue - filledAuctionValue.bidValue, - fill_block: result.ledger, - timestamp: result.latestLedgerCloseTime, - }); return true; } + // allow bidder handler to re-process the auction entry return true; } catch (e: any) { const logMessage = `Error submitting fill for auction\n` + `Type: ${auctionBid.auctionEntry.auction_type}\n` + `User: ${auctionBid.auctionEntry.user_id}\n` + - `Filler: ${auctionBid.filler.name}\nError: ${e}\n`; - await sendSlackNotification(`` + logMessage); - logger.error(logMessage); + `Filler: ${auctionBid.filler.name}`; + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); + logger.error(logMessage, e); return false; } } async submitUnwind(sorobanHelper: SorobanHelper, fillerUnwind: FillerUnwind): Promise { - logger.warn('Filler unwind is not implemented.'); + logger.info(`Submitting unwind for filler ${fillerUnwind.filler.keypair.publicKey()}`); + const filler_pubkey = fillerUnwind.filler.keypair.publicKey(); + const filler_tokens = [ + ...new Set([ + fillerUnwind.filler.primaryAsset, + ...fillerUnwind.filler.supportedBid, + ...fillerUnwind.filler.supportedLot, + ]), + ]; + const pool = await sorobanHelper.loadPool(); + const poolOracle = await sorobanHelper.loadPoolOracle(); + const filler_user = await sorobanHelper.loadUser(filler_pubkey); + const filler_balances = await getFillerAvailableBalances( + fillerUnwind.filler, + filler_tokens, + sorobanHelper + ); + + // 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_contract = new PoolContract(APP_CONFIG.poolAddress); + const result = await sorobanHelper.submitTransaction( + pool_contract.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` + + `Ledger: ${result.ledger}\n` + + `Hash: ${result.txHash}` + ); + this.addSubmission({ type: BidderSubmissionType.UNWIND, filler: fillerUnwind.filler }, 2); + return true; + } + + if (filler_user.positions.liabilities.size > 0) { + const logMessage = + `Filler has liabilities that cannot be removed\n` + + `Filler: ${fillerUnwind.filler.name}\n` + + `Positions: ${stringify(filler_user.positions, 2)}`; + logger.info(logMessage); + await sendSlackNotification(logMessage); + return true; + } + + logger.info(`Filler has no positions to manage, stopping unwind events.`); return true; } diff --git a/src/collector.ts b/src/collector.ts index 319b139..776d7b7 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -1,5 +1,5 @@ import { poolEventFromEventResponse } from '@blend-capital/blend-sdk'; -import { SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; import { ChildProcess } from 'child_process'; import { EventType, @@ -16,11 +16,13 @@ import { stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendEvent } from './utils/messages.js'; +let startup_ledger = 0; + export async function runCollector( worker: ChildProcess, bidder: ChildProcess, db: AuctioneerDatabase, - rpc: SorobanRpc.Server, + stellarRpc: rpc.Server, poolAddress: string, poolEventHandler: PoolEventHandler ) { @@ -29,7 +31,7 @@ export async function runCollector( if (!statusEntry) { statusEntry = { name: 'collector', latest_ledger: 0 }; } - const latestLedger = (await rpc.getLatestLedger()).sequence; + const latestLedger = (await stellarRpc.getLatestLedger()).sequence; if (latestLedger > statusEntry.latest_ledger) { logger.info(`Processing ledger ${latestLedger}`); // new ledger detected @@ -40,8 +42,15 @@ export async function runCollector( }; sendEvent(bidder, ledger_event); + // determine ledgers since bot was started to send long running work events + // this staggers the events from different bots running on the same pool + if (startup_ledger === 0) { + startup_ledger = latestLedger; + } + const ledgersProcessed = latestLedger - startup_ledger; + // send long running work events to worker - if (latestLedger % 10 === 0) { + if (ledgersProcessed % 10 === 0) { // approx every minute const event: PriceUpdateEvent = { type: EventType.PRICE_UPDATE, @@ -49,7 +58,7 @@ export async function runCollector( }; sendEvent(worker, event); } - if (latestLedger % 60 === 0) { + if (ledgersProcessed % 60 === 0) { // approx every 5m // send an oracle scan event const event: OracleScanEvent = { @@ -58,7 +67,7 @@ export async function runCollector( }; sendEvent(worker, event); } - if (latestLedger % 1203 === 0) { + if (ledgersProcessed % 1203 === 0) { // approx every 2hr // send a user update event to update any users that have not been updated in ~2 weeks const event: UserRefreshEvent = { @@ -68,7 +77,7 @@ export async function runCollector( }; sendEvent(worker, event); } - if (latestLedger % 1207 === 0) { + if (ledgersProcessed % 1207 === 0) { // approx every 2hr // send a liq scan event const event: LiqScanEvent = { @@ -84,9 +93,9 @@ 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); - let events: SorobanRpc.Api.RawGetEventsResponse; + let events: rpc.Api.RawGetEventsResponse; try { - events = await rpc._getEvents({ + events = await stellarRpc._getEvents({ startLedger: start_ledger, filters: [ { @@ -100,9 +109,10 @@ export async function runCollector( // Handles the case where the rpc server is restarted and no longer has events from the start ledger we requested if (e.code === -32600) { logger.error( - `Error fetching events at start ledger: ${start_ledger}, retrying with latest ledger ${latestLedger} Error: ${e}` + `Error fetching events at start ledger: ${start_ledger}, retrying with latest ledger ${latestLedger}`, + e ); - events = await rpc._getEvents({ + events = await stellarRpc._getEvents({ startLedger: latestLedger, filters: [ { @@ -132,7 +142,7 @@ export async function runCollector( } } cursor = events.events[events.events.length - 1].pagingToken; - events = await rpc._getEvents({ + events = await stellarRpc._getEvents({ cursor: cursor, filters: [ { diff --git a/src/filler.ts b/src/filler.ts new file mode 100644 index 0000000..12ec671 --- /dev/null +++ b/src/filler.ts @@ -0,0 +1,238 @@ +import { + AuctionData, + FixedMath, + Pool, + PoolOracle, + Positions, + PositionsEstimate, + Request, + RequestType, + Reserve, +} from '@blend-capital/blend-sdk'; +import { Asset } from '@stellar/stellar-sdk'; +import { APP_CONFIG, AuctionProfit, Filler } from './utils/config.js'; +import { stringify } from './utils/json.js'; +import { logger } from './utils/logger.js'; +import { SorobanHelper } from './utils/soroban_helper.js'; + +/** + * Check if the filler supports bidding on the auction. + * @param filler - The filler to check + * @param auctionData - The auction data for the auction + * @returns A boolean indicating if the filler cares about the auction. + */ +export function canFillerBid(filler: Filler, auctionData: AuctionData): boolean { + // validate lot + for (const [assetId, _] of auctionData.lot) { + if (!filler.supportedLot.some((address) => assetId === address)) { + return false; + } + } + // validate bid + for (const [assetId, _] of auctionData.bid) { + if (!filler.supportedBid.some((address) => assetId === address)) { + return false; + } + } + return true; +} + +/** + * Get the profit percentage the filler should bid at for the auction. + * @param filler - 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 { + let bidAssets = Array.from(auctionData.bid.keys()); + let lotAssets = Array.from(auctionData.lot.keys()); + for (const profit of auctionProfits) { + if ( + bidAssets.some((address) => !profit.supportedBid.includes(address)) || + 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; +} + +/** + * Fetch the available balances for a filler. Takes into account any minimum balances required by the filler. + * @param filler - The filler + * @param assets - The assets to fetch balances for + * @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 xlm_address = Asset.native().contractId(APP_CONFIG.networkPassphrase); + const xlm_bal = balances.get(xlm_address); + if (xlm_bal !== undefined) { + const safe_xlm_bal = + xlm_bal > FixedMath.toFixed(50, 7) ? xlm_bal - FixedMath.toFixed(50, 7) : 0n; + balances.set(xlm_address, safe_xlm_bal); + } + return balances; +} + +/** + * Manage a filler's positions in the pool. Returns an array of requests to be submitted to the network. This function + * will attempt to repay liabilities with the filler's assets, and withdraw any unnecessary collateral, up to either the min + * collateral balance or the min health factor. + * + * Note - some buffer is applied to ensure that subsequent calls to "managePositions" does not create dust. + * + * @param filler - The filler + * @param pool - The pool + * @param poolOracle - The pool's oracle object + * @param poolUser - The filler's pool user object + * @param balances - The filler's balances. This should be fetched from `getFillerAvailableBalances` to ensure + * minimum balances are respected. + * @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, + pool: Pool, + poolOracle: PoolOracle, + positions: Positions, + balances: Map +): Request[] { + let requests: Request[] = []; + const positionsEst = PositionsEstimate.build(pool, poolOracle, positions); + let effectiveLiabilities = positionsEst.totalEffectiveLiabilities; + let effectiveCollateral = positionsEst.totalEffectiveCollateral; + + const hasLeftoverLiabilities: number[] = []; + // attempt to repay any liabilities the filler has + for (const [assetIndex, amount] of positions.liabilities) { + const reserve = pool.reserves.get(pool.config.reserveList[assetIndex]); + // this should never happen + if (reserve === undefined) { + logger.error( + `UNEXPECTED: Reserve not found for asset index: ${assetIndex}, positions: ${stringify(positions)}` + ); + continue; + } + // if no price is found, assume 0, so effective liabilities won't change + const oraclePrice = poolOracle.getPriceFloat(reserve.assetId) ?? 0; + let tokenBalance = balances.get(reserve.assetId) ?? 0n; + if (tokenBalance > 0n) { + const balanceAsDTokens = reserve.toDTokensFromAssetFloor(tokenBalance); + const repaidLiability = balanceAsDTokens <= amount ? balanceAsDTokens : amount; + if (balanceAsDTokens <= amount) { + hasLeftoverLiabilities.push(assetIndex); + } + const effectiveLiability = + reserve.toEffectiveAssetFromDTokenFloat(repaidLiability) * oraclePrice; + effectiveLiabilities -= effectiveLiability; + // repay will pull down repayment amount if greater than liabilities + requests.push({ + request_type: RequestType.Repay, + address: reserve.assetId, + amount: tokenBalance, + }); + } else { + hasLeftoverLiabilities.push(assetIndex); + } + } + + // 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 (filler.minHealthFactor * 1.01 > effectiveCollateral / effectiveLiabilities) { + return requests; + } + + // withdrawing collateral needs to be prioritized + // 1. withdraw from assets where the filler maintains a liability + // 2. withdraw positions completely to minimize # of positions + // 3. if no liabilities, withdraw the pimary asset down to min collateral + + // build list of collateral so we can sort it by size ascending + const collateralList: { reserve: Reserve; price: number; amount: bigint; size: number }[] = []; + + for (const [assetIndex, amount] of positions.collateral) { + const reserve = pool.reserves.get(pool.config.reserveList[assetIndex]); + // this should never happen + if (reserve === undefined) { + logger.error( + `UNEXPECTED: Reserve not found for asset index: ${assetIndex}, positions: ${stringify(positions)}` + ); + continue; + } + const price = poolOracle.getPriceFloat(reserve.assetId) ?? 0; + if (price === 0) { + logger.warn( + `Unable to find price for asset: ${reserve.assetId}, skipping collateral withdrawal.` + ); + continue; + } + // hacky - set size to zero for (1), to ensure they are withdrawn first + if (hasLeftoverLiabilities.includes(assetIndex)) { + collateralList.push({ reserve, price, amount, size: 0 }); + } + // hacky - set size to MAX for (3), to ensure it is withdrawn last + else if (reserve.assetId === filler.primaryAsset) { + collateralList.push({ reserve, price, amount, size: Number.MAX_SAFE_INTEGER }); + } else { + const size = reserve.toEffectiveAssetFromBTokenFloat(amount) * price; + collateralList.push({ reserve, price, amount, size }); + } + } + collateralList.sort((a, b) => a.size - b.size); + + // attempt to withdraw any collateral that is not needed + for (const { reserve, price, amount } of collateralList) { + let withdrawAmount: bigint; + if (hasLeftoverLiabilities.length === 0) { + // no liabilities, withdraw the full position + withdrawAmount = BigInt('9223372036854775807'); + } else { + if (filler.minHealthFactor * 1.005 > effectiveCollateral / effectiveLiabilities) { + // stop withdrawing collateral if close to min health factor + break; + } + const maxWithdraw = + (effectiveCollateral - effectiveLiabilities * filler.minHealthFactor) / + (reserve.getCollateralFactor() * price); + const position = reserve.toAssetFromBTokenFloat(amount); + withdrawAmount = + maxWithdraw > position ? BigInt('9223372036854775807') : FixedMath.toFixed(maxWithdraw, 7); + } + + // if this is not a full withdrawal, and the colleratal is not also a liability, stop + if ( + !hasLeftoverLiabilities.includes(reserve.config.index) && + withdrawAmount !== BigInt('9223372036854775807') + ) { + break; + } + // require the filler to keep at least the min collateral balance of their primary asset + if (reserve.assetId === filler.primaryAsset) { + const toMinPosition = reserve.toAssetFromBToken(amount) - filler.minPrimaryCollateral; + withdrawAmount = withdrawAmount > toMinPosition ? toMinPosition : withdrawAmount; + } + + if (withdrawAmount > 0n) { + const withdrawnBToken = reserve.toBTokensFromAssetFloor(withdrawAmount); + effectiveCollateral -= reserve.toEffectiveAssetFromBTokenFloat(withdrawnBToken) * price; + requests.push({ + request_type: RequestType.WithdrawCollateral, + address: reserve.assetId, + amount: withdrawAmount, + }); + } + } + return requests; +} diff --git a/src/liquidations.ts b/src/liquidations.ts index 8cd5e27..09379ae 100644 --- a/src/liquidations.ts +++ b/src/liquidations.ts @@ -1,10 +1,10 @@ import { PositionsEstimate } from '@blend-capital/blend-sdk'; import { updateUser } from './user.js'; -import { AuctioneerDatabase, AuctionType, UserEntry } from './utils/db.js'; +import { APP_CONFIG } from './utils/config.js'; +import { AuctioneerDatabase, AuctionType } from './utils/db.js'; import { logger } from './utils/logger.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission, WorkSubmissionType } from './work_submitter.js'; -import { APP_CONFIG } from './utils/config.js'; /** * Check if a user is liquidatable @@ -15,7 +15,7 @@ export function isLiquidatable(user: PositionsEstimate): boolean { if ( user.totalEffectiveLiabilities > 0 && user.totalEffectiveCollateral > 0 && - user.totalEffectiveCollateral / user.totalEffectiveLiabilities < 0.99 + user.totalEffectiveCollateral / user.totalEffectiveLiabilities < 0.998 ) { return true; } @@ -43,11 +43,14 @@ export function calculateLiquidationPercent(user: PositionsEstimate): bigint { const avgInverseLF = user.totalEffectiveLiabilities / user.totalBorrowed; const avgCF = user.totalEffectiveCollateral / user.totalSupplied; const estIncentive = 1 + (1 - avgCF / avgInverseLF) / 2; - const numerator = user.totalEffectiveLiabilities * 1.1 - user.totalEffectiveCollateral; - const denominator = avgInverseLF * 1.1 - avgCF * estIncentive; + const numerator = user.totalEffectiveLiabilities * 1.06 - user.totalEffectiveCollateral; + const denominator = avgInverseLF * 1.06 - avgCF * estIncentive; const liqPercent = BigInt( Math.min(Math.round((numerator / denominator / user.totalBorrowed) * 100), 100) ); + logger.info( + `Calculated liquidation percent ${liqPercent} with est incentive ${estIncentive} numerator ${numerator} and denominator ${denominator} for user ${user}.` + ); return liqPercent; } diff --git a/src/main.ts b/src/main.ts index 4e9e9c8..8dadbb8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { SorobanRpc } from '@stellar/stellar-sdk'; +import { rpc } from '@stellar/stellar-sdk'; import { fork } from 'child_process'; import { runCollector } from './collector.js'; import { EventType, OracleScanEvent, PriceUpdateEvent, UserRefreshEvent } from './events.js'; @@ -17,7 +17,7 @@ async function main() { let collectorInterval: NodeJS.Timeout | null = null; const db = AuctioneerDatabase.connect(); - const rpc = new SorobanRpc.Server(APP_CONFIG.rpcURL, { allowHttp: true }); + const stellarRpc = new rpc.Server(APP_CONFIG.rpcURL, { allowHttp: true }); function shutdown(fromChild: boolean = false) { console.log('Shutting down auctioneer...'); @@ -98,11 +98,12 @@ async function main() { try { let sorobanHelper = new SorobanHelper(); let poolEventHandler = new PoolEventHandler(db, sorobanHelper, worker); - await runCollector(worker, bidder, db, rpc, APP_CONFIG.poolAddress, poolEventHandler); + await runCollector(worker, bidder, db, stellarRpc, APP_CONFIG.poolAddress, poolEventHandler); } catch (e: any) { logger.error(`Error in collector`, e); } }, 1000); + console.log('Collector polling for events...'); } main().catch((error) => { diff --git a/src/pool_event_handler.ts b/src/pool_event_handler.ts index 43a1134..d8b68e8 100644 --- a/src/pool_event_handler.ts +++ b/src/pool_event_handler.ts @@ -1,6 +1,7 @@ import { PoolEventType } from '@blend-capital/blend-sdk'; -import { canFillerBid } from './auction.js'; +import { ChildProcess } from 'child_process'; import { EventType, PoolEventEvent } from './events.js'; +import { canFillerBid } from './filler.js'; import { updateUser } from './user.js'; import { APP_CONFIG } from './utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from './utils/db.js'; @@ -10,7 +11,6 @@ import { deadletterEvent, sendEvent } from './utils/messages.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; import { WorkSubmission } from './work_submitter.js'; -import { ChildProcess } from 'child_process'; const MAX_RETRIES = 2; const RETRY_DELAY = 200; @@ -43,17 +43,17 @@ export class PoolEventHandler { await this.handlePoolEvent(poolEvent); logger.info(`Successfully processed event. ${poolEvent.event.id}`); return; - } catch (error) { + } catch (error: any) { retries++; if (retries >= MAX_RETRIES) { try { await deadletterEvent(poolEvent); - } catch (error) { - logger.error(`Error sending event to dead letter queue. Error: ${error}`); + } catch (error: any) { + logger.error(`Error sending event to dead letter queue.`, error); } return; } - logger.warn(`Error processing event. ${poolEvent.event.id} Error: ${error}`); + logger.warn(`Error processing event. ${poolEvent.event.id}.`, error); logger.warn( `Retry ${retries + 1}/${MAX_RETRIES}. Waiting ${RETRY_DELAY}ms before next attempt.` ); @@ -132,6 +132,7 @@ export class PoolEventHandler { case PoolEventType.FillAuction: { const logMessage = `Auction Fill Event\nType ${AuctionType[poolEvent.event.auctionType]}\nFiller: ${poolEvent.event.from}\nUser: ${poolEvent.event.user}\nFill Percent: ${poolEvent.event.fillAmount}\nTx Hash: ${poolEvent.event.txHash}\n`; await sendSlackNotification(logMessage); + logger.info(logMessage); if (poolEvent.event.fillAmount === BigInt(100)) { // auction was fully filled, remove from ongoing auctions let runResult = this.db.deleteAuctionEntry( @@ -139,20 +140,28 @@ export class PoolEventHandler { poolEvent.event.auctionType ); if (runResult.changes !== 0) { - logger.info(logMessage); - } - if (poolEvent.event.auctionType === AuctionType.Liquidation) { - const { estimate: userPositionsEstimate, user } = - await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.user); - updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); - } else if (poolEvent.event.auctionType === AuctionType.BadDebt) { - sendEvent(this.worker, { - type: EventType.CHECK_USER, - timestamp: Date.now(), - userId: APP_CONFIG.backstopAddress, - }); + logger.info( + `Auction Deleted\nType: ${AuctionType[poolEvent.event.auctionType]}\nUser: ${poolEvent.event.user}` + ); } } + if (poolEvent.event.auctionType === AuctionType.Liquidation) { + const { estimate: userPositionsEstimate, user } = + await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.user); + updateUser(this.db, pool, user, userPositionsEstimate, poolEvent.event.ledger); + const { estimate: fillerPositionsEstimate, user: filler } = + await this.sorobanHelper.loadUserPositionEstimate(poolEvent.event.from); + 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(poolEvent.event.from); + updateUser(this.db, pool, filler, fillerPositionsEstimate, poolEvent.event.ledger); + sendEvent(this.worker, { + type: EventType.CHECK_USER, + timestamp: Date.now(), + userId: APP_CONFIG.backstopAddress, + }); + } break; } diff --git a/src/user.ts b/src/user.ts index b7a68ce..5abacc1 100644 --- a/src/user.ts +++ b/src/user.ts @@ -49,7 +49,9 @@ export function updateUser( updated: ledger, }; db.setUserEntry(new_entry); - logger.info(`Updated user entry for ${user.userId} at ledger ${ledger}.`); + logger.info( + `Updated user entry for ${user.userId} at ledger ${ledger} with health factor: ${new_entry.health_factor}.` + ); } else { // user does not have liabilities, remove db entry if it exists db.deleteUserEntry(user.userId); diff --git a/src/utils/config.ts b/src/utils/config.ts index d6e3483..1371583 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -5,8 +5,10 @@ import { parse } from './json.js'; export interface Filler { name: string; keypair: Keypair; - minProfitPct: number; + primaryAsset: string; + defaultProfitPct: number; minHealthFactor: number; + minPrimaryCollateral: bigint; forceFill: boolean; supportedBid: string[]; supportedLot: string[]; @@ -18,6 +20,12 @@ export interface PriceSource { symbol: string; } +export interface AuctionProfit { + profitPct: number; + supportedBid: string[]; + supportedLot: string[]; +} + export interface AppConfig { name: string; rpcURL: string; @@ -29,7 +37,8 @@ export interface AppConfig { blndAddress: string; keypair: Keypair; fillers: Filler[]; - priceSources: PriceSource[]; + priceSources: PriceSource[] | undefined; + profits: AuctionProfit[] | undefined; slackWebhook: string | undefined; } @@ -59,15 +68,21 @@ export function validateAppConfig(config: any): boolean { typeof config.blndAddress !== 'string' || typeof config.keypair !== 'string' || !Array.isArray(config.fillers) || - !Array.isArray(config.priceSources) || + (config.priceSources !== undefined && !Array.isArray(config.priceSources)) || + (config.profits !== undefined && !Array.isArray(config.profits)) || (config.slackWebhook !== undefined && typeof config.slackWebhook !== 'string') ) { + console.log('Invalid app config'); return false; } config.keypair = Keypair.fromSecret(config.keypair); - return config.fillers.every(validateFiller) && config.priceSources.every(validatePriceSource); + return ( + config.fillers.every(validateFiller) && + (config.priceSources === undefined || config.priceSources.every(validatePriceSource)) && + (config.profits === undefined || config.profits.every(validateAuctionProfit)) + ); } export function validateFiller(filler: any): boolean { @@ -78,17 +93,21 @@ export function validateFiller(filler: any): boolean { if ( typeof filler.name === 'string' && typeof filler.keypair === 'string' && - typeof filler.minProfitPct === 'number' && + typeof filler.defaultProfitPct === 'number' && typeof filler.minHealthFactor === 'number' && typeof filler.forceFill === 'boolean' && + typeof filler.primaryAsset === 'string' && + typeof filler.minPrimaryCollateral === 'string' && Array.isArray(filler.supportedBid) && filler.supportedBid.every((item: any) => typeof item === 'string') && Array.isArray(filler.supportedLot) && filler.supportedLot.every((item: any) => typeof item === 'string') ) { filler.keypair = Keypair.fromSecret(filler.keypair); + filler.minPrimaryCollateral = BigInt(filler.minPrimaryCollateral); return true; } + console.log('Invalid filler', filler); return false; } @@ -104,6 +123,25 @@ export function validatePriceSource(priceSource: any): boolean { ) { return true; } + console.log('Invalid price source', priceSource); + return false; +} + +export function validateAuctionProfit(profits: any): boolean { + if (typeof profits !== 'object' || profits === null) { + return false; + } + + if ( + typeof profits.profitPct === 'number' && + Array.isArray(profits.supportedBid) && + profits.supportedBid.every((item: any) => typeof item === 'string') && + Array.isArray(profits.supportedLot) && + profits.supportedLot.every((item: any) => typeof item === 'string') + ) { + return true; + } + console.log('Invalid profit', profits); return false; } diff --git a/src/utils/json.ts b/src/utils/json.ts index 8ccaab7..3d62430 100644 --- a/src/utils/json.ts +++ b/src/utils/json.ts @@ -1,3 +1,5 @@ +import { ContractError, ContractErrorType } from '@blend-capital/blend-sdk'; + function replacer(_: any, value: any): any { if (typeof value === 'bigint') { return { type: 'bigint', value: value.toString() }; @@ -46,3 +48,24 @@ export function stringify(value: any, space?: string | number): string { export function parse(jsonString: string): T { return JSON.parse(jsonString, reviver) as T; } + +/** + * Safely serialize an error object to a JSON object that is safe to stringify. This does not + * include the stack trace to make it safe for alerts and external logs. + * @param error - The thrown error + * @returns The object representation of the error + */ +export function serializeError(error: any): any { + if (error instanceof ContractError) { + return { + type: 'ContractError', + message: ContractErrorType[error.type], + }; + } else { + return { + type: 'Error', + message: error?.message, + name: error?.name, + }; + } +} diff --git a/src/utils/prices.ts b/src/utils/prices.ts index a5c662a..78d961d 100644 --- a/src/utils/prices.ts +++ b/src/utils/prices.ts @@ -15,7 +15,7 @@ interface ExchangePrice { export async function setPrices(db: AuctioneerDatabase): Promise { const coinbaseSymbols: string[] = []; const binanceSymbols: string[] = []; - for (const source of APP_CONFIG.priceSources) { + for (const source of APP_CONFIG.priceSources ?? []) { if (source.type === 'coinbase') { coinbaseSymbols.push(source.symbol); } else if (source.type === 'binance') { @@ -30,11 +30,10 @@ export async function setPrices(db: AuctioneerDatabase): Promise { ]); const exchangePriceResult = coinbasePricesResult.concat(binancePricesResult); + const priceSources = APP_CONFIG.priceSources ?? []; const priceEntries: PriceEntry[] = []; for (const price of exchangePriceResult) { - const assetId = APP_CONFIG.priceSources.find( - (source) => source.symbol === price.symbol - )?.assetId; + const assetId = priceSources.find((source) => source.symbol === price.symbol)?.assetId; if (assetId) { priceEntries.push({ asset_id: assetId, diff --git a/src/utils/slack_notifier.ts b/src/utils/slack_notifier.ts index f33b5ff..a24553f 100644 --- a/src/utils/slack_notifier.ts +++ b/src/utils/slack_notifier.ts @@ -16,6 +16,10 @@ export async function sendSlackNotification(message: string): Promise { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } + } else { + console.log( + `Bot Name: ${APP_CONFIG.name}\nTimestamp: ${new Date().toISOString()}\nPool Address: ${APP_CONFIG.poolAddress}\n${message}` + ); } } catch (e) { logger.error(`Error sending slack notification: ${e}`); diff --git a/src/utils/soroban_helper.ts b/src/utils/soroban_helper.ts index 8b03017..bba1554 100644 --- a/src/utils/soroban_helper.ts +++ b/src/utils/soroban_helper.ts @@ -1,6 +1,8 @@ import { + Auction, AuctionData, BackstopToken, + ContractErrorType, Network, parseError, Pool, @@ -15,8 +17,10 @@ import { Contract, Keypair, nativeToScVal, + Operation, + rpc, scValToNative, - SorobanRpc, + Transaction, TransactionBuilder, xdr, } from '@stellar/stellar-sdk'; @@ -47,8 +51,8 @@ export class SorobanHelper { async loadLatestLedger(): Promise { try { - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); - let ledger = await rpc.getLatestLedger(); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + let ledger = await stellarRpc.getLatestLedger(); return ledger.sequence; } catch (e) { logger.error(`Error loading latest ledger: ${e}`); @@ -103,21 +107,21 @@ export class SorobanHelper { } } - async loadAuction(userId: string, auctionType: number): Promise { + async loadAuction(userId: string, auctionType: number): Promise { try { - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); const ledgerKey = AuctionData.ledgerKey(APP_CONFIG.poolAddress, { auct_type: auctionType, user: userId, }); - let ledgerData = await rpc.getLedgerEntries(ledgerKey); + const ledgerData = await stellarRpc.getLedgerEntries(ledgerKey); if (ledgerData.entries.length === 0) { return undefined; } - let auction = PoolContract.parsers.getAuction( + let auctionData = PoolContract.parsers.getAuction( ledgerData.entries[0].val.contractData().val().toXDR('base64') ); - return auction; + return new Auction(userId, auctionType, auctionData); } catch (e) { logger.error(`Error loading auction: ${e}`); throw e; @@ -133,6 +137,36 @@ export class SorobanHelper { ); } + /** + * @dev WARNING: If loading balances for the filler, use `getFillerAvailableBalances` instead. + */ + async loadBalances(userId: string, tokens: string[]): Promise> { + try { + let balances = new Map(); + + // break tokens array into chunks of at most 5 tokens + let concurrency_limit = 5; + let promise_chunks: string[][] = []; + for (let i = 0; i < tokens.length; i += concurrency_limit) { + promise_chunks.push(tokens.slice(i, i + concurrency_limit)); + } + + // fetch each chunk of token balances concurrently + for (const chunk of promise_chunks) { + const chunkResults = await Promise.all( + chunk.map((token) => this.simBalance(token, userId)) + ); + chunk.forEach((token, index) => { + balances.set(token, chunkResults[index]); + }); + } + return balances; + } catch (e) { + logger.error(`Error loading balances: ${e}`); + throw e; + } + } + async simLPTokenToUSDC(amount: bigint): Promise { try { let comet = new Contract(APP_CONFIG.backstopTokenAddress); @@ -153,10 +187,10 @@ export class SorobanHelper { }) .addOperation(op) .build(); - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); - let result = await rpc.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationSuccess(result) && result.result?.retval) { + let result = await stellarRpc.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { return scValToNative(result.result.retval); } return undefined; @@ -178,10 +212,10 @@ export class SorobanHelper { }) .addOperation(op) .build(); - let rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); + let stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); - let result = await rpc.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationSuccess(result) && result.result?.retval) { + let result = await stellarRpc.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(result) && result.result?.retval) { return scValToNative(result.result.retval); } else { return 0n; @@ -195,11 +229,10 @@ export class SorobanHelper { async submitTransaction( operation: string, keypair: Keypair - ): Promise { - const rpc = new SorobanRpc.Server(this.network.rpc, this.network.opts); - const curr_time = Date.now(); - const account = await rpc.getAccount(keypair.publicKey()); - const tx = new TransactionBuilder(account, { + ): Promise { + const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + let account = await stellarRpc.getAccount(keypair.publicKey()); + let tx = new TransactionBuilder(account, { networkPassphrase: this.network.passphrase, fee: BASE_FEE, timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, @@ -207,49 +240,81 @@ export class SorobanHelper { .addOperation(xdr.Operation.fromXDR(operation, 'base64')) .build(); - const simResult = await rpc.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationSuccess(simResult)) { - let assembledTx = SorobanRpc.assembleTransaction(tx, simResult).build(); + logger.info(`Attempting to simulate and submit transaction: ${tx.toXDR()}`); + let simResult = await stellarRpc.simulateTransaction(tx); + + if (rpc.Api.isSimulationRestore(simResult)) { + logger.info('Simulation ran into expired entries. Attempting to restore.'); + account = await stellarRpc.getAccount(keypair.publicKey()); + const fee = Number(simResult.restorePreamble.minResourceFee) + 1000; + const restore_tx = new TransactionBuilder(account, { fee: fee.toString() }) + .setNetworkPassphrase(this.network.passphrase) + .setTimeout(0) + .setSorobanData(simResult.restorePreamble.transactionData.build()) + .addOperation(Operation.restoreFootprint({})) + .build(); + restore_tx.sign(keypair); + let restore_result = await this.sendTransaction(restore_tx); + logger.info(`Successfully restored. Tx Hash: ${restore_result.txHash}`); + account = await stellarRpc.getAccount(keypair.publicKey()); + tx = new TransactionBuilder(account, { + networkPassphrase: this.network.passphrase, + fee: BASE_FEE, + timebounds: { minTime: 0, maxTime: Math.floor(Date.now() / 1000) + 5 * 60 * 1000 }, + }) + .addOperation(xdr.Operation.fromXDR(operation, 'base64')) + .build(); + simResult = await stellarRpc.simulateTransaction(tx); + } + + if (rpc.Api.isSimulationSuccess(simResult)) { + let assembledTx = rpc.assembleTransaction(tx, simResult).build(); assembledTx.sign(keypair); - let txResponse = await rpc.sendTransaction(assembledTx); - while (txResponse.status === 'TRY_AGAIN_LATER' && Date.now() - curr_time < 20000) { - await new Promise((resolve) => setTimeout(resolve, 4000)); - txResponse = await rpc.sendTransaction(assembledTx); - } - if (txResponse.status !== 'PENDING') { - const error = parseError(txResponse); - logger.error( - `Transaction failed to send: Tx Hash: ${txResponse.hash} Error Result XDR: ${txResponse.errorResult?.toXDR('base64')} Parsed Error: ${error}` - ); - throw error; - } + return await this.sendTransaction(assembledTx); + } else { + const error = parseError(simResult); + logger.error(`Tx failed to simlate: ${ContractErrorType[error.type]}`); + throw error; + } + } - let get_tx_response = await rpc.getTransaction(txResponse.hash); - while (get_tx_response.status === 'NOT_FOUND') { - await new Promise((resolve) => setTimeout(resolve, 250)); - get_tx_response = await rpc.getTransaction(txResponse.hash); - } + private async sendTransaction( + transaction: Transaction + ): Promise { + const stellarRpc = new rpc.Server(this.network.rpc, this.network.opts); + let txResponse = await stellarRpc.sendTransaction(transaction); + if (txResponse.status === 'TRY_AGAIN_LATER') { + await new Promise((resolve) => setTimeout(resolve, 4000)); + txResponse = await stellarRpc.sendTransaction(transaction); + } - if (get_tx_response.status !== 'SUCCESS') { - const error = parseError(get_tx_response); - logger.error( - `Tx Failed: ${error}, Error Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}` - ); + if (txResponse.status !== 'PENDING') { + const error = parseError(txResponse); + logger.error( + `Transaction failed to send: Tx Hash: ${txResponse.hash} Error Result XDR: ${txResponse.errorResult?.toXDR('base64')} Parsed Error: ${ContractErrorType[error.type]}` + ); + throw error; + } + let get_tx_response = await stellarRpc.getTransaction(txResponse.hash); + while (get_tx_response.status === 'NOT_FOUND') { + await new Promise((resolve) => setTimeout(resolve, 250)); + get_tx_response = await stellarRpc.getTransaction(txResponse.hash); + } - throw error; - } - logger.info( - 'Transaction successfully submitted: ' + - `Ledger: ${get_tx_response.ledger} ` + - `Latest Ledger Close Time: ${get_tx_response.latestLedgerCloseTime} ` + - `Transaction Result XDR: ${get_tx_response.resultXdr.toXDR('base64')} ` + - `Tx Envelope XDR: ${get_tx_response.envelopeXdr.toXDR('base64')}` + - `Tx Hash: - ${txResponse.hash}` + if (get_tx_response.status !== 'SUCCESS') { + const error = parseError(get_tx_response); + logger.error( + `Tx Failed: ${ContractErrorType[error.type]}, Error Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}` ); - return { ...get_tx_response, txHash: txResponse.hash }; + + throw error; } - const error = parseError(simResult); - throw error; + logger.info( + 'Transaction successfully submitted: ' + + `Ledger: ${get_tx_response.ledger}\n` + + `Transaction Result XDR: ${get_tx_response.resultXdr.toXDR('base64')}\n` + + `Tx Hash: ${txResponse.hash}` + ); + return { ...get_tx_response, txHash: txResponse.hash }; } } diff --git a/src/work_handler.ts b/src/work_handler.ts index 44d5305..c38b070 100644 --- a/src/work_handler.ts +++ b/src/work_handler.ts @@ -52,7 +52,7 @@ export class WorkHandler { await deadletterEvent(appEvent); return false; } - logger.warn(`Error processing event. Error: ${error}`); + logger.warn(`Error processing event.`, error); logger.warn( `Retry ${retries + 1}/${MAX_RETRIES}. Waiting ${RETRY_DELAY}ms before next attempt.` ); diff --git a/src/work_submitter.ts b/src/work_submitter.ts index f2f968d..66010e4 100644 --- a/src/work_submitter.ts +++ b/src/work_submitter.ts @@ -1,7 +1,7 @@ import { ContractError, ContractErrorType, PoolContract } from '@blend-capital/blend-sdk'; import { APP_CONFIG } from './utils/config.js'; import { AuctionType } from './utils/db.js'; -import { stringify } from './utils/json.js'; +import { serializeError, stringify } from './utils/json.js'; import { logger } from './utils/logger.js'; import { sendSlackNotification } from './utils/slack_notifier.js'; import { SorobanHelper } from './utils/soroban_helper.js'; @@ -94,10 +94,11 @@ export class WorkSubmitter extends SubmissionQueue { const logMessage = `Error creating user liquidation\n` + `User: ${userLiquidation.user}\n` + - `Liquidation Percent: ${userLiquidation.liquidationPercent}\nError: ${stringify(e)}\n` + - `Error: ${e}\n`; - logger.error(logMessage); - await sendSlackNotification(`` + logMessage); + `Liquidation Percent: ${userLiquidation.liquidationPercent}`; + logger.error(logMessage, e); + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); return false; } } @@ -115,10 +116,11 @@ export class WorkSubmitter extends SubmissionQueue { logger.info(logMessage); return true; } catch (e: any) { - const logMessage = - `Error transfering bad debt\n` + `User: ${badDebtTransfer.user}\n` + `Error: ${e}\n`; - logger.error(logMessage); - await sendSlackNotification(`` + logMessage); + const logMessage = `Error transfering bad debt\n` + `User: ${badDebtTransfer.user}`; + logger.error(logMessage, e); + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); return false; } } @@ -142,7 +144,9 @@ export class WorkSubmitter extends SubmissionQueue { } catch (e: any) { const logMessage = `Error creating bad debt auction\n` + `Error: ${e}\n`; logger.error(logMessage); - await sendSlackNotification(`` + logMessage); + await sendSlackNotification( + ` ` + logMessage + `\nError: ${stringify(serializeError(e))}` + ); return false; } } diff --git a/test/auction.test.ts b/test/auction.test.ts index 774195c..73aa280 100644 --- a/test/auction.test.ts +++ b/test/auction.test.ts @@ -1,41 +1,35 @@ -import { BackstopToken, Request, RequestType } from '@blend-capital/blend-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; import { - buildFillRequests, - calculateAuctionValue, - calculateBlockFillAndPercent, - scaleAuction, -} from '../src/auction.js'; -import { AuctionBid, BidderSubmissionType } from '../src/bidder_submitter.js'; + Auction, + AuctionType, + BackstopToken, + FixedMath, + PoolUser, + PositionsEstimate, + Request, +} from '@blend-capital/blend-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; +import { calculateAuctionFill, valueBackstopTokenInUSDC } from '../src/auction.js'; +import { getFillerAvailableBalances, getFillerProfitPct } from '../src/filler.js'; import { Filler } from '../src/utils/config.js'; -import { AuctioneerDatabase, AuctionType } from '../src/utils/db.js'; +import { AuctioneerDatabase } from '../src/utils/db.js'; import { SorobanHelper } from '../src/utils/soroban_helper.js'; import { + AQUA, + BACKSTOP, + BACKSTOP_TOKEN, + EURC, inMemoryAuctioneerDb, + MOCK_LEDGER, + MOCK_TIMESTAMP, + mockPool, mockPoolOracle, - mockPoolUser, - mockPoolUserEstimate, - mockedPool, + USDC, + XLM, } from './helpers/mocks.js'; +import { expectRelApproxEqual } from './helpers/utils.js'; -jest.mock('../src/utils/soroban_helper.js', () => { - return { - SorobanHelper: jest.fn().mockImplementation(() => { - return { - loadPool: jest.fn().mockReturnValue(mockedPool), - loadUser: jest.fn().mockReturnValue(mockPoolUser), - loadUserPositionEstimate: jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }), - simLPTokenToUSDC: jest.fn().mockImplementation((number: bigint) => { - return (number * 33333n) / 100000n; - }), - loadPoolOracle: jest.fn().mockReturnValue(mockPoolOracle), - }; - }), - }; -}); - +jest.mock('../src/utils/soroban_helper.js'); +jest.mock('../src/filler.js'); jest.mock('../src/utils/config.js', () => { return { APP_CONFIG: { @@ -52,794 +46,684 @@ jest.mock('../src/utils/config.js', () => { }; }); -describe('calculateBlockFillAndPercent', () => { +describe('auctions', () => { let filler: Filler; - let sorobanHelper: SorobanHelper; + const mockedSorobanHelper = new SorobanHelper() as jest.Mocked; let db: AuctioneerDatabase; + let positionEstimate: PositionsEstimate; + + const mockedGetFilledAvailableBalances = getFillerAvailableBalances as jest.MockedFunction< + typeof getFillerAvailableBalances + >; + const mockedGetFillerProfitPct = getFillerProfitPct as jest.MockedFunction< + typeof getFillerProfitPct + >; + beforeEach(() => { - sorobanHelper = new SorobanHelper(); + jest.resetAllMocks(); + db = inMemoryAuctioneerDb(); filler = { name: 'Tester', keypair: Keypair.random(), - minProfitPct: 0.2, - minHealthFactor: 1.3, + defaultProfitPct: 0.1, + minHealthFactor: 1.2, + primaryAsset: USDC, + minPrimaryCollateral: 0n, forceFill: true, supportedBid: [], supportedLot: [], }; - db = inMemoryAuctioneerDb(); - }); - - it('test user liquidation expect fill under 200', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ]), - block: 123, - }; - - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(312); - expect(fillCalc.fillPercent).toEqual(100); - }); - - it('test user liquidation expect fill over 200', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(343); - expect(fillCalc.fillPercent).toEqual(100); - }); - - it('test force fill user liquidations sets fill to 198', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 90000_0000000n], - ]), - block: 123, - }; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(321); - expect(fillCalc.fillPercent).toEqual(100); - }); - - it('test user liquidation does not exceed min health factor', async () => { - mockPoolUserEstimate.totalEffectiveLiabilities = 18660; - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 88000_0000000n], - ]), - block: 123, + positionEstimate = { + totalBorrowed: 0, + totalSupplied: 0, + // only effective numbers used + totalEffectiveLiabilities: 0, + totalEffectiveCollateral: 4750, + borrowCap: 0, + borrowLimit: 0, + netApr: 0, + supplyApr: 0, + borrowApr: 0, }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(339); - expect(fillCalc.fillPercent).toEqual(50); - }); - - it('test interest auction', async () => { - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - sorobanHelper.simBalance = jest.fn().mockReturnValue(5000_0000000n); - sorobanHelper.simLPTokenToUSDC = jest.fn().mockImplementation((number) => { - return (number * 33333n) / 100000n; + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + estimate: positionEstimate, + user: {} as PoolUser, }); - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 2000_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], - ]), - block: 123, - }; - - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(419); - expect(fillCalc.fillPercent).toEqual(100); - }); - - it('test force fill for interest auction', async () => { - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - sorobanHelper.simBalance = jest.fn().mockReturnValue(5000_0000000n); - sorobanHelper.simLPTokenToUSDC = jest.fn().mockImplementation((number) => { - return (number * 33333n) / 100000n; + mockedSorobanHelper.simLPTokenToUSDC.mockImplementation((number: bigint) => { + // 0.5 USDC per LP token + return Promise.resolve((number * 5000000n) / 10000000n); }); - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 5500_0000000n], - ]), - block: 123, - }; - - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(473); - expect(fillCalc.fillPercent).toEqual(100); - }); - it('test interest auction increases block fill delay to fully fill', async () => { - sorobanHelper.simBalance = jest.fn().mockReturnValue(2000_0000000n); - - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1000_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 4242_0000000n], - ]), - block: 123, - }; - filler.forceFill = false; - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.Interest, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(429); - expect(fillCalc.fillPercent).toEqual(100); }); - it('test bad debt auction', async () => { - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 456_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 456_0000000n], - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 123_0000000n], - ]), - block: 123, - }; - - let fillCalc = await calculateBlockFillAndPercent( - filler, - AuctionType.BadDebt, - auctionData, - sorobanHelper, - db - ); - expect(fillCalc.fillBlock).toEqual(380); - expect(fillCalc.fillPercent).toEqual(100); - }); -}); + describe('calcAuctionFill', () => { + // *** Interest Auctions *** + + it('calcs fill for interest auction', 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([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 272); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill.bidValue, 233.4726912, 0.005); + + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + filler, + [BACKSTOP_TOKEN], + mockedSorobanHelper + ); + }); -describe('calculateAuctionValue', () => { - let sorobanHelper = new SorobanHelper(); - let db = inMemoryAuctioneerDb(); - it('test valuing user auction', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + it('calcs fill for interest auction and delays block to fully fill', 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([[BACKSTOP_TOKEN, FixedMath.toFixed(400)]]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 272 + 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); + }); - let result = await calculateAuctionValue( - AuctionType.Liquidation, - auctionData, - sorobanHelper, - db - ); - expect(result.bidValue).toBeCloseTo(562.42); - expect(result.lotValue).toBeCloseTo(1242.24); - expect(result.effectiveCollateral).toBeCloseTo(1180.13); - expect(result.effectiveLiabilities).toBeCloseTo(749.89); - }); + it('calcs fill for interest auction at next ledger if past target block', async () => { + let nextLedger = MOCK_LEDGER + 280; + 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([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(nextLedger); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill.bidValue, 218.880648, 0.005); + }); - it('test valuing interest auction', async () => { - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - block: 123, - }; + it('calcs fill for interest auction uses db prices when possible', 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, + }); + + db.setPriceEntries([ + { + asset_id: XLM, + price: 0.3, + timestamp: MOCK_TIMESTAMP - 100, + }, + ]); + + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 260); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 284.6922, 0.005); + expectRelApproxEqual(fill.bidValue, 254.805096, 0.005); + }); - let result = await calculateAuctionValue(AuctionType.Interest, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(4115184.85); - expect(result.lotValue).toBeCloseTo(1795.72); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(0); - }); + it('calcs fill for interest auction respects force fill setting', 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(2500)]]), + block: MOCK_LEDGER, + }); + + mockedGetFillerProfitPct.mockReturnValue(0.2); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(1000)]]) + ); + + filler.forceFill = true; + let fill_force = await calculateAuctionFill( + filler, + auction, + nextLedger, + mockedSorobanHelper, + db + ); + + filler.forceFill = false; + let fill_no_force = await calculateAuctionFill( + filler, + auction, + nextLedger, + mockedSorobanHelper, + db + ); + + let expectedRequests: Request[] = [ + { + request_type: 8, + address: BACKSTOP, + amount: 100n, + }, + ]; + expect(fill_force.block).toEqual(MOCK_LEDGER + 350); + 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); + + expect(fill_no_force.block).toEqual(MOCK_LEDGER + 367); + expect(fill_no_force.percent).toEqual(100); + expect(fill_no_force.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill_no_force.lotValue, 260.5722, 0.005); + expectRelApproxEqual(fill_no_force.bidValue, 206.25, 0.005); + }); - it('test valuing bad debt auction', async () => { - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - bid: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + // *** Liquidation Auctions *** + + it('calcs fill for liquidation auction', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([ + [USDC, FixedMath.toFixed(15.93)], + [EURC, FixedMath.toFixed(16.211)], + ]), + bid: new Map([[XLM, FixedMath.toFixed(300.21)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([[USDC, FixedMath.toFixed(100)]]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 194); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 32.8213, 0.005); + expectRelApproxEqual(fill.bidValue, 29.73769976, 0.005); + + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + filler, + [USDC, EURC, XLM], + mockedSorobanHelper + ); + }); - let result = await calculateAuctionValue(AuctionType.BadDebt, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(1808.6); - expect(result.lotValue).toBeCloseTo(4115184.85); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(2061.66); - }); - it('test valuing lp token when simLPTokenToUSDC is not defined', async () => { - sorobanHelper.simLPTokenToUSDC = jest.fn().mockResolvedValue(undefined); - sorobanHelper.loadBackstopToken = jest - .fn() - .mockResolvedValue(new BackstopToken('id', 100n, 100n, 100n, 100, 100, 1)); - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - bid: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + it('calcs fill for liquidation auction and repays incoming liabilties and withdraws 0 CF collateral', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([ + [USDC, FixedMath.toFixed(15.93)], + [EURC, FixedMath.toFixed(16.211)], + [AQUA, FixedMath.toFixed(750)], + ]), + bid: new Map([[XLM, FixedMath.toFixed(300.21)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [USDC, FixedMath.toFixed(100)], + [XLM, FixedMath.toFixed(500)], + ]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + { + request_type: 5, + address: XLM, + amount: 3003808157n, + }, + { + request_type: 3, + address: AQUA, + amount: BigInt('9223372036854775807'), + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 191); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 32.7722, 0.005); + expectRelApproxEqual(fill.bidValue, 29.73769976, 0.005); + }); - let result = await calculateAuctionValue(AuctionType.BadDebt, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(1808.6); - expect(result.lotValue).toBeCloseTo(12345678); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(2061.66); - - auctionData = { - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 12345678_0000000n], - ]), - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 1234_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 5678_0000000n], - ]), - block: 123, - }; + it('calcs fill for liquidation auction adds primary collateral', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 186; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([ + [USDC, FixedMath.toFixed(100)], + [EURC, FixedMath.toFixed(7500)], + ]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [USDC, FixedMath.toFixed(5000)], + [XLM, FixedMath.toFixed(500)], + ]) + ); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + // repays any incoming primary liabilities first + { + request_type: 5, + address: USDC, + amount: 101_0182653n, + }, + // adds additional primary collateral to reach min HF + { + request_type: 2, + address: USDC, + amount: FixedMath.toFixed(4420), + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 187); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 9257.3115, 0.005); + expectRelApproxEqual(fill.bidValue, 8378.033243, 0.005); + }); - result = await calculateAuctionValue(AuctionType.Interest, auctionData, sorobanHelper, db); - expect(result.bidValue).toBeCloseTo(12345678); - expect(result.lotValue).toBeCloseTo(1795.72); - expect(result.effectiveCollateral).toBeCloseTo(0); - expect(result.effectiveLiabilities).toBeCloseTo(0); - }); -}); + it('calcs fill for liquidation auction scales fill percent down', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 188; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([[XLM, FixedMath.toFixed(85000)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue(new Map([])); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 12n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 188); + expect(fill.percent).toEqual(12); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 1116.8179, 0.005); + expectRelApproxEqual(fill.bidValue, 1010.37453, 0.005); + }); -describe('buildFillRequests', () => { - let sorobanHelper = new SorobanHelper(); - it('test user liquidation auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Interest, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ]), - bid: new Map([ - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillInterestAuction, - address: user.publicKey(), - amount: 100n, - }, - ]; - expect(requests.length).toEqual(1); - expect(requests).toEqual(expectRequests); - }); - it('test interest auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Interest, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - bid: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 50000_0000000n], - ]), - block: 123, - }; - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillInterestAuction, - address: user.publicKey(), - amount: 100n, - }, - ]; - expect(requests.length).toEqual(1); - expect(requests).toEqual(expectRequests); - }); + it('calcs fill for liquidation auction delays fill block if filler not healthy', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 123; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([[XLM, FixedMath.toFixed(85000)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 750; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue(new Map([])); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 100n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 300); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 9900.8679, 0.005); + expectRelApproxEqual(fill.bidValue, 4209.893874, 0.005); + }); - it('test bad debt auction requests', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.BadDebt, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM', 30000_0000000n], - ]), - bid: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_0000000n; - else return 0; + it('calcs fill for liquidation auction with repayment, additional collateral, and scaling minor', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 123; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[XLM, FixedMath.toFixed(100000)]]), + bid: new Map([[XLM, FixedMath.toFixed(85000)]]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [XLM, FixedMath.toFixed(15000)], + [USDC, FixedMath.toFixed(4000)], + ]) + ); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 94n, + }, + { + request_type: 5, + address: XLM, + amount: FixedMath.toFixed(15000), + }, + { + request_type: 2, + address: USDC, + amount: FixedMath.toFixed(3954), + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 188); + expect(fill.percent).toEqual(94); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 8748.4069, 0.005); + expectRelApproxEqual(fill.bidValue, 7914.600483, 0.005); }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillBadDebtAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 10000_0000000n, - }, - { - request_type: RequestType.Repay, - address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - amount: 500_0000000n, - }, - ]; - expect(requests.length).toEqual(3); - expect(requests).toEqual(expectRequests); - }); - it('test repay xlm does not use full balance', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 10000_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 80000_0000000n], - ['CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', 456_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 95000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else return 0; + it('calcs fill for liquidation auction with repayment, additional collateral, and scaling large', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.Liquidation, { + lot: new Map([[EURC, FixedMath.toFixed(9100)]]), + bid: new Map([ + [USDC, FixedMath.toFixed(500)], + [XLM, FixedMath.toFixed(85000)], + ]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 700; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [XLM, FixedMath.toFixed(2000)], + [USDC, FixedMath.toFixed(600)], + ]) + ); + + filler.primaryAsset = USDC; + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 6, + address: user, + amount: 15n, + }, + { + request_type: 5, + address: USDC, + amount: 757637015n, + }, + { + request_type: 5, + address: XLM, + amount: FixedMath.toFixed(2000), + }, + { + request_type: 2, + address: USDC, + amount: FixedMath.toFixed(495), + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 197); + expect(fill.percent).toEqual(15); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 1476.3058, 0.005); + expectRelApproxEqual(fill.bidValue, 1338.709125, 0.005); }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 95000_0000000n - BigInt(100e7), - }, - { - request_type: RequestType.Repay, - address: 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - amount: 500_0000000n, - }, - { - request_type: RequestType.WithdrawCollateral, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 9223372036854775807n, - }, - ]; - expect(requests.length).toEqual(4); - expect(requests).toEqual(expectRequests); - }); - it('test requests does not withdraw exisiting supplied position', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - let auctionData = { - lot: new Map([ - ['CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', 10000_0000000n], - ]), - bid: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 800_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 95000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_0000000n; - else return 0n; + // *** Bad Debt Auctions *** + + it('calcs fill for bad debt auction', async () => { + let user = Keypair.random().publicKey(); + let nextLedger = MOCK_LEDGER + 1; + let auction = new Auction(user, AuctionType.BadDebt, { + lot: new Map([[BACKSTOP_TOKEN, FixedMath.toFixed(4200)]]), + bid: new Map([ + [XLM, FixedMath.toFixed(10000)], + [USDC, FixedMath.toFixed(500)], + ]), + block: MOCK_LEDGER, + }); + positionEstimate.totalEffectiveLiabilities = 0; + positionEstimate.totalEffectiveCollateral = 1000; + + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ + user: {} as PoolUser, + estimate: positionEstimate, + }); + mockedGetFillerProfitPct.mockReturnValue(0.1); + mockedGetFilledAvailableBalances.mockResolvedValue( + new Map([ + [USDC, FixedMath.toFixed(4200)], + [XLM, FixedMath.toFixed(5000)], + ]) + ); + + let fill = await calculateAuctionFill(filler, auction, nextLedger, mockedSorobanHelper, db); + + let expectedRequests: Request[] = [ + { + request_type: 7, + address: user, + amount: 100n, + }, + { + request_type: 5, + address: XLM, + amount: FixedMath.toFixed(5000), + }, + { + request_type: 5, + address: USDC, + amount: 5050912865n, + }, + ]; + expect(fill.block).toEqual(MOCK_LEDGER + 157); + expect(fill.percent).toEqual(100); + expect(fill.requests).toEqual(expectedRequests); + expectRelApproxEqual(fill.lotValue, 1648.5, 0.005); + expectRelApproxEqual(fill.bidValue, 1495.503014, 0.005); + + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + filler, + [XLM, USDC], + mockedSorobanHelper + ); }); - mockPoolUser.positions.collateral.set( - mockedPool.config.reserveList.indexOf( - 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75' - ), - 1n - ); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - amount: 10000_0000000n, - }, - ]; - expect(requests.length).toEqual(2); - expect(requests).toEqual(expectRequests); }); - it('test requests does not withdraw below min health factor', async () => { - const filler = Keypair.random(); - const user = Keypair.random(); - const auctionBid: AuctionBid = { - type: BidderSubmissionType.BID, - filler: { - name: '', - keypair: filler, - minProfitPct: 0.2, - minHealthFactor: 1.2, - forceFill: false, - supportedBid: [], - supportedLot: [], - }, - auctionEntry: { - user_id: user.publicKey(), - auction_type: AuctionType.Liquidation, - filler: filler.publicKey(), - start_block: 0, - fill_block: 0, - updated: 0, - }, - }; - mockPoolUserEstimate.totalEffectiveLiabilities = 15660; - sorobanHelper.loadUserPositionEstimate = jest - .fn() - .mockReturnValue({ estimate: mockPoolUserEstimate, user: mockPoolUser }); - let auctionData = { - lot: new Map([ - ['CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', 8000_0000000n], - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 10_0000000n], - ]), - bid: new Map([ - ['CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', 70000_0000000n], - ]), - block: 123, - }; - sorobanHelper.simBalance = jest.fn().mockImplementation((tokenId: string, userId: string) => { - if (tokenId === 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA') - return 10000_0000000n; - else if (tokenId === 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK') - return 500_0000000n; - else if (tokenId === 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV') - return 10000_000000n; + 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); }); - let requests = await buildFillRequests(auctionBid, auctionData, 100, sorobanHelper); - let expectRequests: Request[] = [ - { - request_type: RequestType.FillUserLiquidationAuction, - address: user.publicKey(), - amount: 100n, - }, - { - request_type: RequestType.Repay, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 9900_0000000n, - }, - { - request_type: RequestType.WithdrawCollateral, - address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - amount: 9223372036854775807n, - }, - ]; - expect(requests.length).toEqual(3); - expect(requests).toEqual(expectRequests); - }); - it('test requests does not repay or withdraw without oracle price', async () => {}); -}); + it('values from spot price if sim fails', async () => { + mockedSorobanHelper.simLPTokenToUSDC.mockResolvedValue(undefined); + mockedSorobanHelper.loadBackstopToken.mockResolvedValue({ + lpTokenPrice: 1.25, + } as BackstopToken); -describe('scaleAuction', () => { - it('test auction scaling', () => { - const auctionData = { - lot: new Map([ - ['asset2', 1_0000000n], - ['asset3', 5_0000001n], - ]), - bid: new Map([ - ['asset1', 100_0000000n], - ['asset2', 200_0000001n], - ]), - block: 123, - }; - let scaledAuction = scaleAuction(auctionData, 123, 100); - expect(scaledAuction.block).toEqual(123); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(0); - - // 100 blocks -> 100 percent, validate lot is rounded down - scaledAuction = scaleAuction(auctionData, 223, 100); - expect(scaledAuction.block).toEqual(223); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(5000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(2_5000000n); - - // 100 blocks -> 50 percent, validate bid is rounded up - scaledAuction = scaleAuction(auctionData, 223, 50); - expect(scaledAuction.block).toEqual(223); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(2500000n); - expect(scaledAuction.lot.get('asset3')).toEqual(1_2500000n); - - // 200 blocks -> 100 percent (is same) - scaledAuction = scaleAuction(auctionData, 323, 100); - expect(scaledAuction.block).toEqual(323); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(100_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(200_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 200 blocks -> 75 percent, validate bid is rounded up and lot is rounded down - scaledAuction = scaleAuction(auctionData, 323, 75); - expect(scaledAuction.block).toEqual(323); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(75_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(150_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(7500000n); - expect(scaledAuction.lot.get('asset3')).toEqual(3_7500000n); - - // 300 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 423, 100); - expect(scaledAuction.block).toEqual(423); - expect(scaledAuction.bid.size).toEqual(2); - expect(scaledAuction.bid.get('asset1')).toEqual(50_0000000n); - expect(scaledAuction.bid.get('asset2')).toEqual(100_0000001n); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 400 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 523, 100); - expect(scaledAuction.block).toEqual(523); - expect(scaledAuction.bid.size).toEqual(0); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - - // 500 blocks -> 100 percent (unchanged) - scaledAuction = scaleAuction(auctionData, 623, 100); - expect(scaledAuction.block).toEqual(623); - expect(scaledAuction.bid.size).toEqual(0); - expect(scaledAuction.lot.size).toEqual(2); - expect(scaledAuction.lot.get('asset2')).toEqual(1_0000000n); - expect(scaledAuction.lot.get('asset3')).toEqual(5_0000001n); - }); + let value = await valueBackstopTokenInUSDC(mockedSorobanHelper, FixedMath.toFixed(2)); - it('test auction scaling with 1 stroop', () => { - const auctionData = { - lot: new Map([['asset2', 1n]]), - bid: new Map([['asset1', 1n]]), - block: 123, - }; - // 1 blocks -> 10 percent - let scaledAuction = scaleAuction(auctionData, 124, 10); - expect(scaledAuction.block).toEqual(124); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(0); - - // 399 blocks -> 10 percent - scaledAuction = scaleAuction(auctionData, 522, 10); - expect(scaledAuction.block).toEqual(522); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(0); - - // 399 blocks -> 100 percent - scaledAuction = scaleAuction(auctionData, 522, 100); - expect(scaledAuction.block).toEqual(522); - expect(scaledAuction.bid.size).toEqual(1); - expect(scaledAuction.bid.get('asset1')).toEqual(1n); - expect(scaledAuction.lot.size).toEqual(1); - expect(scaledAuction.lot.get('asset2')).toEqual(1n); + 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 1e669ef..20dce7c 100644 --- a/test/bidder_handler.test.ts +++ b/test/bidder_handler.test.ts @@ -1,6 +1,6 @@ -import { AuctionData } from '@blend-capital/blend-sdk'; +import { Auction } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; -import { calculateBlockFillAndPercent, FillCalculation } from '../src/auction'; +import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { BidderHandler } from '../src/bidder_handler'; import { AuctionBid, BidderSubmissionType, BidderSubmitter } from '../src/bidder_submitter'; import { AppEvent, EventType, LedgerEvent } from '../src/events'; @@ -25,7 +25,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler1', keypair: Keypair.random(), - minProfitPct: 0.05, + defaultProfitPct: 0.05, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'BTC', 'LP'], @@ -34,7 +34,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler2', keypair: Keypair.random(), - minProfitPct: 0.08, + defaultProfitPct: 0.08, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'ETH', 'XLM'], @@ -58,8 +58,8 @@ describe('BidderHandler', () => { let mockedBidderSubmitter: jest.Mocked; let mockedSorobanHelper: jest.Mocked = new SorobanHelper() as jest.Mocked; - let mockedCalcBlockAndFillPercent = calculateBlockFillAndPercent as jest.MockedFunction< - typeof calculateBlockFillAndPercent + let mockedCalcAuctionFill = calculateAuctionFill as jest.MockedFunction< + typeof calculateAuctionFill >; let mockedSendSlackNotif = sendSlackNotification as jest.MockedFunction< typeof sendSlackNotification @@ -92,17 +92,21 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc: AuctionFill = { + block: 1200, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc: FillCalculation = { - fillBlock: 1200, - fillPercent: 50, - }; - mockedCalcBlockAndFillPercent.mockResolvedValue(fill_calc); + mockedCalcAuctionFill.mockResolvedValue(fill_calc); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); @@ -115,7 +119,7 @@ describe('BidderHandler', () => { expect(new_auction_2?.auction_type).toEqual(auction_2.auction_type); expect(new_auction_2?.filler).toEqual(auction_2.filler); expect(new_auction_2?.start_block).toEqual(auction_2.start_block); - expect(new_auction_2?.fill_block).toEqual(fill_calc.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc.block); expect(new_auction_2?.updated).toEqual(ledger); expect(mockedSendSlackNotif).toHaveBeenCalledTimes(1); @@ -145,35 +149,40 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc_1: FillCalculation = { - fillBlock: 1200, - fillPercent: 50, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_1: AuctionFill = { + block: 1200, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - let fill_calc_2: FillCalculation = { - fillBlock: 1002, - fillPercent: 60, + let fill_calc_2: AuctionFill = { + block: 1002, + percent: 60, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent - .mockResolvedValueOnce(fill_calc_1) - .mockResolvedValueOnce(fill_calc_2); + mockedCalcAuctionFill.mockResolvedValueOnce(fill_calc_1).mockResolvedValueOnce(fill_calc_2); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); // validate auction 1 is updated let new_auction_1 = db.getAuctionEntry(auction_1.user_id, auction_1.auction_type); - expect(new_auction_1?.fill_block).toEqual(fill_calc_1.fillBlock); + expect(new_auction_1?.fill_block).toEqual(fill_calc_1.block); expect(new_auction_1?.updated).toEqual(ledger); // validate auction 2 is updated let new_auction_2 = db.getAuctionEntry(auction_2.user_id, auction_2.auction_type); - expect(new_auction_2?.fill_block).toEqual(fill_calc_2.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc_2.block); expect(new_auction_2?.updated).toEqual(ledger); }); @@ -197,30 +206,36 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc_1: FillCalculation = { - fillBlock: 1001, - fillPercent: 50, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_1: AuctionFill = { + block: 1001, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - let fill_calc_2: FillCalculation = { - fillBlock: 995, - fillPercent: 60, + + let fill_calc_2: AuctionFill = { + block: 995, + percent: 60, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent - .mockResolvedValueOnce(fill_calc_1) - .mockResolvedValueOnce(fill_calc_2); + mockedCalcAuctionFill.mockResolvedValueOnce(fill_calc_1).mockResolvedValueOnce(fill_calc_2); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); // validate auction 1 is placed on submission queue let new_auction_1 = db.getAuctionEntry(auction_1.user_id, auction_1.auction_type); - expect(new_auction_1?.fill_block).toEqual(fill_calc_1.fillBlock); + expect(new_auction_1?.fill_block).toEqual(fill_calc_1.block); expect(new_auction_1?.updated).toEqual(ledger); let submission_1: AuctionBid = { @@ -232,7 +247,7 @@ describe('BidderHandler', () => { // validate auction 2 is placed on submission queue let new_auction_2 = db.getAuctionEntry(auction_2.user_id, auction_2.auction_type); - expect(new_auction_2?.fill_block).toEqual(fill_calc_2.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc_2.block); expect(new_auction_2?.updated).toEqual(ledger); let submission_2: AuctionBid = { @@ -254,17 +269,21 @@ describe('BidderHandler', () => { updated: ledger - 1, }; db.setAuctionEntry(auction_1); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; - mockedSorobanHelper.loadAuction.mockResolvedValue(auction_data); - let fill_calc_1: FillCalculation = { - fillBlock: 1001, - fillPercent: 50, + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_1: AuctionFill = { + block: 1001, + percent: 50, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent.mockResolvedValue(fill_calc_1); + mockedCalcAuctionFill.mockResolvedValue(fill_calc_1); mockedBidderSubmitter.containsAuction.mockReturnValue(true); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; @@ -297,19 +316,23 @@ describe('BidderHandler', () => { }; db.setAuctionEntry(auction_1); db.setAuctionEntry(auction_2); - let auction_data: AuctionData = { - bid: new Map([['USD', BigInt(123456)]]), - lot: new Map([['BTC', BigInt(456)]]), - block: ledger - 1, - }; mockedSorobanHelper.loadAuction - .mockRejectedValueOnce(new Error('Teapot')) - .mockResolvedValueOnce(auction_data); - let fill_calc_2: FillCalculation = { - fillBlock: 1002, - fillPercent: 60, + .mockRejectedValueOnce(new Error('teapot')) + .mockResolvedValueOnce( + new Auction('teapot', AuctionType.Liquidation, { + bid: new Map(), + lot: new Map(), + block: ledger - 1, + }) + ); + let fill_calc_2: AuctionFill = { + block: 1002, + percent: 60, + lotValue: 1000, + bidValue: 900, + requests: [], }; - mockedCalcBlockAndFillPercent.mockResolvedValue(fill_calc_2); + mockedCalcAuctionFill.mockResolvedValue(fill_calc_2); const appEvent: AppEvent = { type: EventType.LEDGER, ledger } as LedgerEvent; await bidderHandler.processEvent(appEvent); @@ -322,7 +345,7 @@ describe('BidderHandler', () => { // validate auction 2 is updated let new_auction_2 = db.getAuctionEntry(auction_2.user_id, auction_2.auction_type); - expect(new_auction_2?.fill_block).toEqual(fill_calc_2.fillBlock); + expect(new_auction_2?.fill_block).toEqual(fill_calc_2.block); expect(new_auction_2?.updated).toEqual(ledger); }); }); diff --git a/test/bidder_submitter.test.ts b/test/bidder_submitter.test.ts index 3251a1f..cb32a85 100644 --- a/test/bidder_submitter.test.ts +++ b/test/bidder_submitter.test.ts @@ -1,36 +1,33 @@ -import { RequestType } from '@blend-capital/blend-sdk'; +import { Auction, PoolUser, Positions, Request, RequestType } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; -import { - buildFillRequests, - calculateAuctionValue, - calculateBlockFillAndPercent, - scaleAuction, -} from '../src/auction'; +import { AuctionFill, calculateAuctionFill } from '../src/auction'; import { AuctionBid, BidderSubmissionType, BidderSubmitter, FillerUnwind, } from '../src/bidder_submitter'; -import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db'; +import { getFillerAvailableBalances, managePositions } from '../src/filler'; +import { Filler } from '../src/utils/config'; +import { AuctioneerDatabase, AuctionEntry, AuctionType, FilledAuctionEntry } from '../src/utils/db'; import { logger } from '../src/utils/logger'; import { sendSlackNotification } from '../src/utils/slack_notifier'; import { SorobanHelper } from '../src/utils/soroban_helper'; -import { inMemoryAuctioneerDb } from './helpers/mocks'; +import { inMemoryAuctioneerDb, mockPool, mockPoolOracle } from './helpers/mocks'; // Mock dependencies jest.mock('../src/utils/db'); jest.mock('../src/utils/soroban_helper'); jest.mock('../src/auction'); jest.mock('../src/utils/slack_notifier'); -jest.mock('@blend-capital/blend-sdk'); +jest.mock('../src/filler'); jest.mock('../src/utils/soroban_helper'); jest.mock('@stellar/stellar-sdk', () => { const actual = jest.requireActual('@stellar/stellar-sdk'); return { ...actual, - SorobanRpc: { - ...actual.SorobanRpc, + rpc: { + ...actual.rpc, Server: jest.fn().mockImplementation(() => ({ getLatestLedger: jest.fn().mockResolvedValue({ sequence: 999 }), })), @@ -67,16 +64,6 @@ describe('BidderSubmitter', () => { let mockDb: AuctioneerDatabase; let mockedSorobanHelper = new SorobanHelper() as jest.Mocked; let mockedSorobanHelperConstructor = SorobanHelper as jest.MockedClass; - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), - block: 500, - }); - mockedSorobanHelper.submitTransaction.mockResolvedValue({ - ledger: 1000, - txHash: 'mock-tx-hash', - latestLedgerCloseTime: 123, - } as any); mockedSorobanHelper.network = { rpc: 'test-rpc', passphrase: 'test-pass', @@ -87,14 +74,14 @@ describe('BidderSubmitter', () => { const mockedSendSlackNotif = sendSlackNotification as jest.MockedFunction< typeof sendSlackNotification >; - let mockCalculateBlockFillAndPercent = calculateBlockFillAndPercent as jest.MockedFunction< - typeof calculateBlockFillAndPercent + const mockedCalcAuctionFill = calculateAuctionFill as jest.MockedFunction< + typeof calculateAuctionFill >; - let mockScaleAuction = scaleAuction as jest.MockedFunction; - let mockBuildFillRequests = buildFillRequests as jest.MockedFunction; - let mockCalculateAuctionValue = calculateAuctionValue as jest.MockedFunction< - typeof calculateAuctionValue + const mockedManagePositions = managePositions as jest.MockedFunction; + const mockedGetFilledAvailableBalances = getFillerAvailableBalances as jest.MockedFunction< + typeof getFillerAvailableBalances >; + beforeEach(() => { jest.clearAllMocks(); mockDb = inMemoryAuctioneerDb(); @@ -102,41 +89,53 @@ describe('BidderSubmitter', () => { }); it('should submit a bid successfully', async () => { - mockCalculateBlockFillAndPercent.mockResolvedValue({ fillBlock: 1000, fillPercent: 50 }); - mockScaleAuction.mockReturnValue({ - bid: new Map([['USD', BigInt(12)]]), - lot: new Map([['USD', BigInt(34)]]), - block: 500, - }); - mockBuildFillRequests.mockResolvedValue([ - { - request_type: RequestType.FillUserLiquidationAuction, - address: '', - amount: 0n, - }, - ]); - mockCalculateAuctionValue.mockResolvedValue({ - bidValue: 123, - effectiveLiabilities: 456, - lotValue: 987, - effectiveCollateral: 654, + bidderSubmitter.addSubmission = jest.fn(); + + let auction = new Auction(Keypair.random().publicKey(), AuctionType.Liquidation, { + bid: new Map([['USD', BigInt(1000)]]), + lot: new Map([['USD', BigInt(2000)]]), + block: 800, }); + mockedSorobanHelper.loadAuction.mockResolvedValue(auction); + let auction_fill: AuctionFill = { + percent: 50, + block: 1000, + bidValue: 1234, + lotValue: 2345, + requests: [ + { + request_type: RequestType.FillUserLiquidationAuction, + address: auction.user, + amount: 50n, + }, + ], + }; + mockedCalcAuctionFill.mockResolvedValue(auction_fill); + let submissionResult: any = { + ledger: 1000, + txHash: 'mock-tx-hash', + latestLedgerCloseTime: Date.now(), + }; + mockedSorobanHelper.submitTransaction.mockResolvedValue(submissionResult); + const filler: Filler = { + name: 'test-filler', + keypair: Keypair.random(), + defaultProfitPct: 0, + minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, + forceFill: false, + supportedBid: [], + supportedLot: [], + }; const submission: AuctionBid = { type: BidderSubmissionType.BID, - filler: { - name: 'test-filler', - keypair: Keypair.random(), - minProfitPct: 0, - minHealthFactor: 0, - forceFill: false, - supportedBid: [], - supportedLot: [], - }, + filler, auctionEntry: { - user_id: 'test-user', + user_id: auction.user, auction_type: AuctionType.Liquidation, - filler: 'test-filler', + filler: filler.keypair.publicKey(), start_block: 900, fill_block: 1000, } as AuctionEntry, @@ -144,16 +143,34 @@ describe('BidderSubmitter', () => { const result = await bidderSubmitter.submit(submission); + const expectedFillEntry: FilledAuctionEntry = { + tx_hash: 'mock-tx-hash', + filler: submission.auctionEntry.filler, + user_id: auction.user, + auction_type: submission.auctionEntry.auction_type, + bid: new Map([['USD', BigInt(500)]]), + bid_total: auction_fill.bidValue, + lot: new Map([['USD', BigInt(1000)]]), + lot_total: auction_fill.lotValue, + est_profit: auction_fill.lotValue - auction_fill.bidValue, + fill_block: submissionResult.ledger, + timestamp: submissionResult.latestLedgerCloseTime, + }; expect(result).toBe(true); expect(mockedSorobanHelper.loadAuction).toHaveBeenCalledWith( - 'test-user', - AuctionType.Liquidation + submission.auctionEntry.user_id, + submission.auctionEntry.auction_type ); expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); - expect(mockDb.setFilledAuctionEntry).toHaveBeenCalled(); + expect(mockDb.setFilledAuctionEntry).toHaveBeenCalledWith(expectedFillEntry); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith( + { type: BidderSubmissionType.UNWIND, filler: submission.filler }, + 2 + ); }); - it('should handle auction already filled', async () => { + it('returns true if auction is undefined to return auction entry to handler', async () => { + bidderSubmitter.addSubmission = jest.fn(); mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); mockedSorobanHelperConstructor.mockReturnValue(mockedSorobanHelper); const submission: AuctionBid = { @@ -161,8 +178,10 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], @@ -176,7 +195,93 @@ describe('BidderSubmitter', () => { const result = await bidderSubmitter.submit(submission); expect(result).toBe(true); - expect(mockDb.deleteAuctionEntry).toHaveBeenCalledWith('test-user', AuctionType.Liquidation); + }); + + it('should manage positions during unwind', async () => { + const fillerBalance = new Map([['USD', 123n]]); + const unwindRequest: Request[] = [ + { + request_type: RequestType.Repay, + address: 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + amount: 123n, + }, + ]; + + bidderSubmitter.addSubmission = jest.fn(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue( + new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) + ); + mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); + + mockedManagePositions.mockReturnValue(unwindRequest); + + const submission: FillerUnwind = { + type: BidderSubmissionType.UNWIND, + filler: { + name: 'test-filler', + keypair: Keypair.random(), + defaultProfitPct: 0, + minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + forceFill: false, + supportedBid: ['USD', 'XLM'], + supportedLot: ['EURC', 'XLM'], + }, + }; + let result = await bidderSubmitter.submit(submission); + + expect(result).toBe(true); + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + submission.filler, + ['USD', 'XLM', 'EURC'], + mockedSorobanHelper + ); + expect(mockedManagePositions).toHaveBeenCalled(); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalled(); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledWith(submission, 2); + }); + + it('should stop submitting unwind events when no action is taken', async () => { + const fillerBalance = new Map([['USD', 123n]]); + const unwindRequest: Request[] = []; + + bidderSubmitter.addSubmission = jest.fn(); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); + mockedSorobanHelper.loadPoolOracle.mockResolvedValue(mockPoolOracle); + mockedSorobanHelper.loadUser.mockResolvedValue( + new PoolUser('test-user', new Positions(new Map(), new Map(), new Map()), new Map()) + ); + mockedSorobanHelper.loadBalances.mockResolvedValue(fillerBalance); + + mockedManagePositions.mockReturnValue(unwindRequest); + + const submission: FillerUnwind = { + type: BidderSubmissionType.UNWIND, + filler: { + name: 'test-filler', + keypair: Keypair.random(), + defaultProfitPct: 0, + minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 100n, + forceFill: false, + supportedBid: ['USD', 'XLM'], + supportedLot: ['EURC', 'XLM'], + }, + }; + let result = await bidderSubmitter.submit(submission); + + expect(result).toBe(true); + expect(mockedGetFilledAvailableBalances).toHaveBeenCalledWith( + submission.filler, + ['USD', 'XLM', 'EURC'], + mockedSorobanHelper + ); + expect(mockedSorobanHelper.submitTransaction).toHaveBeenCalledTimes(0); + expect(bidderSubmitter.addSubmission).toHaveBeenCalledTimes(0); }); it('should return true if auction is in the queue', () => { @@ -213,8 +318,10 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], @@ -254,8 +361,10 @@ describe('BidderSubmitter', () => { filler: { name: 'test-filler', keypair: Keypair.random(), - minProfitPct: 0, + defaultProfitPct: 0, minHealthFactor: 0, + primaryAsset: 'USD', + minPrimaryCollateral: 0n, forceFill: false, supportedBid: [], supportedLot: [], diff --git a/test/filler.test.ts b/test/filler.test.ts new file mode 100644 index 0000000..8811d53 --- /dev/null +++ b/test/filler.test.ts @@ -0,0 +1,556 @@ +import { + AuctionData, + FixedMath, + PoolOracle, + Positions, + PriceData, + Request, + RequestType, +} 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 { 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(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +describe('filler', () => { + describe('canFillerBid', () => { + it('returns true if the filler supports the auction', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET0', 100n], + ['ASSET1', 200n], + ]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + block: 123, + }; + + const result = canFillerBid(filler, auctionData); + expect(result).toBe(true); + }); + + it('returns false if the filler does not support the lot', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET0', 100n], + ['ASSET1', 200n], + ]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET0', 200n], + ]), + block: 123, + }; + + const result = canFillerBid(filler, auctionData); + expect(result).toBe(false); + }); + + it('returns false if the filler does not support the bid', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: ['ASSET0', 'ASSET1', 'ASSET2'], + supportedLot: ['ASSET1', 'ASSET2', 'ASSET3'], + }; + const auctionData: AuctionData = { + bid: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + lot: new Map([ + ['ASSET1', 100n], + ['ASSET3', 200n], + ]), + block: 123, + }; + + const result = canFillerBid(filler, auctionData); + expect(result).toBe(false); + }); + }); + describe('getFillerProfitPct', () => { + it('gets profitPct from profit config if available', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + 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], + ['ASSET2', 200n], + ]), + block: 123, + }; + + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.3); + }); + + it('returns first matched profit', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + 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); + expect(result).toBe(0.2); + }); + + it('returns default profit if bid does not match', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + 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], + ['ASSET3', 200n], + ]), + lot: new Map([['ASSET1', 100n]]), + block: 123, + }; + + const result = getFillerProfitPct(filler, profits, auctionData); + expect(result).toBe(0.1); + }); + + it('returns default profit if lot does not match', () => { + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + 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], + ['ASSET0', 200n], + ]), + 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(), + primaryAsset: 'XLM', + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + 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); + expect(result).toBe(0.1); + }); + }); + describe('managePositions', () => { + const assets = mockPool.config.reserveList; + const mockOracle = new PoolOracle( + 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', + new Map([ + [assets[0], { price: BigInt(1e6), timestamp: 1724949300 }], + [assets[1], { price: BigInt(1e7), timestamp: 1724949300 }], + [assets[2], { price: BigInt(1.1e7), timestamp: 1724949300 }], + [assets[3], { price: BigInt(1000e7), timestamp: 1724949300 }], + ]), + 7, + 53255053 + ); + const filler: Filler = { + name: 'Teapot', + keypair: Keypair.random(), + primaryAsset: assets[1], + defaultProfitPct: 0.1, + minHealthFactor: 1.5, + minPrimaryCollateral: FixedMath.toFixed(100, 7), + forceFill: true, + supportedBid: [assets[1], assets[0]], + supportedLot: [assets[1], assets[2], assets[3]], + }; + + it('clears excess liabilities and collateral', () => { + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(100, 7)]]), + // bTokens + new Map([[2, FixedMath.toFixed(500, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(200, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(1234, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: BigInt('9223372036854775807'), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('does not withdraw collateral if a different liability still exists', () => { + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(5000, 7)]]), + // bTokens + new Map([[2, FixedMath.toFixed(4500, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(0, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(3000, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('does not withdraw primary collateral if a different liability still exists', () => { + const positions = new Positions( + // dTokens + new Map([[2, FixedMath.toFixed(4500, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(5000, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(0, 7)], + [assets[2], FixedMath.toFixed(3000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[2], + amount: FixedMath.toFixed(3000, 7), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('can unwind looped positions', () => { + filler.minHealthFactor = 1.1; + const positions = new Positions( + // dTokens + new Map([[1, FixedMath.toFixed(50000, 7)]]), + // bTokens + new Map([[1, FixedMath.toFixed(58000, 7)]]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(0, 7)], + [assets[1], FixedMath.toFixed(5000, 7)], + [assets[2], FixedMath.toFixed(2000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + // return minimum health factor back to 1.5 + filler.minHealthFactor = 1.5; + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[1], + amount: FixedMath.toFixed(5000, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[1], + amount: BigInt(29372567525), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('clears collateral with no liabilities and keeps primary collateral above min collateral', () => { + const positions = new Positions( + // dTokens + new Map([]), + // bTokens + new Map([ + [1, FixedMath.toFixed(125, 7)], + [3, FixedMath.toFixed(1, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(575, 7)], + [assets[1], FixedMath.toFixed(3000, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(0, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.WithdrawCollateral, + address: assets[3], + amount: BigInt('9223372036854775807'), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[1], + amount: 258738051n, + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('clears smallest collateral position first', () => { + const positions = new Positions( + // dTokens + new Map([ + [0, FixedMath.toFixed(1500, 7)], + [3, FixedMath.toFixed(2, 7)], + ]), + // bTokens + new Map([ + [1, FixedMath.toFixed(5000, 7)], + [2, FixedMath.toFixed(500, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(5000, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(0, 7)], + [assets[3], FixedMath.toFixed(1, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[0], + amount: FixedMath.toFixed(5000, 7), + }, + { + request_type: RequestType.Repay, + address: assets[3], + amount: FixedMath.toFixed(1, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: BigInt('9223372036854775807'), + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + + it('partially withdraws large collateral first when a liability position is maintained', () => { + const positions = new Positions( + // dTokens + new Map([ + [2, FixedMath.toFixed(1500, 7)], + [3, FixedMath.toFixed(2, 7)], + ]), + // bTokens + new Map([ + [0, FixedMath.toFixed(500, 7)], + [1, FixedMath.toFixed(2500, 7)], + [2, FixedMath.toFixed(3000, 7)], + ]), + new Map([]) + ); + const balances = new Map([ + [assets[0], FixedMath.toFixed(5000, 7)], + [assets[1], FixedMath.toFixed(1234, 7)], + [assets[2], FixedMath.toFixed(1000, 7)], + [assets[3], FixedMath.toFixed(1, 7)], + ]); + + const requests = managePositions(filler, mockPool, mockOracle, positions, balances); + + const expectedRequests: Request[] = [ + { + request_type: RequestType.Repay, + address: assets[2], + amount: FixedMath.toFixed(1000, 7), + }, + { + request_type: RequestType.Repay, + address: assets[3], + amount: FixedMath.toFixed(1, 7), + }, + { + request_type: RequestType.WithdrawCollateral, + address: assets[2], + amount: 14820705895n, + }, + ]; + expect(requests).toEqual(expectedRequests); + }); + }); +}); diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 0fff3ef..91fd27f 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -1,13 +1,4 @@ -import { - Pool, - PoolOracle, - PoolUser, - PoolUserEmissionData, - PositionsEstimate, - PriceData, - Reserve, -} from '@blend-capital/blend-sdk'; -import { Keypair } from '@stellar/stellar-sdk'; +import { Pool, PoolOracle, PriceData, Reserve } from '@blend-capital/blend-sdk'; import Database from 'better-sqlite3'; import * as fs from 'fs'; import * as path from 'path'; @@ -33,56 +24,35 @@ pool.reserves.forEach((reserve, assetId, map) => { ) ); }); -export let mockedPool = pool; +export let mockPool = pool; -export let mockedReserves = pool.config.reserveList; +export const MOCK_LEDGER = pool.config.latestLedger; +export const MOCK_TIMESTAMP = pool.timestamp; -export let mockPoolUser = new PoolUser( - Keypair.random().publicKey(), - { - liabilities: new Map(), - collateral: new Map(), - supply: new Map(), - }, - new Map() -); +export const XLM = 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA'; +export const XLM_ID = 0; +export const USDC = 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; +export const USDC_ID = 1; +export const EURC = 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV'; +export const EURC_ID = 2; +export const AQUA = 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK'; +export const AQUA_ID = 3; + +export const BACKSTOP = 'CAO3AGAMZVRMHITL36EJ2VZQWKYRPWMQAPDQD5YEOF3GIF7T44U4JAL3'; +export const BACKSTOP_TOKEN = 'CAS3FL6TLZKDGGSISDBWGGPXT3NRR4DYTZD7YOD3HMYO6LTJUVGRVEAM'; export let mockPoolOracle = new PoolOracle( 'CATKK5ZNJCKQQWTUWIUFZMY6V6MOQUGSTFSXMNQZHVJHYF7GVV36FB3Y', new Map([ - [ - 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', - { price: BigInt(9899585234193), timestamp: 1724949300 }, - ], - [ - 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', - { price: BigInt(99969142646062), timestamp: 1724949300 }, - ], - [ - 'CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV', - { price: BigInt(109278286319197), timestamp: 1724949300 }, - ], - [ - 'CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK', - { price: BigInt(64116899991), timestamp: 1724950800 }, - ], + [XLM, { price: BigInt(9899585234193), timestamp: 1724949300 }], + [USDC, { price: BigInt(99969142646062), timestamp: 1724949300 }], + [EURC, { price: BigInt(109278286319197), timestamp: 1724949300 }], + [AQUA, { price: BigInt(64116899991), timestamp: 1724950800 }], ]), 14, 53255053 ); -export let mockPoolUserEstimate: PositionsEstimate = { - totalBorrowed: 0, - totalSupplied: 0, - totalEffectiveLiabilities: 1000, - totalEffectiveCollateral: 25000, - borrowCap: 0, - borrowLimit: 0, - netApr: 0, - supplyApr: 0, - borrowApr: 0, -}; - export function inMemoryAuctioneerDb(): AuctioneerDatabase { let db = new Database(':memory:'); db.exec(fs.readFileSync(path.resolve(__dirname, '../../init_db.sql'), 'utf8')); diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts new file mode 100644 index 0000000..c1fc545 --- /dev/null +++ b/test/helpers/utils.ts @@ -0,0 +1,10 @@ +/** + * Assert that a and b are approximately equal, relative to the smaller of the two, + * within epsilon as a percentage. + * @param a + * @param b + * @param epsilon - The max allowed difference between a and b as a percentage of the smaller of the two + */ +export function expectRelApproxEqual(a: number, b: number, epsilon = 0.001) { + expect(Math.abs(a - b) / Math.min(a, b)).toBeLessThanOrEqual(epsilon); +} diff --git a/test/liquidations.test.ts b/test/liquidations.test.ts index 5f768df..481bc28 100644 --- a/test/liquidations.test.ts +++ b/test/liquidations.test.ts @@ -1,4 +1,5 @@ -import { PoolUser, Positions, PositionsEstimate } from '@blend-capital/blend-sdk'; +import { Auction, PoolUser, Positions, PositionsEstimate } from '@blend-capital/blend-sdk'; +import { Keypair } from '@stellar/stellar-sdk'; import { calculateLiquidationPercent, checkUsersForLiquidationsAndBadDebt, @@ -9,13 +10,8 @@ import { import { APP_CONFIG } from '../src/utils/config.js'; import { AuctioneerDatabase } from '../src/utils/db.js'; import { PoolUserEst, SorobanHelper } from '../src/utils/soroban_helper.js'; -import { UserLiquidation, WorkSubmission, WorkSubmissionType } from '../src/work_submitter.js'; -import { - inMemoryAuctioneerDb, - mockedPool, - mockPoolUser, - mockPoolUserEstimate, -} from './helpers/mocks.js'; +import { WorkSubmissionType } from '../src/work_submitter.js'; +import { inMemoryAuctioneerDb, mockPool } from './helpers/mocks.js'; jest.mock('../src/utils/soroban_helper.js'); jest.mock('../src/utils/logger.js', () => ({ @@ -41,21 +37,19 @@ describe('isLiquidatable', () => { let userEstimate: PositionsEstimate; beforeEach(() => { - userEstimate = mockPoolUserEstimate; - userEstimate.totalEffectiveCollateral = 25000; - userEstimate.totalEffectiveLiabilities = 1000; + userEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); }); - it('returns true if the userEstimate health factor is below .99', async () => { + it('returns true if the userEstimate health factor is lt .998', async () => { userEstimate.totalEffectiveCollateral = 1000; - userEstimate.totalEffectiveLiabilities = 1011; + userEstimate.totalEffectiveLiabilities = 1003; const result = isLiquidatable(userEstimate); expect(result).toBe(true); }); - it('returns false if the userEstimate health facotr is above .99', async () => { + it('returns false if the userEstimate health facotr is gte to .998', async () => { userEstimate.totalEffectiveCollateral = 1000; - userEstimate.totalEffectiveLiabilities = 1010; + userEstimate.totalEffectiveLiabilities = 1002; const result = isLiquidatable(userEstimate); expect(result).toBe(false); }); @@ -65,9 +59,7 @@ describe('isBadDebt', () => { let userEstimate: PositionsEstimate; beforeEach(() => { - userEstimate = mockPoolUserEstimate; - userEstimate.totalEffectiveCollateral = 25000; - userEstimate.totalEffectiveLiabilities = 1000; + userEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); }); it('should return true when totalEffectiveCollateral is 0 and totalEffectiveLiabilities is greater than 0', () => { userEstimate.totalEffectiveCollateral = 0; @@ -98,11 +90,7 @@ describe('calculateLiquidationPercent', () => { let userEstimate: PositionsEstimate; beforeEach(() => { - userEstimate = mockPoolUserEstimate; - userEstimate.totalEffectiveCollateral = 0; - userEstimate.totalEffectiveLiabilities = 0; - userEstimate.totalBorrowed = 0; - userEstimate.totalSupplied = 0; + userEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); }); it('should calculate the correct liquidation percent for typical values', () => { userEstimate.totalEffectiveCollateral = 1000; @@ -110,7 +98,7 @@ describe('calculateLiquidationPercent', () => { userEstimate.totalBorrowed = 1500; userEstimate.totalSupplied = 2000; const result = calculateLiquidationPercent(userEstimate); - expect(Number(result)).toBe(62); + expect(Number(result)).toBe(56); }); it('should calculate max of 100 percent liquidation size', () => { @@ -130,7 +118,7 @@ describe('calculateLiquidationPercent', () => { userEstimate.totalSupplied = 10000000000000; const result = calculateLiquidationPercent(userEstimate); - expect(Number(result)).toBe(9); + expect(Number(result)).toBe(6); }); }); @@ -139,16 +127,24 @@ describe('scanUsers', () => { let mockedSorobanHelper: jest.Mocked; let mockBackstopPositions: PoolUser; let mockBackstopPositionsEstimate: PositionsEstimate; + let mockPoolUserEstimate: PositionsEstimate; + let mockPoolUser: PoolUser; beforeEach(() => { db = inMemoryAuctioneerDb(); mockedSorobanHelper = new SorobanHelper() as jest.Mocked; - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockBackstopPositions = new PoolUser( 'backstopAddress', new Positions(new Map(), new Map(), new Map()), new Map() ); mockBackstopPositionsEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); + mockPoolUserEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); + mockPoolUser = new PoolUser( + Keypair.random().publicKey(), + new Positions(new Map(), new Map(), new Map()), + new Map() + ); }); it('should create a work submission for liquidatable users', async () => { @@ -213,11 +209,7 @@ describe('scanUsers', () => { } return Promise.resolve({ estimate: {}, user: {} } as PoolUserEst); }); - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map(), - lot: new Map(), - block: 123, - }); + mockedSorobanHelper.loadAuction.mockResolvedValue({ user: 'exists' } as Auction); let liquidations = await scanUsers(db, mockedSorobanHelper); expect(liquidations.length).toBe(0); @@ -257,15 +249,19 @@ describe('checkUsersForLiquidationsAndBadDebt', () => { beforeEach(() => { db = inMemoryAuctioneerDb(); mockedSorobanHelper = new SorobanHelper() as jest.Mocked; - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockBackstopPositions = new PoolUser( 'backstopAddress', new Positions(new Map(), new Map(), new Map()), new Map() ); mockBackstopPositionsEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); - mockUser = mockPoolUser; - mockUserEstimate = mockPoolUserEstimate; + mockUserEstimate = new PositionsEstimate(0, 0, 0, 0, 0, 0, 0, 0, 0); + mockUser = new PoolUser( + Keypair.random().publicKey(), + new Positions(new Map(), new Map(), new Map()), + new Map() + ); }); it('should return an empty array when user_ids is empty', async () => { @@ -275,68 +271,55 @@ describe('checkUsersForLiquidationsAndBadDebt', () => { it('should handle backstop address user correctly', async () => { const user_ids = [APP_CONFIG.backstopAddress]; - (mockedSorobanHelper.loadPool as jest.Mock).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockBackstopPositionsEstimate.totalEffectiveLiabilities = 1000; mockBackstopPositionsEstimate.totalEffectiveCollateral = 0; - (mockedSorobanHelper.loadUserPositionEstimate as jest.Mock).mockResolvedValue({ + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ estimate: mockBackstopPositionsEstimate, user: mockBackstopPositions, }); - (mockedSorobanHelper.loadAuction as jest.Mock).mockResolvedValue(undefined); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); const result = await checkUsersForLiquidationsAndBadDebt(db, mockedSorobanHelper, user_ids); expect(result).toEqual([{ type: WorkSubmissionType.BadDebtAuction }]); }); - it('should handle users with liquidations correctly', async () => { + it('should handle liquidatable users correctly', async () => { const user_ids = ['user1']; - (mockedSorobanHelper.loadPool as jest.Mock).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockUserEstimate.totalEffectiveCollateral = 1000; mockUserEstimate.totalEffectiveLiabilities = 1100; - (mockedSorobanHelper.loadUserPositionEstimate as jest.Mock).mockResolvedValue({ + mockUserEstimate.totalBorrowed = 1500; + mockUserEstimate.totalSupplied = 2000; + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ estimate: mockUserEstimate, user: mockUser, }); - (mockedSorobanHelper.loadAuction as jest.Mock).mockResolvedValue(undefined); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); const result = await checkUsersForLiquidationsAndBadDebt(db, mockedSorobanHelper, user_ids); expect(result.length).toBe(1); - expect(result[0].type).toBe(WorkSubmissionType.LiquidateUser); - - // Type Guard Function - function isUserLiquidation(workSubmission: WorkSubmission): workSubmission is UserLiquidation { - return 'user' in workSubmission && 'liquidationPercent' in workSubmission; - } - // Test Case - const workSubmission = result[0] as WorkSubmission; - - if (isUserLiquidation(workSubmission)) { - expect(workSubmission.user).toBe('user1'); - expect(Number(workSubmission.liquidationPercent)).toBe(62); - } else { - throw new Error('Expected workSubmission to be of type LiquidateUser'); - } + expect(result).toEqual([ + { type: WorkSubmissionType.LiquidateUser, user: 'user1', liquidationPercent: 56n }, + ]); }); it('should handle users with bad debt correctly', async () => { const user_ids = ['user1']; - (mockedSorobanHelper.loadPool as jest.Mock).mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); mockUserEstimate.totalEffectiveCollateral = 0; mockUserEstimate.totalEffectiveLiabilities = 1100; - (mockedSorobanHelper.loadUserPositionEstimate as jest.Mock).mockResolvedValue({ + mockedSorobanHelper.loadUserPositionEstimate.mockResolvedValue({ estimate: mockUserEstimate, user: mockUser, }); - (mockedSorobanHelper.loadAuction as jest.Mock).mockResolvedValue(undefined); + mockedSorobanHelper.loadAuction.mockResolvedValue(undefined); const result = await checkUsersForLiquidationsAndBadDebt(db, mockedSorobanHelper, user_ids); expect(result.length).toBe(1); - expect(result[0].type).toBe(WorkSubmissionType.BadDebtTransfer); - - // Type Guard Function expect(result).toEqual([{ type: WorkSubmissionType.BadDebtTransfer, user: 'user1' }]); }); }); diff --git a/test/pool_event_handler.test.ts b/test/pool_event_handler.test.ts index 1999e7b..a911175 100644 --- a/test/pool_event_handler.test.ts +++ b/test/pool_event_handler.test.ts @@ -11,10 +11,10 @@ import { PoolEventHandler } from '../src/pool_event_handler.js'; import { updateUser } from '../src/user.js'; import { APP_CONFIG, AppConfig } from '../src/utils/config.js'; import { AuctioneerDatabase, AuctionEntry, AuctionType } from '../src/utils/db.js'; -import { SorobanHelper } from '../src/utils/soroban_helper.js'; -import { inMemoryAuctioneerDb, mockedPool } from './helpers/mocks.js'; import { logger } from '../src/utils/logger.js'; import { deadletterEvent, sendEvent } from '../src/utils/messages.js'; +import { SorobanHelper } from '../src/utils/soroban_helper.js'; +import { inMemoryAuctioneerDb, mockPool } from './helpers/mocks.js'; jest.mock('../src/user.js'); jest.mock('../src/utils/soroban_helper.js'); @@ -34,7 +34,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler1', keypair: Keypair.random(), - minProfitPct: 0.05, + defaultProfitPct: 0.05, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'BTC', 'LP'], @@ -43,7 +43,7 @@ jest.mock('../src/utils/config.js', () => { { name: 'filler2', keypair: Keypair.random(), - minProfitPct: 0.08, + defaultProfitPct: 0.08, minHealthFactor: 1.1, forceFill: true, supportedBid: ['USD', 'ETH', 'XLM'], @@ -71,7 +71,7 @@ describe('poolEventHandler', () => { beforeEach(() => { jest.clearAllMocks(); db = inMemoryAuctioneerDb(); - mockedSorobanHelper.loadPool.mockResolvedValue(mockedPool); + mockedSorobanHelper.loadPool.mockResolvedValue(mockPool); poolEventHandler = new PoolEventHandler(db, mockedSorobanHelper, mockedWorkerProcess); const fixedTimestamp = 1609459200000; Date.now = jest.fn(() => fixedTimestamp); @@ -104,7 +104,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -129,7 +129,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -143,14 +143,14 @@ describe('poolEventHandler', () => { }, }, }; - mockedSorobanHelper.loadPool - .mockRejectedValueOnce(new Error('Temporary error')) - .mockResolvedValue(mockedPool); + let error = new Error('Temporary error'); + mockedSorobanHelper.loadPool.mockRejectedValueOnce(error).mockResolvedValue(mockPool); await poolEventHandler.processEventWithRetryAndDeadLetter(poolEvent); expect(mockedSorobanHelper.loadPool).toHaveBeenCalledTimes(2); expect(logger.warn).toHaveBeenCalledWith( - `Error processing event. ${poolEvent.event.id} Error: Error: Temporary error` + `Error processing event. ${poolEvent.event.id}.`, + error ); expect(logger.info).toHaveBeenCalledWith(`Successfully processed event. ${poolEvent.event.id}`); }); @@ -161,7 +161,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -188,7 +188,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: 'mockedPoolId', + contractId: 'mockPoolId', contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -203,9 +203,11 @@ describe('poolEventHandler', () => { }, }; - mockedSorobanHelper.loadPool.mockRejectedValue(new Error('Permanent error')); + let error = new Error('Permanent error'); + mockedSorobanHelper.loadPool.mockRejectedValue(error); + let mocked_error = new Error('Mocked error'); const mockDeadLetterEvent = deadletterEvent as jest.MockedFunction; - mockDeadLetterEvent.mockRejectedValue(new Error('Mocked error')); + mockDeadLetterEvent.mockRejectedValue(mocked_error); await poolEventHandler.processEventWithRetryAndDeadLetter(poolEvent); @@ -213,7 +215,8 @@ describe('poolEventHandler', () => { expect(deadletterEvent).toHaveBeenCalledWith(poolEvent); expect(logger.error).toHaveBeenNthCalledWith( 1, - `Error sending event to dead letter queue. Error: Error: Mocked error` + `Error sending event to dead letter queue.`, + mocked_error ); }); @@ -224,13 +227,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.SupplyCollateral, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), bTokensMinted: BigInt(900), @@ -239,7 +242,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('updates user data for withdraw collateral event', async () => { @@ -249,13 +252,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.WithdrawCollateral, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), bTokensBurned: BigInt(900), @@ -264,7 +267,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('updates user data for borrow event', async () => { @@ -274,13 +277,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.Borrow, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), dTokensMinted: BigInt(900), @@ -289,7 +292,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('updates user data for repay event', async () => { @@ -299,13 +302,13 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', txHash: '0x123', eventType: PoolEventType.Repay, - assetId: mockedPool.config.reserveList[0], + assetId: mockPool.config.reserveList[0], from: pool_user, amount: BigInt(1000), dTokensBurned: BigInt(900), @@ -314,7 +317,7 @@ describe('poolEventHandler', () => { await poolEventHandler.handlePoolEvent(poolEvent); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, ledger); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, ledger); }); it('finds filler and tracks auction for new liquidation event', async () => { @@ -325,7 +328,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -361,7 +364,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -397,7 +400,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -435,7 +438,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -487,7 +490,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -534,7 +537,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -557,7 +560,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -576,7 +579,7 @@ describe('poolEventHandler', () => { expect(entries.length).toEqual(1); let deletedAuction = db.getAuctionEntry(pool_user, AuctionType.Liquidation); expect(deletedAuction).toBeUndefined(); - expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockedPool, user, estimate, 12350); + expect(mockedUpdateUser).toHaveBeenCalledWith(db, mockPool, user, estimate, 12350); }); it('deletes fill auction for other fill auction event', async () => { @@ -608,7 +611,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -636,7 +639,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12350, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -670,7 +673,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', @@ -698,7 +701,7 @@ describe('poolEventHandler', () => { type: EventType.POOL_EVENT, event: { id: '1', - contractId: mockedPool.id, + contractId: mockPool.id, contractType: BlendContractType.Pool, ledger: 12345, ledgerClosedAt: '2021-10-01T00:00:00Z', diff --git a/test/user.test.ts b/test/user.test.ts index 3b2e21c..9409ab5 100644 --- a/test/user.test.ts +++ b/test/user.test.ts @@ -1,7 +1,7 @@ import { PoolUser, Positions, PositionsEstimate } from '@blend-capital/blend-sdk'; import { updateUser } from '../src/user.js'; import { AuctioneerDatabase, UserEntry } from '../src/utils/db.js'; -import { inMemoryAuctioneerDb, mockedPool } from './helpers/mocks.js'; +import { inMemoryAuctioneerDb, mockPool } from './helpers/mocks.js'; describe('updateUser', () => { let db: AuctioneerDatabase; @@ -27,26 +27,26 @@ describe('updateUser', () => { ), new Map() ); - updateUser(db, mockedPool, user, user_estimate); + updateUser(db, mockPool, user, user_estimate); let user_entry = db.getUserEntry('GPUBKEY1'); expect(user_entry).toBeDefined(); expect(user_entry?.user_id).toEqual('GPUBKEY1'); expect(user_entry?.health_factor).toEqual(2); expect(user_entry?.liabilities.size).toEqual(2); - expect(user_entry?.liabilities.get(mockedPool.config.reserveList[0])).toEqual(BigInt(12345)); - expect(user_entry?.liabilities.get(mockedPool.config.reserveList[1])).toEqual(BigInt(54321)); + expect(user_entry?.liabilities.get(mockPool.config.reserveList[0])).toEqual(BigInt(12345)); + expect(user_entry?.liabilities.get(mockPool.config.reserveList[1])).toEqual(BigInt(54321)); expect(user_entry?.collateral.size).toEqual(1); - expect(user_entry?.collateral.get(mockedPool.config.reserveList[3])).toEqual(BigInt(789)); - expect(user_entry?.updated).toEqual(mockedPool.config.latestLedger); + expect(user_entry?.collateral.get(mockPool.config.reserveList[3])).toEqual(BigInt(789)); + expect(user_entry?.updated).toEqual(mockPool.config.latestLedger); }); it('deletes existing user without liabilities', async () => { let user_entry: UserEntry = { user_id: 'GPUBKEY1', health_factor: 2, - collateral: new Map([[mockedPool.config.reserveList[3], BigInt(789)]]), - liabilities: new Map([[mockedPool.config.reserveList[2], BigInt(789)]]), + collateral: new Map([[mockPool.config.reserveList[3], BigInt(789)]]), + liabilities: new Map([[mockPool.config.reserveList[2], BigInt(789)]]), updated: 123, }; db.setUserEntry(user_entry); @@ -60,7 +60,7 @@ describe('updateUser', () => { new Positions(new Map(), new Map([[3, BigInt(789)]]), new Map([[2, BigInt(111)]])), new Map() ); - updateUser(db, mockedPool, user, user_estimate); + updateUser(db, mockPool, user, user_estimate); let new_user_entry = db.getUserEntry('GPUBKEY1'); expect(new_user_entry).toBeUndefined(); diff --git a/test/utils/config.test.ts b/test/utils/config.test.ts index 50e7be9..d4d2fec 100644 --- a/test/utils/config.test.ts +++ b/test/utils/config.test.ts @@ -1,6 +1,11 @@ // config.test.ts import { Keypair } from '@stellar/stellar-sdk'; -import { validateAppConfig, validateFiller, validatePriceSource } from '../../src/utils/config'; +import { + validateAppConfig, + validateAuctionProfit, + validateFiller, + validatePriceSource, +} from '../../src/utils/config'; describe('validateAppConfig', () => { it('should return false for non-object config', () => { @@ -41,8 +46,10 @@ describe('validateAppConfig', () => { { name: 'filler', keypair: Keypair.random().secret(), - minProfitPct: 1, + defaultProfitPct: 1, minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], @@ -65,8 +72,10 @@ describe('validateFiller', () => { const invalidFiller = { name: 'filler', keypair: 'secret', - minProfitPct: 1, + defaultProfitPct: 1, minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], supportedLot: 123, // Invalid type @@ -78,8 +87,10 @@ describe('validateFiller', () => { const validFiller = { name: 'filler', keypair: Keypair.random().secret(), - minProfitPct: 1, + defaultProfitPct: 1, minHealthFactor: 1, + primaryAsset: 'asset', + minPrimaryCollateral: '100', forceFill: true, supportedBid: ['bid'], supportedLot: ['lot'], @@ -112,3 +123,28 @@ describe('validatePriceSource', () => { expect(validatePriceSource(validPriceSource)).toBe(true); }); }); + +describe('validateAuctionProfit', () => { + it('should return false for non-object profits', () => { + expect(validateAuctionProfit(null)).toBe(false); + expect(validateAuctionProfit('string')).toBe(false); + }); + + it('should return false for profits with missing or incorrect properties', () => { + const invalidProfits = { + profitPct: 1, + supportedBid: ['asset1', 'asset2'], + supportedLot: 'asset2', // Invalid type + }; + expect(validateAuctionProfit(invalidProfits)).toBe(false); + }); + + it('should return true for valid profits', () => { + const validProfits = { + profitPct: 1, + supportedBid: ['asset1', 'asset2'], + supportedLot: ['asset2'], + }; + expect(validateAuctionProfit(validProfits)).toBe(true); + }); +}); diff --git a/test/work_submitter.test.ts b/test/work_submitter.test.ts index 8c7716e..ce031a0 100644 --- a/test/work_submitter.test.ts +++ b/test/work_submitter.test.ts @@ -1,4 +1,4 @@ -import { ContractError, ContractErrorType } from '@blend-capital/blend-sdk'; +import { Auction, ContractError, ContractErrorType } from '@blend-capital/blend-sdk'; import { Keypair } from '@stellar/stellar-sdk'; import { AppConfig } from '../src/utils/config'; import { AuctionType } from '../src/utils/db'; @@ -63,11 +63,13 @@ describe('WorkSubmitter', () => { }); it('should not submit if auction already exists', async () => { - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), - block: 500, - }); + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('user1', AuctionType.Liquidation, { + bid: new Map([['USD', BigInt(123)]]), + lot: new Map([['USD', BigInt(456)]]), + block: 500, + }) + ); const submission = { type: WorkSubmissionType.LiquidateUser, @@ -228,11 +230,13 @@ describe('WorkSubmitter', () => { }); it('should not submit if auction already exists', async () => { - mockedSorobanHelper.loadAuction.mockResolvedValue({ - bid: new Map([['USD', BigInt(123)]]), - lot: new Map([['USD', BigInt(456)]]), - block: 500, - }); + mockedSorobanHelper.loadAuction.mockResolvedValue( + new Auction('user1', AuctionType.Liquidation, { + bid: new Map([['USD', BigInt(123)]]), + lot: new Map([['USD', BigInt(456)]]), + block: 500, + }) + ); const submission: WorkSubmission = { type: WorkSubmissionType.BadDebtAuction,