diff --git a/apps/api/package.json b/apps/api/package.json index d82cc4801..d1c4aebd8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -49,15 +49,18 @@ "@opentelemetry/instrumentation-pino": "^0.41.0", "@opentelemetry/sdk-node": "^0.52.1", "@sentry/node": "^7.55.2", - "@supercharge/promise-pool": "^3.2.0", + "async-sema": "^3.1.1", "axios": "^1.7.2", "commander": "^12.1.0", "cosmjs-types": "^0.9.0", + "dataloader": "^2.2.2", "date-fns": "^2.29.2", "date-fns-tz": "^1.3.6", "dotenv": "^12.0.4", "drizzle-orm": "^0.31.2", "hono": "3.12.0", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", "human-interval": "^2.0.1", "js-sha256": "^0.9.0", "lodash": "^4.17.21", @@ -82,6 +85,8 @@ "devDependencies": { "@akashnetwork/dev-config": "*", "@faker-js/faker": "^8.4.1", + "@types/http-assert": "^1.5.5", + "@types/http-errors": "^2.0.4", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.0", "@types/memory-cache": "^0.2.2", diff --git a/apps/api/src/billing/config/env.config.ts b/apps/api/src/billing/config/env.config.ts index 028bab813..0b138ee32 100644 --- a/apps/api/src/billing/config/env.config.ts +++ b/apps/api/src/billing/config/env.config.ts @@ -8,7 +8,12 @@ const envSchema = z.object({ TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT: z.number({ coerce: true }), TRIAL_FEES_ALLOWANCE_AMOUNT: z.number({ coerce: true }), TRIAL_ALLOWANCE_DENOM: z.string(), - GAS_SAFETY_MULTIPLIER: z.number({ coerce: true }).default(1.5) + GAS_SAFETY_MULTIPLIER: z.number({ coerce: true }).default(1.5), + FEE_ALLOWANCE_REFILL_THRESHOLD: z.number({ coerce: true }), + DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD: z.number({ coerce: true }), + FEE_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }), + DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT: z.number({ coerce: true }), + ALLOWANCE_REFILL_BATCH_SIZE: z.number({ coerce: true }).default(10) }); export const envConfig = envSchema.parse(process.env); diff --git a/apps/api/src/billing/controllers/wallet/wallet.controller.ts b/apps/api/src/billing/controllers/wallet/wallet.controller.ts index 99f994347..3e4e469e5 100644 --- a/apps/api/src/billing/controllers/wallet/wallet.controller.ts +++ b/apps/api/src/billing/controllers/wallet/wallet.controller.ts @@ -1,5 +1,4 @@ import type { EncodeObject } from "@cosmjs/proto-signing"; -import { PromisePool } from "@supercharge/promise-pool"; import pick from "lodash/pick"; import { singleton } from "tsyringe"; @@ -8,8 +7,8 @@ import { UserWalletRepository } from "@src/billing/repositories"; import type { CreateWalletRequestInput, SignTxRequestInput, SignTxResponseOutput } from "@src/billing/routes"; import { GetWalletQuery } from "@src/billing/routes/get-wallet-list/get-wallet-list.router"; import { ManagedUserWalletService, WalletInitializerService } from "@src/billing/services"; +import { RefillService } from "@src/billing/services/refill/refill.service"; import { TxSignerService } from "@src/billing/services/tx-signer/tx-signer.service"; -import { WithTransaction } from "@src/core/services"; // TODO: authorize endpoints below @singleton() @@ -18,10 +17,10 @@ export class WalletController { private readonly walletManager: ManagedUserWalletService, private readonly userWalletRepository: UserWalletRepository, private readonly walletInitializer: WalletInitializerService, - private readonly signerService: TxSignerService + private readonly signerService: TxSignerService, + private readonly refillService: RefillService ) {} - @WithTransaction() async create({ data: { userId } }: CreateWalletRequestInput): Promise { return { data: await this.walletInitializer.initialize(userId) @@ -41,18 +40,7 @@ export class WalletController { }; } - async refillAll() { - const wallets = await this.userWalletRepository.find(); - const { results, errors } = await PromisePool.withConcurrency(2) - .for(wallets) - .process(async wallet => { - return await this.walletManager.refill(wallet); - }); - - if (errors) { - console.log("DEBUG errors", errors); - } - - console.log("DEBUG results", results); + async refillWallets() { + await this.refillService.refillAll(); } } diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index 99359dc9a..0c0060f13 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, eq, lte, or } from "drizzle-orm"; import first from "lodash/first"; import { singleton } from "tsyringe"; @@ -60,7 +60,7 @@ export class UserWalletRepository { async find(query?: Partial) { const fields = query && (Object.keys(query) as Array); - const where = fields.length ? and(...fields.map(field => eq(this.userWallet[field], query[field]))) : undefined; + const where = fields?.length ? and(...fields.map(field => eq(this.userWallet[field], query[field]))) : undefined; return this.toOutputList( await this.cursor.query.userWalletSchema.findMany({ @@ -69,6 +69,15 @@ export class UserWalletRepository { ); } + async findDrainingWallets(thresholds = { fee: 0, deployment: 0 }, options?: Pick) { + return this.toOutputList( + await this.cursor.query.userWalletSchema.findMany({ + where: or(lte(this.userWallet.deploymentAllowance, thresholds.deployment.toString()), lte(this.userWallet.feeAllowance, thresholds.fee.toString())), + limit: options?.limit || 10 + }) + ); + } + async findByUserId(userId: UserWalletOutput["userId"]) { return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: eq(this.userWallet.userId, userId) })); } diff --git a/apps/api/src/billing/services/batch-signing-stargate-client/batch-signing-stargate-client.ts b/apps/api/src/billing/services/batch-signing-stargate-client/batch-signing-stargate-client.ts new file mode 100644 index 000000000..bc8bc89a4 --- /dev/null +++ b/apps/api/src/billing/services/batch-signing-stargate-client/batch-signing-stargate-client.ts @@ -0,0 +1,37 @@ +import type { OfflineSigner } from "@cosmjs/proto-signing"; +import { HttpEndpoint, SigningStargateClient, SigningStargateClientOptions } from "@cosmjs/stargate"; +import { CometClient, connectComet } from "@cosmjs/tendermint-rpc"; +import type { BroadcastTxSyncResponse } from "@cosmjs/tendermint-rpc/build/comet38"; + +export type { BroadcastTxSyncResponse }; + +export class BatchSigningStargateClient extends SigningStargateClient { + public static async connectWithSigner( + endpoint: string | HttpEndpoint, + signer: OfflineSigner, + options: SigningStargateClientOptions = {} + ): Promise { + const cometClient = await connectComet(endpoint); + return this.createWithSigner(cometClient, signer, options); + } + + public static async createWithSigner( + cometClient: CometClient, + signer: OfflineSigner, + options: SigningStargateClientOptions = {} + ): Promise { + return new BatchSigningStargateClient(cometClient, signer, options); + } + + protected constructor( + cometClient: CometClient | undefined, + private readonly localSigner: OfflineSigner, + options: SigningStargateClientOptions + ) { + super(cometClient, localSigner, options); + } + + public async tmBroadcastTxSync(tx: Uint8Array): Promise { + return (await this.forceGetCometClient().broadcastTxSync({ tx })) as BroadcastTxSyncResponse; + } +} diff --git a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts index 801effc96..8ad73ebae 100644 --- a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts +++ b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts @@ -1,15 +1,15 @@ +import { AllowanceHttpService } from "@akashnetwork/http-sdk"; import { stringToPath } from "@cosmjs/crypto"; -import { DirectSecp256k1HdWallet, EncodeObject } from "@cosmjs/proto-signing"; -import { calculateFee, GasPrice } from "@cosmjs/stargate"; +import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing"; +import { IndexedTx } from "@cosmjs/stargate"; import add from "date-fns/add"; import { singleton } from "tsyringe"; import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { MasterSigningClientService } from "@src/billing/services/master-signing-client/master-signing-client.service"; import { MasterWalletService } from "@src/billing/services/master-wallet/master-wallet.service"; -import { RpcMessageService } from "@src/billing/services/rpc-message-service/rpc-message.service"; +import { RpcMessageService, SpendingAuthorizationMsgOptions } from "@src/billing/services/rpc-message-service/rpc-message.service"; import { LoggerService } from "@src/core"; -import { InternalServerException } from "@src/core/exceptions/internal-server.exception"; interface SpendingAuthorizationOptions { address: string; @@ -17,7 +17,7 @@ interface SpendingAuthorizationOptions { deployment: number; fees: number; }; - expiration: Date; + expiration?: Date; } @singleton() @@ -32,7 +32,8 @@ export class ManagedUserWalletService { @InjectBillingConfig() private readonly config: BillingConfig, private readonly masterWalletService: MasterWalletService, private readonly masterSigningClientService: MasterSigningClientService, - private readonly rpcMessageService: RpcMessageService + private readonly rpcMessageService: RpcMessageService, + private readonly allowanceHttpService: AllowanceHttpService ) {} async createAndAuthorizeTrialSpending({ addressIndex }: { addressIndex: number }) { @@ -63,56 +64,44 @@ export class ManagedUserWalletService { } async authorizeSpending(options: SpendingAuthorizationOptions) { - try { - const messages = this.rpcMessageService.getAllGrantMsgs({ - granter: await this.masterWalletService.getFirstAddress(), - grantee: options.address, - denom: this.config.TRIAL_ALLOWANCE_DENOM, - expiration: options.expiration, - limits: options.limits - }); - const fee = await this.estimateFee(messages, this.config.TRIAL_ALLOWANCE_DENOM); - const txResult = await this.masterSigningClientService.signAndBroadcast(messages, fee); - - if (txResult.code !== 0) { - this.logger.error({ event: "SPENDING_AUTHORIZATION_FAILED", address: options.address, txResult }); - throw new InternalServerException("Failed to authorize spending for address"); - } - - this.logger.debug({ event: "SPENDING_AUTHORIZED", address: options.address }); - } catch (error) { - error.message = `Failed to authorize spending for address ${options.address}: ${error.message}`; - this.logger.error(error); - - if (error.message.includes("fee allowance already exists")) { - this.logger.debug({ event: "SPENDING_ALREADY_AUTHORIZED", address: options.address }); - await this.revokeSpending(options.address); - - await this.authorizeSpending(options); - } else { - throw error; - } - } + const masterWalletAddress = await this.masterWalletService.getFirstAddress(); + const msgOptions = { + granter: masterWalletAddress, + grantee: options.address, + denom: this.config.TRIAL_ALLOWANCE_DENOM, + expiration: options.expiration + }; + + await Promise.all([ + this.authorizeDeploymentSpending({ + ...msgOptions, + limit: options.limits.deployment + }), + this.authorizeFeeSpending({ + ...msgOptions, + limit: options.limits.fees + }) + ]); + + this.logger.debug({ event: "SPENDING_AUTHORIZED", address: options.address }); } - private async revokeSpending(address: string) { - const revokeMessage = this.rpcMessageService.getRevokeAllowanceMsg({ - granter: await this.masterWalletService.getFirstAddress(), - grantee: address - }); + private async authorizeFeeSpending(options: SpendingAuthorizationMsgOptions) { + const feeAllowances = await this.allowanceHttpService.getFeeAllowancesForGrantee(options.grantee); + const feeAllowance = feeAllowances.find(allowance => allowance.granter === options.granter); + const results: Promise[] = []; - const fee = await this.estimateFee([revokeMessage], this.config.TRIAL_ALLOWANCE_DENOM); - await this.masterSigningClientService.signAndBroadcast([revokeMessage], fee); - } + if (feeAllowance) { + results.push(this.masterSigningClientService.executeTx([this.rpcMessageService.getRevokeAllowanceMsg(options)])); + } - private async estimateFee(messages: readonly EncodeObject[], denom: string) { - const gasEstimation = await this.masterSigningClientService.simulate(messages, "allowance grant"); - const estimatedGas = Math.round(gasEstimation * this.config.GAS_SAFETY_MULTIPLIER); + results.push(this.masterSigningClientService.executeTx([this.rpcMessageService.getFeesAllowanceGrantMsg(options)])); - return calculateFee(estimatedGas, GasPrice.fromString(`0.025${denom}`)); + return await Promise.all(results); } - async refill(wallet: any) { - return wallet; + private async authorizeDeploymentSpending(options: SpendingAuthorizationMsgOptions) { + const deploymentAllowanceMsg = this.rpcMessageService.getDepositDeploymentGrantMsg(options); + return await this.masterSigningClientService.executeTx([deploymentAllowanceMsg]); } } diff --git a/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts b/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts index 7cc436e81..7943cc5c9 100644 --- a/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts +++ b/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts @@ -1,24 +1,61 @@ import type { StdFee } from "@cosmjs/amino"; +import { toHex } from "@cosmjs/encoding"; import { EncodeObject, Registry } from "@cosmjs/proto-signing"; -import { SigningStargateClient } from "@cosmjs/stargate"; +import { calculateFee, GasPrice } from "@cosmjs/stargate"; import type { SignerData } from "@cosmjs/stargate/build/signingstargateclient"; +import { BroadcastTxSyncResponse } from "@cosmjs/tendermint-rpc/build/comet38"; +import { Sema } from "async-sema"; +import { TxRaw } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import DataLoader from "dataloader"; +import assert from "http-assert"; import { singleton } from "tsyringe"; import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provider"; +import { BatchSigningStargateClient } from "@src/billing/services/batch-signing-stargate-client/batch-signing-stargate-client"; import { MasterWalletService } from "@src/billing/services/master-wallet/master-wallet.service"; +interface ShortAccountInfo { + accountNumber: number; + sequence: number; +} + @singleton() export class MasterSigningClientService { - private readonly clientAsPromised: Promise; + private readonly clientAsPromised: Promise; + + private readonly semaphore = new Sema(1); + + private accountInfo: ShortAccountInfo; + + private chainId: string; + + private execTxLoader = new DataLoader( + async (batchedMessages: readonly EncodeObject[][]) => { + return this.executeTxBatch(batchedMessages); + }, + { cache: false, batchScheduleFn: callback => setTimeout(callback, 100) } + ); constructor( @InjectBillingConfig() private readonly config: BillingConfig, private readonly masterWalletService: MasterWalletService, @InjectTypeRegistry() private readonly registry: Registry ) { - this.clientAsPromised = SigningStargateClient.connectWithSigner(this.config.RPC_NODE_ENDPOINT, this.masterWalletService, { - registry + this.clientAsPromised = this.initClient(); + } + + private async initClient() { + return BatchSigningStargateClient.connectWithSigner(this.config.RPC_NODE_ENDPOINT, this.masterWalletService, { + registry: this.registry + }).then(async client => { + this.accountInfo = await client.getAccount(await this.masterWalletService.getFirstAddress()).then(account => ({ + accountNumber: account.accountNumber, + sequence: account.sequence + })); + this.chainId = await client.getChainId(); + + return client; }); } @@ -33,4 +70,71 @@ export class MasterSigningClientService { async simulate(messages: readonly EncodeObject[], memo: string) { return (await this.clientAsPromised).simulate(await this.masterWalletService.getFirstAddress(), messages, memo); } + + async executeTx(messages: EncodeObject[]) { + const tx = await this.execTxLoader.load(messages); + + assert(tx.code === 0, 500, "Failed to sign and broadcast tx", { data: tx }); + + return tx; + } + + private async executeTxBatch(messages: readonly EncodeObject[][]) { + await this.semaphore.acquire(); + + const txes: TxRaw[] = []; + let txIndex: number = 0; + + try { + const client = await this.clientAsPromised; + const masterAddress = await this.masterWalletService.getFirstAddress(); + + while (txIndex < messages.length) { + txes.push( + await client.sign( + masterAddress, + messages[txIndex], + await this.estimateFee(messages[txIndex], this.config.TRIAL_ALLOWANCE_DENOM, { mock: true }), + "", + { + accountNumber: this.accountInfo.accountNumber, + sequence: this.accountInfo.sequence++, + chainId: this.chainId + } + ) + ); + txIndex++; + } + + const responses: BroadcastTxSyncResponse[] = []; + txIndex = 0; + + while (txIndex < txes.length - 1) { + const txRaw: TxRaw = txes[txIndex]; + responses.push(await client.tmBroadcastTxSync(TxRaw.encode(txRaw).finish())); + txIndex++; + } + + const lastDelivery = await client.broadcastTx(TxRaw.encode(txes[txes.length - 1]).finish()); + const hashes = [...responses.map(hash => toHex(hash.hash)), lastDelivery.transactionHash]; + + return await Promise.all(hashes.map(hash => client.getTx(hash))); + } finally { + this.semaphore.release(); + } + } + + private async estimateFee(messages: readonly EncodeObject[], denom: string, options?: { mock?: boolean }) { + if (options?.mock) { + return { + amount: [{ denom: this.config.TRIAL_ALLOWANCE_DENOM, amount: "15000" }], + gas: "500000" + }; + } + + const gasEstimation = await this.simulate(messages, ""); + const estimatedGas = Math.round(gasEstimation * this.config.GAS_SAFETY_MULTIPLIER); + + return calculateFee(estimatedGas, GasPrice.fromString(`0.025${denom}`)); + } } diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts new file mode 100644 index 000000000..d8e4de61d --- /dev/null +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -0,0 +1,57 @@ +import { singleton } from "tsyringe"; + +import { BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; +import { ManagedUserWalletService } from "@src/billing/services"; +import { BalancesService } from "@src/billing/services/balances/balances.service"; +import { LoggerService } from "@src/core"; + +@singleton() +export class RefillService { + private readonly logger = new LoggerService({ context: RefillService.name }); + + constructor( + @InjectBillingConfig() private readonly config: BillingConfig, + private readonly userWalletRepository: UserWalletRepository, + private readonly managedUserWalletService: ManagedUserWalletService, + private readonly balancesService: BalancesService + ) {} + + async refillAll() { + const wallets = await this.userWalletRepository.findDrainingWallets( + { + deployment: this.config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD, + fee: this.config.FEE_ALLOWANCE_REFILL_THRESHOLD + }, + { limit: this.config.ALLOWANCE_REFILL_BATCH_SIZE } + ); + + if (wallets.length) { + try { + await Promise.all(wallets.map(wallet => this.refillWallet(wallet))); + } finally { + await this.refillAll(); + } + } + } + + private async refillWallet(wallet: UserWalletOutput) { + await this.chargeUser(wallet); + + const limits = { + deployment: this.config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT, + fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT + }; + + await this.managedUserWalletService.authorizeSpending({ + address: wallet.address, + limits + }); + + await this.balancesService.updateUserWalletLimits(wallet); + } + + private async chargeUser(wallet: UserWalletOutput) { + this.logger.debug({ event: "CHARGE_USER", wallet }); + } +} diff --git a/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts b/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts index 3cf0dcd0c..7df22ca3d 100644 --- a/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts +++ b/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts @@ -4,7 +4,7 @@ import { BasicAllowance } from "cosmjs-types/cosmos/feegrant/v1beta1/feegrant"; import { MsgGrantAllowance } from "cosmjs-types/cosmos/feegrant/v1beta1/tx"; import { singleton } from "tsyringe"; -interface SpendingAuthorizationOptions { +export interface SpendingAuthorizationMsgOptions { granter: string; grantee: string; denom: string; @@ -14,70 +14,59 @@ interface SpendingAuthorizationOptions { @singleton() export class RpcMessageService { - getAllGrantMsgs( - options: Omit & { - limits: { - deployment: number; - fees: number; - }; - } - ) { - return [this.getGrantMsg({ ...options, limit: options.limits.deployment }), this.getGrantBasicAllowanceMsg({ ...options, limit: options.limits.fees })]; - } - - getGrantBasicAllowanceMsg({ denom, limit, expiration, granter, grantee }: SpendingAuthorizationOptions) { - const allowance = { - typeUrl: "/cosmos.feegrant.v1beta1.BasicAllowance", - value: Uint8Array.from( - BasicAllowance.encode({ - spendLimit: [ - { - denom: denom, - amount: limit.toString() - } - ], - expiration: expiration - ? { - seconds: BigInt(Math.floor(expiration.getTime() / 1_000)), - nanos: Math.floor((expiration.getTime() % 1_000) * 1_000_000) - } - : undefined - }).finish() - ) - }; - + getFeesAllowanceGrantMsg({ denom, limit, expiration, granter, grantee }: SpendingAuthorizationMsgOptions) { return { typeUrl: "/cosmos.feegrant.v1beta1.MsgGrantAllowance", value: MsgGrantAllowance.fromPartial({ - granter: granter, - grantee: grantee, - allowance: allowance + granter, + grantee, + allowance: { + typeUrl: "/cosmos.feegrant.v1beta1.BasicAllowance", + value: Uint8Array.from( + BasicAllowance.encode({ + spendLimit: [ + { + denom, + amount: limit.toString() + } + ], + expiration: expiration + ? { + seconds: BigInt(Math.floor(expiration.getTime() / 1_000)), + nanos: Math.floor((expiration.getTime() % 1_000) * 1_000_000) + } + : undefined + }).finish() + ) + } }) }; } - getGrantMsg({ denom, limit, expiration, granter, grantee }: SpendingAuthorizationOptions) { + getDepositDeploymentGrantMsg({ denom, limit, expiration, granter, grantee }: SpendingAuthorizationMsgOptions) { return { typeUrl: "/cosmos.authz.v1beta1.MsgGrant", value: { - granter: granter, - grantee: grantee, + granter, + grantee, grant: { authorization: { typeUrl: "/akash.deployment.v1beta3.DepositDeploymentAuthorization", value: DepositDeploymentAuthorization.encode( DepositDeploymentAuthorization.fromPartial({ spendLimit: { - denom: denom, + denom, amount: limit.toString() } }) ).finish() }, - expiration: { - seconds: Math.floor(expiration.getTime() / 1_000), - nanos: Math.floor((expiration.getTime() % 1_000) * 1_000_000) - } + expiration: expiration + ? { + seconds: Math.floor(expiration.getTime() / 1_000), + nanos: Math.floor((expiration.getTime() % 1_000) * 1_000_000) + } + : undefined } } }; @@ -85,10 +74,10 @@ export class RpcMessageService { getRevokeAllowanceMsg({ granter, grantee }: { granter: string; grantee: string }) { return { - typeUrl: "/cosmos.authz.v1beta1.MsgRevoke", + typeUrl: "/cosmos.feegrant.v1beta1.MsgRevokeAllowance", value: MsgRevoke.fromPartial({ - granter: granter, - grantee: grantee, + granter, + grantee, msgTypeUrl: "/cosmos.feegrant.v1beta1.MsgGrantAllowance" }) }; diff --git a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts index 9934476d7..637d23cdd 100644 --- a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts +++ b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts @@ -1,8 +1,8 @@ -import { AllowanceHttpService } from "@akashnetwork/http-sdk"; import { stringToPath } from "@cosmjs/crypto"; import { DirectSecp256k1HdWallet, EncodeObject, Registry } from "@cosmjs/proto-signing"; import { calculateFee, GasPrice, SigningStargateClient } from "@cosmjs/stargate"; import { DeliverTxResponse } from "@cosmjs/stargate/build/stargateclient"; +import assert from "http-assert"; import pick from "lodash/pick"; import { singleton } from "tsyringe"; @@ -11,7 +11,6 @@ import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provide import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { MasterWalletService } from "@src/billing/services"; import { BalancesService } from "@src/billing/services/balances/balances.service"; -import { ForbiddenException } from "@src/core"; type StringifiedEncodeObject = Omit & { value: string }; type SimpleSigningStargateClient = { @@ -29,15 +28,14 @@ export class TxSignerService { @InjectTypeRegistry() private readonly registry: Registry, private readonly userWalletRepository: UserWalletRepository, private readonly masterWalletService: MasterWalletService, - private readonly allowanceHttpService: AllowanceHttpService, private readonly balancesService: BalancesService ) {} async signAndBroadcast(userId: UserWalletOutput["userId"], messages: StringifiedEncodeObject[]) { const userWallet = await this.userWalletRepository.findByUserId(userId); - const decodedMessages = this.decodeMessages(messages); + assert(userWallet, 403, "User wallet not found"); - ForbiddenException.assert(userWallet); + const decodedMessages = this.decodeMessages(messages); const client = await this.getClientForAddressIndex(userWallet.id); const tx = await client.signAndBroadcast(decodedMessages); diff --git a/apps/api/src/console.ts b/apps/api/src/console.ts index 176bae059..979d364db 100644 --- a/apps/api/src/console.ts +++ b/apps/api/src/console.ts @@ -1,25 +1,35 @@ import "reflect-metadata"; +import "./dotenv"; +import "./open-telemetry"; +import { context, trace } from "@opentelemetry/api"; import { Command } from "commander"; -import dotenv from "dotenv"; import { container } from "tsyringe"; import { WalletController } from "@src/billing/controllers/wallet/wallet.controller"; - -dotenv.config({ path: ".env.local" }); -dotenv.config(); +import { LoggerService } from "@src/core"; const program = new Command(); program.name("API Console").description("CLI to run API commands").version("0.0.0"); +const tracer = trace.getTracer("API Console"); program .command("refill-wallets") .description("Refill draining wallets") .action(async () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - await require("./core/providers/postgres.provider").migratePG(); - await container.resolve(WalletController).refillAll(); + await context.with(trace.setSpan(context.active(), tracer.startSpan("refill-wallets")), async () => { + const logger = new LoggerService({ context: "CLI" }); + logger.info("Refilling wallets"); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { migratePG, closeConnections } = await require("./core/providers/postgres.provider"); + await migratePG(); + + await container.resolve(WalletController).refillWallets(); + + await closeConnections(); + logger.info("Finished refilling wallets"); + }); }); program.parse(); diff --git a/apps/api/src/core/config/env.config.ts b/apps/api/src/core/config/env.config.ts index 9a9185575..669c0b872 100644 --- a/apps/api/src/core/config/env.config.ts +++ b/apps/api/src/core/config/env.config.ts @@ -5,7 +5,8 @@ const envSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).optional().default("development"), LOG_FORMAT: z.enum(["json", "pretty"]).optional().default("json"), // TODO: make required once billing is in prod - POSTGRES_DB_URI: z.string().optional() + POSTGRES_DB_URI: z.string().optional(), + POSTGRES_MAX_CONNECTIONS: z.number({ coerce: true }).optional().default(20) }); export const envConfig = envSchema.parse(process.env); diff --git a/apps/api/src/core/exceptions/forbidden.exception.ts b/apps/api/src/core/exceptions/forbidden.exception.ts deleted file mode 100644 index 198b33feb..000000000 --- a/apps/api/src/core/exceptions/forbidden.exception.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ManagedException } from "@src/core/exceptions/managed.exception"; - -export class ForbiddenException extends ManagedException { - static assert(condition: unknown): void { - if (!condition) { - throw new ForbiddenException(); - } - } -} diff --git a/apps/api/src/core/exceptions/index.ts b/apps/api/src/core/exceptions/index.ts deleted file mode 100644 index 7a1192391..000000000 --- a/apps/api/src/core/exceptions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./managed.exception"; -export * from "./forbidden.exception"; diff --git a/apps/api/src/core/exceptions/internal-server.exception.ts b/apps/api/src/core/exceptions/internal-server.exception.ts deleted file mode 100644 index 0ca007eac..000000000 --- a/apps/api/src/core/exceptions/internal-server.exception.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ManagedException } from "@src/core/exceptions/managed.exception"; - -export class InternalServerException extends ManagedException { - static assert(condition: unknown): void { - if (!condition) { - throw new InternalServerException(); - } - } -} diff --git a/apps/api/src/core/exceptions/managed.exception.ts b/apps/api/src/core/exceptions/managed.exception.ts deleted file mode 100644 index 72faee4d6..000000000 --- a/apps/api/src/core/exceptions/managed.exception.ts +++ /dev/null @@ -1,10 +0,0 @@ -type ManagedExceptionData = Record; - -export class ManagedException extends Error { - readonly data: ManagedExceptionData; - constructor(message?: string, data?: ManagedExceptionData) { - super(message); - this.name = this.constructor.name; - this.data = data; - } -} diff --git a/apps/api/src/core/exceptions/not-found.exception.ts b/apps/api/src/core/exceptions/not-found.exception.ts deleted file mode 100644 index 673427386..000000000 --- a/apps/api/src/core/exceptions/not-found.exception.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ManagedException } from "@src/core/exceptions/managed.exception"; - -export class NotFoundException extends ManagedException { - static assert(condition: unknown): void { - if (!condition) { - throw new NotFoundException(); - } - } -} diff --git a/apps/api/src/core/index.ts b/apps/api/src/core/index.ts index e50b947d6..62b2597da 100644 --- a/apps/api/src/core/index.ts +++ b/apps/api/src/core/index.ts @@ -1,3 +1,2 @@ export * from "./providers"; -export * from "./exceptions"; export * from "./services"; diff --git a/apps/api/src/core/providers/postgres.provider.ts b/apps/api/src/core/providers/postgres.provider.ts index 55356df49..215bd2769 100644 --- a/apps/api/src/core/providers/postgres.provider.ts +++ b/apps/api/src/core/providers/postgres.provider.ts @@ -12,7 +12,7 @@ import * as userSchemas from "@src/user/model-schemas"; const logger = new LoggerService({ context: "POSTGRES" }); const migrationClient = postgres(config.POSTGRES_DB_URI, { max: 1, onnotice: logger.info.bind(logger) }); -const appClient = postgres(config.POSTGRES_DB_URI); +const appClient = postgres(config.POSTGRES_DB_URI, { max: config.POSTGRES_MAX_CONNECTIONS, onnotice: logger.info.bind(logger) }); const schema = { ...userSchemas, ...billingSchemas }; const drizzleOptions = { logger: new DefaultLogger({ writer: new PostgresLoggerService() }), schema }; @@ -29,3 +29,5 @@ export const InjectPg = () => inject(POSTGRES_DB); export type ApiPgDatabase = typeof pgDatabase; export type ApiPgSchema = typeof schema; + +export const closeConnections = async () => await Promise.all([migrationClient.end(), appClient.end()]); diff --git a/apps/api/src/core/services/hono-error-handler/hono-error-handler.service.ts b/apps/api/src/core/services/hono-error-handler/hono-error-handler.service.ts index e11fcde42..0869f210b 100644 --- a/apps/api/src/core/services/hono-error-handler/hono-error-handler.service.ts +++ b/apps/api/src/core/services/hono-error-handler/hono-error-handler.service.ts @@ -1,16 +1,10 @@ import type { Context, Env } from "hono"; +import { isHttpError } from "http-errors"; import { singleton } from "tsyringe"; import { ZodError } from "zod"; -import { ForbiddenException, ManagedException } from "@src/core/exceptions"; -import { NotFoundException } from "@src/core/exceptions/not-found.exception"; import { LoggerService } from "@src/core/services/logger/logger.service"; -const EXCEPTION_STATUSES = { - [ForbiddenException.name]: 403, - [NotFoundException.name]: 404 -}; - @singleton() export class HonoErrorHandlerService { private readonly logger = new LoggerService({ context: "ErrorHandler" }); @@ -22,10 +16,9 @@ export class HonoErrorHandlerService { handle(error: Error, c: Context): Response | Promise { this.logger.error(error); - if (error instanceof ManagedException) { + if (isHttpError(error)) { const { name } = error.constructor; - const status = EXCEPTION_STATUSES[name]; - return c.json({ error: name, message: error.message, data: error.data }, { status }); + return c.json({ error: name, message: error.message, data: error.data }, { status: error.status }); } if (error instanceof ZodError) { diff --git a/apps/api/src/core/services/logger/logger.service.ts b/apps/api/src/core/services/logger/logger.service.ts index ab234aa79..41e499d62 100644 --- a/apps/api/src/core/services/logger/logger.service.ts +++ b/apps/api/src/core/services/logger/logger.service.ts @@ -1,3 +1,4 @@ +import { isHttpError } from "http-errors"; import pino, { Bindings, LoggerOptions } from "pino"; import pretty from "pino-pretty"; @@ -36,6 +37,9 @@ export class LoggerService { } protected toLoggableInput(message: any) { + if (isHttpError(message)) { + return { status: message.status, message: message.message, stack: message.stack, data: message.data }; + } if (message instanceof Error) { return message.stack; } diff --git a/apps/api/src/core/services/tx/tx.service.ts b/apps/api/src/core/services/tx/tx.service.ts index ba7b72fa2..bee846366 100644 --- a/apps/api/src/core/services/tx/tx.service.ts +++ b/apps/api/src/core/services/tx/tx.service.ts @@ -1,6 +1,5 @@ import type { ExtractTablesWithRelations } from "drizzle-orm"; import type { PgTransaction } from "drizzle-orm/pg-core"; -import type {} from "drizzle-orm/postgres-js"; import { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js/session"; import { AsyncLocalStorage } from "node:async_hooks"; import { container, singleton } from "tsyringe"; diff --git a/apps/api/src/dotenv.ts b/apps/api/src/dotenv.ts new file mode 100644 index 000000000..e8ae1ea63 --- /dev/null +++ b/apps/api/src/dotenv.ts @@ -0,0 +1,4 @@ +import dotenv from "dotenv"; + +dotenv.config({ path: ".env.local" }); +dotenv.config(); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b33bf686b..5885ef7ea 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,10 +1,6 @@ import "reflect-metadata"; import "./open-telemetry"; - -import dotenv from "dotenv"; - -dotenv.config({ path: ".env.local" }); -dotenv.config(); +import "./dotenv"; async function bootstrap() { /* eslint-disable @typescript-eslint/no-var-requires */ diff --git a/apps/api/src/user/controllers/user/user.controller.ts b/apps/api/src/user/controllers/user/user.controller.ts index cb55aea90..657eebbe7 100644 --- a/apps/api/src/user/controllers/user/user.controller.ts +++ b/apps/api/src/user/controllers/user/user.controller.ts @@ -1,6 +1,6 @@ +import assert from "http-assert"; import { singleton } from "tsyringe"; -import { NotFoundException } from "@src/core/exceptions/not-found.exception"; import { UserRepository } from "@src/user/repositories"; import { GetUserParams } from "@src/user/routes/get-anonymous-user/get-anonymous-user.router"; import { AnonymousUserResponseOutput } from "@src/user/routes/schemas/user.schema"; @@ -18,7 +18,7 @@ export class UserController { async getById({ id }: GetUserParams): Promise { const user = await this.userRepository.findAnonymousById(id); - NotFoundException.assert(user); + assert(user, 404, "User not found"); return { data: user }; } diff --git a/apps/api/test/functional/app.spec.ts b/apps/api/test/functional/app.spec.ts index 41957447b..a7aa14746 100644 --- a/apps/api/test/functional/app.spec.ts +++ b/apps/api/test/functional/app.spec.ts @@ -1,15 +1,10 @@ import { app, initDb } from "@src/app"; -import { closeConnections } from "@src/db/dbConnection"; describe("app", () => { beforeAll(async () => { await initDb(); }); - afterAll(async () => { - await closeConnections(); - }); - describe("GET /status", () => { it("should return app stats and meta", async () => { const res = await app.request("/status"); diff --git a/apps/api/test/functional/create-wallet.spec.ts b/apps/api/test/functional/create-wallet.spec.ts index dd4ccbbdb..1856b47f8 100644 --- a/apps/api/test/functional/create-wallet.spec.ts +++ b/apps/api/test/functional/create-wallet.spec.ts @@ -6,7 +6,7 @@ import { BILLING_CONFIG, BillingConfig, USER_WALLET_SCHEMA, UserWalletSchema } f import { ApiPgDatabase, POSTGRES_DB } from "@src/core"; import { USER_SCHEMA, UserSchema } from "@src/user/providers"; -jest.setTimeout(10000); +jest.setTimeout(20000); describe("wallets", () => { const userWalletSchema = container.resolve(USER_WALLET_SCHEMA); diff --git a/apps/api/test/functional/wallets-refill.spec.ts b/apps/api/test/functional/wallets-refill.spec.ts new file mode 100644 index 000000000..49e7a643d --- /dev/null +++ b/apps/api/test/functional/wallets-refill.spec.ts @@ -0,0 +1,70 @@ +import { WalletService } from "@test/services/wallet.service"; +import { container } from "tsyringe"; + +import { app } from "@src/app"; +import { WalletController } from "@src/billing/controllers/wallet/wallet.controller"; +import { BILLING_CONFIG, BillingConfig, USER_WALLET_SCHEMA, UserWalletSchema } from "@src/billing/providers"; +import { UserWalletRepository } from "@src/billing/repositories"; +import { ManagedUserWalletService } from "@src/billing/services"; +import { ApiPgDatabase, POSTGRES_DB } from "@src/core"; +import { USER_SCHEMA, UserSchema } from "@src/user/providers"; + +jest.setTimeout(240000); + +describe("Wallets Refill", () => { + const managedUserWalletService = container.resolve(ManagedUserWalletService); + const db = container.resolve(POSTGRES_DB); + const userWalletSchema = container.resolve(USER_WALLET_SCHEMA); + const userSchema = container.resolve(USER_SCHEMA); + const config = container.resolve(BILLING_CONFIG); + const walletController = container.resolve(WalletController); + const walletService = new WalletService(app); + const userWalletRepository = container.resolve(UserWalletRepository); + + afterEach(async () => { + await Promise.all([db.delete(userWalletSchema), db.delete(userSchema)]); + }); + + describe("console refill-wallets", () => { + it("should refill draining wallets", async () => { + const prepareRecords = Array.from({ length: 15 }).map(async () => { + const records = await walletService.createUserAndWallet(); + const user = records.user; + let wallet = records.wallet; + + expect(wallet.creditAmount).toBe(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT + config.TRIAL_FEES_ALLOWANCE_AMOUNT); + const limits = { + deployment: config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD, + fees: config.FEE_ALLOWANCE_REFILL_THRESHOLD + }; + await managedUserWalletService.authorizeSpending({ + address: wallet.address, + limits + }); + await userWalletRepository.updateById( + wallet.id, + { + deploymentAllowance: String(limits.deployment), + feeAllowance: String(limits.fees) + }, + { returning: true } + ); + wallet = await walletService.getWalletByUserId(user.id); + + expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD + config.FEE_ALLOWANCE_REFILL_THRESHOLD); + + return { user, wallet }; + }); + + const records = await Promise.all(prepareRecords); + await walletController.refillWallets(); + + await Promise.all( + records.map(async ({ wallet, user }) => { + wallet = await walletService.getWalletByUserId(user.id); + expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT + config.FEE_ALLOWANCE_REFILL_AMOUNT); + }) + ); + }); + }); +}); diff --git a/apps/api/test/services/wallet.service.ts b/apps/api/test/services/wallet.service.ts new file mode 100644 index 000000000..a2c07c65a --- /dev/null +++ b/apps/api/test/services/wallet.service.ts @@ -0,0 +1,32 @@ +import { Hono } from "hono"; + +export class WalletService { + constructor(private readonly app: Hono) {} + + async createUserAndWallet() { + const userResponse = await this.app.request("/v1/anonymous-users", { + method: "POST", + headers: new Headers({ "Content-Type": "application/json" }) + }); + const { data: user } = await userResponse.json(); + const walletResponse = await this.app.request("/v1/wallets", { + method: "POST", + body: JSON.stringify({ + data: { userId: user.id } + }), + headers: new Headers({ "Content-Type": "application/json" }) + }); + const { data: wallet } = await walletResponse.json(); + + return { user, wallet }; + } + + async getWalletByUserId(userId: string): Promise<{ id: number; address: string; creditAmount: number }> { + const walletResponse = await this.app.request(`/v1/wallets?userId=${userId}`, { + headers: new Headers({ "Content-Type": "application/json" }) + }); + const { data } = await walletResponse.json(); + + return data[0]; + } +} diff --git a/apps/api/test/setup-functional-tests.ts b/apps/api/test/setup-functional-tests.ts index b2729433f..e219016d2 100644 --- a/apps/api/test/setup-functional-tests.ts +++ b/apps/api/test/setup-functional-tests.ts @@ -2,10 +2,14 @@ import "reflect-metadata"; import dotenv from "dotenv"; -import { migratePG } from "@src/core"; +import { closeConnections, migratePG } from "@src/core"; dotenv.config({ path: ".env.functional.test" }); beforeAll(async () => { await migratePG(); }); + +afterAll(async () => { + await closeConnections(); +}); diff --git a/apps/deploy-web/src/hooks/useAllowance.tsx b/apps/deploy-web/src/hooks/useAllowance.tsx index e553c3ec6..793bb2721 100644 --- a/apps/deploy-web/src/hooks/useAllowance.tsx +++ b/apps/deploy-web/src/hooks/useAllowance.tsx @@ -26,7 +26,7 @@ const AllowanceNotificationMessage: FC = () => ( export const useAllowance = () => { const { address } = useWallet(); - const [defaultFeeGranter, setDefaultFeeGranter] = useLocalStorage("default-fee-granter", undefined); + const [defaultFeeGranter, setDefaultFeeGranter] = useLocalStorage(`default-fee-granters/${address}`, undefined); const { data: allFeeGranters, isLoading, isFetched } = useAllowancesGranted(address); const { enqueueSnackbar } = useSnackbar(); diff --git a/package-lock.json b/package-lock.json index 5bd077aa8..4f515616f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,15 +48,18 @@ "@opentelemetry/instrumentation-pino": "^0.41.0", "@opentelemetry/sdk-node": "^0.52.1", "@sentry/node": "^7.55.2", - "@supercharge/promise-pool": "^3.2.0", + "async-sema": "^3.1.1", "axios": "^1.7.2", "commander": "^12.1.0", "cosmjs-types": "^0.9.0", + "dataloader": "^2.2.2", "date-fns": "^2.29.2", "date-fns-tz": "^1.3.6", "dotenv": "^12.0.4", "drizzle-orm": "^0.31.2", "hono": "3.12.0", + "http-assert": "^1.5.0", + "http-errors": "^2.0.0", "human-interval": "^2.0.1", "js-sha256": "^0.9.0", "lodash": "^4.17.21", @@ -81,6 +84,8 @@ "devDependencies": { "@akashnetwork/dev-config": "*", "@faker-js/faker": "^8.4.1", + "@types/http-assert": "^1.5.5", + "@types/http-errors": "^2.0.4", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.0", "@types/memory-cache": "^0.2.2", @@ -18906,14 +18911,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.54.2.tgz", "integrity": "sha512-R1PwtDvUfs99cAjfuQ/WpwJ3c92+DAMy9xGApjqlWQMj0FKQabUAys2swfTRNzuYAYJh7NqK2dzcYVNkKLEKUg==" }, - "node_modules/@supercharge/promise-pool": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@supercharge/promise-pool/-/promise-pool-3.2.0.tgz", - "integrity": "sha512-pj0cAALblTZBPtMltWOlZTQSLT07jIaFNeM8TWoJD1cQMgDB9mcMlVMoetiB35OzNJpqQ2b+QEtwiR9f20mADg==", - "engines": { - "node": ">=8" - } - }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -19376,6 +19373,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/http-assert": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.5.tgz", + "integrity": "sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==", + "dev": true + }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -22444,6 +22447,11 @@ "tslib": "^2.0.0" } }, + "node_modules/async-sema": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -25321,6 +25329,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dataloader": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", + "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==" + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -25441,6 +25454,11 @@ } } }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==" + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -29194,6 +29212,49 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",