diff --git a/grt.ts b/grt.ts index aa8761d..5010154 100755 --- a/grt.ts +++ b/grt.ts @@ -1,14 +1,15 @@ #!/usr/bin/env bun -import path from "path"; 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)) diff --git a/src/datafetcher.ts b/src/datafetcher.ts index 8b37893..e354023 100644 --- a/src/datafetcher.ts +++ b/src/datafetcher.ts @@ -14,6 +14,11 @@ 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"); @@ -22,7 +27,13 @@ class DataFetcher { this.provider = provider; } - // @TODO: refactor, we are mixing two concepts here get critical accounts with subgraph data + /** + * 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[] = []; @@ -31,7 +42,7 @@ class DataFetcher { if (criticalAccounts.length > 0) { for (const account of criticalAccounts) { - console.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)); + 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 @@ -46,7 +57,7 @@ class DataFetcher { if (netFlowRate >= ZERO) { continue; } - console.log("! Critical", account.account.id, "token", account.token.id, "net fr", netFlowRate, "(cfa", cfaNetFlowRate, "gda", gdaNetFlowRate, ")"); + 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; @@ -85,11 +96,11 @@ class DataFetcher { } } - console.log(` available balance ${availableBalance}, deposit ${deposit}, consumed deposit ${consumedDepositPercentage}%, flows to-be-liquidated/total: ${processedCFAFlows}/${cfaFlows.length} cfa | ${processedGDAFlows}/${gdaFlows.length} gda`); + 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 { - console.log("!!! no cfa|gda outflows to liquidate"); + log("!!! no cfa|gda outflows to liquidate"); } } } @@ -97,6 +108,12 @@ class DataFetcher { 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(); @@ -121,6 +138,12 @@ class DataFetcher { ); } + /** + * 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(); @@ -139,6 +162,11 @@ class DataFetcher { ); } + /** + * 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); @@ -169,6 +197,11 @@ class DataFetcher { ); } + /** + * 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) => `{ @@ -195,7 +228,11 @@ class DataFetcher { 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) => `{ @@ -213,7 +250,11 @@ class DataFetcher { ); } - /// DataFetcher methods + /** + * 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) { @@ -228,6 +269,13 @@ class DataFetcher { 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[] = []; diff --git a/src/graphinator.ts b/src/graphinator.ts index 17f6a34..8b6c3de 100644 --- a/src/graphinator.ts +++ b/src/graphinator.ts @@ -2,12 +2,14 @@ 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}`); +/** + * Graphinator is responsible for processing and liquidating flows. + */ export default class Graphinator { private dataFetcher: DataFetcher; @@ -20,11 +22,18 @@ export default class Graphinator { 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; - console.log(`maxGasPrice: ${maxGasPrice} (${maxGasPrice / 1000000000} gwei)`); + log(`maxGasPrice: ${maxGasPrice} (${maxGasPrice / 1000000000} gwei)`); const network = sfMeta.getNetworkByName(networkName); if (network === undefined) { @@ -41,36 +50,40 @@ export default class Graphinator { if (!network.contractsV1.gdaV1Forwarder) { throw new Error("GDA Forwarder contract address not found in metadata"); } - console.log(`(Graphinator) Initialized wallet: ${this.wallet.address}`); + log(`Initialized wallet: ${this.wallet.address}`); 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); - console.log(`(Graphinator) Initialized batch contract at ${network.contractsV1.batchLiquidator}`); + log(`Initialized batch contract at ${network.contractsV1.batchLiquidator}`); this.depositConsumedPctThreshold = import.meta.env.DEPOSIT_CONSUMED_PCT_THRESHOLD ? Number(import.meta.env.DEPOSIT_CONSUMED_PCT_THRESHOLD) : 20; - console.log(`(Graphinator) Will liquidate outflows of accounts with more than ${this.depositConsumedPctThreshold}% of the deposit consumed`); + log(`Will liquidate outflows of accounts with more than ${this.depositConsumedPctThreshold}% of the deposit consumed`); } // 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) { - console.log(`Found ${flowsToLiquidate.length} flows to liquidate`); + 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 { - console.log(`(Graphinator) No critical accounts for token: ${tokenAddr}`); + log(`No critical accounts for token: ${tokenAddr}`); } } } @@ -78,6 +91,11 @@ export default class Graphinator { // 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); @@ -95,15 +113,15 @@ export default class Graphinator { }; if (process.env.DRY_RUN) { - console.log(`(Graphinator) Dry run - tx: ${JSON.stringify(tx, bigIntToStr)}`); + 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(); - console.log(`(Graphinator) Transaction successful: ${receipt?.hash}`); + log(`Transaction successful: ${receipt?.hash}`); } } else { - console.log(`(Graphinator) Gas price ${initialGasPrice} too high, skipping transaction`); + log(`Gas price ${initialGasPrice} too high, skipping transaction`); await this._sleep(1000); } } catch (error) { @@ -111,11 +129,21 @@ export default class Graphinator { } } + /** + * 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) { @@ -124,7 +152,12 @@ export default class Graphinator { return chunks; } - + /** + * 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"); @@ -139,6 +172,11 @@ export default class Graphinator { 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, @@ -147,6 +185,11 @@ export default class Graphinator { return Math.floor(Number(gasEstimate) * this.gasMultiplier); } + /** + * 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)); }