Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
9 changes: 9 additions & 0 deletions packages/worker/src/http/index.ts
Original file line number Diff line number Diff line change
@@ -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);
};
22 changes: 22 additions & 0 deletions packages/worker/src/http/middlewares/handleErrors.ts
Original file line number Diff line number Diff line change
@@ -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" });
};
76 changes: 76 additions & 0 deletions packages/worker/src/http/modules/gasless/controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<GaslessUtxo>(COLLECTION_GASLESS_UTXOS)
);

const utxo = await utxos.reserve({
reservedBy: accountId ?? "anonymous",
estimatedMaxFee,
});

if (!utxo) {
throw new AppError(503, "Pool exhausted - no UTXOs available");
}

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<void> {
try {
const db = await MongoDatabase.connect();
const utxos = gaslessUtxosCollection(
db.getCollection<GaslessUtxo>(COLLECTION_GASLESS_UTXOS)
);

const stats = await utxos.getStats();

res.status(200).json(stats);
} catch (err) {
next(err);
}
}
}
9 changes: 9 additions & 0 deletions packages/worker/src/http/modules/gasless/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions packages/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
UserBlockSyncCron,
} from "./queues/userBlockSync";
import { GaslessUtxoCleanup } from "@/queues/gaslessUtxos/gaslessUtxoCleanup";
import { setupRoutes } from "@/http";

const {
WORKER_PORT,
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/queues/gaslessUtxos/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions packages/worker/src/queues/gaslessUtxos/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface GaslessUtxo {
txId: string;
outputIndex: number;
amount: string;
owner: string;
status: "available" | "reserved" | "spent";
reservedAt?: Date;
reservedBy?: string;
Expand All @@ -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;
}
45 changes: 31 additions & 14 deletions packages/worker/src/queues/gaslessUtxos/utils/getStats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,41 @@ import { GaslessUtxo, GaslessUtxoStats } from "../types";
export const getStats = async (
collection: Collection<GaslessUtxo>
): Promise<GaslessUtxoStats> => {
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<GaslessUtxoStats, "total">;
if (key in stats) stats[key] = row.count;
stats.total += row.count;
for (const row of result) {
const status = row._id as keyof Omit<GaslessUtxoStats, "totalValue">;
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(),
};
};
11 changes: 8 additions & 3 deletions packages/worker/src/queues/gaslessUtxos/utils/reserve.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { Collection } from "mongodb";
import { GaslessUtxo, ReserveUtxoOptions } from "../types";
import { DEFAULT_TTL_SECONDS } from "@/queues/gaslessUtxos/constants";
import { SAFETY_MARGIN_PERCENT } from "../constants";

export const reserve = async (
collection: Collection<GaslessUtxo>,
options: ReserveUtxoOptions
): Promise<GaslessUtxo | null> => {
const { reservedBy, ttlSeconds = DEFAULT_TTL_SECONDS } = options;
const { reservedBy, estimatedMaxFee } = options;

const minAmount = (
(BigInt(Math.floor(estimatedMaxFee)) * BigInt(SAFETY_MARGIN_PERCENT)) /
BigInt(100)
).toString();

return collection.findOneAndUpdate(
{ status: "available" },
{ status: "available", amount: { $gte: minAmount } },
{
$set: {
status: "reserved",
Expand Down
Loading
Loading