Skip to content

refactor(billing): require wallet address in public wallet API responses#3521

Open
baktun14 wants to merge 3 commits into
mainfrom
refactor/billing-require-wallet-address-in-api
Open

refactor(billing): require wallet address in public wallet API responses#3521
baktun14 wants to merge 3 commits into
mainfrom
refactor/billing-require-wallet-address-in-api

Conversation

@baktun14

@baktun14 baktun14 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Why

Closes CON-745

CON-744 (#3512, #3513, deployed) guarantees that every wallet returned by the public API has an address: GET /v1/wallets only returns activated wallets (activation happens strictly after address derivation), and POST /v1/start-trial responds only after the address is derived. The loose nullable contract now only hides bugs and forces null-tolerant handling in API consumers.

What

apps/api — declare address (plus id, userId, createdAt, which are DB-non-null already) as required in the wallet response schema:

  • WalletOutputSchema: drop .nullable() on the four fields; createdAt switches z.coerce.date()z.date() because coercion makes the OpenAPI generator's isNullable() probe succeed on null (new Date(null) is valid), which kept a phantom nullable: true in the spec. Response schemas are compile-time/docs only — nothing parses them at runtime.
  • The invariant is proven at compile time, with no new runtime asserts: toPublic now requires WalletInitialized (moved to the repository from wallet-reader.service.ts), WalletReaderService.getWallets filters with a type predicate (which also silently excludes any pathological activated-but-addressless legacy row instead of 500ing), and WalletInitializerService.#ensureWalletVia returns the narrowed type its JSDoc already promised.
  • New createInitializedUserWallet seeder for fixtures that mock the narrowed contract.

Generated artifacts — docs snapshot, swagger/openapi.json, and @akashnetwork/console-api-types regenerated. Note: the checked-in spec was stale (predates #3513 and the idempotent top-up work), so the regen also drops the long-removed 202 from /v1/start-trial, adds its 409, and picks up idempotencyKey on checkout — incidental drift, not introduced by this PR.

apps/deploy-web — drop the null-address tolerance that CON-744 shipped as a temporary bridge:

Verified end-to-end against a locally booted API: an activated wallet returns a non-null address, a pre-activation wallet is excluded from GET /v1/wallets, and the served OpenAPI doc marks all four fields required (only the intentionally nullable 3DS fields remain nullable).

Summary by CodeRabbit

  • New Features
    • Added idempotencyKey support for Stripe transaction confirmation requests.
    • Added a 409 response for /v1/start-trial when trial provisioning is already in progress.
  • Bug Fixes
    • Updated trial/wallet readiness behavior so wallet-dependent steps wait for a fully initialized wallet.
    • Wallet response payloads now consistently return non-null id, userId, address, and createdAt where applicable.
  • Tests
    • Refreshed wallet seeding and readiness-related coverage to match the tightened wallet initialization behavior.

@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: 7a92b117-3290-4a93-8fef-bbe756ad6114

📥 Commits

Reviewing files that changed from the base of the PR and between cb04b6c and aaf18a7.

📒 Files selected for processing (3)
  • apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts
  • apps/api/swagger/openapi.json
  • apps/deploy-web/src/hooks/useManagedWallet.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts
  • apps/deploy-web/src/hooks/useManagedWallet.ts

📝 Walkthrough

Walkthrough

Wallet initialization now guarantees initialized public wallet data, frontend hooks handle wallet presence independently of address presence, and API schemas document non-null wallet fields, a new trial-provisioning conflict response, and optional transaction idempotency keys.

Changes

Wallet initialization and API contract

Layer / File(s) Summary
Backend wallet initialization flow
apps/api/src/billing/..., apps/api/src/deployment/...
Introduces the repository-owned WalletInitialized type, normalizes wallet addresses during initialization, filters uninitialized wallets, and updates dependent imports.
Initialized wallet fixtures and refill coverage
apps/api/test/seeders/..., apps/api/src/billing/services/refill/...
Adds an initialized-wallet seeder and uses it in refill scenarios requiring wallet activation state.
Frontend wallet readiness and persistence
apps/deploy-web/src/hooks/...
Treats an existing wallet as ready and persists it without requiring wallet.address; related tests and helpers now use non-null addresses.
Wallet and trial API contracts
apps/api/src/billing/http-schemas/..., apps/api/swagger/openapi.json, packages/console-api-types/...
Makes wallet response fields non-nullable, replaces the start-trial 202 response with 409, and adds the optional idempotencyKey field alongside specification metadata updates.

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

Possibly related PRs

Suggested reviewers: 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 refactor/billing-require-wallet-address-in-api

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

@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.20%. Comparing base (627ad0c) to head (aaf18a7).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3521      +/-   ##
==========================================
- Coverage   74.18%   73.20%   -0.98%     
==========================================
  Files        1142     1052      -90     
  Lines       29721    27371    -2350     
  Branches     7447     6987     -460     
==========================================
- Hits        22049    20038    -2011     
+ Misses       6771     6462     -309     
+ Partials      901      871      -30     
Flag Coverage Δ *Carryforward flag
api 87.41% <100.00%> (+<0.01%) ⬆️
deploy-web 63.66% <100.00%> (-0.02%) ⬇️
log-collector ?
notifications 93.84% <ø> (ø) Carriedforward from cb04b6c
provider-console 81.38% <ø> (ø) Carriedforward from cb04b6c
provider-inventory ?
provider-proxy 88.17% <ø> (ø) Carriedforward from cb04b6c
tx-signer ?

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

Files with missing lines Coverage Δ
...repositories/user-wallet/user-wallet.repository.ts 81.35% <100.00%> (ø)
...s/wallet-initializer/wallet-initializer.service.ts 100.00% <100.00%> (ø)
...ng/services/wallet-reader/wallet-reader.service.ts 94.73% <100.00%> (+0.61%) ⬆️
...ces/deployment-reader/deployment-reader.service.ts 85.41% <ø> (ø)
...ces/deployment-writer/deployment-writer.service.ts 100.00% <ø> (ø)
apps/deploy-web/src/hooks/useEnsureTrialStarted.ts 100.00% <100.00%> (ø)
apps/deploy-web/src/hooks/useManagedWallet.ts 92.59% <100.00%> (-0.96%) ⬇️

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

@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: 5

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

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

Use type-only imports for wallet aliases.

UserWalletOutput, UserWalletPublicOutput, and WalletInitialized are type-only symbols, while UserWalletRepository is a runtime dependency. Split or annotate these imports to satisfy the shared TypeScript import rule and avoid runtime import issues.

  • apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts#L6-L6: mark the wallet aliases as type-only while keeping UserWalletRepository as a value import.
  • apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts#L6-L6: apply the same type-only import treatment.

As per coding guidelines, TypeScript imports must use consistent type imports.

🤖 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.ts`
at line 6, Update the imports in
apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts#L6-L6
and apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts#L6-L6
so UserWalletOutput, UserWalletPublicOutput, and WalletInitialized use type-only
imports, while UserWalletRepository remains a runtime import.

Source: Coding guidelines

🤖 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 87-94: Update `#ensureWalletVia` to perform the wallet address
mutation through the authorization-scoped repository argument by replacing
this.userWalletRepository.updateById with repository.updateById, while
preserving the existing update parameters and returned wallet behavior.

In `@apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts`:
- Around line 22-24: Update the wallet filter in the wallet-reader mapping flow
to validate that address is present and non-empty before asserting
WalletInitialized; reject undefined, null, and empty or whitespace-only values
while preserving the activatedAt requirement and existing toPublic mapping.

In
`@apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts`:
- Around line 19-20: Change the WalletInitialized import in the deployment
reader service to a type-only import, while keeping WalletReaderService as a
regular import.

In `@apps/api/swagger/openapi.json`:
- Line 1790: Remove the generated static default value for dynamic endDate at
apps/api/swagger/openapi.json lines 1790-1790 and 1916-1916, omitting the
default property entirely at both sites so the OpenAPI schema does not contain a
date that becomes stale.
- Line 5: Update the OpenAPI server URL in the generator configuration to the
intended public API URL instead of localhost, then regenerate the specification
so Swagger UI and generated clients use the deployed server.

---

Nitpick comments:
In
`@apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts`:
- Line 6: Update the imports in
apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts#L6-L6
and apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts#L6-L6
so UserWalletOutput, UserWalletPublicOutput, and WalletInitialized use type-only
imports, while UserWalletRepository remains a runtime import.
🪄 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: cee7dad8-2ed7-4893-a71b-d7722cb62820

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • apps/api/test/functional/__snapshots__/docs.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (17)
  • apps/api/src/billing/http-schemas/wallet.schema.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/wallet-initializer/wallet-initializer.service.ts
  • apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts
  • apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts
  • apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts
  • apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts
  • apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts
  • apps/api/src/deployment/services/lease/lease.service.spec.ts
  • apps/api/swagger/openapi.json
  • apps/api/test/seeders/user-wallet.seeder.ts
  • apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts
  • apps/deploy-web/src/hooks/useEnsureTrialStarted.ts
  • apps/deploy-web/src/hooks/useManagedWallet.spec.tsx
  • apps/deploy-web/src/hooks/useManagedWallet.ts
  • packages/console-api-types/src/schema.d.ts
💤 Files with no reviewable changes (1)
  • apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts

Comment on lines +87 to +94
async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise<WalletInitialized> {
const { wallet } = await repository.getOrCreate({ userId });

if (wallet.address) return wallet;
if (wallet.address) return { ...wallet, address: wallet.address };

const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id });
return await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true });
const updatedWallet = await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true });
return { ...updatedWallet, address };

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Preserve the CASL-scoped repository for the address update.

#ensureWalletVia reads through repository—which is authorization-scoped during trial initialization—but mutates through this.userWalletRepository. Use repository.updateById(...) so the entire get/check/mutate sequence remains authorization-scoped.

Proposed fix
-    const updatedWallet = await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true });
+    const updatedWallet = await repository.updateById(wallet.id, { address }, { returning: true });

Based on learnings, user-scoped billing mutations should remain behind the CASL-protected repository boundary; as per path instructions, get/check/mutate authorization gaps must be reviewed explicitly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise<WalletInitialized> {
const { wallet } = await repository.getOrCreate({ userId });
if (wallet.address) return wallet;
if (wallet.address) return { ...wallet, address: wallet.address };
const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id });
return await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true });
const updatedWallet = await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true });
return { ...updatedWallet, address };
async `#ensureWalletVia`(repository: UserWalletRepository, userId: string): Promise<WalletInitialized> {
const { wallet } = await repository.getOrCreate({ userId });
if (wallet.address) return { ...wallet, address: wallet.address };
const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id });
const updatedWallet = await repository.updateById(wallet.id, { address }, { returning: true });
return { ...updatedWallet, address };
🤖 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.ts`
around lines 87 - 94, Update `#ensureWalletVia` to perform the wallet address
mutation through the authorization-scoped repository argument by replacing
this.userWalletRepository.updateById with repository.updateById, while
preserving the existing update parameters and returned wallet behavior.

Sources: Path instructions, Learnings

Comment on lines +22 to +24
return wallets
.filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && wallet.address !== null)
.map(wallet => this.userWalletRepository.toPublic(wallet));

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate the actual address value before asserting WalletInitialized.

address !== null allows undefined and empty strings, while the type predicate claims a guaranteed initialized wallet. That can emit invalid API data and pass an unusable owner address downstream.

Proposed fix
     return wallets
-      .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && wallet.address !== null)
+      .filter(
+        (wallet): wallet is WalletInitialized =>
+          wallet.activatedAt != null && typeof wallet.address === "string" && wallet.address.length > 0
+      )
       .map(wallet => this.userWalletRepository.toPublic(wallet));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return wallets
.filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && wallet.address !== null)
.map(wallet => this.userWalletRepository.toPublic(wallet));
return wallets
.filter(
(wallet): wallet is WalletInitialized =>
wallet.activatedAt != null && typeof wallet.address === "string" && wallet.address.length > 0
)
.map(wallet => this.userWalletRepository.toPublic(wallet));
🤖 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-reader/wallet-reader.service.ts` around
lines 22 - 24, Update the wallet filter in the wallet-reader mapping flow to
validate that address is present and non-empty before asserting
WalletInitialized; reject undefined, null, and empty or whitespace-only values
while preserving the activatedAt requirement and existing toPublic mapping.

Comment on lines +19 to +20
import { WalletInitialized } from "@src/billing/repositories";
import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n --glob 'eslint.config.*' --glob '.eslintrc*' 'consistent-type-imports' .
ast-grep run --lang ts \
  --pattern 'import { WalletInitialized } from "`@src/billing/repositories`"' \
  apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts

Repository: akash-network/console

Length of output: 305


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## CLAUDE.md\n'
fd -a '^CLAUDE\.md$' .

printf '\n## ESLint config files\n'
fd -a 'eslint\.config\..*|\.eslintrc.*' .

printf '\n## Target file outline\n'
ast-grep outline apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts --view expanded || true

printf '\n## Target file lines\n'
cat -n apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts | sed -n '1,140p'

Repository: akash-network/console

Length of output: 8765


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Search for type-import rule\n'
rg -n --hidden --glob '!**/node_modules/**' 'consistent-type-imports|import type|type imports|typescript-eslint' .

printf '\n## Search for WalletInitialized usage\n'
rg -n --hidden --glob '!**/node_modules/**' 'WalletInitialized' apps/api/src

printf '\n## Package / lint context\n'
fd -a 'package\.json$' . | head -n 20

Repository: akash-network/console

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## apps/api/eslint.config.mjs\n'
cat -n apps/api/eslint.config.mjs | sed -n '1,220p'

printf '\n## root eslint.config.mjs\n'
cat -n eslint.config.mjs | sed -n '1,220p'

printf '\n## shared TS config\n'
cat -n packages/dev-config/eslint/typescript.mjs | sed -n '1,120p'

Repository: akash-network/console

Length of output: 3656


Use a type-only import for WalletInitialized. WalletInitialized is only used in a type position here, so import type keeps this aligned with the repo's consistent-type-imports rule.

🤖 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/deployment/services/deployment-reader/deployment-reader.service.ts`
around lines 19 - 20, Change the WalletInitialized import in the deployment
reader service to a type-only import, while keeping WalletReaderService as a
regular import.

Source: Coding guidelines

Comment thread apps/api/swagger/openapi.json Outdated
Comment thread apps/api/swagger/openapi.json Outdated
"type": "string",
"format": "date",
"default": "2026-07-20",
"default": "2026-07-24",

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove time-dependent OpenAPI defaults. These endpoints state that endDate defaults to the current UTC day, but the committed July 24, 2026 value becomes false on July 25, 2026.

  • apps/api/swagger/openapi.json#L1790-L1790: omit the generated static default for the dynamic endDate.
  • apps/api/swagger/openapi.json#L1916-L1916: omit the generated static default for the dynamic endDate.
📍 Affects 1 file
  • apps/api/swagger/openapi.json#L1790-L1790 (this comment)
  • apps/api/swagger/openapi.json#L1916-L1916
🤖 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/swagger/openapi.json` at line 1790, Remove the generated static
default value for dynamic endDate at apps/api/swagger/openapi.json lines
1790-1790 and 1916-1916, omitting the default property entirely at both sites so
the OpenAPI schema does not contain a date that becomes stale.

- merge duplicated @src/billing/repositories import in deployment-writer
- flatten managed-wallet storage-sync effect to an early return + ternary
- revert accidental openapi regeneration artifacts (localhost server url,
  today's date defaults, dropped trailing newline)
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