From 01a2455aa06032edf972f71ac5aff8843811e807 Mon Sep 17 00:00:00 2001 From: didi Date: Fri, 13 Dec 2024 19:02:33 +0100 Subject: [PATCH 1/3] apply flow specific gas price limit --- README.md | 17 +++++++ grt.ts | 24 +++++++-- src/datafetcher.ts | 8 ++- src/graphinator.ts | 124 ++++++++++++++++++++++++++++++++++----------- 4 files changed, 137 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index af8f8e7..cf87dda 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,23 @@ Make sure `grt.ts` is executable. See `./grt.ts --help` for more config options. +## Gas price strategy + +Graphinator can be configured to make sound decisions despite gas price swings, with the following priorities: +- don't let high-value flows slip through +- don't let gas price spikes drain the sender account +- don't let dust flows open forever + +This is achieved by using 3 configuration parameters: +- _reference gas price limit_: this is the limit which should be applied to a reference flow with a flowrate worth 1$ per day. For all flows with known value (meaning, the token price is known to the application) the limit is set proportionally to this reference value. +E.g. if this limit were set to 1 gwei and a flow worth 1000 $/day were critical, graphinator would bid up to 1000 gwei in order to liquidate it. +This proportionality makes sure that an attacker couldn't take advantage of gas price spikes with a times insolvency. +- _fallback gas price limit_: if the token price and thus the value of the flow isn't known, this limit is applied. It will usually be set higher than the reference limit, but still low enough to not risk the account being drained for likely not very urgent liquidations (assuming it's mostly less important tokens we don't know the price for). +- _minimum gas price limit_: since most tokens don't have a minimum deposit set, there can be a lot of "dust streams" out there which are never really worth liquidating. +In case we won't those liquidated anyway, setting this value to a value which is at least occasionally above the chain's gas price makes sure those flows don't linger on insolvent forever. + +Limitation: the current implementation does not take into account the min deposit setting of a token. + ## License [MIT](https://choosealicense.com/licenses/mit/) \ No newline at end of file diff --git a/grt.ts b/grt.ts index 5010154..ff7e758 100755 --- a/grt.ts +++ b/grt.ts @@ -35,11 +35,23 @@ const argv = await yargs(hideBin(process.argv)) description: 'Address of the Super Token to process. If not set, all "listed" (curated) Super Tokens will be processed', default: process.env.TOKEN }) - .option('maxGasPriceMwei', { + .option('referenceGasPriceLimitMwei', { + alias: 'r', + type: 'number', + description: 'Set the reference gas price limit in mwei (milli wei) - limit which should be applied to a flow with a daily flowrate worth 1$. Default: 1000 (1 gwei)', + default: process.env.REFERENCE_GAS_PRICE_LIMIT_MWEI ? parseInt(process.env.REFERENCE_GAS_PRICE_LIMIT_MWEI) : 1000 + }) + .option('fallbackGasPriceLimitMwei', { + alias: 'f', + type: 'number', + description: 'Set the fallback gas price limit in mwei (milli wei) - used for flows with unknown token price. Default: 10 x referenceGasPriceLimit', + default: process.env.FALLBACK_GAS_PRICE_LIMIT_MWEI ? parseInt(process.env.FALLBACK_GAS_PRICE_LIMIT_MWEI) : undefined + }) + .option('minGasPriceLimitMwei', { alias: 'm', type: 'number', - description: 'Set the max gas price in mwei (milli wei). Default: 10000 (10 gwei)', - default: process.env.MAX_GAS_PRICE_MWEI ? parseInt(process.env.MAX_GAS_PRICE_MWEI) : 10000 + description: 'Set the minimum gas price limit in mwei (milli wei) - used to prevent dust streams to persist forever. Default: 0.1 x referenceGasPriceLimit', + default: process.env.MIN_GAS_PRICE_LIMIT_MWEI ? parseInt(process.env.MIN_GAS_PRICE_LIMIT_MWEI) : undefined }) .option('gasMultiplier', { alias: 'g', @@ -67,9 +79,11 @@ const batchSize = argv.batchSize; const gasMultiplier = argv.gasMultiplier; const token = argv.token; const loop = argv.loop; -const maxGasPrice = argv.maxGasPriceMwei * 1000000; +const referenceGasPriceLimit = argv.referenceGasPriceLimitMwei * 1000000; +const fallbackGasPriceLimit = argv.fallbackGasPriceLimitMwei ? argv.fallbackGasPriceLimitMwei * 1000000 : referenceGasPriceLimit * 10; +const minGasPriceLimit = argv.minGasPriceLimitMwei ? argv.minGasPriceLimitMwei * 1000000 : referenceGasPriceLimit * 0.1; -const ghr = new Graphinator(network, batchSize, gasMultiplier, maxGasPrice); +const ghr = new Graphinator(network, batchSize, gasMultiplier, referenceGasPriceLimit, fallbackGasPriceLimit, minGasPriceLimit); if(loop) { const executeLiquidations = async () => { try { diff --git a/src/datafetcher.ts b/src/datafetcher.ts index e354023..2a0be4b 100644 --- a/src/datafetcher.ts +++ b/src/datafetcher.ts @@ -42,6 +42,10 @@ class DataFetcher { if (criticalAccounts.length > 0) { for (const account of criticalAccounts) { + // ONLY_ACCOUNT is just for testing + if (process.env.ONLY_ACCOUNT && account.account.id !== process.env.ONLY_ACCOUNT) { + continue; + } log(`? Probing ${account.account.id} token ${account.token.id} net fr ${account.totalNetFlowRate} cfa net fr ${account.totalCFANetFlowRate} gda net fr ${await gdaForwarder.getNetFlow(account.token.id, account.account.id)}`); const rtb = await targetToken.realtimeBalanceOfNow(account.account.id); const { availableBalance, deposit } = rtb; @@ -72,7 +76,7 @@ class DataFetcher { }); netFlowRate += BigInt(flow.currentFlowRate); processedCFAFlows++; - if (netFlowRate >= ZERO) { + if (!process.env.LIQUIDATE_ALL && netFlowRate >= ZERO) { break; } } @@ -91,7 +95,7 @@ class DataFetcher { }); netFlowRate += BigInt(flow.pool.flowRate); processedGDAFlows++; - if (netFlowRate >= BigInt(0)) { + if (!process.env.LIQUIDATE_ALL && netFlowRate >= BigInt(0)) { break; } } diff --git a/src/graphinator.ts b/src/graphinator.ts index 35699bb..7316dfe 100644 --- a/src/graphinator.ts +++ b/src/graphinator.ts @@ -7,6 +7,24 @@ const GDAv1ForwarderAbi = require("@superfluid-finance/ethereum-contracts/build/ const bigIntToStr = (key: string, value: any) => (typeof value === 'bigint' ? value.toString() : value); const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); + +type TokenPrices = Record; + +const tokenPricesAllNetworks: Record = { + "base-mainnet": { + // DEGENx + "0x1eff3dd78f4a14abfa9fa66579bd3ce9e1b30529": 0.02, + // ETHx + "0x46fd5cfb4c12d87acd3a13e92baa53240c661d93": 4000, + // USDCx + "0xd04383398dd2426297da660f9cca3d439af9ce1b": 1, + // cbBTCx + "0xdfd428908909cb5e24f5e79e6ad6bde10bdf2327": 100000, + // DAIx + "0x708169c8c87563ce904e0a7f3bfc1f3b0b767f41": 1, + } +} + /** * Graphinator is responsible for processing and liquidating flows. */ @@ -20,7 +38,11 @@ export default class Graphinator { private depositConsumedPctThreshold: number; private batchSize: number; private gasMultiplier: number; - private maxGasPrice: number; + private referenceGasPriceLimit: number; + private fallbackGasPriceLimit: number; + private minGasPriceLimit: number; + private tokenPrices: Record; + private refGasPrice: number; /** * Creates an instance of Graphinator. @@ -29,11 +51,18 @@ export default class Graphinator { * @param gasMultiplier - The gas multiplier for estimating gas limits. * @param maxGasPrice - The maximum gas price allowed. */ - constructor(networkName: string, batchSize: number, gasMultiplier: number, maxGasPrice: number) { + constructor(networkName: string, batchSize: number, gasMultiplier: number, referenceGasPriceLimit: number, fallbackGasPriceLimit: number, minGasPriceLimit: number) { this.batchSize = batchSize; this.gasMultiplier = gasMultiplier; - this.maxGasPrice = maxGasPrice; - log(`maxGasPrice: ${maxGasPrice} (${maxGasPrice / 1000000000} gwei)`); + this.referenceGasPriceLimit = referenceGasPriceLimit; + this.fallbackGasPriceLimit = fallbackGasPriceLimit; + this.minGasPriceLimit = minGasPriceLimit; + log(`referenceGasPriceLimit: ${referenceGasPriceLimit} (${referenceGasPriceLimit / 1000000000} gwei)`); + log(`fallbackGasPriceLimit: ${fallbackGasPriceLimit} (${fallbackGasPriceLimit / 1000000000} gwei)`); + log(`minGasPriceLimit: ${minGasPriceLimit} (${minGasPriceLimit / 1000000000} gwei)`); + if (this.minGasPriceLimit > this.fallbackGasPriceLimit || this.minGasPriceLimit > this.referenceGasPriceLimit) { + throw new Error("minGasPriceLimit must be less than fallbackGasPriceLimit and less than referenceGasPriceLimit"); + } const network = sfMeta.getNetworkByName(networkName); if (network === undefined) { @@ -63,6 +92,13 @@ export default class Graphinator { ? Number(import.meta.env.DEPOSIT_CONSUMED_PCT_THRESHOLD) : 20; log(`Will liquidate outflows of accounts with more than ${this.depositConsumedPctThreshold}% of the deposit consumed`); + + this.tokenPrices = tokenPricesAllNetworks[networkName] || {}; + log(`Token prices: ${JSON.stringify(this.tokenPrices, null, 2)}`); + + // default: 0.011 gwei + this.refGasPrice = process.env.REF_GAS_PRICE_MWEI ? Number(process.env.REF_GAS_PRICE_MWEI) * 1000000 : 11000000; + log(`Ref gas price: ${this.refGasPrice / 1000000000} gwei`); } // If no token is provided: first get a list of all tokens. @@ -78,9 +114,25 @@ export default class Graphinator { const flowsToLiquidate = await this.dataFetcher.getFlowsToLiquidate(tokenAddr, this.gdaForwarder, this.depositConsumedPctThreshold); if (flowsToLiquidate.length > 0) { log(`Found ${flowsToLiquidate.length} flows to liquidate`); - const chunks = this._chunkArray(flowsToLiquidate, this.batchSize); + + // now we calculate the max gas price per flow and filter out those above the current gas price + const currentGasPrice = Number((await this.provider.getFeeData()).gasPrice); + if (!currentGasPrice) { + throw new Error("Current gas price not found"); + } + log(`Current network gas price: ${currentGasPrice / 1e9} gwei`); + + const flowsWorthLiquidating = flowsToLiquidate.filter(flow => this._calculateMaxGasPrice(flow) >= currentGasPrice); + log(`${flowsWorthLiquidating.length} flows with max gas price in range`); + + // now sort the flows by max gas price descending + flowsWorthLiquidating.sort((a, b) => this._calculateMaxGasPrice(b) - this._calculateMaxGasPrice(a)); + log(`Sorted flows by max gas price descending`); + + const chunks = this._chunkArray(flowsWorthLiquidating, this.batchSize); for (const chunk of chunks) { - await this.batchLiquidateFlows(tokenAddr, chunk); + // leave some margin to avoid getting stuck if the gas price is ticking up + await this.batchLiquidateFlows(tokenAddr, chunk, Math.floor(currentGasPrice * 1.2)); } } else { log(`No critical accounts for token: ${tokenAddr}`); @@ -88,6 +140,26 @@ export default class Graphinator { } } + + _calculateMaxGasPrice(flow: Flow): number { + console.log("flow.token", flow.token); + const tokenPrice = this.tokenPrices[flow.token]; + console.log("tokenPrice", tokenPrice); + + const flowrate = Number(flow.flowrate); + + // refMaxGasPrice: the max gas price for 1$ worth of daily flowrate. + // REF_GAS_PRICE_MWEI MUST be defined! + const refDailyNFR = 1e18; + //console.log("refDailyNFR", refDailyNFR); + console.log("flow.flowrate", flowrate); + const dailyNFR = tokenPrice ? Math.round(flowrate * 86400 * tokenPrice) : undefined; + console.log(`dailyNFR: ${dailyNFR / 1e18}$`); + const maxGasPrice = dailyNFR ? Math.max(this.minGasPriceLimit, Math.round(dailyNFR * this.referenceGasPriceLimit / refDailyNFR)) : this.fallbackGasPriceLimit; + console.log(`maxGasPrice: ${maxGasPrice / 1e9} gwei`); + return maxGasPrice; + } + // Liquidate all flows in one batch transaction. // The caller is responsible for sizing the array such that it fits into one transaction. // (Note: max digestible size depends on chain and context like account status, SuperApp receiver etc.) @@ -96,33 +168,27 @@ export default class Graphinator { * @param token - The address of the token. * @param flows - The array of flows to liquidate. */ - private async batchLiquidateFlows(token: AddressLike, flows: Flow[]): Promise { + private async batchLiquidateFlows(token: AddressLike, flows: Flow[], maxGasPrice: number): Promise { try { const txData = await this._generateBatchLiquidationTxData(token, flows); const gasLimit = await this._estimateGasLimit(txData); - const initialGasPrice = (await this.provider.getFeeData()).gasPrice; - - if (initialGasPrice && initialGasPrice <= this.maxGasPrice) { - const tx = { - to: txData.to, - data: txData.data, - gasLimit, - gasPrice: initialGasPrice, - chainId: (await this.provider.getNetwork()).chainId, - nonce: await this.provider.getTransactionCount(this.wallet.address), - }; - - if (process.env.DRY_RUN) { - log(`Dry run - tx: ${JSON.stringify(tx, bigIntToStr)}`); - } else { - const signedTx = await this.wallet.signTransaction(tx); - const transactionResponse = await this.provider.broadcastTransaction(signedTx); - const receipt = await transactionResponse.wait(); - log(`Transaction successful: ${receipt?.hash}`); - } + + const tx = { + to: txData.to, + data: txData.data, + gasLimit, + gasPrice: maxGasPrice, + chainId: (await this.provider.getNetwork()).chainId, + nonce: await this.provider.getTransactionCount(this.wallet.address), + }; + + if (process.env.DRY_RUN) { + log(`Dry run - tx: ${JSON.stringify(tx, bigIntToStr)}`); } else { - log(`Gas price ${initialGasPrice} too high, skipping transaction`); - await this._sleep(1000); + const signedTx = await this.wallet.signTransaction(tx); + const transactionResponse = await this.provider.broadcastTransaction(signedTx); + const receipt = await transactionResponse.wait(); + log(`Transaction successful: ${receipt?.hash}`); } } catch (error) { console.error(`(Graphinator) Error processing chunk: ${error}`); From fba8259c37d3b5a6a5cef4f99ce822c849f67025 Mon Sep 17 00:00:00 2001 From: didi Date: Sat, 14 Dec 2024 12:52:27 +0100 Subject: [PATCH 2/3] fix LIQUIDATE_ALL mode --- src/datafetcher.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/datafetcher.ts b/src/datafetcher.ts index 2a0be4b..87f137f 100644 --- a/src/datafetcher.ts +++ b/src/datafetcher.ts @@ -37,15 +37,17 @@ class DataFetcher { async getFlowsToLiquidate(token: AddressLike, gdaForwarder: Contract, depositConsumedPctThreshold: number): Promise { const returnData: Flow[] = []; - const criticalAccounts = await this.getCriticalAccountsByTokenNow(token); + const criticalAccounts = await this.getCriticalAccountsByTokenNow(token, process.env.LIQUIDATE_ALL ? false : true); const targetToken = new Contract(token.toString(), ISuperTokenAbi, this.provider); if (criticalAccounts.length > 0) { + console.log(`Found ${criticalAccounts.length} maybe-critical accounts`); for (const account of criticalAccounts) { // ONLY_ACCOUNT is just for testing if (process.env.ONLY_ACCOUNT && account.account.id !== process.env.ONLY_ACCOUNT) { continue; } + // TODO: totalNetFlowrate seems to only be CFA. log(`? Probing ${account.account.id} token ${account.token.id} net fr ${account.totalNetFlowRate} cfa net fr ${account.totalCFANetFlowRate} gda net fr ${await gdaForwarder.getNetFlow(account.token.id, account.account.id)}`); const rtb = await targetToken.realtimeBalanceOfNow(account.account.id); const { availableBalance, deposit } = rtb; @@ -58,7 +60,7 @@ class DataFetcher { const cfaNetFlowRate = BigInt(account.totalCFANetFlowRate); const gdaNetFlowRate = await gdaForwarder.getNetFlow(account.token.id, account.account.id); let netFlowRate = cfaNetFlowRate + gdaNetFlowRate; - if (netFlowRate >= ZERO) { + if (!process.env.LIQUIDATE_ALL && netFlowRate >= ZERO) { continue; } log(`! Critical ${account.account.id} token ${account.token.id} net fr ${netFlowRate} (cfa ${cfaNetFlowRate} gda ${gdaNetFlowRate})`); @@ -171,7 +173,8 @@ class DataFetcher { * @param token - The address of the token. * @returns A promise that resolves to an array of critical accounts. */ - async getCriticalAccountsByTokenNow(token: AddressLike): Promise { + async getCriticalAccountsByTokenNow(token: AddressLike, onlyNegativeNetFlowrate: boolean = true): Promise { + console.log(`Getting critical accounts for token ${token} with onlyNegativeNetFlowrate ${onlyNegativeNetFlowrate}`); const _tokenLowerCase = token.toString().toLowerCase(); const timestamp = Math.floor(Date.now() / 1000); return this._queryAllPages( @@ -179,7 +182,7 @@ class DataFetcher { accountTokenSnapshots (first: ${MAX_ITEMS}, where: { id_gt: "${lastId}", - totalNetFlowRate_lt: 0, + ${onlyNegativeNetFlowrate ? 'totalNetFlowRate_lt: 0,' : ''} maybeCriticalAtTimestamp_lt: ${timestamp} token: "${_tokenLowerCase}" } From d00ad8c7c313e3e182d683ee6e57cd9095d9da35 Mon Sep 17 00:00:00 2001 From: didi Date: Tue, 17 Dec 2024 22:06:27 +0100 Subject: [PATCH 3/3] script for updating token prices, snapshot of current token prices --- .env.example | 5 + README.md | 2 + data/token_prices.json | 135 ++++++++++++++++++++++++ src/graphinator.ts | 37 ++----- utils/update-token-prices.js | 192 +++++++++++++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 27 deletions(-) create mode 100644 .env.example create mode 100644 data/token_prices.json create mode 100644 utils/update-token-prices.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0ea40c --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# private key of the account to be used for liquidation transactions +PRIVATE_KEY=... + +# coingecko API needed in order to update token prices +COINGECKO_API_KEY=... diff --git a/README.md b/README.md index cf87dda..bed0860 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ In case we won't those liquidated anyway, setting this value to a value which is Limitation: the current implementation does not take into account the min deposit setting of a token. +Token prices are taken from `data/token_prices.json` which can be updated running `bun utils/update-token-prices.js` (you need to provide an env var `COINGECKO_API_KEY`). This prices don't need to be very accurate thus don't need frequent updating. + ## License [MIT](https://choosealicense.com/licenses/mit/) \ No newline at end of file diff --git a/data/token_prices.json b/data/token_prices.json new file mode 100644 index 0000000..70205bc --- /dev/null +++ b/data/token_prices.json @@ -0,0 +1,135 @@ +{ + "xdai-mainnet": { + "0x59988e47A3503AaFaA0368b9deF095c818Fdca01": 1, + "0x2bf2ba13735160624a0feae98f6ac8f70885ea61": 17195.16, + "0x63e62989d9eb2d37dfdb1f93a22f063635b07d51": 0.00212553, + "0x1234756ccf0660e866305289267211823ae86eec": 0.996915, + "0x7aeca73f38f8f33ab7ff067fed1268384d12324d": 0.01554628, + "0x9757d68a4273635c69d93b84ee3cdac2304dd467": 3946.9, + "0xac945dc430cef2bb8fb78b3cb5fbc936dd2c8ab6": 0.775939, + "0xc0712524b39323eb2437e69226b261d928629dc8": 3.27, + "0xd30aa2f6fc32c4dc8bf3bd5994d89c8f9e81fcd1": 1.14, + "0xe83fd17028c2dd3ca4a9b75f2836d4558fe00686": 0.647644 + }, + "polygon-mainnet": { + "0x3aD736904E9e65189c3000c7DD2c8AC8bB7cD4e3": 0.578071, + "0x263026e7e53dbfdce5ae55ade22493f828922965": 0.00196513, + "0x00fa3405a6bbd94549f3b4855a9736766c4f237e": 1, + "0x02ef6868d69707b6093a2962bb5fe5661fcc0deb": 1.071, + "0x07b24bbd834c1c546ece89ff95f71d9f13a2ebd1": 1.001, + "0x12c294107772b10815307c05989dabd71c21670e": 0.646793, + "0x1305f6b6df9dc47159d12eb7ac2804d4a33173c2": 1, + "0x1adca32b906883e474aebcba5708b41f3645f941": 0.114399, + "0x1bd951a9c9a5ec665abef684425f025dc8738cca": 0.217891, + "0x229c5d13452dc302499b5c113768a0db0c9d5c05": 0.04447279, + "0x27e1e4e6bc79d93032abef01025811b7e4727e85": 3948.18, + "0x2c530af1f088b836fa0dca23c7ea50e669508c4c": 1805.49, + "0x3038b359240dff5ccd42dffd21f12b428034be38": 1.05, + "0x32cefdf2b3df73bdebaa7cd3b0135b3a79d28dcc": 0.128545, + "0x3d9cc088bd9357e5941b68d26d6d09254a69949d": 0.02961529, + "0x4086ebf75233e8492f1bcda41c7f2a8288c2fb92": 106304, + "0x48a7908771c752aacf5cd0088dad0a0daaea3716": 0.01160509, + "0x49765f8fcf0a1cd4f98da906f0974a9085d43e51": 0.74182, + "0x4bde23854e7c81218463f6c8f331b46144e98eac": 0.983393, + "0x5d6fdc854b46e8b237bd2ccc2714cfa3d18cf58e": 282.64, + "0x5e31d5bdd6c87edff8659d9ead9ce0013fb47184": 1.055, + "0x61a7b6f0a7737d9bd38fdeaf1d4160e16bf23043": 0.00248786, + "0x6328c1c2e258a314bdac5227e9c7d212312297ad": 1.3, + "0x673d41ebc2b499d3545e5a0309124cd94eda3fb0": 0.00150573, + "0x72a9bae5ce6de9816aadcbc24daa09f5d169a980": 1.056, + "0x7f078f02a77e91e67ee592faed23d1cfcb390a60": 0.0475671, + "0x96eac3913bab431c28895f02cf5c56ad2dab8439": 1.14, + "0x992446b88a7e62c7235bd88108f44543c1887c1f": 0.996278, + "0xa1bd23b582c12c22e5e264a0a69847ca0ed9f2b0": 28.11, + "0xb0512060ee623a656da1f25686743474228ba0e6": 1.53, + "0xb63e38d21b31719e6df314d3d2c351df0d4a9162": 0.448186, + "0xb683fb34a77c06931ba62d804252d1f60596a36a": 0.01025227, + "0xc3fa1e27a2a58ee476f04d4f4867f06b6e906cc5": 0.00228216, + "0xcaa7349cea390f89641fe306d93591f87595dc1f": 1, + "0xcae73e9eee8a01b8b7f94b59133e3821f21470ab": 0.146418, + "0xcb5676568febb4e4f0dca9407318836e7a973183": 9.59, + "0xd89c35b586eadfbde1a3b2d36fb5746c6d3601bc": 1.42, + "0xe04ad5d86c40d53a12357e1ba2a9484f60db0da5": 0.577741, + "0xe0d1978033ee7cf12a8246da7f173f9441b769b0": 0.00529792, + "0xe2d04ab74eed9627c828b3fc10e5fc96fae70348": 0.315083, + "0xfac83774854237b6e31c4b051b91015e403956d3": 0.218918, + "0xfbb291570de4b87353b1e0f586df97a1ed856470": 0.00679081, + "0xfd0577c4707367ff9b637f219388919d3be37592": 2.23 + }, + "optimism-mainnet": { + "0x4ac8bD1bDaE47beeF2D1c6Aa62229509b962Aa0d": 3948.48, + "0x1828bff08bd244f7990eddcd9b19cc654b33cdb4": 2.39, + "0x35adeb0638eb192755b6e52544650603fe65a006": 1.001, + "0x4cab5b9930210e2edc6a905b9c75d615872a1a7e": 0.00822256, + "0x7d342726b69c28d942ad8bfe6ac81b972349d524": 1, + "0x8430f084b939208e2eded1584889c9a66b90562f": 1, + "0x9638ec1d29dfa9835fdb7fa74b5b77b14d6ac77e": 106330, + "0x9f41d0aa24e599fd8d0c180ee3c0f609dc41c622": 1.002 + }, + "arbitrum-one": { + "0xe6C8d111337D0052b9D88BF5d7D55B7f8385ACd3": 3948.48, + "0x1dbc1809486460dcd189b8a15990bca3272ee04e": 1, + "0x22389dd0df30487a8feaa4eebf98cc64d3273294": 15.66, + "0x521677a61d101a80ce0fb903b13cb485232774ee": 0.999562, + "0x95f1ee4cb6dc16136a79d367a010add361e5192c": 1.055, + "0xb3edb2f90fec1bf1f872a9ef143cfd614773ad04": 0.977141, + "0xefa54be8d63fd0d95edd7965d0bd7477c33995a8": 14.53, + "0xfc55f2854e74b4f42d01a6d3daac4c52d9dfdcff": 1.001 + }, + "avalanche-c": { + "0xBE916845D8678b5d2F7aD79525A62D7c08ABba7e": 49.01, + "0xc0fbc4967259786c743361a5885ef49380473dcf": 0.163305, + "0x288398f314d472b82c44855f3f6ff20b633c2a97": 1.001, + "0x6af916e2001bc4935e6d2f256363ed54eb8e20e0": 1.055, + "0x7cd00c2b9a78f270b897457ab070274e4a17de83": 1.001, + "0xa60c5bebccdb9738f63891bbdd7fec3e762f9098": 0.510835, + "0xdf37ee57b2efd215a6a8329d6b8b72064f09bbd0": 1 + }, + "bsc-mainnet": { + "0x529A4116F160c833c61311569D6B33dFF41fD657": 728.78, + "0x0419e1fa3671754f77ec7d5416219a5f9a08b530": 0.999725, + "0x744786ab00ed5a0b77ca754eb6f3ec0607c7fa79": 0.00902639 + }, + "eth-mainnet": { + "0xC22BeA0Be9872d8B7B3933CEc70Ece4D53A900da": 3948.48, + "0x1ba8603da702602a8657980e825a6daa03dee93a": 1.001, + "0x3fa59ea0eee311782c5a5062da1fabd1a70e1d6d": 0.00529792, + "0x479347dfd0be56f2a5f7bb1506bfd7ab24d4ba26": 0.061749, + "0x4f228bf911ed67730e4b51b1f82ac291b49053ee": 1, + "0x7367ab6df2af082f04298196167f37f1e1629d1e": 1.001, + "0x8f6f22a962899ade3c46627df45d4a05622cebf2": 0.00506274, + "0xd70408b34ed121722631d647d37c4e6641ec363d": 1 + }, + "celo-mainnet": { + "0x671425Ae1f272Bc6F79beC3ed5C4b00e9c628240": 0.809481, + "0x62b8b11039fcfe5ab0c56e502b1c372a3d2a9c7a": 0.00005154, + "0x3acb9a08697b6db4cd977e8ab42b6f24722e6d6e": 1.003, + "0x51cacc88227a038cb6083a7870daf7fa3ebd906c": 3956.35, + "0x7a5f9c3e43aadc62647ab5d41802db33dc7d8c4b": 0.163491, + "0xd9f9a02e49225c7ab5b40fce8d44d256b0e984fb": 1.051 + }, + "base-mainnet": { + "0x46fd5cfB4c12D87acD3a13e92BAa53240C661D93": 3948.48, + "0xc0fbc4967259786c743361a5885ef49380473dcf": 0.163305, + "0x04ffb9ce95af003a3aa3611be6c6ca1431151fb5": 0.00065766, + "0x09b1ad979d093377e201d804fa9ac0a9a07cfb0b": 1.9, + "0x1eff3dd78f4a14abfa9fa66579bd3ce9e1b30529": 0.01387201, + "0x304989da2adc80a6568170567d477af5e48dbaae": 4165, + "0x307d8225c6428a1e505f824b1c97cf9127351f0c": 0.0000124, + "0x4db26c973fae52f43bd96a8776c2bf1b0dc29556": 1, + "0x4e395ec7b71dd87a23dd836edb3efe15a6c2002b": 0.01611749, + "0x58122a048878f25c8c5d4b562419500ed74c6f75": 4682.13, + "0x5f2fab273f1f64b6bc6ab8f35314cd21501f35c5": 0.02309759, + "0x65e9b4ae7885bd3e4e1cc4f280863b859d231fc2": 0.0000353, + "0x708169c8c87563ce904e0a7f3bfc1f3b0b767f41": 0.999927, + "0x7d2e87324c9b2cc983804fe53df67f1add3f913c": 1.012, + "0x7ef392131c3ab326016cf7b560f96c91f4f9d4fa": 0.00166606, + "0x8414ab8c70c7b16a46012d49b8111959baf2fc42": 0.00000167, + "0x9097e4a4d75a611b65ab21d98a7d5b1177c050f7": 0.02490377, + "0xcc6bce523c20f582daaf0ccafaae981df46ceb41": 0.00122015, + "0xd04383398dd2426297da660f9cca3d439af9ce1b": 1.001, + "0xdfd428908909cb5e24f5e79e6ad6bde10bdf2327": 106770, + "0xe58267cd7299c29a1b77f4e66cd12dd24a2cd2fd": 0.02690319, + "0xefbe11336b0008dce3797c515e6457cc4841645c": 0.00066834 + } +} \ No newline at end of file diff --git a/src/graphinator.ts b/src/graphinator.ts index 7316dfe..28106f0 100644 --- a/src/graphinator.ts +++ b/src/graphinator.ts @@ -5,25 +5,11 @@ import sfMeta from "@superfluid-finance/metadata"; const BatchLiquidatorAbi = require("@superfluid-finance/ethereum-contracts/build/hardhat/contracts/utils/BatchLiquidator.sol/BatchLiquidator.json").abi; const GDAv1ForwarderAbi = require("@superfluid-finance/ethereum-contracts/build/hardhat/contracts/utils/GDAv1Forwarder.sol/GDAv1Forwarder.json").abi; +const tokenPricesAllNetworks = require("../data/token_prices.json") || undefined; + const bigIntToStr = (key: string, value: any) => (typeof value === 'bigint' ? value.toString() : value); const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); -type TokenPrices = Record; - -const tokenPricesAllNetworks: Record = { - "base-mainnet": { - // DEGENx - "0x1eff3dd78f4a14abfa9fa66579bd3ce9e1b30529": 0.02, - // ETHx - "0x46fd5cfb4c12d87acd3a13e92baa53240c661d93": 4000, - // USDCx - "0xd04383398dd2426297da660f9cca3d439af9ce1b": 1, - // cbBTCx - "0xdfd428908909cb5e24f5e79e6ad6bde10bdf2327": 100000, - // DAIx - "0x708169c8c87563ce904e0a7f3bfc1f3b0b767f41": 1, - } -} /** * Graphinator is responsible for processing and liquidating flows. @@ -94,7 +80,7 @@ export default class Graphinator { log(`Will liquidate outflows of accounts with more than ${this.depositConsumedPctThreshold}% of the deposit consumed`); this.tokenPrices = tokenPricesAllNetworks[networkName] || {}; - log(`Token prices: ${JSON.stringify(this.tokenPrices, null, 2)}`); + log(`Loaded ${Object.keys(this.tokenPrices).length} token prices`); // default: 0.011 gwei this.refGasPrice = process.env.REF_GAS_PRICE_MWEI ? Number(process.env.REF_GAS_PRICE_MWEI) * 1000000 : 11000000; @@ -140,23 +126,20 @@ export default class Graphinator { } } - + /* + * Calculate the max gas price we're willed to bid for liquidating this flow, + * taking into account the normalized (deniminated in the same unit of account) flowrate, the reference gas price limit + * (representing our limit for a normalized flowrate of 1 token per day) + * amd the minimum gas price limit (which avoids dust flows to exist in perpetuity). + * If the token price is not known, the fallback limit is returned. + */ _calculateMaxGasPrice(flow: Flow): number { - console.log("flow.token", flow.token); const tokenPrice = this.tokenPrices[flow.token]; - console.log("tokenPrice", tokenPrice); - const flowrate = Number(flow.flowrate); - // refMaxGasPrice: the max gas price for 1$ worth of daily flowrate. - // REF_GAS_PRICE_MWEI MUST be defined! const refDailyNFR = 1e18; - //console.log("refDailyNFR", refDailyNFR); - console.log("flow.flowrate", flowrate); const dailyNFR = tokenPrice ? Math.round(flowrate * 86400 * tokenPrice) : undefined; - console.log(`dailyNFR: ${dailyNFR / 1e18}$`); const maxGasPrice = dailyNFR ? Math.max(this.minGasPriceLimit, Math.round(dailyNFR * this.referenceGasPriceLimit / refDailyNFR)) : this.fallbackGasPriceLimit; - console.log(`maxGasPrice: ${maxGasPrice / 1e9} gwei`); return maxGasPrice; } diff --git a/utils/update-token-prices.js b/utils/update-token-prices.js new file mode 100644 index 0000000..8bdeafa --- /dev/null +++ b/utils/update-token-prices.js @@ -0,0 +1,192 @@ +/* + * Creates a json file with the current price of all listed tokens of all SF networks known to coingecko. + * First fetches networks.json from github and filters mainnets. + * Then fetches a list of "coins" from coingecko (/coins/list). + * Then for each mainnet, gets the listed SuperTokens from protocol subgraph. + * Matches SF networks and token addresses with coingecko platforms and coin addresses. + * For each "platform", does a request to /simple/token_price/ for getting the price of assets we care about. + * Writes the data to json. + */ + +// Import necessary modules +import axios from 'axios'; +import fs from 'fs'; + +const outputFile = './data/token_prices.json'; +const cgBaseUrl = process.env.COINGECKO_BASE_URL || 'https://pro-api.coingecko.com/api/v3'; +const cgApiKey = process.env.COINGECKO_API_KEY; + +if (!cgApiKey) { + throw new Error('COINGECKO_API_KEY is not set'); +} + +async function fetchListedSuperTokens(network) { + const subgraph_url = `https://${network.name}.subgraph.x.superfluid.dev`; + + try { + const response = await axios.post(subgraph_url, { + query: ` + query { + tokens(first: 1000, where: { isSuperToken: true, isListed: true, isNativeAssetSuperToken: false }) { + id + underlyingAddress + name + symbol + } + } + ` + }); + return response.data.data.tokens; + } catch (error) { + throw new Error(`Error fetching tokens for ${network.name}: ${error}`); + } +} + +async function fetchCoingeckoPlatformTokenPrices(cg_base_url, cg_api_token, platform, addresses) { + const url = `${cg_base_url}/simple/token_price/${platform}?contract_addresses=${addresses.join(',')}&vs_currencies=usd`; + try { + const response = await axios.get(url, { + headers: { + 'x-cg-pro-api-key': cg_api_token + } + }); + return response.data; + } catch (error) { + console.error(`Error fetching prices for tokens on ${platform}: ${error}`); + return {}; + } +} + +async function fetchNativeTokenId(cg_base_url, cg_api_token, symbol) { + // overrides: xDAI -> DAI, MATIC -> POL + if (symbol === 'xDAI') { + symbol = 'DAI'; + } else if (symbol === 'MATIC') { + symbol = 'POL'; + } + + try { + const response = await axios.get(`${cg_base_url}/search?query=${symbol}`, { + headers: { + 'x-cg-pro-api-key': cg_api_token + } + }); + + // Find the most relevant coin matching the symbol + const coin = response.data.coins.find(c => + c.symbol.toLowerCase() === symbol.toLowerCase() && + c.market_cap_rank // Prefer coins with market cap ranking + ); + + return coin?.id; + } catch (error) { + console.error(`Error searching for native token ${symbol}: ${error}`); + return null; + } +} + +async function fetchNativeCoinPrice(cg_base_url, cg_api_token, coinId) { + const url = `${cg_base_url}/simple/price?ids=${coinId}&vs_currencies=usd`; + try { + const response = await axios.get(url, { + headers: { + 'x-cg-pro-api-key': cg_api_token + } + }); + return response.data[coinId]?.usd; + } catch (error) { + console.error(`Error fetching price for native coin ${coinId}: ${error}`); + return null; + } +} + +async function run(cg_base_url, cg_api_token) { + let tokenPrices = {}; + + try { + console.log("Fetching networks.json from github"); + const networks_json = (await axios.get('https://raw.githubusercontent.com/superfluid-finance/protocol-monorepo/dev/packages/metadata/networks.json')).data; + const filtered_networks = networks_json.filter(network => !network.isTestnet); + + console.log("Fetching coinList from coingecko"); + const coinList = (await axios.get(`${cg_base_url}/coins/list?include_platform=true`, { + headers: { + 'x-cg-pro-api-key': cg_api_token + } + })).data; + + await filtered_networks.reduce(async (promise, network) => { + await promise; + + if (!network.coinGeckoId) { + console.log(`Skipping network ${network.name} - no Coingecko ID found`); + return; + } + + console.log(`Processing network ${network.name} (Coingecko platform: ${network.coinGeckoId})`); + const tokens = await fetchListedSuperTokens(network); + + tokenPrices[network.name] = {}; + + // Split tokens into three categories + const nativeTokenWrapper = network.nativeTokenWrapper; + + const pureSuperTokens = tokens.filter(token => + token.underlyingAddress === '0x0000000000000000000000000000000000000000' + ); + const wrapperSuperTokens = tokens.filter(token => + token.underlyingAddress !== '0x0000000000000000000000000000000000000000' && + token.id.toLowerCase() !== network.nativeTokenWrapper?.toLowerCase() + ); + + // 1. Handle native token wrapper + if (nativeTokenWrapper && network.nativeTokenSymbol) { + const nativeTokenId = await fetchNativeTokenId(cg_base_url, cg_api_token, network.nativeTokenSymbol); + if (nativeTokenId) { + const price = await fetchNativeCoinPrice(cg_base_url, cg_api_token, nativeTokenId); + if (price) { + tokenPrices[network.name][nativeTokenWrapper] = price; + console.log(` Native token wrapper ${nativeTokenWrapper} (${network.nativeTokenSymbol}x) price: ${price}`); + } + } + } + + // 2. Handle pure super tokens + if (pureSuperTokens.length > 0) { + const addresses = pureSuperTokens.map(token => token.id); + const prices = await fetchCoingeckoPlatformTokenPrices(cg_base_url, cg_api_token, network.coinGeckoId, addresses); + + pureSuperTokens.forEach(token => { + const price = prices[token.id.toLowerCase()]?.usd; + if (price) { + tokenPrices[network.name][token.id] = price; + console.log(` Pure super token ${token.id} (${token.symbol}) price: ${price}`); + } + }); + } + + // 3. Handle wrapper super tokens + if (wrapperSuperTokens.length > 0) { + const addresses = wrapperSuperTokens.map(token => token.underlyingAddress); + const prices = await fetchCoingeckoPlatformTokenPrices(cg_base_url, cg_api_token, network.coinGeckoId, addresses); + + wrapperSuperTokens.forEach(token => { + const price = prices[token.underlyingAddress.toLowerCase()]?.usd; + if (price) { + tokenPrices[network.name][token.id] = price; + console.log(` Wrapper super token ${token.id} (${token.symbol}) price: ${price}`); + } + }); + } + }, Promise.resolve()); + + // Write results to file + fs.writeFileSync(outputFile, JSON.stringify(tokenPrices, null, 2)); + console.log(`Output saved to ${outputFile}`); + + } catch (error) { + console.error("An error occurred:", error); + } +} + +run(cgBaseUrl, cgApiKey);