diff --git a/apps/api/drizzle/0026_chief_mysterio.sql b/apps/api/drizzle/0026_chief_mysterio.sql new file mode 100644 index 0000000000..34872b755b --- /dev/null +++ b/apps/api/drizzle/0026_chief_mysterio.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user_wallets" ALTER COLUMN "created_at" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "user_wallets" ALTER COLUMN "updated_at" SET NOT NULL;--> statement-breakpoint diff --git a/apps/api/drizzle/meta/0026_snapshot.json b/apps/api/drizzle/meta/0026_snapshot.json new file mode 100644 index 0000000000..09c0f76a75 --- /dev/null +++ b/apps/api/drizzle/meta/0026_snapshot.json @@ -0,0 +1,1024 @@ +{ + "id": "93a5b2f2-ea5b-469a-b051-54439a64209d", + "prevId": "12d95c2d-4fb4-4458-a587-63f74f590bad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + } + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.stripe_transactions": { + "name": "stripe_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stripe_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "stripe_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_promotion_code_id": { + "name": "stripe_promotion_code_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_brand": { + "name": "card_brand", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_last4": { + "name": "card_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_transactions_user_id_idx": { + "name": "stripe_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_payment_intent_id_idx": { + "name": "stripe_transactions_stripe_payment_intent_id_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_charge_id_idx": { + "name": "stripe_transactions_stripe_charge_id_idx", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_coupon_id_idx": { + "name": "stripe_transactions_stripe_coupon_id_idx", + "columns": [ + { + "expression": "stripe_coupon_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_promotion_code_id_idx": { + "name": "stripe_transactions_stripe_promotion_code_id_idx", + "columns": [ + { + "expression": "stripe_promotion_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_status_idx": { + "name": "stripe_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_created_at_idx": { + "name": "stripe_transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_created_at_idx": { + "name": "stripe_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_transactions_user_id_userSetting_id_fk": { + "name": "stripe_transactions_user_id_userSetting_id_fk", + "tableFrom": "stripe_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.wallet_settings": { + "name": "wallet_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auto_reload_enabled": { + "name": "auto_reload_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_settings_user_id_idx": { + "name": "wallet_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_settings_wallet_id_user_wallets_id_fk": { + "name": "wallet_settings_wallet_id_user_wallets_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "user_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_settings_user_id_userSetting_id_fk": { + "name": "wallet_settings_user_id_userSetting_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_settings_wallet_id_unique": { + "name": "wallet_settings_wallet_id_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_id" + ] + } + } + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_user_agent": { + "name": "last_user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_fingerprint": { + "name": "last_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "id_auto_top_up_enabled_closed_idx": { + "name": "id_auto_top_up_enabled_closed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auto_top_up_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_settings_user_id_userSetting_id_fk": { + "name": "deployment_settings_user_id_userSetting_id_fk", + "tableFrom": "deployment_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dseq_user_id_idx": { + "name": "dseq_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "dseq", + "user_id" + ] + } + } + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_format": { + "name": "key_format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_userSetting_id_fk": { + "name": "api_keys_user_id_userSetting_id_fk", + "tableFrom": "api_keys", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_unique": { + "name": "api_keys_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + } + } + }, + "enums": { + "public.stripe_transaction_status": { + "name": "stripe_transaction_status", + "schema": "public", + "values": [ + "created", + "pending", + "requires_action", + "succeeded", + "failed", + "refunded", + "canceled" + ] + }, + "public.stripe_transaction_type": { + "name": "stripe_transaction_type", + "schema": "public", + "values": [ + "payment_intent", + "coupon_claim" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 06d1b5acc9..96227220c7 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1767109794187, "tag": "0025_overrated_saracen", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1767358169884, + "tag": "0026_chief_mysterio", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts index a79518df13..4eff596278 100644 --- a/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts +++ b/apps/api/src/billing/model-schemas/user-wallet/user-wallet.schema.ts @@ -12,8 +12,8 @@ export const UserWallets = pgTable("user_wallets", { deploymentAllowance: allowance("deployment_allowance"), feeAllowance: allowance("fee_allowance"), isTrialing: boolean("trial").default(true), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow() + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull() }); function allowance(name: string) { diff --git a/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts b/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts index 521d1282ac..eb79838a22 100644 --- a/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts +++ b/apps/api/src/billing/services/managed-signer/managed-signer.service.spec.ts @@ -12,7 +12,9 @@ import type { AuthService } from "@src/auth/services/auth.service"; import { TrialDeploymentLeaseCreated } from "@src/billing/events/trial-deployment-lease-created"; import type { UserWalletRepository } from "@src/billing/repositories"; import type { BalancesService } from "@src/billing/services/balances/balances.service"; +import type { BillingConfigService } from "@src/billing/services/billing-config/billing-config.service"; import type { ChainErrorService } from "@src/billing/services/chain-error/chain-error.service"; +import type { ManagedUserWalletService } from "@src/billing/services/managed-user-wallet/managed-user-wallet.service"; import type { TrialValidationService } from "@src/billing/services/trial-validation/trial-validation.service"; import type { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; import type { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; @@ -24,6 +26,7 @@ import { createAkashAddress } from "../../../../test/seeders"; import type { TxManagerService } from "../tx-manager/tx-manager.service"; import { ManagedSignerService } from "./managed-signer.service"; +import { mockConfigService } from "@test/mocks/config-service.mock"; import { UserSeeder } from "@test/seeders/user.seeder"; import { UserWalletSeeder } from "@test/seeders/user-wallet.seeder"; @@ -69,7 +72,8 @@ describe(ManagedSignerService.name, () => { const user = UserSeeder.create({ userId: "user-123" }); const { service } = setup({ findOneByUserId: jest.fn().mockResolvedValue(wallet), - findById: jest.fn().mockResolvedValue(user) + findById: jest.fn().mockResolvedValue(user), + retrieveAndCalcFeeLimit: jest.fn().mockResolvedValue(0) }); await expect(service.executeDerivedDecodedTxByUserId("user-123", [])).rejects.toThrow("Not enough funds to cover the transaction fee"); @@ -89,7 +93,8 @@ describe(ManagedSignerService.name, () => { const { service } = setup({ findOneByUserId: jest.fn().mockResolvedValue(wallet), - findById: jest.fn().mockResolvedValue(user) + findById: jest.fn().mockResolvedValue(user), + retrieveDeploymentLimit: jest.fn().mockResolvedValue(0) }); await expect(service.executeDerivedDecodedTxByUserId("user-123", [deploymentMessage])).rejects.toThrow("Not enough funds to cover the deployment costs"); @@ -587,6 +592,8 @@ describe(ManagedSignerService.name, () => { validateLeaseProvidersAuditors?: TrialValidationService["validateLeaseProvidersAuditors"]; signAndBroadcastWithDerivedWallet?: TxManagerService["signAndBroadcastWithDerivedWallet"]; refreshUserWalletLimits?: BalancesService["refreshUserWalletLimits"]; + retrieveAndCalcFeeLimit?: BalancesService["retrieveAndCalcFeeLimit"]; + retrieveDeploymentLimit?: BalancesService["retrieveDeploymentLimit"]; publish?: DomainEventsService["publish"]; transformChainError?: ChainErrorService["toAppError"]; hasLeases?: LeaseHttpService["hasLeases"]; @@ -601,7 +608,9 @@ describe(ManagedSignerService.name, () => { findById: input?.findById ?? jest.fn() }), balancesService: mock({ - refreshUserWalletLimits: input?.refreshUserWalletLimits ?? jest.fn() + refreshUserWalletLimits: input?.refreshUserWalletLimits ?? jest.fn(), + retrieveAndCalcFeeLimit: input?.retrieveAndCalcFeeLimit ?? jest.fn().mockResolvedValue(1000000), + retrieveDeploymentLimit: input?.retrieveDeploymentLimit ?? jest.fn().mockResolvedValue(5000000) }), authService: mock({ currentUser: input?.currentUser ?? UserSeeder.create({ userId: "current-user" }), @@ -630,6 +639,14 @@ describe(ManagedSignerService.name, () => { }), walletReloadJobService: mock({ scheduleImmediate: jest.fn() + }), + billingConfigService: mockConfigService({ + FEE_ALLOWANCE_REFILL_AMOUNT: 1000000, + FEE_ALLOWANCE_REFILL_THRESHOLD: 100000, + TRIAL_ALLOWANCE_EXPIRATION_DAYS: 30 + }), + managedUserWalletService: mock({ + refillWalletFees: jest.fn() }) }; @@ -639,6 +656,7 @@ describe(ManagedSignerService.name, () => { const service = new ManagedSignerService( registryMock, + mocks.billingConfigService, mocks.userWalletRepository, mocks.userRepository, mocks.balancesService, @@ -649,7 +667,8 @@ describe(ManagedSignerService.name, () => { mocks.txManagerService, mocks.domainEvents, mocks.leaseHttpService, - mocks.walletReloadJobService + mocks.walletReloadJobService, + mocks.managedUserWalletService ); return { service, registry: registryMock, ...mocks }; diff --git a/apps/api/src/billing/services/managed-signer/managed-signer.service.ts b/apps/api/src/billing/services/managed-signer/managed-signer.service.ts index 59905d5126..a666f68d96 100644 --- a/apps/api/src/billing/services/managed-signer/managed-signer.service.ts +++ b/apps/api/src/billing/services/managed-signer/managed-signer.service.ts @@ -12,6 +12,7 @@ import { AuthService } from "@src/auth/services/auth.service"; import { TrialDeploymentLeaseCreated } from "@src/billing/events/trial-deployment-lease-created"; import { InjectTypeRegistry } from "@src/billing/providers/type-registry.provider"; import { UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; +import { ManagedUserWalletService } from "@src/billing/services/managed-user-wallet/managed-user-wallet.service"; import { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; import { WalletReloadJobService } from "@src/billing/services/wallet-reload-job/wallet-reload-job.service"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; @@ -19,6 +20,7 @@ import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; import { UserOutput, UserRepository } from "@src/user/repositories"; import { BalancesService } from "../balances/balances.service"; +import { BillingConfigService } from "../billing-config/billing-config.service"; import { ChainErrorService } from "../chain-error/chain-error.service"; import { TrialValidationService } from "../trial-validation/trial-validation.service"; @@ -30,6 +32,7 @@ const SPENDING_TXS = [MsgCreateDeployment, MsgAccountDeposit]; export class ManagedSignerService { constructor( @InjectTypeRegistry() private readonly registry: Registry, + private readonly billingConfigService: BillingConfigService, private readonly userWalletRepository: UserWalletRepository, private readonly userRepository: UserRepository, private readonly balancesService: BalancesService, @@ -40,7 +43,8 @@ export class ManagedSignerService { private readonly txManagerService: TxManagerService, private readonly domainEvents: DomainEventsService, private readonly leaseHttpService: LeaseHttpService, - private readonly walletReloadJobService: WalletReloadJobService + private readonly walletReloadJobService: WalletReloadJobService, + private readonly managedUserWalletService: ManagedUserWalletService ) {} async executeDerivedTx(walletIndex: number, messages: readonly EncodeObject[]) { @@ -153,7 +157,9 @@ export class ManagedSignerService { /** * Validates that the user wallet has sufficient balances to cover transaction fees and deployment costs. - * Uses cached allowances if balance is greater than 0, otherwise fetches current balances from the chain to ensure accuracy. + * Always fetches fee allowance from the chain to ensure accuracy, as database values may be out of sync. + * Fetches deployment allowance from the chain only when a deployment message is present, otherwise uses cached value. + * Automatically refills fee authorization for eligible trial wallets if fee allowance is below FEE_ALLOWANCE_REFILL_THRESHOLD. * Throws an assertion error if insufficient funds are available. * * @param userWallet - The user wallet to validate balances for @@ -163,13 +169,18 @@ export class ManagedSignerService { */ async #validateBalances(userWallet: UserWalletOutput, messages: EncodeObject[]) { const hasDeploymentMessage = messages.some(message => message.typeUrl.endsWith(".MsgCreateDeployment")); - const [feeAllowance, deploymentAllowance] = await Promise.all([ - userWallet.feeAllowance > 0 ? Promise.resolve(userWallet.feeAllowance) : this.balancesService.retrieveAndCalcFeeLimit(userWallet), - !hasDeploymentMessage || userWallet.deploymentAllowance > 0 - ? Promise.resolve(userWallet.deploymentAllowance) - : this.balancesService.retrieveDeploymentLimit(userWallet) + const [existingFeeAllowance, deploymentAllowance] = await Promise.all([ + this.balancesService.retrieveAndCalcFeeLimit(userWallet), + !hasDeploymentMessage ? Promise.resolve(userWallet.deploymentAllowance) : this.balancesService.retrieveDeploymentLimit(userWallet) ]); + let feeAllowance = existingFeeAllowance; + + if (feeAllowance < this.billingConfigService.get("FEE_ALLOWANCE_REFILL_THRESHOLD")) { + await this.managedUserWalletService.refillWalletFees(this, userWallet); + feeAllowance = await this.balancesService.retrieveAndCalcFeeLimit(userWallet); + } + assert(feeAllowance > 0, 402, "Not enough funds to cover the transaction fee"); assert(!hasDeploymentMessage || deploymentAllowance > 0, 402, "Not enough funds to cover the deployment costs"); } diff --git a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts index 65c6287c63..7b0134d3a2 100644 --- a/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts +++ b/apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts @@ -2,12 +2,16 @@ import { AuthzHttpService } from "@akashnetwork/http-sdk"; import { LoggerService } from "@akashnetwork/logging"; import { EncodeObject } from "@cosmjs/proto-signing"; import add from "date-fns/add"; +import addDays from "date-fns/addDays"; +import isAfter from "date-fns/isAfter"; +import subDays from "date-fns/subDays"; +import assert from "http-assert"; import { singleton } from "tsyringe"; import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; +import { UserWalletOutput } from "@src/billing/repositories"; import { TxManagerService } from "@src/billing/services/tx-manager/tx-manager.service"; -import type { DryRunOptions } from "@src/core/types/console"; -import { ManagedSignerService } from "../managed-signer/managed-signer.service"; +import type { ManagedSignerService } from "../managed-signer/managed-signer.service"; import { RpcMessageService, SpendingAuthorizationMsgOptions } from "../rpc-message-service/rpc-message.service"; interface SpendingAuthorizationOptions { @@ -30,19 +34,18 @@ export class ManagedUserWalletService { constructor( @InjectBillingConfig() private readonly config: BillingConfig, private readonly txManagerService: TxManagerService, - private readonly managedSignerService: ManagedSignerService, private readonly rpcMessageService: RpcMessageService, private readonly authzHttpService: AuthzHttpService ) {} - async createAndAuthorizeTrialSpending({ addressIndex }: { addressIndex: number }) { + async createAndAuthorizeTrialSpending(signer: ManagedSignerService, { addressIndex }: { addressIndex: number }) { const address = await this.txManagerService.getDerivedWalletAddress(addressIndex); const limits = { deployment: this.config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT, fees: this.config.TRIAL_FEES_ALLOWANCE_AMOUNT }; - await this.authorizeSpending({ + await this.authorizeSpending(signer, { address, limits: limits, expiration: add(new Date(), { days: this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS }) @@ -58,7 +61,7 @@ export class ManagedUserWalletService { return { address }; } - async authorizeSpending(options: SpendingAuthorizationOptions) { + async authorizeSpending(signer: ManagedSignerService, options: SpendingAuthorizationOptions) { const fundingWalletAddress = await this.txManagerService.getFundingWalletAddress(); const msgOptions = { granter: fundingWalletAddress, @@ -68,12 +71,12 @@ export class ManagedUserWalletService { await Promise.all([ "deployment" in options.limits && - this.authorizeDeploymentSpending({ + this.authorizeDeploymentSpending(signer, { ...msgOptions, denom: this.config.DEPLOYMENT_GRANT_DENOM, limit: options.limits.deployment }), - this.authorizeFeeSpending({ + this.authorizeFeeSpending(signer, { ...msgOptions, limit: options.limits.fees }) @@ -82,7 +85,32 @@ export class ManagedUserWalletService { this.logger.debug({ event: "SPENDING_AUTHORIZED", address: options.address }); } - private async authorizeFeeSpending(options: Omit) { + /** + * Refills fee authorization for a wallet that is eligible for trial allowances. + * Authorizes fees for wallets in the trial window, applying the same logic as RefillService.refillWalletFees. + * Sets expiration date based on a trial window if the wallet is trialing. + * + * @param signer - The ManagedSignerService instance to use for authorization + * @param userWallet - The user wallet to refill fees for + */ + async refillWalletFees(signer: ManagedSignerService, { address, ...userWallet }: UserWalletOutput) { + assert(address, 402, "Wallet is not initialized"); + + const trialWindowStart = subDays(new Date(), this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS); + const isInTrialWindow = userWallet.isTrialing && isAfter(userWallet.createdAt, trialWindowStart); + const expiration = isInTrialWindow ? addDays(userWallet.createdAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; + const fees = this.config.FEE_ALLOWANCE_REFILL_AMOUNT; + + await this.authorizeSpending(signer, { + address, + limits: { + fees + }, + expiration + }); + } + + private async authorizeFeeSpending(signer: ManagedSignerService, options: Omit) { const messages: EncodeObject[] = []; const hasValidFeeAllowance = await this.authzHttpService.hasFeeAllowance(options.granter, options.grantee); @@ -92,43 +120,11 @@ export class ManagedUserWalletService { messages.push(this.rpcMessageService.getFeesAllowanceGrantMsg(options)); - return await this.managedSignerService.executeFundingTx(messages); + return await signer.executeFundingTx(messages); } - private async authorizeDeploymentSpending(options: SpendingAuthorizationMsgOptions) { + private async authorizeDeploymentSpending(signer: ManagedSignerService, options: SpendingAuthorizationMsgOptions) { const deploymentAllowanceMsg = this.rpcMessageService.getDepositDeploymentGrantMsg(options); - return await this.managedSignerService.executeFundingTx([deploymentAllowanceMsg]); - } - - async revokeAll(grantee: string, reason?: string, options?: DryRunOptions) { - const fundingWalletAddress = await this.txManagerService.getFundingWalletAddress(); - const params = { granter: fundingWalletAddress, grantee }; - const messages: EncodeObject[] = []; - const revokeSummary = { - feeAllowance: false, - deploymentGrant: false - }; - - if (await this.authzHttpService.hasFeeAllowance(params.granter, params.grantee)) { - revokeSummary.feeAllowance = true; - messages.push(this.rpcMessageService.getRevokeAllowanceMsg(params)); - } - - if (await this.authzHttpService.hasDepositDeploymentGrant(params.granter, params.grantee)) { - revokeSummary.deploymentGrant = true; - messages.push(this.rpcMessageService.getRevokeDepositDeploymentGrantMsg(params)); - } - - if (!messages.length) { - return revokeSummary; - } - - if (!options?.dryRun) { - await this.managedSignerService.executeFundingTx(messages); - } - - this.logger.info({ event: "SPENDING_REVOKED", address: params.grantee, revokeSummary, reason }); - - return revokeSummary; + return await signer.executeFundingTx([deploymentAllowanceMsg]); } } diff --git a/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts b/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts index 684e59df3a..ff364a7596 100644 --- a/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts +++ b/apps/api/src/billing/services/provider-cleanup/provider-cleanup.service.ts @@ -62,7 +62,7 @@ export class ProviderCleanupService { } catch (error: any) { if (error.message.includes("not allowed to pay fees")) { if (!options.dryRun) { - await this.managedUserWalletService.authorizeSpending({ + await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { address: wallet.address!, limits: { fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT diff --git a/apps/api/src/billing/services/refill/refill.service.spec.ts b/apps/api/src/billing/services/refill/refill.service.spec.ts index 351d715577..f6314c3170 100644 --- a/apps/api/src/billing/services/refill/refill.service.spec.ts +++ b/apps/api/src/billing/services/refill/refill.service.spec.ts @@ -2,7 +2,7 @@ import { mock } from "jest-mock-extended"; import type { BillingConfig } from "@src/billing/providers"; import type { UserWalletRepository } from "@src/billing/repositories"; -import type { ManagedUserWalletService } from "@src/billing/services"; +import type { ManagedSignerService, ManagedUserWalletService } from "@src/billing/services"; import type { BalancesService } from "@src/billing/services/balances/balances.service"; import { RefillService } from "@src/billing/services/refill/refill.service"; import type { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; @@ -16,7 +16,8 @@ describe(RefillService.name, () => { const amountUsd = 100; it("should top up existing wallet", async () => { - const { service, userWalletRepository, managedUserWalletService, balancesService, walletInitializerService, analyticsService } = setup(); + const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, walletInitializerService, analyticsService } = + setup(); const existingWallet = UserWalletSeeder.create({ userId }); userWalletRepository.findOneBy.mockResolvedValue(existingWallet); managedUserWalletService.authorizeSpending.mockResolvedValue(); @@ -26,7 +27,7 @@ describe(RefillService.name, () => { await service.topUpWallet(amountUsd, userId); expect(userWalletRepository.findOneBy).toHaveBeenCalledWith({ userId }); - expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith({ + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: existingWallet.address, limits: { deployment: 1005000, fees: 1000 } }); @@ -37,7 +38,8 @@ describe(RefillService.name, () => { }); it("should create new wallet when none exists", async () => { - const { service, userWalletRepository, walletInitializerService, balancesService, managedUserWalletService, analyticsService } = setup(); + const { service, userWalletRepository, walletInitializerService, balancesService, managedUserWalletService, managedSignerService, analyticsService } = + setup(); const newWallet = UserWalletSeeder.create({ userId }); userWalletRepository.findOneBy.mockResolvedValue(undefined); walletInitializerService.initialize.mockResolvedValue(newWallet); @@ -48,7 +50,7 @@ describe(RefillService.name, () => { await service.topUpWallet(amountUsd, userId); expect(userWalletRepository.findOneBy).toHaveBeenCalledWith({ userId }); - expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith({ + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: newWallet.address, limits: { deployment: 1000000, fees: 1000 } }); @@ -58,7 +60,8 @@ describe(RefillService.name, () => { }); it("should handle race condition when creating wallet", async () => { - const { service, userWalletRepository, managedUserWalletService, balancesService, walletInitializerService, analyticsService } = setup(); + const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, walletInitializerService, analyticsService } = + setup(); const existingWallet = UserWalletSeeder.create({ userId }); userWalletRepository.findOneBy.mockResolvedValue(undefined); managedUserWalletService.authorizeSpending.mockResolvedValue(); @@ -73,11 +76,11 @@ describe(RefillService.name, () => { expect(userWalletRepository.findOneBy).toHaveBeenCalledWith({ userId }); expect(userWalletRepository.findOneBy).toHaveBeenCalledTimes(2); - expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith({ + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: existingWallet.address, limits: { deployment: 1000000, fees: 1000 } }); - expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith({ + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: existingWallet.address, limits: { deployment: 2 * 1000000, fees: 1000 } }); @@ -91,6 +94,7 @@ describe(RefillService.name, () => { const billingConfig = mock(); const userWalletRepository = mock(); const managedUserWalletService = mock(); + const managedSignerService = mock(); const balancesService = mock(); const walletInitializerService = mock(); const analyticsService = mock(); @@ -101,6 +105,7 @@ describe(RefillService.name, () => { billingConfig, userWalletRepository, managedUserWalletService, + managedSignerService, balancesService, walletInitializerService, analyticsService @@ -111,6 +116,7 @@ describe(RefillService.name, () => { billingConfig, userWalletRepository, managedUserWalletService, + managedSignerService, balancesService, walletInitializerService, analyticsService @@ -122,7 +128,7 @@ describe(RefillService.name, () => { const userId = "test-user-id"; it("reduces wallet balance by the specified amount", async () => { - const { service, userWalletRepository, managedUserWalletService, balancesService, analyticsService } = setup(); + const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, analyticsService } = setup(); const existingWallet = UserWalletSeeder.create({ userId, address: "akash1test..." }); userWalletRepository.findOneBy.mockResolvedValue(existingWallet); @@ -135,7 +141,7 @@ describe(RefillService.name, () => { expect(userWalletRepository.findOneBy).toHaveBeenCalledWith({ userId }); expect(balancesService.retrieveDeploymentLimit).toHaveBeenCalledWith(existingWallet); // 5000000 - (100 * 10000) = 5000000 - 1000000 = 4000000 - expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith({ + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: existingWallet.address, limits: { deployment: 4000000, fees: 1000 } }); @@ -144,7 +150,7 @@ describe(RefillService.name, () => { }); it("does not reduce balance below zero", async () => { - const { service, userWalletRepository, managedUserWalletService, balancesService, analyticsService } = setup(); + const { service, userWalletRepository, managedUserWalletService, managedSignerService, balancesService, analyticsService } = setup(); const existingWallet = UserWalletSeeder.create({ userId, address: "akash1test..." }); userWalletRepository.findOneBy.mockResolvedValue(existingWallet); @@ -155,7 +161,7 @@ describe(RefillService.name, () => { await service.reduceWalletBalance(100, userId); // Try to reduce by $1 (100 cents) // Should set to 0, not negative - expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith({ + expect(managedUserWalletService.authorizeSpending).toHaveBeenCalledWith(managedSignerService, { address: existingWallet.address, limits: { deployment: 0, fees: 1000 } }); @@ -191,6 +197,7 @@ describe(RefillService.name, () => { const billingConfig = mock(); const userWalletRepository = mock(); const managedUserWalletService = mock(); + const managedSignerService = mock(); const balancesService = mock(); const walletInitializerService = mock(); const analyticsService = mock(); @@ -201,6 +208,7 @@ describe(RefillService.name, () => { billingConfig, userWalletRepository, managedUserWalletService, + managedSignerService, balancesService, walletInitializerService, analyticsService @@ -211,6 +219,7 @@ describe(RefillService.name, () => { billingConfig, userWalletRepository, managedUserWalletService, + managedSignerService, balancesService, walletInitializerService, analyticsService diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index b0861d59d9..17b39110c6 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -7,6 +7,7 @@ import { singleton } from "tsyringe"; import { type BillingConfig, InjectBillingConfig } from "@src/billing/providers"; import { type UserWalletOutput, UserWalletRepository } from "@src/billing/repositories"; import { BalancesService } from "@src/billing/services/balances/balances.service"; +import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { ManagedUserWalletService } from "@src/billing/services/managed-user-wallet/managed-user-wallet.service"; import { WalletInitializerService } from "@src/billing/services/wallet-initializer/wallet-initializer.service"; import { Semaphore } from "@src/core/lib/semaphore.decorator"; @@ -20,6 +21,7 @@ export class RefillService { @InjectBillingConfig() private readonly config: BillingConfig, private readonly userWalletRepository: UserWalletRepository, private readonly managedUserWalletService: ManagedUserWalletService, + private readonly managedSignerService: ManagedSignerService, private readonly balancesService: BalancesService, private readonly walletInitializerService: WalletInitializerService, private readonly analyticsService: AnalyticsService @@ -48,7 +50,7 @@ export class RefillService { const expiration = isInTrialWindow && userWallet.createdAt ? addDays(userWallet.createdAt, this.config.TRIAL_ALLOWANCE_EXPIRATION_DAYS) : undefined; - await this.managedUserWalletService.authorizeSpending({ + await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { address: userWallet.address!, limits: { fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT @@ -69,7 +71,7 @@ export class RefillService { const nextLimit = currentLimit + amountUsd * 10000; const limits = { deployment: nextLimit, fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT }; - await this.managedUserWalletService.authorizeSpending({ + await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { address: userWallet.address!, limits }); @@ -99,7 +101,7 @@ export class RefillService { const nextLimit = Math.max(0, currentLimit - reductionAmount); const limits = { deployment: nextLimit, fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT }; - await this.managedUserWalletService.authorizeSpending({ + await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { address: userWallet.address, limits }); 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 cc5f4419d1..4f3923a320 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 @@ -1,14 +1,18 @@ +import { Registry } from "@cosmjs/proto-signing"; +import type { MockProxy } from "jest-mock-extended"; import { mock } from "jest-mock-extended"; import { container } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; import { TrialStarted } from "@src/billing/events/trial-started"; +import { TYPE_REGISTRY } from "@src/billing/providers/type-registry.provider"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; import type { FeatureFlagValue } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; import { ProviderJwtTokenService } from "@src/provider/services/provider-jwt-token/provider-jwt-token.service"; import { UserWalletRepository } from "../../repositories/user-wallet/user-wallet.repository"; +import { ManagedSignerService } from "../managed-signer/managed-signer.service"; import { ManagedUserWalletService } from "../managed-user-wallet/managed-user-wallet.service"; import { WalletInitializerService } from "./wallet-initializer.service"; @@ -23,20 +27,20 @@ describe(WalletInitializerService.name, () => { const newWallet = UserWalletSeeder.create({ userId }); const chainWallet = createChainWallet(); const getOrCreateWallet = jest.fn().mockImplementation(async () => ({ wallet: newWallet, isNew: true })); - const createAndAuthorizeTrialSpending = jest.fn().mockImplementation(async () => chainWallet); const updateWalletById = jest.fn().mockImplementation(async () => newWallet); const di = setup({ getOrCreateWallet, - createAndAuthorizeTrialSpending, updateWalletById, enabledFeatures: [FeatureFlags.ANONYMOUS_FREE_TRIAL] }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createAndAuthorizeTrialSpending.mockResolvedValue(chainWallet); await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); - expect(createAndAuthorizeTrialSpending).toHaveBeenCalledWith({ addressIndex: newWallet.id }); + expect(managedUserWalletService.createAndAuthorizeTrialSpending).toHaveBeenCalledWith(di.resolve(ManagedSignerService), { addressIndex: newWallet.id }); expect(updateWalletById).toHaveBeenCalledWith( newWallet.id, { @@ -52,18 +56,17 @@ describe(WalletInitializerService.name, () => { const userId = "test-user-id"; const existingWallet = UserWalletSeeder.create({ userId }); const getOrCreateWallet = jest.fn().mockResolvedValue({ wallet: existingWallet, isNew: false }); - const createAndAuthorizeTrialSpending = jest.fn().mockResolvedValue(undefined); const di = setup({ getOrCreateWallet, - createAndAuthorizeTrialSpending, enabledFeatures: [FeatureFlags.ANONYMOUS_FREE_TRIAL] }); + const managedUserWalletService = di.resolve(ManagedUserWalletService); await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); expect(getOrCreateWallet).toHaveBeenCalledWith({ userId }); - expect(createAndAuthorizeTrialSpending).not.toHaveBeenCalled(); + expect(managedUserWalletService.createAndAuthorizeTrialSpending).not.toHaveBeenCalled(); }); it("throws an error when cannot authorize trial spending and deletes user wallet", async () => { @@ -71,17 +74,17 @@ describe(WalletInitializerService.name, () => { const newWallet = UserWalletSeeder.create({ userId }); const getOrCreateWallet = jest.fn().mockImplementation(async () => ({ wallet: newWallet, isNew: true })); const deleteWalletById = jest.fn().mockImplementation(async () => null); - const createAndAuthorizeTrialSpending = jest.fn().mockRejectedValue(new Error("Failed to authorize trial")); const di = setup({ getOrCreateWallet, deleteWalletById, - createAndAuthorizeTrialSpending, enabledFeatures: [FeatureFlags.ANONYMOUS_FREE_TRIAL] }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createAndAuthorizeTrialSpending.mockRejectedValue(new Error("Failed to authorize trial")); await expect(di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId)).rejects.toThrow("Failed to authorize trial"); - expect(createAndAuthorizeTrialSpending).toHaveBeenCalledWith({ addressIndex: newWallet.id }); + expect(managedUserWalletService.createAndAuthorizeTrialSpending).toHaveBeenCalledWith(di.resolve(ManagedSignerService), { addressIndex: newWallet.id }); expect(deleteWalletById).toHaveBeenCalledWith(newWallet.id); expect(di.resolve(DomainEventsService).publish).not.toHaveBeenCalled(); }); @@ -89,17 +92,18 @@ describe(WalletInitializerService.name, () => { it(`publishes "TrialStarted" event when ANONYMOUS_FREE_TRIAL is disabled`, async () => { const userId = "test-user-id"; const newWallet = UserWalletSeeder.create({ userId }); + const chainWallet = createChainWallet(); const getOrCreateWallet = jest.fn().mockImplementation(async () => ({ wallet: newWallet, isNew: true })); - const createAndAuthorizeTrialSpending = jest.fn().mockImplementation(async () => createChainWallet()); const updateWalletById = jest.fn().mockImplementation(async () => newWallet); const di = setup({ userId, getOrCreateWallet, - createAndAuthorizeTrialSpending, updateWalletById, enabledFeatures: [] }); + const managedUserWalletService = di.resolve(ManagedUserWalletService) as MockProxy; + managedUserWalletService.createAndAuthorizeTrialSpending.mockResolvedValue(chainWallet); await di.resolve(WalletInitializerService).initializeAndGrantTrialLimits(userId); @@ -111,17 +115,12 @@ describe(WalletInitializerService.name, () => { getOrCreateWallet?: UserWalletRepository["getOrCreate"]; updateWalletById?: UserWalletRepository["updateById"]; deleteWalletById?: UserWalletRepository["deleteById"]; - createAndAuthorizeTrialSpending?: ManagedUserWalletService["createAndAuthorizeTrialSpending"]; userId?: string; enabledFeatures?: FeatureFlagValue[]; }) { const di = container.createChildContainer(); - di.registerInstance( - ManagedUserWalletService, - mock({ - createAndAuthorizeTrialSpending: input?.createAndAuthorizeTrialSpending - }) - ); + di.registerInstance(TYPE_REGISTRY, new Registry()); + di.registerInstance(ManagedUserWalletService, mock()); di.registerInstance( UserWalletRepository, mock({ @@ -157,6 +156,7 @@ describe(WalletInitializerService.name, () => { generateJwtToken: jest.fn().mockResolvedValue("mock-jwt-token") }) ); + di.registerInstance(ManagedSignerService, mock()); container.clearInstances(); 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 9ae2a536f5..74583e7df8 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,6 +3,7 @@ import { singleton } from "tsyringe"; import { AuthService } from "@src/auth/services/auth.service"; import { TrialStarted } from "@src/billing/events/trial-started"; import { UserWalletPublicOutput, UserWalletRepository } from "@src/billing/repositories"; +import { ManagedSignerService } from "@src/billing/services/managed-signer/managed-signer.service"; import { DomainEventsService } from "@src/core/services/domain-events/domain-events.service"; import { FeatureFlags } from "@src/core/services/feature-flags/feature-flags"; import { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; @@ -12,6 +13,7 @@ import { ManagedUserWalletService } from "../managed-user-wallet/managed-user-wa export class WalletInitializerService { constructor( private readonly walletManager: ManagedUserWalletService, + private readonly managedSignerService: ManagedSignerService, private readonly userWalletRepository: UserWalletRepository, private readonly authService: AuthService, private readonly domainEvents: DomainEventsService, @@ -25,7 +27,7 @@ export class WalletInitializerService { let isTrialSpendingAuthorized = false; try { - const wallet = await this.walletManager.createAndAuthorizeTrialSpending({ addressIndex: userWallet.id }); + const wallet = await this.walletManager.createAndAuthorizeTrialSpending(this.managedSignerService, { addressIndex: userWallet.id }); userWallet = await this.userWalletRepository.updateById( userWallet.id, { diff --git a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts index e738b39400..8f21c56a8c 100644 --- a/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts +++ b/apps/api/src/deployment/services/stale-managed-deployments-cleaner/stale-managed-deployments-cleaner.service.ts @@ -66,7 +66,7 @@ export class StaleManagedDeploymentsCleanerService { this.logger.info({ event: "DEPLOYMENT_CLEAN_UP_SUCCESS", owner: wallet.address }); } catch (error: any) { if (error.message.includes("not allowed to pay fees")) { - await this.managedUserWalletService.authorizeSpending({ + await this.managedUserWalletService.authorizeSpending(this.managedSignerService, { address: wallet.address!, limits: { fees: this.config.FEE_ALLOWANCE_REFILL_AMOUNT diff --git a/apps/api/test/functional/wallets-refill.spec.ts b/apps/api/test/functional/wallets-refill.spec.ts index cb8f6f95df..a4c8b9d15d 100644 --- a/apps/api/test/functional/wallets-refill.spec.ts +++ b/apps/api/test/functional/wallets-refill.spec.ts @@ -4,7 +4,7 @@ import { WalletController } from "@src/billing/controllers/wallet/wallet.control import type { BillingConfig } from "@src/billing/providers"; import { BILLING_CONFIG } from "@src/billing/providers"; import { UserWalletRepository } from "@src/billing/repositories"; -import { ManagedUserWalletService } from "@src/billing/services"; +import { ManagedSignerService, ManagedUserWalletService } from "@src/billing/services"; import { app } from "@src/rest-app"; import { WalletTestingService } from "@test/services/wallet-testing.service"; @@ -28,7 +28,8 @@ describe("Wallets Refill", () => { const limits = { fees: config.FEE_ALLOWANCE_REFILL_THRESHOLD }; - await managedUserWalletService.authorizeSpending({ + const managedSignerService = container.resolve(ManagedSignerService); + await managedUserWalletService.authorizeSpending(managedSignerService, { address: wallet.address, limits });