Transaction.js
1 import mongoose from "mongoose"; 2 import networks from "@/data/crypto/networks.js"; 3 import { validateWalletAddress, validateAmount, validateTxHash, handleError } from "@/utils/cryptoUtils.js"; 4 import { sanitizeString } from "@/utils/sanitizer.js"; 5 import { sanitizeQuery } from "@/services/databaseService.js"; 6 import { logger } from "@/middleware/logging.js"; 7 import config from "@/config.js"; 8 9 const metadataSchema = new mongoose.Schema({ 10 lockId: { type: String, maxlength: config.limits.stringMax }, 11 serializedTransaction: { type: String, maxlength: 10000 }, 12 retryCount: { type: Number, default: 0, max: 10 }, 13 lastRetryAt: { type: Date }, 14 verificationAttempts: { type: Number, default: 0, max: 20 }, 15 }, { _id: false, strict: true }); 16 17 const schema = new mongoose.Schema({ 18 txId: { 19 type: String, 20 required: true, 21 unique: true, 22 index: true, 23 maxlength: config.limits.stringMax, 24 validate: { 25 validator: (v) => config.patterns.txIdRegex.test(v), 26 message: "Invalid transaction ID format", 27 }, 28 }, 29 idempotencyKey: { 30 type: String, 31 maxlength: config.limits.stringMax, 32 sparse: true, 33 validate: { 34 validator: (v) => !v || config.patterns.idempotencyKeyRegex.test(v), 35 message: "Invalid idempotency key format", 36 }, 37 }, 38 type: { 39 type: String, 40 enum: config.transactions.types, 41 required: true, 42 index: true, 43 }, 44 userId: { 45 type: mongoose.Schema.Types.ObjectId, 46 ref: "User", 47 required: true, 48 index: true, 49 }, 50 walletAddress: { 51 type: String, 52 required: true, 53 index: true, 54 maxlength: config.limits.stringMax, 55 validate: { 56 validator: function (v) { 57 return validateWalletAddress(this.chain, v); 58 }, 59 message: "Invalid wallet address for chain", 60 }, 61 }, 62 currency: { 63 type: String, 64 required: true, 65 index: true, 66 maxlength: 20, 67 uppercase: true, 68 validate: { 69 validator: (v) => config.patterns.currencyRegex.test(v), 70 message: "Invalid currency format", 71 }, 72 }, 73 chain: { 74 type: String, 75 required: true, 76 index: true, 77 enum: Object.keys(networks), 78 lowercase: true, 79 }, 80 amount: { 81 type: String, 82 required: true, 83 validate: { 84 validator: (v) => validateAmount(v, config.fiat.precision.cents), 85 message: "Amount must be a valid non-negative number", 86 }, 87 }, 88 verifiedAmount: { 89 type: String, 90 validate: { 91 validator: (v) => !v || validateAmount(v, config.fiat.precision.cents), 92 message: "Verified amount must must be a valid non-negative number", 93 }, 94 }, 95 feeAmount: { 96 type: String, 97 default: "0", 98 validate: { 99 validator: (v) => validateAmount(v, config.fiat.precision.cents), 100 message: "Fee amount must be a valid non-negative number", 101 }, 102 }, 103 feeCurrency: { 104 type: String, 105 maxlength: 20, 106 uppercase: true, 107 }, 108 priceAtTransaction: { 109 type: Number, 110 min: 0, 111 max: 1000000000, 112 }, 113 previousBalance: { 114 type: String, 115 validate: { 116 validator: (v) => !v || validateAmount(v, config.fiat.precision.cents), 117 message: "Previous balance must be a valid non-negative number", 118 }, 119 }, 120 newBalance: { 121 type: String, 122 validate: { 123 validator: (v) => !v || validateAmount(v, config.fiat.precision.cents), 124 message: "New balance must be a valid non-negative number", 125 }, 126 }, 127 onchainTxHash: { 128 type: String, 129 maxlength: config.limits.stringMax, 130 sparse: true, 131 validate: { 132 validator: function (v) { 133 if (!v) { 134 return true; 135 } 136 return validateTxHash(this.chain, v); 137 }, 138 message: "Invalid transaction hash for chain", 139 }, 140 }, 141 escrowAddress: { 142 type: String, 143 maxlength: config.limits.stringMax, 144 }, 145 blockHeight: { 146 type: Number, 147 min: 0, 148 }, 149 confirmations: { 150 type: Number, 151 default: 0, 152 min: 0, 153 max: 1000000, 154 }, 155 status: { 156 type: String, 157 enum: config.transactions.statuses, 158 default: "pending", 159 index: true, 160 }, 161 errorCode: { 162 type: String, 163 maxlength: 64, 164 set: (v) => sanitizeString(v, 64), 165 }, 166 cancelledAt: { type: Date }, 167 expiredAt: { type: Date }, 168 isSimulated: { type: Boolean, default: false }, 169 ipAddress: { 170 type: String, 171 maxlength: 45, 172 validate: { 173 validator: (v) => !v || config.patterns.ipRegex.test(v), 174 message: "Invalid IP address format", 175 }, 176 }, 177 userAgent: { 178 type: String, 179 maxlength: 512, 180 set: (v) => sanitizeString(v, 512), 181 }, 182 requestedAt: { type: Date }, 183 confirmedAt: { type: Date }, 184 metadata: { 185 type: metadataSchema, 186 default: {}, 187 }, 188 integrityHash: { 189 type: String, 190 maxlength: 128, 191 }, 192 version: { 193 type: Number, 194 default: 1, 195 }, 196 }, { 197 timestamps: true, 198 versionKey: false, 199 strict: true, 200 toJSON: { 201 transform: (doc, ret) => { 202 delete ret.ipAddress; 203 delete ret.userAgent; 204 delete ret.metadata; 205 delete ret.integrityHash; 206 delete ret.__v; 207 return ret; 208 }, 209 }, 210 }); 211 212 schema.index({ createdAt: -1 }); 213 schema.index({ userId: 1, createdAt: -1 }); 214 schema.index({ userId: 1, status: 1, createdAt: -1 }); 215 schema.index({ currency: 1, chain: 1 }); 216 schema.index({ type: 1, status: 1 }); 217 schema.index({ status: 1, createdAt: 1 }); 218 schema.index({ onchainTxHash: 1 }, { name: "onchainTxHash_idx", unique: true, sparse: true }); 219 schema.index({ idempotencyKey: 1 }, { name: "idempotencyKey_idx", unique: true, sparse: true }); 220 schema.index({ status: 1, expiredAt: 1 }); 221 schema.index({ userId: 1, type: 1, status: 1 }); 222 223 schema.methods.calculateIntegrityHash = function () { 224 const data = `${this.txId}:${this.userId}:${this.type}:${this.amount}:${this.currency}:${this.chain}:${this.status}`; 225 return crypto.createHash("sha256").update(data).digest("hex"); 226 }; 227 228 schema.methods.verifyIntegrity = function () { 229 if (!this.integrityHash) { 230 return true; 231 } 232 return this.integrityHash === this.calculateIntegrityHash(); 233 }; 234 235 schema.methods.canTransition = function (newStatus) { 236 const validTransitions = { 237 pending: ["processing", "confirmed", "failed", "cancelled", "expired"], 238 processing: ["confirmed", "failed", "rolled_back"], 239 confirmed: [], 240 failed: [], 241 rolled_back: [], 242 cancelled: [], 243 expired: ["confirmed"], 244 }; 245 return validTransitions[this.status]?.includes(newStatus) || false; 246 }; 247 248 schema.methods.transitionTo = async function (newStatus) { 249 try { 250 if (!this.canTransition(newStatus)) { 251 throw new Error(`Invalid status transition from ${this.status} to ${newStatus}`); 252 } 253 this.status = newStatus; 254 this.integrityHash = this.calculateIntegrityHash(); 255 if (newStatus === "confirmed") { 256 this.confirmedAt = new Date(); 257 } else if (newStatus === "cancelled") { 258 this.cancelledAt = new Date(); 259 } else if (newStatus === "expired") { 260 this.expiredAt = new Date(); 261 } 262 await this.save(); 263 } catch (error) { 264 handleError(error, "Transaction.transitionTo"); 265 } 266 }; 267 268 schema.pre("save", async function (next) { 269 try { 270 if (this.isNew || this.isModified("status") || this.isModified("amount")) { 271 this.integrityHash = this.calculateIntegrityHash(); 272 } 273 if (this.isModified("walletAddress") && this.chain) { 274 if (!validateWalletAddress(this.chain, this.walletAddress)) { 275 return next(new Error("Invalid wallet address for chain")); 276 } 277 } 278 if (this.isModified("onchainTxHash") && this.onchainTxHash && this.chain) { 279 if (!validateTxHash(this.onchainTxHash, this.chain)) { 280 return next(new Error("Invalid transaction hash for chain")); 281 } 282 } 283 next(); 284 } catch (error) { 285 handleError(error, "Transaction.pre-save"); 286 } 287 }); 288 289 schema.pre("findOneAndUpdate", async function (next) { 290 try { 291 const update = this.getUpdate(); 292 if (update.$set) { 293 if (update.$set.amount !== undefined && !validateAmount(update.$set.amount, config.fiat.precision.cents)) { 294 return next(new Error("Invalid amount value")); 295 } 296 if (update.$set.verifiedAmount !== undefined && !validateAmount(update.$set.verifiedAmount, config.fiat.precision.cents)) { 297 return next(new Error("Invalid verified amount value")); 298 } 299 } 300 next(); 301 } catch (error) { 302 handleError(error, "Transaction.pre-findOneAndUpdate"); 303 } 304 }); 305 306 schema.statics.findByTxId = async function (txId) { 307 try { 308 if (!txId || typeof txId !== "string" || !config.patterns.txIdRegex.test(txId)) { 309 return null; 310 } 311 return this.findOne({ txId }); 312 } catch (error) { 313 handleError(error, "Transaction.findByTxId"); 314 } 315 }; 316 317 schema.statics.findByIdempotencyKey = async function (key) { 318 try { 319 if (!key || typeof key !== "string" || key.length > config.limits.stringMax) { 320 return null; 321 } 322 return this.findOne({ idempotencyKey: key }); 323 } catch (error) { 324 handleError(error, "Transaction.findByIdempotencyKey"); 325 } 326 }; 327 328 schema.statics.findPendingByUser = async function (userId, limit = 10) { 329 try { 330 return this.find({ userId, status: "pending" }) 331 .sort({ createdAt: -1 }) 332 .limit(Math.min(limit, config.limits.queryMax)) 333 .lean(); 334 } catch (error) { 335 handleError(error, "Transaction.findPendingByUser"); 336 } 337 }; 338 339 schema.statics.expirePendingTransactions = async function (timeoutMs = config.timeouts.transactionExpire) { 340 try { 341 if (!timeoutMs || isNaN(timeoutMs)) { 342 logger.warn("Invalid timeoutMs, returning early"); 343 return { modifiedCount: 0 }; 344 } 345 346 const cutoffDate = new Date(Date.now() - timeoutMs); 347 348 const db = mongoose.connection.db; 349 const query = { status: "pending", createdAt: { $lte: cutoffDate } }; 350 const sanitizedQuery = sanitizeQuery(query); 351 const result = await db.collection("transactions").updateMany( 352 sanitizedQuery, 353 { $set: { status: "expired", expiredAt: new Date() } }, 354 ); 355 356 return result; 357 } catch (error) { 358 handleError(error, "Transaction.expirePendingTransactions"); 359 } 360 }; 361 362 export const Transaction = mongoose.model("Transaction", schema);