Skip to content

fix(billing): stamp wallet activation only after trial provisioning succeeds#3520

Open
baktun14 wants to merge 2 commits into
mainfrom
fix/billing-stamp-activation-only-on-success
Open

fix(billing): stamp wallet activation only after trial provisioning succeeds#3520
baktun14 wants to merge 2 commits into
mainfrom
fix/billing-stamp-activation-only-on-success

Conversation

@baktun14

@baktun14 baktun14 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Why

Related to CON-744

After deploying #3513, every trial started in a ~2h prod window ended up with activated_at = NULL even though provisioning fully succeeded on-chain (fee + deposit grants landed, users were actively deploying). Verified against prod data and public LCD state.

Root cause: activated_at doubled as both the in-flight claim marker and the completion marker.

  1. A trial start stamped activated_at up front and rolled it back on failure. The rollback was unconditional, so a slow failing attempt could clear a stamp it no longer owned.
  2. The idempotency fast-path read the mid-flight stamp as "trial started" and returned 200 while the claiming attempt could still fail and unstamp.

Net effect: provisioned, actively-deploying wallets that are invisible to GET /v1/wallets, skipped by the refill cron (findDrainingWallets), and ignored by the trial fingerprint check. Affected prod rows were repaired manually with a one-off UPDATE.

What

  • New nullable user_wallets.activation_claimed_at column (migration 0034, additive) guards in-flight provisioning:
    • claimActivation sets it conditionally; a claim older than 5 minutes is considered abandoned (crashed/hung attempt) and can be taken over.
    • releaseActivationClaim clears only the claim identified by its own timestamp, so a stale attempt cannot clobber a newer claim.
  • activated_at is now written only on success, atomically with the granted allowances — there is no rollback to get wrong, and the fast-path/GET /v1/wallets/refill cron/fingerprint check can only ever observe completed activations.
  • RefillService uses the new markActivated (direct stamp, no claim cycle) since funding-based activation has no post-claim provisioning step to guard.

No API contract changes. The migration is a single nullable-column ALTER TABLE — no backfill, no locks beyond a brief ACCESS EXCLUSIVE.

Summary by CodeRabbit

  • New Features

    • Added a safer wallet activation workflow using separate in-progress activation claims and completed activation timestamps.
    • Added stale-claim takeover so abandoned activation attempts can be recovered after a short period.
    • Improved activation failure handling by releasing the correct activation claim to allow future attempts.
  • Bug Fixes

    • Prevented concurrent activation attempts from conflicting or overwriting each other.
    • Ensured wallets that are already activated are not reprocessed.

…ucceeds

A trial start claimed activation by stamping activated_at up front and
rolled the stamp back on failure. The rollback was unconditional and the
stamp was readable mid-flight, so the idempotency fast-path could report
the trial as started while a slow failing attempt later cleared the
stamp - leaving provisioned, actively-deploying wallets invisible to
the refill cron, the wallets endpoint and the trial fingerprint check.

Provisioning is now guarded by a dedicated activation_claimed_at column
with stale-claim takeover, releases are scoped to the claim they own,
and activated_at is written only together with the granted allowances.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c381f18e-5172-4b89-b1a4-6bd6d137cae1

📥 Commits

Reviewing files that changed from the base of the PR and between a2cd4d1 and 9c90aaf.

📒 Files selected for processing (2)
  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts
  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts
  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts

📝 Walkthrough

Walkthrough

Adds a nullable wallet activation claim timestamp, stale-claim takeover, guarded claim release, and explicit activation completion. Trial initialization now releases failed claims, while refill activation uses markActivated; migrations, fixtures, and integration tests are updated.

Changes

Wallet activation flow

Layer / File(s) Summary
Activation claim schema and migration
apps/api/drizzle/0034_natural_piledriver.sql, apps/api/drizzle/meta/*, apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts, apps/api/test/seeders/user-wallet.seeder.ts
Adds the nullable timezone-aware activationClaimedAt field to the wallet schema, migration metadata, and test fixtures.
Claim acquisition and lifecycle
apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts, apps/api/src/billing/repositories/user-wallet/user-wallet.repository.integration.ts
Claims unactivated wallets, replaces claims older than five minutes, releases only matching claims, and clears claims when activation completes.
Trial initialization claim handling
apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts, apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts
Persists activation completion and releases the current claim when trial authorization fails, while logging release errors.
Refill and activated-wallet integration
apps/api/src/billing/services/refill/refill.service.ts, apps/api/src/billing/services/refill/refill.service.spec.ts, apps/api/src/user/repositories/user/user.repository.integration.ts
Uses markActivated for refill activation and updates related integration coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: ygrishajev

✨ 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-stamp-activation-only-on-success

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

@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

🧹 Nitpick comments (2)
apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts (1)

153-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the vacuous negative assertion.

not.toHaveBeenCalledWith(wallet.id, { activatedAt: null }) passes even if updateById is called with something entirely different; in the new failure path it isn't called at all. Assert that directly.

♻️ Proposed change
       expect(releaseActivationClaim).toHaveBeenCalledWith(wallet.id, claimedAt);
-      expect(updateWalletById).not.toHaveBeenCalledWith(wallet.id, { activatedAt: null });
+      expect(updateWalletById).not.toHaveBeenCalled();

As per path instructions, "Verify meaningful assertions, not just snapshot coverage".

🤖 Prompt for 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.

In
`@apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts`
around lines 153 - 170, In the test “releases only its own claim and keeps the
wallet when authorization fails,” replace the vacuous updateWalletById argument
assertion with a direct assertion that updateWalletById is not called at all.
Keep the existing releaseActivationClaim assertion unchanged.

Source: Path instructions

apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts (1)

94-111: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider computing the claim stamp and cutoff in the DB rather than the app clock.

new Date() for both the stamp and staleClaimCutoff means the claim lifetime depends on per-instance clock skew: an instance whose clock runs behind can take over a claim that is younger than 5 minutes (or refuse to take over a genuinely abandoned one). sql\now()`/sql`now() - interval '5 minutes'`` would make the window skew-independent. With a 5-minute window this is unlikely to bite in practice, so it's a hardening nit rather than a defect.

🤖 Prompt for 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.

In `@apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts`
around lines 94 - 111, Update claimActivation to use database-side timestamps
for both activationClaimedAt and staleClaimCutoff, using the existing SQL
expression utilities to represent current time and five minutes earlier. Remove
the app-clock Date calculations while preserving the existing accessibility,
activation, and stale-claim conditions.
🤖 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/wallet-initializer/wallet-initializer.service.ts`:
- Around line 67-70: Update the catch block in the wallet-initializer
provisioning flow to preserve and rethrow the original error even when
releaseActivationClaim fails. Add the service’s standard
LoggerService/createOtelLogger setup and wrap releaseActivationClaim in its own
try/catch, logging release failures through that logger before rethrowing the
original error.

---

Nitpick comments:
In `@apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts`:
- Around line 94-111: Update claimActivation to use database-side timestamps for
both activationClaimedAt and staleClaimCutoff, using the existing SQL expression
utilities to represent current time and five minutes earlier. Remove the
app-clock Date calculations while preserving the existing accessibility,
activation, and stale-claim conditions.

In
`@apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts`:
- Around line 153-170: In the test “releases only its own claim and keeps the
wallet when authorization fails,” replace the vacuous updateWalletById argument
assertion with a direct assertion that updateWalletById is not called at all.
Keep the existing releaseActivationClaim assertion unchanged.
🪄 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: f485078c-c9a6-4879-aef8-1fb14489198a

📥 Commits

Reviewing files that changed from the base of the PR and between 627ad0c and a2cd4d1.

📒 Files selected for processing (12)
  • apps/api/drizzle/0034_natural_piledriver.sql
  • apps/api/drizzle/meta/0034_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts
  • apps/api/src/billing/repositories/user-wallet/user-wallet.repository.integration.ts
  • apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts
  • apps/api/src/billing/services/refill/refill.service.spec.ts
  • apps/api/src/billing/services/refill/refill.service.ts
  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts
  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts
  • apps/api/src/user/repositories/user/user.repository.integration.ts
  • apps/api/test/seeders/user-wallet.seeder.ts

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.22%. Comparing base (627ad0c) to head (9c90aaf).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3520      +/-   ##
==========================================
- Coverage   74.18%   73.22%   -0.97%     
==========================================
  Files        1142     1052      -90     
  Lines       29721    27378    -2343     
  Branches     7447     6986     -461     
==========================================
- Hits        22049    20047    -2002     
+ Misses       6771     6461     -310     
+ Partials      901      870      -31     
Flag Coverage Δ *Carryforward flag
api 87.43% <100.00%> (+0.02%) ⬆️ Carriedforward from a2cd4d1
deploy-web 63.68% <ø> (ø) Carriedforward from a2cd4d1
log-collector ?
notifications 93.84% <ø> (ø) Carriedforward from a2cd4d1
provider-console 81.38% <ø> (ø) Carriedforward from a2cd4d1
provider-inventory ?
provider-proxy 88.17% <ø> (ø) Carriedforward from a2cd4d1
tx-signer ?

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

Files with missing lines Coverage Δ
...ng/model-schemas/user-wallet/user-wallet.schema.ts 75.00% <ø> (ø)
...repositories/user-wallet/user-wallet.repository.ts 83.33% <100.00%> (+1.97%) ⬆️
.../api/src/billing/services/refill/refill.service.ts 97.77% <100.00%> (ø)
...s/wallet-initializer/wallet-initializer.service.ts 100.00% <ø> (ø)

... and 92 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.

Wrap releaseActivationClaim in a catch so a failing release logs and is
swallowed instead of masking the original provisioning error. The claim
is already protected by stale takeover, so release is best-effort.
@baktun14

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit review

Pushed 9c90aaf addressing the review feedback.

✅ Fixed — best-effort claim release (wallet-initializer.service.ts)
A failing releaseActivationClaim would propagate and mask the original provisioning error. Wrapped it in a .catch that logs TRIAL_CLAIM_RELEASE_ERROR (via createOtelLogger, matching RefillService) and swallows the release error, so the caller/logs keep the real root cause. The claim is already covered by the 5-min stale-takeover, so release is safe to treat as best-effort. Added a regression test (surfaces the original provisioning error even when releasing the claim fails).

✅ Fixed — tightened vacuous assertion (wallet-initializer.service.spec.ts)
Replaced not.toHaveBeenCalledWith(wallet.id, { activatedAt: null }) with not.toHaveBeenCalled(). In the failure path updateById isn't called at all, so this is a stronger, non-vacuous assertion.

⏭️ Deferred — DB-side claim timestamp (user-wallet.repository.ts)
Not applied, by design. releaseActivationClaim releases a claim by exact match eq(activationClaimedAt, claimedAt). now() produces microsecond-precision timestamptz, but the value round-trips back through a millisecond-precision JS Date, so an exact-match release would silently stop matching its own claim. The new Date() stamp is load-bearing for that exact-match, not merely stylistic. As the review notes, with a 5-min window clock skew is "unlikely to bite in practice… a hardening nit rather than a defect," so the correctness tradeoff isn't worth it here.

Note: the CodeRabbit ESLint "unused eslint-disable directive" tool-run warning on user-wallet.schema.ts is a CodeRabbit tooling misconfiguration — our own npm run lint confirms the import-x/no-cycle directive is required (real cycle via Users), so it's left in place.

npm run test:unit (wallet-initializer), npm run lint --quiet, and tsc --noEmit (no new errors) all pass locally.

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.

1 participant