Skip to content

Commit

Permalink
feat(deployment): auto top up custodial deployment with available amount
Browse files Browse the repository at this point in the history
closes #524
  • Loading branch information
ygrishajev committed Dec 9, 2024
1 parent 70abd0d commit a782dc0
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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
? [
{
Expand All @@ -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
})
Expand All @@ -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 (
Expand All @@ -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(
[
{
Expand All @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,6 +16,7 @@ interface Balances {
feesLimit: number;
deploymentLimit: number;
balance: number;
feesBalance?: number;
}

interface TopUpSummary {
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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<Balances, "balance" | "deploymentLimit" | "feesLimit">) {
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) {
Expand Down
13 changes: 9 additions & 4 deletions packages/http-sdk/src/balance/balance-http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@ 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 {
constructor(config?: Pick<AxiosRequestConfig, "baseURL">) {
super(config);
}

async getBalance(address: string, denom: string) {
async getBalance(address: string, denom: string): Promise<Balance | undefined> {
const response = this.extractData(await this.get<BalanceResponse>(`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;
}
}

0 comments on commit a782dc0

Please sign in to comment.