From 323e64eab340c2004b28b27b8828f80a05367a57 Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Wed, 11 Mar 2026 14:12:36 -0300 Subject: [PATCH 1/8] feat: add unit tests for gaslessUtxos collection --- packages/worker/package.json | 2 + .../worker/src/tests/gaslessUtxos.test.ts | 360 ++++++++++++++++++ .../src/tests/utils/gaslessTestEnvironment.ts | 46 +++ 3 files changed, 408 insertions(+) create mode 100644 packages/worker/src/tests/gaslessUtxos.test.ts create mode 100644 packages/worker/src/tests/utils/gaslessTestEnvironment.ts diff --git a/packages/worker/package.json b/packages/worker/package.json index c96a69480..b87ed9ba6 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -45,9 +45,11 @@ "eslint-plugin-prettier": "3.3.1", "husky": "5.2.0", "lint-staged": "10.5.4", + "mongodb-memory-server": "^11.0.1", "prettier": "2.2.1", "pretty-quick": "3.1.0", "ts-node-dev": "2.0.0", + "tsconfig-paths": "^4.2.0", "tscpaths": "0.0.9" } } diff --git a/packages/worker/src/tests/gaslessUtxos.test.ts b/packages/worker/src/tests/gaslessUtxos.test.ts new file mode 100644 index 000000000..63dcd11da --- /dev/null +++ b/packages/worker/src/tests/gaslessUtxos.test.ts @@ -0,0 +1,360 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { gaslessUtxosCollection } from "@/queues/gaslessUtxos"; +import { GaslessTestEnvironment } from "@/tests/utils/gaslessTestEnvironment"; + +test("gaslessUtxos", async (t) => { + const env = new GaslessTestEnvironment(); + + await env.init(); + + t.after(async () => { + await env.close(); + }); + + await t.test("findAvailable()", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test("should return only available UTXOs", async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available" }, + { utxoId: "utxo-2", status: "reserved" }, + { utxoId: "utxo-3", status: "spent" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const result = await utxos.findAvailable(); + + assert.equal(result.length, 1); + assert.equal(result[0].utxoId, "utxo-1"); + }); + + await t.test( + "should return empty array when no UTXOs available", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "reserved" }, + { utxoId: "utxo-2", status: "spent" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const result = await utxos.findAvailable(); + + assert.equal(result.length, 0); + } + ); + }); + + await t.test("reserve()", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test("should reserve an available UTXO", async () => { + await env.seed([{ utxoId: "utxo-1", status: "available" }]); + + const utxos = gaslessUtxosCollection(env.collection); + const reserved = await utxos.reserve({ reservedBy: "reservation-123" }); + + assert.ok(reserved); + assert.equal(reserved.utxoId, "utxo-1"); + assert.equal(reserved.status, "reserved"); + assert.equal(reserved.reservedBy, "reservation-123"); + assert.ok(reserved.reservedAt instanceof Date); + }); + + await t.test("should return null when no UTXOs available", async () => { + await env.seed([{ utxoId: "utxo-1", status: "spent" }]); + + const utxos = gaslessUtxosCollection(env.collection); + const reserved = await utxos.reserve({ reservedBy: "reservation-123" }); + + assert.equal(reserved, null); + }); + + await t.test("should not reserve an already reserved UTXO", async () => { + await env.seed([ + { utxoId: "utxo-1", status: "reserved", reservedBy: "reservation-abc" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const reserved = await utxos.reserve({ reservedBy: "reservation-xyz" }); + + assert.equal(reserved, null); + }); + + await t.test( + "should handle race condition — only one reservation wins", + async () => { + await env.seed([{ utxoId: "utxo-1", status: "available" }]); + + const utxos = gaslessUtxosCollection(env.collection); + + const [first, second] = await Promise.all([ + utxos.reserve({ reservedBy: "reservation-A" }), + utxos.reserve({ reservedBy: "reservation-B" }), + ]); + + const winners = [first, second].filter(Boolean); + const losers = [first, second].filter((r) => r === null); + + assert.equal(winners.length, 1); + assert.equal(losers.length, 1); + + assert.equal(winners[0]!.status, "reserved"); + } + ); + + await t.test( + "should handle race condition with multiple UTXOs", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available" }, + { utxoId: "utxo-2", status: "available" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + + const results = await Promise.all([ + utxos.reserve({ reservedBy: "reservation-A" }), + utxos.reserve({ reservedBy: "reservation-B" }), + utxos.reserve({ reservedBy: "reservation-C" }), + ]); + + const winners = results.filter(Boolean); + const losers = results.filter((r) => r === null); + + assert.equal(winners.length, 2); + assert.equal(losers.length, 1); + } + ); + }); + + await t.test("release()", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test( + "should release a reserved UTXO back to available", + async () => { + await env.seed([ + { + utxoId: "utxo-1", + status: "reserved", + reservedBy: "reservation-123", + reservedAt: new Date(), + }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.release("utxo-1"); + + assert.ok(released); + assert.equal(released.status, "available"); + assert.equal(released.reservedBy, undefined); + assert.equal(released.reservedAt, undefined); + } + ); + + await t.test("should return null when UTXO is not reserved", async () => { + await env.seed([{ utxoId: "utxo-1", status: "available" }]); + + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.release("utxo-1"); + + assert.equal(released, null); + }); + + await t.test("should return null when UTXO does not exist", async () => { + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.release("utxo-nonexistent"); + + assert.equal(released, null); + }); + }); + + await t.test("markSpent()", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test("should mark a reserved UTXO as spent", async () => { + await env.seed([ + { + utxoId: "utxo-1", + status: "reserved", + reservedBy: "reservation-123", + reservedAt: new Date(), + }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const spent = await utxos.markSpent("utxo-1", "0xspent-tx-hash"); + + assert.ok(spent); + assert.equal(spent.status, "spent"); + assert.equal(spent.spentTxHash, "0xspent-tx-hash"); + assert.equal(spent.reservedBy, undefined); + assert.equal(spent.reservedAt, undefined); + }); + + await t.test("should return null when UTXO is not reserved", async () => { + await env.seed([{ utxoId: "utxo-1", status: "available" }]); + + const utxos = gaslessUtxosCollection(env.collection); + const spent = await utxos.markSpent("utxo-1", "0xspent-tx-hash"); + + assert.equal(spent, null); + }); + + await t.test("should return null when UTXO does not exist", async () => { + const utxos = gaslessUtxosCollection(env.collection); + const spent = await utxos.markSpent( + "utxo-nonexistent", + "0xspent-tx-hash" + ); + + assert.equal(spent, null); + }); + }); + + await t.test("getStats()", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test("should return correct counts per status", async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available" }, + { utxoId: "utxo-2", status: "available" }, + { utxoId: "utxo-3", status: "reserved" }, + { utxoId: "utxo-4", status: "spent" }, + { utxoId: "utxo-5", status: "spent" }, + { utxoId: "utxo-6", status: "spent" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const stats = await utxos.getStats(); + + assert.equal(stats.available, 2); + assert.equal(stats.reserved, 1); + assert.equal(stats.spent, 3); + assert.equal(stats.total, 6); + }); + + await t.test("should return zeros when collection is empty", async () => { + const utxos = gaslessUtxosCollection(env.collection); + const stats = await utxos.getStats(); + + assert.equal(stats.available, 0); + assert.equal(stats.reserved, 0); + assert.equal(stats.spent, 0); + assert.equal(stats.total, 0); + }); + }); + + await t.test("releaseExpired()", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test( + "should release reservations older than 5 minutes", + async () => { + const expiredDate = new Date(Date.now() - 10 * 60 * 1000); + + await env.seed([ + { + utxoId: "utxo-1", + status: "reserved", + reservedBy: "reservation-123", + reservedAt: expiredDate, + }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.releaseExpired(); + + assert.equal(released, 1); + + const doc = await env.collection.findOne({ utxoId: "utxo-1" }); + assert.equal(doc?.status, "available"); + assert.equal(doc?.reservedBy, undefined); + assert.equal(doc?.reservedAt, undefined); + } + ); + + await t.test( + "should not release reservations newer than 5 minutes", + async () => { + const recentDate = new Date(Date.now() - 1 * 60 * 1000); + + await env.seed([ + { + utxoId: "utxo-1", + status: "reserved", + reservedBy: "reservation-123", + reservedAt: recentDate, + }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.releaseExpired(); + + assert.equal(released, 0); + + const doc = await env.collection.findOne({ utxoId: "utxo-1" }); + assert.equal(doc?.status, "reserved"); + } + ); + + await t.test("should release only expired ones when mixed", async () => { + const expiredDate = new Date(Date.now() - 10 * 60 * 1000); + const recentDate = new Date(Date.now() - 1 * 60 * 1000); + + await env.seed([ + { + utxoId: "utxo-1", + status: "reserved", + reservedBy: "r-1", + reservedAt: expiredDate, + }, + { + utxoId: "utxo-2", + status: "reserved", + reservedBy: "r-2", + reservedAt: recentDate, + }, + { + utxoId: "utxo-3", + status: "reserved", + reservedBy: "r-3", + reservedAt: expiredDate, + }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.releaseExpired(); + + assert.equal(released, 2); + + const stillReserved = await env.collection.findOne({ utxoId: "utxo-2" }); + assert.equal(stillReserved?.status, "reserved"); + }); + + await t.test("should return 0 when nothing is expired", async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available" }, + { utxoId: "utxo-2", status: "spent" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const released = await utxos.releaseExpired(); + + assert.equal(released, 0); + }); + }); +}); diff --git a/packages/worker/src/tests/utils/gaslessTestEnvironment.ts b/packages/worker/src/tests/utils/gaslessTestEnvironment.ts new file mode 100644 index 000000000..b538f2a6d --- /dev/null +++ b/packages/worker/src/tests/utils/gaslessTestEnvironment.ts @@ -0,0 +1,46 @@ +import { MongoMemoryServer } from "mongodb-memory-server"; +import { MongoClient, Collection } from "mongodb"; +import { GaslessUtxo } from "@/queues/gaslessUtxos"; +import { COLLECTION_GASLESS_UTXOS } from "@/queues/gaslessUtxos"; + +export class GaslessTestEnvironment { + private mongod!: MongoMemoryServer; + private client!: MongoClient; + collection!: Collection; + + async init(): Promise { + this.mongod = await MongoMemoryServer.create(); + const uri = this.mongod.getUri(); + + this.client = new MongoClient(uri); + await this.client.connect(); + + const db = this.client.db("test"); + this.collection = db.collection(COLLECTION_GASLESS_UTXOS); + } + + async close(): Promise { + await this.client.close(); + await this.mongod.stop(); + } + + async clear(): Promise { + await this.collection.deleteMany({}); + } + + async seed(utxos: Partial[]): Promise { + const docs = utxos.map((u) => ({ + utxoId: u.utxoId ?? "utxo-1", + txId: u.txId ?? "0xabc", + outputIndex: u.outputIndex ?? 0, + amount: u.amount ?? "200000000000000", + status: u.status ?? "available", + createdAt: u.createdAt ?? new Date(), + ...(u.reservedBy && { reservedBy: u.reservedBy }), + ...(u.reservedAt && { reservedAt: u.reservedAt }), + ...(u.spentTxHash && { spentTxHash: u.spentTxHash }), + })) as GaslessUtxo[]; + + await this.collection.insertMany(docs); + } +} From 42a97ec6dcb1cd047ffedf1fd74626340c400975 Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Wed, 11 Mar 2026 15:15:09 -0300 Subject: [PATCH 2/8] feat: add base HTTP controller and routes structure --- packages/worker/src/http/index.ts | 9 ++++++++ .../src/http/middlewares/handleErrors.ts | 23 +++++++++++++++++++ .../src/http/modules/gasless/controller.ts | 16 +++++++++++++ .../worker/src/http/modules/gasless/routes.ts | 8 +++++++ packages/worker/src/index.ts | 2 ++ 5 files changed, 58 insertions(+) create mode 100644 packages/worker/src/http/index.ts create mode 100644 packages/worker/src/http/middlewares/handleErrors.ts create mode 100644 packages/worker/src/http/modules/gasless/controller.ts create mode 100644 packages/worker/src/http/modules/gasless/routes.ts 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..66ff6dbfe --- /dev/null +++ b/packages/worker/src/http/middlewares/handleErrors.ts @@ -0,0 +1,23 @@ +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; + } + + console.error("[HTTP]: Unhandled error", err); + 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..b1e7395af --- /dev/null +++ b/packages/worker/src/http/modules/gasless/controller.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from "express"; +import { AppError } from "@/http/middlewares/handleErrors"; + +export class GaslessController { + static async reserve( + _req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + throw new AppError(501, "Not implemented"); + } 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..b6dc0f300 --- /dev/null +++ b/packages/worker/src/http/modules/gasless/routes.ts @@ -0,0 +1,8 @@ +import { Router } from "express"; +import { GaslessController } from "@/http/modules/gasless/controller"; + +const gaslessRouter = Router(); + +gaslessRouter.post("/reserve", GaslessController.reserve); + +export default gaslessRouter; diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 4c02e4f80..20a1c79f0 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,7 @@ console.log( ); const app = express(); +setupRoutes(app); const serverAdapter = new ExpressAdapter(); createBullBoard({ From 4a4b1f0b6b0ea2459f09104cb8774fb05162f764 Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Wed, 11 Mar 2026 15:22:52 -0300 Subject: [PATCH 3/8] fix: remove console.log and add basic Express middleware --- packages/worker/src/http/middlewares/handleErrors.ts | 1 - packages/worker/src/index.ts | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/http/middlewares/handleErrors.ts b/packages/worker/src/http/middlewares/handleErrors.ts index 66ff6dbfe..88c6940fe 100644 --- a/packages/worker/src/http/middlewares/handleErrors.ts +++ b/packages/worker/src/http/middlewares/handleErrors.ts @@ -18,6 +18,5 @@ export const handleErrors = ( return; } - console.error("[HTTP]: Unhandled error", err); res.status(500).json({ error: "Internal server error" }); }; diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 20a1c79f0..3b6cebb67 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -55,6 +55,8 @@ console.log( ); const app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); setupRoutes(app); const serverAdapter = new ExpressAdapter(); From 0889af849ae49e7ce1c177eca9e40f2d4e021923 Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Thu, 12 Mar 2026 14:57:16 -0300 Subject: [PATCH 4/8] feat: implement POST /worker/gasless/reserve endpoint --- .../src/http/modules/gasless/controller.ts | 40 ++++++++++++++++++- .../worker/src/queues/gaslessUtxos/types.ts | 3 +- .../src/queues/gaslessUtxos/utils/reserve.ts | 7 ++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/http/modules/gasless/controller.ts b/packages/worker/src/http/modules/gasless/controller.ts index b1e7395af..232c30411 100644 --- a/packages/worker/src/http/modules/gasless/controller.ts +++ b/packages/worker/src/http/modules/gasless/controller.ts @@ -1,14 +1,50 @@ 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, + req: Request, res: Response, next: NextFunction ): Promise { try { - throw new AppError(501, "Not implemented"); + 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"); + } + + // TODO: space for blocking rules + + 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); } diff --git a/packages/worker/src/queues/gaslessUtxos/types.ts b/packages/worker/src/queues/gaslessUtxos/types.ts index 130db3dc4..7d0a44b14 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; @@ -22,5 +23,5 @@ export interface GaslessUtxoStats { export interface ReserveUtxoOptions { reservedBy: string; - ttlSeconds?: number; + estimatedMaxFee: number; } diff --git a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts index ce322a04b..29a8ac977 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts @@ -1,15 +1,16 @@ 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 = String(Math.ceil(estimatedMaxFee * 1.5)); return collection.findOneAndUpdate( - { status: "available" }, + { status: "available", amount: { $gte: minAmount } }, { $set: { status: "reserved", From ef6444f92ae17268b151eda78012791c505b4492 Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Thu, 12 Mar 2026 15:08:02 -0300 Subject: [PATCH 5/8] feat: implement GET /worker/gasless/pool/stats endpoint --- .../src/http/modules/gasless/controller.ts | 19 ++++++++++ .../worker/src/http/modules/gasless/routes.ts | 1 + .../worker/src/queues/gaslessUtxos/types.ts | 2 +- .../src/queues/gaslessUtxos/utils/getStats.ts | 36 +++++++++++-------- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/packages/worker/src/http/modules/gasless/controller.ts b/packages/worker/src/http/modules/gasless/controller.ts index 232c30411..239360759 100644 --- a/packages/worker/src/http/modules/gasless/controller.ts +++ b/packages/worker/src/http/modules/gasless/controller.ts @@ -49,4 +49,23 @@ export class GaslessController { 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 index b6dc0f300..ab61be140 100644 --- a/packages/worker/src/http/modules/gasless/routes.ts +++ b/packages/worker/src/http/modules/gasless/routes.ts @@ -4,5 +4,6 @@ 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/queues/gaslessUtxos/types.ts b/packages/worker/src/queues/gaslessUtxos/types.ts index 7d0a44b14..294a6706e 100644 --- a/packages/worker/src/queues/gaslessUtxos/types.ts +++ b/packages/worker/src/queues/gaslessUtxos/types.ts @@ -18,7 +18,7 @@ export interface GaslessUtxoStats { available: number; reserved: number; spent: number; - total: number; + totalValue: string; } export interface ReserveUtxoOptions { diff --git a/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts b/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts index 4b1f52415..60349f73e 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts @@ -4,24 +4,32 @@ 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: string }>([ + { + $group: { + _id: "$status", + count: { $sum: 1 }, + totalAmount: { $sum: { $toLong: "$amount" } }, + }, + }, ]) .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(), + }; }; From 4b1ee523e98091312e0c08c7f450f1db367e15c2 Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Thu, 12 Mar 2026 15:29:51 -0300 Subject: [PATCH 6/8] fix: address code review critical and important issues --- .../worker/src/http/modules/gasless/controller.ts | 7 ++++++- packages/worker/src/index.ts | 4 ++-- .../src/queues/gaslessUtxos/utils/getStats.ts | 13 +++++++++++-- .../worker/src/queues/gaslessUtxos/utils/reserve.ts | 5 ++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/http/modules/gasless/controller.ts b/packages/worker/src/http/modules/gasless/controller.ts index 239360759..011379d47 100644 --- a/packages/worker/src/http/modules/gasless/controller.ts +++ b/packages/worker/src/http/modules/gasless/controller.ts @@ -22,7 +22,12 @@ export class GaslessController { throw new AppError(400, "estimatedMaxFee must be a positive number"); } - // TODO: space for blocking rules + 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( diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 3b6cebb67..247e63bb0 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -55,8 +55,8 @@ console.log( ); const app = express(); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: "1mb" })); +app.use(express.urlencoded({ extended: true, limit: "1mb" })); setupRoutes(app); const serverAdapter = new ExpressAdapter(); diff --git a/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts b/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts index 60349f73e..15870694b 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/getStats.ts @@ -5,12 +5,21 @@ export const getStats = async ( collection: Collection ): Promise => { const result = await collection - .aggregate<{ _id: string; count: number; totalAmount: string }>([ + .aggregate<{ _id: string; count: number; totalAmount: number }>([ { $group: { _id: "$status", count: { $sum: 1 }, - totalAmount: { $sum: { $toLong: "$amount" } }, + totalAmount: { + $sum: { + $convert: { + input: "$amount", + to: "long", + onError: 0, + onNull: 0, + }, + }, + }, }, }, ]) diff --git a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts index 29a8ac977..59f7b5aac 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts @@ -7,7 +7,10 @@ export const reserve = async ( ): Promise => { const { reservedBy, estimatedMaxFee } = options; - const minAmount = String(Math.ceil(estimatedMaxFee * 1.5)); + const minAmount = ( + (BigInt(Math.floor(estimatedMaxFee)) * BigInt(150)) / + BigInt(100) + ).toString(); return collection.findOneAndUpdate( { status: "available", amount: { $gte: minAmount } }, From 13897f19c60fa0fafb00532dff416fb748b8b2fe Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Thu, 12 Mar 2026 15:53:29 -0300 Subject: [PATCH 7/8] refactor: update and add complete flow tests for gasless endpoints --- .../worker/src/tests/gaslessUtxos.test.ts | 252 +++++++++++++++--- .../src/tests/utils/gaslessTestEnvironment.ts | 1 + 2 files changed, 220 insertions(+), 33 deletions(-) diff --git a/packages/worker/src/tests/gaslessUtxos.test.ts b/packages/worker/src/tests/gaslessUtxos.test.ts index 63dcd11da..3356e7e9a 100644 --- a/packages/worker/src/tests/gaslessUtxos.test.ts +++ b/packages/worker/src/tests/gaslessUtxos.test.ts @@ -3,6 +3,11 @@ import assert from "node:assert/strict"; import { gaslessUtxosCollection } from "@/queues/gaslessUtxos"; import { GaslessTestEnvironment } from "@/tests/utils/gaslessTestEnvironment"; +const DEFAULT_FEE = 100; +const DEFAULT_AMOUNT = "200"; +const LOW_AMOUNT = "100"; +const DEFAULT_AMOUNT_WEI = "200000000000000"; + test("gaslessUtxos", async (t) => { const env = new GaslessTestEnvironment(); @@ -52,35 +57,73 @@ test("gaslessUtxos", async (t) => { await env.clear(); }); - await t.test("should reserve an available UTXO", async () => { - await env.seed([{ utxoId: "utxo-1", status: "available" }]); - - const utxos = gaslessUtxosCollection(env.collection); - const reserved = await utxos.reserve({ reservedBy: "reservation-123" }); + await t.test( + "should reserve a UTXO with amount >= estimatedMaxFee * 1.5", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: DEFAULT_AMOUNT }, + ]); - assert.ok(reserved); - assert.equal(reserved.utxoId, "utxo-1"); - assert.equal(reserved.status, "reserved"); - assert.equal(reserved.reservedBy, "reservation-123"); - assert.ok(reserved.reservedAt instanceof Date); - }); + const utxos = gaslessUtxosCollection(env.collection); + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: DEFAULT_FEE, + }); + + assert.ok(reserved); + assert.equal(reserved.utxoId, "utxo-1"); + assert.equal(reserved.status, "reserved"); + assert.equal(reserved.reservedBy, "reservation-123"); + assert.ok(reserved.reservedAt instanceof Date); + } + ); await t.test("should return null when no UTXOs available", async () => { - await env.seed([{ utxoId: "utxo-1", status: "spent" }]); + await env.seed([ + { utxoId: "utxo-1", status: "spent", amount: DEFAULT_AMOUNT }, + ]); const utxos = gaslessUtxosCollection(env.collection); - const reserved = await utxos.reserve({ reservedBy: "reservation-123" }); + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: DEFAULT_FEE, + }); assert.equal(reserved, null); }); + await t.test( + "should return null when UTXO amount is insufficient for estimatedMaxFee", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: LOW_AMOUNT }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: DEFAULT_FEE, + }); + + assert.equal(reserved, null); + } + ); + await t.test("should not reserve an already reserved UTXO", async () => { await env.seed([ - { utxoId: "utxo-1", status: "reserved", reservedBy: "reservation-abc" }, + { + utxoId: "utxo-1", + status: "reserved", + reservedBy: "reservation-abc", + amount: DEFAULT_AMOUNT, + }, ]); const utxos = gaslessUtxosCollection(env.collection); - const reserved = await utxos.reserve({ reservedBy: "reservation-xyz" }); + const reserved = await utxos.reserve({ + reservedBy: "reservation-xyz", + estimatedMaxFee: DEFAULT_FEE, + }); assert.equal(reserved, null); }); @@ -88,13 +131,21 @@ test("gaslessUtxos", async (t) => { await t.test( "should handle race condition — only one reservation wins", async () => { - await env.seed([{ utxoId: "utxo-1", status: "available" }]); + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: DEFAULT_AMOUNT }, + ]); const utxos = gaslessUtxosCollection(env.collection); const [first, second] = await Promise.all([ - utxos.reserve({ reservedBy: "reservation-A" }), - utxos.reserve({ reservedBy: "reservation-B" }), + utxos.reserve({ + reservedBy: "reservation-A", + estimatedMaxFee: DEFAULT_FEE, + }), + utxos.reserve({ + reservedBy: "reservation-B", + estimatedMaxFee: DEFAULT_FEE, + }), ]); const winners = [first, second].filter(Boolean); @@ -102,7 +153,6 @@ test("gaslessUtxos", async (t) => { assert.equal(winners.length, 1); assert.equal(losers.length, 1); - assert.equal(winners[0]!.status, "reserved"); } ); @@ -111,16 +161,25 @@ test("gaslessUtxos", async (t) => { "should handle race condition with multiple UTXOs", async () => { await env.seed([ - { utxoId: "utxo-1", status: "available" }, - { utxoId: "utxo-2", status: "available" }, + { utxoId: "utxo-1", status: "available", amount: DEFAULT_AMOUNT }, + { utxoId: "utxo-2", status: "available", amount: DEFAULT_AMOUNT }, ]); const utxos = gaslessUtxosCollection(env.collection); const results = await Promise.all([ - utxos.reserve({ reservedBy: "reservation-A" }), - utxos.reserve({ reservedBy: "reservation-B" }), - utxos.reserve({ reservedBy: "reservation-C" }), + utxos.reserve({ + reservedBy: "reservation-A", + estimatedMaxFee: DEFAULT_FEE, + }), + utxos.reserve({ + reservedBy: "reservation-B", + estimatedMaxFee: DEFAULT_FEE, + }), + utxos.reserve({ + reservedBy: "reservation-C", + estimatedMaxFee: DEFAULT_FEE, + }), ]); const winners = results.filter(Boolean); @@ -226,14 +285,14 @@ test("gaslessUtxos", async (t) => { await env.clear(); }); - await t.test("should return correct counts per status", async () => { + await t.test("should return correct counts and totalValue", async () => { await env.seed([ - { utxoId: "utxo-1", status: "available" }, - { utxoId: "utxo-2", status: "available" }, - { utxoId: "utxo-3", status: "reserved" }, - { utxoId: "utxo-4", status: "spent" }, - { utxoId: "utxo-5", status: "spent" }, - { utxoId: "utxo-6", status: "spent" }, + { utxoId: "utxo-1", status: "available", amount: "200" }, + { utxoId: "utxo-2", status: "available", amount: "200" }, + { utxoId: "utxo-3", status: "reserved", amount: "200" }, + { utxoId: "utxo-4", status: "spent", amount: "200" }, + { utxoId: "utxo-5", status: "spent", amount: "200" }, + { utxoId: "utxo-6", status: "spent", amount: "200" }, ]); const utxos = gaslessUtxosCollection(env.collection); @@ -242,9 +301,25 @@ test("gaslessUtxos", async (t) => { assert.equal(stats.available, 2); assert.equal(stats.reserved, 1); assert.equal(stats.spent, 3); - assert.equal(stats.total, 6); + assert.equal(stats.totalValue, "1200"); }); + await t.test( + "should sum amounts correctly across different statuses", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: "300" }, + { utxoId: "utxo-2", status: "reserved", amount: "500" }, + { utxoId: "utxo-3", status: "spent", amount: "200" }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + const stats = await utxos.getStats(); + + assert.equal(stats.totalValue, "1000"); + } + ); + await t.test("should return zeros when collection is empty", async () => { const utxos = gaslessUtxosCollection(env.collection); const stats = await utxos.getStats(); @@ -252,7 +327,7 @@ test("gaslessUtxos", async (t) => { assert.equal(stats.available, 0); assert.equal(stats.reserved, 0); assert.equal(stats.spent, 0); - assert.equal(stats.total, 0); + assert.equal(stats.totalValue, "0"); }); }); @@ -357,4 +432,115 @@ test("gaslessUtxos", async (t) => { assert.equal(released, 0); }); }); + + await t.test("complete flow", async (t) => { + t.afterEach(async () => { + await env.clear(); + }); + + await t.test( + "reserve -> release should return UTXO to available", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: DEFAULT_AMOUNT }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: DEFAULT_FEE, + }); + + assert.ok(reserved); + assert.equal(reserved.status, "reserved"); + + const released = await utxos.release("utxo-1"); + + assert.ok(released); + assert.equal(released.status, "available"); + assert.equal(released.reservedBy, undefined); + assert.equal(released.reservedAt, undefined); + } + ); + + await t.test( + "reserve -> release -> reserve should allow UTXO to be reused", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: DEFAULT_AMOUNT }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + + const firstReservation = await utxos.reserve({ + reservedBy: "reservation-A", + estimatedMaxFee: DEFAULT_FEE, + }); + assert.ok(firstReservation); + assert.equal(firstReservation.status, "reserved"); + + await utxos.release("utxo-1"); + + const secondReservation = await utxos.reserve({ + reservedBy: "reservation-B", + estimatedMaxFee: DEFAULT_FEE, + }); + assert.ok(secondReservation); + assert.equal(secondReservation.utxoId, "utxo-1"); + assert.equal(secondReservation.status, "reserved"); + assert.equal(secondReservation.reservedBy, "reservation-B"); + } + ); + + await t.test("should return null when pool is exhausted", async () => { + await env.seed([ + { utxoId: "utxo-1", status: "spent", amount: DEFAULT_AMOUNT }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: DEFAULT_FEE, + }); + + assert.equal(reserved, null); + }); + + await t.test( + "should return null when UTXO amount is insufficient for estimatedMaxFee", + async () => { + await env.seed([ + { utxoId: "utxo-1", status: "available", amount: LOW_AMOUNT }, + ]); + + const utxos = gaslessUtxosCollection(env.collection); + + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: DEFAULT_FEE, + }); + + assert.equal(reserved, null); + } + ); + + await t.test( + "should use default seed amount (wei) and reserve correctly", + async () => { + await env.seed([{ utxoId: "utxo-1", status: "available" }]); + + const utxos = gaslessUtxosCollection(env.collection); + + const reserved = await utxos.reserve({ + reservedBy: "reservation-123", + estimatedMaxFee: 100000000000000, + }); + + assert.ok(reserved); + assert.equal(reserved.amount, DEFAULT_AMOUNT_WEI); + } + ); + }); }); diff --git a/packages/worker/src/tests/utils/gaslessTestEnvironment.ts b/packages/worker/src/tests/utils/gaslessTestEnvironment.ts index b538f2a6d..30833a6d9 100644 --- a/packages/worker/src/tests/utils/gaslessTestEnvironment.ts +++ b/packages/worker/src/tests/utils/gaslessTestEnvironment.ts @@ -34,6 +34,7 @@ export class GaslessTestEnvironment { txId: u.txId ?? "0xabc", outputIndex: u.outputIndex ?? 0, amount: u.amount ?? "200000000000000", + owner: u.owner ?? "0xdefault-owner", status: u.status ?? "available", createdAt: u.createdAt ?? new Date(), ...(u.reservedBy && { reservedBy: u.reservedBy }), From be6e77b75e9fec38cb6969c018682ccafc2f623a Mon Sep 17 00:00:00 2001 From: Gabriel Tozatti Date: Thu, 12 Mar 2026 16:07:57 -0300 Subject: [PATCH 8/8] fix: address code review important and suggestion issues --- packages/worker/src/http/modules/gasless/controller.ts | 2 +- packages/worker/src/queues/gaslessUtxos/constants.ts | 2 +- packages/worker/src/queues/gaslessUtxos/utils/reserve.ts | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/worker/src/http/modules/gasless/controller.ts b/packages/worker/src/http/modules/gasless/controller.ts index 011379d47..6a2794f8d 100644 --- a/packages/worker/src/http/modules/gasless/controller.ts +++ b/packages/worker/src/http/modules/gasless/controller.ts @@ -40,7 +40,7 @@ export class GaslessController { }); if (!utxo) { - throw new AppError(503, "POOL_EXHAUSTED"); + throw new AppError(503, "Pool exhausted - no UTXOs available"); } res.status(200).json({ diff --git a/packages/worker/src/queues/gaslessUtxos/constants.ts b/packages/worker/src/queues/gaslessUtxos/constants.ts index 221e2da35..ab7638fc8 100644 --- a/packages/worker/src/queues/gaslessUtxos/constants.ts +++ b/packages/worker/src/queues/gaslessUtxos/constants.ts @@ -1,4 +1,4 @@ export const COLLECTION_GASLESS_UTXOS = "gasless_utxos"; -export const DEFAULT_TTL_SECONDS = 60 * 60; export const CLEANUP_INTERVAL_MS = 60 * 1000; export const RESERVE_TTLS_MS = 5 * 60 * 1000; +export const SAFETY_MARGIN_PERCENT = 150; diff --git a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts index 59f7b5aac..82c0c9785 100644 --- a/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts +++ b/packages/worker/src/queues/gaslessUtxos/utils/reserve.ts @@ -1,5 +1,6 @@ import { Collection } from "mongodb"; import { GaslessUtxo, ReserveUtxoOptions } from "../types"; +import { SAFETY_MARGIN_PERCENT } from "../constants"; export const reserve = async ( collection: Collection, @@ -8,7 +9,7 @@ export const reserve = async ( const { reservedBy, estimatedMaxFee } = options; const minAmount = ( - (BigInt(Math.floor(estimatedMaxFee)) * BigInt(150)) / + (BigInt(Math.floor(estimatedMaxFee)) * BigInt(SAFETY_MARGIN_PERCENT)) / BigInt(100) ).toString();