-
Notifications
You must be signed in to change notification settings - Fork 5
feat: create schema and service for managing UTXOs #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +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; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { MongoDatabase } from "@/clients/mongoClient"; | ||
| import { | ||
| COLLECTION_GASLESS_UTXOS, | ||
| CLEANUP_INTERVAL_MS, | ||
| } from "@/queues/gaslessUtxos/constants"; | ||
| import { GaslessUtxo } from "@/queues/gaslessUtxos/types"; | ||
| import { gaslessUtxosCollection } from "@/queues/gaslessUtxos"; | ||
|
|
||
| export class GaslessUtxoCleanup { | ||
| private static instance?: GaslessUtxoCleanup; | ||
| private static intervalRef?: NodeJS.Timeout; | ||
|
|
||
| private constructor() {} | ||
|
|
||
| static start(): GaslessUtxoCleanup { | ||
| if (!GaslessUtxoCleanup.instance) { | ||
| GaslessUtxoCleanup.instance = new GaslessUtxoCleanup(); | ||
|
|
||
| GaslessUtxoCleanup.intervalRef = setInterval(async () => { | ||
| const db = await MongoDatabase.connect(); | ||
| const utxos = gaslessUtxosCollection( | ||
| db.getCollection<GaslessUtxo>(COLLECTION_GASLESS_UTXOS) | ||
| ); | ||
| const released = await utxos.releaseExpired(); | ||
| if (released > 0) { | ||
| console.log( | ||
| `[GASLESS_UTXO_CLEANUP]: Released ${released} expired reservation(s).` | ||
| ); | ||
| } | ||
| }, CLEANUP_INTERVAL_MS); | ||
| } | ||
|
|
||
| return GaslessUtxoCleanup.instance; | ||
| } | ||
|
|
||
| static stop(): void { | ||
| if (GaslessUtxoCleanup.intervalRef) { | ||
| clearInterval(GaslessUtxoCleanup.intervalRef); | ||
| GaslessUtxoCleanup.intervalRef = undefined; | ||
| console.log("[GASLESS_UTXO_CLEANUP]: Stopped."); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { Collection } from 'mongodb'; | ||
| import { GaslessUtxo, type ReserveUtxoOptions, GaslessUtxoStats } from './types'; | ||
| import { findAvailable } from './utils/findAvailable'; | ||
| import { reserve } from './utils/reserve'; | ||
| import { release } from './utils/release'; | ||
| import { markSpent } from './utils/markSpent'; | ||
| import { getStats } from './utils/getStats'; | ||
| import { releaseExpired } from './utils/releaseExpired'; | ||
|
|
||
| export const gaslessUtxosCollection = (collection: Collection<GaslessUtxo>) => ({ | ||
| findAvailable: (): Promise<GaslessUtxo[]> => findAvailable(collection), | ||
| reserve: (options: ReserveUtxoOptions): Promise<GaslessUtxo | null> => reserve(collection, options), | ||
| release: (utxoId: string): Promise<GaslessUtxo | null> => release(collection, utxoId), | ||
| markSpent: (utxoId: string, spentTxHash: string): Promise<GaslessUtxo | null> => markSpent(collection, utxoId, spentTxHash), | ||
| getStats: (): Promise<GaslessUtxoStats> => getStats(collection), | ||
| releaseExpired: (): Promise<number> => releaseExpired(collection), | ||
| }); | ||
|
|
||
| export * from './types'; | ||
| export * from './constants'; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { ObjectId } from "mongodb"; | ||
|
|
||
| export interface GaslessUtxo { | ||
| _id?: ObjectId; | ||
| utxoId: string; | ||
| txId: string; | ||
| outputIndex: number; | ||
| amount: string; | ||
| status: "available" | "reserved" | "spent"; | ||
| reservedAt?: Date; | ||
| reservedBy?: string; | ||
| spentTxHash?: string; | ||
| createdAt: Date; | ||
| } | ||
|
|
||
| export interface GaslessUtxoStats { | ||
| available: number; | ||
| reserved: number; | ||
| spent: number; | ||
| total: number; | ||
| } | ||
|
|
||
| export interface ReserveUtxoOptions { | ||
| reservedBy: string; | ||
| ttlSeconds?: number; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { Collection } from "mongodb"; | ||
| import { GaslessUtxo } from "../types"; | ||
|
|
||
| export const findAvailable = async ( | ||
| collection: Collection<GaslessUtxo> | ||
| ): Promise<GaslessUtxo[]> => { | ||
| return collection.find({ status: "available" }).toArray(); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { Collection } from "mongodb"; | ||
| 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 } } }, | ||
| ]) | ||
| .toArray(); | ||
|
|
||
| const stats: GaslessUtxoStats = { | ||
| available: 0, | ||
| reserved: 0, | ||
| spent: 0, | ||
| total: 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; | ||
| } | ||
|
|
||
| return stats; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Collection } from "mongodb"; | ||
| import { GaslessUtxo } from "../types"; | ||
|
|
||
| export const markSpent = async ( | ||
| collection: Collection<GaslessUtxo>, | ||
| utxoId: string, | ||
| spentTxHash: string | ||
| ): Promise<GaslessUtxo | null> => { | ||
| return collection.findOneAndUpdate( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 IMPORTANT: Missing input validation Problem: utxoId and spentTxHash parameters are not validated. Empty strings or invalid formats could cause issues. Suggestion: Add validation: |
||
| { utxoId, status: "reserved" }, | ||
| { | ||
| $set: { status: "spent", spentTxHash }, | ||
| $unset: { reservedBy: "", reservedAt: "" }, | ||
| }, | ||
| { returnDocument: "after" } | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { Collection } from "mongodb"; | ||
| import { GaslessUtxo } from "../types"; | ||
|
|
||
| export const release = async ( | ||
| collection: Collection<GaslessUtxo>, | ||
| utxoId: string | ||
| ): Promise<GaslessUtxo | null> => { | ||
| return collection.findOneAndUpdate( | ||
| { utxoId, status: "reserved" }, | ||
| { | ||
| $set: { status: "available" }, | ||
| $unset: { reservedBy: "", reservedAt: "" }, | ||
| }, | ||
| { returnDocument: "after" } | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { Collection } from "mongodb"; | ||
| import { GaslessUtxo } from "@/queues/gaslessUtxos"; | ||
| import { RESERVE_TTLS_MS } from "@/queues/gaslessUtxos"; | ||
|
|
||
| export const releaseExpired = async ( | ||
| collection: Collection<GaslessUtxo> | ||
| ): Promise<number> => { | ||
| const expiredBefore = new Date(Date.now() - RESERVE_TTLS_MS); | ||
|
|
||
| const result = await collection.updateMany( | ||
| { status: "reserved", reservedAt: { $lte: expiredBefore } }, | ||
| { | ||
| $set: { status: "available" }, | ||
| $unset: { reservedBy: "", reservedAt: "" }, | ||
| } | ||
| ); | ||
|
|
||
| return result.modifiedCount; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { Collection } from "mongodb"; | ||
| import { GaslessUtxo, ReserveUtxoOptions } from "../types"; | ||
| import { DEFAULT_TTL_SECONDS } from "@/queues/gaslessUtxos/constants"; | ||
|
|
||
| export const reserve = async ( | ||
| collection: Collection<GaslessUtxo>, | ||
| options: ReserveUtxoOptions | ||
| ): Promise<GaslessUtxo | null> => { | ||
| const { reservedBy, ttlSeconds = DEFAULT_TTL_SECONDS } = options; | ||
|
|
||
| return collection.findOneAndUpdate( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 IMPORTANT: Missing TTL cleanup mechanism Problem: Reserved UTXOs with expired TTL will remain stuck in 'reserved' status forever, causing resource leaks. The reservedAt timestamp is set but never used for cleanup. Suggestion: Add a cleanup method that finds expired reservations: |
||
| { status: "available" }, | ||
| { | ||
| $set: { | ||
| status: "reserved", | ||
| reservedBy, | ||
| reservedAt: new Date(), | ||
| }, | ||
| }, | ||
| { returnDocument: "after" } | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 SUGGESTION: Missing newline at end of file
Problem: File should end with a newline character for consistency.
Suggestion: Add a newline at the end of the file.