Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/api/src/billing/http-schemas/wallet.schema.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { z } from "@hono/zod-openapi";

const WalletOutputSchema = z.object({
id: z.number().nullable().openapi({}),
userId: z.string().nullable().openapi({}),
id: z.number().openapi({}),
userId: z.string().openapi({}),
creditAmount: z.number().openapi({}),
address: z.string().nullable().openapi({}),
address: z.string().openapi({}),
denom: z.string().openapi({}),
isTrialing: z.boolean(),
topUpMinAmountUsd: z.number().openapi({ description: "Minimum USD amount accepted by the next paid top-up for this wallet." }),
createdAt: z.coerce.date().nullable().openapi({})
createdAt: z.date().openapi({})
});

const WalletWithOptional3DSSchema = WalletOutputSchema.extend({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ export type UserWalletOutput = Omit<DbUserWalletOutput, "feeAllowance" | "deploy
feeAllowance: number;
};

export type WalletInitialized = Omit<UserWalletOutput, "address"> & { address: string };

export interface UserWalletPublicOutput {
id: UserWalletOutput["id"];
userId: UserWalletOutput["userId"];
address: UserWalletOutput["address"];
address: WalletInitialized["address"];
creditAmount: UserWalletOutput["creditAmount"];
isTrialing: boolean;
createdAt: UserWalletOutput["createdAt"];
Expand Down Expand Up @@ -164,7 +166,7 @@ export class UserWalletRepository extends BaseRepository<ApiPgTables["UserWallet
return dbInput;
}

toPublic(output: UserWalletOutput): UserWalletPublicOutput {
toPublic(output: WalletInitialized): UserWalletPublicOutput {
return {
id: output.id,
userId: output.userId,
Expand Down
10 changes: 5 additions & 5 deletions apps/api/src/billing/services/refill/refill.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { RefillService } from "@src/billing/services/refill/refill.service";
import type { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service";
import type { AnalyticsService } from "@src/core/services/analytics/analytics.service";

import { createUserWallet } from "@test/seeders/user-wallet.seeder";
import { createInitializedUserWallet, createUserWallet } from "@test/seeders/user-wallet.seeder";

describe(RefillService.name, () => {
describe("topUpWallet", () => {
Expand All @@ -19,7 +19,7 @@ describe(RefillService.name, () => {
it("should top up existing activated wallet", async () => {
const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, walletInitializerService, analyticsService } =
setup();
const existingWallet = createUserWallet({ userId });
const existingWallet = createInitializedUserWallet({ userId });
walletInitializerService.ensureWallet.mockResolvedValue(existingWallet);
userWalletRepository.claimActivation.mockResolvedValue(undefined);
managedUserWalletService.authorizeSpending.mockResolvedValue();
Expand All @@ -40,7 +40,7 @@ describe(RefillService.name, () => {

it("attaches payment context to the balance_top_up analytics event", async () => {
const { service, userWalletRepository, managedUserWalletService, balancesService, analyticsService, walletInitializerService } = setup();
const existingWallet = createUserWallet({ userId });
const existingWallet = createInitializedUserWallet({ userId });
walletInitializerService.ensureWallet.mockResolvedValue(existingWallet);
userWalletRepository.claimActivation.mockResolvedValue(undefined);
managedUserWalletService.authorizeSpending.mockResolvedValue();
Expand Down Expand Up @@ -72,7 +72,7 @@ describe(RefillService.name, () => {

it("does not end trial when endTrial option is false", async () => {
const { service, userWalletRepository, managedUserWalletService, balancesService, walletInitializerService } = setup();
const existingWallet = createUserWallet({ userId });
const existingWallet = createInitializedUserWallet({ userId });
walletInitializerService.ensureWallet.mockResolvedValue(existingWallet);
userWalletRepository.claimActivation.mockResolvedValue(undefined);
managedUserWalletService.authorizeSpending.mockResolvedValue();
Expand All @@ -87,7 +87,7 @@ describe(RefillService.name, () => {
it("activates a non-activated wallet on first funding", async () => {
const { service, userWalletRepository, walletInitializerService, balancesService, managedUserWalletService, managedSignerService, analyticsService } =
setup();
const wallet = createUserWallet({ userId, activatedAt: null });
const wallet = createInitializedUserWallet({ userId, activatedAt: null });
const activatedWallet = { ...wallet, activatedAt: new Date() };
walletInitializerService.ensureWallet.mockResolvedValue(wallet);
userWalletRepository.claimActivation.mockResolvedValue(activatedWallet);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { singleton } from "tsyringe";

import { AuthService } from "@src/auth/services/auth.service";
import { TrialStarted } from "@src/billing/events/trial-started";
import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories";
import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, WalletInitialized } from "@src/billing/repositories";
import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service";
import { StripeService } from "@src/billing/services/stripe/stripe.service";
import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service";
Expand Down Expand Up @@ -69,27 +69,28 @@ export class WalletInitializerService {

await this.domainEvents.publish(new TrialStarted({ userId }));

return this.userWalletRepository.toPublic(activatedWallet);
return this.userWalletRepository.toPublic({ ...activatedWallet, address: userWallet.address });
}

/**
* Idempotently guarantees the user has a wallet row with a derived address.
* Address derivation is pure (no chain transaction), so this is safe to run on every registration.
*/
async ensureWallet(userId: string): Promise<UserWalletOutput> {
async ensureWallet(userId: string): Promise<WalletInitialized> {
return this.#ensureWalletVia(this.userWalletRepository, userId);
}

/**
* Concurrent calls may both derive the address, but derivation is deterministic per wallet id,
* so the two updates write the same value and the operation stays idempotent.
*/
async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise<UserWalletOutput> {
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 };
Comment on lines +87 to +94

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

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import assert from "http-assert";
import { Lifecycle, scoped } from "tsyringe";

import { AuthService } from "@src/auth/services/auth.service";
import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories";
import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, WalletInitialized } from "@src/billing/repositories";

export interface GetWalletOptions {
userId: string;
}

export type WalletInitialized = Omit<UserWalletOutput, "address"> & { address: string };

@scoped(Lifecycle.ResolutionScoped)
export class WalletReaderService {
constructor(
Expand All @@ -21,7 +19,9 @@ export class WalletReaderService {
async getWallets(query: GetWalletOptions): Promise<UserWalletPublicOutput[]> {
const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query);

return wallets.filter(wallet => wallet.activatedAt).map(wallet => this.userWalletRepository.toPublic(wallet));
return wallets
.filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && wallet.address !== null)
.map(wallet => this.userWalletRepository.toPublic(wallet));
Comment on lines +22 to +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 | 🟠 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.

}

async getWalletByUserId(userId: string): Promise<WalletInitialized>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { AxiosError } from "axios";
import { describe, expect, it, vi } from "vitest";
import { mock } from "vitest-mock-extended";

import type { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { WalletInitialized } from "@src/billing/repositories";
import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { LoggerService } from "@src/core/providers/logging.provider";
import type { FallbackDeploymentReaderService } from "@src/deployment/services/fallback-deployment-reader/fallback-deployment-reader.service";
import type { FallbackLeaseReaderService } from "@src/deployment/services/fallback-lease-reader/fallback-lease-reader.service";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { InternalServerError } from "http-errors";
import { Op } from "sequelize";
import { singleton } from "tsyringe";

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

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

import { Memoize } from "@src/caching/helpers";
import { LoggerService } from "@src/core";
import { GetDeploymentResponse, ListDeploymentsItem } from "@src/deployment/http-schemas/deployment.schema";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { MsgCloseDeployment, MsgCreateDeployment, MsgUpdateDeployment } from "@a
import { afterEach, describe, expect, it, vi } from "vitest";
import { mock, type MockProxy } from "vitest-mock-extended";

import type { WalletInitialized } from "@src/billing/repositories";
import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service";
import type { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service";
import type { RpcMessageService } from "@src/billing/services/rpc-message-service/rpc-message.service";
import type { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema";
import type { SdlService } from "@src/deployment/services/sdl/sdl.service";
import type { ProviderService } from "@src/provider/services/provider/provider.service";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { manifestToSortedJSON } from "@akashnetwork/chain-sdk";
import assert from "http-assert";
import { singleton } from "tsyringe";

import { UserWalletOutput } from "@src/billing/repositories";
import { UserWalletOutput, WalletInitialized } from "@src/billing/repositories";
import { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service";
import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service";
import { RpcMessageService } from "@src/billing/services/rpc-message-service/rpc-message.service";
import { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import {
CreateDeploymentRequest,
CreateDeploymentResponse,
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/deployment/services/lease/lease.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import type { LeaseHttpService } from "@akashnetwork/http-sdk";
import { describe, expect, it } from "vitest";
import { mock } from "vitest-mock-extended";

import type { WalletInitialized } from "@src/billing/repositories";
import type { ManagedSignerService, RpcMessageService } from "@src/billing/services";
import type { WalletInitialized, WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service";
import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema";
import type { DeploymentReaderService } from "@src/deployment/services/deployment-reader/deployment-reader.service";
import type { ProviderService } from "@src/provider/services/provider/provider.service";
Expand Down
106 changes: 15 additions & 91 deletions apps/api/swagger/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"/v1/start-trial": {
"post": {
"summary": "Start a trial period for a user",
"description": "Creates a managed wallet for a user and initiates a trial period. This endpoint handles payment method validation and may require 3D Secure authentication for certain payment methods. Returns wallet information and trial status.",
"description": "Creates a managed wallet for a user and initiates a trial period. Returns wallet information and trial status.",
"tags": [
"Wallet"
],
Expand Down Expand Up @@ -56,94 +56,16 @@
"type": "object",
"properties": {
"id": {
"type": "number",
"nullable": true
},
"userId": {
"type": "string",
"nullable": true
},
"creditAmount": {
"type": "number"
},
"address": {
"type": "string",
"nullable": true
},
"denom": {
"type": "string"
},
"isTrialing": {
"type": "boolean"
},
"topUpMinAmountUsd": {
"type": "number",
"description": "Minimum USD amount accepted by the next paid top-up for this wallet."
},
"createdAt": {
"type": "string",
"nullable": true
},
"requires3DS": {
"type": "boolean"
},
"clientSecret": {
"type": "string",
"nullable": true
},
"paymentIntentId": {
"type": "string",
"nullable": true
},
"paymentMethodId": {
"type": "string",
"nullable": true
}
},
"required": [
"id",
"userId",
"creditAmount",
"address",
"denom",
"isTrialing",
"topUpMinAmountUsd",
"createdAt"
],
"additionalProperties": false
}
},
"required": [
"data"
]
}
}
}
},
"202": {
"description": "3D Secure authentication required to complete trial setup",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "object",
"properties": {
"id": {
"type": "number",
"nullable": true
},
"userId": {
"type": "string",
"nullable": true
"type": "string"
},
"creditAmount": {
"type": "number"
},
"address": {
"type": "string",
"nullable": true
"type": "string"
},
"denom": {
"type": "string"
Expand All @@ -156,8 +78,7 @@
"description": "Minimum USD amount accepted by the next paid top-up for this wallet."
},
"createdAt": {
"type": "string",
"nullable": true
"type": "string"
},
"requires3DS": {
"type": "boolean"
Expand Down Expand Up @@ -194,6 +115,9 @@
}
}
}
},
"409": {
"description": "Trial provisioning is already in progress for this user; retry shortly"
}
}
}
Expand Down Expand Up @@ -238,19 +162,16 @@
"type": "object",
"properties": {
"id": {
"type": "number",
"nullable": true
"type": "number"
},
"userId": {
"type": "string",
"nullable": true
"type": "string"
},
"creditAmount": {
"type": "number"
},
"address": {
"type": "string",
"nullable": true
"type": "string"
},
"denom": {
"type": "string"
Expand All @@ -263,8 +184,7 @@
"description": "Minimum USD amount accepted by the next paid top-up for this wallet."
},
"createdAt": {
"type": "string",
"nullable": true
"type": "string"
},
"requires3DS": {
"type": "boolean"
Expand Down Expand Up @@ -1479,6 +1399,10 @@
},
"awaitResolved": {
"type": "boolean"
},
"idempotencyKey": {
"type": "string",
"format": "uuid"
}
},
"required": [
Expand Down
Loading