diff --git a/Readme.md b/Readme.md index 0c44551..dcb4cd7 100644 --- a/Readme.md +++ b/Readme.md @@ -12,7 +12,7 @@ git clone https://github.com/eq-lab/slender-keeper.git cd slender-keeper yarn install ``` -3. **Configuration**: Configure the service by setting envs (see below) or by updating the necessary parameters in the `src/consts.ts` file. +3. **Configuration**: Configure the service by setting envs (see below) or by updating the necessary parameters in the `src/configuration.ts` file. 4. **Running the Service**: Start the service by running the following command: ```bash yarn start @@ -26,14 +26,15 @@ This service is designed to automate the process of monitoring of borrowers posi - If the liquidator has sufficient balances, it initiates the liquidation process for the borrower. - The service handles liquidation errors and updates the database with borrowers accordingly. ## Configuration -To configure the service for your specific environment, you'll need to set the following env or set values in the `src/consts.ts` file: -`CONTRACT_CREATION_LEDGER` - ledget at which Slender pool was created\ +To configure the service for your specific environment, you'll need to set the following env or set values in the `src/configuration.ts` file: +`CONTRACT_CREATION_LEDGER` - ledger number at which Slender pool was created\ `POOL_ID` - Slender pool address\ +`POOL_ASSETS` - comma separated list of pool asset addresses\ `XLM_NATIVE` - address of XLM contract\ `SOROBAN_URL` - Sorban RPC URL\ `HORIZON_URL` - Horizon RPC URL\ `NETWORK_PASSPHRASE` - Soroban passphrase\ `LIQUIDATOR_ADDRESS` - liquidator's account address\ -`LIQUIDATOR_SECRET` - liquidator's secret key +`LIQUIDATOR_SECRET` - liquidator's secret key\ ## License This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/package.json b/package.json index 00dd286..6ff279f 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,8 @@ "main": "dist/index.js", "dependencies": { "better-sqlite3": "^8.6.0", - "bigint-conversion": "^2.4.1", - "soroban-client": "1.0.0-beta.2", - "stellar-base": "10.0.0-beta.1", - "stellar-sdk": "11.0.0-beta.3" + "bigint-conversion": "^2.4.3", + "@stellar/stellar-sdk": "^11.2.1" }, "scripts": { "start": "yarn build & node dist/index.js", diff --git a/src/consts.ts b/src/configuration.ts similarity index 57% rename from src/consts.ts rename to src/configuration.ts index 5592e25..8adf013 100644 --- a/src/consts.ts +++ b/src/configuration.ts @@ -1,8 +1,9 @@ export const POOL_PRECISION_FACTOR = 1_000_000_000; -export const CONTRACT_CREATION_LEDGER = process.env.CONTRACT_CREATION_LEDGER || 849500; -export const POOL_ID = process.env.POOL_ID || "CAVSCGJKXNS5UW25N4FOC647I2GNQ66N47FND55L6TMDQ7OU5LIMDKGH"; -export const SOROBAN_URL = process.env.SOROBAN_URL || "https://rpc-futurenet.stellar.org:443"; +export const CONTRACT_CREATION_LEDGER = process.env.CONTRACT_CREATION_LEDGER || 753012; +export const POOL_ID = process.env.POOL_ID || "CCR254VF53IMGX36QVN4ZJOKR6GK3KQJD6BJISX7LE7TXPKQEUV3MFUB"; +export const SOROBAN_URL = process.env.SOROBAN_URL || "https://rpc-futurenet.stellar.org"; export const HORIZON_URL = process.env.HORIZON_URL || "https://horizon-futurenet.stellar.org"; export const NETWORK_PASSPHRASE = process.env.NETWORK_PASSPHRASE || "Test SDF Future Network ; October 2022"; export const LIQUIDATOR_ADDRESS = process.env.LIQUIDATOR_ADDRESS; export const LIQUIDATOR_SECRET = process.env.LIQUIDATOR_SECRET; +export const POOL_ASSETS = process.env.POOL_ASSETS || "CB3VNKT7UEAHHETRHPC3XEAE3SRSVIASUG3P6KG5JFVY6Q5SVISJH2EJ,CC3OEW3BQUUMRWGPDKYESZAOXEOPBLDHKMZR2JYNHR23LIF2ULQVCAUG,CB2O6IY6EVBWFCFKAI2FNWTAYOB4RASUTYPC6VWKQ6IN44VASBQOMWKY"; \ No newline at end of file diff --git a/src/contracts.ts b/src/contracts.ts index bcc4b47..5080fc3 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,119 +1,134 @@ -import { Address, Contract, Server, xdr, scValToBigInt, TransactionBuilder, BASE_FEE, TimeoutInfinite, Keypair, SorobanRpc } from "soroban-client"; +import { Address, BASE_FEE, Contract, Keypair, SorobanRpc, TimeoutInfinite, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import { promisify } from "util"; + import { PoolAccountPosition, PoolReserveData, ReserveData } from "./types"; -import { parseScvToJs } from "./parseScvToJs"; -import { LIQUIDATOR_ADDRESS, LIQUIDATOR_SECRET, NETWORK_PASSPHRASE, POOL_ID, POOL_PRECISION_FACTOR } from "./consts"; - -export const getInstanceStorage = async (server: Server, contractId: string) => { - const ledgerKey = xdr.LedgerKey.contractData( - new xdr.LedgerKeyContractData({ - contract: new Contract(contractId).address().toScAddress(), - key: xdr.ScVal.scvLedgerKeyContractInstance(), - durability: xdr.ContractDataDurability.persistent() - }) - ); - const poolInstanceLedgerEntriesRaw = await server.getLedgerEntries(ledgerKey); - const poolInstanceLedgerEntries = xdr.LedgerEntryData.fromXDR(poolInstanceLedgerEntriesRaw.entries[0].xdr, "base64"); - return (poolInstanceLedgerEntries.value() as any).body().value().val().value().storage(); +import { LIQUIDATOR_ADDRESS, LIQUIDATOR_SECRET, NETWORK_PASSPHRASE, POOL_ASSETS, POOL_ID } from "./configuration"; +import { convertScvToJs } from "./parseScvToJs"; + +export async function getReserves(rpc: SorobanRpc.Server): Promise { + const reserves = POOL_ASSETS + .split(",") + .map(asset => getReserve(rpc, asset) + .then((r) => ({ asset: asset, reserve: r })) + .catch(() => undefined)); + + const reserveData = (await Promise.all(reserves)) + .filter((t) => !!t && t.reserve.reserve_type[0] === "Fungible") + .map(r => ({ asset: r.asset, debt_token: r.reserve.reserve_type[2] })); + + return reserveData; } -export const getReserves = async (server: Server) => { - const poolInstanceStorageEntries = await getInstanceStorage(server, POOL_ID); - const reserves = new Map(); - const reservesReverseMap = {}; - const getDefaultReserve = () => ({ debtToken: "", sTokenUnderlyingBalance: 0n }); - for (let i = 0; i < poolInstanceStorageEntries.length; i++) { - const key = parseScvToJs(poolInstanceStorageEntries[i].key()); - if (key[0] === "ReserveAssetKey") { - const token = key[1]; - const value = parseScvToJs(poolInstanceStorageEntries[i].val()) as PoolReserveData; - const { debt_token_address: debtToken, s_token_address: sToken } = value; - const reserve = reserves.get(token) || getDefaultReserve(); - reserve.debtToken = debtToken; - reservesReverseMap[sToken] = { lpTokenType: "sToken", token }; - reserves.set(token, reserve); - } - } - // exceeded-limit-fix - for (let i = 0; i < poolInstanceStorageEntries.length; i++) { - const key = parseScvToJs(poolInstanceStorageEntries[i].key()); - if (key[0] === "STokenUnderlyingBalance") { - const sToken = key[1]; - const value = parseScvToJs(poolInstanceStorageEntries[i].val()) as bigint; - const { token } = reservesReverseMap[sToken]; - const reserve = reserves.get(token); - reserve.sTokenUnderlyingBalance = value; - reserves.set(token, reserve); - } - } +export async function getBalance(rpc: SorobanRpc.Server, token: string, user: string): Promise { + return simulateTransaction(rpc, token, "balance", Address.fromString(user).toScVal()); +} - return reserves; +export async function getReserve(rpc: SorobanRpc.Server, asset: string): Promise { + return simulateTransaction(rpc, POOL_ID, "get_reserve", Address.fromString(asset).toScVal()); } -export const getPrice = async (server: Server, priceFeed: string, token: string) => { - const priceFeedInstanceStorageEntries = await getInstanceStorage(server, priceFeed); - for (let i = 0; i < priceFeedInstanceStorageEntries.length; i++) { - const key = parseScvToJs(priceFeedInstanceStorageEntries[i].key()); - if (key[0] === "Price" && key[1] === token) { - const value = scValToBigInt(priceFeedInstanceStorageEntries[i].val()); - return value; - } - } +export async function getAccountPosition(rpc: SorobanRpc.Server, user: string): Promise { + return simulateTransaction(rpc, POOL_ID, "account_position", Address.fromString(user).toScVal()); } -export const getBalance = async (server: Server, token: string, user: string): Promise => - simulateTransaction(server, token, "balance", Address.fromString(user).toScVal()); +export async function getDebtCoeff(rpc: SorobanRpc.Server, token: string): Promise { + return simulateTransaction(rpc, POOL_ID, "debt_coeff", new Contract(token).address().toScVal()) +} -export const getAccountPosition = async (server: Server, user: string): Promise => - simulateTransaction(server, POOL_ID, "account_position", Address.fromString(user).toScVal()); +export async function liquidate(rpc: SorobanRpc.Server, who: string): Promise { + return call(rpc, POOL_ID, "liquidate", Address.fromString(LIQUIDATOR_ADDRESS).toScVal(), Address.fromString(who).toScVal(), xdr.ScVal.scvBool(false)); +} -export const getDebtCoeff = async (server: Server, token: string): Promise => - simulateTransaction(server, token, "debt_coeff", new Contract(token).address().toScVal()) +async function simulateTransaction( + rpc: SorobanRpc.Server, + contractAddress: string, + method: string, + ...args: xdr.ScVal[] +): Promise { + const caller = await rpc.getAccount(LIQUIDATOR_ADDRESS); + const contract = new Contract(contractAddress); -export const liquidate = async (server: Server, who: string, token: string) => { - const account = await server.getAccount(LIQUIDATOR_ADDRESS); - const contract = new Contract(POOL_ID); - const operation = new TransactionBuilder(account, { + const transaction = new TransactionBuilder(caller, { fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE, - }).addOperation(contract.call( - "liquidate", - Address.fromString(LIQUIDATOR_ADDRESS).toScVal(), - Address.fromString(who).toScVal(), - Address.fromString(token).toScVal(), - xdr.ScVal.scvBool(false)) - ) + }) + .addOperation(contract.call(method, ...(args ?? []))) .setTimeout(TimeoutInfinite) .build(); - const transaction = await server.prepareTransaction( - operation, - process.env.PASSPHRASE); - transaction.sign(Keypair.fromSecret(LIQUIDATOR_SECRET)); - return server.sendTransaction(transaction); -} -export const getCompoundedDebt = async (server: Server, who: string, debtToken: string, debtCoeff: bigint): Promise => { - const debtTokenBalance = await getBalance(server, debtToken, who); - return (debtCoeff * debtTokenBalance) / BigInt(POOL_PRECISION_FACTOR); + const simulated = await rpc.simulateTransaction(transaction); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw new Error(simulated.error); + } else if (!simulated.result) { + throw new Error(`invalid simulation: no result in ${simulated}`); + } + + return convertScvToJs(simulated.result.retval); } -async function simulateTransaction(server: Server, contractAddress: string, call: string, ...args: xdr.ScVal[]): Promise { - const account = await server.getAccount(LIQUIDATOR_ADDRESS); +async function call( + rpc: SorobanRpc.Server, + contractAddress: string, + method: string, + ...args: xdr.ScVal[] +): Promise { + const callerKeys = Keypair.fromSecret(LIQUIDATOR_SECRET); + + const caller = await rpc.getAccount(callerKeys.publicKey()); const contract = new Contract(contractAddress); - const transaction = new TransactionBuilder(account, { + + const operation = new TransactionBuilder(caller, { fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE, - }).addOperation(contract.call(call, ...args)) + }) + .addOperation(contract.call(method, ...(args ?? []))) .setTimeout(TimeoutInfinite) .build(); - return server.simulateTransaction(transaction) - .then(simulated => { - if (SorobanRpc.isSimulationError(simulated)) { - throw new Error(simulated.error); - } else if (!simulated.result) { - throw new Error(`invalid simulation: no result in ${simulated}`); - } - - return parseScvToJs(simulated.result.retval) - }); -} \ No newline at end of file + const simulated = (await rpc.simulateTransaction( + operation, + )) as SorobanRpc.Api.SimulateTransactionSuccessResponse; + + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw new Error(simulated.error); + } else if (!simulated.result) { + throw new Error(`Invalid simulation: no result in ${simulated}`); + } + + const transaction = SorobanRpc.assembleTransaction(operation, simulated).build(); + + transaction.sign(callerKeys); + + const response = await rpc.sendTransaction(transaction); + + let result: SorobanRpc.Api.GetTransactionResponse; + let attempts = 15; + + if (response.status == 'ERROR') { + throw Error(`Failed to send transaction: ${response.errorResult.toXDR('base64')}`); + } + + do { + await delay(1000); + result = await rpc.getTransaction(response.hash); + attempts--; + } while (result.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts > 0); + + if (result.status == SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { + throw Error('Submitted transaction was not found'); + } + + if ('resultXdr' in result) { + const getResult = result as SorobanRpc.Api.GetTransactionResponse; + if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error('Transaction result is insuccessfull'); + } else { + return; + } + } + + throw Error(`Transaction failed (method: ${method})`); +} + +export let delay = promisify((ms, res) => setTimeout(res, ms)) diff --git a/src/db.ts b/src/db.ts index 4d32bdb..570fa42 100644 --- a/src/db.ts +++ b/src/db.ts @@ -22,7 +22,7 @@ export const updateLastSyncedLedger = (lastSyncedLedger: number) => { db.prepare('UPDATE ledger SET last_synced=(?) WHERE rowid=1').run(lastSyncedLedger); } -export const readBorrowers = () => db.prepare('SELECT borrower from borrowers').get() || [] +export const readBorrowers = () => db.prepare('SELECT borrower from borrowers').all() || [] export const insertBorrowers = (borrowers: string[]) => { for (const borrower of borrowers) { diff --git a/src/index.ts b/src/index.ts index dd95685..08ae0d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,18 @@ -import { - Server, -} from "soroban-client"; import { populateDbWithBorrowers } from "./sync"; -import { getDebtCoeff, getAccountPosition, getReserves, getBalance, liquidate, getCompoundedDebt } from "./contracts"; -import { POOL_PRECISION_FACTOR, SOROBAN_URL, LIQUIDATOR_ADDRESS } from "./consts"; +import { getDebtCoeff, getAccountPosition, getReserves, getBalance, liquidate } from "./contracts"; +import { POOL_PRECISION_FACTOR, SOROBAN_URL, LIQUIDATOR_ADDRESS } from "./configuration"; import { readBorrowers, deleteBorrower, deleteBorrowers } from "./db"; +import { SorobanRpc } from "@stellar/stellar-sdk"; async function main() { - const server = new Server(SOROBAN_URL); + const rpc = new SorobanRpc.Server(SOROBAN_URL); + while (true) { - await populateDbWithBorrowers(server); + await populateDbWithBorrowers(rpc); const users = readBorrowers(); - const reserves = await getReserves(server); - - const positionsResults = await Promise.allSettled(users.map(user => getAccountPosition(server, user))); + const reserves = await getReserves(rpc); + + const positionsResults = await Promise.allSettled(users.map(user => getAccountPosition(rpc, user.borrower))); const borrowersToLiquidate = []; const borrowersToDelete = []; @@ -31,44 +30,71 @@ async function main() { deleteBorrowers(borrowersToDelete); - for (const [token, { debtToken }] of reserves.entries()) { - let abortLiquidation = false; - let liquidatorBalance; + const liquidatorBalances = new Map; + const borrowersDebt = new Map>; + + for (const { asset, debt_token } of reserves) { try { - liquidatorBalance = await getBalance(server, token, LIQUIDATOR_ADDRESS); + const liquidatorBalance = await getBalance(rpc, asset, LIQUIDATOR_ADDRESS); + liquidatorBalances.set(asset, liquidatorBalance); } catch (e) { throw Error(`Read liquidator balance error (may be storage expired): ${e}`); } - const debtCoeff = await getDebtCoeff(server, token); - for (const borrower of borrowersToLiquidate) { - let borrowerBalance; - do { - try { - borrowerBalance = await getCompoundedDebt(server, borrower, debtToken, debtCoeff); - } catch (e) { - console.warn(`Read borrower balance error: ${e}\nborrower: ${borrower}\ntoken: ${token}`); - break; - } - if (liquidatorBalance >= borrowerBalance) { - try { - await liquidate(server, borrower, token); - liquidatorBalance -= borrowerBalance; - } catch (e) { - console.warn(`Liquidation error: ${e}\nborrower: ${borrower}\ntoken: ${token}`); - break; - } - } + const debtCoeff = await getDebtCoeff(rpc, asset); + + for (const borrower of borrowersToLiquidate) { + try { + const debtTokenBalance = await getBalance(rpc, debt_token, borrower.borrower); + const compoundedDebt = (debtCoeff * debtTokenBalance) / BigInt(POOL_PRECISION_FACTOR); + const debts = borrowersDebt.get(asset) || new Map; + debts.set(asset, compoundedDebt); + borrowersDebt.set(borrower.borrower, debts); + } catch (e) { + console.warn(`Read borrower balance error: ${e}`); + continue; + } + } + } - if (liquidatorBalance <= 0n) { - abortLiquidation = true; - } - } while (!abortLiquidation || borrowerBalance !== 0); + const liquidations = []; - if (abortLiquidation) { + for (const [borrower, debts] of borrowersDebt.entries()) { + let abortLiquidation = false; + for (const [token, debt] of debts.entries()) { + if (liquidatorBalances.get(token) <= debt) { + // avoid liquidation for current borrower + abortLiquidation = true; break; } } + if (abortLiquidation) { + continue; + } + liquidations.push( + liquidate(rpc, borrower) + .then(() => [borrower, undefined]) + .catch((reason) => [borrower, reason]) + ); + for (const [token, debt] of debts.entries()) { + const liquidatorBalance = liquidatorBalances.get(token) - debt; + abortLiquidation = liquidatorBalance <= 0n; + liquidatorBalances.set(token, liquidatorBalance); + } + // avoid liquidation for all further borrowers + if (abortLiquidation) { + break; + } + } + + const liquidationResults = await Promise.allSettled(liquidations); + + for (const liquidationResult of liquidationResults) { + if (liquidationResult.status === "fulfilled" && liquidationResult.value[1] == undefined) { + deleteBorrower(liquidationResult.value[0]); + } else { + console.warn(`Liquidation error: ${liquidationResult}`); + } } } } diff --git a/src/parseScvToJs.ts b/src/parseScvToJs.ts index efe1c5e..f12f061 100644 --- a/src/parseScvToJs.ts +++ b/src/parseScvToJs.ts @@ -1,104 +1,11 @@ -import { Address, xdr } from 'soroban-client'; -import { Buffer } from "node:buffer"; +import { Address, xdr } from '@stellar/stellar-sdk'; import { bufToBigint } from 'bigint-conversion'; type ElementType = T extends Array ? U : never; -type KeyType = T extends Map ? K : never; -type ValueType = T extends Map ? V : never; +type KeyType = T extends Map ? K : never; +type ValueType = T extends Map ? V : never; -export function convertToScvAddress(value: string): xdr.ScVal { - let addrObj = Address.fromString(value); - return addrObj.toScVal(); -} - -export function convertToScvBool(value: boolean): xdr.ScVal { - return xdr.ScVal.scvBool(value); -} - -export function convertToScvMap(value: object): xdr.ScVal { - const map = Object - .keys(value) - .map(k => new xdr.ScMapEntry({ - key: xdr.ScVal.scvSymbol(k), - val: value[k] - })); - - return xdr.ScVal.scvMap(map); -} - -export function convertToScvVec(value: xdr.ScVal[]): xdr.ScVal { - return xdr.ScVal.scvVec(value); -} - -export function convertToScvI128(value: bigint): xdr.ScVal { - return xdr.ScVal.scvI128(new xdr.Int128Parts({ - lo: xdr.Uint64.fromString((value & BigInt(0xFFFFFFFFFFFFFFFFn)).toString()), - hi: xdr.Int64.fromString(((value >> BigInt(64)) & BigInt(0xFFFFFFFFFFFFFFFFn)).toString()), - })) -} - -export function convertToScvU128(value: bigint): xdr.ScVal { - return xdr.ScVal.scvU128(new xdr.UInt128Parts({ - lo: xdr.Uint64.fromString((value & BigInt(0xFFFFFFFFFFFFFFFFn)).toString()), - hi: xdr.Int64.fromString(((value >> BigInt(64)) & BigInt(0xFFFFFFFFFFFFFFFFn)).toString()), - })) -} - -export function convertToScvU32(value: number): xdr.ScVal { - return xdr.ScVal.scvU32(value); -} - -export function convertToScvString(value: string): xdr.ScVal { - return xdr.ScVal.scvString(value); -} - -export function convertToScvBytes(value: string, encoding: BufferEncoding): xdr.ScVal { - const bytes = Buffer.from(value, encoding); - return xdr.ScVal.scvBytes(bytes); -} - -export function parseMetaXdrToJs(value: string): T { - const val = xdr.TransactionMeta - .fromXDR(value, "base64") - .v3() - .sorobanMeta()! - .returnValue(); - - return parseScvToJs(val); -} - -export function parseScvToBigInt(scval: xdr.ScVal | undefined): BigInt { - switch (scval?.switch()) { - case undefined: { - return undefined; - } - case xdr.ScValType.scvU64(): { - const { high, low } = scval.u64(); - return bufToBigint(new Uint32Array([high, low])); - } - case xdr.ScValType.scvI64(): { - const { high, low } = scval.i64(); - return bufToBigint(new Int32Array([high, low])); - } - case xdr.ScValType.scvU128(): { - const parts = scval.u128(); - const a = parts.hi(); - const b = parts.lo(); - return bufToBigint(new Uint32Array([a.high, a.low, b.high, b.low])); - } - case xdr.ScValType.scvI128(): { - const parts = scval.i128(); - const a = parts.hi(); - const b = parts.lo(); - return bufToBigint(new Uint32Array([a.high, a.low, b.high, b.low])); - } - default: { - throw new Error(`Invalid type for scvalToBigInt: ${scval?.switch().name}`); - } - }; -} - -export function parseScvToJs(val: xdr.ScVal): T { +export const convertScvToJs = (val: xdr.ScVal): T => { switch (val?.switch()) { case xdr.ScValType.scvBool(): { return val.b() as unknown as T; @@ -119,7 +26,7 @@ export function parseScvToJs(val: xdr.ScVal): T { case xdr.ScValType.scvI128(): case xdr.ScValType.scvU256(): case xdr.ScValType.scvI256(): { - return parseScvToBigInt(val) as unknown as T; + return convertScvToBigInt(val) as unknown as T; } case xdr.ScValType.scvAddress(): { return Address.fromScVal(val).toString() as unknown as T; @@ -135,36 +42,36 @@ export function parseScvToJs(val: xdr.ScVal): T { } case xdr.ScValType.scvVec(): { type Element = ElementType; - return val.vec().map(v => parseScvToJs(v)) as unknown as T; + return val.vec().map((v) => convertScvToJs(v)) as unknown as T; } case xdr.ScValType.scvMap(): { type Key = KeyType; type Value = ValueType; - let res: any = {}; + const res: unknown = {}; val.map().forEach((e) => { - let key = parseScvToJs(e.key()); + const key = convertScvToJs(e.key()); let value; - let v: xdr.ScVal = e.val(); + const v: xdr.ScVal = e.val(); switch (v?.switch()) { case xdr.ScValType.scvMap(): { - let inner_map = new Map() as Map; + const inner_map = new Map() as Map; v.map().forEach((e) => { - let key = parseScvToJs(e.key()); - let value = parseScvToJs(e.val()); + const key = convertScvToJs(e.key()); + const value = convertScvToJs(e.val()); inner_map.set(key, value); }); value = inner_map; break; } default: { - value = parseScvToJs(e.val()); + value = convertScvToJs(e.val()); } } res[key as Key] = value as Value; }); - return res as unknown as T + return res as unknown as T; } case xdr.ScValType.scvLedgerKeyNonce(): return val.nonceKey() as unknown as T; @@ -176,5 +83,34 @@ export function parseScvToJs(val: xdr.ScVal): T { default: { throw new Error(`type not implemented yet: ${val?.switch().name}`); } - }; -} + } +}; + +const convertScvToBigInt = (scval: xdr.ScVal | undefined): bigint => { + switch (scval?.switch()) { + case undefined: { + return undefined; + } + case xdr.ScValType.scvU64(): { + const { high, low } = scval.u64(); + return bufToBigint(new Uint32Array([high, low])); + } + case xdr.ScValType.scvI64(): { + const { high, low } = scval.i64(); + return bufToBigint(new Int32Array([high, low])); + } + case xdr.ScValType.scvU128(): { + const parts = scval.u128(); + const a = parts.hi(); + const b = parts.lo(); + return bufToBigint(new Uint32Array([a.high, a.low, b.high, b.low])); + } + case xdr.ScValType.scvI128(): { + const parts = scval.i128(); + return BigInt(parts.lo().toString()) | (BigInt(parts.hi().toString()) << BigInt(64)); + } + default: { + throw new Error(`Invalid type for scvalToBigInt: ${scval?.switch().name}`); + } + } +}; diff --git a/src/sync.ts b/src/sync.ts index 317e549..03c7010 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,28 +1,37 @@ -import { Server, xdr } from "soroban-client"; -import StellarSdk from 'stellar-sdk'; -import { humanizeEvents } from 'stellar-base'; import { readLastSyncedLedger, updateLastSyncedLedger, insertBorrowers } from "./db"; -import { CONTRACT_CREATION_LEDGER, HORIZON_URL, POOL_ID } from "./consts"; +import { CONTRACT_CREATION_LEDGER, HORIZON_URL, POOL_ID } from "./configuration"; +import { Horizon, SorobanRpc, humanizeEvents, xdr } from "@stellar/stellar-sdk"; -export const populateDbWithBorrowers = async (server: Server) => { - let lastLedger = (await server.getLatestLedger()).sequence; +export const populateDbWithBorrowers = async (rpc: SorobanRpc.Server) => { + let lastLedger = (await rpc.getLatestLedger()).sequence; const lastSyncedLedger = readLastSyncedLedger(); + if (lastLedger > lastSyncedLedger) { - const horizon = new StellarSdk.Server(HORIZON_URL); + const horizon = new Horizon.Server(HORIZON_URL); let currentLedger = lastSyncedLedger === 0 ? CONTRACT_CREATION_LEDGER : lastSyncedLedger + 1; + console.log(`Sync from: ${currentLedger} to ${lastLedger}`); + while (lastLedger > currentLedger) { - const stellarTransactions = (await horizon.transactions().forLedger(currentLedger).call()).records; - for (const tx of stellarTransactions) { - let xdrEvents = xdr.TransactionMeta.fromXDR(tx.result_meta_xdr, "base64").v3().sorobanMeta().events(); - const events = humanizeEvents(xdrEvents) - .filter(e => e.contractId === POOL_ID && (e.topics[0] === 'borrow')); - const borrowersAddresses = events.map(e => e.topics[1]); - insertBorrowers(borrowersAddresses); + const transactions = await horizon.transactions().forLedger(currentLedger).call(); + + for (const tx of transactions.records) { + const xdrMeta = xdr.TransactionMeta.fromXDR(tx.result_meta_xdr, "base64").v3().sorobanMeta(); + + if (!xdrMeta || !xdrMeta.events()) + continue; + + const events = humanizeEvents(xdrMeta.events()) + .filter(e => e.contractId === POOL_ID && e.topics[0] === 'borrow'); + const borrower = events.map(e => e.topics[1]); + + insertBorrowers(borrower); } + updateLastSyncedLedger(currentLedger); + currentLedger += 1; - lastLedger = (await server.getLatestLedger()).sequence; + lastLedger = (await rpc.getLatestLedger()).sequence; } } } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index bb240ea..edf6427 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,22 +1,14 @@ export interface ReserveData { - debtToken: string, - sTokenUnderlyingBalance: bigint, + asset: string; + debt_token: string; } export interface PoolReserveData { - configuration: Map, - lender_ar: bigint, - lender_ir: bigint, - borrower_ar: bigint, - borrower_ir: bigint, - last_update_timestamp: number, - s_token_address: string, - debt_token_address: string, - id: number[], + reserve_type: string[] } export interface PoolAccountPosition { - npv: bigint, - debt: bigint - discounted_collateral: bigint, + npv: bigint; + debt: bigint; + discounted_collateral: bigint; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3032606..f39046f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32,6 +32,40 @@ resolved "https://registry.yarnpkg.com/@juanelas/base64/-/base64-1.1.2.tgz#968ff0f9c48adcfa79a0802385d56a2a661cb6cd" integrity sha512-mr2pfRQpWap0Uq4tlrCgp3W+Yjx1/Bpq4QJsYeAQUh1mExgyQvXz7xUhmYT2HcLLspuAL5dpnos8P2QhaCSXsQ== +"@stellar/js-xdr@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@stellar/js-xdr/-/js-xdr-3.1.0.tgz#37c23e6c913d011f750808f3d8b60174b633e137" + integrity sha512-mYTyFnhgyQgyvpAYZRO1LurUn2MxcIZRj74zZz/BxKEk7zrL4axhQ1ez0HL2BRi0wlG6cHn5BeD/t9Xcyp7CSQ== + +"@stellar/stellar-base@^11.0.0": + version "11.0.0" + resolved "https://registry.yarnpkg.com/@stellar/stellar-base/-/stellar-base-11.0.0.tgz#9c2595f0aa1fe955191656fe9bda7a6b34ef82f0" + integrity sha512-KPTjaWJCG2m7hMCPRWFGGPaG5qOkgPLWvFVOhe1HUy7dlE4MxxPfdusz0mcLkf6VT7doqhLB1rIt0D9M2GgQcQ== + dependencies: + "@stellar/js-xdr" "^3.1.0" + base32.js "^0.1.0" + bignumber.js "^9.1.2" + buffer "^6.0.3" + sha.js "^2.3.6" + tweetnacl "^1.0.3" + typescript "^5.3.3" + optionalDependencies: + sodium-native "^4.0.8" + +"@stellar/stellar-sdk@^11.2.1": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@stellar/stellar-sdk/-/stellar-sdk-11.2.2.tgz#e2768d519a05f396ff3f89f9fb7a7c5ed22c7c74" + integrity sha512-50dpxpZE2e87LjIln1EZnBh7r+JFWwyGs+NiGPOZ+LzJd2YRf54+YxzDX6ELVOrUqp2TRTLnzthXXTNFeVobFw== + dependencies: + "@stellar/stellar-base" "^11.0.0" + axios "^1.6.7" + bignumber.js "^9.1.2" + eventsource "^2.0.2" + randombytes "^2.1.0" + toml "^3.0.0" + typescript "^5.3.3" + urijs "^1.19.1" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -77,12 +111,12 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -axios@^1.4.0, axios@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267" - integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ== +axios@^1.6.7: + version "1.6.7" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.7.tgz#7b48c2e27c96f9c68a2f8f31e2ab19f59b06b0a7" + integrity sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA== dependencies: - follow-redirects "^1.15.0" + follow-redirects "^1.15.4" form-data "^4.0.0" proxy-from-env "^1.1.0" @@ -104,14 +138,14 @@ better-sqlite3@^8.6.0: bindings "^1.5.0" prebuild-install "^7.1.1" -bigint-conversion@^2.4.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/bigint-conversion/-/bigint-conversion-2.4.2.tgz#30c50c76d370ed90f34b4fe47c7d5b14dda8361e" - integrity sha512-2s3aRYaQtYGqY1w6x2PQqYbHUjktOWX0S0dkv7c+FjRnkKspictZ4IaEKmGEtZJjT2ccR29ufPB7QPEDfSs4bg== +bigint-conversion@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/bigint-conversion/-/bigint-conversion-2.4.3.tgz#cca2ff59033960be8ff517b8e931db82b6bb24fa" + integrity sha512-eM76IXlhXQD6HAoE6A7QLQ3jdC04EJdjH3zrlU1Jtt4/jj+O/pMGjGR5FY8/55FOIBsK25kly0RoG4GA4iKdvg== dependencies: "@juanelas/base64" "^1.1.2" -bignumber.js@^9.1.1, bignumber.js@^9.1.2: +bignumber.js@^9.1.2: version "9.1.2" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== @@ -214,10 +248,10 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== -follow-redirects@^1.15.0: - version "1.15.2" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" - integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== +follow-redirects@^1.15.4: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== form-data@^4.0.0: version "4.0.0" @@ -253,11 +287,6 @@ ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -js-xdr@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/js-xdr/-/js-xdr-3.0.0.tgz#fb74275de0ed3cec61269721140a576edf6fca7e" - integrity sha512-tSt6UKJ2L7t+yaQURGkHo9kop9qnVbChTlCu62zNiDbDZQoZb/YjUj2iFJ3lgelhfg9p5bhO2o/QX+g36TPsSQ== - lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -412,65 +441,13 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" -sodium-native@^4.0.1: - version "4.0.4" - resolved "https://registry.yarnpkg.com/sodium-native/-/sodium-native-4.0.4.tgz#561b7c39c97789f8202d6fd224845fe2e8cd6879" - integrity sha512-faqOKw4WQKK7r/ybn6Lqo1F9+L5T6NlBJJYvpxbZPetpWylUVqz449mvlwIBKBqxEHbWakWuOlUt8J3Qpc4sWw== +sodium-native@^4.0.8: + version "4.0.10" + resolved "https://registry.yarnpkg.com/sodium-native/-/sodium-native-4.0.10.tgz#24af8db06518807a8ddf4e5143d819f14bf7a837" + integrity sha512-vrJQt4gASntDbnltRRk9vN4rks1SehjM12HkqQtu250JtWT+/lo8oEOa1HvSq3+8hzJdYcCJuLR5qRGxoRDjAg== dependencies: node-gyp-build "^4.6.0" -soroban-client@1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/soroban-client/-/soroban-client-1.0.0-beta.2.tgz#a59f9bd88436d6f06f38213efc4547676bee70d1" - integrity sha512-v5h3yvef7HkUD3H26w33NUEgRXcPiOSDWEsVzMloaxsprs3N002tXJHvFF+Uw1eYt50Uk6bvqBgvkLwX10VENw== - dependencies: - axios "^1.4.0" - bignumber.js "^9.1.1" - buffer "^6.0.3" - stellar-base v10.0.0-beta.1 - urijs "^1.19.1" - -stellar-base@10.0.0-beta.1, stellar-base@v10.0.0-beta.1: - version "10.0.0-beta.1" - resolved "https://registry.yarnpkg.com/stellar-base/-/stellar-base-10.0.0-beta.1.tgz#5b4209fbc44b8af82dd3ee8b7f6f4397126d8c3b" - integrity sha512-zXC5AsbUsLi57JruyeIMv23s3iUxq/P2ZFrSJ+FerLIZjSAjY8EDs4zwY4LCuu7swUu46Lm8GK6sqxUZCPekHw== - dependencies: - base32.js "^0.1.0" - bignumber.js "^9.1.2" - buffer "^6.0.3" - js-xdr "^3.0.0" - sha.js "^2.3.6" - tweetnacl "^1.0.3" - optionalDependencies: - sodium-native "^4.0.1" - -stellar-base@^10.0.0-beta.1: - version "10.0.0-soroban.8" - resolved "https://registry.yarnpkg.com/stellar-base/-/stellar-base-10.0.0-soroban.8.tgz#8c5671f9d5a183aeb3077746db56e65b0b5c05da" - integrity sha512-mtj+4EcCnp4ZyH2FzRl62/DAstTXOddHVRZdzFQ94WgyQz2yVNzt+ANDS1D/7ku4d2mIzoJIj9l0/H0A5nRgXQ== - dependencies: - base32.js "^0.1.0" - bignumber.js "^9.1.2" - buffer "^6.0.3" - js-xdr "^3.0.0" - sha.js "^2.3.6" - tweetnacl "^1.0.3" - optionalDependencies: - sodium-native "^4.0.1" - -stellar-sdk@11.0.0-beta.3: - version "11.0.0-beta.3" - resolved "https://registry.yarnpkg.com/stellar-sdk/-/stellar-sdk-11.0.0-beta.3.tgz#fbdc115d3775adad338658613df2ed97468efa0a" - integrity sha512-obdiB4f9bK978twh2l7EA6K3p6i3+67Vf4Bp8vrGTqDG+1iTTDHftt98uBLFVaLjB2B9hq/wXk/Mqttl+hRGHQ== - dependencies: - axios "^1.5.0" - bignumber.js "^9.1.2" - eventsource "^2.0.2" - randombytes "^2.1.0" - stellar-base "^10.0.0-beta.1" - toml "^3.0.0" - urijs "^1.19.1" - string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" @@ -545,6 +522,11 @@ typescript@^5.1.6: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +typescript@^5.3.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + urijs@^1.19.1: version "1.19.11" resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.11.tgz#204b0d6b605ae80bea54bea39280cdb7c9f923cc"