├── .gitignore ├── src ├── util │ ├── conversion.ts │ ├── process.ts │ ├── math.ts │ ├── crypto.ts │ ├── logic.ts │ ├── blockchain.ts │ ├── retry.ts │ ├── format.ts │ └── prices.ts ├── constants.ts ├── services │ ├── validator │ │ ├── types.ts │ │ ├── helpers.ts │ │ └── validator.ts │ ├── indexer │ │ ├── types.ts │ │ ├── helpers.ts │ │ └── indexer.ts │ └── liquidator │ │ ├── helpers.ts │ │ └── liquidator.ts ├── config.ts ├── db │ ├── types.ts │ ├── helpers.ts │ └── database.ts ├── README.md ├── lib │ ├── balances.ts │ ├── highload_contract_v2.ts │ └── messenger.ts ├── variative_config.ts ├── steady_config.ts └── index.ts ├── nodemon.json ├── jest.config.js ├── jest.config.ts ├── LICENSE.md ├── package.json ├── scripts └── deploy_hw_v2.ts ├── README.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | .env 4 | 5 | build -------------------------------------------------------------------------------- /src/util/conversion.ts: -------------------------------------------------------------------------------- 1 | export const str = (v: any): string => v?.toString() ?? ''; 2 | 3 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": ".ts,.js", 4 | "ignore": [], 5 | "exec": "npx ts-node ./src/index.ts" 6 | } -------------------------------------------------------------------------------- /src/util/process.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number) { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/__tests__/**/*.test.ts'], 5 | }; 6 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/__tests__/**/*.test.ts'], 5 | }; 6 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "@ton/core"; 2 | 3 | export const NULL_ADDRESS = Address.parse('0:0000000000000000000000000000000000000000000000000000000000000000'); -------------------------------------------------------------------------------- /src/services/validator/types.ts: -------------------------------------------------------------------------------- 1 | import {Cell, Dictionary} from "@ton/ton"; 2 | 3 | export type PriceData = { 4 | dict: Dictionary; 5 | dataCell: Cell; 6 | }; -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /* ====================== Variative configuration part ====================== */ 2 | export * from './steady_config'; 3 | 4 | // specify your own variative part of the config 5 | export * from './variative_config'; 6 | -------------------------------------------------------------------------------- /src/util/math.ts: -------------------------------------------------------------------------------- 1 | export const bigAbs = (value: bigint) => value > 0n ? value : -value; 2 | export const bigIntMin = (...args) => args.reduce((m, e) => e < m ? e : m); 3 | export const bigIntMax = (...args) => args.reduce((m, e) => e > m ? e : m); 4 | -------------------------------------------------------------------------------- /src/util/crypto.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | 3 | export function sha256Hash(input: string): bigint { 4 | const hash = crypto.createHash('sha256'); 5 | hash.update(input); 6 | const hashBuffer = hash.digest(); 7 | const hashHex = hashBuffer.toString('hex'); 8 | return BigInt('0x' + hashHex); 9 | } -------------------------------------------------------------------------------- /src/util/logic.ts: -------------------------------------------------------------------------------- 1 | export function notDefined(v: any): boolean { 2 | return typeof v === 'undefined' || v === null; 3 | } 4 | 5 | export function isDefined(v: any): boolean { 6 | return !notDefined(v); 7 | } 8 | 9 | export function checkDefined(v: any, message: string) { 10 | if (notDefined(v)) { 11 | throw new Error(message); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/services/indexer/types.ts: -------------------------------------------------------------------------------- 1 | import {TupleReader} from "@ton/core"; 2 | 3 | export type GetResult = { 4 | gas_used: number; 5 | stack: TupleReader; 6 | exit_code: number; 7 | }; 8 | 9 | export type LiquidationAssetsInfo = { 10 | loanAssetName: string, 11 | loanAssetDecimals: bigint, 12 | collateralAssetName: string, 13 | collateralAssetDecimals: bigint 14 | } 15 | -------------------------------------------------------------------------------- /src/util/blockchain.ts: -------------------------------------------------------------------------------- 1 | import {TonClient} from "@ton/ton"; 2 | import {Address} from "@ton/core"; 3 | 4 | type AddressState = "active" | "uninitialized" | "frozen"; 5 | 6 | export async function checkAddressState(tonClient: TonClient, address: Address): Promise { 7 | const accountState = await tonClient.getContractState(address); 8 | return accountState.state; 9 | } 10 | -------------------------------------------------------------------------------- /src/db/types.ts: -------------------------------------------------------------------------------- 1 | import { Dictionary } from "@ton/ton"; 2 | 3 | export type PrincipalsDict = Dictionary; 4 | export const emptyPrincipals = () => Dictionary.empty(); 5 | 6 | export type User = { 7 | id: number; 8 | wallet_address: string; 9 | contract_address: string; 10 | subaccountId: number; 11 | code_version: number; 12 | created_at: number; 13 | updated_at: number; 14 | actualized_at: number; 15 | principals: PrincipalsDict; 16 | state: string; 17 | }; 18 | 19 | export type Task = { 20 | id: number; 21 | wallet_address: string; 22 | contract_address: string; 23 | subaccountId: number; 24 | created_at: number; 25 | updated_at: number; 26 | loan_asset: bigint; 27 | collateral_asset: bigint; 28 | liquidation_amount: bigint; 29 | min_collateral_amount: bigint; 30 | prices_cell: string; 31 | query_id: bigint; 32 | state: string; 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2024 TON LANDING FOUNDATION 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evaa-liquidator", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "tsc -p .", 8 | "start": "ts-node src/index.ts", 9 | "start:dev": "npx nodemon", 10 | "test": "npx tsx --test" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@types/chai": "^4.3.17", 17 | "@types/jest": "^29.5.13", 18 | "@types/node": "^20.8.3", 19 | "chai": "^5.1.1", 20 | "jest": "^29.7.0", 21 | "nodemon": "^3.0.1", 22 | "ts-node": "^10.9.1", 23 | "typescript": "^5.3.3" 24 | }, 25 | "dependencies": { 26 | "@evaafi/sdk": "^0.9.2-a", 27 | "@iota/sdk": "^1.1.0", 28 | "@orbs-network/ton-access": "^2.3.3", 29 | "@ton/core": "0.56.0", 30 | "@ton/crypto": "3.3.0", 31 | "@ton/ton": "14.0.0", 32 | "@tonconnect/sdk": "^3.0.5", 33 | "axios": "^1.5.1", 34 | "crypto-js": "^4.2.0", 35 | "decimal.js": "10.4.3", 36 | "dotenv": "^16.4.5", 37 | "ethereumjs-util": "7.0.10", 38 | "ethers": "5.6.9", 39 | "evaa-liquidator": "./", 40 | "sort-deep-object-arrays": "^1.1.2", 41 | "sqlite": "^5.0.1", 42 | "sqlite3": "^5.1.6" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/db/helpers.ts: -------------------------------------------------------------------------------- 1 | import {repeatStr} from "../util/format"; 2 | 3 | export function makeCreateUsersScript(columns: string[]): string { 4 | return ` 5 | CREATE TABLE IF NOT EXISTS users( 6 | id INTEGER PRIMARY KEY AUTOINCREMENT, 7 | wallet_address VARCHAR NOT NULL, 8 | contract_address VARCHAR UNIQUE NOT NULL, 9 | subaccountId INTEGER NOT NULL, 10 | code_version INTEGER NOT NULL, 11 | created_at TIMESTAMP NOT NULL, 12 | updated_at TIMESTAMP NOT NULL, 13 | actualized_at TIMESTAMP NOT NULL, 14 | ${columns.map(name => `${name} VARCHAR NOT NULL, `).join('\n')} 15 | state VARCHAR NOT NULL DEFAULT 'active')`; 16 | } 17 | 18 | export function makeProcessUserScript(columns: string[]): string { 19 | const principal_insert_cols = columns.map(col => ( 20 | `${col} = CASE WHEN actualized_at < ? THEN ? ELSE ${col} END`) 21 | ).join(',\n'); 22 | console.log('principals_insert_cols: ', principal_insert_cols); 23 | return ` 24 | INSERT INTO users( 25 | wallet_address, contract_address, subaccountId, code_version, created_at, updated_at, actualized_at, 26 | ${columns.map(name => `${name}`).join(', ')}) 27 | VALUES(${repeatStr('?', 7 + columns.length).join(', ')}) 28 | ON CONFLICT(contract_address) DO UPDATE SET 29 | code_version = CASE WHEN actualized_at < ? THEN ? ELSE code_version END, 30 | created_at = CASE WHEN created_at > ? THEN ? ELSE created_at END, 31 | updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END, 32 | actualized_at = CASE WHEN actualized_at < ? THEN ? ELSE actualized_at END, 33 | ${principal_insert_cols}`; 34 | } 35 | -------------------------------------------------------------------------------- /src/util/retry.ts: -------------------------------------------------------------------------------- 1 | import {sleep} from "./process"; 2 | 3 | export type AsyncLambda = () => Promise; 4 | 5 | const DUMMY_FUNCTION_INSTANCE = async (): Promise => { 6 | }; 7 | 8 | type RetryParams = { 9 | attempts: number, 10 | attemptInterval: number, 11 | verbose: boolean, 12 | on_fail: typeof DUMMY_FUNCTION_INSTANCE, 13 | } 14 | 15 | const DEFAULT_RETRY_PARAMS = { 16 | attempts: 3, 17 | attemptInterval: 3000, 18 | verbose: true, 19 | on_fail: DUMMY_FUNCTION_INSTANCE, 20 | }; 21 | 22 | /** 23 | * Tries to run specified lambda several times if it throws 24 | * @type T type of the return value 25 | * @param lambda lambda function to run 26 | * @param params retry function params: attempts - for number of attempts, attemptInterval - number of ms to wait between retries, ... 27 | * @returns 28 | */ 29 | export async function retry(lambda: AsyncLambda, params: any = {}) { 30 | let value: any = null; 31 | let ok = false; 32 | const {attempts, attemptInterval, verbose, on_fail}: RetryParams = {...DEFAULT_RETRY_PARAMS, ...params}; 33 | let n = attempts; 34 | 35 | while (n > 0 && !ok) { 36 | try { 37 | value = await lambda(); 38 | ok = true; 39 | } catch (e) { 40 | if (typeof on_fail === 'function') { 41 | await on_fail(); 42 | } 43 | if (verbose) { 44 | console.log(e); 45 | } 46 | console.log(`Call failed, retrying. Retries left: ${--n}`); 47 | await sleep(attemptInterval); 48 | } 49 | } 50 | 51 | return {ok, value}; 52 | } -------------------------------------------------------------------------------- /src/util/format.ts: -------------------------------------------------------------------------------- 1 | import {Address} from "@ton/core"; 2 | import {IS_TESTNET} from "../config"; 3 | import {WalletBalances} from "../lib/balances"; 4 | import {Dictionary} from "@ton/ton"; 5 | import {ExtendedAssetsConfig, PoolAssetConfig} from "@evaafi/sdk"; 6 | 7 | export function getAddressFriendly(addr: Address) { 8 | return IS_TESTNET ? 9 | addr.toString({ 10 | bounceable: true, 11 | testOnly: true 12 | }) : 13 | addr.toString({ 14 | bounceable: true, 15 | testOnly: false 16 | }) 17 | } 18 | 19 | export function getFriendlyAmount(amount: bigint, decimals: bigint, name: string) { 20 | let amt = Number(amount); 21 | const scale = (10n ** decimals); 22 | amt /= Number(scale); 23 | return amt.toFixed(2) + " " + name; 24 | } 25 | 26 | export function formatBalances( 27 | balances: WalletBalances, 28 | extAssetsConfig: ExtendedAssetsConfig, 29 | poolAssetsConfig: PoolAssetConfig[] 30 | ) { 31 | return poolAssetsConfig 32 | .map((asset) => { 33 | const assetConfig = extAssetsConfig.get(asset.assetId); 34 | if (!assetConfig) throw `No config for asset ${asset.assetId}`; 35 | const decimals: bigint = assetConfig.decimals; 36 | const balance: bigint = balances.get(asset.assetId) ?? 0n; 37 | const name = asset.name; 38 | return `- ${asset.name}: ${getFriendlyAmount( 39 | balance, 40 | decimals, 41 | name 42 | )}`; 43 | }) 44 | .join("\n"); 45 | } 46 | 47 | export function printPrices(prices: Dictionary) { 48 | prices.keys().forEach((assetId) => { 49 | const price = prices.get(assetId); 50 | console.log(`Asset: ${assetId}: ${Number(price) / 10 ** 9}`); 51 | }) 52 | } 53 | 54 | export function repeatStr(s: string, n: number) { 55 | return Array.from({length: n}, () => s) 56 | } 57 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Liquidation Logic 2 | 3 | This code snippet handles liquidation logic based on the type of loan asset. 4 | 5 | ## TON Loan Asset 6 | 7 | If the loan asset is TON (TON Crystal), the following steps are performed: 8 | 9 | 1. Set the liquidation opcode to `0x3`. 10 | 2. Store the query ID, which can be `0`. 11 | 3. Store the user's wallet address (not the user SC address). This address is used to calculate the user SC address. 12 | 4. Store the ID of the token to be received. It's a SHA256 HASH derived from the Jetton wallet address of the EVAA master smart contract. 13 | 5. Store the minimal amount of tokens required to satisfy the liquidation. 14 | 6. Set a constant value of `-1` (can always be `-1`). 15 | 7. Reference the `pricessCell`, which contains prices obtainable from the IOTA NFT. 16 | 8. Conclude the cell. 17 | 18 | Amount to send: `task.liquidationAmount`, minus `0.33` for blockchain fees. The EVAA smart contract will calculate the amount of collateral tokens to send back based on this number. 19 | 20 | Destination address: `evaaMaster`. 21 | 22 | #### Other Loan Assets 23 | 24 | For loan assets other than TON, the following steps are performed: 25 | 26 | 1. Set the jetton transfer opcode to `0xf8a7ea5`. 27 | 2. Store the query ID, which can be `0`. 28 | 3. Store the amount of jettons to send (The EVAA smart contract will calculate the amount of collateral tokens to send back based on this number). 29 | 4. Store the address of the jetton receiver smart contract, which is the EVAA master. 30 | 5. Store the address of the contract to receive leftover TONs. 31 | 6. Set a bit to `0`. 32 | 7. Store the TON amount to forward in a token notification (Note: Clarification needed). 33 | 8. Set another bit to `1`. 34 | 9. Reference a sub-cell, which replicates the TON liquidation logic. 35 | 10. Conclude the main cell. 36 | 37 | Amount to send: `toNano('1')` for transaction chain fees (Note: Clarification needed). 38 | 39 | Destination address: The Jetton wallet associated with the loan asset. 40 | This code provides a clear explanation of the liquidation process, with detailed comments to understand each step. -------------------------------------------------------------------------------- /src/lib/balances.ts: -------------------------------------------------------------------------------- 1 | import { TON_MAINNET } from "@evaafi/sdk"; 2 | import { type Address, Dictionary, type TonClient } from "@ton/ton"; 3 | import { checkAddressState } from "../util/blockchain"; 4 | import { notDefined } from "../util/logic"; 5 | import { retry } from "../util/retry"; 6 | 7 | export type WalletBalances = Dictionary; 8 | 9 | export function initEmptyBalances(): WalletBalances { 10 | return Dictionary.empty( 11 | Dictionary.Keys.BigUint(256), 12 | Dictionary.Values.BigUint(64), 13 | ); 14 | } 15 | 16 | export async function getBalances( 17 | tonClient: TonClient, 18 | walletAddress: Address, 19 | assetIDs: bigint[], 20 | jettonWallets: Map, 21 | ): Promise { 22 | const balancesResult = await retry( 23 | async (): Promise => { 24 | const balance: WalletBalances = initEmptyBalances(); 25 | const tonBalance = await tonClient.getBalance(walletAddress); 26 | 27 | balance.set(TON_MAINNET.assetId, tonBalance); 28 | const res = await Promise.all( 29 | assetIDs.map(async (assetId): Promise => { 30 | const jwAddress = jettonWallets.get(assetId); 31 | if (notDefined(jwAddress)) { 32 | return 0n; // Asset is not supported, returning 0n balance 33 | } 34 | 35 | try { 36 | const accountState = await checkAddressState(tonClient, jwAddress); 37 | if (accountState !== "active") { 38 | console.log( 39 | `${assetId}: JETTON WALLET ${jwAddress} is not active: ${accountState}`, 40 | ); 41 | return 0n; 42 | } 43 | } catch (e) { 44 | console.error( 45 | `Error checking address state for asset ${assetId} at ${jwAddress}: ${e}`, 46 | ); 47 | return 0n; 48 | } 49 | 50 | try { 51 | const _res = await tonClient.runMethod( 52 | jwAddress, 53 | "get_wallet_data", 54 | ); 55 | return _res.stack.readBigNumber(); 56 | } catch (e) { 57 | console.error( 58 | `Error getting wallet data for asset ${assetId} at ${jwAddress}: ${e}`, 59 | ); 60 | return 0n; 61 | } 62 | }), 63 | ); 64 | 65 | res.forEach((amount, index) => { 66 | balance.set(assetIDs[index], amount); 67 | }); 68 | 69 | return balance; 70 | }, 71 | { attempts: 10, attemptInterval: 1000 }, 72 | ); 73 | 74 | if (!balancesResult.ok) { 75 | throw new Error("Failed to get balances"); 76 | } 77 | 78 | return balancesResult.value; 79 | } 80 | -------------------------------------------------------------------------------- /src/services/liquidator/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { OpenedContract } from "@ton/ton"; 2 | import type { WalletBalances } from "../../lib/balances"; 3 | import { 4 | type EvaaMasterClassic, 5 | type EvaaMasterPyth, 6 | type ExtendedAssetsConfig, 7 | type ExtendedAssetsData, 8 | type MasterConstants, 9 | type PoolAssetConfig, 10 | presentValue, 11 | TON_MAINNET, 12 | } from "@evaafi/sdk"; 13 | import { POOL_CONFIG } from "../../config"; 14 | import { formatBalances, getFriendlyAmount } from "../../util/format"; 15 | 16 | type TaskMinimal = { 17 | id: number; 18 | loan_asset: bigint; 19 | liquidation_amount: bigint; 20 | }; 21 | 22 | export function formatNotEnoughBalanceMessage( 23 | task: Task, 24 | balance: WalletBalances, 25 | extAssetsConfig: ExtendedAssetsConfig, 26 | poolAssetsConfig: PoolAssetConfig[], 27 | ) { 28 | const assets = POOL_CONFIG.poolAssetsConfig; 29 | const loan_asset = assets.find((asset) => asset.assetId === task.loan_asset); 30 | if (!loan_asset) throw `${task.loan_asset} is not supported`; 31 | 32 | const formattedBalances = formatBalances( 33 | balance, 34 | extAssetsConfig, 35 | poolAssetsConfig, 36 | ); 37 | const loan_config = extAssetsConfig.get(task.loan_asset); 38 | if (!loan_config) throw `No config for asset ${task.loan_asset}`; 39 | 40 | return ` 41 | ❌ Not enough balance for liquidation task ${task.id} 42 | 43 | Loan asset: ${loan_asset.name} 44 | Liquidation amount: ${getFriendlyAmount( 45 | task.liquidation_amount, 46 | loan_config.decimals, 47 | loan_asset.name, 48 | )} 49 | My balance: 50 | ${formattedBalances}`; 51 | } 52 | 53 | export type Log = { 54 | id: number; 55 | walletAddress: string; 56 | }; 57 | 58 | /** 59 | * Calculates asset dust amount 60 | * @param assetId asset id 61 | * @param assetsConfigDict assets config collection 62 | * @param assetsDataDict assets data collection 63 | * @param masterConstants master constants 64 | */ 65 | export function calculateDust( 66 | assetId: bigint, 67 | assetsConfigDict: ExtendedAssetsConfig, 68 | assetsDataDict: ExtendedAssetsData, 69 | masterConstants: MasterConstants, 70 | ) { 71 | const data = assetsDataDict.get(assetId)!; 72 | const config = assetsConfigDict.get(assetId)!; 73 | 74 | const dustPresent = presentValue( 75 | data.sRate, 76 | data.bRate, 77 | config.dust, 78 | masterConstants, 79 | ); 80 | return dustPresent.amount; 81 | } 82 | 83 | export function getJettonIDs( 84 | evaa: OpenedContract, 85 | ): bigint[] { 86 | return evaa.poolConfig.poolAssetsConfig 87 | .filter((asset) => asset.assetId !== TON_MAINNET.assetId) 88 | .map((asset) => asset.assetId); 89 | } 90 | -------------------------------------------------------------------------------- /scripts/deploy_hw_v2.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { mnemonicToWalletKey } from "@ton/crypto"; 4 | import { 5 | Address, 6 | beginCell, 7 | Cell, 8 | contractAddress, 9 | Dictionary, 10 | internal, 11 | type StateInit, 12 | storeMessageRelaxed, 13 | TonClient, 14 | toNano, 15 | WalletContractV4, 16 | } from "@ton/ton"; 17 | import { 18 | DEFAULT_SUBWALLET_ID, 19 | HIGHLOAD_CODE_V2, 20 | HighloadWalletV2, 21 | } from "../src/lib/highload_contract_v2"; 22 | 23 | const TON_CLIENT = new TonClient({ 24 | endpoint: "https://toncenter.com/api/v2/jsonRPC", 25 | apiKey: process.env.TONCENTER_API_KEY, 26 | }); 27 | 28 | const WORKCHAIN = 0; 29 | 30 | function createTransferMessage( 31 | to: Address, 32 | value: bigint, 33 | body: Cell = Cell.EMPTY, 34 | ): Cell { 35 | return beginCell() 36 | .store( 37 | storeMessageRelaxed( 38 | internal({ 39 | value, 40 | to, 41 | body, 42 | }), 43 | ), 44 | ) 45 | .endCell(); 46 | } 47 | 48 | async function deployHWV2() { 49 | if (!process.env.WALLET_PRIVATE_KEY) { 50 | throw new Error("WALLET_PRIVATE_KEY environment variable is required"); 51 | } 52 | if (!process.env.TONCENTER_API_KEY) { 53 | throw new Error("TONCENTER_API_KEY environment variable is required"); 54 | } 55 | 56 | const WALLET_KEY_PAIR = await mnemonicToWalletKey( 57 | process.env.WALLET_PRIVATE_KEY.split(" "), 58 | ); 59 | 60 | const WALLET_CONTRACT = TON_CLIENT.open( 61 | WalletContractV4.create({ 62 | workchain: WORKCHAIN, 63 | publicKey: WALLET_KEY_PAIR.publicKey, 64 | }), 65 | ); 66 | 67 | const HWV2_STATE: StateInit = { 68 | code: HIGHLOAD_CODE_V2, 69 | data: beginCell() 70 | .storeUint(DEFAULT_SUBWALLET_ID, 32) 71 | .storeUint(0, 64) 72 | .storeBuffer(WALLET_KEY_PAIR.publicKey) 73 | .storeBit(0) 74 | .endCell(), 75 | }; 76 | 77 | const HWV2_ADDRESS = contractAddress(WORKCHAIN, HWV2_STATE); 78 | 79 | await WALLET_CONTRACT.sender(WALLET_KEY_PAIR.secretKey).send({ 80 | to: HWV2_ADDRESS, 81 | value: toNano("0.5"), 82 | }); 83 | 84 | const hwv2 = new HighloadWalletV2( 85 | TON_CLIENT, 86 | HWV2_ADDRESS, 87 | WALLET_KEY_PAIR.publicKey, 88 | DEFAULT_SUBWALLET_ID, 89 | ); 90 | 91 | const highloadMessages = Dictionary.empty(); 92 | 93 | highloadMessages.set( 94 | 0, 95 | createTransferMessage( 96 | Address.parse("UQCsg2ebW92NaprS7djOHqDn0yytfKPd1ZnyN-t6TartT5VP"), 97 | toNano("0.01"), 98 | ), 99 | ); 100 | 101 | const messagesId = await hwv2.sendMessages( 102 | highloadMessages, 103 | WALLET_KEY_PAIR.secretKey, 104 | ); 105 | console.log(messagesId); 106 | } 107 | 108 | deployHWV2(); 109 | -------------------------------------------------------------------------------- /src/variative_config.ts: -------------------------------------------------------------------------------- 1 | import { MAINNET_STABLE_POOL_CONFIG } from "@evaafi/sdk"; 2 | import { Address } from "@ton/core"; 3 | import { TonClient } from "@ton/ton"; 4 | import { configDotenv } from "dotenv"; 5 | import { ASSET_ID } from "./steady_config"; 6 | 7 | export const HIGHLOAD_ADDRESS = Address.parse( 8 | "EQDo27P-CAam_G2xmQd4CxnFYjY2FKPmmKEc8wTCh4c33Mhi", 9 | ); 10 | // jetton wallets of specified highloadAddress 11 | export const JETTON_WALLETS = new Map([ 12 | // Main-jwallets 13 | [ 14 | ASSET_ID.jUSDT, 15 | Address.parse("EQA6X8-lL4GOV8unCtzgx0HiQJovggHEniGIPGjB7RBIRR3M"), 16 | ], 17 | [ 18 | ASSET_ID.jUSDC, 19 | Address.parse("EQA6mXtvihA1GG57dFCbzI1NsBlMu4iN-iSxbzN_seSlbaVM"), 20 | ], 21 | [ 22 | ASSET_ID.stTON, 23 | Address.parse("EQAw_YE5y9U3LFTPtm7peBWKz1PUg77DYlrJ3_NDyQAfab5s"), 24 | ], 25 | [ 26 | ASSET_ID.tsTON, 27 | Address.parse("EQDdpsEJ2nyPP2W2yzdcM2A4FeU-IQGyxM0omo0U2Yv2DvTB"), 28 | ], 29 | [ 30 | ASSET_ID.USDT, 31 | Address.parse("EQC183ELZmTbdsfRtPmp-SzyRXf0UOV3pdNNwtX2P98z2pQM"), 32 | ], 33 | [ 34 | ASSET_ID.USDe, 35 | Address.parse("EQDjWDWecXYI-eRy9tohBTJolnz65TKTDrkYIs__CM_w6psi"), 36 | ], 37 | [ 38 | ASSET_ID.tsUSDe, 39 | Address.parse("EQBLULqLWax9U7Er6j-mEGQbUzBQOP2FMEaDofNU8yVTr-eZ"), 40 | ], 41 | // LP-jwallets 42 | [ 43 | ASSET_ID.TONUSDT_DEDUST, 44 | Address.parse("EQD1msA18OaAzYPAVrFKfbxHCl1kxQkzsY7zolgtwAqgUuMP"), 45 | ], 46 | [ 47 | ASSET_ID.TONUSDT_STONFI, 48 | Address.parse("EQAoXoKRiIx8SDXBXKUHJXfGYXi98a7Pr0UzMOSLz4gely2Z"), 49 | ], 50 | [ 51 | ASSET_ID.TON_STORM, 52 | Address.parse("EQChlnD11dNt5QpiykF_WMniq8WfsQ8I4n2aFhfknU5eOfbP"), 53 | ], 54 | [ 55 | ASSET_ID.USDT_STORM, 56 | Address.parse("EQAQnMn2bCY1BcTVqawdblFMh3yw5kkJqiHi52ey-gbL6ofM"), 57 | ], 58 | // // Alt-jwallets 59 | [ 60 | ASSET_ID.NOT, 61 | Address.parse("EQA_0UoglJR8JtKq9CGZdBBY9TY3vyW8Z7obKY95Q9_1Cih9"), 62 | ], 63 | [ 64 | ASSET_ID.DOGS, 65 | Address.parse("EQAbOe8N-RjCL6Y9vI6nlY9WPNeQwiJN7fzf80mGdogiPChn"), 66 | ], 67 | [ 68 | ASSET_ID.CATI, 69 | Address.parse("EQDg_8tzSeJ64lejC3TPfNdlhR1HbLC7uABTRzdJMq55HEFy"), 70 | ], 71 | // Stable-jwallets 72 | [ 73 | ASSET_ID.PT_tsUSDe_18Dec2025, 74 | Address.parse("EQBxIkea3baUXLtPVuVaSMsWIkC5S0It3OcM8MeYpPnoEWeM"), 75 | ], 76 | ]); 77 | 78 | export const IS_TESTNET = false; 79 | 80 | const DB_PATH_MAINNET = "./database-mainnet.db"; 81 | const DB_PATH_TESTNET = "./database-testnet.db"; 82 | 83 | export const DB_PATH = IS_TESTNET ? DB_PATH_TESTNET : DB_PATH_MAINNET; 84 | 85 | /* Actual configuration */ 86 | export const RPC_ENDPOINT = "https://toncenter.com/api/v2/jsonRPC"; 87 | export const TON_API_ENDPOINT = "https://tonapi.io/"; 88 | 89 | export async function makeTonClient() { 90 | configDotenv(); 91 | const tonClient = new TonClient({ 92 | endpoint: RPC_ENDPOINT, 93 | apiKey: process.env.TONCENTER_API_KEY, 94 | }); 95 | return tonClient; 96 | } 97 | 98 | export const USER_UPDATE_DELAY = 60_000; // 60 seconds 99 | export const TX_PROCESS_DELAY = 40; // ms 100 | export const RPC_CALL_DELAY = 20; // ms 101 | 102 | // export const POOL_CONFIG = MAINNET_LP_POOL_CONFIG; // for main pool v5 103 | export const POOL_CONFIG = MAINNET_STABLE_POOL_CONFIG; 104 | -------------------------------------------------------------------------------- /src/steady_config.ts: -------------------------------------------------------------------------------- 1 | import type { Address } from "@ton/core"; 2 | import { 3 | ASSET_ID as _ASSET_ID, 4 | EvaaMasterClassic, 5 | EvaaMasterPyth, 6 | MAINNET_ALTS_POOL_CONFIG, 7 | MAINNET_LP_POOL_CONFIG, 8 | MAINNET_POOL_CONFIG, 9 | MAINNET_STABLE_POOL_CONFIG, 10 | } from "@evaafi/sdk"; 11 | import { sha256Hash } from "./util/crypto"; 12 | 13 | export const ASSET_ID = { 14 | ..._ASSET_ID, 15 | time: sha256Hash("time"), 16 | }; 17 | 18 | /** 19 | * Priority order of collaterals to select for calculating liquidation parameters 20 | */ 21 | export const COLLATERAL_SELECT_PRIORITY = new Map([ 22 | [ASSET_ID.USDT, 1], 23 | [ASSET_ID.USDe, 2], 24 | [ASSET_ID.tsUSDe, 3], 25 | [ASSET_ID.TON, 4], 26 | [ASSET_ID.stTON, 5], 27 | [ASSET_ID.jUSDT, 6], 28 | [ASSET_ID.tsTON, 7], 29 | [ASSET_ID.jUSDC, 8], 30 | [ASSET_ID.TONUSDT_DEDUST, 9], 31 | [ASSET_ID.TONUSDT_STONFI, 10], 32 | [ASSET_ID.TON_STORM, 11], 33 | [ASSET_ID.USDT_STORM, 12], 34 | [ASSET_ID.NOT, 13], 35 | [ASSET_ID.DOGS, 14], 36 | [ASSET_ID.CATI, 15], 37 | [ASSET_ID.PT_tsUSDe_01Sep2025, 16], 38 | [ASSET_ID.PT_tsUSDe_18Dec2025, 17], 39 | ]); 40 | export const NO_PRIORITY_SELECTED = 999; 41 | 42 | /** 43 | * lower bound of asset worth to swap 44 | */ 45 | export const PRICE_ACCURACY: bigint = 1_000_000_000n; // 10^9 46 | export const MIN_WORTH_SWAP_LIMIT: bigint = 100n * PRICE_ACCURACY; // usd 47 | 48 | /** 49 | * should cancel liquidation if amount is less than that number 50 | */ 51 | export const LIQUIDATION_BALANCE_LIMITS = new Map([ 52 | [ASSET_ID.TON, 5_000_000_000n], 53 | [ASSET_ID.jUSDT, 1_000_000n], 54 | [ASSET_ID.jUSDC, 1_000_000n], 55 | [ASSET_ID.stTON, 1_000_000_000n], 56 | [ASSET_ID.tsTON, 1_000_000_000n], 57 | [ASSET_ID.USDT, 1_000_000n], 58 | [ASSET_ID.USDe, 1_000_000n], 59 | [ASSET_ID.tsUSDe, 1_000_000n], 60 | [ASSET_ID.TONUSDT_DEDUST, 1_000_000_000n], 61 | [ASSET_ID.TONUSDT_STONFI, 1_000_000_000n], 62 | [ASSET_ID.TON_STORM, 1_000_000_000n], 63 | [ASSET_ID.USDT_STORM, 1_000_000_000n], 64 | [ASSET_ID.NOT, 1_000_000_000n], 65 | [ASSET_ID.DOGS, 1_000_000_000n], 66 | [ASSET_ID.CATI, 1_000_000_000n], 67 | ]); 68 | 69 | /** 70 | * EVAA contract versions 71 | */ 72 | 73 | export const EVAA_CONTRACT_VERSIONS_MAP = new Map< 74 | Address, 75 | { 76 | name: string; 77 | master: typeof EvaaMasterPyth | typeof EvaaMasterClassic; 78 | v4_upgrade_lt: number; 79 | v9_upgrade_lt: number; 80 | } 81 | >([ 82 | [ 83 | MAINNET_POOL_CONFIG.masterAddress, 84 | { 85 | name: "Main pool", 86 | master: EvaaMasterPyth, 87 | v4_upgrade_lt: 49828980000001, 88 | v9_upgrade_lt: 61426459000001, 89 | }, 90 | ], 91 | [ 92 | MAINNET_LP_POOL_CONFIG.masterAddress, 93 | { 94 | name: "LP pool", 95 | master: EvaaMasterClassic, 96 | v4_upgrade_lt: 49712577000001, 97 | v9_upgrade_lt: 61359759000001, 98 | }, 99 | ], 100 | [ 101 | MAINNET_ALTS_POOL_CONFIG.masterAddress, 102 | { 103 | name: "Alts pool", 104 | master: EvaaMasterClassic, 105 | v4_upgrade_lt: 0, 106 | v9_upgrade_lt: 61187409000001, 107 | }, 108 | ], 109 | [ 110 | MAINNET_STABLE_POOL_CONFIG.masterAddress, 111 | { 112 | name: "Stable pool", 113 | master: EvaaMasterClassic, 114 | v4_upgrade_lt: 0, 115 | v9_upgrade_lt: 61359759000001, 116 | }, 117 | ], 118 | ]); 119 | /** 120 | * assets banned from being swapped from 121 | */ 122 | export const BANNED_ASSETS_FROM = [ASSET_ID.jUSDC]; 123 | 124 | /** 125 | * assets banned from being swapped to 126 | */ 127 | export const BANNED_ASSETS_TO = [ASSET_ID.jUSDC]; 128 | 129 | /** 130 | * should we skip value check when assigning a swap task? 131 | */ 132 | export const SKIP_SWAP_VALUE_CHECK = false; 133 | 134 | /** 135 | * liquidator prices update interval in seconds 136 | */ 137 | export const LIQUIDATOR_PRICES_UPDATE_INTERVAL = 15; 138 | 139 | /** 140 | * validator price actuality time since issued,validator receives price data from sdk, 141 | * if this value is exceeded, there might be something wrong with sdk 142 | */ 143 | export const VALIDATOR_MAX_PRICES_ISSUED = 136; 144 | 145 | /** 146 | * liquidator price actuality time since issued 147 | */ 148 | export const LIQUIDATOR_MAX_PRICES_ISSUED = 150; 149 | -------------------------------------------------------------------------------- /src/lib/highload_contract_v2.ts: -------------------------------------------------------------------------------- 1 | import {Address, beginCell, Cell, ContractProvider, Dictionary, TonClient} from "@ton/ton"; 2 | // import {makeQueryId, retry} from "../util"; 3 | import crypto from "crypto"; 4 | import {sign} from "@ton/crypto"; 5 | import {retry} from "../util/retry"; 6 | import {Contract, internal, storeMessageRelaxed} from "@ton/core"; 7 | 8 | export const HIGHLOAD_CODE_V2: Cell = Cell.fromBase64('te6ccgEBCQEA5QABFP8A9KQT9LzyyAsBAgEgAgMCAUgEBQHq8oMI1xgg0x/TP/gjqh9TILnyY+1E0NMf0z/T//QE0VNggED0Dm+hMfJgUXO68qIH+QFUEIf5EPKjAvQE0fgAf44WIYAQ9HhvpSCYAtMH1DAB+wCRMuIBs+ZbgyWhyEA0gED0Q4rmMQHIyx8Tyz/L//QAye1UCAAE0DACASAGBwAXvZznaiaGmvmOuF/8AEG+X5dqJoaY+Y6Z/p/5j6AmipEEAgegc30JjJLb/JXdHxQANCCAQPSWb6VsEiCUMFMDud4gkzM2AZJsIeKz'); 9 | export const DEFAULT_SUBWALLET_ID = 698983191n; 10 | 11 | const ATTEMPTS_NUMBER: number = 3; 12 | const ATTEMPTS_INTERVAL: number = 1000; 13 | 14 | /** 15 | * makes a new query ID for a highload wallet 16 | * @param timeout transaction timeout in seconds 17 | */ 18 | export function makeQueryID(timeout: number = 60): bigint { 19 | const queryID = crypto.randomBytes(4).readUint32BE(); 20 | const now = Math.floor(Date.now() / 1000); 21 | const finalQueryID: bigint = (BigInt(now + timeout) << 32n) + BigInt(queryID); 22 | return finalQueryID; 23 | } 24 | 25 | export function makeLiquidationCell( 26 | amount: bigint, 27 | destination: Address | string, 28 | body: Cell 29 | ): Cell { 30 | return beginCell() 31 | .store(storeMessageRelaxed(internal({ 32 | value: amount, 33 | to: destination, 34 | body 35 | }))).endCell(); 36 | } 37 | 38 | export class HighloadWalletV2 implements Contract { 39 | _tonClient: TonClient; 40 | _address: Address; 41 | _subwalletId: bigint; 42 | _contract: ContractProvider; 43 | 44 | constructor(tonClient: TonClient, highloadAddress: Address, publicKey: Buffer, subwalletId: bigint = DEFAULT_SUBWALLET_ID) { 45 | this._tonClient = tonClient; 46 | this._address = highloadAddress; 47 | this._subwalletId = subwalletId; 48 | this._contract = tonClient.provider(highloadAddress, { 49 | code: HIGHLOAD_CODE_V2, 50 | data: beginCell() 51 | .storeUint(subwalletId, 32) 52 | .storeUint(0, 64) 53 | .storeBuffer(publicKey) 54 | .storeBit(0) 55 | .endCell() 56 | }); 57 | } 58 | 59 | get address() { 60 | return this._address; 61 | } 62 | 63 | async sendMessages(messagesDictionary: Dictionary, secretKey: Buffer, timeout: number = 60): Promise { 64 | const queryID = makeQueryID(timeout); 65 | const toSign = beginCell() 66 | .storeUint(this._subwalletId, 32) 67 | .storeUint(queryID, 64) 68 | .storeDict(messagesDictionary, Dictionary.Keys.Int(16), { 69 | serialize: (src, builder) => { 70 | builder.storeUint( 71 | 1, // send_mode: 1 + 2 : PAY_GAS_SEPARATELY (ok) | IGNORE_ERRORS (?) 72 | 8 73 | ); 74 | builder.storeRef(src); 75 | }, 76 | parse: (src) => { 77 | return beginCell() 78 | .storeUint(src.loadUint(1), 8) 79 | .storeRef(src.loadRef()) 80 | .endCell(); 81 | } 82 | }); 83 | 84 | const signature = sign(toSign.endCell().hash(), secretKey); 85 | 86 | const body = beginCell() 87 | .storeBuffer(signature) // store signature 88 | .storeBuilder(toSign) // store our message 89 | .endCell(); 90 | 91 | // send messages 92 | const res = await retry( 93 | async () => { 94 | await this._contract.external(body); 95 | }, { 96 | attempts: ATTEMPTS_NUMBER, attemptInterval: ATTEMPTS_INTERVAL, verbose: true, 97 | on_fail: () => console.warn('SEND MESSAGES FAILED, RETRYING') 98 | }); 99 | 100 | if (!res.ok) { 101 | throw new Error('Send messages failed'); 102 | } 103 | 104 | return queryID; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/util/prices.ts: -------------------------------------------------------------------------------- 1 | import {Cell, Dictionary} from "@ton/ton"; 2 | 3 | export function unpackPrices(pricesCell: Cell): Dictionary | undefined { 4 | if (!pricesCell) return undefined; 5 | const slice = pricesCell.beginParse(); 6 | let assetCell: Cell | null = slice.loadRef(); 7 | const res = Dictionary.empty(); 8 | while (assetCell != Cell.EMPTY && assetCell !== null) { 9 | const slice = assetCell.beginParse(); 10 | const assetId = slice.loadUintBig(256); 11 | const medianPrice = slice.loadCoins(); 12 | res.set(assetId, medianPrice); 13 | assetCell = slice.loadMaybeRef(); 14 | 15 | } 16 | return res; 17 | } 18 | 19 | export type OracleInfoItem = { 20 | oracle_id: number, 21 | signature: Buffer, 22 | timestamp: number, 23 | } 24 | 25 | export type PriceDataPartialInfo = { 26 | medianPricesDict: Dictionary, 27 | oraclesInfos: OracleInfoItem[] 28 | } 29 | 30 | export function parsePriceDataPartial(priceData: Cell): PriceDataPartialInfo { 31 | const priceDataSlice = priceData.beginParse(); 32 | const medianPricesDict = Dictionary.empty(); 33 | let ref = priceDataSlice.loadRef(); 34 | 35 | // parse median prices dict 36 | while (true) { 37 | const s = ref.beginParse(); 38 | medianPricesDict.set(s.loadUintBig(256), s.loadCoins()); 39 | if (s.remainingRefs > 0) { 40 | ref = s.loadRef(); 41 | } else break; 42 | } 43 | 44 | let oracleData = priceDataSlice.loadRef(); 45 | const oraclesInfos: OracleInfoItem[] = []; 46 | let oracleSlice = oracleData.beginParse(); 47 | 48 | while (true) { 49 | const oracle_id = oracleSlice.loadUint(32); 50 | const signature = oracleSlice.loadBuffer(64); 51 | const merkle_proof = oracleSlice.loadRef(); 52 | const pruned_data = merkle_proof.refs[0]; 53 | const pruned_slice = pruned_data.beginParse(); 54 | const timestamp = pruned_slice.loadUint(32); 55 | 56 | oraclesInfos.push({ 57 | oracle_id, 58 | timestamp, 59 | signature, 60 | 61 | }); 62 | 63 | if (oracleSlice.remainingRefs === 0) break; 64 | oracleSlice = oracleSlice.loadRef().beginParse(); 65 | } 66 | return { 67 | medianPricesDict, 68 | oraclesInfos 69 | } 70 | } 71 | 72 | export const CheckOraclesEnum = { 73 | OK: 0, 74 | OUT_OF_DATE: 1, 75 | NOT_ENOUGH_ORACLES: 2, 76 | HAS_DUPLICATE_IDS: 3, 77 | HAS_DUPLICATE_SIGNATURES: 4, 78 | INVALID_PRICE_DATA: 5, 79 | } 80 | 81 | export const CheckOraclesMessage = [ 82 | 'OK', 83 | 'Price data is out of date', 84 | 'Number of oracles is not enough', 85 | 'Price data has duplicate oracle ids', 86 | 'Invalid price data, cannot be parsed', 87 | ]; 88 | 89 | /** 90 | * @brief do fast checks if price data cell is ok 91 | * @param priceData price data cell 92 | * @param maxSecondsPassed max seconds since data issued 93 | */ 94 | export function checkPriceData(priceData: Cell, maxSecondsPassed: number): number { 95 | const now = Date.now() / 1000; 96 | try { 97 | const res = parsePriceDataPartial(priceData); 98 | const oracles = res.oraclesInfos; 99 | if (oracles.length < 3) return CheckOraclesEnum.NOT_ENOUGH_ORACLES; 100 | 101 | const oracleIds = oracles.map(item=>item.oracle_id); 102 | if (oracles.length !== (new Set(oracleIds).size)) { 103 | return CheckOraclesEnum.HAS_DUPLICATE_IDS; 104 | } 105 | 106 | for (const oracle of oracles) { 107 | if (now - oracle.timestamp > maxSecondsPassed) { 108 | return CheckOraclesEnum.OUT_OF_DATE; 109 | } 110 | } 111 | } catch (e) { 112 | return CheckOraclesEnum.INVALID_PRICE_DATA; 113 | } 114 | 115 | return CheckOraclesEnum.OK; 116 | } 117 | 118 | export function isPriceDataActual(priceData: Cell, maxSecondsPassed: number): boolean { 119 | const now = Date.now() / 1000; 120 | try { 121 | const res = parsePriceDataPartial(priceData); 122 | for (const oracle of res.oraclesInfos) { 123 | if (now - oracle.timestamp > maxSecondsPassed) return false; 124 | } 125 | } catch (e) { 126 | return false; 127 | } 128 | 129 | return true; 130 | } 131 | -------------------------------------------------------------------------------- /src/lib/messenger.ts: -------------------------------------------------------------------------------- 1 | // small logger 2 | export function formatDateTime(): string { 3 | const now = new Date(); 4 | const year = now.getFullYear(); 5 | const month = String(now.getMonth() + 1).padStart(2, '0'); 6 | const day = String(now.getDate()).padStart(2, '0'); 7 | const hours = String(now.getHours()).padStart(2, '0'); 8 | const minutes = String(now.getMinutes()).padStart(2, '0'); 9 | const seconds = String(now.getSeconds()).padStart(2, '0'); 10 | 11 | return `${hours}:${minutes}:${seconds} ${day}-${month}-${year}`; 12 | } 13 | 14 | export function logMessage(message: string, printDateTime: boolean = true) { 15 | if (printDateTime) { 16 | console.log(`[${formatDateTime()}] ${message}`); 17 | } else { 18 | console.log(message); 19 | } 20 | } 21 | 22 | // telegram sender 23 | export type SendMessageOptions = { 24 | throwOnFailure?: boolean, 25 | debug?: boolean, 26 | printDateTime?: boolean, 27 | }; 28 | 29 | const DEFAULT_OPTIONS = {throwOnFailure: false, debug: false, printDateTime: true}; 30 | 31 | export async function sendTelegramMessage( 32 | message: string, 33 | telegramBotToken: string, 34 | telegramChatId: string, 35 | telegramTopicId?: string, 36 | options?: SendMessageOptions 37 | ) { 38 | // console.log({message, telegramBotToken, telegramChatId, telegramTopicId, options}); 39 | const url = `https://api.telegram.org/bot${telegramBotToken}/sendMessage`; 40 | try { 41 | const bodyBase = { 42 | chat_id: telegramChatId, 43 | text: message, 44 | parse_mode: 'html' 45 | }; 46 | 47 | const body = telegramTopicId ? 48 | {...bodyBase, ...{message_thread_id: telegramTopicId}} : bodyBase; 49 | 50 | const response = await fetch(url, { 51 | method: 'POST', 52 | headers: {'Content-Type': 'application/json'}, 53 | body: JSON.stringify(body), 54 | }); 55 | 56 | if (!response.ok) { 57 | throw new Error(response.statusText); 58 | } 59 | 60 | const data = await response.json(); 61 | if (options?.debug) { 62 | console.log('Message sent successfully', data); 63 | } 64 | } catch (error) { 65 | if (options?.throwOnFailure) { 66 | throw error; 67 | } else { 68 | console.error('Error sending message:', error); 69 | logMessage(message, options?.printDateTime); 70 | } 71 | } 72 | } 73 | 74 | export abstract class Messenger { 75 | abstract sendMessage(message: string): Promise; 76 | } 77 | 78 | export class ChannelMessenger extends Messenger { 79 | private readonly botToken: string; 80 | private readonly chatId: string; 81 | private readonly options: SendMessageOptions; 82 | 83 | constructor(telegramBotToken: string, channelId: string, options?: SendMessageOptions) { 84 | super(); 85 | this.botToken = telegramBotToken; 86 | this.chatId = channelId; 87 | this.options = {...DEFAULT_OPTIONS, ...(options ?? {})}; 88 | } 89 | 90 | async sendMessage(message: string): Promise { 91 | try { 92 | await sendTelegramMessage(message, this.botToken, this.chatId, undefined, this.options) 93 | } catch (e) { 94 | console.error("FAILED TO SEND CHAT MESSAGE: ", e); 95 | if (this?.options?.throwOnFailure) throw e; 96 | } 97 | } 98 | 99 | log(message: string): void { 100 | console.log(message); 101 | } 102 | } 103 | 104 | export class TopicMessenger extends Messenger { 105 | private readonly botToken: string; 106 | private readonly chatId: string; 107 | private readonly topicId: string; 108 | private readonly options: SendMessageOptions; 109 | 110 | constructor(botToken: string, groupId: string, topicId: string, options?: SendMessageOptions) { 111 | super(); 112 | this.botToken = botToken; 113 | this.chatId = groupId; 114 | this.topicId = topicId; 115 | this.options = {...DEFAULT_OPTIONS, ...(options ?? {})}; 116 | } 117 | 118 | async sendMessage(message: string): Promise { 119 | try { 120 | await sendTelegramMessage(message, this.botToken, this.chatId, this.topicId, this.options); 121 | } catch (e) { 122 | console.error("FAILED TO SEND CHAT MESSAGE: ", e); 123 | if (this.options?.throwOnFailure) throw e; 124 | } 125 | } 126 | } -------------------------------------------------------------------------------- /src/services/validator/helpers.ts: -------------------------------------------------------------------------------- 1 | import {Cell, Dictionary} from "@ton/ton"; 2 | import {COLLATERAL_SELECT_PRIORITY, MIN_WORTH_SWAP_LIMIT, NO_PRIORITY_SELECTED} from "../../config"; 3 | import {bigAbs} from "../../util/math"; 4 | import { 5 | ExtendedAssetsConfig, 6 | ExtendedAssetsData, 7 | MasterConstants, 8 | PoolConfig, 9 | presentValue, 10 | SelectedAssets 11 | } from "@evaafi/sdk"; 12 | import {MyDatabase} from "../../db/database"; 13 | import {User} from "../../db/types"; 14 | import {retry} from "../../util/retry"; 15 | 16 | type MinConfig = { 17 | decimals: bigint, 18 | liquidationThreshold: bigint, 19 | liquidationReserveFactor: bigint, 20 | liquidationBonus: bigint 21 | }; 22 | type MinData = { sRate: bigint, bRate: bigint }; 23 | 24 | 25 | export function selectLiquidationAssets( 26 | principalsDict: Dictionary, 27 | pricesDict: Dictionary, 28 | assetConfigDict: Dictionary, 29 | assetsDataDict: Dictionary, 30 | poolConfig: PoolConfig 31 | ): SelectedAssets { 32 | let collateralValue = 0n; 33 | let collateralId = 0n; 34 | let loanValue = 0n; 35 | let loanId = 0n; 36 | 37 | let priority_collateral_id = 0n; 38 | let priority_collateral_value = 0n; 39 | let selected_priority = NO_PRIORITY_SELECTED; 40 | const FACTOR_SCALE = poolConfig.masterConstants.FACTOR_SCALE 41 | 42 | for (const assetId of principalsDict.keys()) { 43 | const principal: bigint = principalsDict.get(assetId)!; 44 | const assetPrice = pricesDict.get(assetId); 45 | if (!assetPrice) { 46 | console.warn(`No price for assetId ${assetId}`); 47 | continue; 48 | } 49 | const assetData = assetsDataDict.get(assetId)!; 50 | if (!assetData) { 51 | console.warn(`Dynamics for assetId ${assetId} is not defined, skipping`); 52 | continue; 53 | } 54 | const assetConfig = assetConfigDict.get(assetId); 55 | if (!assetConfig) { 56 | console.warn(`Config for assetId ${assetId} is not defined, skipping`); 57 | continue; 58 | } 59 | const assetScale = 10n ** assetConfig.decimals; 60 | let balance = 0n; 61 | if (principal > 0n) { 62 | balance = (BigInt(principal) * BigInt(assetData.sRate) / BigInt(FACTOR_SCALE)).valueOf(); 63 | } else { 64 | balance = (BigInt(principal) * BigInt(assetData.bRate) / BigInt(FACTOR_SCALE)).valueOf(); 65 | } 66 | 67 | const assetValue = bigAbs(balance) * assetPrice / assetScale; 68 | if (balance > 0n) { 69 | // priority based collateral selection logic 70 | if (assetValue > MIN_WORTH_SWAP_LIMIT) { 71 | const priority = COLLATERAL_SELECT_PRIORITY.get(assetId); 72 | if ((priority !== undefined) && (selected_priority > priority)) { 73 | selected_priority = priority; 74 | priority_collateral_id = assetId; 75 | priority_collateral_value = assetValue; 76 | } 77 | } 78 | 79 | if (assetValue > collateralValue) { 80 | collateralValue = assetValue; 81 | collateralId = assetId; 82 | } 83 | } else if (balance < 0n) { 84 | if (assetValue > loanValue) { 85 | loanValue = assetValue; 86 | loanId = assetId; 87 | } 88 | } 89 | } 90 | 91 | // use old collateral selection logic 92 | if (selected_priority < NO_PRIORITY_SELECTED) { 93 | collateralId = priority_collateral_id; 94 | collateralValue = priority_collateral_value; 95 | } 96 | 97 | return { 98 | selectedCollateralId: collateralId, 99 | selectedCollateralValue: collateralValue, 100 | selectedLoanId: loanId, 101 | selectedLoanValue: loanValue, 102 | } 103 | } 104 | 105 | export async function addLiquidationTask( 106 | db: MyDatabase, user: User, 107 | loanAssetId: bigint, collateralAssetId: bigint, 108 | liquidationAmount: bigint, minCollateralAmount: bigint, 109 | pricesCell: Cell) { 110 | 111 | const queryID = BigInt(Date.now()); 112 | 113 | console.log('ADDING LIQUIDATE TASK TO DB: ', { 114 | user: user.wallet_address, 115 | loanAssetId, 116 | collateralAssetId, 117 | liquidationAmount, 118 | minCollateralAmount, 119 | queryID 120 | }); 121 | 122 | // db might be busy, retry 5 times, wait 1 sec before retry 123 | const res = await retry(async () => { 124 | await db.addTask( 125 | user.wallet_address, 126 | user.contract_address, 127 | user.subaccountId, 128 | Date.now(), 129 | loanAssetId, 130 | collateralAssetId, 131 | liquidationAmount, 132 | minCollateralAmount, 133 | pricesCell.toBoc().toString("base64"), 134 | queryID 135 | ); 136 | }, {attempts: 5, attemptInterval: 1000} 137 | ); 138 | 139 | return res.ok; 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Evaa Protocol and Hack-ton-berfest 2023 2 | 3 | --- 4 | 5 | ### **Table of Contents** 6 | 7 | - [Understanding Evaa & Liquidation Bots](#understanding-evaa--liquidation-bots) 8 | - [Hack-ton-berfest & Evaa Hackathon](#hack-ton-berfest--evaa-hackathon) 9 | - [Get Involved](#get-involved) 10 | 11 | --- 12 | 13 | ### **Understanding Evaa & Liquidation Bots** 14 | 15 | **Evaa Protocol**: 16 | > Evaa is a decentralized financial protocol to enhance the lending and borrowing of digital assets in the DeFi space. It hinges on collateralization to ensure that loans are secure. When collateral value drops to a certain level, intervention becomes necessary. 17 | 18 | **Liquidation Bots**: 19 | > In DeFi, quick market downturns can put loans at risk. Liquidation bots are automated tools that vigilantly monitor the market for these vulnerable positions on platforms like Evaa. Upon detection, they automatically initiate the liquidation process. This proactive approach is vital in preserving the integrity, stability, and trustworthiness of the lending ecosystem. 20 | 21 | --- 22 | 23 | ### **Hack-ton-berfest & Evaa Hackathon** 24 | 25 | **Hack-ton-berfest**: 26 | > An iteration of the renowned Hacktoberfest, Hack-ton-berfest is a month-long ode to open-source software celebrated every October. Developers worldwide converge to enhance open-source projects, ensuring the software community continues to thrive and innovate. 27 | > Link: https://society.ton.org/hack-ton-berfest-2023 28 | 29 | **Evaa Hackathon**: 30 | > Running parallel to Hack-ton-berfest, the Evaa Hackathon zeroes in on the creation and enhancement of liquidation bots tailored for the Evaa ecosystem. It's a pedestal for developers to manifest their skills, bring value to the rapidly evolving DeFi space, and seize opportunities for rewards and acclaim. 31 | > Link: https://evaa.gitbook.io/evaadev/ 32 | 33 | --- 34 | 35 | ### **Get Involved** 36 | 37 | 🌍 **A Global Invitation**: 38 | 39 | We're reaching out to developers, DeFi enthusiasts, and innovative minds across the globe! 40 | 41 | - Engage with the global open-source community via **Hack-ton-berfest**. 42 | - Showcase your DeFi acumen and prowess through the **Evaa Hackathon**. 43 | 44 | 🔗 **Deep Dive into DeFi**: 45 | 46 | Are you eager to unravel more about DeFi and our innovative protocol? Could you jump into the conversation on **Protocol Hub **? This hub is a nexus for vibrant discussions, creativity, and insights and serves as a direct channel to engage with the core Evaa team. 47 | 48 | 🚀 **Join the Movement**: 49 | 50 | Don't let this golden opportunity slip away. Dive in, contribute, foster connections, and be a pivotal part of sculpting the financial future. Let's architect the next big thing in DeFi together in our Evaa Protocol Hub: https://t.me/EvaaProtocolHub 51 | 52 | --- 53 | 54 | ### **Understanding the EVAA Protocol GET Methods** 55 | 56 | #### 1. `get_wallet_data` 57 | - **Description**: Retrieves wallet data, such as the balance. 58 | - **Usage**: 59 | ```typescript 60 | myBalance.usdt = (await tonClient.runMethod(jettonWallets.usdt, 'get_wallet_data')).stack.readBigNumber(); 61 | ``` 62 | - **Arguments**: 63 | - Wallet address (e.g., `jettonWallets.usdt`) 64 | - **Return:** 65 | ``` 66 | assetBalance = Uint(64); 67 | ``` 68 | 69 | #### 2. `getAssetsData` 70 | - **Description**: Returns data about assets. 71 | - **Usage**: 72 | ```typescript 73 | const assetsDataResult = await tonClient.runMethod(masterAddress, 'getAssetsData'); 74 | ``` 75 | - **Name:** getAssetsData 76 | - **Arguments**: 77 | - Master address (e.g., `masterAddress`) 78 | - **Return:** 79 | ``` 80 | [ 81 | Dict 89 | ] 90 | ``` 91 | 92 | 93 | #### 3. `getAssetsConfig` 94 | - **Description**: Returns the asset configuration. 95 | - **Usage**: 96 | ```typescript 97 | const assetConfigResult = await tonClient.runMethod(masterAddress, 'getAssetsConfig'); 98 | ``` 99 | - **Name:** getAssetsConfig 100 | - **Arguments**: 101 | - Master address (e.g., `masterAddress`) 102 | - **Return:** 103 | ``` 104 | [ 105 | Dict 118 | ] 119 | ``` 120 | 121 | #### 4. `getAllUserScData` 122 | - **Description**: Returns all user data related to the smart contract. 123 | - **Usage**: 124 | ```typescript 125 | userDataResult = await tonClient.runMethodWithError(userContractAddress, 'getAllUserScData'); 126 | ``` 127 | - **Arguments**: 128 | - User smart contract address (e.g., `userContractAddress`) 129 | - **Return:** 130 | ``` 131 | [ 132 | Dict 141 | ] 142 | ``` 143 | 144 | ### **Understanding the EVAA Protocol Liquidation TX** 145 | 146 | #### TON Loan Asset 147 | 148 | If the loan asset is TON (TON Crystal), the following steps are performed: 149 | 150 | 1. Set the liquidation opcode to `0x3`. 151 | 2. Store the query ID, which can be `0`. 152 | 3. Store the user's wallet address (not the user SC address). This address is used to calculate the user SC address. 153 | 4. Store the ID of the token to be received. It's a SHA256 HASH derived from the Jetton wallet address of the EVAA master smart contract. 154 | 5. Store the minimal amount of tokens required to satisfy the liquidation. 155 | 6. Set a constant value of `-1` (can always be `-1`). 156 | 7. Reference the `pricessCell`, which contains prices obtainable from the IOTA NFT. 157 | 8. Conclude the cell. 158 | 159 | Amount to send: `task.liquidationAmount`, minus `0.33` for blockchain fees. The EVAA smart contract will calculate the amount of collateral tokens to send back based on this number. 160 | 161 | Destination address: `evaaMaster`. 162 | 163 | #### Other Loan Assets 164 | 165 | For loan assets other than TON, the following steps are performed: 166 | 167 | 1. Set the jetton transfer opcode to `0xf8a7ea5`. 168 | 2. Store the query ID, which can be `0`. 169 | 3. Store the amount of jettons to send (The EVAA smart contract will calculate the amount of collateral tokens to send back based on this number). 170 | 4. Store the address of the jetton receiver smart contract, which is the EVAA master. 171 | 5. Store the address of the contract to receive leftover TONs. 172 | 6. Set a bit to `0`. 173 | 7. Store the TON amount to forward in a token notification (Note: Clarification needed). 174 | 8. Set another bit to `1`. 175 | 9. Reference a sub-cell, which replicates the TON liquidation logic. 176 | 10. Conclude the main cell. 177 | 178 | Amount to send: `toNano('1')` for transaction chain fees (Note: Clarification needed). 179 | 180 | Destination address: The Jetton wallet associated with the loan asset. 181 | 182 | This code provides a clear explanation of the liquidation process, with detailed comments to understand each step. 183 | 184 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {MyDatabase} from "./db/database"; 2 | import {OpenedContract, TonClient} from "@ton/ton"; 3 | import {DB_PATH, EVAA_CONTRACT_VERSIONS_MAP, HIGHLOAD_ADDRESS, IS_TESTNET, makeTonClient, POOL_CONFIG, TON_API_ENDPOINT} from "./config"; 4 | import axios, {AxiosInstance} from "axios"; 5 | import {handleTransactions} from "./services/indexer/indexer"; 6 | import {validateBalances} from "./services/validator/validator"; 7 | import {configDotenv} from "dotenv"; 8 | import {handleLiquidates} from "./services/liquidator/liquidator"; 9 | import {mnemonicToWalletKey} from "@ton/crypto"; 10 | import * as https from "https"; 11 | import {sleep} from "./util/process"; 12 | import {clearInterval} from "node:timers"; 13 | import {EvaaMasterClassic, EvaaMasterPyth, MAINNET_POOL_CONFIG} from "@evaafi/sdk"; 14 | import {retry} from "./util/retry"; 15 | import {ChannelMessenger, logMessage, Messenger, TopicMessenger} from "./lib/messenger"; 16 | import {HighloadWalletV2} from "./lib/highload_contract_v2"; 17 | 18 | function makeTonApi(endpoint, apiKey: string) { 19 | const tonApi = axios.create({ 20 | baseURL: endpoint, 21 | httpsAgent: new https.Agent({ 22 | rejectUnauthorized: false, 23 | }), 24 | headers: { 25 | Authorization: apiKey, // The apiKey will be started with "Bearer " in .env, e.g. "Bearer 1234567890" 26 | }, 27 | }); 28 | return tonApi; 29 | } 30 | 31 | async function main(bot: Messenger) { 32 | configDotenv(); 33 | const db = new MyDatabase(POOL_CONFIG.poolAssetsConfig); 34 | await db.init(DB_PATH); 35 | 36 | const tonApi: AxiosInstance = makeTonApi(TON_API_ENDPOINT, process.env.TONAPI_KEY); 37 | const tonClient: TonClient = await makeTonClient(); 38 | 39 | const evaaMaster = EVAA_CONTRACT_VERSIONS_MAP.get(POOL_CONFIG.masterAddress).master; 40 | 41 | const evaa: OpenedContract = 42 | tonClient.open( 43 | new evaaMaster({ debug: IS_TESTNET, poolConfig: POOL_CONFIG }), 44 | ); 45 | 46 | const evaaPriceCollector = evaa.poolConfig.collector; 47 | const res = await retry( 48 | async () => await evaa.getSync(), 49 | {attempts: 10, attemptInterval: 5000} 50 | ); 51 | if (!res.ok) throw (`Failed to sync evaa master`); 52 | 53 | const keys = await mnemonicToWalletKey(process.env.WALLET_PRIVATE_KEY.split(' ')); 54 | // const highloadContract = openHighloadContract(tonClient, keys.publicKey); 55 | const highloadContract = new HighloadWalletV2(tonClient, HIGHLOAD_ADDRESS, keys.publicKey); 56 | 57 | logMessage(`Indexer is syncing...`); 58 | await handleTransactions(db, tonApi, tonClient, bot, evaa, HIGHLOAD_ADDRESS, true); 59 | logMessage(`Indexer is synced. Waiting 1 sec before starting`); 60 | 61 | await sleep(1000); 62 | 63 | let handlingTransactions = false; 64 | const transactionID = setInterval(async () => { 65 | if (handlingTransactions) { 66 | logMessage('Transaction Handler: handling transactions in progress, wait more...'); 67 | return; 68 | } 69 | logMessage('Starting handleTransactions...') 70 | handlingTransactions = true; 71 | handleTransactions(db, tonApi, tonClient, bot, evaa, HIGHLOAD_ADDRESS) 72 | .catch(e => { 73 | console.log(e); 74 | if (JSON.stringify(e).length == 2) { 75 | bot.sendMessage(`[Indexer]: ${e}`); 76 | return; 77 | } 78 | bot.sendMessage(`[Indexer]: ${JSON.stringify(e).slice(0, 300)}`); 79 | }) 80 | .finally(() => { 81 | handlingTransactions = false; 82 | logMessage("Exiting from handleTransactions..."); 83 | }); 84 | }, 5000); 85 | 86 | let validating = false; 87 | const validatorID = setInterval(() => { 88 | if (validating) { 89 | logMessage('Validator: validation in progress, wait more...'); 90 | return; 91 | } 92 | validating = true; 93 | 94 | validateBalances(db, evaa, evaaPriceCollector, bot, POOL_CONFIG) 95 | .catch(e => { 96 | console.log(e); 97 | if (JSON.stringify(e).length == 2) { 98 | bot.sendMessage(`[Validator]: ${e}`); 99 | return; 100 | } 101 | bot.sendMessage(`[Validator]: ${JSON.stringify(e).slice(0, 300)}`); 102 | }) 103 | .finally(() => { 104 | validating = false; 105 | }) 106 | }, 5000); 107 | 108 | let liquidating = false; 109 | const liquidatorID = setInterval(() => { 110 | if (liquidating) { 111 | logMessage('Liquidator: liquidation in progress, wait more...'); 112 | return; 113 | } 114 | liquidating = true; 115 | handleLiquidates(db, tonClient, highloadContract, HIGHLOAD_ADDRESS, evaa, keys, bot) 116 | .catch(async (e) => { 117 | console.log(e); 118 | if (JSON.stringify(e).length == 2) { 119 | await bot.sendMessage(`[Liquidator]: ${e}`); 120 | return; 121 | } 122 | await bot.sendMessage(`[Liquidator]: ${JSON.stringify(e, null, 2).slice(0, 300)}`); 123 | }) 124 | .finally(async () => { 125 | liquidating = false; 126 | 127 | logMessage('Exiting from handleLiquidates...'); 128 | }); 129 | }, 5000); 130 | 131 | let blacklisting = false; 132 | const blacklisterID = setInterval(async () => { 133 | if (blacklisting) { 134 | logMessage('BLACKLISTER: Blacklisting is in progress, wait more...'); 135 | return; 136 | } 137 | blacklisting = true; 138 | try { 139 | await db.handleFailedTasks(); 140 | await db.deleteOldTasks(); 141 | } catch (e) { 142 | console.log(e); 143 | } finally { 144 | blacklisting = false; 145 | logMessage("Exiting from blacklisting..."); 146 | } 147 | }, 5000); 148 | 149 | // handle interruption Ctrl+C === SIGINT 150 | let first_sigint_request = true; 151 | process.on('SIGINT', async () => { 152 | if (first_sigint_request) { 153 | first_sigint_request = false; 154 | clearInterval(transactionID); 155 | clearInterval(validatorID); 156 | clearInterval(liquidatorID); 157 | clearInterval(blacklisterID); 158 | 159 | const message = `Received SIGINT, stopping services...`; 160 | logMessage(message); 161 | await bot.sendMessage(message); 162 | 163 | setTimeout(() => { 164 | throw ('Forced exit...'); 165 | }, 10_000); 166 | } else { 167 | throw ('Forced exit...'); 168 | } 169 | }); 170 | } 171 | 172 | (() => { 173 | configDotenv(); 174 | 175 | const {TELEGRAM_BOT_TOKEN: token, SERVICE_CHAT_ID: chatId, TELEGRAM_TOPIC_ID: topicId} = process.env; 176 | const messenger = topicId !== undefined ? 177 | new TopicMessenger(token, chatId, topicId) : new ChannelMessenger(token, chatId); 178 | 179 | main(messenger) 180 | .catch(e => { 181 | console.log(e); 182 | if (JSON.stringify(e).length == 2) { 183 | messenger.sendMessage(`Fatal error: ${e}`).then(); 184 | return; 185 | } 186 | messenger.sendMessage(`Fatal error: ${JSON.stringify(e).slice(0, 300)} `).then(); 187 | }) 188 | .finally(() => logMessage("Exiting...")); 189 | })(); 190 | -------------------------------------------------------------------------------- /src/services/validator/validator.ts: -------------------------------------------------------------------------------- 1 | import type { Cell, Dictionary, OpenedContract } from "@ton/ton"; 2 | import type { MyDatabase } from "../../db/database"; 3 | import type { PriceData } from "./types"; 4 | import { 5 | type AbstractCollector, 6 | calculateHealthParams, 7 | calculateLiquidationAmounts, 8 | type EvaaMasterClassic, 9 | type EvaaMasterPyth, 10 | findAssetById, 11 | type PoolConfig, 12 | TON_MAINNET, 13 | } from "@evaafi/sdk"; 14 | import { isAxiosError } from "axios"; 15 | import { logMessage, type Messenger } from "../../lib/messenger"; 16 | import { 17 | LIQUIDATOR_PRICES_UPDATE_INTERVAL, 18 | VALIDATOR_MAX_PRICES_ISSUED, 19 | } from "../../steady_config"; 20 | import { 21 | CheckOraclesEnum, 22 | CheckOraclesMessage, 23 | checkPriceData, 24 | } from "../../util/prices"; 25 | import { retry } from "../../util/retry"; 26 | import { addLiquidationTask, selectLiquidationAssets } from "./helpers"; 27 | 28 | export async function validateBalances( 29 | db: MyDatabase, 30 | evaa: OpenedContract, 31 | evaaPriceCollector: AbstractCollector, 32 | bot: Messenger, 33 | poolConfig: PoolConfig, 34 | ) { 35 | try { 36 | const users = await db.getUsers(); 37 | 38 | let pricesDict: Dictionary; 39 | let pricesCell: Cell; 40 | let lastPricesSync = 0; // not up to date 41 | const isPriceDataActual = () => 42 | (Date.now() - lastPricesSync) / 1000 < LIQUIDATOR_PRICES_UPDATE_INTERVAL; 43 | 44 | const updatePrices = async () => { 45 | if (isPriceDataActual()) return; 46 | 47 | // fetch prices 48 | const pricesRes = await retry( 49 | async () => 50 | await evaaPriceCollector.getPrices(evaa.poolConfig.poolAssetsConfig), 51 | { attempts: 10, attemptInterval: 1000 }, 52 | ); 53 | if (!pricesRes.ok) throw new Error(`Failed to fetch prices`); 54 | 55 | const res = checkPriceData( 56 | pricesRes.value.dataCell, 57 | VALIDATOR_MAX_PRICES_ISSUED, 58 | ); 59 | if (res !== CheckOraclesEnum.OK) { 60 | throw new Error(`${CheckOraclesMessage.at(res)}, cannot continue`); 61 | } 62 | 63 | pricesDict = pricesRes.value.dict; 64 | pricesCell = pricesRes.value.dataCell; 65 | lastPricesSync = Date.now(); 66 | logMessage("Prices updated, data is ok"); 67 | }; 68 | 69 | // sync evaa (required to update rates mostly) 70 | const evaaSyncRes = await retry(async () => await evaa.getSync(), { 71 | attempts: 10, 72 | attemptInterval: 1000, 73 | }); 74 | if (!evaaSyncRes.ok) { 75 | throw new Error(`Failed to sync evaa`); 76 | } 77 | 78 | const assetsDataDict = evaa.data.assetsData; 79 | const assetsConfigDict = evaa.data.assetsConfig; 80 | 81 | for (const user of users) { 82 | await updatePrices(); 83 | 84 | if (await db.isTaskExists(user.wallet_address)) { 85 | logMessage( 86 | `Validator: Task for ${user.wallet_address} already exists, skipping...`, 87 | ); 88 | continue; 89 | } 90 | 91 | let healthParams: any; // TODO: add return type to sdk 92 | 93 | try { 94 | healthParams = calculateHealthParams({ 95 | assetsData: evaa.data.assetsData, 96 | assetsConfig: evaa.data.assetsConfig, 97 | principals: user.principals, 98 | prices: pricesDict, 99 | poolConfig, 100 | }); 101 | } catch (e) { 102 | logMessage( 103 | `Failed to calculate heath factor for user ${user.wallet_address}`, 104 | ); 105 | console.log(e); 106 | continue; 107 | } 108 | 109 | if (!healthParams.isLiquidatable) { 110 | continue; 111 | } 112 | 113 | if (healthParams.totalSupply === 0n) { 114 | const message = `Validator: Problem with user ${user.wallet_address}: account doesn't have collateral at all, and will be blacklisted`; 115 | logMessage(message); 116 | if (!(await db.blacklistUser(user.wallet_address))) { 117 | await bot.sendMessage(`${message} : Failed to blacklist user`); 118 | } else { 119 | await bot.sendMessage(`${message} : User was blacklisted`); 120 | } 121 | continue; 122 | } 123 | 124 | // uncomment this option instead for selectLiquidationAssets if you need simply the greatest pair of assets 125 | 126 | // const {selectedLoanId, selectedCollateralId} = selectGreatestAssets( 127 | // user.principals, pricesDict, assetsConfigDict, assetsDataDict, poolConfig 128 | // ); 129 | 130 | // priority assets 131 | const { selectedLoanId, selectedCollateralId } = selectLiquidationAssets( 132 | user.principals, 133 | pricesDict, 134 | assetsConfigDict, 135 | assetsDataDict, 136 | poolConfig, 137 | ); 138 | 139 | const loanAsset = findAssetById(selectedLoanId, poolConfig); 140 | const collateralAsset = findAssetById(selectedCollateralId, poolConfig); 141 | if (!loanAsset || !collateralAsset) { 142 | logMessage( 143 | `Failed to select loan or collateral for liquidation: loan id: ${selectedLoanId}, collateral id: ${selectedCollateralId}, skipping user`, 144 | ); 145 | continue; 146 | } 147 | const { totalSupply, totalDebt } = healthParams; 148 | const { maxLiquidationAmount, maxCollateralRewardAmount } = 149 | calculateLiquidationAmounts( 150 | loanAsset, 151 | collateralAsset, 152 | totalSupply, 153 | totalDebt, 154 | user.principals, 155 | pricesDict, 156 | assetsDataDict, 157 | assetsConfigDict, 158 | poolConfig.masterConstants, 159 | ); 160 | 161 | const minCollateralAmount = maxCollateralRewardAmount; // liquidator will deduct dust 162 | 163 | if (!assetsConfigDict.has(collateralAsset.assetId)) { 164 | logMessage( 165 | `Validator: No config for collateral ${collateralAsset.name}, skipping...`, 166 | ); 167 | continue; 168 | } 169 | const collateralConfig = assetsConfigDict.get(collateralAsset.assetId)!; 170 | const collateralScale = 10n ** collateralConfig.decimals; 171 | 172 | if (!pricesDict.has(collateralAsset.assetId)) { 173 | logMessage( 174 | `Validator: No price for collateral ${collateralAsset.name}, skipping...`, 175 | ); 176 | continue; 177 | } 178 | const collateralPrice = pricesDict.get(collateralAsset.assetId)!; 179 | if (collateralPrice <= 0) { 180 | logMessage( 181 | `Validator: Invalid price for collateral ${collateralAsset.name}, skipping...`, 182 | ); 183 | continue; 184 | } 185 | 186 | const MIN_ALLOWED_COLLATERAL_WORTH = pricesDict.get(TON_MAINNET.assetId); // 1 TON worth in 10**9 decimals 187 | if ( 188 | minCollateralAmount * collateralPrice >= 189 | MIN_ALLOWED_COLLATERAL_WORTH * collateralScale 190 | ) { 191 | const res = await addLiquidationTask( 192 | db, 193 | user, 194 | loanAsset.assetId, 195 | collateralAsset.assetId, 196 | maxLiquidationAmount, 197 | minCollateralAmount, 198 | pricesCell, 199 | ); 200 | 201 | console.log("health params for liquidation:", { healthParams }); 202 | 203 | if (!res) { 204 | await bot.sendMessage( 205 | `Failed to add db task for user ${user.wallet_address}`, 206 | ); 207 | // continue; 208 | } else { 209 | await bot.sendMessage(`Task for ${user.wallet_address} added`); 210 | logMessage(`Task for ${user.wallet_address} added`); 211 | } 212 | } else { 213 | // logMessage(`Not enough collateral for ${user.wallet_address}`); 214 | } 215 | } 216 | // logMessage(`Finish validating balances.`) 217 | } catch (e) { 218 | if (!isAxiosError(e)) { 219 | console.log(e); 220 | throw `Not axios error: ${JSON.stringify(e)}}`; 221 | } 222 | 223 | if (e.response) { 224 | logMessage( 225 | `Validator: Error: ${e.response.status} - ${e.response.statusText}`, 226 | ); 227 | } else if (e.request) { 228 | logMessage(`Validator: Error: No response from server. 229 | 230 | ${e.request}`); 231 | } else { 232 | logMessage(`Validator: Error: unknown`); 233 | } 234 | console.log(e); 235 | logMessage(`Validator: Error while validating balances...`); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/services/liquidator/liquidator.ts: -------------------------------------------------------------------------------- 1 | import type { KeyPair } from "@ton/crypto"; 2 | import { 3 | BigMath, 4 | type ClassicLiquidationParameters, 5 | calculateMinCollateralByTransferredAmount, 6 | type EvaaMasterClassic, 7 | type EvaaMasterPyth, 8 | FEES, 9 | findAssetById, 10 | PythCollector, 11 | type PythLiquidationParameters, 12 | type PythMasterData, 13 | TON_MAINNET, 14 | } from "@evaafi/sdk"; 15 | import { Address } from "@ton/core"; 16 | import { 17 | Cell, 18 | Dictionary, 19 | type OpenedContract, 20 | type TonClient, 21 | toNano, 22 | } from "@ton/ton"; 23 | import { 24 | JETTON_WALLETS, 25 | LIQUIDATION_BALANCE_LIMITS, 26 | LIQUIDATOR_MAX_PRICES_ISSUED, 27 | } from "../../config"; 28 | import { 29 | DATABASE_DEFAULT_RETRY_OPTIONS, 30 | type MyDatabase, 31 | } from "../../db/database"; 32 | import { getBalances, type WalletBalances } from "../../lib/balances"; 33 | import { 34 | type HighloadWalletV2, 35 | makeLiquidationCell, 36 | } from "../../lib/highload_contract_v2"; 37 | import { logMessage, type Messenger } from "../../lib/messenger"; 38 | import { getAddressFriendly } from "../../util/format"; 39 | import { isPriceDataActual } from "../../util/prices"; 40 | import { retry } from "../../util/retry"; 41 | import { 42 | calculateDust, 43 | formatNotEnoughBalanceMessage, 44 | getJettonIDs, 45 | type Log, 46 | } from "./helpers"; 47 | 48 | const MAX_TASKS_FETCH = 100; 49 | 50 | export async function handleLiquidates( 51 | db: MyDatabase, 52 | tonClient: TonClient, 53 | highloadContract: HighloadWalletV2, 54 | highloadAddress: Address, 55 | evaa: OpenedContract, 56 | keys: KeyPair, 57 | bot: Messenger, 58 | ) { 59 | const dust = (assetId: bigint) => { 60 | return calculateDust( 61 | assetId, 62 | evaa.data.assetsConfig, 63 | evaa.data.assetsData, 64 | evaa.poolConfig.masterConstants, 65 | ); 66 | }; 67 | 68 | const cancelOldTasksRes = await retry( 69 | async () => await db.cancelOldTasks(), 70 | DATABASE_DEFAULT_RETRY_OPTIONS, 71 | ); 72 | if (!cancelOldTasksRes.ok) { 73 | await bot.sendMessage( 74 | "Failed to cancel old tasks, database probably is busy...", 75 | ); 76 | } 77 | const tasks = await db.getTasks(MAX_TASKS_FETCH); 78 | const jettonIDs = getJettonIDs(evaa); 79 | 80 | const liquidatorBalances: WalletBalances = await getBalances( 81 | tonClient, 82 | highloadAddress, 83 | jettonIDs, 84 | JETTON_WALLETS, 85 | ); 86 | 87 | const log: Log[] = []; 88 | const highloadMessages = Dictionary.empty(); 89 | 90 | for (const task of tasks) { 91 | const liquidatorLoanBalance = liquidatorBalances.get(task.loan_asset) ?? 0n; 92 | if ( 93 | liquidatorLoanBalance < LIQUIDATION_BALANCE_LIMITS.get(task.loan_asset) 94 | ) { 95 | logMessage( 96 | `Liquidator: Not enough balance for liquidation task ${task.id}`, 97 | ); 98 | await bot.sendMessage( 99 | formatNotEnoughBalanceMessage( 100 | task, 101 | liquidatorBalances, 102 | evaa.data.assetsConfig, 103 | evaa.poolConfig.poolAssetsConfig, 104 | ), 105 | ); 106 | await db.cancelTaskNoBalance(task.id); 107 | continue; 108 | } 109 | 110 | const { 111 | liquidation_amount: maxLiquidationAmount, 112 | min_collateral_amount: maxRewardAmount, 113 | } = task; 114 | 115 | const loanDust = dust(task.loan_asset); 116 | const collateralDust = dust(task.collateral_asset); 117 | 118 | let allowedLiquidationAmount: bigint; 119 | let liquidationAmount: bigint; 120 | let quotedCollateralAmount = maxRewardAmount; 121 | 122 | if (task.loan_asset === TON_MAINNET.assetId) { 123 | allowedLiquidationAmount = BigMath.min( 124 | maxLiquidationAmount, 125 | liquidatorLoanBalance - toNano(2), 126 | ); 127 | liquidationAmount = BigMath.min( 128 | maxLiquidationAmount + loanDust, 129 | liquidatorLoanBalance - toNano(2), 130 | ); 131 | } else { 132 | allowedLiquidationAmount = BigMath.min( 133 | maxLiquidationAmount, 134 | liquidatorLoanBalance, 135 | ); 136 | liquidationAmount = BigMath.min( 137 | maxLiquidationAmount + loanDust, 138 | liquidatorLoanBalance, 139 | ); 140 | } 141 | 142 | if (liquidatorLoanBalance < maxLiquidationAmount) { 143 | quotedCollateralAmount = calculateMinCollateralByTransferredAmount( 144 | allowedLiquidationAmount, 145 | maxLiquidationAmount, 146 | maxRewardAmount, 147 | ); 148 | } 149 | 150 | task.liquidation_amount = liquidationAmount; 151 | // TODO: update coefficient after thorough check 152 | task.min_collateral_amount = 153 | (quotedCollateralAmount * 97n) / 100n - collateralDust; 154 | 155 | console.log({ 156 | walletAddress: task.wallet_address, 157 | liquidationAmount: task.liquidation_amount, 158 | collateralAmount: task.min_collateral_amount, 159 | }); 160 | 161 | const priceData = Cell.fromBase64(task.prices_cell); 162 | // check priceData is up-to-date 163 | if (!isPriceDataActual(priceData, LIQUIDATOR_MAX_PRICES_ISSUED)) { 164 | const message = `Price data for task ${task.id} is too old, task will be canceled`; 165 | console.log(message); 166 | await bot.sendMessage(message); 167 | await retry( 168 | async () => await db.cancelTask(task.id), 169 | DATABASE_DEFAULT_RETRY_OPTIONS, 170 | ); // if failed to access database it's not critical, just go on 171 | continue; 172 | } 173 | 174 | const loanAsset = findAssetById(task.loan_asset, evaa.poolConfig); 175 | if (!loanAsset) { 176 | logMessage( 177 | `Liquidator: Asset ${task.loan_asset} is not supported, skipping...`, 178 | ); 179 | await bot.sendMessage( 180 | `Asset ${task.loan_asset} is not supported, skipping...`, 181 | ); 182 | continue; 183 | } 184 | 185 | let amount = 0n; 186 | let destAddr: string; 187 | let liquidationBody: Cell; 188 | 189 | const baseLiquidationParams = { 190 | borrowerAddress: Address.parse(task.wallet_address), 191 | loanAsset: task.loan_asset, 192 | collateralAsset: task.collateral_asset, 193 | minCollateralAmount: task.min_collateral_amount, 194 | liquidationAmount: task.liquidation_amount, 195 | queryID: task.query_id, 196 | liquidatorAddress: highloadAddress, 197 | includeUserCode: true, 198 | asset: loanAsset, 199 | payload: Cell.EMPTY, 200 | customPayloadRecipient: highloadAddress, 201 | customPayloadSaturationFlag: false, 202 | subaccountId: task.subaccountId, 203 | }; 204 | 205 | if ("pythAddress" in evaa.data.masterConfig.oraclesInfo) { 206 | // Pyth 207 | const masterData = evaa.data as PythMasterData; 208 | const pythConfig = masterData.masterConfig.oraclesInfo; 209 | 210 | if (!(evaa.poolConfig.collector instanceof PythCollector)) { 211 | throw new Error("Collector is not PythCollector but master is Pyth"); 212 | } 213 | 214 | const collateralAsset = findAssetById( 215 | task.collateral_asset, 216 | evaa.poolConfig, 217 | ); 218 | 219 | if (!collateralAsset) { 220 | logMessage( 221 | `Liquidator: Collateral asset ${task.collateral_asset} is not supported, skipping...`, 222 | ); 223 | await bot.sendMessage( 224 | `Collateral asset ${task.collateral_asset} is not supported, skipping...`, 225 | ); 226 | continue; 227 | } 228 | 229 | const user = await db.getUser(task.contract_address); 230 | const pc = await evaa.poolConfig.collector.getPricesForLiquidate( 231 | user.principals, 232 | ); 233 | 234 | const liquidationParameters = { 235 | ...baseLiquidationParams, 236 | pyth: { 237 | priceData: pc.dataCell, 238 | targetFeeds: pc.targetFeeds(), 239 | refAssets: pc.refAssets(), 240 | pythAddress: pythConfig.pythAddress, 241 | // Params for TON (ProxySpecificPythParams) 242 | minPublishTime: pc.minPublishTime, 243 | maxPublishTime: pc.maxPublishTime, 244 | // Params for Jetton (OnchainSpecificPythParams) 245 | publishGap: 10, 246 | maxStaleness: LIQUIDATOR_MAX_PRICES_ISSUED, 247 | }, 248 | } as PythLiquidationParameters; 249 | liquidationBody = ( 250 | evaa as unknown as OpenedContract 251 | ).createLiquidationMessage(liquidationParameters); 252 | } else { 253 | // Classic 254 | const liquidationParameters = { 255 | ...baseLiquidationParams, 256 | priceData, 257 | } as ClassicLiquidationParameters; 258 | liquidationBody = ( 259 | evaa as unknown as OpenedContract 260 | ).createLiquidationMessage(liquidationParameters); 261 | } 262 | 263 | if (task.loan_asset === TON_MAINNET.assetId) { 264 | amount = task.liquidation_amount + FEES.LIQUIDATION; 265 | destAddr = getAddressFriendly(evaa.poolConfig.masterAddress); 266 | } else { 267 | // already checked loanAsset above 268 | destAddr = JETTON_WALLETS.get(task.loan_asset).toString(); 269 | amount = FEES.LIQUIDATION_JETTON; 270 | } 271 | 272 | liquidatorBalances.set( 273 | task.loan_asset, 274 | (liquidatorBalances.get(task.loan_asset) ?? 0n) - task.liquidation_amount, 275 | ); // actualize remaining balance 276 | 277 | highloadMessages.set( 278 | task.id, 279 | makeLiquidationCell(amount, destAddr, liquidationBody), 280 | ); 281 | 282 | await db.takeTask(task.id); // update task status to processing 283 | log.push({ id: task.id, walletAddress: task.wallet_address }); // collection of taken tasks 284 | } 285 | 286 | if (log.length === 0) return; 287 | const res = await retry( 288 | async () => { 289 | const queryID = await highloadContract.sendMessages( 290 | highloadMessages, 291 | keys.secretKey, 292 | ); 293 | logMessage(`Liquidator: Highload message sent, queryID: ${queryID}`); 294 | }, 295 | { attempts: 20, attemptInterval: 200 }, 296 | ); // TODO: maybe add tx send watcher 297 | 298 | // if 20 attempts is not enough, means something is broken, maybe network, liquidator will be restarted 299 | if (!res) throw `Liquidator: Failed to send highload message`; 300 | 301 | const logStrings: string[] = [ 302 | `\nLiquidation tasks sent for ${log.length} users:`, 303 | ]; 304 | for (const task of log) { 305 | logStrings.push(`ID: ${task.id}, Wallet: ${task.walletAddress}`); 306 | await db.liquidateSent(task.id); 307 | } 308 | logMessage(`Liquidator: ${logStrings.join("\n")}`); 309 | } 310 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["es2022"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | "outDir": "build", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": false, /* Enable all strict type-checking options. */ 86 | "noImplicitAny": false, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | }, 109 | "include": ["src/**/*", "jest.config.ts"] 110 | } 111 | -------------------------------------------------------------------------------- /src/services/indexer/helpers.ts: -------------------------------------------------------------------------------- 1 | import { EvaaMasterClassic, EvaaMasterPyth, ExtendedAssetsConfig, PoolAssetConfig, PoolConfig } from "@evaafi/sdk" 2 | import { Address, Slice } from "@ton/core" 3 | import { Cell, Dictionary, OpenedContract } from "@ton/ton" 4 | import { BANNED_ASSETS_FROM, BANNED_ASSETS_TO, MIN_WORTH_SWAP_LIMIT } from "../../config" 5 | import { Task } from "../../db/types" 6 | import { WalletBalances } from "../../lib/balances" 7 | import { logMessage } from "../../lib/messenger" 8 | import { formatBalances, getAddressFriendly, getFriendlyAmount } from "../../util/format" 9 | import { sleep } from "../../util/process" 10 | 11 | export function makeGetAccountTransactionsRequest(address: Address, before_lt: number) { 12 | if (before_lt === 0) 13 | return `v2/blockchain/accounts/${address.toRawString()}/transactions?limit=1000` 14 | else 15 | return `v2/blockchain/accounts/${address.toRawString()}/transactions?before_lt=${before_lt}&limit=1000` 16 | } 17 | 18 | export function isBannedSwapFrom(assetID: bigint): boolean { 19 | return BANNED_ASSETS_FROM.findIndex(value => value === assetID) >= 0; 20 | } 21 | 22 | export function isBannedSwapTo(assetID: bigint): boolean { 23 | return BANNED_ASSETS_TO.findIndex(value => value === assetID) >= 0; 24 | } 25 | 26 | /** 27 | * @param assetIdFrom asset to exchange from (database specific asset id) 28 | * @param assetAmount amount of assets in its wei 29 | * @param assetIdTo asset to exchange to (database specific asset id) 30 | * @param extAssetsConfig assets config dictionary 31 | * @param prices prices dictionary 32 | * @param poolConfig 33 | */ 34 | export async function checkEligibleSwapTask( 35 | assetIdFrom: bigint, assetAmount: bigint, assetIdTo: bigint, 36 | extAssetsConfig: ExtendedAssetsConfig, prices: Dictionary, 37 | poolConfig: PoolConfig 38 | ): Promise { 39 | if (prices === undefined) { 40 | console.error(`Failed to obtain prices from middleware!`); 41 | return false; 42 | } 43 | 44 | const assetFrom = poolConfig.poolAssetsConfig.find(asset => (asset.assetId === assetIdFrom)); 45 | const assetTo = poolConfig.poolAssetsConfig.find(asset => (asset.assetId === assetIdTo)); 46 | const assetFromConfig = extAssetsConfig.get(assetIdFrom); 47 | 48 | if (!assetFrom) { 49 | console.error("Unsupported asset id: ", assetIdFrom); 50 | return false; 51 | } 52 | 53 | if (!assetFromConfig) { 54 | console.error('No config for asset id: ', assetIdFrom); 55 | return false; 56 | } 57 | if (!assetTo) { 58 | console.error("Unsupported asset id: ", assetIdTo); 59 | return false; 60 | } 61 | 62 | if (isBannedSwapFrom(assetIdFrom)) { 63 | console.error(`Cant swap ${assetFrom.name} asset!`); 64 | return false; 65 | } 66 | 67 | if (isBannedSwapTo(assetIdTo)) { 68 | console.error(`Cant swap to ${assetTo.name} asset!`); 69 | return false; 70 | } 71 | 72 | const assetPrice = prices.get(assetIdFrom); 73 | if (assetPrice === undefined) { 74 | console.error(`No price for asset ${assetFrom.name}`); 75 | return false; 76 | } 77 | 78 | const assetFromScale = 10n ** assetFromConfig.decimals; 79 | const assetWorth = assetAmount * assetPrice / assetFromScale; // norm_price * PRICE_ACCURACY( == 10**9) 80 | 81 | return assetWorth > MIN_WORTH_SWAP_LIMIT; 82 | } 83 | 84 | const ERROR_DESCRIPTIONS = new Map([ 85 | [0x30F1, "Master liquidating too much"], 86 | [0x31F2, "Not liquidatable"], 87 | [0x31F3, "Min collateral not satisfied"], 88 | [0x31F4, "User not enough collateral"], 89 | [0x31F5, "User liquidating too much"], 90 | [0x31F6, "Master not enough liquidity"], 91 | [0x31F7, "Liquidation prices missing"], 92 | [0x31F0, "User withdraw in process"], 93 | [0x31FE, "Liquidation execution crashed"],], 94 | ) 95 | 96 | export const ERROR_CODE = { 97 | MASTER_LIQUIDATING_TOO_MUCH: 0x30F1, 98 | NOT_LIQUIDATABLE: 0x31F2, 99 | MIN_COLLATERAL_NOT_SATISFIED: 0x31F3, 100 | USER_NOT_ENOUGH_COLLATERAL: 0x31F4, 101 | USER_LIQUIDATING_TOO_MUCH: 0x31F5, 102 | MASTER_NOT_ENOUGH_LIQUIDITY: 0x31F6, 103 | LIQUIDATION_PRICES_MISSING: 0x31F7, 104 | USER_WITHDRAW_IN_PROCESS: 0x31F0, 105 | LIQUIDATION_EXECUTION_CRASHED: 0x31FE, 106 | } 107 | 108 | export const OP_CODE_SHARED = { 109 | JETTON_TRANSFER_NOTIFICATION: 0x7362d09c, 110 | JETTON_TRANSFER_INTERNAL: 0x7362d09c, 111 | DEBUG_PRINCIPALS: 0xd2, 112 | MASTER_SUPPLY_SUCCESS: 0x11a, 113 | MASTER_WITHDRAW_COLLATERALIZED: 0x211, 114 | USER_WITHDRAW_SUCCESS: 0x211a, 115 | MASTER_LIQUIDATE_SATISFIED: 0x311, 116 | USER_LIQUIDATE_SUCCESS: 0x311a, 117 | MASTER_LIQUIDATE_UNSATISFIED: 0x31f, 118 | }; 119 | 120 | export const OP_CODE_V4 = { 121 | MASTER_SUPPLY: 0x1, 122 | MASTER_WITHDRAW: 0x2, 123 | MASTER_LIQUIDATE: 0x3, 124 | ...OP_CODE_SHARED 125 | } 126 | 127 | export const OP_CODE_V9 = { 128 | MASTER_SUPPLY: 0x1, 129 | MASTER_SUPPLY_WITHDRAW: 0x4, 130 | MASTER_LIQUIDATE: 0x3, 131 | SUPPLY_WITHDRAW_SUCCESS: 0x16, 132 | ...OP_CODE_SHARED, 133 | }; 134 | 135 | export function getErrorDescription(errorId: number): string { 136 | return ERROR_DESCRIPTIONS.get(errorId) ?? 'Unknown error'; 137 | } 138 | 139 | export class DelayedCallDispatcher { 140 | lastCallTimestamp: number = 0; 141 | delay: number; 142 | 143 | constructor(delay: number) { 144 | this.delay = delay; 145 | } 146 | 147 | async makeCall(func: () => Promise): Promise { 148 | const timeElapsed = Date.now() - this.lastCallTimestamp; 149 | const waitTimeLeft = this.delay - timeElapsed; 150 | const toSleep = waitTimeLeft > 0 ? waitTimeLeft : 0; 151 | this.lastCallTimestamp = toSleep + Date.now(); 152 | if (toSleep > 0) { 153 | logMessage(`DelayedCallDispatcher: will sleep ${toSleep}ms more`); 154 | } 155 | await sleep(toSleep); 156 | 157 | return await func(); 158 | } 159 | } 160 | 161 | export function formatLiquidationSuccess( 162 | task: Task, loanInfo: AssetInfo, collateralInfo: AssetInfo, parsedTx: ParsedSatisfiedTx, 163 | txHash: string, txTime: Date, masterAddress: Address, 164 | myBalance: WalletBalances, 165 | assetsConfig: ExtendedAssetsConfig, 166 | poolAssetsConfig: PoolAssetConfig[], 167 | prices: Dictionary) { 168 | const {liquidatableAmount, protocolGift, collateralRewardAmount} = parsedTx; 169 | 170 | const { 171 | query_id: queryID, 172 | wallet_address: walletAddress, 173 | contract_address: contractAddress, 174 | loan_asset: loanId, 175 | collateral_asset: collateralId, 176 | } = task; 177 | 178 | const loanPrice = prices.get(loanId)! 179 | const collateralPrice = prices.get(collateralId)!; 180 | const transferredLoan = liquidatableAmount + protocolGift; 181 | const transferredValue = transferredLoan * loanPrice / loanInfo.scale; 182 | const receivedValue = collateralRewardAmount * collateralPrice / collateralInfo.scale; 183 | const profit = receivedValue - transferredValue; 184 | const profitMargin = (Number(profit * 100n) / Number(transferredValue)).toFixed(2); 185 | 186 | return `✅ Liquidation task (Query ID: ${queryID}) successfully completed 187 | Evaa master address: ${getAddressFriendly(masterAddress)} 188 | Loan asset: ${loanInfo.name} 189 | Loan amount: ${getFriendlyAmount(liquidatableAmount, loanInfo.decimals, loanInfo.name)} 190 | Protocol gift: ${getFriendlyAmount(protocolGift, loanInfo.decimals, loanInfo.name)} 191 | Collateral asset: ${collateralInfo.name} 192 | Collateral amount: ${getFriendlyAmount(collateralRewardAmount, collateralInfo.decimals, collateralInfo.name)} 193 | Transferred value: ${getFriendlyAmount(transferredValue, 9n, 'USD')} 194 | Earned: ${getFriendlyAmount(profit, 9n, 'USD')} 195 | Profit margin: ${profitMargin}% 196 | 197 | User address: ${getAddressFriendly(Address.parse(walletAddress))} 198 | Contract address: ${getAddressFriendly(Address.parse(contractAddress))} 199 | Hash: ${txHash} 200 | Time: ${txTime.toLocaleString('en-US', {timeZone: 'UTC'})} UTC 201 | 202 | My balance: 203 | ${formatBalances(myBalance, assetsConfig, poolAssetsConfig)}`; 204 | } 205 | 206 | export function formatLiquidationUnsatisfied(task: Task, 207 | transferredInfo: AssetInfo, collateralInfo: AssetInfo, 208 | loanAmount: bigint, masterAddress: Address, 209 | liquidatorAddress: Address) { 210 | const {min_collateral_amount, wallet_address} = task; 211 | 212 | return ` 213 | Evaa master address: ${getAddressFriendly(masterAddress)} 214 | 215 | User address: ${getAddressFriendly(Address.parse(wallet_address))} 216 | Liquidator address: ${getAddressFriendly(liquidatorAddress)} 217 | assetID: ${transferredInfo.name} 218 | transferred amount: ${getFriendlyAmount(loanAmount, transferredInfo.decimals, transferredInfo.name)} 219 | collateralAssetID: ${collateralInfo.name} 220 | minCollateralAmount: ${getFriendlyAmount(min_collateral_amount, collateralInfo.decimals, collateralInfo.name)}\n` 221 | } 222 | 223 | export function formatSwapAssignedMessage(loanAsset: AssetInfo, collateralAsset: AssetInfo, collateralRewardAmount: bigint,) { 224 | const amount = getFriendlyAmount(collateralRewardAmount, collateralAsset.decimals, collateralAsset.name); 225 | return `Assigned swap task for exchanging of ${amount} for ${loanAsset.name}`; 226 | } 227 | 228 | export function formatSwapCanceledMessage(loanInfo: AssetInfo, collateralInfo: AssetInfo, collateralRewardAmount: bigint) { 229 | return `Swap cancelled (${getFriendlyAmount(collateralRewardAmount, collateralInfo.decimals, collateralInfo.name)} -> ${loanInfo.name})` 230 | } 231 | 232 | export type AssetInfo = { name: string, decimals: bigint, scale: bigint } 233 | 234 | export function getAssetInfo(assetId: bigint, evaa: OpenedContract): AssetInfo { 235 | const assetPoolConfig = evaa.poolConfig.poolAssetsConfig.find(it => it.assetId === assetId); 236 | // throw ok, because no pool config or no data means poll misconfiguration 237 | if (!assetPoolConfig) throw (`Asset ${assetId} is not supported`); 238 | if (!evaa.data.assetsConfig.has(assetId)) throw (`No data for asset ${assetId}`); 239 | 240 | const name = assetPoolConfig.name; 241 | const decimals = evaa.data.assetsConfig.get(assetId)!.decimals; 242 | const scale = 10n ** decimals; 243 | 244 | return {name, decimals, scale} 245 | } 246 | 247 | type ParsedSatisfiedTx = { 248 | deltaLoanPrincipal: bigint, 249 | liquidatableAmount: bigint, 250 | protocolGift: bigint, 251 | newLoanPrincipal: bigint, 252 | collateralAssetId: bigint, 253 | deltaCollateralPrincipal: bigint, 254 | collateralRewardAmount: bigint, 255 | // not sure about following data, probably depends on contract version 256 | // minCollateralAmount: bigint, 257 | // newCollateralPrincipal: bigint, 258 | // forwardTonAmount: bigint, 259 | } 260 | 261 | export function parseSatisfiedTxMsg(satisfiedTx: Slice): ParsedSatisfiedTx { 262 | const extra = satisfiedTx.loadRef().beginParse(); 263 | const deltaLoanPrincipal = extra.loadUintBig(64); // delta loan principal 264 | const liquidatableAmount = extra.loadUintBig(64); // loan amount 265 | const protocolGift = extra.loadUintBig(64); // protocol gift 266 | const newLoanPrincipal = extra.loadUintBig(64); // user new loan principal 267 | const collateralAssetId = extra.loadUintBig(256); // collateral asset id 268 | const deltaCollateralPrincipal = extra.loadUintBig(64); // delta collateral principal amount 269 | const collateralRewardAmount = extra.loadUintBig(64); // collateral reward for liquidation 270 | // const minCollateralAmount = extra.loadUintBig(64); 271 | // const newCollateralPrincipal = extra.loadUintBig(64); 272 | // const forwardTonAmount = extra.loadUintBig(64); 273 | // const customResponsePayload: Cell = extra.loadMaybeRef(); 274 | 275 | return { 276 | deltaLoanPrincipal, 277 | liquidatableAmount, 278 | protocolGift, 279 | newLoanPrincipal, 280 | collateralAssetId, 281 | deltaCollateralPrincipal, 282 | collateralRewardAmount, 283 | // minCollateralAmount, 284 | // newCollateralPrincipal, 285 | // forwardTonAmount, 286 | } 287 | } 288 | 289 | export type EvaaError = { 290 | errorCode: number, 291 | } 292 | 293 | export type LiquidationError = EvaaError & {}; 294 | 295 | export type MasterLiquidatingTooMuchError = LiquidationError & { 296 | type: 'MasterLiquidatingTooMuchError', 297 | errorCode: 0x30F1, 298 | maxAllowedLiquidation: bigint, 299 | } 300 | 301 | export type UserWithdrawInProgressError = LiquidationError & { 302 | type: 'UserWithdrawInProgressError', 303 | errorCode: 0x31F0, 304 | } 305 | export type NotLiquidatableError = LiquidationError & { 306 | type: 'NotLiquidatableError', 307 | errorCode: 0x31F2, 308 | }; 309 | export type LiquidationExecutionCrashedError = LiquidationError & { 310 | type: 'LiquidationExecutionCrashedError', 311 | errorCode: 0x31FE, 312 | }; 313 | 314 | export type MinCollateralNotSatisfiedError = LiquidationError & { 315 | type: 'MinCollateralNotSatisfiedError', 316 | errorCode: 0x31F3, 317 | minCollateralAmount: bigint, 318 | }; 319 | 320 | export type UserNotEnoughCollateralError = LiquidationError & { 321 | type: 'UserNotEnoughCollateralError', 322 | errorCode: 0x31F4, 323 | collateralPresent: bigint, 324 | } 325 | 326 | export type UserLiquidatingTooMuchError = LiquidationError & { 327 | type: 'UserLiquidatingTooMuchError', 328 | errorCode: 0x31F5, 329 | maxNotTooMuchValue: bigint, 330 | } 331 | 332 | export type MasterNotEnoughLiquidityError = LiquidationError & { 333 | type: 'MasterNotEnoughLiquidityError', 334 | errorCode: 0x31F6, 335 | availableLiquidity: bigint, 336 | } 337 | 338 | export type LiquidationPricesMissing = LiquidationError & { 339 | type: 'LiquidationPricesMissing', 340 | errorCode: 0x31F7, 341 | } 342 | 343 | export type UnknownError = LiquidationError & { 344 | type: 'UnknownError', 345 | errorCode: 0xFFFF, 346 | } 347 | 348 | export type LiquidationUnsatisfiedError = MasterLiquidatingTooMuchError | 349 | UserWithdrawInProgressError | NotLiquidatableError | 350 | LiquidationExecutionCrashedError | MinCollateralNotSatisfiedError | 351 | UserNotEnoughCollateralError | UserLiquidatingTooMuchError | 352 | MasterNotEnoughLiquidityError | LiquidationPricesMissing | 353 | UnknownError; 354 | 355 | export type ParsedUnsatisfiedTx = { 356 | op: number, 357 | queryID: bigint, 358 | userAddress: Address, 359 | liquidatorAddress: Address, 360 | transferredAssetID: bigint, 361 | transferredAmount: bigint, 362 | collateralAssetID: bigint, 363 | minCollateralAmount: bigint 364 | forwardTonAmount?: bigint, 365 | customResponsePayload?: Cell, 366 | error: LiquidationUnsatisfiedError, 367 | } 368 | 369 | export function parseUnsatisfiedTxMsg(body: Slice): ParsedUnsatisfiedTx { 370 | const op = body.loadUint(32); 371 | const queryId = body.loadUintBig(64); 372 | const userAddress = body.loadAddress(); 373 | const liquidatorAddress = body.loadAddress(); 374 | const assetID = body.loadUintBig(256); // transferred 375 | const nextBody = body.loadRef().beginParse(); 376 | body.endParse(); 377 | 378 | const transferredAmount = nextBody.loadUintBig(64); 379 | const collateralAssetID = nextBody.loadUintBig(256); 380 | const minCollateralAmount = nextBody.loadUintBig(64); 381 | let forwardTonAmount = undefined; 382 | let customResponsePayload = undefined; 383 | if (nextBody.remainingRefs > 0) { 384 | forwardTonAmount = nextBody.loadUintBig(64); 385 | customResponsePayload = nextBody.loadRef(); 386 | } 387 | let error: LiquidationUnsatisfiedError; 388 | const errorCode = nextBody.loadUint(32); 389 | switch (errorCode) { 390 | case ERROR_CODE.MASTER_LIQUIDATING_TOO_MUCH: { 391 | const maxAllowedLiquidation = nextBody.loadUintBig(64); 392 | error = { 393 | errorCode: 0x30F1, 394 | type: 'MasterLiquidatingTooMuchError', 395 | maxAllowedLiquidation: maxAllowedLiquidation 396 | }; 397 | break; 398 | } 399 | case ERROR_CODE.NOT_LIQUIDATABLE: { 400 | error = {errorCode: 0x31F2, type: 'NotLiquidatableError'}; 401 | break 402 | } 403 | case ERROR_CODE.LIQUIDATION_EXECUTION_CRASHED: { 404 | error = {errorCode: 0x31FE, type: 'LiquidationExecutionCrashedError'}; 405 | break; 406 | } 407 | case ERROR_CODE.MIN_COLLATERAL_NOT_SATISFIED: { 408 | const minCollateralAmount = nextBody.loadUintBig(64); 409 | error = {errorCode: 0x31F3, type: 'MinCollateralNotSatisfiedError', minCollateralAmount}; 410 | break; 411 | } 412 | case ERROR_CODE.USER_NOT_ENOUGH_COLLATERAL: { 413 | const collateralPresent = nextBody.loadUintBig(64); 414 | error = {errorCode: 0x31F4, type: 'UserNotEnoughCollateralError', collateralPresent}; 415 | break; 416 | } 417 | case ERROR_CODE.USER_LIQUIDATING_TOO_MUCH: { 418 | const maxNotTooMuchValue = nextBody.loadUintBig(64); 419 | error = {errorCode: 0x31F5, type: 'UserLiquidatingTooMuchError', maxNotTooMuchValue}; 420 | break; 421 | } 422 | case ERROR_CODE.MASTER_NOT_ENOUGH_LIQUIDITY: { 423 | const availableLiquidity = nextBody.loadUintBig(64); 424 | error = {errorCode: 0x31F6, type: 'MasterNotEnoughLiquidityError', availableLiquidity}; 425 | break; 426 | } 427 | case ERROR_CODE.LIQUIDATION_PRICES_MISSING: { 428 | error = {errorCode: 0x31F7, type: 'LiquidationPricesMissing'}; 429 | break; 430 | } 431 | case ERROR_CODE.USER_WITHDRAW_IN_PROCESS: { 432 | error = {errorCode: 0x31F0, type: 'UserWithdrawInProgressError'}; 433 | break; 434 | } 435 | default: { 436 | error = {errorCode: 0xFFFF, type: 'UnknownError'}; 437 | } 438 | } 439 | return { 440 | op, 441 | queryID: queryId, 442 | userAddress, 443 | liquidatorAddress, 444 | transferredAssetID: assetID, 445 | transferredAmount, 446 | collateralAssetID, 447 | minCollateralAmount, 448 | forwardTonAmount, 449 | customResponsePayload, 450 | error 451 | } 452 | } 453 | -------------------------------------------------------------------------------- /src/db/database.ts: -------------------------------------------------------------------------------- 1 | import { PoolAssetConfig } from "@evaafi/sdk" 2 | import { Database, open } from "sqlite" 3 | import sqlite3 from 'sqlite3' 4 | import { retry } from "../util/retry" 5 | import { makeCreateUsersScript, makeProcessUserScript } from "./helpers" 6 | import { emptyPrincipals, PrincipalsDict, Task, User } from "./types" 7 | 8 | const seconds = (s: number) => s * 1000; 9 | 10 | // tasks time-to-live 11 | export const TTL = { 12 | pending: seconds(60), 13 | processing: seconds(60), 14 | sent: seconds(300), 15 | success: seconds(10), 16 | unsatisfied: seconds(10), 17 | insufficient_balance: seconds(300), 18 | } 19 | 20 | const DATABASE_RETRY_TIMEOUT = seconds(1); 21 | const DATABASE_RETRY_ATTEMPTS = 10; 22 | export const DATABASE_DEFAULT_RETRY_OPTIONS = { 23 | attempts: DATABASE_RETRY_ATTEMPTS, 24 | attemptInterval: DATABASE_RETRY_TIMEOUT 25 | }; 26 | 27 | export class MyDatabase { 28 | protected db: Database; 29 | public readonly COLUMNS: string[]; 30 | public readonly ASSET_IDS: bigint[]; 31 | public readonly CREATE_USERS: string; 32 | public readonly INSERT_OR_UPDATE_USER: string; 33 | 34 | private fetchDbPrincipals(row: any) { 35 | const principals = emptyPrincipals(); 36 | this.COLUMNS.forEach((col, index) => { 37 | const id = this.ASSET_IDS[index]; 38 | const amount = row[col]; 39 | const parsed = this.toBigIntSafe(amount); 40 | principals.set(id, parsed); 41 | }); 42 | return principals; 43 | } 44 | 45 | private toBigIntSafe(value: unknown): bigint { 46 | if (value === null || value === undefined) return 0n; 47 | if (typeof value === 'bigint') return value; 48 | const s = String(value).trim(); 49 | if (s.length === 0) return 0n; 50 | // Strip any fractional part if present (e.g., "1762508457418.0") 51 | const match = s.match(/^-?\d+/); 52 | const intPart = match ? match[0] : '0'; 53 | try { 54 | return BigInt(intPart); 55 | } catch { 56 | return 0n; 57 | } 58 | } 59 | 60 | constructor(poolAssetsConfig: PoolAssetConfig[]) { 61 | const columns = poolAssetsConfig.map(x => (x.name + '_principal').toLowerCase()); 62 | const assetNames = poolAssetsConfig.map(x => x.name); 63 | const assetIds = poolAssetsConfig.map(x => x.assetId); 64 | 65 | this.COLUMNS = columns; 66 | this.ASSET_IDS = assetIds; 67 | 68 | console.log('ASSETS LIST: ', columns); 69 | console.log('ASSET NAMES: ', assetNames); 70 | console.log('ASSET IDS: ', assetIds); 71 | 72 | this.CREATE_USERS = makeCreateUsersScript(columns); 73 | console.log('CREATE USERS SCRIPT: ', this.CREATE_USERS); 74 | 75 | this.INSERT_OR_UPDATE_USER = makeProcessUserScript(columns); 76 | console.log('PROCESS USER SCRIPT: ', this.INSERT_OR_UPDATE_USER); 77 | } 78 | 79 | async close() { 80 | try { 81 | await this.db.close(); 82 | } catch (e) { 83 | console.warn('Failed to close db.', e); 84 | } 85 | } 86 | 87 | async init(arg: string | Database) { 88 | if (typeof arg === 'string') { 89 | this.db = await open({ 90 | filename: arg, 91 | driver: sqlite3.Database 92 | }); 93 | } else { 94 | this.db = arg; 95 | } 96 | 97 | await this.db.run(` 98 | CREATE TABLE IF NOT EXISTS transactions( 99 | id INTEGER PRIMARY KEY AUTOINCREMENT, 100 | hash VARCHAR NOT NULL, 101 | utime TIMESTAMP NOT NULL 102 | ) 103 | `); 104 | 105 | await this.db.run(this.CREATE_USERS); 106 | 107 | await this.db.run(` 108 | CREATE TABLE IF NOT EXISTS liquidation_tasks( 109 | id INTEGER PRIMARY KEY AUTOINCREMENT, 110 | wallet_address VARCHAR NOT NULL, 111 | contract_address VARCHAR NOT NULL, 112 | subaccountId VARCHAR NOT NULL, 113 | created_at TIMESTAMP NOT NULL, 114 | updated_at TIMESTAMP NOT NULL, 115 | loan_asset VARCHAR NOT NULL, 116 | collateral_asset VARCHAR NOT NULL, 117 | liquidation_amount VARCHAR NOT NULL, 118 | min_collateral_amount VARCHAR NOT NULL, 119 | prices_cell TEXT NOT NULL, 120 | query_id VARCHAR NOT NULL UNIQUE, 121 | state VARCHAR NOT NULL DEFAULT 'pending' 122 | ) 123 | `) 124 | 125 | await this.db.run(` 126 | CREATE TABLE IF NOT EXISTS swap_tasks( 127 | id INTEGER PRIMARY KEY AUTOINCREMENT, 128 | created_at TIMESTAMP NOT NULL, 129 | updated_at TIMESTAMP NOT NULL, 130 | token_offer VARCHAR NOT NULL, 131 | token_ask VARCHAR NOT NULL, 132 | swap_amount VARCHAR NOT NULL, 133 | query_id VARCHAR NOT NULL DEFAULT '0', 134 | route_id VARCHAR NOT NULL DEFAULT '0', 135 | state VARCHAR NOT NULL DEFAULT 'pending', 136 | status INTEGER NOT NULL DEFAULT 0, 137 | prices_cell VARCHAR NOT NULL DEFAULT '' 138 | ) 139 | `); 140 | // no prices ('') means that value check will not be done 141 | 142 | await this.db.run(` 143 | CREATE TABLE IF NOT EXISTS swap_tasks( 144 | id INTEGER PRIMARY KEY AUTOINCREMENT, 145 | created_at TIMESTAMP NOT NULL, 146 | updated_at TIMESTAMP NOT NULL, 147 | token_offer VARCHAR NOT NULL, 148 | token_ask VARCHAR NOT NULL, 149 | swap_amount VARCHAR NOT NULL, 150 | query_id VARCHAR NOT NULL DEFAULT '0', 151 | route_id VARCHAR NOT NULL DEFAULT '0', 152 | state VARCHAR NOT NULL DEFAULT 'pending', 153 | status INTEGER NOT NULL DEFAULT 0, 154 | prices_cell VARCHAR NOT NULL DEFAULT '' 155 | )` 156 | ); 157 | } 158 | 159 | async addTransaction(hash: string, utime: number) { 160 | await retry(async () => { 161 | await this.db.run( 162 | `INSERT INTO transactions(hash, utime) VALUES(?, ?)`, 163 | hash, utime 164 | ); 165 | }, DATABASE_DEFAULT_RETRY_OPTIONS); 166 | } 167 | 168 | async isTxExists(hash: string) { 169 | const res = await retry(async (): Promise => { 170 | const result = await this.db.get(`SELECT * FROM transactions WHERE hash = ?`, hash) 171 | return !!result 172 | }, DATABASE_DEFAULT_RETRY_OPTIONS); 173 | if (!res.ok) throw (`Failed to check tx, db error`); 174 | 175 | return res.value; 176 | } 177 | 178 | mapPrincipals(principals: PrincipalsDict) { 179 | return this.ASSET_IDS.map(id => (principals.get(id) ?? 0n).toString()); 180 | } 181 | 182 | async getUser(contract_address: string): Promise { 183 | const result = await retry(async () => { 184 | return await this.db.get( 185 | `SELECT * FROM users WHERE contract_address = ?`, 186 | contract_address 187 | ); 188 | }, DATABASE_DEFAULT_RETRY_OPTIONS); 189 | 190 | if (!result.ok) throw (`Failed to get user, problem with db`); 191 | if (!result.value) return undefined; 192 | 193 | const row = result.value; 194 | const principals = this.fetchDbPrincipals(row); 195 | const { 196 | id, 197 | wallet_address, 198 | subaccountId, 199 | code_version, 200 | created_at, 201 | updated_at, 202 | actualized_at, 203 | state, 204 | } = row; 205 | return { 206 | id, wallet_address, contract_address, subaccountId, 207 | code_version, created_at, updated_at, actualized_at, 208 | principals, state, 209 | } 210 | } 211 | 212 | async updateUserTime(contract_address: string, created_at: number, updated_at: number) { 213 | await retry(async () => { 214 | await this.db.run(` 215 | UPDATE users 216 | SET created_at = CASE WHEN created_at > ? THEN ? ELSE created_at END, 217 | updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END 218 | WHERE contract_address = ? 219 | `, created_at, created_at, updated_at, updated_at, contract_address) 220 | }, DATABASE_DEFAULT_RETRY_OPTIONS); 221 | } 222 | 223 | async insertOrUpdateUser(user: User) { 224 | const _principals = this.mapPrincipals(user.principals); 225 | const insertParameters = [ 226 | user.wallet_address, user.contract_address, user.subaccountId, 227 | user.code_version, user.created_at, user.updated_at, user.actualized_at, 228 | ..._principals 229 | ]; 230 | 231 | const baseUpdateParameters = [ 232 | user.actualized_at, user.code_version, // code version 233 | user.created_at, user.created_at, // created time 234 | user.updated_at, user.updated_at, // updated time 235 | user.actualized_at, user.actualized_at, // actualized time 236 | ]; 237 | 238 | const principalUpdateParameters = this.ASSET_IDS.map( 239 | asset_id => [user.actualized_at, (user.principals.get(asset_id) ?? 0n).toString()] 240 | ).flat(); 241 | 242 | const parameters = [ 243 | insertParameters, 244 | baseUpdateParameters, 245 | principalUpdateParameters, 246 | ].flat(); 247 | 248 | await this.db.run(this.INSERT_OR_UPDATE_USER, ...parameters); 249 | } 250 | 251 | async getUsers() { 252 | const result = await retry(async () => 253 | await this.db.all(`SELECT * FROM users WHERE state = 'active'`) 254 | , DATABASE_DEFAULT_RETRY_OPTIONS 255 | ); 256 | 257 | if (!result.ok) throw (`Failed to get users, problem with db`); 258 | 259 | const users: User[] = []; 260 | for (const row of result.value) { 261 | const principals = this.fetchDbPrincipals(row); 262 | const { 263 | id, 264 | wallet_address, 265 | contract_address, 266 | subaccountId, 267 | code_version, 268 | created_at, 269 | actualized_at, 270 | updated_at, 271 | state 272 | } = row; 273 | users.push({ 274 | id, wallet_address, contract_address, subaccountId, 275 | code_version, created_at, updated_at, actualized_at, 276 | principals, state, 277 | }); 278 | } 279 | 280 | return users; 281 | } 282 | 283 | async addTask(walletAddress: string, contractAddress: string, subaccountId: number, createdAt: number, loanAsset: bigint, 284 | collateralAsset: bigint, liquidationAmount: bigint, minCollateralAmount: bigint, 285 | pricesCell: string, queryID: bigint) { 286 | await this.db.run(` 287 | INSERT INTO liquidation_tasks(wallet_address, contract_address, subaccountId, created_at, updated_at, loan_asset, 288 | collateral_asset, liquidation_amount, min_collateral_amount, prices_cell, query_id 289 | ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 290 | walletAddress, contractAddress, subaccountId.toString(), createdAt, createdAt, 291 | loanAsset.toString(), collateralAsset.toString(), liquidationAmount.toString(), minCollateralAmount.toString(), 292 | pricesCell, queryID.toString() 293 | ) 294 | } 295 | 296 | async getTasks(max: number): Promise { 297 | const result = await retry(async () => { 298 | return await this.db.all( 299 | `SELECT * FROM liquidation_tasks WHERE state = 'pending' LIMIT ?`, max 300 | ); 301 | }, DATABASE_DEFAULT_RETRY_OPTIONS); 302 | 303 | if (!result.ok) throw (`Failed to get tasks from db`); 304 | if (!result.value) return undefined; 305 | 306 | const tasks: Task[] = []; 307 | for (const row of result.value) { 308 | tasks.push({ 309 | id: row.id, 310 | wallet_address: row.wallet_address, 311 | contract_address: row.contract_address, 312 | subaccountId: row.subaccountId, 313 | created_at: row.created_at, 314 | updated_at: row.updated_at, 315 | loan_asset: BigInt(row.loan_asset), 316 | collateral_asset: BigInt(row.collateral_asset), 317 | liquidation_amount: BigInt(row.liquidation_amount), 318 | min_collateral_amount: BigInt(row.min_collateral_amount), 319 | prices_cell: row.prices_cell, 320 | query_id: BigInt(row.query_id), 321 | state: row.state 322 | }); 323 | } 324 | 325 | return tasks; 326 | } 327 | 328 | async takeTask(id: number) { 329 | const res = await retry(async () => await this.db.run(` 330 | UPDATE liquidation_tasks SET state = 'processing', updated_at = ? WHERE id = ?`, Date.now(), id), 331 | DATABASE_DEFAULT_RETRY_OPTIONS 332 | ); 333 | if (!res.ok) throw (`Failed to update task status to 'processing'`); 334 | } 335 | 336 | async liquidateSent(id: number) { 337 | const res = await retry(async () => await this.db.run(` 338 | UPDATE liquidation_tasks SET state = 'sent', updated_at = ? WHERE id = ? `, Date.now(), id), 339 | DATABASE_DEFAULT_RETRY_OPTIONS); 340 | if (!res.ok) throw (`Failed to update task status to 'sent'`); 341 | } 342 | 343 | async liquidateSuccess(queryID: bigint) { 344 | const res = await retry(async () => await this.db.run(` 345 | UPDATE liquidation_tasks SET state = 'success', updated_at = ? WHERE query_id = ? `, 346 | Date.now(), queryID.toString() 347 | ), DATABASE_DEFAULT_RETRY_OPTIONS); 348 | if (!res.ok) throw (`Failed to update task status to 'success'`); 349 | } 350 | 351 | async getTask(queryID: bigint): Promise { 352 | const result = await retry( 353 | async () => await this.db.get( 354 | `SELECT * FROM liquidation_tasks WHERE query_id = ?`, queryID.toString() 355 | ), DATABASE_DEFAULT_RETRY_OPTIONS); 356 | 357 | if (!result.ok) throw (`Failed to get task from db`); 358 | 359 | const task = result.value; 360 | if (!task) return undefined; 361 | 362 | return { 363 | id: task.id, 364 | wallet_address: task.wallet_address, 365 | contract_address: task.contract_address, 366 | subaccountId: task.subaccountId, 367 | created_at: task.created_at, 368 | updated_at: task.updated_at, 369 | loan_asset: BigInt(task.loan_asset), 370 | collateral_asset: BigInt(task.collateral_asset), 371 | liquidation_amount: BigInt(task.liquidation_amount), 372 | min_collateral_amount: BigInt(task.min_collateral_amount), 373 | prices_cell: task.prices_cell, 374 | query_id: BigInt(task.query_id), 375 | state: task.state 376 | }; 377 | } 378 | 379 | async handleFailedTasks() { 380 | const oldSentTasksRes = await retry( 381 | async () => await this.db.all(` 382 | UPDATE liquidation_tasks 383 | SET state = 'failed', updated_at = ? 384 | WHERE state in ('sent') AND ? - updated_at > ? 385 | `, Date.now(), Date.now(), TTL.sent), 386 | DATABASE_DEFAULT_RETRY_OPTIONS 387 | ); 388 | if (!oldSentTasksRes.ok) { 389 | // not critical, just continue working 390 | console.log('FAILED TO HANDLE OLD SENT TASKS'); 391 | } 392 | 393 | const oldProcessingTasksRes = await retry( 394 | async () => await this.db.run(` 395 | UPDATE liquidation_tasks 396 | SET state = 'failed', updated_at = ? 397 | WHERE state in ('processing') AND ? - updated_at > ? 398 | `, Date.now(), Date.now(), TTL.processing), 399 | DATABASE_DEFAULT_RETRY_OPTIONS 400 | ); 401 | if (!oldProcessingTasksRes.ok) { 402 | // not critical, just continue working 403 | console.log('FAILED TO HANDLE OLD SENT TASKS'); 404 | } 405 | } 406 | 407 | async blacklistUser(walletAddress: string) : Promise { 408 | const res = await retry(async () => await this.db.all(` 409 | UPDATE users SET state = 'blacklist' WHERE users.wallet_address = ? 410 | `, walletAddress), DATABASE_DEFAULT_RETRY_OPTIONS); 411 | return res.ok; 412 | } 413 | 414 | async isTaskExists(walletAddress: string) { 415 | const now = Date.now(); 416 | const result = await this.db.get(` 417 | SELECT * FROM liquidation_tasks 418 | WHERE 419 | (wallet_address = ? AND state = 'pending' AND ? - updated_at < ${TTL.pending}) OR 420 | (wallet_address = ? AND state = 'processing' AND ? - updated_at < ${TTL.processing}) OR 421 | (wallet_address = ? AND state = 'sent' AND ? - updated_at < ${TTL.sent}) OR 422 | (wallet_address = ? AND state = 'success' AND ? - updated_at < ${TTL.success}) OR 423 | (wallet_address = ? AND state = 'unsatisfied' AND ? - updated_at < ${TTL.unsatisfied}) OR 424 | (wallet_address = ? AND state = 'insufficient_balance' AND ? - updated_at < ${TTL.insufficient_balance}) 425 | `, walletAddress, now, walletAddress, now, walletAddress, now, 426 | walletAddress, now, walletAddress, now, walletAddress, now 427 | ); 428 | return !!result; 429 | } 430 | 431 | async cancelOldTasks() { 432 | await this.db.run(` 433 | UPDATE liquidation_tasks 434 | SET state = 'cancelled', updated_at = ? 435 | WHERE state = 'pending' AND ? - created_at > ? 436 | `, Date.now(), Date.now(), TTL.pending) // 30sec -> old 437 | } 438 | 439 | async cancelTask(taskId: number) { 440 | await this.db.run(` 441 | UPDATE liquidation_tasks 442 | SET state = 'cancelled', updated_at = ?, 443 | WHERE id = ? 444 | `, Date.now(), taskId) 445 | } 446 | 447 | async cancelTaskNoBalance(id: number) { 448 | await this.db.run(` 449 | UPDATE liquidation_tasks 450 | SET state = 'insufficient_balance', updated_at = ? 451 | WHERE id = ? 452 | `, Date.now(), id) 453 | } 454 | 455 | async deleteOldTasks() { 456 | await this.db.run(` 457 | DELETE FROM liquidation_tasks 458 | WHERE ? - created_at >= 60 * 60 * 24 * 7 * 1000 459 | `, Date.now()) 460 | } 461 | 462 | async unsatisfyTask(queryID: bigint) { 463 | await this.db.run(` 464 | UPDATE liquidation_tasks 465 | SET state = 'unsatisfied', updated_at = ? 466 | WHERE query_id = ? 467 | `, Date.now(), queryID.toString()) 468 | } 469 | 470 | async addSwapTask(createdAt: number, tokenOffer: bigint, tokenAsk: bigint, swapAmount: bigint, pricesCell: string) { 471 | await this.db.run(`INSERT INTO swap_tasks 472 | (created_at, updated_at, token_offer, token_ask, swap_amount, prices_cell) 473 | VALUES(?, ?, ?, ?, ?, ?)`, 474 | createdAt, createdAt, tokenOffer.toString(), tokenAsk.toString(), swapAmount.toString(), pricesCell) 475 | } 476 | } 477 | -------------------------------------------------------------------------------- /src/services/indexer/indexer.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosInstance, AxiosResponse } from "axios"; 2 | import type { User } from "../../db/types"; 3 | import { 4 | ASSET_ID, 5 | type EvaaMasterClassic, 6 | type EvaaMasterPyth, 7 | EvaaUser, 8 | } from "@evaafi/sdk"; 9 | import { Address } from "@ton/core"; 10 | import { 11 | Cell, 12 | type Dictionary, 13 | type OpenedContract, 14 | type TonClient, 15 | } from "@ton/ton"; 16 | import { 17 | EVAA_CONTRACT_VERSIONS_MAP, 18 | JETTON_WALLETS, 19 | RPC_CALL_DELAY, 20 | TX_PROCESS_DELAY, 21 | USER_UPDATE_DELAY, 22 | } from "../../config"; 23 | import { 24 | DATABASE_DEFAULT_RETRY_OPTIONS, 25 | type MyDatabase, 26 | } from "../../db/database"; 27 | import { getBalances } from "../../lib/balances"; 28 | import { logMessage, type Messenger } from "../../lib/messenger"; 29 | import { getAddressFriendly, getFriendlyAmount } from "../../util/format"; 30 | import { unpackPrices } from "../../util/prices"; 31 | import { sleep } from "../../util/process"; 32 | import { retry } from "../../util/retry"; 33 | import { 34 | checkEligibleSwapTask, 35 | DelayedCallDispatcher, 36 | formatLiquidationSuccess, 37 | formatLiquidationUnsatisfied, 38 | formatSwapAssignedMessage, 39 | formatSwapCanceledMessage, 40 | getAssetInfo, 41 | getErrorDescription, 42 | makeGetAccountTransactionsRequest, 43 | OP_CODE_V4, 44 | OP_CODE_V9, 45 | parseSatisfiedTxMsg, 46 | parseUnsatisfiedTxMsg, 47 | } from "./helpers"; 48 | 49 | export async function getTransactionsBatch( 50 | tonApi: AxiosInstance, 51 | bot: Messenger, 52 | evaaMaster: Address, 53 | before_lt: number, 54 | ): Promise> { 55 | let attempts = 0; 56 | while (true) { 57 | try { 58 | const request = makeGetAccountTransactionsRequest(evaaMaster, before_lt); 59 | const res = await tonApi.get(request); 60 | attempts = 0; 61 | return res; 62 | } catch (e) { 63 | attempts++; 64 | if (attempts > 3) { 65 | await bot.sendMessage(`🚨🚨🚨 Unknown problem with TonAPI 🚨🚨🚨`); 66 | console.log(e); 67 | await sleep(10000); 68 | attempts = 0; 69 | } else { 70 | await sleep(1000); 71 | } 72 | } 73 | } 74 | } 75 | 76 | export async function handleTransactions( 77 | db: MyDatabase, 78 | tonApi: AxiosInstance, 79 | tonClient: TonClient, 80 | messenger: Messenger, 81 | evaa: OpenedContract, 82 | walletAddress: Address, 83 | sync = false, 84 | ) { 85 | const dispatcher = new DelayedCallDispatcher(RPC_CALL_DELAY); 86 | 87 | let before_lt = 0; 88 | while (true) { 89 | const batchResult = await getTransactionsBatch( 90 | tonApi, 91 | messenger, 92 | evaa.address, 93 | before_lt, 94 | ); 95 | const transactions = batchResult?.data?.transactions; 96 | if (!Array.isArray(transactions) || transactions.length === 0) break; 97 | const firstTxExists = await db.isTxExists(transactions[0].hash); 98 | if (firstTxExists) { 99 | if (sync) break; 100 | if (before_lt !== 0) { 101 | logMessage( 102 | `Indexer: Resetting before_lt to 0. Before lt was: ${before_lt}`, 103 | ); 104 | before_lt = 0; 105 | } 106 | await sleep(1000); 107 | continue; 108 | } 109 | 110 | for (const tx of transactions) { 111 | await sleep(TX_PROCESS_DELAY); 112 | const hash = tx.hash; 113 | const utime = tx.utime * 1000; 114 | const result = await db.isTxExists(hash); 115 | if (result) continue; 116 | await db.addTransaction(hash, utime); 117 | before_lt = tx.lt; 118 | 119 | const _op = tx["in_msg"]["op_code"] ? tx["in_msg"]["op_code"] : undefined; 120 | if (_op === undefined) continue; 121 | const op = parseInt(_op); 122 | let userContractAddress: Address; 123 | const evaaContractVersion = EVAA_CONTRACT_VERSIONS_MAP.get( 124 | evaa.poolConfig.masterAddress, 125 | ); 126 | if (!evaaContractVersion) { 127 | throw new Error( 128 | `Indexer: No version mapping found for master address ${evaa.poolConfig.masterAddress}`, 129 | ); 130 | } 131 | 132 | if ( 133 | before_lt >= evaaContractVersion.v4_upgrade_lt && 134 | before_lt < evaaContractVersion.v9_upgrade_lt 135 | ) { 136 | if ( 137 | op === OP_CODE_V4.MASTER_SUPPLY || 138 | op === OP_CODE_V4.MASTER_WITHDRAW || 139 | op === OP_CODE_V4.MASTER_LIQUIDATE || 140 | op === OP_CODE_V4.JETTON_TRANSFER_NOTIFICATION || 141 | op === OP_CODE_V4.DEBUG_PRINCIPALS 142 | ) { 143 | if (!(tx.compute_phase.success === true)) continue; 144 | 145 | const outMsgs = tx.out_msgs; 146 | if (outMsgs.length !== 1) continue; 147 | userContractAddress = Address.parseRaw( 148 | outMsgs[0].destination.address, 149 | ); 150 | 151 | if (op === OP_CODE_V4.JETTON_TRANSFER_NOTIFICATION) { 152 | const inAddress = Address.parseRaw(tx.in_msg.source.address); 153 | if (inAddress.equals(userContractAddress)) { 154 | logMessage( 155 | `Indexer: Contract ${getAddressFriendly( 156 | userContractAddress, 157 | )} is not a user contract`, 158 | ); 159 | continue; 160 | } 161 | } 162 | } else if ( 163 | op === OP_CODE_V4.MASTER_SUPPLY_SUCCESS || 164 | op === OP_CODE_V4.MASTER_WITHDRAW_COLLATERALIZED || 165 | op === OP_CODE_V4.MASTER_LIQUIDATE_SATISFIED || 166 | op == OP_CODE_V4.MASTER_LIQUIDATE_UNSATISFIED 167 | ) { 168 | if (!(tx.compute_phase.success === true)) continue; 169 | 170 | userContractAddress = Address.parseRaw(tx.in_msg.source.address); 171 | if (op === OP_CODE_V4.MASTER_LIQUIDATE_SATISFIED) { 172 | tx.out_msgs.sort((a, b) => a.created_lt - b.created_lt); 173 | const report = tx.out_msgs[0]; 174 | if (!report) { 175 | throw new Error(`Report is undefined for transaction ${hash}`); 176 | } 177 | const bodySlice = Cell.fromBoc( 178 | Buffer.from(report["raw_body"], "hex"), 179 | )[0].beginParse(); 180 | bodySlice.loadCoins(); // contract version 181 | bodySlice.loadMaybeRef(); // upgrade info 182 | bodySlice.loadInt(2); // upgrade exec 183 | const reportOp = bodySlice.loadUint(32); 184 | if (reportOp != OP_CODE_V4.USER_LIQUIDATE_SUCCESS) { 185 | logMessage(`Indexer: ${reportOp.toString(16)}`); 186 | logMessage( 187 | `Indexer: Report op is not 0x331a for transaction ${hash}`, 188 | ); 189 | } 190 | const queryID = bodySlice.loadUintBig(64); 191 | const task = await db.getTask(queryID); 192 | if (task !== undefined) { 193 | await db.liquidateSuccess(queryID); 194 | logMessage( 195 | `Indexer: Liquidation task (Query ID: ${queryID}) successfully completed`, 196 | ); 197 | 198 | const loanAsset = getAssetInfo(task.loan_asset, evaa); 199 | const collateralAsset = getAssetInfo(task.collateral_asset, evaa); 200 | 201 | const satisfiedTx = Cell.fromBoc( 202 | Buffer.from(tx["in_msg"]["raw_body"], "hex"), 203 | )[0].beginParse(); 204 | let parsedTx; 205 | try { 206 | parsedTx = parseSatisfiedTxMsg(satisfiedTx); 207 | } catch (parseErr) { 208 | const errorMessage = 209 | parseErr instanceof Error 210 | ? parseErr.message 211 | : String(parseErr); 212 | logMessage( 213 | `Indexer: Tx ${hash}: parseSatisfiedTxMsg failed, ignoring. Error: ${errorMessage}`, 214 | ); 215 | messenger.sendMessage( 216 | `🚨🚨🚨 Tx ${hash}: parseSatisfiedTxMsg failed, ignoring. Error: ${errorMessage} 🚨🚨🚨`, 217 | ); 218 | continue; 219 | } 220 | const prices: Dictionary = unpackPrices( 221 | Cell.fromBase64(task.prices_cell), 222 | ); 223 | 224 | const assetIds = evaa.poolConfig.poolAssetsConfig 225 | .filter((it) => it.assetId !== ASSET_ID.TON) 226 | .map((it) => it.assetId); 227 | 228 | const liquidatorBalances = await getBalances( 229 | tonClient, 230 | walletAddress, 231 | assetIds, 232 | JETTON_WALLETS, 233 | ); 234 | const localTime = new Date(utime); 235 | await messenger.sendMessage( 236 | formatLiquidationSuccess( 237 | task, 238 | loanAsset, 239 | collateralAsset, 240 | parsedTx, 241 | hash, 242 | localTime, 243 | evaa.address, 244 | liquidatorBalances, 245 | evaa.data.assetsConfig, 246 | evaa.poolConfig.poolAssetsConfig, 247 | prices, 248 | ), 249 | ); 250 | 251 | const skipCheck = false; 252 | let shouldSwap = skipCheck; 253 | if (!skipCheck) { 254 | shouldSwap = await checkEligibleSwapTask( 255 | task.collateral_asset, 256 | parsedTx.collateralRewardAmount, 257 | task.loan_asset, 258 | evaa.data.assetsConfig, 259 | prices, 260 | evaa.poolConfig, 261 | ); 262 | } 263 | 264 | if (shouldSwap) { 265 | // swapper will check it 266 | await db.addSwapTask( 267 | Date.now(), 268 | task.collateral_asset, 269 | task.loan_asset, 270 | parsedTx.collateralRewardAmount, 271 | task.prices_cell, 272 | ); 273 | await messenger.sendMessage( 274 | formatSwapAssignedMessage( 275 | loanAsset, 276 | collateralAsset, 277 | parsedTx.collateralRewardAmount, 278 | ), 279 | ); 280 | } else { 281 | await messenger.sendMessage( 282 | formatSwapCanceledMessage( 283 | loanAsset, 284 | collateralAsset, 285 | parsedTx.collateralRewardAmount, 286 | ), 287 | ); 288 | } 289 | } 290 | } else if (op === OP_CODE_V4.MASTER_LIQUIDATE_UNSATISFIED) { 291 | const unsatisfiedTx = Cell.fromBoc( 292 | Buffer.from(tx["in_msg"]["raw_body"], "hex"), 293 | )[0].beginParse(); 294 | let parsedTx; 295 | try { 296 | parsedTx = parseUnsatisfiedTxMsg(unsatisfiedTx); 297 | } catch (parseErr) { 298 | const errorMessage = 299 | parseErr instanceof Error ? parseErr.message : String(parseErr); 300 | logMessage( 301 | `Indexer: Tx ${hash}: parseUnsatisfiedTxMsg failed, ignoring. Error: ${errorMessage}`, 302 | ); 303 | await messenger.sendMessage( 304 | `🚨🚨🚨 Tx ${hash}: parseUnsatisfiedTxMsg failed, ignoring. Error: ${errorMessage} 🚨🚨🚨`, 305 | ); 306 | continue; 307 | } 308 | 309 | const task = await db.getTask(parsedTx.queryID); 310 | if (task !== undefined) { 311 | await db.unsatisfyTask(parsedTx.queryID); 312 | 313 | const transferredInfo = getAssetInfo( 314 | parsedTx.transferredAssetID, 315 | evaa, 316 | ); 317 | const collateralInfo = getAssetInfo( 318 | parsedTx.collateralAssetID, 319 | evaa, 320 | ); 321 | 322 | console.log("\n----- Unsatisfied liquidation task -----\n"); 323 | const unsatisfiedDescription = formatLiquidationUnsatisfied( 324 | task, 325 | transferredInfo, 326 | collateralInfo, 327 | parsedTx.transferredAmount, 328 | evaa.address, 329 | parsedTx.liquidatorAddress, 330 | ); 331 | logMessage(unsatisfiedDescription); 332 | 333 | const errorDescription = getErrorDescription( 334 | parsedTx.error.errorCode, 335 | ); 336 | 337 | logMessage(`Indexer: Error: ${errorDescription}`); 338 | const errorType = parsedTx.error.type; 339 | if (errorType === "MasterLiquidatingTooMuchError") { 340 | logMessage(`Query ID: ${parsedTx.queryID}`); 341 | logMessage( 342 | `Max allowed liquidation: ${parsedTx.error.maxAllowedLiquidation}`, 343 | ); 344 | } else if (errorType === "UserWithdrawInProgressError") { 345 | await messenger.sendMessage( 346 | `🚨🚨🚨 Liquidation failed. User ${getAddressFriendly( 347 | parsedTx.userAddress, 348 | )} withdraw in process 🚨🚨🚨`, 349 | ); 350 | } else if (errorType === "NotLiquidatableError") { 351 | // error message already logged 352 | } else if (errorType === "MinCollateralNotSatisfiedError") { 353 | logMessage( 354 | `Collateral amount: ${getFriendlyAmount( 355 | parsedTx.error.minCollateralAmount, 356 | collateralInfo.decimals, 357 | collateralInfo.name, 358 | )}`, 359 | ); 360 | } else if (errorType === "UserNotEnoughCollateralError") { 361 | logMessage( 362 | `Collateral present: ${getFriendlyAmount( 363 | parsedTx.error.collateralPresent, 364 | collateralInfo.decimals, 365 | collateralInfo.name, 366 | )}`, 367 | ); 368 | } else if (errorType === "UserLiquidatingTooMuchError") { 369 | logMessage( 370 | `Max not too much: ${parsedTx.error.maxNotTooMuchValue}`, 371 | ); 372 | } else if (errorType === "MasterNotEnoughLiquidityError") { 373 | logMessage( 374 | `Available liquidity: ${parsedTx.error.availableLiquidity}`, 375 | ); 376 | } else if (errorType === "LiquidationPricesMissing") { 377 | // error message already logged 378 | } 379 | 380 | console.log("\n----- Unsatisfied liquidation task -----\n"); 381 | } 382 | } 383 | } else { 384 | continue; 385 | } 386 | } 387 | 388 | let subaccountId = 0; 389 | let userAddress: Address = null; 390 | if (before_lt >= evaaContractVersion.v9_upgrade_lt) { 391 | for (const outMsg of tx.out_msgs) { 392 | if (outMsg.msg_type == "ext_out_msg") { 393 | const intMsgSlice = Cell.fromBoc( 394 | Buffer.from(outMsg.raw_body, "hex"), 395 | )[0].beginParse(); 396 | 397 | const logOpCode = intMsgSlice.loadUint(8); 398 | 399 | // Parse subaccount_id from log messages (v9+) 400 | // Log structure: 401 | // - 8 bits: log op code 402 | // - slice: owner_address 403 | // - slice: sender_address (user sc addr) 404 | // - [optional slice for liquidate/withdraw: liquidator_address or recipient_address] 405 | // - 32 bits: timestamp 406 | // - 16 bits: subaccount_id (signed int) 407 | if ( 408 | logOpCode == OP_CODE_V9.MASTER_SUPPLY || // log::supply_success = 0x1 409 | logOpCode == OP_CODE_V9.MASTER_LIQUIDATE || // log::liquidate_success = 0x3 410 | logOpCode == OP_CODE_V9.SUPPLY_WITHDRAW_SUCCESS // log::supply_withdraw_success = 0x16 411 | ) { 412 | userAddress = intMsgSlice.loadAddress(); // owner_address 413 | userContractAddress = intMsgSlice.loadAddress(); // sender_address (user sc addr) 414 | 415 | // For liquidate and supply_withdraw, there's a third address 416 | if ( 417 | logOpCode == OP_CODE_V9.SUPPLY_WITHDRAW_SUCCESS || 418 | logOpCode == OP_CODE_V9.MASTER_LIQUIDATE 419 | ) { 420 | intMsgSlice.loadAddress(); // liquidator or SW recipient address 421 | } 422 | 423 | // Skip timestamp (32 bits) 424 | intMsgSlice.loadUint(32); 425 | 426 | // Load subaccount_id (16 bits, signed) 427 | subaccountId = intMsgSlice.loadInt(16); 428 | 429 | logMessage( 430 | `Indexer: logOpCode: 0x${logOpCode.toString(16)}, userAddress: ${userAddress}, userContractAddress: ${userContractAddress}, Subaccount ID: ${subaccountId}`, 431 | ); 432 | } 433 | } 434 | } 435 | } 436 | 437 | if (!userContractAddress) continue; 438 | const delay = 439 | Date.now() >= utime + USER_UPDATE_DELAY ? 0 : USER_UPDATE_DELAY; 440 | setTimeout(async () => { 441 | const userContractFriendly = getAddressFriendly(userContractAddress); 442 | const user = await db.getUser(userContractFriendly); 443 | if (user && user.updated_at > utime) { 444 | logMessage( 445 | `Indexer: Update user time for contract ${userContractFriendly}`, 446 | ); 447 | await db.updateUserTime(userContractFriendly, utime, utime); 448 | // console.log(`Contract ${getAddressFriendly(userContractAddress)} updated (time)`); 449 | return; 450 | } 451 | 452 | const openedUserContract = tonClient.open( 453 | EvaaUser.createFromAddress(userContractAddress, evaa.poolConfig), 454 | ); 455 | const res = await retry( 456 | async () => { 457 | await dispatcher.makeCall(async () => { 458 | logMessage(`Indexer: syncing user ${userContractFriendly}`); 459 | return await openedUserContract.getSyncLite( 460 | evaa.data.assetsData, 461 | evaa.data.assetsConfig, 462 | ); 463 | }); 464 | }, 465 | { attempts: 10, attemptInterval: 2000 }, 466 | ); 467 | 468 | if (!res.ok) { 469 | logMessage( 470 | `Indexer: Problem with TonClient. Reindex is needed. User contract: ${userContractFriendly}`, 471 | ); 472 | await messenger.sendMessage( 473 | [ 474 | `🚨🚨🚨 Problem with TonClient. Reindex is needed 🚨🚨🚨`, 475 | `🚨🚨🚨 Problem with user contract ${userContractFriendly} 🚨🚨🚨`, 476 | ].join("\n"), 477 | ); 478 | return; 479 | } 480 | 481 | if (openedUserContract.liteData.type != "active") { 482 | logMessage(`Indexer: User ${userContractFriendly} is not active!`); 483 | return; 484 | } 485 | 486 | const { 487 | codeVersion, 488 | ownerAddress: userAddress, 489 | principals, 490 | } = openedUserContract.liteData; 491 | 492 | const actualUser: User = { 493 | id: 0, 494 | wallet_address: 495 | user?.wallet_address ?? getAddressFriendly(userAddress), 496 | contract_address: user?.contract_address ?? userContractFriendly, 497 | subaccountId: 498 | subaccountId !== 0 ? subaccountId : (user?.subaccountId ?? 0), // backward compability for old EVAA tx's 499 | code_version: codeVersion, 500 | created_at: Math.min(utime, user?.created_at ?? Date.now()), 501 | updated_at: Math.max(utime, user?.updated_at ?? 0), 502 | actualized_at: Date.now(), 503 | principals: principals, 504 | state: "active", 505 | }; 506 | 507 | const userRes = await retry( 508 | async () => await db.insertOrUpdateUser(actualUser), 509 | DATABASE_DEFAULT_RETRY_OPTIONS, 510 | ); 511 | 512 | if (!userRes.ok) { 513 | const message = `Indexer: Failed to actualize user ${userContractFriendly}`; 514 | logMessage(message); 515 | await messenger.sendMessage(message); 516 | } 517 | }, delay); 518 | } 519 | 520 | logMessage(`Indexer: Before lt: ${before_lt}`); 521 | await sleep(1500); 522 | } 523 | } 524 | --------------------------------------------------------------------------------