diff --git a/README.md b/README.md index da70f8b..af8f8e7 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,42 @@ -# graphinator +# About -Graphinator is a small tool to execute liquidations based on graph data. +The **graphinator** is a lightweight alternative to the [superfluid-sentinel](https://github.com/superfluid-finance/superfluid-sentinel). +It looks for [critical or insolvent accounts](https://docs.superfluid.finance/docs/protocol/advanced-topics/solvency/liquidations-and-toga) and liquidates their outgoing flows (CFA and GDA). +Unlike the sentinel, it is stateless and relies on the [Superfluid Subgraph](https://console.superfluid.finance/subgraph) as data source. +By default, the graphinator operates in a _one-shot_ mode, meaning: it checks and liquidates once, then exits. +For continued operation, it's recommended to set up a cronjob. -## Prerequisites - -- [Bun](https://bun.sh/) +Once graphinator instance operates for a to-be-specified chain. +By default, it operates on all [listed Super Token](https://console.superfluid.finance/supertokens), but also allows to operate only on a single Super Token. -## Install Bun +## Prerequisites +Install Bun: ```bash curl -fsSL https://bun.sh/install | bash ``` -To install dependencies: - +Set up the repo and install dependencies: ```bash +git clone https://github.com/superfluid-finance/graphinator +cd graphinator bun install ``` ## Run -- Set your environment variables in a `.env` file. You'll need to provide your private key. (check ```.env.example```) -- Make the `grt.ts` file executable: `chmod +x grt.ts` - -Fast run: - -```bash -./grt.ts -t 0x1eff3dd78f4a14abfa9fa66579bd3ce9e1b30529 ``` - -### OR - -```bash -./grt.ts -t 0x1eff3dd78f4a14abfa9fa66579bd3ce9e1b30529 -l true +PRIVATE_KEY=... ./grt.ts -n ``` -### Options +_network_ needs to be the canonical name of a chain where Superfluid is deployed. See [metadata/networks.json](https://github.com/superfluid-finance/protocol-monorepo/blob/dev/packages/metadata/networks.json) (field _name_). For example `base-mainnet`. + +You can also provide `PRIVATE_KEY` via an `.env` file. -- `--network`: The network to use. Defaults to `base-mainnet`. -- `--token`: The token to liquidate. This is a required option. -- `--batchSize`: The number of accounts to liquidate in each batch. Defaults to `15`. -- `--gasMultiplier`: A multiplier to apply to the estimated gas cost for each transaction. Defaults to `1.2`. -- `--loop`: If set, the script will run indefinitely, checking for new accounts to liquidate every 15min. +Make sure `grt.ts` is executable. +See `./grt.ts --help` for more config options. ## License diff --git a/bun.lockb b/bun.lockb index cd55a74..8196a5b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/grt.ts b/grt.ts index f57d6a3..5010154 100755 --- a/grt.ts +++ b/grt.ts @@ -1,77 +1,88 @@ #!/usr/bin/env bun +import dotenv from "dotenv"; + import yargs from "yargs" import { hideBin } from "yargs/helpers" import Graphinator from './src/graphinator'; + const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); + +dotenv.config(); + const argv = await yargs(hideBin(process.argv)) .option('network', { alias: 'n', type: 'string', description: 'Set the network', - default: 'base-mainnet' + demandOption: true, + default: process.env.NETWORK }) - .option('batchSize', { - alias: 'b', + /* + Note: there's currently no scientific way to determine a safe batch size. + That's because the gas consumed by an individual flow's liquidation can vary widely, + especially if SuperApp callbacks are involved. + The safe and default choice is thus 1. + Most of the time considerably higher values (e.g. 10) will work and may be used. + But since the logic is currently such that a failing batch could stall any progress, + setting this differently should be a conscious choice. + */ + .option('token', { + alias: 't', + type: 'string', + 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', { + alias: 'm', type: 'number', - description: 'Set the batch size', - default: 15 + 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 }) .option('gasMultiplier', { alias: 'g', type: 'number', - description: 'Set the gas multiplier', - default: 1.2 + description: 'Set the gas multiplier - allows to define the gas limit margin set on top of the estimation', + default: process.env.GAS_MULTIPLIER ? parseFloat(process.env.GAS_MULTIPLIER) : 1.2 }) - .option('token', { - alias: 't', - type: 'string', - description: 'Set the token to liquidate', - demandOption: true + .option('batchSize', { + alias: 'b', + type: 'number', + description: 'Set the batch size', + default: process.env.BATCH_SIZE ? parseInt(process.env.BATCH_SIZE) : 1 }) .option('loop', { alias: 'l', type: 'boolean', - description: 'Set to true to loop forever, false to run once', - default: false - }) - .option('maxGasPrice', { - alias: 'm', - type: 'number', - description: 'Set the max gas price', - default: 500000000 + description: 'Set to true to loop forever, false to run once.', + default: process.env.LOOP === 'true' }) .parse(); -const runAgainIn = 15 * 60 * 1000; +const runAgainIn = 30000 //15 * 60 * 1000; const network = argv.network; const batchSize = argv.batchSize; const gasMultiplier = argv.gasMultiplier; -const token = argv.token.toLowerCase(); +const token = argv.token; const loop = argv.loop; -const maxGasPrice = BigInt(argv.maxGasPrice); - - -const config = { - batchContractAddress: '0x6b008BAc0e5846cB5d9Ca02ca0e801fCbF88B6f9', - gdaForwarderAddress: '0x6DA13Bde224A05a288748d857b9e7DDEffd1dE08', - superTokenAddress: token -} +const maxGasPrice = argv.maxGasPriceMwei * 1000000; -const ghr = new Graphinator(network, config); +const ghr = new Graphinator(network, batchSize, gasMultiplier, maxGasPrice); if(loop) { - log("run liquidations forever...", "🤖"); - await ghr.run(batchSize, gasMultiplier, maxGasPrice, BigInt(0)); - setInterval(async () => { + const executeLiquidations = async () => { try { - await ghr.run(batchSize, gasMultiplier, maxGasPrice, BigInt(0)); + await ghr.processAll(token); } catch (error) { console.error(error); + } finally { + log(`run again in ${runAgainIn}`); + setTimeout(executeLiquidations, runAgainIn); // Schedule the next run } - }, runAgainIn); + }; + await executeLiquidations(); } else { - log("run liquidations once...", "🤖"); - await ghr.run(batchSize, gasMultiplier, maxGasPrice, BigInt(0)); + log(new Date().toISOString() + " - run liquidations..."); + await ghr.processAll(token); } diff --git a/package.json b/package.json index 8767318..395e8cd 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "dependencies": { "@superfluid-finance/ethereum-contracts": "^1.9.1", + "@superfluid-finance/metadata": "^1.5.0", "@types/yargs": "^17.0.32", "axios": "^1.7.1", + "dotenv": "^16.4.5", "ethers": "^6.12.1", "yargs": "^17.7.2" } diff --git a/src/datafetcher.ts b/src/datafetcher.ts new file mode 100644 index 0000000..e354023 --- /dev/null +++ b/src/datafetcher.ts @@ -0,0 +1,307 @@ +import axios, {type AxiosResponse } from 'axios'; +import {Contract, type AddressLike, JsonRpcProvider, ethers} from "ethers"; +import type { CriticalAccount, Flow } from "./types/types.ts"; +import { AgreementType } from "./types/types.ts"; +const ISuperTokenAbi = require("@superfluid-finance/ethereum-contracts/build/hardhat/contracts/interfaces/superfluid/ISuperToken.sol/ISuperToken.json").abi; + +const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); +const MAX_ITEMS = 1000; +const ZERO = BigInt(0); + +// Fetches data using subgraph and rpc +class DataFetcher { + + private subgraphUrl: string; + private provider: JsonRpcProvider; + + /** + * Creates an instance of DataFetcher. + * @param subgraphUrl - The URL of the subgraph endpoint. + * @param provider - The JSON RPC provider. + */ + constructor(subgraphUrl: string, provider: JsonRpcProvider) { + if(!subgraphUrl) { + throw new Error("subgraph URL not set"); + } + this.subgraphUrl = subgraphUrl; + this.provider = provider; + } + + /** + * Fetches flows to liquidate based on the given token, GDA forwarder contract, and deposit consumed percentage threshold. + * @param token - The address of the token. + * @param gdaForwarder - The GDA forwarder contract. + * @param depositConsumedPctThreshold - The deposit consumed percentage threshold. + * @returns A promise that resolves to an array of Flow objects. + */ + async getFlowsToLiquidate(token: AddressLike, gdaForwarder: Contract, depositConsumedPctThreshold: number): Promise { + + const returnData: Flow[] = []; + const criticalAccounts = await this.getCriticalAccountsByTokenNow(token); + const targetToken = new Contract(token.toString(), ISuperTokenAbi, this.provider); + + if (criticalAccounts.length > 0) { + for (const account of criticalAccounts) { + 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; + if (availableBalance < 0) { // critical or insolvent + const consumedDepositPercentage = -Number(availableBalance * 100n / deposit); + if (consumedDepositPercentage < depositConsumedPctThreshold) { + continue; + } + + const cfaNetFlowRate = BigInt(account.totalCFANetFlowRate); + const gdaNetFlowRate = await gdaForwarder.getNetFlow(account.token.id, account.account.id); + let netFlowRate = cfaNetFlowRate + gdaNetFlowRate; + if (netFlowRate >= ZERO) { + continue; + } + log(`! Critical ${account.account.id} token ${account.token.id} net fr ${netFlowRate} (cfa ${cfaNetFlowRate} gda ${gdaNetFlowRate})`); + + const cfaFlows = await this.getOutgoingFlowsFromAccountByToken(account.token.id, account.account.id); + let processedCFAFlows = 0; + for (const flow of cfaFlows) { + const data = flow.id.split("-"); + returnData.push({ + agreementType: AgreementType.CFA, + sender: data[0], + receiver: data[1], + token: data[2], + flowrate: BigInt(flow.currentFlowRate) + }); + netFlowRate += BigInt(flow.currentFlowRate); + processedCFAFlows++; + if (netFlowRate >= ZERO) { + break; + } + } + + const gdaFlows = await this.getAllFlowDistributions(account.token.id, account.account.id); + let processedGDAFlows = 0; + for (const flow of gdaFlows) { + const data = flow.id.split("-"); + const pool = data[1]; + returnData.push({ + agreementType: AgreementType.GDA, + sender: account.account.id, + receiver: pool, + token: account.token.id, + flowrate: BigInt(flow.pool.flowRate) + }); + netFlowRate += BigInt(flow.pool.flowRate); + processedGDAFlows++; + if (netFlowRate >= BigInt(0)) { + break; + } + } + + log(`available balance ${availableBalance}, deposit ${deposit}, consumed deposit ${consumedDepositPercentage}%, flows to-be-liquidated/total: ${processedCFAFlows}/${cfaFlows.length} cfa | ${processedGDAFlows}/${gdaFlows.length} gda`); + if (processedCFAFlows > 0 || processedGDAFlows > 0) { + continue; + } else { + log("!!! no cfa|gda outflows to liquidate"); + } + } + } + } + return returnData.sort((a, b) => Number(b.flowrate - a.flowrate)); + } + + /** + * Fetches outgoing flows from an account by token. + * @param token - The address of the token. + * @param account - The address of the account. + * @returns A promise that resolves to an array of outgoing flows. + */ + async getOutgoingFlowsFromAccountByToken(token: AddressLike, account: AddressLike): Promise { + const _accountLowerCase = account.toString().toLowerCase(); + const _tokenLowerCase = token.toString().toLowerCase(); + return this._queryAllPages( + (lastId: string) => `{ + account(id: "${_accountLowerCase}") { + outflows( + orderBy: currentFlowRate, + orderDirection: desc, + where: { + token: "${_tokenLowerCase}", + id_gt: "${lastId}", + currentFlowRate_not: "0", + }) { + id + currentFlowRate + } + } + }`, + res => res.data.data.account.outflows, + i => i + ); + } + + /** + * Fetches all flow distributions for a given token and account. + * @param token - The address of the token. + * @param account - The address of the account. + * @returns A promise that resolves to an array of flow distributions. + */ + async getAllFlowDistributions(token: AddressLike, account: AddressLike): Promise { + const _accountLowerCase = account.toString().toLowerCase(); + const _tokenLowerCase = token.toString().toLowerCase(); + return this._queryAllPages( + (lastId: string) => `{ + poolDistributors(where: {account: "${_accountLowerCase}", id_gt: "${lastId}", pool_: {token: "${_tokenLowerCase}"}, flowRate_gt: "0"}) { + id + pool { + id + flowRate + } + } + }`, + res => res.data.data.poolDistributors, + i => i + ); + } + + /** + * Fetches critical accounts by token at the current time. + * @param token - The address of the token. + * @returns A promise that resolves to an array of critical accounts. + */ + async getCriticalAccountsByTokenNow(token: AddressLike): Promise { + const _tokenLowerCase = token.toString().toLowerCase(); + const timestamp = Math.floor(Date.now() / 1000); + return this._queryAllPages( + (lastId: string) => `{ + accountTokenSnapshots (first: ${MAX_ITEMS}, + where: { + id_gt: "${lastId}", + totalNetFlowRate_lt: 0, + maybeCriticalAtTimestamp_lt: ${timestamp} + token: "${_tokenLowerCase}" + } + ) { + id + totalNetFlowRate + totalCFANetFlowRate + token { + id + symbol + } + account { + id + } + } + }`, + res => res.data.data.accountTokenSnapshots, + i => i + ); + } + + /** + * Fetches critical accounts at a specific timestamp. + * @param timestamp - The timestamp to fetch critical accounts at. + * @returns A promise that resolves to an array of critical accounts. + */ + async getCriticalAccountsAt(timestamp: number): Promise { + return this._queryAllPages( + (lastId: string) => `{ + accountTokenSnapshots (first: ${MAX_ITEMS}, + where: { + id_gt: "${lastId}", + totalNetFlowRate_lt: 0, + maybeCriticalAtTimestamp_lt: ${timestamp} + } + ){ + id + totalNetFlowRate + totalCFANetFlowRate + token { + id + symbol + } + account { + id + } + } + }`, + res => res.data.data.accountTokenSnapshots, + i => i + ); + } + /** + * Fetches all super tokens. + * @param isListed - Whether to fetch listed tokens or not. + * @returns A promise that resolves to an array of super tokens. + */ + async getSuperTokens(isListed: boolean = true): Promise { + return this._queryAllPages( + (lastId: string) => `{ + tokens(first: ${MAX_ITEMS}, where: {id_gt: "${lastId}", isListed: ${isListed}}) { + isListed + isNativeAssetSuperToken + isSuperToken + name + symbol + id + } + }`, + res => res.data.data.tokens, + i => i + ); + } + + /** + * Executes a GraphQL query. + * @param query - The GraphQL query string. + * @returns A promise that resolves to the Axios response. + */ + private async _graphql(query: string): Promise> { + + if (!this.subgraphUrl) { + throw new Error("DataFetcher URL not set"); + } + + const headers = { + //"Authorization": `bearer ${process.env.GITHUB_TOKEN}`, + //"Accept": accept ? accept : "application/vnd.github.v3+json", + }; + + return await axios.post(this.subgraphUrl, {query}, {headers}); + } + + /** + * Queries all pages of a paginated GraphQL response. + * @param queryFn - A function that generates the GraphQL query string. + * @param toItems - A function that extracts items from the Axios response. + * @param itemFn - A function that processes each item. + * @returns A promise that resolves to an array of all items. + */ + private async _queryAllPages(queryFn: (lastId: string) => string, toItems: (res: AxiosResponse) => any[], itemFn: (item: any) => any): Promise { + let lastId = ""; + const items: any[] = []; + + while (true) { + const res = await this._graphql(queryFn(lastId)); + + if (res.status !== 200 || res.data.errors) { + console.error(`bad response ${res.status}`); + //process.exit(2); + } else if (res.data === "") { + console.error("empty response data"); + } else { + const newItems = toItems(res); + items.push(...newItems.map(itemFn)); + + if (newItems.length < MAX_ITEMS) { + break; + } else { + lastId = newItems[newItems.length - 1].id; + } + } + } + + return items; + } +} + +export default DataFetcher; diff --git a/src/graphinator.ts b/src/graphinator.ts index 5223ef6..8b6c3de 100644 --- a/src/graphinator.ts +++ b/src/graphinator.ts @@ -1,118 +1,196 @@ -import {type AddressLike, ethers} from "ethers"; -import RPC, {ContractManager} from "./rpc.ts"; -import type SubGraphReader from "./subgraph.ts"; -import type { Pair } from "./subgraph.ts"; - - -type ContractConfig = { - batchContractAddress: string, - gdaForwarderAddress: string, - superTokenAddress: string -} - - +import {type AddressLike, ethers, type TransactionLike} from "ethers"; +import DataFetcher from "./datafetcher.ts"; +import type {Flow} from "./types/types.ts"; +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 bigIntToStr = (key: string, value: any) => (typeof value === 'bigint' ? value.toString() : value); const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); - -enum Priority { - HIGH, - NORMAL, - LOW -} - +/** + * Graphinator is responsible for processing and liquidating flows. + */ export default class Graphinator { - private subgraph: SubGraphReader; - private rpc: RPC; - private contractManager: ContractManager; + private dataFetcher: DataFetcher; + private provider: ethers.JsonRpcProvider; + private wallet: ethers.Wallet; + private gdaForwarder: ethers.Contract; + private batchLiquidator: ethers.Contract; + private depositConsumedPctThreshold: number; + private batchSize: number; + private gasMultiplier: number; + private maxGasPrice: number; + + /** + * Creates an instance of Graphinator. + * @param networkName - The name of the network. + * @param batchSize - The size of the batch for processing flows. + * @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) { + this.batchSize = batchSize; + this.gasMultiplier = gasMultiplier; + this.maxGasPrice = maxGasPrice; + log(`maxGasPrice: ${maxGasPrice} (${maxGasPrice / 1000000000} gwei)`); + + const network = sfMeta.getNetworkByName(networkName); + if (network === undefined) { + throw new Error(`network ${networkName} unknown - not in metadata. If the name is correct, you may need to update.`); + } + this.provider = new ethers.JsonRpcProvider(`https://rpc-endpoints.superfluid.dev/${networkName}?app=graphinator`); + this.dataFetcher = new DataFetcher(`https://subgraph-endpoints.superfluid.dev/${networkName}/protocol-v1`, this.provider); - constructor(network: string, config: ContractConfig) { - if(!network) { - throw new Error("No network provided"); + const privateKey = import.meta.env.PRIVATE_KEY; + if (!privateKey) { + throw new Error("No private key provided"); } - if(!config.superTokenAddress) { - throw new Error("No token provided"); + this.wallet = new ethers.Wallet(privateKey, this.provider); + if (!network.contractsV1.gdaV1Forwarder) { + throw new Error("GDA Forwarder contract address not found in metadata"); } + log(`Initialized wallet: ${this.wallet.address}`); - this.rpc = new RPC(network, config); - this.subgraph = this.rpc.getSubgraphReader(`https://${network}.subgraph.x.superfluid.dev`); - this.contractManager = this.rpc.getContractManager(); - } - - async chunkAndLiquidate(priority: Priority, pairs: Pair[], batchSize: number, gasMultiplier: number, maxGasPrice: bigint) { - // split into chunks - const chunks = []; - for (let i = 0; i < pairs.length; i += batchSize) { - chunks.push(pairs.slice(i, i + batchSize)); + this.gdaForwarder = new ethers.Contract(network.contractsV1.gdaV1Forwarder!, GDAv1ForwarderAbi, this.wallet); + if (!network.contractsV1.gdaV1Forwarder) { + throw new Error("Batch Liquidator contract address not found in metadata"); } + this.batchLiquidator = new ethers.Contract(network.contractsV1.batchLiquidator!, BatchLiquidatorAbi, this.wallet); + log(`Initialized batch contract at ${network.contractsV1.batchLiquidator}`); - for (const chunk of chunks) { - const txData = await this.contractManager.generateBatchLiquidationTxDataNewBatch(chunk); - const gasEstimate = await this.rpc.estimateGas({ - to: txData.target.toString(), - data: txData.tx - }); - const gasLimit = Math.floor(Number(gasEstimate) * gasMultiplier); - - const initialGasPrice = (await this.rpc.getFeeData()).gasPrice; - let gasPrice = maxGasPrice; - if(import.meta.env.MAX_GAS_PRICE_MWEI) { - log(`max gas price set to ${import.meta.env.MAX_GAS_PRICE_MWEI} mwei`, "⛽️") - gasPrice = ethers.parseUnits(import.meta.env.MAX_GAS_PRICE_MWEI, 'mwei'); - } + this.depositConsumedPctThreshold = import.meta.env.DEPOSIT_CONSUMED_PCT_THRESHOLD + ? Number(import.meta.env.DEPOSIT_CONSUMED_PCT_THRESHOLD) + : 20; + log(`Will liquidate outflows of accounts with more than ${this.depositConsumedPctThreshold}% of the deposit consumed`); + } - let adjustedGasPrice = Number(gasPrice); - if(priority === Priority.HIGH) { - adjustedGasPrice = 2.5; - } else if(priority === Priority.LOW) { - adjustedGasPrice = 0.5; + // If no token is provided: first get a list of all tokens. + // Then for the provided or all tokens: + // get the outgoing flows of all critical accounts, then chunk and batch-liquidate them + /** + * Processes all tokens or a specific token to find and liquidate flows. + * @param token - The address of the token to process. If not provided, all tokens will be processed. + */ + async processAll(token?: AddressLike): Promise { + const tokenAddrs = token ? [token] : await this._getSuperTokens(); + for (const tokenAddr of tokenAddrs) { + 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); + for (const chunk of chunks) { + await this.batchLiquidateFlows(tokenAddr, chunk); + } + } else { + log(`No critical accounts for token: ${tokenAddr}`); } + } + } - if(initialGasPrice && initialGasPrice <= adjustedGasPrice) { - // send tx + // 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.) + /** + * Liquidates all flows in one batch transaction. + * @param token - The address of the token. + * @param flows - The array of flows to liquidate. + */ + private async batchLiquidateFlows(token: AddressLike, flows: Flow[]): 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.target.toString(), - data: txData.tx, - gasLimit: gasLimit, + to: txData.to, + data: txData.data, + gasLimit, gasPrice: initialGasPrice, - chainId: (await this.rpc.getNetwork()).chainId, - nonce: await this.rpc.getTransactionCount() + chainId: (await this.provider.getNetwork()).chainId, + nonce: await this.provider.getTransactionCount(this.wallet.address), }; - const hash = await this.rpc.signAndSendTransaction(tx); - log(`hash ${hash}`, "✅"); - // sleep for 3 seconds - await new Promise(resolve => setTimeout(resolve, 3000)); + + 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}`); + } } else { - log(`gas price ${initialGasPrice} too high, skipping tx`, "⛽️"); + log(`Gas price ${initialGasPrice} too high, skipping transaction`); + await this._sleep(1000); } + } catch (error) { + console.error(`(Graphinator) Error processing chunk: ${error}`); } } - async run(batchSize:number, gasMultiplier:number, maxGasPrice:bigint, netFlowThreshold:bigint): Promise { - try { - netFlowThreshold = BigInt(72685368696059); - const pairs = await this.subgraph.getCriticalPairs(netFlowThreshold); - - if (pairs.length === 0) { - log("no streams to liquidate found"); - return; - } - - const highPriorityPairs = pairs.filter(pair => pair.priority >= 80); - const normalPriorityPairs = pairs.filter(pair => pair.priority >= 65 && pair.priority < 80); - const lowPriorityPairs = pairs.filter(pair => pair.priority < 65); - - console.log("High Priority Pairs: ", highPriorityPairs.length); - console.log("Normal Priority Pairs: ", normalPriorityPairs.length); - console.log("Low Priority Pairs: ", lowPriorityPairs.length); + /** + * Fetches all super tokens. + * @returns A promise that resolves to an array of super token addresses. + */ + private async _getSuperTokens(): Promise { + return (await this.dataFetcher.getSuperTokens()) + .map(token => token.id); + } + /** + * Splits an array into chunks of a specified size. + * @param array - The array to split. + * @param size - The size of each chunk. + * @returns An array of chunks. + */ + private _chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } - await this.chunkAndLiquidate(Priority.HIGH, highPriorityPairs, batchSize, gasMultiplier, maxGasPrice); - await this.chunkAndLiquidate(Priority.NORMAL, normalPriorityPairs, batchSize, gasMultiplier, maxGasPrice); - await this.chunkAndLiquidate(Priority.LOW, lowPriorityPairs, batchSize, gasMultiplier, maxGasPrice); + /** + * Generates the transaction data for batch liquidation. + * @param token - The address of the token. + * @param flows - The array of flows to liquidate. + * @returns A promise that resolves to the transaction data. + */ + private async _generateBatchLiquidationTxData(token: AddressLike, flows: Flow[]): Promise { + if (!flows.every(flow => flow.token === token)) { + throw new Error("flow with wrong token"); + } + const structParams = flows.map(flows => ({ + agreementOperation: flows.agreementType, + sender: flows.sender, + receiver: flows.receiver, + })); + const transactionData = this.batchLiquidator!.interface.encodeFunctionData('deleteFlows', [token, structParams]); + const transactionTo = await this.batchLiquidator!.getAddress(); + return { data: transactionData, to: transactionTo }; + } + /** + * Estimates the gas limit for a transaction. + * @param transaction - The transaction to estimate the gas limit for. + * @returns A promise that resolves to the estimated gas limit. + */ + private async _estimateGasLimit(transaction: TransactionLike): Promise { + const gasEstimate = await this.provider.estimateGas({ + to: transaction.to, + data: transaction.data, + }); + return Math.floor(Number(gasEstimate) * this.gasMultiplier); + } - } catch (error) { - console.error(error); - } + /** + * Pauses execution for a specified amount of time. + * @param ms - The number of milliseconds to pause. + * @returns A promise that resolves after the specified time. + */ + private async _sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } } diff --git a/src/rpc.ts b/src/rpc.ts deleted file mode 100644 index 7481573..0000000 --- a/src/rpc.ts +++ /dev/null @@ -1,171 +0,0 @@ -import {ethers, type AddressLike, Interface, type InterfaceAbi, type TransactionRequest} from "ethers"; -import SubGraphReader from "./subgraph.ts"; - -const ISuperToken = require("@superfluid-finance/ethereum-contracts/build/hardhat/contracts/interfaces/superfluid/ISuperToken.sol/ISuperToken.json").abi; -const BatchContract = require("@superfluid-finance/ethereum-contracts/build/hardhat/contracts/utils/BatchLiquidator.sol/BatchLiquidator.json").abi; -const GDAv1Forwarder = require("@superfluid-finance/ethereum-contracts/build/hardhat/contracts/utils/GDAv1Forwarder.sol/GDAv1Forwarder.json").abi; -const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); - -type TransactionData = { - data: string, - to: AddressLike -} - -// Manages RPC calls and smart contract interactions -export default class RPC { - - private provider: ethers.JsonRpcProvider; - private wallet: ethers.Wallet; - private contractManager: ContractManager; - - constructor(networkName: string, config: { batchContractAddress: string; gdaForwarderAddress: string; superTokenAddress: string; } ) { - this.provider = new ethers.JsonRpcProvider(`https://${networkName}.rpc.x.superfluid.dev/`); - const __privateKey = import.meta.env.PRIVATE_KEY; - if(!__privateKey) { - throw new Error("No private key provided"); - } - - this.wallet = new ethers.Wallet(__privateKey, this.provider); - this.contractManager = new ContractManager(config, this.wallet); - } - - async estimateGas(txData: TransactionData): Promise { - return await this.provider.estimateGas(txData); - } - - async getFeeData(): Promise { - return await this.provider.getFeeData(); - } - - async getNetwork(): Promise { - return await this.provider.getNetwork(); - } - - async getInstanceOfContract(address: AddressLike, abi: Interface | InterfaceAbi,) { - address = await address; - return new ethers.Contract(address, abi, this.wallet); - } - - async getTransactionCount(): Promise { - return await this.provider.getTransactionCount(this.wallet.address); - } - - getContractManager() { - return this.contractManager; - } - - getSubgraphReader(subgraphUrl: string) { - return new SubGraphReader(subgraphUrl, this.contractManager); - } - - getProvider() { - return this.provider; - } - - async signAndSendTransaction(tx: TransactionRequest) { - const signedTx = await this.wallet.signTransaction(tx); - const transactionResponse = await this.provider.broadcastTransaction(signedTx); - const receipt = await transactionResponse.wait(); - return receipt?.hash; - } -} - -export class SuperToken { - private superToken: ethers.Contract; - - constructor(superToken: ethers.Contract) { - this.superToken = superToken; - } - - async getAddress() { - return await this.superToken.getAddress(); - } - - async isAccountCriticalNow(account: AddressLike): Promise { - return await this.superToken.isAccountCriticalNow(account); - } - - async getPriority(criticalAccount: AddressLike, totalNetFlow: bigint, netFlowThreshold: bigint): Promise { - - const [rtb, isSolvent] = await Promise.all([ - this.superToken.realtimeBalanceOfNow(criticalAccount), - this.superToken.isAccountSolventNow(criticalAccount) - ]); - - let { availableBalance, deposit } = rtb; - availableBalance = -Number(availableBalance); - deposit = Number(deposit); - - if (deposit === 0) { - throw new Error("Deposit is zero, can't calculate priority."); - } - const consumedDepositPercentage = Math.max(0, Math.min(100, Math.round(availableBalance / deposit * 100))); - const howFastIsConsuming = Math.abs(Number(totalNetFlow)) / Number(netFlowThreshold); - - // baseline - let priority = 50n; - if (!isSolvent) { - priority += 20n; - } - if (howFastIsConsuming > 10) { - priority += 10n; - } - // adjusted to have linear growth and not a step function - if (totalNetFlow > 0n) { - priority += (20n * totalNetFlow) / netFlowThreshold; - } - // +1 is just to make sure it's not 0 and also to make it 1-100 - const progressBarLength = 50; - const filledLength = Math.round(consumedDepositPercentage / 100 * progressBarLength); - const progressBar = '█'.repeat(filledLength) + '-'.repeat(progressBarLength - filledLength); - log(`acccount ${criticalAccount} deposit consumed: [${progressBar}] ${consumedDepositPercentage}%`, "⚖️"); - - const priorityNumber = Number(priority); - return Math.max(0, Math.min(100, priorityNumber)); - } -} - -// Holds the contract addresses and ABIs -export class ContractManager { - - private batchContract: ethers.Contract; - private gdaForwarder: ethers.Contract; - private superToken: SuperToken; - - constructor(config: { batchContractAddress: string; gdaForwarderAddress: string; superTokenAddress: string; }, wallet: ethers.Wallet) { - this.batchContract = new ethers.Contract(config.batchContractAddress, BatchContract, wallet); - this.gdaForwarder = new ethers.Contract(config.gdaForwarderAddress, GDAv1Forwarder, wallet); - this.superToken = new SuperToken(new ethers.Contract(config.superTokenAddress, ISuperToken, wallet)); - } - - getBatchContractInstance() { - return this.batchContract; - } - - getGDAForwarderInstance() { - return this.gdaForwarder; - } - - getSuperTokenInstance() { - return this.superToken; - } - - async generateBatchLiquidationTxDataNewBatch(liquidationParams: string | any[]) { - const superToken = await this.superToken.getAddress(); - try { - let structParams = []; - for(let i = 0; i < liquidationParams.length; i++) { - structParams.push({ - agreementOperation: liquidationParams[i].source === "CFA" ? "0" : "1", - sender: liquidationParams[i].sender, - receiver: liquidationParams[i].receiver - }) - } - const tx = this.batchContract.interface.encodeFunctionData('deleteFlows', [superToken, structParams]); - return { tx: tx, target: await this.batchContract.getAddress() }; - } catch (error) { - console.error(error); - throw error; - } - } -} \ No newline at end of file diff --git a/src/sentinel-manifest.json b/src/sentinel-manifest.json new file mode 100644 index 0000000..74034ae --- /dev/null +++ b/src/sentinel-manifest.json @@ -0,0 +1,93 @@ +{ + "schema-version": "3", + "name": "sentinel-manifest", + "networks": { + "1": { + "name": "Ethereum", + "network_type": "evm" + }, + "5": { + "name": "Goerli Testnet", + "network_type": "evm" + }, + "10": { + "name": "Optimism", + "network_type": "evm-l2", + "batch_contract": "0x84956C84c33c38AcE22C9324F1f92028AF2215ce" + }, + "56": { + "name": "Binance Smart Chain", + "network_type": "evm", + "batch_contract": "0x27636F8E129cdd4ccA0F30E2b4C116DDaC773bE5" + }, + "100": { + "name": "Gnosis Chain", + "network_type": "evm", + "batch_contract": "0x96C3C2d23d143301cF363a02cB7fe3596d2834d7" + }, + "137": { + "name": "Polygon", + "network_type": "evm", + "batch_contract": "0xA7afDc46999076C295cfC6812dd73d103cF64e19" + }, + "420": { + "name": "Optimism Goerli Testnet", + "network_type": "evm-l2" + }, + "8453": { + "name": "Base Mainnet", + "network_type": "evm-l2", + "batch_contract": "0x6b008BAc0e5846cB5d9Ca02ca0e801fCbF88B6f9" + }, + "42161": { + "name": "Arbitrum One", + "network_type": "evm-l2", + "batch_contract": "0x9224413b9177E6c1D5721B4a4D1D00eC84B07Ce7" + }, + "42220": { + "name": "Celo", + "network_type": "evm", + "batch_contract": "0xCb0Ff4D0cA186f0Fc0301258066Fe3fA258417a6" + }, + "43113": { + "name": "Avalanche Fuji Testnet", + "network_type": "evm" + }, + "43114": { + "name": "Avalanche C-Chain", + "network_type": "evm", + "batch_contract": "0x3b387638a5d33aE8772715642A21345f23Af824c" + }, + "80001": { + "name": "Polygon Mumbai Testnet", + "network_type": "evm", + "cid": "QmV1MCLREr2DWczA6nvajxe1EfiGmTgT9PH2h2Qo1Zvfo7" + }, + "84531": { + "name": "Base Goerli Testnet", + "network_type": "evm" + }, + "421613": { + "name": "Arbitrum Goerli Testnet", + "network_type": "evm-l2" + }, + "534351": { + "name": "Scroll Sepolia Testnet", + "network_type": "evm-l2" + }, + "534352": { + "name": "Scroll", + "network_type": "evm-l2", + "batch_contract": "0x3024A39099D4FAE7c9eA8329FAfe05576AEd2c00" + }, + "11155420": { + "name": "Optimism Sepolia Testnet", + "network_type": "evm-l2" + }, + "666666666": { + "name": "Degenchain", + "network_type": "evm-l2", + "batch_contract": "0x7BCE8e8401dc98E3Da26F1D701c3C2168b8e466c" + } + } +} diff --git a/src/subgraph.ts b/src/subgraph.ts deleted file mode 100644 index 20c3c21..0000000 --- a/src/subgraph.ts +++ /dev/null @@ -1,199 +0,0 @@ -import axios, {type AxiosResponse } from 'axios'; -import {JsonRpcProvider, Contract, type AddressLike, type Interface, type InterfaceAbi} from "ethers"; -import {type ContractManager, SuperToken} from "./rpc.ts"; - -const log = (msg: string, lineDecorator="") => console.log(`${new Date().toISOString()} - ${lineDecorator} (Graphinator) ${msg}`); -const MAX_ITEMS = 1000; - -export type Pair = { - source: string, - sender: string, - receiver: string, - token: string, - flowrate: bigint, - priority: number -}; - -class Subgraph { - private subgraphUrl: string; - - constructor(url: string) { - if(!url) { - throw new Error("Subgraph URL not set"); - } - this.subgraphUrl = url; - } - - async graphql(query: string, accept?: string): Promise> { - - if (!this.subgraphUrl) { - throw new Error("Subgraph URL not set"); - } - - const headers = { - //"Authorization": `bearer ${process.env.GITHUB_TOKEN}`, - //"Accept": accept ? accept : "application/vnd.github.v3+json", - }; - - return await axios.post(this.subgraphUrl, {query}, {headers}); - } - - async queryAllPages(queryFn: (lastId: string) => string, toItems: (res: AxiosResponse) => any[], itemFn: (item: any) => any): Promise { - let lastId = ""; - const items: any[] = []; - - while (true) { - const res = await this.graphql(queryFn(lastId)); - - if (res.status !== 200 || res.data.errors) { - console.error(res.data); - //process.exit(2); - } - - const newItems = toItems(res); - items.push(...newItems.map(itemFn)); - - if (newItems.length < MAX_ITEMS) { - break; - } else { - lastId = newItems[newItems.length - 1].id; - } - } - - return items; - } - - async getAllOutFlows(token: string, account: string): Promise { - // TODO: order change broke pagination. We don't really need pagination here though - return this.queryAllPages( - (lastId: string) => `{ - account(id: "${account}") { - outflows( - orderBy: currentFlowRate, - orderDirection: desc, - where: { - token: "${token}", - id_gt: "${lastId}", - currentFlowRate_not: "0", - }) { - id - currentFlowRate - } - } - }`, - res => res.data.data.account.outflows, - i => i - ); - } - - async getAccountsCriticalAt(timestamp: number): Promise { - return this.queryAllPages( - (lastId: string) => `{ - accountTokenSnapshots (first: ${MAX_ITEMS}, - where: { - id_gt: "${lastId}", - totalNetFlowRate_lt: 0, - maybeCriticalAtTimestamp_lt: ${timestamp} - } - ) { - id - balanceUntilUpdatedAt - maybeCriticalAtTimestamp - isLiquidationEstimateOptimistic - activeIncomingStreamCount - activeOutgoingStreamCount - activeGDAOutgoingStreamCount - activeCFAOutgoingStreamCount - totalInflowRate - totalOutflowRate - totalNetFlowRate - totalCFAOutflowRate - totalCFANetFlowRate - totalGDAOutflowRate - totalDeposit - token { - id - symbol - } - account { - id - } - } - }`, - res => res.data.data.accountTokenSnapshots, - i => i - ); - } -} - -class SubGraphReader { - private subgraph: Subgraph; - private targetToken: SuperToken; - private gdaForwarder: Contract; - - constructor(url: string, contractManager: ContractManager) { - this.subgraph = new Subgraph(url); - this.targetToken = contractManager.getSuperTokenInstance(); - this.gdaForwarder = contractManager.getGDAForwarderInstance(); - } - - async getCriticalPairs(netFlowThreshold: bigint): Promise { - - const returnData: Pair[] = []; - const now = Math.floor(Date.now() / 1000); - const criticalAccounts = await this.subgraph.getAccountsCriticalAt(now); - const tokenAddressLowerCase = (await this.targetToken.getAddress()).toLowerCase(); - - for (const account of criticalAccounts) { - if(account.token.id.toLowerCase() === tokenAddressLowerCase) { - const isCritical = await this.targetToken.isAccountCriticalNow(account.account.id); - - // sleep 0.5s to avoid rate limiting - await new Promise(r => setTimeout(r, 500)); - if(isCritical) { - - const cfaNetFlowRate = BigInt(account.totalCFANetFlowRate); - const gdaNetFlowRate = await this.gdaForwarder.getNetFlow(account.token.id, account.account.id); - let totalNetFlow = cfaNetFlowRate + gdaNetFlowRate; - const priority = await this.targetToken.getPriority(account.account.id, totalNetFlow, netFlowThreshold) - - if (totalNetFlow >= BigInt(0)) { - log(`account ${account.account.id} netFlowRate ${totalNetFlow}, skipping`, "⏭️"); - continue; - } - - const cfaFlows = await this.subgraph.getAllOutFlows(account.token.id, account.account.id); - const nrFlows = cfaFlows.length; - log(`account ${account.account.id} netFlowRate ${totalNetFlow} with cfaNetFlowRate ${cfaNetFlowRate} & gdaNetFlowRate ${gdaNetFlowRate}`, "⚠️"); - log(`\t|--------------> has ${nrFlows} outflows`, "⚖️"); - let processedFlows = 0; - for (const flow of cfaFlows) { - const data = flow.id.split("-"); - returnData.push({ - source: "CFA", - sender: data[0], - receiver: data[1], - token: data[2], - flowrate: BigInt(flow.currentFlowRate), - priority: priority - }); - totalNetFlow += BigInt(flow.currentFlowRate); - //console.log(`CFA flow: ${data[0]} -> ${data[1]}: ${flow.currentFlowRate} - projected acc net flow rate now: ${netFlowRate}`); - processedFlows++; - if (totalNetFlow >= BigInt(0)) { - break; - } - } - if (processedFlows > 0) { - log(`netFlowRate projected to become positive with ${processedFlows} of ${nrFlows} liquidated`, "🔄"); - } - } - } - } - - // sort by flowrate descending - return returnData.sort((a, b) => Number(b.flowrate - a.flowrate)); - } -} - -export default SubGraphReader; diff --git a/src/types/types.ts b/src/types/types.ts new file mode 100644 index 0000000..8f2eebf --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,34 @@ +import type {AddressLike} from "ethers"; + +export enum AgreementType { + CFA = 0, + GDA = 1 +} + +export type CriticalAccount = { + id: AddressLike, + totalNetFlowRate: number, + totalCFANetFlowRate: number, + token: Token, + account: { + id: AddressLike + } +} + +export type Flow = { + agreementType: AgreementType, + sender: AddressLike, + receiver: AddressLike, + token: AddressLike, + flowrate: bigint +}; + +export type Token = { + decimals: number, + isListed: boolean, + isNativeAssetSuperToken: boolean, + isSuperToken: boolean, + name: string, + symbol: string, + id: AddressLike +} \ No newline at end of file