Skip to content

Commit

Permalink
feat(billing): implement sign-tx endpoint
Browse files Browse the repository at this point in the history
as a part of this:
- refactor some common deps within the billing module
- add custom exceptions and global error handler

refs #247
  • Loading branch information
ygrishajev committed Jul 10, 2024
1 parent deca03b commit bf2acb9
Show file tree
Hide file tree
Showing 24 changed files with 365 additions and 84 deletions.
2 changes: 1 addition & 1 deletion apps/api/.env.functional.test
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ RPC_NODE_ENDPOINT=https://rpc.sandbox-01.aksh.pw:443
TRIAL_DEPLOYMENT_ALLOWANCE_AMOUNT=100000
TRIAL_FEES_ALLOWANCE_AMOUNT=100000
TRIAL_ALLOWANCE_DENOM=uakt
LOG_LEVEL=info
LOG_LEVEL=debug
BILLING_ENABLED=true
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
},
"dependencies": {
"@akashnetwork/akash-api": "^1.3.0",
"@akashnetwork/akashjs": "^0.10.0",
"@akashnetwork/database": "*",
"@chain-registry/assets": "^0.7.1",
"@cosmjs/amino": "^0.32.4",
"@cosmjs/crypto": "^0.28.11",
"@cosmjs/encoding": "^0.28.11",
"@cosmjs/math": "^0.28.11",
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { cors } from "hono/cors";
import { container } from "tsyringe";

import { HttpLoggerService, LoggerService } from "@src/core";
import { HonoErrorHandlerService } from "@src/core/services/hono-error-handler/hono-error-handler.service";
import packageJson from "../package.json";
import { chainDb, syncUserSchema, userDb } from "./db/dbConnection";
import { apiRouter } from "./routers/apiRouter";
Expand Down Expand Up @@ -85,7 +86,9 @@ appHono.route("/internal", internalRouter);
// TODO: remove condition once billing is in prod
if (BILLING_ENABLED === "true") {
// eslint-disable-next-line @typescript-eslint/no-var-requires
appHono.route("/", require("./billing").walletRouter);
const { createWalletRouter, signTxRouter } = require("./billing");
appHono.route("/", createWalletRouter);
appHono.route("/", signTxRouter);
}

appHono.get("/status", c => {
Expand All @@ -102,6 +105,8 @@ appHono.get("/status", c => {
return c.json({ version, memory, tasks: tasksStatus });
});

appHono.onError(container.resolve(HonoErrorHandlerService).handle);

function startScheduler() {
scheduler.start();
}
Expand Down
18 changes: 13 additions & 5 deletions apps/api/src/billing/controllers/wallet/wallet.controller.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import type { EncodeObject } from "@cosmjs/proto-signing";
import type { StdFee } from "@cosmjs/stargate";
import { PromisePool } from "@supercharge/promise-pool";
import { singleton } from "tsyringe";

import { UserWalletRepository } from "@src/billing/repositories";
import { CreateWalletInput, CreateWalletOutput } from "@src/billing/routes";
import { WalletInitializerService, WalletService } from "@src/billing/services";
import type { CreateWalletInput, CreateWalletOutput, SignTxInput, SignTxOutput } from "@src/billing/routes";
import { ManagedUserWalletService, WalletInitializerService } from "@src/billing/services";
import { TxSignerService } from "@src/billing/services/tx-signer/tx-signer.service";
import { WithTransaction } from "@src/core/services";

@singleton()
export class WalletController {
constructor(
private readonly walletManager: WalletService,
private readonly walletManager: ManagedUserWalletService,
private readonly userWalletRepository: UserWalletRepository,
private readonly walletInitializer: WalletInitializerService
private readonly walletInitializer: WalletInitializerService,
private readonly signerService: TxSignerService
) {}

@WithTransaction()
async create({ userId }: CreateWalletInput): Promise<CreateWalletOutput> {
await this.walletInitializer.initialize(userId);
return await this.walletInitializer.initialize(userId);
}

async signTx({ userId, messages, fee }: SignTxInput): Promise<SignTxOutput> {
return await this.signerService.sign(userId, messages as EncodeObject[], fee as StdFee);
}

async refillAll() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ApiPgDatabase, InjectPg } from "@src/core/providers";
import { TxService } from "@src/core/services";

export type UserInput = Partial<UserWalletSchema["$inferInsert"]>;
export type UserOutput = Partial<UserWalletSchema["$inferSelect"]>;

@singleton()
export class UserWalletRepository {
Expand All @@ -30,10 +31,10 @@ export class UserWalletRepository {
}

async updateById<R extends boolean>(
id: UserWalletSchema["$inferSelect"]["id"],
id: UserOutput["id"],
payload: Partial<UserInput>,
options?: { returning: R }
): Promise<R extends true ? UserWalletSchema["$inferSelect"] : void> {
): Promise<R extends true ? UserOutput : void> {
const pg = this.txManager.getPgTx() || this.pg;
const cursor = pg.update(this.userWallet).set(payload).where(eq(this.userWallet.id, id));

Expand All @@ -49,4 +50,8 @@ export class UserWalletRepository {
async find() {
return await this.pg.query.userWalletSchema.findMany();
}

async findByUserId(userId: UserOutput["userId"]) {
return await this.pg.query.userWalletSchema.findFirst({ where: eq(this.userWallet.userId, userId) });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ export const CreateWalletInputSchema = z.object({
userId: z.string().openapi({})
});

export const CreateWalletOutputSchema = z.void();
export const CreateWalletOutputSchema = z.object({
userId: z.string().openapi({}),
address: z.string().openapi({})
});
export type CreateWalletInput = z.infer<typeof CreateWalletInputSchema>;
export type CreateWalletOutput = z.infer<typeof CreateWalletOutputSchema>;

Expand All @@ -32,9 +35,8 @@ const route = createRoute({
}
}
});
export const walletRouter = new OpenAPIHono();
export const createWalletRouter = new OpenAPIHono();

walletRouter.openapi(route, async function routeWallet(c) {
await container.resolve(WalletController).create(c.req.valid("json"));
return c.json(undefined, 200);
createWalletRouter.openapi(route, async function routeWallet(c) {
return c.json(await container.resolve(WalletController).create(c.req.valid("json")), 200);
});
3 changes: 2 additions & 1 deletion apps/api/src/billing/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./wallet/wallet.router";
export * from "@src/billing/routes/create-wallet/create-wallet.router";
export * from "@src/billing/routes/sign-tx/sign-tx.router";
65 changes: 65 additions & 0 deletions apps/api/src/billing/routes/sign-tx/sign-tx.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { container } from "tsyringe";
import { z } from "zod";

import { WalletController } from "@src/billing/controllers/wallet/wallet.controller";

export const SignTxInputSchema = z.object({
userId: z.string(),
messages: z
.array(
z.object({
typeUrl: z.string(),
value: z.object({})
})
)
.min(1)
.openapi({}),
fee: z.object({
amount: z.array(
z.object({
denom: z.string(),
amount: z.string()
})
),
gas: z.string(),
granter: z.string().optional(),
payer: z.string().optional()
})
});

export const SignTxOutputSchema = z.string();
export type SignTxInput = z.infer<typeof SignTxInputSchema>;
export type SignTxOutput = z.infer<typeof SignTxOutputSchema>;

const route = createRoute({
method: "post",
path: "/v1/sign-tx",
summary: "Signs a transaction via a user managed wallet",
tags: ["Wallets"],
request: {
body: {
content: {
"application/json": {
schema: SignTxInputSchema
}
}
}
},
responses: {
200: {
description: "Returns a signed transaction",
content: {
"application/json": {
schema: SignTxOutputSchema
}
}
}
}
});
export const signTxRouter = new OpenAPIHono();

signTxRouter.openapi(route, async function routeSignTx(c) {
const payload = await container.resolve(WalletController).signTx(c.req.valid("json"));
return c.json(payload, 200);
});
4 changes: 3 additions & 1 deletion apps/api/src/billing/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "@src/billing/services/wallet/wallet.service";
export * from "@src/billing/services/managed-user-wallet/managed-user-wallet.service";
export * from "./rpc-message-service/rpc-message.service";
export * from "./wallet-initializer/wallet-initializer.service";
export * from "./master-wallet/master-wallet.service";
export * from "@src/billing/services/master-signing-client/master-signing-client.service";
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { stringToPath } from "@cosmjs/crypto";
import { DirectSecp256k1HdWallet, EncodeObject } from "@cosmjs/proto-signing";
import { calculateFee, GasPrice, SigningStargateClient } from "@cosmjs/stargate";
import { calculateFee, GasPrice } from "@cosmjs/stargate";
import add from "date-fns/add";
import { singleton } from "tsyringe";

import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";
import { MasterSigningClientService } from "@src/billing/services/master-signing-client/master-signing-client.service";
import { MasterWalletService } from "@src/billing/services/master-wallet/master-wallet.service";
import { RpcMessageService } from "@src/billing/services/rpc-message-service/rpc-message.service";
import { LoggerService } from "@src/core";

Expand All @@ -18,19 +20,17 @@ interface SpendingAuthorizationOptions {
}

@singleton()
export class WalletService {
export class ManagedUserWalletService {
private readonly PREFIX = "akash";

private readonly HD_PATH = "m/44'/118'/0'/0";

private masterWallet: DirectSecp256k1HdWallet;

private client: SigningStargateClient;

private readonly logger = new LoggerService({ context: WalletService.name });
private readonly logger = new LoggerService({ context: ManagedUserWalletService.name });

constructor(
@InjectBillingConfig() private readonly config: BillingConfig,
private readonly masterWalletService: MasterWalletService,
private readonly masterSigningClientService: MasterSigningClientService,
private readonly rpcMessageService: RpcMessageService
) {}

Expand Down Expand Up @@ -62,7 +62,7 @@ export class WalletService {

async authorizeSpending(options: SpendingAuthorizationOptions) {
try {
const masterWalletAddress = await this.getMasterWalletAddress();
const masterWalletAddress = await this.masterWalletService.getFirstAddress();
const messageParams = {
granter: masterWalletAddress,
grantee: options.address,
Expand All @@ -79,9 +79,8 @@ export class WalletService {
limit: options.limits.fees
})
];
const client = await this.getClient();
const fee = await this.estimateFee(messages, this.config.TRIAL_ALLOWANCE_DENOM);
await client.signAndBroadcast(masterWalletAddress, messages, fee);
await this.masterSigningClientService.signAndBroadcast(masterWalletAddress, messages, fee);
this.logger.debug({ event: "SPENDING_AUTHORIZED", address: options.address });
} catch (error) {
if (error.message.includes("fee allowance already exists")) {
Expand All @@ -95,9 +94,8 @@ export class WalletService {
}

private async estimateFee(messages: readonly EncodeObject[], denom: string) {
const client = await this.getClient();
const address = await this.getMasterWalletAddress();
const gasEstimation = await client.simulate(address, messages, "allowance grant");
const address = await this.masterWalletService.getFirstAddress();
const gasEstimation = await this.masterSigningClientService.simulate(address, messages, "allowance grant");
const estimatedGas = Math.round(gasEstimation * this.config.GAS_SAFETY_MULTIPLIER);

return calculateFee(estimatedGas, GasPrice.fromString(`0.025${denom}`));
Expand All @@ -106,25 +104,4 @@ export class WalletService {
async refill(wallet: any) {

Check warning on line 104 in apps/api/src/billing/services/managed-user-wallet/managed-user-wallet.service.ts

View workflow job for this annotation

GitHub Actions / validate-n-build

Unexpected any. Specify a different type
return wallet;
}

private async getMasterWalletAddress() {
const masterWallet = await this.getMasterWallet();
const [account] = await masterWallet.getAccounts();
return account.address;
}

private async getMasterWallet() {
if (!this.masterWallet) {
this.masterWallet = await DirectSecp256k1HdWallet.fromMnemonic(this.config.MASTER_WALLET_MNEMONIC, { prefix: this.PREFIX });
}

return this.masterWallet;
}

private async getClient() {
if (!this.client) {
this.client = await SigningStargateClient.connectWithSigner(this.config.RPC_NODE_ENDPOINT, await this.getMasterWallet());
}
return this.client;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { StdFee } from "@cosmjs/amino";
import type { EncodeObject } from "@cosmjs/proto-signing";
import { SigningStargateClient } from "@cosmjs/stargate";
import type { SignerData } from "@cosmjs/stargate/build/signingstargateclient";
import { singleton } from "tsyringe";

import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";
import { MasterWalletService } from "@src/billing/services/master-wallet/master-wallet.service";

@singleton()
export class MasterSigningClientService {
private readonly clientAsPromised: Promise<SigningStargateClient>;

constructor(
@InjectBillingConfig() private readonly config: BillingConfig,
private readonly masterWalletService: MasterWalletService
) {
this.clientAsPromised = SigningStargateClient.connectWithSigner(this.config.RPC_NODE_ENDPOINT, this.masterWalletService);
}

async signAndBroadcast(signerAddress: string, messages: readonly EncodeObject[], fee: StdFee | "auto" | number, memo?: string) {
return (await this.clientAsPromised).signAndBroadcast(signerAddress, messages, fee, memo);
}

async sign(signerAddress: string, messages: readonly EncodeObject[], fee: StdFee, memo: string, explicitSignerData?: SignerData) {
return (await this.clientAsPromised).sign(signerAddress, messages, fee, memo, explicitSignerData);
}

async simulate(signerAddress: string, messages: readonly EncodeObject[], memo: string) {
return (await this.clientAsPromised).simulate(signerAddress, messages, memo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { DirectSecp256k1HdWallet } from "@cosmjs/proto-signing";
import { OfflineDirectSigner } from "@cosmjs/proto-signing/build/signer";
import { SignDoc } from "cosmjs-types/cosmos/tx/v1beta1/tx";
import { singleton } from "tsyringe";

import { BillingConfig, InjectBillingConfig } from "@src/billing/providers";

@singleton()
export class MasterWalletService implements OfflineDirectSigner {
private readonly PREFIX = "akash";

private readonly instanceAsPromised: Promise<DirectSecp256k1HdWallet>;

constructor(@InjectBillingConfig() private readonly config: BillingConfig) {
this.instanceAsPromised = DirectSecp256k1HdWallet.fromMnemonic(this.config.MASTER_WALLET_MNEMONIC, { prefix: this.PREFIX });
}

async getAccounts() {
return (await this.instanceAsPromised).getAccounts();
}

async signDirect(signerAddress: string, signDoc: SignDoc) {
return (await this.instanceAsPromised).signDirect(signerAddress, signDoc);
}

async getFirstAddress() {
const accounts = await this.getAccounts();
return accounts[0].address;
}
}
Loading

0 comments on commit bf2acb9

Please sign in to comment.