From a782dc09a432008973b2284f8af87d153fc66e64 Mon Sep 17 00:00:00 2001 From: Yaroslav Grishajev Date: Mon, 9 Dec 2024 12:28:32 +0100 Subject: [PATCH] feat(deployment): auto top up custodial deployment with available amount closes #524 --- .../top-up-custodial-balance.service.ts | 23 +++++ ...p-up-custodial-deployments.service.spec.ts | 97 +++++++++++++++---- .../top-up-custodial-deployments.service.ts | 57 +++++++---- .../src/balance/balance-http.service.ts | 13 ++- 4 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 apps/api/src/deployment/services/top-up-custodial-balance/top-up-custodial-balance.service.ts diff --git a/apps/api/src/deployment/services/top-up-custodial-balance/top-up-custodial-balance.service.ts b/apps/api/src/deployment/services/top-up-custodial-balance/top-up-custodial-balance.service.ts new file mode 100644 index 000000000..b2623e2d3 --- /dev/null +++ b/apps/api/src/deployment/services/top-up-custodial-balance/top-up-custodial-balance.service.ts @@ -0,0 +1,23 @@ +interface Balances { + denom: string; + feesLimit: number; + deploymentLimit: number; + balance: number; + feesBalance?: number; +} + +export class TopUpCustodialBalanceService { + constructor(readonly balances: Balances) {} + + recordTx(amount: number, fees: number) { + this.balances.deploymentLimit -= amount; + this.balances.balance -= amount; + this.balances.feesLimit -= fees; + + if (this.balances.denom === "uakt") { + this.balances.balance -= fees; + } else { + this.balances.feesBalance -= fees; + } + } +} diff --git a/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.spec.ts b/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.spec.ts index 2bc4354ce..7d67ee030 100644 --- a/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.spec.ts +++ b/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.spec.ts @@ -1,8 +1,8 @@ import "@test/mocks/logger-service.mock"; import { AllowanceHttpService, BalanceHttpService, Denom } from "@akashnetwork/http-sdk"; -import { faker } from "@faker-js/faker"; import { MsgExec } from "cosmjs-types/cosmos/authz/v1beta1/tx"; +import { secondsInWeek } from "date-fns/constants"; import { describe } from "node:test"; import { container } from "tsyringe"; @@ -25,6 +25,8 @@ import { DrainingDeploymentSeeder } from "@test/seeders/draining-deployment.seed import { FeesAuthorizationSeeder } from "@test/seeders/fees-authorization.seeder"; import { stub } from "@test/services/stub"; +const USDC_IBC_DENOM = "ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1"; + describe(TopUpCustodialDeploymentsService.name, () => { const CURRENT_BLOCK_HEIGHT = 7481457; const UAKT_TOP_UP_MASTER_WALLET_ADDRESS = AkashAddressSeeder.create(); @@ -71,13 +73,14 @@ describe(TopUpCustodialDeploymentsService.name, () => { type SeedParams = { denom: Denom; - balance?: string; + balance?: number; + feesBalance?: number; grantee: string; expectedDeploymentsTopUpCount?: 0 | 1 | 2; hasDeployments?: boolean; }; - const seedFor = ({ denom, balance = "100000000", grantee, expectedDeploymentsTopUpCount = 2, hasDeployments = true }: SeedParams) => { + const seedFor = ({ denom, balance = 100000000, feesBalance = 1000000, grantee, expectedDeploymentsTopUpCount = 2, hasDeployments = true }: SeedParams) => { const owner = AkashAddressSeeder.create(); return { @@ -90,8 +93,9 @@ describe(TopUpCustodialDeploymentsService.name, () => { feeAllowance: FeesAuthorizationSeeder.create({ granter: owner, grantee: grantee, - allowance: { spend_limit: { denom } } + allowance: { spend_limit: { denom: "uakt" } } }), + feesBalance: denom === "uakt" ? undefined : BalanceSeeder.create({ denom: "uakt", amount: feesBalance }), drainingDeployments: hasDeployments ? [ { @@ -113,24 +117,66 @@ describe(TopUpCustodialDeploymentsService.name, () => { grantee: UAKT_TOP_UP_MASTER_WALLET_ADDRESS }), seedFor({ - denom: "ibc/170C677610AC31DF0904FFE09CD3B5C657492170E7E52372E48756B71E56F2F1", + denom: USDC_IBC_DENOM, grantee: USDT_TOP_UP_MASTER_WALLET_ADDRESS }), + seedFor({ + denom: USDC_IBC_DENOM, + grantee: USDT_TOP_UP_MASTER_WALLET_ADDRESS, + balance: 5500000, + expectedDeploymentsTopUpCount: 2 + }), + seedFor({ + denom: USDC_IBC_DENOM, + grantee: USDT_TOP_UP_MASTER_WALLET_ADDRESS, + balance: 5040000, + expectedDeploymentsTopUpCount: 1 + }), + seedFor({ + denom: USDC_IBC_DENOM, + grantee: USDT_TOP_UP_MASTER_WALLET_ADDRESS, + balance: 5500000, + expectedDeploymentsTopUpCount: 2 + }), + seedFor({ + denom: USDC_IBC_DENOM, + grantee: USDT_TOP_UP_MASTER_WALLET_ADDRESS, + feesBalance: 0, + expectedDeploymentsTopUpCount: 0 + }), + seedFor({ + denom: USDC_IBC_DENOM, + grantee: USDT_TOP_UP_MASTER_WALLET_ADDRESS, + feesBalance: 5000, + expectedDeploymentsTopUpCount: 1 + }), + seedFor({ + denom: "uakt", + balance: 5045000, + grantee: UAKT_TOP_UP_MASTER_WALLET_ADDRESS, + expectedDeploymentsTopUpCount: 1 + }), seedFor({ denom: "uakt", - balance: "5500000", + balance: 5000, + grantee: UAKT_TOP_UP_MASTER_WALLET_ADDRESS, + expectedDeploymentsTopUpCount: 0 + }), + seedFor({ + denom: "uakt", + balance: 10000, grantee: UAKT_TOP_UP_MASTER_WALLET_ADDRESS, expectedDeploymentsTopUpCount: 1 }), seedFor({ denom: "uakt", - balance: "5500000", + balance: 5500000, grantee: UAKT_TOP_UP_MASTER_WALLET_ADDRESS, hasDeployments: false }), seedFor({ denom: "uakt", - balance: "0", + balance: 0, grantee: UAKT_TOP_UP_MASTER_WALLET_ADDRESS, expectedDeploymentsTopUpCount: 0 }) @@ -143,12 +189,20 @@ describe(TopUpCustodialDeploymentsService.name, () => { return data.find(({ grant }) => grant.granter === granter && grant.grantee === grantee)?.feeAllowance; }); jest.spyOn(balanceHttpService, "getBalance").mockImplementation(async (address: string, denom: Denom) => { - return ( - data.find(({ grant }) => grant.granter === address)?.balance || { - amount: "0", - denom - } - ); + const record = data.find(({ grant }) => grant.granter === address); + + if (record?.balance.denom === denom) { + return record.balance; + } + + if (record?.feesBalance.denom === denom) { + return record.feesBalance; + } + + return { + amount: 0, + denom + }; }); jest.spyOn(drainingDeploymentService, "findDeployments").mockImplementation(async (owner, denom) => { return ( @@ -157,18 +211,22 @@ describe(TopUpCustodialDeploymentsService.name, () => { ?.drainingDeployments?.map(({ deployment }) => deployment) || [] ); }); - jest.spyOn(drainingDeploymentService, "calculateTopUpAmount").mockImplementation(async () => faker.number.int({ min: 3500000, max: 4000000 })); + jest.spyOn(drainingDeploymentService, "calculateTopUpAmount").mockImplementation(async ({ blockRate }) => (blockRate * secondsInWeek) / 6); it("should top up draining deployment given owners have sufficient grants and balances", async () => { await topUpDeploymentsService.topUpDeployments({ dryRun: false }); - expect(uaktMasterSigningClientService.executeTx).toHaveBeenCalledTimes(3); - expect(usdtMasterSigningClientService.executeTx).toHaveBeenCalledTimes(2); + let uaktCount = 0; + let usdtCount = 0; data.forEach(({ drainingDeployments, grant }) => { drainingDeployments.forEach(({ isExpectedToTopUp, deployment }) => { if (isExpectedToTopUp) { - const client = deployment.denom === "uakt" ? uaktMasterSigningClientService : usdtMasterSigningClientService; + const isAkt = deployment.denom === "uakt"; + const client = isAkt ? uaktMasterSigningClientService : usdtMasterSigningClientService; + uaktCount += isAkt ? 1 : 0; + usdtCount += isAkt ? 0 : 1; + expect(client.executeTx).toHaveBeenCalledWith( [ { @@ -189,6 +247,9 @@ describe(TopUpCustodialDeploymentsService.name, () => { } }); }); + + expect(uaktMasterSigningClientService.executeTx).toHaveBeenCalledTimes(uaktCount); + expect(usdtMasterSigningClientService.executeTx).toHaveBeenCalledTimes(usdtCount); }); xdescribe("actual top up deployment tx on demand", () => { diff --git a/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.ts b/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.ts index 53f021bbe..c71386109 100644 --- a/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.ts +++ b/apps/api/src/deployment/services/top-up-custodial-deployments/top-up-custodial-deployments.service.ts @@ -7,6 +7,7 @@ import { BlockHttpService } from "@src/chain/services/block-http/block-http.serv import { ErrorService } from "@src/core/services/error/error.service"; import { TopUpSummarizer } from "@src/deployment/lib/top-up-summarizer/top-up-summarizer"; import { DrainingDeploymentService } from "@src/deployment/services/draining-deployment/draining-deployment.service"; +import { TopUpCustodialBalanceService } from "@src/deployment/services/top-up-custodial-balance/top-up-custodial-balance.service"; import { TopUpToolsService } from "@src/deployment/services/top-up-tools/top-up-tools.service"; import { DeploymentsRefiller, TopUpDeploymentsOptions } from "@src/deployment/types/deployments-refiller"; @@ -15,6 +16,7 @@ interface Balances { feesLimit: number; deploymentLimit: number; balance: number; + feesBalance?: number; } interface TopUpSummary { @@ -82,21 +84,19 @@ export class TopUpCustodialDeploymentsService implements DeploymentsRefiller { return; } - const balances = await this.collectWalletBalances(grant); - - let { deploymentLimit, feesLimit, balance } = balances; + const balancesService = new TopUpCustodialBalanceService(await this.collectWalletBalances(grant)); let hasTopUp = false; for (const { dseq, denom, blockRate, predictedClosedHeight } of drainingDeployments) { - const amount = await this.drainingDeploymentService.calculateTopUpAmount({ blockRate }); - if (!this.canTopUp(amount, { deploymentLimit, feesLimit, balance })) { - this.logger.info({ event: "INSUFFICIENT_BALANCE", granter: owner, grantee, balances: { deploymentLimit, feesLimit, balance } }); + const amount = await this.calculateTopUpAmount(blockRate, balancesService.balances); + + if (!this.canTopUp(amount, balancesService.balances)) { + this.logger.info({ event: "INSUFFICIENT_BALANCE", granter: owner, grantee, balances: balancesService.balances }); summary.inc("insufficientBalanceCount"); break; } - deploymentLimit -= amount; - feesLimit -= this.MIN_FEES_AVAILABLE; - balance -= amount + this.MIN_FEES_AVAILABLE; + + balancesService.recordTx(amount, this.MIN_FEES_AVAILABLE); await this.topUpDeployment( { @@ -124,27 +124,50 @@ export class TopUpCustodialDeploymentsService implements DeploymentsRefiller { const denom = grant.authorization.spend_limit.denom; const deploymentLimit = parseFloat(grant.authorization.spend_limit.amount); - const feesLimit = await this.retrieveFeesLimit(grant.granter, grant.grantee, denom); - const { amount } = await this.balanceHttpService.getBalance(grant.granter, denom); - const balance = parseFloat(amount); + const feesLimit = await this.retrieveFeesLimit(grant.granter, grant.grantee); + const [{ amount: balance }, feesBalance] = await Promise.all([ + this.balanceHttpService.getBalance(grant.granter, denom), + denom !== "uakt" && this.balanceHttpService.getBalance(grant.granter, "uakt") + ]); return { denom, feesLimit, deploymentLimit, - balance + balance, + feesBalance: feesBalance?.amount }; } - private async retrieveFeesLimit(granter: string, grantee: string, denom: string) { + private async retrieveFeesLimit(granter: string, grantee: string) { const feesAllowance = await this.allowanceHttpService.getFeeAllowanceForGranterAndGrantee(granter, grantee); - const feesSpendLimit = feesAllowance.allowance.spend_limit.find(limit => limit.denom === denom); + const feesSpendLimit = feesAllowance.allowance.spend_limit.find(limit => limit.denom === "uakt"); return feesSpendLimit ? parseFloat(feesSpendLimit.amount) : 0; } - private canTopUp(amount: number, balances: Pick) { - return balances.deploymentLimit > amount && balances.feesLimit > this.MIN_FEES_AVAILABLE && balances.balance > amount + this.MIN_FEES_AVAILABLE; + private async calculateTopUpAmount(blockRate: number, balances: Balances) { + const amount = await this.drainingDeploymentService.calculateTopUpAmount({ blockRate }); + + if (balances.denom === "uakt") { + const smallestAmount = Math.min(amount, balances.deploymentLimit - this.MIN_FEES_AVAILABLE, balances.balance - this.MIN_FEES_AVAILABLE); + return Math.max(smallestAmount, 0); + } + + return Math.min(amount, balances.deploymentLimit, balances.balance); + } + + private canTopUp(amount: number, balances: Balances) { + if (!amount) { + return false; + } + + const hasSufficientDeploymentLimit = amount <= balances.deploymentLimit; + const hasSufficientFeesLimit = balances.feesLimit >= this.MIN_FEES_AVAILABLE; + const hasSufficientFeesBalance = typeof balances.feesBalance === "undefined" || balances.feesBalance >= this.MIN_FEES_AVAILABLE; + const hasSufficientBalance = balances.balance >= (balances.denom === "uakt" ? amount + this.MIN_FEES_AVAILABLE : amount); + + return hasSufficientDeploymentLimit && hasSufficientFeesLimit && hasSufficientFeesBalance && hasSufficientBalance; } async topUpDeployment({ grantee, ...messageInput }: ExecDepositDeploymentMsgOptions, client: MasterSigningClientService, options: TopUpDeploymentsOptions) { diff --git a/packages/http-sdk/src/balance/balance-http.service.ts b/packages/http-sdk/src/balance/balance-http.service.ts index a9c3477ed..ea965b89d 100644 --- a/packages/http-sdk/src/balance/balance-http.service.ts +++ b/packages/http-sdk/src/balance/balance-http.service.ts @@ -3,13 +3,18 @@ import type { AxiosRequestConfig } from "axios"; import { HttpService } from "../http/http.service"; import type { Denom } from "../types/denom.type"; -export interface Balance { +export interface RawBalance { amount: string; denom: Denom; } +export interface Balance { + amount: number; + denom: Denom; +} + interface BalanceResponse { - balance: Balance; + balance: RawBalance; } export class BalanceHttpService extends HttpService { @@ -17,8 +22,8 @@ export class BalanceHttpService extends HttpService { super(config); } - async getBalance(address: string, denom: string) { + async getBalance(address: string, denom: string): Promise { const response = this.extractData(await this.get(`cosmos/bank/v1beta1/balances/${address}/by_denom?denom=${denom}`)); - return response.balance; + return response.balance ? { amount: parseFloat(response.balance.amount), denom: response.balance.denom } : undefined; } }