From 294228f9ac98ed621fd9f41da1de38cc4f0ac5ab Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:59:34 +0100 Subject: [PATCH 01/12] refactor(billing): require wallet address in public wallet API responses --- .../src/billing/http-schemas/wallet.schema.ts | 8 +- .../user-wallet/user-wallet.repository.ts | 6 +- .../services/refill/refill.service.spec.ts | 10 +- .../wallet-initializer.service.ts | 13 +- .../wallet-reader/wallet-reader.service.ts | 8 +- .../deployment-reader.service.spec.ts | 3 +- .../deployment-reader.service.ts | 3 +- .../deployment-writer.service.spec.ts | 3 +- .../deployment-writer.service.ts | 3 +- .../services/lease/lease.service.spec.ts | 3 +- apps/api/swagger/openapi.json | 114 +++--------------- .../__snapshots__/docs.spec.ts.snap | 8 -- apps/api/test/seeders/user-wallet.seeder.ts | 9 +- packages/console-api-types/src/schema.d.ts | 44 +++---- 14 files changed, 75 insertions(+), 160 deletions(-) diff --git a/apps/api/src/billing/http-schemas/wallet.schema.ts b/apps/api/src/billing/http-schemas/wallet.schema.ts index f9af8c6d0e..20e5582f3b 100644 --- a/apps/api/src/billing/http-schemas/wallet.schema.ts +++ b/apps/api/src/billing/http-schemas/wallet.schema.ts @@ -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({ diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index dc24fe1e16..df6d9de8b0 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -22,10 +22,12 @@ export type UserWalletOutput = Omit & { 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"]; @@ -164,7 +166,7 @@ export class UserWalletRepository extends BaseRepository { describe("topUpWallet", () => { @@ -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(); @@ -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(); @@ -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(); @@ -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); diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts index 74b90c4dee..41219be3ae 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts @@ -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"; @@ -69,14 +69,14 @@ 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 { + async ensureWallet(userId: string): Promise { return this.#ensureWalletVia(this.userWalletRepository, userId); } @@ -84,12 +84,13 @@ export class WalletInitializerService { * 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 { + async #ensureWalletVia(repository: UserWalletRepository, userId: string): Promise { 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 }; } } diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index a7b4b7d256..63495cf763 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -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 & { address: string }; - @scoped(Lifecycle.ResolutionScoped) export class WalletReaderService { constructor( @@ -21,7 +19,9 @@ export class WalletReaderService { async getWallets(query: GetWalletOptions): Promise { 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)); } async getWalletByUserId(userId: string): Promise; diff --git a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts index 956c6b2e95..8722904d01 100644 --- a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts +++ b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.spec.ts @@ -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"; diff --git a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts index f0ed3450f6..70f9f50028 100644 --- a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts +++ b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts @@ -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"; import { Memoize } from "@src/caching/helpers"; import { LoggerService } from "@src/core"; import { GetDeploymentResponse, ListDeploymentsItem } from "@src/deployment/http-schemas/deployment.schema"; diff --git a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts index 4932ad3f54..540be28dc6 100644 --- a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts +++ b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.spec.ts @@ -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 { LoggerService } from "@src/core"; import type { GetDeploymentResponse } from "@src/deployment/http-schemas/deployment.schema"; import type { SdlService } from "@src/deployment/services/sdl/sdl.service"; diff --git a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts index 271a1822ab..9e771663c8 100644 --- a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts +++ b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts @@ -3,10 +3,11 @@ import assert from "http-assert"; import { singleton } from "tsyringe"; import { UserWalletOutput } from "@src/billing/repositories"; +import { 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 { LoggerService } from "@src/core"; import { CreateDeploymentRequest, diff --git a/apps/api/src/deployment/services/lease/lease.service.spec.ts b/apps/api/src/deployment/services/lease/lease.service.spec.ts index d0d43ce5a5..9163e13326 100644 --- a/apps/api/src/deployment/services/lease/lease.service.spec.ts +++ b/apps/api/src/deployment/services/lease/lease.service.spec.ts @@ -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"; diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index ff9e677f11..9c47ee913d 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "servers": [ { - "url": "https://console-api-sandbox.akash.network" + "url": "http://localhost:3080" } ], "info": { @@ -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" ], @@ -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" @@ -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" @@ -194,6 +115,9 @@ } } } + }, + "409": { + "description": "Trial provisioning is already in progress for this user; retry shortly" } } } @@ -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" @@ -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" @@ -1479,6 +1399,10 @@ }, "awaitResolved": { "type": "boolean" + }, + "idempotencyKey": { + "type": "string", + "format": "uuid" } }, "required": [ @@ -1863,7 +1787,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", + "default": "2026-07-24", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -1989,7 +1913,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", + "default": "2026-07-24", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -22621,4 +22545,4 @@ } } } -} +} \ No newline at end of file diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 033f33c6a7..d0b36a5370 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -13409,7 +13409,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "additionalProperties": false, "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -13417,7 +13416,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -13427,7 +13425,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -13449,7 +13446,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "number", }, "userId": { - "nullable": true, "type": "string", }, }, @@ -15293,7 +15289,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "items": { "properties": { "address": { - "nullable": true, "type": "string", }, "clientSecret": { @@ -15301,7 +15296,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "createdAt": { - "nullable": true, "type": "string", }, "creditAmount": { @@ -15311,7 +15305,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "string", }, "id": { - "nullable": true, "type": "number", }, "isTrialing": { @@ -15333,7 +15326,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "type": "number", }, "userId": { - "nullable": true, "type": "string", }, }, diff --git a/apps/api/test/seeders/user-wallet.seeder.ts b/apps/api/test/seeders/user-wallet.seeder.ts index edf6bff580..1709fc6560 100644 --- a/apps/api/test/seeders/user-wallet.seeder.ts +++ b/apps/api/test/seeders/user-wallet.seeder.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; -import type { UserWalletOutput } from "@src/billing/repositories"; +import type { UserWalletOutput, WalletInitialized } from "@src/billing/repositories"; import { createAkashAddress } from "./akash-address.seeder"; export function createUserWallet({ @@ -27,3 +27,10 @@ export function createUserWallet({ activatedAt }; } + +export function createInitializedUserWallet({ + address = createAkashAddress(), + ...input +}: Partial & { address?: string } = {}): WalletInitialized { + return { ...createUserWallet(input), address }; +} diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index fd2cb7c16d..07e687eb88 100644 --- a/packages/console-api-types/src/schema.d.ts +++ b/packages/console-api-types/src/schema.d.ts @@ -15,7 +15,7 @@ export interface paths { put?: never; /** * 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. */ post: { parameters: { @@ -42,15 +42,15 @@ export interface paths { content: { "application/json": { data: { - id: number | null; - userId: string | null; + id: number; + userId: string; creditAmount: number; - address: string | null; + address: string; denom: string; isTrialing: boolean; /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ topUpMinAmountUsd: number; - createdAt: string | null; + createdAt: string; requires3DS?: boolean; clientSecret?: string | null; paymentIntentId?: string | null; @@ -59,30 +59,12 @@ export interface paths { }; }; }; - /** @description 3D Secure authentication required to complete trial setup */ - 202: { + /** @description Trial provisioning is already in progress for this user; retry shortly */ + 409: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - data: { - id: number | null; - userId: string | null; - creditAmount: number; - address: string | null; - denom: string; - isTrialing: boolean; - /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ - topUpMinAmountUsd: number; - createdAt: string | null; - requires3DS?: boolean; - clientSecret?: string | null; - paymentIntentId?: string | null; - paymentMethodId?: string | null; - }; - }; - }; + content?: never; }; }; }; @@ -120,15 +102,15 @@ export interface paths { content: { "application/json": { data: { - id: number | null; - userId: string | null; + id: number; + userId: string; creditAmount: number; - address: string | null; + address: string; denom: string; isTrialing: boolean; /** @description Minimum USD amount accepted by the next paid top-up for this wallet. */ topUpMinAmountUsd: number; - createdAt: string | null; + createdAt: string; requires3DS?: boolean; clientSecret?: string | null; paymentIntentId?: string | null; @@ -7593,6 +7575,8 @@ export interface operations { paymentMethodId: string; amount: number; awaitResolved?: boolean; + /** Format: uuid */ + idempotencyKey?: string; }; }; }; From 824bfea18f02a08503f7910f3b4d2f7755d7541b Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:00:12 +0100 Subject: [PATCH 02/12] refactor(managed-wallet): drop null-address wallet handling from web consumers --- .../src/hooks/useEnsureTrialStarted.spec.ts | 9 ------ .../src/hooks/useEnsureTrialStarted.ts | 2 +- .../src/hooks/useManagedWallet.spec.tsx | 32 +++---------------- apps/deploy-web/src/hooks/useManagedWallet.ts | 8 ++--- 4 files changed, 7 insertions(+), 44 deletions(-) diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts index b3e3ed32d4..d41af8427e 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.spec.ts @@ -25,15 +25,6 @@ describe(useEnsureTrialStarted.name, () => { expect(create).not.toHaveBeenCalled(); }); - it("fires when a wallet row exists but is not yet initialized (no address)", () => { - const create = vi.fn(); - const { dependencies } = setup({ wallet: { address: null } as never, isLoading: false, create }); - - renderHook(() => useEnsureTrialStarted(dependencies)); - - expect(create).toHaveBeenCalledTimes(1); - }); - it("does not fire while another mutation is in flight", () => { const create = vi.fn(); const { dependencies } = setup({ wallet: undefined, isLoading: true, create }); diff --git a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts index 69eeceb2cb..0dde5be1d2 100644 --- a/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts +++ b/apps/deploy-web/src/hooks/useEnsureTrialStarted.ts @@ -43,7 +43,7 @@ export type EnsureTrialStartedResult = { */ export const useEnsureTrialStarted = (d = DEPENDENCIES): EnsureTrialStartedResult => { const { wallet, create, isLoading, createError, resetCreate, refetch } = d.useManagedWallet(); - const isWalletReady = !!wallet?.address; + const isWalletReady = !!wallet; const isStartingTrial = d.useIsMutating({ mutationKey: QueryKeys.getManagedWalletCreateMutationKey() }) > 0; useEffect(() => { diff --git a/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx b/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx index 9e86420afe..6d48d4f91d 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx +++ b/apps/deploy-web/src/hooks/useManagedWallet.spec.tsx @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { mock } from "vitest-mock-extended"; import { useCreateManagedWalletMutation } from "@src/queries/useManagedWalletQuery"; -import { getStorageManagedWallet, updateStorageManagedWallet } from "@src/utils/walletUtils"; +import { getStorageManagedWallet } from "@src/utils/walletUtils"; import { useManagedWallet } from "./useManagedWallet"; import { act } from "@testing-library/react"; @@ -34,30 +34,7 @@ describe(useManagedWallet.name, () => { }); }); - it("keeps the stored wallet untouched when the API returns a wallet without an address", async () => { - const userId = "user-guard-merge"; - updateStorageManagedWallet({ userId, address: "akash1existing", creditAmount: 100, isTrialing: true, selected: true }); - - const { result } = setup({ userId, apiWallet: buildApiWallet({ userId, address: null, creditAmount: 0 }) }); - - await vi.waitFor(() => { - expect(result.current.managed.wallet).toBeDefined(); - }); - expect(getStorageManagedWallet(userId)).toMatchObject({ address: "akash1existing", creditAmount: 100, isTrialing: true }); - }); - - it("does not persist a wallet without an address to storage", async () => { - const userId = "user-guard-empty"; - - const { result } = setup({ userId, apiWallet: buildApiWallet({ userId, address: null }) }); - - await vi.waitFor(() => { - expect(result.current.managed.wallet).toBeDefined(); - }); - expect(getStorageManagedWallet(userId)).toBeUndefined(); - }); - - it("persists the queried wallet to storage once it has an address", async () => { + it("persists the queried wallet to storage", async () => { const userId = "user-sync"; setup({ userId, apiWallet: buildApiWallet({ userId, address: "akash1queried", creditAmount: 25 }) }); @@ -82,15 +59,14 @@ describe(useManagedWallet.name, () => { }); }); - /** Mirrors the real API contract: `address` is nullable while a wallet is mid-provisioning, even though the SDK type claims `string`. */ - function buildApiWallet(overrides: { userId: string; address: string | null; creditAmount?: number }) { + function buildApiWallet(overrides: { userId: string; address: string; creditAmount?: number }) { return { ...mock(), isTrialing: true, creditAmount: overrides.creditAmount ?? 0, userId: overrides.userId, address: overrides.address - } as ApiManagedWalletOutput; + }; } function setup(input?: { userId?: string; apiWallet?: ApiManagedWalletOutput; createdWallet?: ApiManagedWalletOutput }) { diff --git a/apps/deploy-web/src/hooks/useManagedWallet.ts b/apps/deploy-web/src/hooks/useManagedWallet.ts index a7e165572b..9e5301af18 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -39,13 +39,9 @@ export const useManagedWallet = () => { }, [signedInUser?.id, queried, created, setIsSignedInWithTrial]); useEffect(() => { - if (!wallet?.address) { - return; - } - - if (isCreated) { + if (wallet && isCreated) { updateStorageManagedWallet({ ...wallet, selected: true }); - } else { + } else if (wallet) { updateStorageManagedWallet(wallet); } }, [isCreated, wallet]); From 0be7d86751e0440edac1df67476e420c1050d0d2 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:04:12 +0100 Subject: [PATCH 03/12] refactor(managed-wallet): tidy wallet-address refactor diff - 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) --- .../deployment-writer/deployment-writer.service.ts | 3 +-- apps/api/swagger/openapi.json | 8 ++++---- apps/deploy-web/src/hooks/useManagedWallet.ts | 7 ++----- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts index 9e771663c8..0555339ee6 100644 --- a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts +++ b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts @@ -2,8 +2,7 @@ import { manifestToSortedJSON } from "@akashnetwork/chain-sdk"; import assert from "http-assert"; import { singleton } from "tsyringe"; -import { UserWalletOutput } from "@src/billing/repositories"; -import { WalletInitialized } 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"; diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index 9c47ee913d..42ca4eb184 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "servers": [ { - "url": "http://localhost:3080" + "url": "https://console-api-sandbox.akash.network" } ], "info": { @@ -1787,7 +1787,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-24", + "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -1913,7 +1913,7 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-24", + "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -22545,4 +22545,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/deploy-web/src/hooks/useManagedWallet.ts b/apps/deploy-web/src/hooks/useManagedWallet.ts index 9e5301af18..8449309edf 100644 --- a/apps/deploy-web/src/hooks/useManagedWallet.ts +++ b/apps/deploy-web/src/hooks/useManagedWallet.ts @@ -39,11 +39,8 @@ export const useManagedWallet = () => { }, [signedInUser?.id, queried, created, setIsSignedInWithTrial]); useEffect(() => { - if (wallet && isCreated) { - updateStorageManagedWallet({ ...wallet, selected: true }); - } else if (wallet) { - updateStorageManagedWallet(wallet); - } + if (!wallet) return; + updateStorageManagedWallet(isCreated ? { ...wallet, selected: true } : wallet); }, [isCreated, wallet]); useEffect(() => { From 0a5558001fc963aeee4d0454cfba54ca74fdb054 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:27:15 +0100 Subject: [PATCH 04/12] refactor(billing): scope wallet address mutation and harden readiness guard Addresses review feedback on the wallet-address contract: - #ensureWalletVia now mutates through the CASL-scoped `repository` argument instead of the unscoped `this.userWalletRepository`, so the get/check/mutate sequence stays authorization-scoped during trial init. - getWallets filters activated wallets with a truthiness check on `address` so an empty-string address is also excluded and the `WalletInitialized` predicate holds. - Use type-only imports for the wallet type aliases and WalletInitialized. --- .../services/wallet-initializer/wallet-initializer.service.ts | 4 ++-- .../billing/services/wallet-reader/wallet-reader.service.ts | 2 +- .../services/deployment-reader/deployment-reader.service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts index 41219be3ae..52b0b20a4c 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts @@ -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, WalletInitialized } from "@src/billing/repositories"; +import { type UserWalletOutput, type UserWalletPublicOutput, UserWalletRepository, type 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"; @@ -90,7 +90,7 @@ export class WalletInitializerService { if (wallet.address) return { ...wallet, address: wallet.address }; const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id }); - const updatedWallet = await this.userWalletRepository.updateById(wallet.id, { address }, { returning: true }); + const updatedWallet = await repository.updateById(wallet.id, { address }, { returning: true }); return { ...updatedWallet, address }; } } diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index 63495cf763..5839f4c17a 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -20,7 +20,7 @@ export class WalletReaderService { const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); return wallets - .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && wallet.address !== null) + .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && !!wallet.address) .map(wallet => this.userWalletRepository.toPublic(wallet)); } diff --git a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts index 70f9f50028..cfaa86fa39 100644 --- a/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts +++ b/apps/api/src/deployment/services/deployment-reader/deployment-reader.service.ts @@ -16,7 +16,7 @@ import { InternalServerError } from "http-errors"; import { Op } from "sequelize"; import { singleton } from "tsyringe"; -import { WalletInitialized } from "@src/billing/repositories"; +import type { WalletInitialized } from "@src/billing/repositories"; import { WalletReaderService } from "@src/billing/services/wallet-reader/wallet-reader.service"; import { Memoize } from "@src/caching/helpers"; import { LoggerService } from "@src/core"; From 9e439e545198a3cb65b4141364b5813b75027be4 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:51:35 +0100 Subject: [PATCH 05/12] fix(billing): stop publishing a stale endDate default in the usage OpenAPI spec The usage-history query schema defaulted `endDate` with `.default(() => new Date()...)`. The OpenAPI generator evaluates that at generation time and freezes a static date into the spec, which then goes stale and misleads generated clients into sending an old default date. Move the "default to today" logic into the schema transform (next to the existing startDate-from-endDate derivation) so runtime behavior is unchanged but the generated spec no longer carries a static default. Regenerate openapi.json, update the docs snapshot, and drop the now-redundant non-null assertions in the router and refine. --- .../src/billing/http-schemas/usage.schema.ts | 26 +++++++++---------- .../src/billing/routes/usage/usage.router.ts | 4 +-- apps/api/swagger/openapi.json | 2 -- .../__snapshots__/docs.spec.ts.snap | 2 -- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/api/src/billing/http-schemas/usage.schema.ts b/apps/api/src/billing/http-schemas/usage.schema.ts index a716fa0f74..c1068dcaee 100644 --- a/apps/api/src/billing/http-schemas/usage.schema.ts +++ b/apps/api/src/billing/http-schemas/usage.schema.ts @@ -12,29 +12,27 @@ export const GetUsageHistoryQuerySchema = z description: "Start date (YYYY-MM-DD). Defaults to 30 days before endDate", example: "2024-01-01" }), - endDate: z - .string() - .date() - .default(() => new Date().toISOString().split("T")[0]) - .openapi({ - description: "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", - example: "2024-01-31" - }) + endDate: z.string().date().optional().openapi({ + description: "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", + example: "2024-01-31" + }) }) .transform(data => { + const endDate = data.endDate ?? new Date().toISOString().split("T")[0]; + if (data.startDate) { - return data; + return { ...data, startDate: data.startDate, endDate }; } - const endDate = new Date(data.endDate); - endDate.setDate(endDate.getDate() - 30); + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - 30); - return { ...data, startDate: endDate.toISOString().split("T")[0] }; + return { ...data, startDate: startDate.toISOString().split("T")[0], endDate }; }) .refine( data => { - const end = new Date(data.endDate!); - const start = new Date(data.startDate!); + const end = new Date(data.endDate); + const start = new Date(data.startDate); const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)); diff --git a/apps/api/src/billing/routes/usage/usage.router.ts b/apps/api/src/billing/routes/usage/usage.router.ts index cffa238deb..f9bc0571ae 100644 --- a/apps/api/src/billing/routes/usage/usage.router.ts +++ b/apps/api/src/billing/routes/usage/usage.router.ts @@ -59,12 +59,12 @@ export const usageRouter = new OpenApiHonoHandler(); usageRouter.openapi(getUsageHistoryRoute, async function routeGetBillingUsage(c) { const { address, startDate, endDate } = c.req.valid("query"); - const usageHistory = await container.resolve(UsageController).getHistory(address, startDate!, endDate!); + const usageHistory = await container.resolve(UsageController).getHistory(address, startDate, endDate); return c.json(usageHistory); }); usageRouter.openapi(getUsageHistoryStatsRoute, async function routeGetBillingOverview(c) { const { address, startDate, endDate } = c.req.valid("query"); - const usageHistoryStats = await container.resolve(UsageController).getHistoryStats(address, startDate!, endDate!); + const usageHistoryStats = await container.resolve(UsageController).getHistoryStats(address, startDate, endDate); return c.json(usageHistoryStats); }); diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index 42ca4eb184..3df827b7ec 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -1787,7 +1787,6 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, @@ -1913,7 +1912,6 @@ "schema": { "type": "string", "format": "date", - "default": "2026-07-20", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31" }, diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index d0b36a5370..c6fe691467 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -14010,7 +14010,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "name": "endDate", "required": false, "schema": { - "default": "2025-07-03", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31", "format": "date", @@ -14136,7 +14135,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "name": "endDate", "required": false, "schema": { - "default": "2025-07-03", "description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59", "example": "2024-01-31", "format": "date", From 0252d67190e5e1c6f8f504c06c395d2e252df6d0 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:15:14 +0100 Subject: [PATCH 06/12] fix(billing): derive usage startDate with UTC date arithmetic new Date("YYYY-MM-DD") parses at UTC midnight, but getDate()/setDate() operate in the process timezone, so the 30-day default window could land one day early across a DST boundary in non-UTC deployments (e.g. endDate 2024-11-15 yielded 2024-10-15 under America/New_York instead of 2024-10-16). Compute the offset with getUTCDate()/setUTCDate() so the derived startDate is timezone-independent, and add usage.schema specs covering the window derivation, boundary crossing, and timezone regression. --- .../billing/http-schemas/usage.schema.spec.ts | 64 +++++++++++++++++++ .../src/billing/http-schemas/usage.schema.ts | 4 +- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/billing/http-schemas/usage.schema.spec.ts diff --git a/apps/api/src/billing/http-schemas/usage.schema.spec.ts b/apps/api/src/billing/http-schemas/usage.schema.spec.ts new file mode 100644 index 0000000000..5b9a18949a --- /dev/null +++ b/apps/api/src/billing/http-schemas/usage.schema.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { GetUsageHistoryQuerySchema } from "./usage.schema"; + +describe("Usage Schema", () => { + describe("GetUsageHistoryQuerySchema", () => { + const address = "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6"; + + it("derives startDate as 30 days before the provided endDate", () => { + const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-31" }); + + expect(result.startDate).toBe("2024-01-01"); + expect(result.endDate).toBe("2024-01-31"); + }); + + it("derives startDate across month and year boundaries", () => { + const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-15" }); + + expect(result.startDate).toBe("2023-12-16"); + expect(result.endDate).toBe("2024-01-15"); + }); + + it("computes startDate in UTC regardless of the process timezone", () => { + const originalTimezone = process.env.TZ; + process.env.TZ = "America/New_York"; + + try { + const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-11-15" }); + + expect(result.startDate).toBe("2024-10-16"); + expect(result.endDate).toBe("2024-11-15"); + } finally { + process.env.TZ = originalTimezone; + } + }); + + it("keeps the provided startDate untouched", () => { + const result = GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-01", endDate: "2024-01-31" }); + + expect(result.startDate).toBe("2024-01-01"); + expect(result.endDate).toBe("2024-01-31"); + }); + + it("defaults endDate to today and spans a 30-day window when both dates are omitted", () => { + const result = GetUsageHistoryQuerySchema.parse({ address }); + + expect(result.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + const spanInDays = (Date.parse(result.endDate) - Date.parse(result.startDate)) / (24 * 60 * 60 * 1000); + expect(spanInDays).toBe(30); + }); + + it("rejects a range wider than 366 days", () => { + expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2023-01-01", endDate: "2024-12-31" })).toThrow( + "Date range cannot exceed 366 days and startDate must be before endDate" + ); + }); + + it("rejects a startDate after the endDate", () => { + expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-02-01", endDate: "2024-01-01" })).toThrow( + "Date range cannot exceed 366 days and startDate must be before endDate" + ); + }); + }); +}); diff --git a/apps/api/src/billing/http-schemas/usage.schema.ts b/apps/api/src/billing/http-schemas/usage.schema.ts index c1068dcaee..3d3d92729b 100644 --- a/apps/api/src/billing/http-schemas/usage.schema.ts +++ b/apps/api/src/billing/http-schemas/usage.schema.ts @@ -24,8 +24,8 @@ export const GetUsageHistoryQuerySchema = z return { ...data, startDate: data.startDate, endDate }; } - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - 30); + const startDate = new Date(`${endDate}T00:00:00.000Z`); + startDate.setUTCDate(startDate.getUTCDate() - 30); return { ...data, startDate: startDate.toISOString().split("T")[0], endDate }; }) From 6c12ce5fb4ef051a2b0e840dc17f459c0a0fdfa4 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:06:21 +0530 Subject: [PATCH 07/12] refactor(billing): address review feedback on wallet initialization Resolve reviewer questions on the wallet-address refactor: - Add an isWalletInitialized type guard next to WalletInitialized and use it in WalletInitializerService.ensureWallet and WalletReaderService .getWallets, replacing the { ...wallet, address: wallet.address } spread that read as a no-op. That spread existed only to re-apply the narrowed string type over the nullable field the object spread reintroduces. - Inline #ensureWalletVia into ensureWallet: merging #3542 removed the second, CASL-scoped caller, leaving a single caller that always passed the plain repository, so the indirection no longer earned its keep. - Drop returning: true on the address update. The returned row was reconstructed as { ...wallet, address } regardless, so echoing the row back from the DB was redundant; no consumer reads the other columns. - Update the two specs that asserted returning: true on the address update. --- .../user-wallet/user-wallet.repository.ts | 4 ++++ .../wallet-initializer.service.spec.ts | 4 ++-- .../wallet-initializer.service.ts | 19 ++++++------------- .../wallet-reader/wallet-reader.service.ts | 4 ++-- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index 8084265df7..0666ceffa7 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -24,6 +24,10 @@ export type UserWalletOutput = Omit & { address: string }; +export function isWalletInitialized(wallet: UserWalletOutput): wallet is WalletInitialized { + return wallet.address !== null; +} + export interface UserWalletPublicOutput { id: UserWalletOutput["id"]; userId: UserWalletOutput["userId"]; diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts index 7992f92955..35aaf2ffb9 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts @@ -55,7 +55,7 @@ describe(WalletInitializerService.name, () => { await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: orphanWallet.id }); - expect(updateWalletById).toHaveBeenCalledWith(orphanWallet.id, { address: derivedAddress }, { returning: true }); + expect(updateWalletById).toHaveBeenCalledWith(orphanWallet.id, { address: derivedAddress }); }); it("returns the current state without chain calls when the wallet is already activated", async () => { @@ -162,7 +162,7 @@ describe(WalletInitializerService.name, () => { expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: bareWallet.id }); - expect(updateWalletById).toHaveBeenCalledWith(bareWallet.id, { address: derivedAddress }, { returning: true }); + expect(updateWalletById).toHaveBeenCalledWith(bareWallet.id, { address: derivedAddress }); expect(result.address).toBe(derivedAddress); }); diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts index f4892a4e36..201b31dc29 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.ts @@ -2,7 +2,7 @@ import assert from "http-assert"; import { singleton } from "tsyringe"; import { TrialStarted } from "@src/billing/events/trial-started"; -import { type UserWalletPublicOutput, UserWalletRepository, type WalletInitialized } from "@src/billing/repositories"; +import { isWalletInitialized, type UserWalletPublicOutput, UserWalletRepository, type WalletInitialized } from "@src/billing/repositories"; import { TrialActivationInstrumentationService } from "@src/billing/services/activate-trial/trial-activation-instrumentation.service"; import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { StripeService } from "@src/billing/services/stripe/stripe.service"; @@ -74,22 +74,15 @@ export class WalletInitializerService { /** * 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 { - 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 { - const { wallet } = await repository.getOrCreate({ userId }); - - if (wallet.address) return { ...wallet, address: wallet.address }; + async ensureWallet(userId: string): Promise { + const { wallet } = await this.userWalletRepository.getOrCreate({ userId }); + if (isWalletInitialized(wallet)) return wallet; const { address } = await this.walletManager.createWallet({ addressIndex: wallet.id }); - const updatedWallet = await repository.updateById(wallet.id, { address }, { returning: true }); - return { ...updatedWallet, address }; + await this.userWalletRepository.updateById(wallet.id, { address }); + return { ...wallet, address }; } } diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index 5839f4c17a..9295d78ffc 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -3,7 +3,7 @@ import assert from "http-assert"; import { Lifecycle, scoped } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; -import { UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, WalletInitialized } from "@src/billing/repositories"; +import { isWalletInitialized, UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, WalletInitialized } from "@src/billing/repositories"; export interface GetWalletOptions { userId: string; @@ -20,7 +20,7 @@ export class WalletReaderService { const wallets = await this.userWalletRepository.accessibleBy(this.authService.ability, "read").find(query); return wallets - .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && !!wallet.address) + .filter((wallet): wallet is WalletInitialized => wallet.activatedAt !== null && isWalletInitialized(wallet)) .map(wallet => this.userWalletRepository.toPublic(wallet)); } From 6924eaad90c9d7c6dfc621bbad6a5c3bc9ff393a Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:29:02 +0530 Subject: [PATCH 08/12] fix(config): restore production server URL in openapi spec A regeneration ran the generator with the default SERVER_ORIGIN (localhost:3080) and overwrote the sandbox URL that origin/main ships, making Swagger UI and generated clients target the caller's machine. Restore servers[0].url to https://console-api-sandbox.akash.network. --- apps/api/swagger/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index 68de990a5c..d931c8bd10 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "servers": [ { - "url": "http://localhost:3080" + "url": "https://console-api-sandbox.akash.network" } ], "info": { From 6ca8e9e09249d11515c7ec33a66bb65a00d0155a Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:53:29 +0530 Subject: [PATCH 09/12] fix(billing): treat empty-string address as uninitialized wallet --- .../user-wallet/user-wallet.repository.ts | 2 +- .../wallet-initializer.service.spec.ts | 18 ++++++++++++++++++ .../wallet-reader.service.spec.ts | 10 ++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts index 0666ceffa7..40da355fbc 100644 --- a/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts +++ b/apps/api/src/billing/repositories/user-wallet/user-wallet.repository.ts @@ -25,7 +25,7 @@ export type UserWalletOutput = Omit & { address: string }; export function isWalletInitialized(wallet: UserWalletOutput): wallet is WalletInitialized { - return wallet.address !== null; + return !!wallet.address; } export interface UserWalletPublicOutput { diff --git a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts index 35aaf2ffb9..7897ef8807 100644 --- a/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts +++ b/apps/api/src/billing/services/wallet-initializer/wallet-initializer.service.spec.ts @@ -166,6 +166,24 @@ describe(WalletInitializerService.name, () => { expect(result.address).toBe(derivedAddress); }); + it("derives an address when the existing wallet has an empty-string address", async () => { + const userId = "test-user-id"; + const emptyAddressWallet = createUserWallet({ userId, address: "" }); + const derivedAddress = "akash1derived"; + const getOrCreateWallet = vi.fn().mockResolvedValue({ wallet: emptyAddressWallet, isNew: false }); + const updateWalletById = vi.fn().mockImplementation(async (id, patch) => ({ ...emptyAddressWallet, ...patch })); + + const di = setup({ getOrCreateWallet, updateWalletById }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createWallet.mockResolvedValue({ address: derivedAddress }); + + const result = await di.resolve(WalletInitializerService).ensureWallet(userId); + + expect(managedUserWalletService.createWallet).toHaveBeenCalledWith({ addressIndex: emptyAddressWallet.id }); + expect(updateWalletById).toHaveBeenCalledWith(emptyAddressWallet.id, { address: derivedAddress }); + expect(result.address).toBe(derivedAddress); + }); + it("returns the existing wallet without deriving an address again", async () => { const userId = "test-user-id"; const existingWallet = createUserWallet({ userId }); diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts index a6d4ddd49c..c53747b1dd 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.spec.ts @@ -21,6 +21,16 @@ describe(WalletReaderService.name, () => { expect(result[0].address).toBe(activatedWallet.address); }); + it("excludes activated wallets with an empty-string address", async () => { + const userId = "test-user-id"; + const emptyAddressWallet = createUserWallet({ userId, activatedAt: new Date(), address: "" }); + const { service } = setup({ wallets: [emptyAddressWallet] }); + + const result = await service.getWallets({ userId }); + + expect(result).toEqual([]); + }); + it("returns an empty list when the user only has a non-activated wallet", async () => { const userId = "test-user-id"; const nonActivatedWallet = createUserWallet({ userId, activatedAt: null }); From 7ba0481fb129e94fa397f2ed77a0c8b301a89c9e Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:33:18 +0530 Subject: [PATCH 10/12] refactor(deployment): use type-only import for wallet types in deployment writer --- .../services/deployment-writer/deployment-writer.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts index 0555339ee6..d0b0ca5b03 100644 --- a/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts +++ b/apps/api/src/deployment/services/deployment-writer/deployment-writer.service.ts @@ -2,7 +2,7 @@ import { manifestToSortedJSON } from "@akashnetwork/chain-sdk"; import assert from "http-assert"; import { singleton } from "tsyringe"; -import { UserWalletOutput, WalletInitialized } from "@src/billing/repositories"; +import type { 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"; From d9f8537e8e70704c778cb724581eee50f18b11ae Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:51:23 +0530 Subject: [PATCH 11/12] refactor(billing): mark type-only wallet imports in wallet reader --- .../services/wallet-reader/wallet-reader.service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index 9295d78ffc..01de516716 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -3,7 +3,13 @@ import assert from "http-assert"; import { Lifecycle, scoped } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; -import { isWalletInitialized, UserWalletOutput, UserWalletPublicOutput, UserWalletRepository, WalletInitialized } from "@src/billing/repositories"; +import { + isWalletInitialized, + type UserWalletOutput, + type UserWalletPublicOutput, + UserWalletRepository, + type WalletInitialized +} from "@src/billing/repositories"; export interface GetWalletOptions { userId: string; From 3a3f940a7da25753e4d4532a092e680bd400e4f0 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:52:07 +0530 Subject: [PATCH 12/12] refactor(billing): reuse isWalletInitialized guard in getWalletByUserId --- .../services/wallet-reader/wallet-reader.service.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts index 01de516716..7a30396106 100644 --- a/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts +++ b/apps/api/src/billing/services/wallet-reader/wallet-reader.service.ts @@ -43,12 +43,8 @@ export class WalletReaderService { return userWallet; } - const { address } = userWallet; - assert(address, 403, "UserWallet is not initialized"); + assert(isWalletInitialized(userWallet), 403, "UserWallet is not initialized"); - return { - ...userWallet, - address - }; + return userWallet; } }