diff --git a/packages/worker/src/http/index.ts b/packages/worker/src/http/index.ts new file mode 100644 index 000000000..b2c2d62fb --- /dev/null +++ b/packages/worker/src/http/index.ts @@ -0,0 +1,9 @@ +import { Application } from "express"; +import gaslessRouter from "./modules/gasless/routes"; +import { handleErrors } from "./middlewares/handleErrors"; + +export const setupRoutes = (app: Application): void => { + app.use("/worker/gasless", gaslessRouter); + + app.use(handleErrors); +}; diff --git a/packages/worker/src/http/middlewares/handleErrors.ts b/packages/worker/src/http/middlewares/handleErrors.ts new file mode 100644 index 000000000..88c6940fe --- /dev/null +++ b/packages/worker/src/http/middlewares/handleErrors.ts @@ -0,0 +1,22 @@ +import { Request, Response, NextFunction } from "express"; + +export class AppError extends Error { + constructor(public readonly statusCode: number, message: string) { + super(message); + this.name = "AppError"; + } +} + +export const handleErrors = ( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +): void => { + if (err instanceof AppError) { + res.status(err.statusCode).json({ error: err.message }); + return; + } + + res.status(500).json({ error: "Internal server error" }); +}; diff --git a/packages/worker/src/http/modules/gasless/controller.ts b/packages/worker/src/http/modules/gasless/controller.ts new file mode 100644 index 000000000..011379d47 --- /dev/null +++ b/packages/worker/src/http/modules/gasless/controller.ts @@ -0,0 +1,76 @@ +import { Request, Response, NextFunction } from "express"; +import { MongoDatabase } from "@/clients/mongoClient"; +import { gaslessUtxosCollection } from "@/queues/gaslessUtxos"; +import { COLLECTION_GASLESS_UTXOS } from "@/queues/gaslessUtxos/constants"; +import { GaslessUtxo } from "@/queues/gaslessUtxos/types"; +import { AppError } from "@/http/middlewares/handleErrors"; + +export class GaslessController { + static async reserve( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { accountId, estimatedMaxFee } = req.body; + + if (estimatedMaxFee === undefined || estimatedMaxFee === null) { + throw new AppError(400, "estimatedMaxFee is required"); + } + + if (typeof estimatedMaxFee !== "number" || estimatedMaxFee <= 0) { + throw new AppError(400, "estimatedMaxFee must be a positive number"); + } + + if ( + accountId !== undefined && + (typeof accountId !== "string" || accountId.trim().length === 0) + ) { + throw new AppError(400, "accountId must be a non-empty string"); + } + + const db = await MongoDatabase.connect(); + const utxos = gaslessUtxosCollection( + db.getCollection(COLLECTION_GASLESS_UTXOS) + ); + + const utxo = await utxos.reserve({ + reservedBy: accountId ?? "anonymous", + estimatedMaxFee, + }); + + if (!utxo) { + throw new AppError(503, "POOL_EXHAUSTED"); + } + + res.status(200).json({ + utxoId: utxo.utxoId, + txId: utxo.txId, + outputIndex: utxo.outputIndex, + amount: utxo.amount, + owner: utxo.owner, + }); + } catch (err) { + next(err); + } + } + + static async stats( + _req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const db = await MongoDatabase.connect(); + const utxos = gaslessUtxosCollection( + db.getCollection(COLLECTION_GASLESS_UTXOS) + ); + + const stats = await utxos.getStats(); + + res.status(200).json(stats); + } catch (err) { + next(err); + } + } +} diff --git a/packages/worker/src/http/modules/gasless/routes.ts b/packages/worker/src/http/modules/gasless/routes.ts new file mode 100644 index 000000000..ab61be140 --- /dev/null +++ b/packages/worker/src/http/modules/gasless/routes.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { GaslessController } from "@/http/modules/gasless/controller"; + +const gaslessRouter = Router(); + +gaslessRouter.post("/reserve", GaslessController.reserve); +gaslessRouter.get("/pool/stats", GaslessController.stats); + +export default gaslessRouter; diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 4c02e4f80..247e63bb0 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -14,6 +14,7 @@ import { UserBlockSyncCron, } from "./queues/userBlockSync"; import { GaslessUtxoCleanup } from "@/queues/gaslessUtxos/gaslessUtxoCleanup"; +import { setupRoutes } from "@/http"; const { WORKER_PORT, @@ -54,6 +55,9 @@ console.log( ); const app = express(); +app.use(express.json({ limit: "1mb" })); +app.use(express.urlencoded({ extended: true, limit: "1mb" })); +setupRoutes(app); const serverAdapter = new ExpressAdapter(); createBullBoard({ diff --git a/packages/worker/src/queues/gaslessUtxos/types.ts b/packages/worker/src/queues/gaslessUtxos/types.ts index 130db3dc4..294a6706e 100644 --- a/packages/worker/src/queues/gaslessUtxos/types.ts +++ b/packages/worker/src/queues/gaslessUtxos/types.ts @@ -6,6 +6,7 @@ export interface GaslessUtxo { txId: string; outputIndex: number; amount: string; + owner: string; status: "available" | "reserved" | "spent"; reservedAt?: Date; reservedBy?: string; @@ -17,10 +18,10 @@ export interface GaslessUtxoStats { available: number; reserved: number; spent: number; - total: number; + totalValue: string; } export interface ReserveUtxoOptions { reservedBy: string; - ttlSeconds?: number; + estimatedMaxFee: number; } diff --git a/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts b/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts index 4b1f52415..15870694b 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts @@ -4,24 +4,41 @@ import { GaslessUtxo, GaslessUtxoStats } from "../types"; export const getStats = async ( collection: Collection ): Promise => { - const rows = await collection - .aggregate<{ _id: string; count: number }>([ - { $group: { _id: "$status", count: { $sum: 1 } } }, + const result = await collection + .aggregate<{ _id: string; count: number; totalAmount: number }>([ + { + $group: { + _id: "$status", + count: { $sum: 1 }, + totalAmount: { + $sum: { + $convert: { + input: "$amount", + to: "long", + onError: 0, + onNull: 0, + }, + }, + }, + }, + }, ]) .toArray(); - const stats: GaslessUtxoStats = { - available: 0, - reserved: 0, - spent: 0, - total: 0, - }; + const stats = { available: 0, reserved: 0, spent: 0, totalValue: BigInt(0) }; - for (const row of rows) { - const key = row._id as keyof Omit; - if (key in stats) stats[key] = row.count; - stats.total += row.count; + for (const row of result) { + const status = row._id as keyof Omit; + if (status in stats) { + stats[status] = row.count; + } + stats.totalValue += BigInt(row.totalAmount ?? 0); } - return stats; + return { + available: stats.available, + reserved: stats.reserved, + spent: stats.spent, + totalValue: stats.totalValue.toString(), + }; }; diff --git a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts index ce322a04b..59f7b5aac 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts @@ -1,15 +1,19 @@ import { Collection } from "mongodb"; import { GaslessUtxo, ReserveUtxoOptions } from "../types"; -import { DEFAULT_TTL_SECONDS } from "@/queues/gaslessUtxos/constants"; export const reserve = async ( collection: Collection, options: ReserveUtxoOptions ): Promise => { - const { reservedBy, ttlSeconds = DEFAULT_TTL_SECONDS } = options; + const { reservedBy, estimatedMaxFee } = options; + + const minAmount = ( + (BigInt(Math.floor(estimatedMaxFee)) * BigInt(150)) / + BigInt(100) + ).toString(); return collection.findOneAndUpdate( - { status: "available" }, + { status: "available", amount: { $gte: minAmount } }, { $set: { status: "reserved",