Skip to content

Commit

Permalink
feat(billing): use akt for managed wallet fees
Browse files Browse the repository at this point in the history
refs #247
  • Loading branch information
ygrishajev committed Aug 13, 2024
1 parent b883c80 commit 41d58e6
Show file tree
Hide file tree
Showing 14 changed files with 219 additions and 31 deletions.
10 changes: 5 additions & 5 deletions apps/api/.env.functional.test
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ NETWORK=sandbox
POSTGRES_DB_URI=postgres://postgres:[email protected]: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
ANONYMOUS_USER_TOKEN_SECRET=ANONYMOUS_USER_TOKEN_SECRET
2 changes: 1 addition & 1 deletion apps/api/src/billing/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserWalletSchema["$inferInsert"]>;
export type DbUserWalletInput = Partial<UserWalletSchema["$inferSelect"]>;
export type UserWalletInput = Partial<
Omit<DbUserWalletInput, "deploymentAllowance" | "feeAllowance"> & {
deploymentAllowance: number;
feeAllowance: number;
}
>;
export type DbUserWalletOutput = UserWalletSchema["$inferSelect"];
export type UserWalletOutput = DbUserWalletOutput & {
export type UserWalletOutput = Omit<DbUserWalletOutput, "feeAllowance" | "deploymentAllowance"> & {
creditAmount: number;
deploymentAllowance: number;
feeAllowance: number;
};

export interface ListOptions {
Expand Down Expand Up @@ -49,7 +57,7 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
async updateById(id: UserWalletOutput["id"], payload: Partial<UserWalletInput>, options?: { returning: boolean }): Promise<void | UserWalletOutput> {
const cursor = this.cursor
.update(this.schema)
.set(payload)
.set(this.toInput(payload))
.where(this.whereAccessibleBy(eq(this.schema.id, id)));

if (options?.returning) {
Expand Down Expand Up @@ -84,6 +92,10 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
);
}

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)) }));
}
Expand All @@ -96,11 +108,28 @@ export class UserWalletRepository extends BaseRepository<UserWalletSchema> {
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<T extends UserWalletOutput>(output: T): Pick<T, "id" | "userId" | "address" | "creditAmount" | "isTrialing"> {
return pick(output, ["id", "userId", "address", "creditAmount", "isTrialing"]);
}
Expand Down
12 changes: 5 additions & 7 deletions apps/api/src/billing/services/balances/balances.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,14 @@ export class BalancesService {

const update: Partial<UserWalletInput> = {};

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;
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<SpendingAuthorizationMsgOptions, "denom">) {
const feeAllowances = await this.allowanceHttpService.getFeeAllowancesForGrantee(options.grantee);
const feeAllowance = feeAllowances.find(allowance => allowance.granter === options.granter);
const results: Promise<IndexedTx>[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
};
}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/billing/services/refill/refill.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface SpendingAuthorizationMsgOptions {

@singleton()
export class RpcMessageService {
getFeesAllowanceGrantMsg({ denom, limit, expiration, granter, grantee }: SpendingAuthorizationMsgOptions) {
getFeesAllowanceGrantMsg({ limit, expiration, granter, grantee }: Omit<SpendingAuthorizationMsgOptions, "denom">) {
return {
typeUrl: "/cosmos.feegrant.v1beta1.MsgGrantAllowance",
value: MsgGrantAllowance.fromPartial({
Expand All @@ -26,7 +26,7 @@ export class RpcMessageService {
BasicAllowance.encode({
spendLimit: [
{
denom,
denom: "uakt",
amount: limit.toString()
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
Expand Down
92 changes: 92 additions & 0 deletions apps/api/test/functional/create-deployment.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Registry>(TYPE_REGISTRY);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const userWalletSchema = container.resolve<UserWalletSchema>(USER_WALLET_SCHEMA);
const userSchema = container.resolve<UserSchema>(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;
}
});
30 changes: 30 additions & 0 deletions apps/api/test/functional/create-wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,6 +16,7 @@ describe("wallets", () => {
const config = container.resolve<BillingConfig>(BILLING_CONFIG);
const db = container.resolve<ApiPgDatabase>(POSTGRES_DB);
const userWalletsTable = db.query.userWalletSchema;
const allowanceHttpService = container.resolve(AllowanceHttpService);

afterEach(async () => {
await Promise.all([db.delete(userWalletSchema), db.delete(userSchema)]);
Expand All @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading

0 comments on commit 41d58e6

Please sign in to comment.