Skip to content

fix(billing): classify escrow settlement underflow as non-retriable - #3526

Merged
baktun14 merged 1 commit into
mainfrom
fix/billing-classify-escrow-settlement-panic
Jul 29, 2026
Merged

fix(billing): classify escrow settlement underflow as non-retriable#3526
baktun14 merged 1 commit into
mainfrom
fix/billing-classify-escrow-settlement-panic

Conversation

@baktun14

@baktun14 baktun14 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Why

The CloseTrialDeployment job was panicking on-chain during close simulation with negative decimal coin amount: -2.0 when a trial deployment's escrow account is overdrawn. The panic surfaced as a generic 500, so pg-boss retried the deterministic failure 5 times over ~25 min, and the dseq/owner were never logged (identifying the affected deployment required a Grafana + on-chain dive).

The underlying chain bug (escrow full-block settlement builds a DecCoin from an overdraw-negative balance) is tracked for the Core team in Ref AKT-658. This PR is the console-side mitigation; the escrow account self-heals once it settles to zero, so retrying can never succeed and stop-and-alert is the correct behavior.

What

  • Classify the escrow-settlement panic (negative decimal coin amount) as a non-retriable 400 in both the api and tx-signer chain-error services, and expose isUnsettleableDeploymentError.
  • CloseTrialDeploymentHandler: on this error, log CLOSE_TRIAL_DEPLOYMENT_UNSETTLEABLE with walletId/dseq/owner/userId and return instead of throwing (so pg-boss doesn't retry a deterministic failure), and don't enqueue the "closed" notification. Other errors still rethrow (retry preserved).
  • Stale managed-deployments cleaner: downgrade the same error to a recognized DEPLOYMENT_CLEAN_UP_UNSETTLEABLE event instead of a generic cleanup error.
  • Unit tests for all four (api 35 pass, tx-signer 20 pass).

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of deployments with escrow failures that cannot be settled yet by treating them as HTTP 400 errors.
    • Prevented unnecessary retries and notification behavior for unsettleeable escrow failures, with clearer logged events in the close/cleanup workflows.
    • Ensured existing behavior remains for other error types.
  • Tests
    • Expanded unit coverage to verify the underflow/escrow-failure mapping and that unsettleeable vs unrelated vs fee-authorized scenarios behave correctly across the deployment closing and stale cleanup flows.

@baktun14
baktun14 requested a review from a team as a code owner July 27, 2026 12:48
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f6586fed-c265-4967-946c-721c1cf6bf08

📥 Commits

Reviewing files that changed from the base of the PR and between 578e191 and 35b027f.

📒 Files selected for processing (8)
  • apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts
  • apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.spec.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts
  • apps/tx-signer/src/services/chain-error/chain-error.service.spec.ts
  • apps/tx-signer/src/services/chain-error/chain-error.service.ts
📝 Walkthrough

Walkthrough

Changes

Unsettleable escrow handling

Layer / File(s) Summary
Escrow underflow classification
apps/api/src/billing/services/chain-error/*, apps/tx-signer/src/services/chain-error/*
Negative decimal coin amount errors map to HTTP 400, and the API exposes an unsettleable-deployment classifier with unit coverage.
Trial deployment close handling
apps/api/src/app/services/close-trial-deployment/*
Trial deployment close failures classified as unsettleable are logged and suppressed; other errors continue to propagate.
Stale deployment cleanup handling
apps/api/src/deployment/services/stale-managed-deployments-cleaner/*
Stale cleanup logs unsettleable failures without retrying or authorizing spending, while fee-related retry behavior remains covered by tests.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: ygrishajev, stalniy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/billing-classify-escrow-settlement-panic

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.85%. Comparing base (ef5bc88) to head (35b027f).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3526      +/-   ##
==========================================
- Coverage   74.51%   73.85%   -0.67%     
==========================================
  Files        1150     1086      -64     
  Lines       29939    28030    -1909     
  Branches     7481     7086     -395     
==========================================
- Hits        22310    20701    -1609     
+ Misses       6739     6472     -267     
+ Partials      890      857      -33     
Flag Coverage Δ *Carryforward flag
api 88.01% <100.00%> (+0.21%) ⬆️
deploy-web 64.09% <ø> (ø) Carriedforward from ef5bc88
log-collector ?
notifications 93.84% <ø> (ø) Carriedforward from ef5bc88
provider-console 81.38% <ø> (ø) Carriedforward from ef5bc88
provider-inventory ?
provider-proxy 88.17% <ø> (ø) Carriedforward from ef5bc88
tx-signer 86.72% <ø> (ø)

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
...trial-deployment/close-trial-deployment.handler.ts 95.55% <100.00%> (+0.68%) ⬆️
...illing/services/chain-error/chain-error.service.ts 100.00% <100.00%> (ø)
...eaner/stale-managed-deployments-cleaner.service.ts 100.00% <100.00%> (+35.29%) ⬆️
...er/src/services/chain-error/chain-error.service.ts 100.00% <ø> (ø)

... and 67 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@baktun14
baktun14 force-pushed the fix/billing-classify-escrow-settlement-panic branch from 281ba41 to 24b392c Compare July 29, 2026 16:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/billing/services/chain-error/chain-error.service.ts`:
- Around line 129-131: The isUnsettleableDeploymentError method only recognizes
the raw escrow underflow message, so it misses errors normalized by toAppError.
Update this method to also classify the mapped “Deployment escrow cannot be
settled yet” message or inspect the available originalError, and add coverage
for errors thrown as toAppError results so cleanup handlers suppress them
correctly.

In
`@apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts`:
- Around line 18-30: Update the “swallows an unsettleable escrow error” test and
its setup helper to return the logger, then assert that
DEPLOYMENT_CLEAN_UP_UNSETTLEABLE is emitted with the expected reason and owner
while preserving the existing no-refill and single-attempt assertions.

In
`@apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts`:
- Around line 80-85: Update the retry flow in the stale deployment cleanup
method around the fee-authorized retry and the existing
isUnsettleableDeploymentError classifier. Catch the retry’s underflow error,
classify it with the same unsettled-deployment handling, and emit
DEPLOYMENT_CLEAN_UP_UNSETTLEABLE with the deployment owner before suppressing
it, while preserving existing handling for other errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cd3d2062-aae1-4b9f-98e5-0be7b0f762f2

📥 Commits

Reviewing files that changed from the base of the PR and between 7d35e7f and 24b392c.

📒 Files selected for processing (8)
  • apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts
  • apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.spec.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts
  • apps/tx-signer/src/services/chain-error/chain-error.service.spec.ts
  • apps/tx-signer/src/services/chain-error/chain-error.service.ts

Comment thread apps/api/src/billing/services/chain-error/chain-error.service.ts

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring — the inline finding is confirmed and it undermines the entire purpose of this PR: I traced deploymentWriterService.close -> executeDecodedTxByUserWallet -> executeDerivedTx, and executeDerivedTx's catch block already rewrites the error via chainErrorService.toAppError before either call site ever checks isUnsettleableDeploymentError, so the new classification can never match in production.

Extended reasoning...

Overview

This PR adds isUnsettleableDeploymentError to classify a specific chain panic ("negative decimal coin amount") as non-retriable in CloseTrialDeploymentHandler and StaleManagedDeploymentsCleanerService, plus a matching status-code mapping in the tx-signer chain-error service.

Security risks

None — this is error classification/logging logic, no auth, injection, or data-exposure surface.

Level of scrutiny

This warrants a human look despite being a small, mechanical-looking diff, because the confirmed bug means the feature is currently a no-op: I verified ManagedSignerService.executeDerivedTx catches the raw chain error and rethrows it through chainErrorService.toAppError, which rewrites the message to "Deployment escrow cannot be settled yet" before either CloseTrialDeploymentHandler.handle's catch block or StaleManagedDeploymentsCleanerService.cleanUpForWallet ever calls isUnsettleableDeploymentError on it. Both call sites test for the raw clue string "negative decimal coin amount", which no longer appears in the message by the time it reaches them, so both always fall through to the retry/rethrow branch — exactly the pre-PR behavior the PR is meant to fix.

Other factors

The new/updated unit tests reject the raw chain-panic string directly into the mocked deploymentWriterService.close / managedSignerService.executeDerivedTx, bypassing the real toAppError rewrite that happens inside executeDerivedTx in production — which is why the tests pass despite the classification never firing end-to-end. This needs a fix (e.g. check the raw error via originalError, or also match the mapped message) before merge.

Comment thread apps/api/src/billing/services/chain-error/chain-error.service.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts`:
- Around line 99-100: Update the stale-managed-deployments cleaner
error-handling tests around the ErrorService setup and unrelated-error case to
use the same mocked logger passed into ErrorService, then assert the generic
error-handler path is invoked with the unrelated error rather than only
verifying cleanup() resolves. Preserve the existing expected behavior for
handled errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 727ea69e-65ea-4b19-b24a-a3eb3b885f1a

📥 Commits

Reviewing files that changed from the base of the PR and between 24b392c and 578e191.

📒 Files selected for processing (5)
  • apps/api/src/app/services/close-trial-deployment/close-trial-deployment.handler.spec.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.spec.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.spec.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/api/src/billing/services/chain-error/chain-error.service.ts
  • apps/api/src/billing/services/chain-error/chain-error.service.spec.ts
  • apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found this run. The critical bug I flagged previously — isUnsettleableDeploymentError never matching in production because toAppError rewrites the error message before classification runs — looks fixed in commit 578e191, which now also inspects originalError and routes the stale-cleaner's fee-authorized retry through the same classifier. Given this is production-incident mitigation code around chain-error retry semantics, and it already needed a fix for a no-op bug once, I think it's worth a human confirming the fix end-to-end before merge.

What was reviewed:

  • Confirmed isUnsettleableDeploymentError now matches on originalError.message as well as the top-level message, closing the gap that made the original mitigation a no-op against errors normalized by toAppError.
  • Confirmed the stale-cleaner's fee-authorized retry (previously unguarded) now routes through the same classifier via the new closeDeployments helper, so an underflow on retry is logged as DEPLOYMENT_CLEAN_UP_UNSETTLEABLE instead of escaping and being retried.
  • Checked both CloseTrialDeploymentHandler and the stale cleaner return (rather than throw) only on classified errors, so unrelated errors still rethrow/retry as before.
  • Chain-error mapping additions in both apps/api and apps/tx-signer are simple substring-to-status entries consistent with the existing pattern in each service.
Extended reasoning...

Overview

This PR classifies a specific on-chain panic (negative decimal coin amount, caused by an escrow account settling with a negative balance) as a non-retriable error in both apps/api and apps/tx-signer's chain-error services, and wires that classification into CloseTrialDeploymentHandler and StaleManagedDeploymentsCleanerService so pg-boss stops retrying a deterministic failure and instead logs a recognized event with the affected wallet/dseq/owner.

Security risks

None specific to this PR — no new auth, crypto, or user-input-handling surface. The main risk is a functional one: misclassifying which errors are safe to swallow vs. rethrow could hide real failures or spuriously suppress retries for a recoverable error. I traced both call sites and confirmed non-matching errors still hit the throw error / rethrow branch.

Level of scrutiny

This touches production incident-mitigation code in the billing/chain-error retry path, which is sensitive because pg-boss retry behavior and escrow error classification are hard to fully exercise in unit tests (as the previous run's finding showed — a subtle message-rewriting order-of-operations bug made the entire original mitigation a no-op). Given that history and the fact the code needed a real fix rather than a nit, this warrants a human's confirmation despite the fix now looking correct on inspection.

Other factors

No bugs were found by the bug hunting system in this run, and my own read of commit 578e191 shows it correctly addresses the previously-flagged defect plus the two CodeRabbit-flagged gaps (retry-path underflow, and a test that didn't assert the log event). Test coverage for the new paths looks meaningful (asserts the actual log payload, exercises the real ChainErrorService against a toAppError-normalized error rather than a raw string). I'm deferring primarily due to the sensitivity of the code path and its recent history, not because of any issue found in this pass.

@baktun14
baktun14 force-pushed the fix/billing-classify-escrow-settlement-panic branch from 578e191 to 90aa255 Compare July 29, 2026 17:33
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.
@baktun14
baktun14 force-pushed the fix/billing-classify-escrow-settlement-panic branch from 90aa255 to 35b027f Compare July 29, 2026 17:36

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the critical classification bug I flagged in a previous run (isUnsettleableDeploymentError not matching the toAppError-normalized message) is fixed in 578e191 and verified against the real production code path.

Extended reasoning...

Overview

This PR classifies the chain's escrow-settlement-underflow panic (negative decimal coin amount) as a non-retriable error across three consumers: ChainErrorService (api and tx-signer), CloseTrialDeploymentHandler, and StaleManagedDeploymentsCleanerService. On this specific error, the two job handlers now log a dedicated event and return instead of throwing, so pg-boss stops retrying a deterministic on-chain failure.

Security risks

None. This is error classification/control-flow logic for an internal billing job; no user input, auth, or crypto is touched. The main risk was purely functional: mis-classifying the error would either (a) keep retrying forever (status quo bug) or (b) silently swallow a real failure. Both are now covered by tests.

Level of scrutiny and verification performed

This required a high level of scrutiny given the risk of the fix being a silent no-op, which is exactly what happened in an earlier revision: I previously flagged that isUnsettleableDeploymentError only matched the raw chain panic text, but ManagedSignerService.executeDerivedTx always rewrites the error via chainErrorService.toAppError before either call site sees it, so the classifier would never match in production and the whole feature would be a no-op. I re-verified this against the current code: executeDerivedTx (managed-signer.service.ts:57-65) still throws the toAppError-normalized error, and isUnsettleableDeploymentError (chain-error.service.ts) now inspects both error.message and error.originalError.message, correctly catching the normalized case. This is covered by a regression test that runs isUnsettleableDeploymentError(await toAppError(rawPanic, [])) and asserts true, which fails against the old code.

I also checked the fee-authorized-retry path in the stale cleaner (extracted into closeDeployments()), confirming an underflow from the retry now reaches the same classifier, and confirmed the test-quality gap I raised about the 'rethrows unrelated errors' test (whether it could distinguish rethrow-then-swallow-by-ErrorService from silently-swallowed-before-ErrorService) is now resolved on disk — setup() returns the errorLogger passed into ErrorService, and the test asserts it receives the unrelated error with the DEPLOYMENT_CLEAN_UP_ERROR event.

Other factors

All four issues raised across this PR's review (three by CodeRabbit, one by me) were fixed with targeted regression coverage and confirmed by CodeRabbit and my own re-check of the current source. The bug-hunting system found nothing new this run.

@baktun14
baktun14 added this pull request to the merge queue Jul 29, 2026
Merged via the queue into main with commit 6e62e34 Jul 29, 2026
58 checks passed
@baktun14
baktun14 deleted the fix/billing-classify-escrow-settlement-panic branch July 29, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants