diff --git a/apps/api/.env.functional.test b/apps/api/.env.functional.test index 934bcc6e7..d6f9eacaf 100644 --- a/apps/api/.env.functional.test +++ b/apps/api/.env.functional.test @@ -3,12 +3,12 @@ NETWORK=sandbox POSTGRES_DB_URI=postgres://postgres:password@192.168.2.20:5432/console-users RPC_NODE_ENDPOINT=https://rpc.sandbox-01.aksh.pw:443 TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT=20000000 +DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT=20000000 +DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD=2000000 TRIAL_FEES_ALLOWANCE_AMOUNT=5000000 +FEE_ALLOWANCE_REFILL_AMOUNT=5000000 FEE_ALLOWANCE_REFILL_THRESHOLD=500000 -DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD=2000000 -FEE_ALLOWANCE_REFILL_AMOUNT=20000000 -DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT=5000000 -TRIAL_ALLOWANCE_DENOM=uakt +DEPLOYMENT_GRANT_DENOM=ibc/12C6A0C374171B595A0A9E18B83FA09D295FB1F2D8C6DAA3AC28683471752D84 LOG_LEVEL=debug BILLING_ENABLED=true -ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET \ No newline at end of file +ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET diff --git a/apps/api/src/billing/config/env.config.ts b/apps/api/src/billing/config/env.config.ts index 33c81db8e..61f97f5ed 100644 --- a/apps/api/src/billing/config/env.config.ts +++ b/apps/api/src/billing/config/env.config.ts @@ -7,7 +7,7 @@ const envSchema = z.object({ TRIAL_ALLOWANCE_EXPIRATION_DAYS: z.number({ coerce: true }).default(14), TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT: z.number({ coerce: true }), TRIAL_FEES_ALLOWANCE_AMOUNT: z.number({ coerce: true }), - TRIAL_ALLOWANCE_DENOM: z.string(), + DEPLOYMENT_GRANT_DENOM: z.string(), GAS_SAFETY_MULTIPLIER: z.number({ coerce: true }).default(1.5), FEE_ALLOWANCE_REFILL_THRESHOLD: z.number({ coerce: true }), DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD: z.number({ coerce: true }), 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 260ecc0cf..15c92c962 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 @@ -8,10 +8,18 @@ import { ApiPgDatabase, InjectPg } from "@src/core/providers"; import { AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; import { TxService } from "@src/core/services"; -export type UserWalletInput = Partial; +export type DbUserWalletInput = Partial; +export type UserWalletInput = Partial< + Omit & { + deploymentAllowance: number; + feeAllowance: number; + } +>; export type DbUserWalletOutput = UserWalletSchema["$inferSelect"]; -export type UserWalletOutput = DbUserWalletOutput & { +export type UserWalletOutput = Omit & { creditAmount: number; + deploymentAllowance: number; + feeAllowance: number; }; export interface ListOptions { @@ -49,7 +57,7 @@ export class UserWalletRepository extends BaseRepository { async updateById(id: UserWalletOutput["id"], payload: Partial, options?: { returning: boolean }): Promise { const cursor = this.cursor .update(this.schema) - .set(payload) + .set(this.toInput(payload)) .where(this.whereAccessibleBy(eq(this.schema.id, id))); if (options?.returning) { @@ -84,6 +92,10 @@ export class UserWalletRepository extends BaseRepository { ); } + async findById(id: UserWalletOutput["id"]) { + return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.id, id)) })); + } + async findByUserId(userId: UserWalletOutput["userId"]) { return this.toOutput(await this.cursor.query.userWalletSchema.findFirst({ where: this.whereAccessibleBy(eq(this.schema.userId, userId)) })); } @@ -96,11 +108,28 @@ export class UserWalletRepository extends BaseRepository { return ( dbOutput && { ...dbOutput, - creditAmount: parseFloat(dbOutput.deploymentAllowance) + parseFloat(dbOutput.feeAllowance) + creditAmount: parseFloat(dbOutput.deploymentAllowance), + deploymentAllowance: parseFloat(dbOutput.deploymentAllowance), + feeAllowance: parseFloat(dbOutput.feeAllowance) } ); } + private toInput({ deploymentAllowance, feeAllowance, ...input }: UserWalletInput): DbUserWalletInput { + const dbInput: DbUserWalletInput = { + ...input + }; + + if (deploymentAllowance) { + dbInput.deploymentAllowance = deploymentAllowance.toString(); + } + + if (feeAllowance) { + dbInput.feeAllowance = feeAllowance.toString(); + } + return dbInput; + } + toPublic(output: T): Pick { return pick(output, ["id", "userId", "address", "creditAmount", "isTrialing"]); } diff --git a/apps/api/src/billing/services/balances/balances.service.ts b/apps/api/src/billing/services/balances/balances.service.ts index 93a3dc12f..d84aa8ecb 100644 --- a/apps/api/src/billing/services/balances/balances.service.ts +++ b/apps/api/src/billing/services/balances/balances.service.ts @@ -27,16 +27,14 @@ export class BalancesService { const update: Partial = {}; - const feeLimitStr = feeLimit.toString(); + const feeLimitStr = feeLimit; if (userWallet.feeAllowance !== feeLimitStr) { update.feeAllowance = feeLimitStr; } - const deploymentLimitStr = deploymentLimit.toString(); - - if (userWallet.deploymentAllowance !== deploymentLimitStr) { - update.deploymentAllowance = deploymentLimitStr; + if (userWallet.deploymentAllowance !== deploymentLimit) { + update.deploymentAllowance = deploymentLimit; } return update; @@ -52,7 +50,7 @@ export class BalancesService { } return allowance.allowance.spend_limit.reduce((acc, { denom, amount }) => { - if (denom !== this.config.TRIAL_ALLOWANCE_DENOM) { + if (denom !== "uakt") { return acc; } @@ -66,7 +64,7 @@ export class BalancesService { const masterWalletAddress = await this.masterWalletService.getFirstAddress(); return deploymentAllowance.reduce((acc, allowance) => { - if (allowance.granter !== masterWalletAddress || allowance.authorization.spend_limit.denom !== this.config.TRIAL_ALLOWANCE_DENOM) { + if (allowance.granter !== masterWalletAddress || allowance.authorization.spend_limit.denom !== this.config.DEPLOYMENT_GRANT_DENOM) { return acc; } 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 8ad73ebae..677613d4d 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 @@ -68,13 +68,13 @@ export class ManagedUserWalletService { const msgOptions = { granter: masterWalletAddress, grantee: options.address, - denom: this.config.TRIAL_ALLOWANCE_DENOM, expiration: options.expiration }; await Promise.all([ this.authorizeDeploymentSpending({ ...msgOptions, + denom: this.config.DEPLOYMENT_GRANT_DENOM, limit: options.limits.deployment }), this.authorizeFeeSpending({ @@ -86,7 +86,7 @@ export class ManagedUserWalletService { this.logger.debug({ event: "SPENDING_AUTHORIZED", address: options.address }); } - private async authorizeFeeSpending(options: SpendingAuthorizationMsgOptions) { + private async authorizeFeeSpending(options: Omit) { const feeAllowances = await this.allowanceHttpService.getFeeAllowancesForGrantee(options.grantee); const feeAllowance = feeAllowances.find(allowance => allowance.granter === options.granter); const results: Promise[] = []; diff --git a/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts b/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts index ecc90020c..733fd9c02 100644 --- a/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts +++ b/apps/api/src/billing/services/master-signing-client/master-signing-client.service.ts @@ -110,7 +110,7 @@ export class MasterSigningClientService { while (txIndex < messages.length) { txes.push( - await client.sign(masterAddress, messages[txIndex], await this.estimateFee(messages[txIndex], this.config.TRIAL_ALLOWANCE_DENOM, { mock: true }), "", { + await client.sign(masterAddress, messages[txIndex], await this.estimateFee(messages[txIndex], this.config.DEPLOYMENT_GRANT_DENOM, { mock: true }), "", { accountNumber: this.accountInfo.accountNumber, sequence: this.accountInfo.sequence++, chainId: this.chainId @@ -137,7 +137,7 @@ export class MasterSigningClientService { private async estimateFee(messages: readonly EncodeObject[], denom: string, options?: { mock?: boolean }) { if (options?.mock) { return { - amount: [{ denom: this.config.TRIAL_ALLOWANCE_DENOM, amount: "15000" }], + amount: [{ denom: "uakt", amount: "15000" }], gas: "500000" }; } diff --git a/apps/api/src/billing/services/refill/refill.service.ts b/apps/api/src/billing/services/refill/refill.service.ts index 491f13f81..ee9116e12 100644 --- a/apps/api/src/billing/services/refill/refill.service.ts +++ b/apps/api/src/billing/services/refill/refill.service.ts @@ -29,6 +29,8 @@ export class RefillService { if (wallets.length) { try { await Promise.all(wallets.map(wallet => this.refillWallet(wallet))); + } catch (error) { + this.logger.error({ event: "REFILL_ERROR", error }); } finally { await this.refillAll(); } diff --git a/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts b/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts index 7df22ca3d..a2fdc8b1e 100644 --- a/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts +++ b/apps/api/src/billing/services/rpc-message-service/rpc-message.service.ts @@ -14,7 +14,7 @@ export interface SpendingAuthorizationMsgOptions { @singleton() export class RpcMessageService { - getFeesAllowanceGrantMsg({ denom, limit, expiration, granter, grantee }: SpendingAuthorizationMsgOptions) { + getFeesAllowanceGrantMsg({ limit, expiration, granter, grantee }: Omit) { return { typeUrl: "/cosmos.feegrant.v1beta1.MsgGrantAllowance", value: MsgGrantAllowance.fromPartial({ @@ -26,7 +26,7 @@ export class RpcMessageService { BasicAllowance.encode({ spendLimit: [ { - denom, + denom: "uakt", amount: limit.toString() } ], diff --git a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts index 53b2a160d..b8f2a1576 100644 --- a/apps/api/src/billing/services/tx-signer/tx-signer.service.ts +++ b/apps/api/src/billing/services/tx-signer/tx-signer.service.ts @@ -72,7 +72,7 @@ export class TxSignerService { async signAndBroadcast(messages: readonly EncodeObject[]) { const gasEstimation = await client.simulate(walletAddress, messages, "managed wallet gas estimation"); const estimatedGas = Math.round(gasEstimation * GAS_SAFETY_MULTIPLIER); - const fee = calculateFee(estimatedGas, GasPrice.fromString(`0.025uakt`)); + const fee = calculateFee(estimatedGas, GasPrice.fromString("0.025uakt")); return await client.signAndBroadcast(walletAddress, messages, { ...fee, granter }, "managed wallet tx"); } 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 e0c2ebac2..f8cc37947 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 @@ -21,8 +21,8 @@ export class WalletInitializerService { id, { address: wallet.address, - deploymentAllowance: String(wallet.limits.deployment), - feeAllowance: String(wallet.limits.fees) + deploymentAllowance: wallet.limits.deployment, + feeAllowance: wallet.limits.fees }, { returning: true } ); diff --git a/apps/api/test/functional/create-deployment.spec.ts b/apps/api/test/functional/create-deployment.spec.ts new file mode 100644 index 000000000..2175c2944 --- /dev/null +++ b/apps/api/test/functional/create-deployment.spec.ts @@ -0,0 +1,92 @@ +import { certificateManager } from "@akashnetwork/akashjs/build/certificates/certificate-manager"; +import { SDL } from "@akashnetwork/akashjs/build/sdl"; +import type { Registry } from "@cosmjs/proto-signing"; +import { WalletService } from "@test/services/wallet.service"; +import axios from "axios"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { container } from "tsyringe"; + +import { app } from "@src/app"; +import { config } from "@src/billing/config"; +import { USER_WALLET_SCHEMA, UserWalletSchema } from "@src/billing/providers"; +import { TYPE_REGISTRY } from "@src/billing/providers/type-registry.provider"; +import { MasterWalletService } from "@src/billing/services"; +import { ApiPgDatabase, POSTGRES_DB } from "@src/core"; +import { USER_SCHEMA, UserSchema } from "@src/user/providers"; + +jest.setTimeout(30000); + +const yml = fs.readFileSync(path.resolve(__dirname, "../mocks/hello-world-sdl.yml"), "utf8"); + +// TODO: finish this test to create a lease and then close the deployment +describe("Tx Sign", () => { + const registry = container.resolve(TYPE_REGISTRY); + const db = container.resolve(POSTGRES_DB); + const userWalletSchema = container.resolve(USER_WALLET_SCHEMA); + const userSchema = container.resolve(USER_SCHEMA); + const walletService = new WalletService(app); + const masterWalletService = container.resolve(MasterWalletService); + + afterEach(async () => { + await Promise.all([db.delete(userWalletSchema), db.delete(userSchema)]); + }); + + describe("POST /v1/tx", () => { + it("should create a deployment for a user", async () => { + const { user, token, wallet } = await walletService.createUserAndWallet(); + const res = await app.request("/v1/tx", { + method: "POST", + body: await createMessagePayload(user.id, wallet.address), + headers: new Headers({ "Content-Type": "application/json", authorization: `Bearer ${token}` }) + }); + const result = await res.json(); + + console.log("DEBUG result", result); + + expect(res.status).toBe(200); + expect(result).toMatchObject({ data: { code: 0, transactionHash: expect.any(String) } }); + }); + }); + + async function createMessagePayload(userId: string, address: string) { + const { cert, publicKey } = certificateManager.generatePEM(address); + + const sdl = SDL.fromString(yml, "beta3", "sandbox"); + + return JSON.stringify({ + data: { + userId: userId, + messages: [ + { + typeUrl: "/akash.cert.v1beta3.MsgCreateCertificate", + value: { + owner: address, + cert: Buffer.from(cert).toString("base64"), + pubkey: Buffer.from(publicKey).toString("base64") + } + }, + { + typeUrl: "/akash.deployment.v1beta3.MsgCreateDeployment", + value: { + id: { + owner: address, + dseq: await getCurrentHeight() + }, + groups: sdl.groups(), + version: await sdl.manifestVersion(), + deposit: { denom: config.DEPLOYMENT_GRANT_DENOM, amount: "5000000" }, + depositor: await masterWalletService.getFirstAddress() + } + } + ].map(message => ({ typeUrl: message.typeUrl, value: Buffer.from(registry.encode(message)).toString("base64") })) + } + }); + } + + async function getCurrentHeight() { + // TODO: extract this base url to env var + const response = await axios.get(`https://api.sandbox-01.aksh.pw/blocks/latest`); + return response.data.block.header.height; + } +}); diff --git a/apps/api/test/functional/create-wallet.spec.ts b/apps/api/test/functional/create-wallet.spec.ts index 40b14720c..f9846230c 100644 --- a/apps/api/test/functional/create-wallet.spec.ts +++ b/apps/api/test/functional/create-wallet.spec.ts @@ -1,3 +1,4 @@ +import { AllowanceHttpService } from "@akashnetwork/http-sdk"; import { faker } from "@faker-js/faker"; import { eq } from "drizzle-orm"; import { container } from "tsyringe"; @@ -15,6 +16,7 @@ describe("wallets", () => { const config = container.resolve(BILLING_CONFIG); const db = container.resolve(POSTGRES_DB); const userWalletsTable = db.query.userWalletSchema; + const allowanceHttpService = container.resolve(AllowanceHttpService); afterEach(async () => { await Promise.all([db.delete(userWalletSchema), db.delete(userSchema)]); @@ -38,6 +40,10 @@ describe("wallets", () => { }); const getWalletsResponse = await app.request(`/v1/wallets?userId=${userId}`, { headers }); const userWallet = await userWalletsTable.findFirst({ where: eq(userWalletSchema.userId, userId) }); + const allowances = await Promise.all([ + allowanceHttpService.getDeploymentAllowancesForGrantee(userWallet.address), + allowanceHttpService.getFeeAllowancesForGrantee(userWallet.address) + ]); expect(createWalletResponse.status).toBe(200); expect(getWalletsResponse.status).toBe(200); @@ -69,6 +75,30 @@ describe("wallets", () => { feeAllowance: `${config.TRIAL_FEES_ALLOWANCE_AMOUNT}.00`, isTrialing: true }); + expect(allowances).toMatchObject([ + [ + { + granter: expect.any(String), + grantee: userWallet.address, + authorization: { + "@type": "/akash.deployment.v1beta3.DepositDeploymentAuthorization", + spend_limit: { denom: config.DEPLOYMENT_GRANT_DENOM, amount: String(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT) } + }, + expiration: expect.any(String) + } + ], + [ + { + granter: expect.any(String), + grantee: userWallet.address, + allowance: { + "@type": "/cosmos.feegrant.v1beta1.BasicAllowance", + spend_limit: [{ denom: "uakt", amount: String(config.TRIAL_FEES_ALLOWANCE_AMOUNT) }], + expiration: expect.any(String) + } + } + ] + ]); }); it("should throw 401 provided no auth header ", async () => { diff --git a/apps/api/test/functional/wallets-refill.spec.ts b/apps/api/test/functional/wallets-refill.spec.ts index c7fba35e9..09ec269c5 100644 --- a/apps/api/test/functional/wallets-refill.spec.ts +++ b/apps/api/test/functional/wallets-refill.spec.ts @@ -31,8 +31,11 @@ describe("Wallets Refill", () => { const records = await walletService.createUserAndWallet(); const { user, token } = records; let { wallet } = records; + const walletRecord = await userWalletRepository.findById(wallet.id); + + expect(wallet.creditAmount).toBe(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT); + expect(walletRecord.feeAllowance).toBe(config.TRIAL_FEES_ALLOWANCE_AMOUNT); - expect(wallet.creditAmount).toBe(config.TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT + config.TRIAL_FEES_ALLOWANCE_AMOUNT); const limits = { deployment: config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD, fees: config.FEE_ALLOWANCE_REFILL_THRESHOLD @@ -44,14 +47,14 @@ describe("Wallets Refill", () => { await userWalletRepository.updateById( wallet.id, { - deploymentAllowance: String(limits.deployment), - feeAllowance: String(limits.fees) + deploymentAllowance: limits.deployment, + feeAllowance: limits.fees }, { returning: true } ); wallet = await walletService.getWalletByUserId(user.id, token); - expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD + config.FEE_ALLOWANCE_REFILL_THRESHOLD); + expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_THRESHOLD); expect(wallet.isTrialing).toBe(true); return { user, token, wallet }; @@ -63,7 +66,10 @@ describe("Wallets Refill", () => { await Promise.all( records.map(async ({ wallet, token, user }) => { wallet = await walletService.getWalletByUserId(user.id, token); - expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT + config.FEE_ALLOWANCE_REFILL_AMOUNT); + const walletRecord = await userWalletRepository.findById(wallet.id); + + expect(wallet.creditAmount).toBe(config.DEPLOYMENT_ALLOWANCE_REFILL_AMOUNT); + expect(walletRecord.feeAllowance).toBe(config.FEE_ALLOWANCE_REFILL_AMOUNT); expect(wallet.isTrialing).toBe(false); }) ); diff --git a/apps/api/test/mocks/hello-world-sdl.yml b/apps/api/test/mocks/hello-world-sdl.yml new file mode 100644 index 000000000..fa1a8952e --- /dev/null +++ b/apps/api/test/mocks/hello-world-sdl.yml @@ -0,0 +1,31 @@ +--- +version: "2.0" +services: + web: + image: akashlytics/hello-akash-world:0.2.0 + expose: + - port: 3000 + as: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: 0.5 + memory: + size: 512Mi + storage: + - size: 512Mi + placement: + dcloud: + pricing: + web: + denom: ibc/12C6A0C374171B595A0A9E18B83FA09D295FB1F2D8C6DAA3AC28683471752D84 + amount: 1000 +deployment: + web: + dcloud: + profile: web + count: 1