From 35b027fd2d74f8149fda75dbe8bd31c2440ebd49 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:30:40 +0100 Subject: [PATCH] fix(billing): classify escrow settlement underflow as non-retriable The CloseTrialDeployment job panics on-chain during close simulation with "negative decimal coin amount" when an escrow account is overdrawn. It was surfaced as a generic 500, so pg-boss retried the deterministic failure 5 times and the dseq/owner were never logged. Classify the panic in the api and tx-signer chain-error services (400), skip-with-alert in the trial-close handler (logs CLOSE_TRIAL_DEPLOYMENT_UNSETTLEABLE with dseq/owner instead of retrying), and downgrade it to a recognized DEPLOYMENT_CLEAN_UP_UNSETTLEABLE event in the stale managed-deployments cleaner. executeDerivedTx rewrites the error through toAppError before it reaches either handler, so isUnsettleableDeploymentError also inspects the originalError that toAppError attaches (matching only the raw message made the classifier a no-op in production). In the cleaner, the fee-authorized retry runs through a closeDeployments helper so an underflow from either attempt is classified and logged. Inject LoggerService so the event is assertable, and exercise the real ChainErrorService against a normalized error in the specs. --- .../close-trial-deployment.handler.spec.ts | 49 +++++- .../close-trial-deployment.handler.ts | 22 ++- .../chain-error/chain-error.service.spec.ts | 33 ++++ .../chain-error/chain-error.service.ts | 12 ++ ...anaged-deployments-cleaner.service.spec.ts | 153 ++++++++++++++---- ...ale-managed-deployments-cleaner.service.ts | 52 ++++-- .../chain-error/chain-error.service.spec.ts | 7 + .../chain-error/chain-error.service.ts | 1 + 8 files changed, 274 insertions(+), 55 deletions(-) diff --git a/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts b/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts index 6412f8ba4a..451d4ea747 100644 --- a/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts +++ b/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts @@ -1,9 +1,12 @@ -import type { DeploymentHttpService } from "@akashnetwork/http-sdk"; +import type { BalanceHttpService, DeploymentHttpService } from "@akashnetwork/http-sdk"; +import createError from "http-errors"; import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { UserWalletRepository } from "@src/billing/repositories"; import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; +import { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; +import type { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; import type { LoggerService } from "@src/core/providers/logging.provider"; import { JOB_NAME, type JobPayload, type JobQueueService } from "@src/core/services/job-queue/job-queue.service"; import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; @@ -179,6 +182,44 @@ describe(CloseTrialDeploymentHandler.name, () => { expect(jobQueueService.enqueue).not.toHaveBeenCalled(); }); + it("logs unsettleable event and skips retry and notification when escrow cannot be settled", async () => { + const wallet = createUserWallet({ + id: 123, + userId: "user-123", + address: "akash1test", + isTrialing: true + }); + + const closeError = createError(400, "Deployment escrow cannot be settled yet", { + originalError: new Error("Query failed with (6): rpc error: code = Unknown desc = recovered: negative decimal coin amount: -2.000000000000000000") + }); + + const { handler, jobQueueService, logger } = setup({ + findWalletById: vi.fn().mockResolvedValue(wallet), + findDeployment: vi.fn().mockResolvedValue({ deployment: { state: "active" } } as GetDeploymentResponse["data"]), + closeDeployment: vi.fn().mockRejectedValue(closeError) + }); + + const payload: JobPayload = { + walletId: wallet.id, + dseq: "test-dseq", + version: 1 + }; + + await expect(handler.handle(payload)).resolves.toBeUndefined(); + + expect(jobQueueService.enqueue).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith({ + event: "CLOSE_TRIAL_DEPLOYMENT_UNSETTLEABLE", + reason: "Deployment escrow cannot be settled yet; chain rejects close until it settles", + job: CloseTrialDeployment[JOB_NAME], + walletId: payload.walletId, + dseq: payload.dseq, + owner: wallet.address, + userId: wallet.userId + }); + }); + it("logs error when find deployment returns an error", async () => { const wallet = createUserWallet({ id: 123, userId: "user-123", address: "akash1test", isTrialing: true }); @@ -272,7 +313,8 @@ describe(CloseTrialDeploymentHandler.name, () => { }), billingConfig: mock({ get: vi.fn().mockReturnValue(input?.trialDeploymentLifetimeInHours ?? 24) - }) + }), + chainErrorService: new ChainErrorService(mock(), mock(), mock()) }; const handler = new CloseTrialDeploymentHandler( @@ -281,7 +323,8 @@ describe(CloseTrialDeploymentHandler.name, () => { mocks.jobQueueService, mocks.deploymentWriterService, mocks.deploymentService, - mocks.billingConfig + mocks.billingConfig, + mocks.chainErrorService ); return { handler, ...mocks }; diff --git a/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.ts b/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.ts index 1c216c88dd..2bd436110d 100644 --- a/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.ts +++ b/apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.ts @@ -3,6 +3,7 @@ import { singleton } from "tsyringe"; import { UserWalletRepository } from "@src/billing/repositories"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; +import { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; import { Job, JOB_NAME, JobHandler, JobPayload, JobQueueService, LoggerService } from "@src/core"; import { DeploymentWriterService } from "@src/deployment/services/deployment-writer/deployment-writer.service"; import { RESOLVED_MARKER } from "@src/notifications/services/notification-data-resolver/notification-data-resolver.service"; @@ -33,7 +34,8 @@ export class CloseTrialDeploymentHandler implements JobHandler): Promise { @@ -115,7 +117,23 @@ export class CloseTrialDeploymentHandler implements JobHandler { expect(appErr.message).toBe("Insufficient balance"); }); + it("returns 400 for escrow settlement underflow panic", async () => { + const { service } = setup(); + const err = new Error("Query failed with (6): rpc error: code = Unknown desc = recovered: negative decimal coin amount: -2.000000000000000000"); + + const appErr = await service.toAppError(err, encodeMessages); + expect(appErr).toBeInstanceOf(BadRequest); + expect(appErr.message).toBe("Deployment escrow cannot be settled yet"); + }); + it("returns 502 when cause is an AxiosError with 502 status", async () => { const { service } = setup(); const axiosError = new AxiosError("Request failed", "ERR_BAD_RESPONSE", undefined, undefined, { @@ -280,6 +289,30 @@ describe(ChainErrorService.name, () => { }); }); + describe("isUnsettleableDeploymentError", () => { + it("returns true for the escrow settlement underflow panic", () => { + const { service } = setup(); + const err = new Error("Query failed with (6): rpc error: code = Unknown desc = recovered: negative decimal coin amount: -2.000000000000000000"); + + expect(service.isUnsettleableDeploymentError(err)).toBe(true); + }); + + it("returns true for an error already normalized by toAppError", async () => { + const { service } = setup(); + const rawPanic = new Error("Query failed with (6): rpc error: code = Unknown desc = recovered: negative decimal coin amount: -2.000000000000000000"); + const appError = await service.toAppError(rawPanic, []); + + expect(appError.message).toBe("Deployment escrow cannot be settled yet"); + expect(service.isUnsettleableDeploymentError(appError)).toBe(true); + }); + + it("returns false for an unrelated error", () => { + const { service } = setup(); + + expect(service.isUnsettleableDeploymentError(new Error("insufficient balance"))).toBe(false); + }); + }); + function setup(): { balanceHttpService: MockProxy; billingConfigService: MockProxy; diff --git a/apps/api/src/billing/services/chain-error/chain-error.service.ts b/apps/api/src/billing/services/chain-error/chain-error.service.ts index d2fe27efd0..5a91ae7ed4 100644 --- a/apps/api/src/billing/services/chain-error/chain-error.service.ts +++ b/apps/api/src/billing/services/chain-error/chain-error.service.ts @@ -7,6 +7,8 @@ import { singleton } from "tsyringe"; import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; +const ESCROW_SETTLEMENT_UNDERFLOW_MESSAGE = "negative decimal coin amount" as const; + @singleton() export class ChainErrorService { private readonly ERRORS = { @@ -69,6 +71,10 @@ export class ChainErrorService { "insufficient balance": { code: 402, message: "Insufficient balance" + }, + [ESCROW_SETTLEMENT_UNDERFLOW_MESSAGE]: { + code: 400, + message: "Deployment escrow cannot be settled yet" } }; @@ -120,6 +126,12 @@ export class ChainErrorService { return /account closed|deployment closed/i.test(error.message); } + public isUnsettleableDeploymentError(error: Error): boolean { + const originalError = (error as { originalError?: unknown }).originalError; + const messages = [error.message, originalError instanceof Error ? originalError.message : undefined]; + return messages.some(message => message?.toLowerCase().includes(ESCROW_SETTLEMENT_UNDERFLOW_MESSAGE)); + } + public async isMasterWalletInsufficientFundsError(error: Error) { if (!error.message.toLowerCase().includes("insufficient funds")) return false; diff --git a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts index 4b2b62d0e2..130bdfa483 100644 --- a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts +++ b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts @@ -1,65 +1,146 @@ -import { describe, expect, it } from "vitest"; +import type { BalanceHttpService } from "@akashnetwork/http-sdk"; +import createError from "http-errors"; +import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { BillingConfig } from "@src/billing/providers"; -import type { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; +import type { UserWalletRepository } from "@src/billing/repositories"; import type { ManagedUserWalletService, RpcMessageService } from "@src/billing/services"; +import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; +import { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; import type { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; +import type { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; import type { BlockRepository } from "@src/chain/repositories/block.repository"; -import type { ErrorService } from "@src/core/services/error/error.service"; +import type { LoggerService } from "@src/core/providers/logging.provider"; +import { ErrorService } from "@src/core/services/error/error.service"; import type { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; import { StaleManagedDeploymentsCleanerService } from "./stale-managed-deployments-cleaner.service"; +import { createUserWallet } from "@test/seeders/user-wallet.seeder"; + +const UNSETTLEABLE_PANIC = "Query failed with (6): rpc error: code = Unknown desc = recovered: negative decimal coin amount: -2.000000000000000000"; +const UNSETTLEABLE_LOG = { + event: "DEPLOYMENT_CLEAN_UP_UNSETTLEABLE", + reason: "Deployment escrow cannot be settled yet; chain rejects close until it settles", + owner: "akash1test" +}; + describe(StaleManagedDeploymentsCleanerService.name, () => { - const wallet = mock({ id: 1, address: "akash1testaddr" }); + describe("cleanUpForWallet", () => { + it("cuts off well below the current height when no age override is passed", async () => { + const { service, deploymentRepository, wallet } = setup({ currentHeight: 1_000_000 }); - it("cuts off well below the current height when no age override is passed", async () => { - const { service, deploymentRepository } = setup({ currentHeight: 1_000_000 }); + await service.cleanUpForWallet(wallet); - await service.cleanUpForWallet(wallet); + const cutoff = deploymentRepository.findStaleDeployments.mock.calls[0][0].createdHeight; + expect(cutoff).toBeLessThan(1_000_000); + }); - const cutoff = deploymentRepository.findStaleDeployments.mock.calls[0][0].createdHeight; - expect(cutoff).toBeLessThan(1_000_000); - }); + it("uses the current height as the cutoff when age 0 is passed so every lease-less deployment is stale", async () => { + const { service, deploymentRepository, wallet } = setup({ currentHeight: 1_000_000 }); - it("uses the current height as the cutoff when age 0 is passed so every lease-less deployment is stale", async () => { - const { service, deploymentRepository } = setup({ currentHeight: 1_000_000 }); + await service.cleanUpForWallet(wallet, 0); - await service.cleanUpForWallet(wallet, 0); + expect(deploymentRepository.findStaleDeployments).toHaveBeenCalledWith({ owner: wallet.address, createdHeight: 1_000_000 }); + }); - expect(deploymentRepository.findStaleDeployments).toHaveBeenCalledWith({ owner: wallet.address, createdHeight: 1_000_000 }); - }); + it("does not broadcast when there are no stale deployments", async () => { + const { service, managedSignerService, wallet } = setup({ staleDeployments: [] }); + + await service.cleanUpForWallet(wallet, 0); - it("does not broadcast when there are no stale deployments", async () => { - const { service, managedSignerService } = setup({ staleDeployments: [] }); + expect(managedSignerService.executeDerivedTx).not.toHaveBeenCalled(); + }); - await service.cleanUpForWallet(wallet, 0); + it("closes all stale deployments in a single derived tx", async () => { + const closeMsg = { typeUrl: "/close", value: {} }; + const { service, managedSignerService, rpcMessageService, wallet } = setup({ staleDeployments: [{ dseq: 1 }, { dseq: 2 }] }); + rpcMessageService.getCloseDeploymentMsg.mockReturnValue(closeMsg as never); - expect(managedSignerService.executeDerivedTx).not.toHaveBeenCalled(); + await service.cleanUpForWallet(wallet, 0); + + expect(managedSignerService.executeDerivedTx).toHaveBeenCalledWith(wallet.id, [closeMsg, closeMsg]); + }); }); - it("closes all stale deployments in a single derived tx", async () => { - const closeMsg = { typeUrl: "/close", value: {} }; - const { service, managedSignerService, rpcMessageService } = setup({ staleDeployments: [{ dseq: 1 }, { dseq: 2 }] }); - rpcMessageService.getCloseDeploymentMsg.mockReturnValue(closeMsg as never); + describe("cleanup", () => { + it("logs the unsettleable event and swallows the error without refilling fees or retrying", async () => { + const { service, managedSignerService, managedUserWalletService, logger, errorLogger } = setup({ + executeDerivedTx: vi.fn().mockRejectedValue(buildUnsettleableAppError()) + }); + + await expect(service.cleanup({ concurrency: 1 })).resolves.toBeUndefined(); + + expect(managedSignerService.executeDerivedTx).toHaveBeenCalledTimes(1); + expect(managedUserWalletService.authorizeSpending).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(UNSETTLEABLE_LOG); + expect(errorLogger.error).not.toHaveBeenCalled(); + }); + + it("refills fees and retries when the wallet is not allowed to pay fees", async () => { + const executeDerivedTx = vi.fn().mockRejectedValueOnce(new Error("not allowed to pay fees")).mockResolvedValueOnce(undefined); + const { service, managedUserWalletService, logger } = setup({ executeDerivedTx }); + + await service.cleanup({ concurrency: 1 }); - await service.cleanUpForWallet(wallet, 0); + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledTimes(1); + expect(executeDerivedTx).toHaveBeenCalledTimes(2); + expect(logger.error).not.toHaveBeenCalled(); + }); - expect(managedSignerService.executeDerivedTx).toHaveBeenCalledWith(wallet.id, [closeMsg, closeMsg]); + it("logs the unsettleable event when the fee-authorized retry hits the escrow underflow", async () => { + const executeDerivedTx = vi.fn().mockRejectedValueOnce(new Error("not allowed to pay fees")).mockRejectedValueOnce(buildUnsettleableAppError()); + const { service, managedUserWalletService, logger } = setup({ executeDerivedTx }); + + await expect(service.cleanup({ concurrency: 1 })).resolves.toBeUndefined(); + + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledTimes(1); + expect(executeDerivedTx).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledWith(UNSETTLEABLE_LOG); + }); + + it("rethrows unrelated errors into the wallet error handler without retrying", async () => { + const unexpectedError = new Error("some unexpected failure"); + const { service, managedSignerService, managedUserWalletService, logger, errorLogger } = setup({ + executeDerivedTx: vi.fn().mockRejectedValue(unexpectedError) + }); + + await expect(service.cleanup({ concurrency: 1 })).resolves.toBeUndefined(); + + expect(managedSignerService.executeDerivedTx).toHaveBeenCalledTimes(1); + expect(managedUserWalletService.authorizeSpending).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(errorLogger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "DEPLOYMENT_CLEAN_UP_ERROR", error: unexpectedError })); + }); }); - function setup(input?: { currentHeight?: number; staleDeployments?: { dseq: number }[] }) { - const userWalletRepository = mock(); + function buildUnsettleableAppError() { + return createError(400, "Deployment escrow cannot be settled yet", { originalError: new Error(UNSETTLEABLE_PANIC) }); + } + + function setup(input?: { currentHeight?: number; staleDeployments?: { dseq: number }[]; executeDerivedTx?: ManagedSignerService["executeDerivedTx"] }) { + const wallet = createUserWallet({ id: 123, address: "akash1test" }); + + const userWalletRepository = mock({ + paginate: vi.fn(async (_options, cb) => { + await cb([wallet]); + }) as UserWalletRepository["paginate"] + }); const deploymentRepository = mock(); const blockRepository = mock(); const rpcMessageService = mock(); - const managedSignerService = mock(); - const config = mock(); + const managedSignerService = mock({ + executeDerivedTx: input?.executeDerivedTx ?? vi.fn().mockResolvedValue(undefined) + }); const managedUserWalletService = mock(); - const errorService = mock(); + const config = mock({ FEE_ALLOWANCE_REFILL_AMOUNT: 1000 }); + const logger = mock(); + const errorLogger = mock(); + const errorService = new ErrorService(errorLogger); + const chainErrorService = new ChainErrorService(mock(), mock(), mock()); blockRepository.getLatestProcessedHeight.mockResolvedValue(input?.currentHeight ?? 1_000_000); - deploymentRepository.findStaleDeployments.mockResolvedValue(input?.staleDeployments ?? []); + deploymentRepository.findStaleDeployments.mockResolvedValue(input?.staleDeployments ?? [{ dseq: 456 }]); const service = new StaleManagedDeploymentsCleanerService( userWalletRepository, @@ -69,19 +150,23 @@ describe(StaleManagedDeploymentsCleanerService.name, () => { managedSignerService, config, managedUserWalletService, - errorService + errorService, + chainErrorService, + logger ); return { service, + wallet, userWalletRepository, deploymentRepository, blockRepository, rpcMessageService, managedSignerService, - config, managedUserWalletService, - errorService + chainErrorService, + logger, + errorLogger }; } }); diff --git a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts index c767b5d8c1..2c1b71e5d0 100644 --- a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts +++ b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts @@ -1,12 +1,14 @@ -import { createOtelLogger } from "@akashnetwork/logging/otel"; +import type { EncodeObject } from "@cosmjs/proto-signing"; import { secondsInMinute } from "date-fns/constants"; import { singleton } from "tsyringe"; import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { ManagedUserWalletService, RpcMessageService } from "@src/billing/services"; +import { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { BlockRepository } from "@src/chain/repositories/block.repository"; +import { LoggerService } from "@src/core"; import { ErrorService } from "@src/core/services/error/error.service"; import { DeploymentRepository } from "@src/deployment/repositories/deployment/deployment.repository"; import { CleanUpStaleDeploymentsParams } from "@src/deployment/types/state-deployments"; @@ -14,8 +16,6 @@ import { averageBlockTime } from "@src/utils/constants"; @singleton() export class StaleManagedDeploymentsCleanerService { - private readonly logger = createOtelLogger({ context: StaleManagedDeploymentsCleanerService.name }); - private readonly MAX_LIVE_BLOCKS = Math.floor((10 * secondsInMinute) / averageBlockTime); constructor( @@ -26,8 +26,12 @@ export class StaleManagedDeploymentsCleanerService { private readonly managedSignerService: ManagedSignerService, @InjectBillingConfig() private readonly config: BillingConfig, private readonly managedUserWalletService: ManagedUserWalletService, - private readonly errorService: ErrorService - ) {} + private readonly errorService: ErrorService, + private readonly chainErrorService: ChainErrorService, + private readonly logger: LoggerService + ) { + this.logger.setContext(StaleManagedDeploymentsCleanerService.name); + } async cleanup(options: CleanUpStaleDeploymentsParams) { await this.userWalletRepository.paginate({ limit: options.concurrency || 10 }, async wallets => { @@ -62,22 +66,38 @@ export class StaleManagedDeploymentsCleanerService { this.logger.info({ event: "DEPLOYMENT_CLEAN_UP", owner: wallet.address }); try { - await this.managedSignerService.executeDerivedTx(wallet.id, messages); + await this.closeDeployments(wallet, messages); this.logger.info({ event: "DEPLOYMENT_CLEAN_UP_SUCCESS", owner: wallet.address }); - } catch (error: any) { - if (error.message.includes("not allowed to pay fees")) { - await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { - address: wallet.address!, - limits: { - fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT - } + } catch (error) { + if (error instanceof Error && this.chainErrorService.isUnsettleableDeploymentError(error)) { + this.logger.error({ + event: "DEPLOYMENT_CLEAN_UP_UNSETTLEABLE", + reason: "Deployment escrow cannot be settled yet; chain rejects close until it settles", + owner: wallet.address }); + return; + } + + throw error; + } + } - await this.managedSignerService.executeDerivedTx(wallet.id, messages); - this.logger.info({ event: "DEPLOYMENT_CLEAN_UP_SUCCESS", owner: wallet.address }); - } else { + private async closeDeployments(wallet: UserWalletOutput, messages: EncodeObject[]) { + try { + await this.managedSignerService.executeDerivedTx(wallet.id, messages); + } catch (error: any) { + if (!error.message.includes("not allowed to pay fees")) { throw error; } + + await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { + address: wallet.address!, + limits: { + fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT + } + }); + + await this.managedSignerService.executeDerivedTx(wallet.id, messages); } } } diff --git a/apps/tx-signer/src/services/chain-error/chain-error.service.spec.ts b/apps/tx-signer/src/services/chain-error/chain-error.service.spec.ts index f5119f1fbd..6f8d8efdc2 100644 --- a/apps/tx-signer/src/services/chain-error/chain-error.service.spec.ts +++ b/apps/tx-signer/src/services/chain-error/chain-error.service.spec.ts @@ -97,6 +97,13 @@ describe(ChainErrorService.name, () => { ).toBe(402); }); + it("returns 400 for escrow settlement underflow panic", () => { + const { service } = setup(); + expect( + service.getChainErrorStatus("Query failed with (6): rpc error: code = Unknown desc = recovered: negative decimal coin amount: -2.000000000000000000") + ).toBe(400); + }); + it("returns 503 for bad status on response 502 error", () => { const { service } = setup(); expect(service.getChainErrorStatus("Bad status on response: 502")).toBe(503); diff --git a/apps/tx-signer/src/services/chain-error/chain-error.service.ts b/apps/tx-signer/src/services/chain-error/chain-error.service.ts index 6dfb730fb4..b5fddafc68 100644 --- a/apps/tx-signer/src/services/chain-error/chain-error.service.ts +++ b/apps/tx-signer/src/services/chain-error/chain-error.service.ts @@ -18,6 +18,7 @@ export class ChainErrorService { "order not open": 400, "invalid unit price": 400, "insufficient balance": 402, + "negative decimal coin amount": 400, "bad status on response: 502": 503, "bad status on response: 503": 503, "bad status on response: 504": 503