UNPKG

wisdom-sdk

Version:

Core business logic and data access layer for prediction markets

1,294 lines (1,293 loc) 56 kB
import { marketStore } from './chunk-IA2KNKYT.js'; import { predictionContractStore } from './chunk-5SR5XDZR.js'; import { userStatsStore } from './chunk-ZJ2UKXRV.js'; import { storeEntity, getKeys, getSetMembers, getEntity, addToSet, deleteEntity, removeFromSet, startTransaction } from './chunk-FIJAO3BQ.js'; import { logger, AppError } from './chunk-2OHF4QSJ.js'; // src/custody-store.ts var custodyLogger = logger.child({ context: "custody-store" }); var batchConfig = { enabled: process.env.ENABLE_BATCH_PROCESSING === "true", maxBatchSize: Number(process.env.BATCH_MAX_SIZE || "200"), minAgeMinutes: Number(process.env.BATCH_MIN_AGE_MINUTES || "15") }; var TransactionType = /* @__PURE__ */ ((TransactionType2) => { TransactionType2["TRANSFER"] = "transfer"; TransactionType2["PREDICT"] = "predict"; TransactionType2["CLAIM_REWARD"] = "claim-reward"; return TransactionType2; })(TransactionType || {}); var custodyStore = { // Take custody of a signed transaction async takeCustody(data) { try { if (!data.signature || !data.userId || !data.type) { throw new AppError({ message: "Invalid custody data", context: "custody-store", code: "CUSTODY_VALIDATION_ERROR", data: { hasSignature: !!data.signature, hasUserId: !!data.userId, hasType: !!data.type } }).log(); } const existingTx = await this.findBySignature(data.signature); if (existingTx.length > 0) { throw new AppError({ message: "Transaction already in custody", context: "custody-store", code: "TRANSACTION_ALREADY_IN_CUSTODY", data: { signature: data.signature, existingCustody: existingTx[0]?.id } }).log(); } custodyLogger.debug({ signature: data.signature.substring(0, 8) + "...", userId: data.userId, type: data.type }, "Taking custody of transaction"); const id = `${data.signature.substring(0, 10)}-${data.nonce}`; const now = (/* @__PURE__ */ new Date()).toISOString(); const transaction = { id, signature: data.signature, nonce: data.nonce, signer: data.signer, type: data.type, subnetId: data.subnetId, userId: data.userId, takenCustodyAt: now, status: "pending" }; if (data.to) transaction.to = data.to; if (data.amount !== void 0) transaction.amount = data.amount; if (data.marketId !== void 0) transaction.marketId = data.marketId; if (data.outcomeId !== void 0) transaction.outcomeId = data.outcomeId; if (data.receiptId !== void 0) transaction.receiptId = data.receiptId; if (data.type === "predict" /* PREDICT */ && data.marketId && data.outcomeId !== void 0) { try { const market = await marketStore.getMarket(data.marketId.toString()); if (market) { const outcome = market.outcomes.find((o) => o.id === data.outcomeId); if (outcome) { transaction.marketName = market.name; transaction.outcomeName = outcome.name; transaction.nftReceipt = { id: `${id}-nft`, tokenId: `${data.marketId}-${data.userId}-${now}`, image: this.generateNftImage(market.name, outcome.name, data.amount || 0), transactionId: id, marketName: market.name, outcomeName: outcome.name, amount: data.amount, createdAt: now }; } } } catch (marketError) { custodyLogger.error( { marketId: data.marketId, error: marketError }, "Failed to get market data for custody NFT" ); } } const tx = await startTransaction(); try { await tx.addEntity("CUSTODY_TRANSACTION", id, transaction); await tx.addToSetInTransaction("USER_TRANSACTIONS", data.userId, id); await tx.addToSetInTransaction("SIGNER_TRANSACTIONS", data.signer, id); if (data.type === "predict" /* PREDICT */ && data.marketId) { await tx.addToSetInTransaction("MARKET_TRANSACTIONS", data.marketId.toString(), id); } if (transaction.nftReceipt) { await tx.addEntity("CUSTODY_NFT_RECEIPT", transaction.nftReceipt.id, transaction.nftReceipt); } const success = await tx.execute(); if (!success) { throw new AppError({ message: "Failed to take custody - transaction failed", context: "custody-store", code: "CUSTODY_TRANSACTION_ERROR", data: { transactionId: id } }).log(); } if (data.type === "predict" /* PREDICT */ && data.marketId && data.outcomeId !== void 0 && data.amount) { try { await marketStore.updateMarketStats( data.marketId.toString(), data.outcomeId, data.amount, data.userId ); } catch (statsError) { custodyLogger.error( { marketId: data.marketId, error: statsError }, "Failed to update market stats for custody transaction" ); } } custodyLogger.info( { transactionId: id, userId: data.userId }, "Transaction custody established successfully" ); return { success: true, transaction }; } catch (error) { if (error instanceof AppError) { throw error; } else { throw new AppError({ message: "Error during custody transaction", context: "custody-store", code: "CUSTODY_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { signature: data.signature, userId: data.userId } }).log(); } } } catch (error) { if (error instanceof AppError) { return { success: false, error: error.message }; } else { const appError = new AppError({ message: "Failed to take custody of transaction", context: "custody-store", code: "CUSTODY_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { signature: data.signature, userId: data.userId } }).log(); return { success: false, error: appError.message }; } } }, // Get all transactions in custody for a user async getUserTransactions(userId) { try { if (!userId) return []; const transactionIds = await getSetMembers("USER_TRANSACTIONS", userId); if (transactionIds.length === 0) { return []; } const transactions = await Promise.all( transactionIds.map((id) => this.getTransaction(id, { verifyBlockchain: true })) ); return transactions.filter(Boolean); } catch (error) { custodyLogger.error({ userId, error }, "Error getting user custody transactions"); return []; } }, /** * Get all potentially redeemable (won) predictions for a user with blockchain verification * This is specifically for showing accurate "Ready to Redeem" predictions to users */ async getUserRedeemablePredictions(userId) { try { if (!userId) { return { redeemablePredictions: [], totalPotentialPayout: 0 }; } custodyLogger.debug({ userId }, "Getting redeemable predictions for user"); const allTransactions = await this.getUserTransactions(userId); const potentiallyRedeemable = allTransactions.filter( (tx) => tx.type === "predict" /* PREDICT */ && (tx.blockchainStatus === "won" || tx.status === "submitted") ); if (potentiallyRedeemable.length === 0) { return { redeemablePredictions: [], totalPotentialPayout: 0 }; } custodyLogger.debug({ userId, potentialCount: potentiallyRedeemable.length }, "Found potentially redeemable predictions"); const verifiedRedeemable = []; let totalPayout = 0; const batchSize = 5; for (let i = 0; i < potentiallyRedeemable.length; i += batchSize) { const batch = potentiallyRedeemable.slice(i, i + batchSize); const verificationPromises = batch.map(async (tx) => { try { const receiptId = tx.receiptId || tx.nonce; const rewardQuote = await predictionContractStore.getRewardQuote(receiptId); if (rewardQuote && rewardQuote.dy > 0) { return { ...tx, potentialPayout: rewardQuote.dy, isVerifiedWinner: true }; } return null; } catch (error) { custodyLogger.warn( { transactionId: tx.id, error: error instanceof Error ? error.message : String(error) }, "Error verifying prediction redeemability" ); return tx.blockchainStatus === "won" ? tx : null; } }); const results = await Promise.all(verificationPromises); for (const result of results) { if (result) { verifiedRedeemable.push(result); totalPayout += result.potentialPayout || 0; } } } custodyLogger.info({ userId, verifiedCount: verifiedRedeemable.length, totalPayout }, "Verified redeemable predictions against blockchain"); return { redeemablePredictions: verifiedRedeemable, totalPotentialPayout: totalPayout }; } catch (error) { custodyLogger.error({ userId, error: error instanceof Error ? error.message : String(error) }, "Error getting redeemable predictions"); return { redeemablePredictions: [], totalPotentialPayout: 0 }; } }, // Get all transactions in custody for a signer async getSignerTransactions(signer) { try { if (!signer) return []; const transactionIds = await getSetMembers("SIGNER_TRANSACTIONS", signer); if (transactionIds.length === 0) { return []; } const transactions = await Promise.all( transactionIds.map((id) => this.getTransaction(id)) ); return transactions.filter(Boolean); } catch (error) { custodyLogger.error({ signer, error }, "Error getting signer custody transactions"); return []; } }, // Get all transactions in custody for a market async getMarketTransactions(marketId) { try { if (!marketId) return []; const transactionIds = await getSetMembers("MARKET_TRANSACTIONS", marketId); if (transactionIds.length === 0) { return []; } const transactions = await Promise.all( transactionIds.map((id) => this.getTransaction(id)) ); return transactions.filter(Boolean); } catch (error) { custodyLogger.error({ marketId, error }, "Error getting market custody transactions"); return []; } }, /** * Helper function to get the user-facing status for a transaction * This combines the internal status with blockchain verification data */ getUserFacingStatus(transaction) { if (transaction.blockchainStatus) { return transaction.blockchainStatus; } if (transaction.status === "confirmed") { return "redeemed"; } return "unresolved"; }, /** * Get a specific transaction by ID * @param id The transaction ID * @param options Optional parameters * @param options.verifyBlockchain Whether to verify status against blockchain for submitted transactions * @returns The transaction object */ async getTransaction(id, options = { verifyBlockchain: true }) { try { const transaction = await getEntity("CUSTODY_TRANSACTION", id); if (!transaction) { return transaction; } if (options.verifyBlockchain && transaction.type === "predict" /* PREDICT */ && transaction.status === "submitted") { try { const receiptId = transaction.receiptId || transaction.nonce; const now = (/* @__PURE__ */ new Date()).toISOString(); const onChainStatus = await predictionContractStore.getPredictionStatus(receiptId); console.log(onChainStatus); if (onChainStatus) { transaction.blockchainStatus = onChainStatus; transaction.verifiedAt = now; if (onChainStatus === "won") { const reward = await predictionContractStore.getRewardQuote(receiptId); if (reward) { transaction.potentialPayout = reward.dy; transaction.isVerifiedWinner = true; } } await storeEntity("CUSTODY_TRANSACTION", id, transaction); custodyLogger.debug({ transactionId: id, blockchainStatus: onChainStatus, technicalStatus: transaction.status }, "Updated transaction with blockchain status"); } } catch (error) { custodyLogger.warn({ transactionId: id, error: error instanceof Error ? error.message : String(error) }, "Failed to verify transaction against blockchain"); } } return transaction; } catch (error) { custodyLogger.error({ transactionId: id, error: error instanceof Error ? error.message : String(error) }, "Error getting transaction"); return null; } }, // Find a transaction by signature async findBySignature(signature) { try { if (!signature) return []; const marketsResult = await marketStore.getMarkets(); const markets = marketsResult.items; if (markets.length === 0) { custodyLogger.debug({ signature }, "No markets found to check for transactions"); return []; } const marketTransactionIdsPromises = markets.map( (market) => this.getMarketTransactions(market.id).then( (transactions) => transactions.filter((tx) => tx.signature === signature) ) ); const marketTransactions = await Promise.all(marketTransactionIdsPromises); const matchingTransactions = marketTransactions.flat(); custodyLogger.debug({ signature, marketsCount: markets.length, matchingTransactionsCount: matchingTransactions.length }, "Finished searching for transactions by signature"); return matchingTransactions; } catch (error) { custodyLogger.error({ signature, error }, "Error finding transaction by signature"); return []; } }, // Get a specific NFT receipt async getNFTReceipt(id) { try { if (!id) return void 0; const receipt = await getEntity("CUSTODY_NFT_RECEIPT", id); return receipt || void 0; } catch (error) { custodyLogger.error({ receiptId: id, error }, "Error getting custody NFT receipt"); return void 0; } }, // Get pending predictions for a specific market async getPendingPredictionsForMarket(marketId) { try { if (!marketId) return []; custodyLogger.debug({ marketId }, "Getting pending predictions for market"); const transactionIds = await getSetMembers("MARKET_TRANSACTIONS", marketId.toString()); custodyLogger.debug({ marketId, transactionCount: transactionIds.length }, "Found market transactions"); if (transactionIds.length === 0) { return []; } const transactions = await Promise.all( transactionIds.map((id) => this.getTransaction(id)) ); const validTransactions = transactions.filter(Boolean); custodyLogger.debug({ marketId, validTransactionCount: validTransactions.length, statuses: validTransactions.map((tx) => tx?.status), types: validTransactions.map((tx) => tx?.type) }, "Received valid transactions"); const pendingPredictions = transactions.filter(Boolean).filter((tx) => tx && tx.status === "pending" && tx.type === "predict" /* PREDICT */); custodyLogger.debug({ marketId, pendingCount: pendingPredictions.length, pendingIds: pendingPredictions.map((tx) => tx.id) }, "Found pending predictions for market"); return pendingPredictions; } catch (error) { custodyLogger.error({ marketId, error }, "Error getting pending predictions for market"); return []; } }, // Get all pending predictions across all markets async getAllPendingPredictions() { try { const marketsResult = await marketStore.getMarkets(); const markets = marketsResult.items; custodyLogger.info({ marketCount: markets.length }, "Getting pending predictions - found markets"); if (markets.length === 0) { return []; } const allMarketTransactionsPromises = markets.map((market) => this.getMarketTransactions(market.id)); const allMarketTransactions = await Promise.all(allMarketTransactionsPromises); const allTransactions = allMarketTransactions.flat(); custodyLogger.info({ transactionCount: allTransactions.length, marketCount: markets.length }, "Getting pending predictions - found transactions"); if (allTransactions.length === 0) { return []; } const pendingPredictions = allTransactions.filter((tx) => tx?.status === "pending" && tx.type === "predict" /* PREDICT */); if (pendingPredictions.length > 0) { custodyLogger.info({ pendingCount: pendingPredictions.length, timestamps: pendingPredictions.map((tx) => tx?.takenCustodyAt), now: (/* @__PURE__ */ new Date()).toISOString() }, "Found pending predictions with timestamps"); } else { custodyLogger.info({}, "No pending predictions found"); } return pendingPredictions; } catch (error) { custodyLogger.error({ error }, "Error getting all pending predictions"); return []; } }, // Update transaction status async updateTransactionStatus(id, status, details) { try { const transaction = await this.getTransaction(id); if (!transaction) return void 0; if (status === "confirmed" && transaction.type === "predict" /* PREDICT */) { try { const receiptId = transaction.receiptId || transaction.nonce; const isWinner = await predictionContractStore.isPredictionWinner(receiptId); if (!isWinner) { custodyLogger.warn( { transactionId: id, receiptId }, "Attempt to confirm (redeem) a prediction that is not a winner on the blockchain" ); return { ...transaction, status: "rejected", rejectedAt: (/* @__PURE__ */ new Date()).toISOString(), rejectionReason: "Prediction is not eligible for redemption according to the blockchain." }; } custodyLogger.info( { transactionId: id, receiptId }, "Confirmed prediction is a winner on the blockchain, proceeding with redemption" ); } catch (verificationError) { custodyLogger.error( { transactionId: id, error: verificationError instanceof Error ? verificationError.message : String(verificationError) }, "Error verifying prediction winner status on blockchain" ); } } const now = (/* @__PURE__ */ new Date()).toISOString(); const updatedTransaction = { ...transaction, status }; if (status === "submitted") { updatedTransaction.submittedAt = now; } else if (status === "confirmed") { updatedTransaction.confirmedAt = now; } else if (status === "rejected") { updatedTransaction.rejectedAt = now; if (details?.reason) { updatedTransaction.rejectionReason = details.reason; } } await storeEntity("CUSTODY_TRANSACTION", id, updatedTransaction); return updatedTransaction; } catch (error) { custodyLogger.error({ transactionId: id, error }, "Error updating custody transaction status"); return void 0; } }, // Mark a transaction as submitted to the blockchain async markAsSubmitted(id) { return this.updateTransactionStatus(id, "submitted"); }, // Mark a transaction as confirmed on the blockchain async markAsConfirmed(id) { return this.updateTransactionStatus(id, "confirmed"); }, // Mark a transaction as rejected async markAsRejected(id, reason) { return this.updateTransactionStatus(id, "rejected", { reason }); }, // Delete a transaction and its associated NFT receipt async deleteTransaction(transactionId) { try { if (!transactionId) return false; const transaction = await this.getTransaction(transactionId); if (!transaction) return false; await deleteEntity("CUSTODY_TRANSACTION", transactionId); await removeFromSet("USER_TRANSACTIONS", transaction.userId, transactionId); await removeFromSet("SIGNER_TRANSACTIONS", transaction.signer, transactionId); if (transaction.type === "predict" /* PREDICT */ && transaction.marketId) { await removeFromSet("MARKET_TRANSACTIONS", transaction.marketId.toString(), transactionId); } if (transaction.nftReceipt?.id) { await deleteEntity("CUSTODY_NFT_RECEIPT", transaction.nftReceipt.id); } return true; } catch (error) { custodyLogger.error({ transactionId, error }, "Error deleting custody transaction"); return false; } }, /** * Synchronize submitted prediction statuses with blockchain state * This is a batch operation to update all submitted predictions in custody * This should be called periodically (e.g., by a cron job) */ async syncSubmittedPredictionStatuses() { try { custodyLogger.info({}, "Starting batch synchronization of submitted prediction statuses"); const marketsResult = await marketStore.getMarkets(); const markets = marketsResult.items; if (markets.length === 0) { return { success: true, updated: 0, errors: 0, details: { won: 0, lost: 0, pending: 0, redeemed: 0 } }; } let totalUpdated = 0; let totalErrors = 0; let wonCount = 0; let lostCount = 0; let pendingCount = 0; let redeemedCount = 0; for (const market of markets) { try { custodyLogger.debug({ marketId: market.id }, "Processing market for submitted predictions"); const transactions = await this.getMarketTransactions(market.id); const submittedPredictions = transactions.filter( (tx) => tx.type === "predict" /* PREDICT */ && tx.status === "submitted" ); if (submittedPredictions.length === 0) { continue; } custodyLogger.info({ marketId: market.id, submittedCount: submittedPredictions.length }, "Found submitted predictions for market"); const receiptIds = submittedPredictions.map((tx) => tx.receiptId || tx.nonce); const statusUpdates = await predictionContractStore.getStatusUpdatesForPendingPredictions(receiptIds); console.log({ statusUpdates }); wonCount += statusUpdates.won.length; lostCount += statusUpdates.lost.length; totalErrors += statusUpdates.errors.length; for (const receiptId of statusUpdates.won) { const tx = submittedPredictions.find((t) => (t.receiptId || t.nonce) === receiptId); if (tx) { try { const reward = await predictionContractStore.getRewardQuote(receiptId); const now = (/* @__PURE__ */ new Date()).toISOString(); const updatedTx = { ...tx, blockchainStatus: "won", verifiedAt: now, isVerifiedWinner: true, potentialPayout: reward?.dy || tx.potentialPayout }; await storeEntity("CUSTODY_TRANSACTION", tx.id, updatedTx); totalUpdated++; custodyLogger.debug({ transactionId: tx.id, receiptId, blockchainStatus: "won" }, "Updated transaction to won status based on blockchain"); } catch (error) { custodyLogger.error({ transactionId: tx?.id, receiptId, error: error instanceof Error ? error.message : String(error) }, "Error updating transaction to won status"); totalErrors++; } } } for (const receiptId of statusUpdates.lost) { const tx = submittedPredictions.find((t) => (t.receiptId || t.nonce) === receiptId); if (tx) { try { const now = (/* @__PURE__ */ new Date()).toISOString(); const updatedTx = { ...tx, blockchainStatus: "lost", verifiedAt: now, isVerifiedWinner: false }; await storeEntity("CUSTODY_TRANSACTION", tx.id, updatedTx); totalUpdated++; custodyLogger.debug({ transactionId: tx.id, receiptId, blockchainStatus: "lost" }, "Updated transaction to lost status based on blockchain"); } catch (error) { custodyLogger.error({ transactionId: tx?.id, receiptId, error: error instanceof Error ? error.message : String(error) }, "Error updating transaction to lost status"); totalErrors++; } } } } catch (marketError) { custodyLogger.error({ marketId: market.id, error: marketError instanceof Error ? marketError.message : String(marketError) }, "Error processing market for status updates"); totalErrors++; } } custodyLogger.info({ totalUpdated, totalErrors, won: wonCount, lost: lostCount, pending: pendingCount, redeemed: redeemedCount }, "Completed batch synchronization of submitted prediction statuses"); return { success: true, updated: totalUpdated, errors: totalErrors, details: { won: wonCount, lost: lostCount, pending: pendingCount, redeemed: redeemedCount } }; } catch (error) { custodyLogger.error({ error: error instanceof Error ? error.message : String(error) }, "Error in batch synchronization of submitted prediction statuses"); return { success: false, updated: 0, errors: 1, error: "Failed to synchronize prediction statuses: " + (error instanceof Error ? error.message : String(error)) }; } }, // Generate a placeholder image URL for the NFT // Similar to the prediction store generateNftImage(marketName, outcomeName, amount) { const bgColor = "#1a2026"; const textColor = "#ffffff"; const accentColor = "#36c758"; const sanitizedOutcome = outcomeName.replace(/[<>&"']/g, ""); const sanitizedMarket = marketName.substring(0, 30).replace(/[<>&"']/g, ""); const svg = ` <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> <rect width="600" height="400" fill="${bgColor}" /> <rect x="20" y="20" width="560" height="360" stroke="${accentColor}" stroke-width="2" fill="none" /> <text x="300" y="100" font-family="Arial, sans-serif" font-size="24" text-anchor="middle" fill="${accentColor}">Signet Transaction Receipt</text> <text x="300" y="170" font-family="Arial, sans-serif" font-size="20" text-anchor="middle" fill="${textColor}">${sanitizedOutcome}</text> <text x="300" y="220" font-family="Arial, sans-serif" font-size="16" text-anchor="middle" fill="${textColor}">${sanitizedMarket}</text> <text x="300" y="270" font-family="Arial, sans-serif" font-size="24" text-anchor="middle" fill="${accentColor}">$${amount.toFixed(2)}</text> <text x="300" y="340" font-family="Arial, sans-serif" font-size="12" text-anchor="middle" fill="${textColor}">Fully backed by on-chain transaction</text> </svg>`; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }, /** * Check if a prediction can be returned by the user * This checks if the prediction is still within the return window and hasn't been submitted on-chain * * @param transactionId The ID of the transaction to check * @returns A boolean indicating if the prediction can be returned */ async canReturnPrediction(transactionId) { try { const transaction = await this.getTransaction(transactionId); if (!transaction) { return { canReturn: false, reason: "Transaction not found" }; } if (transaction.type !== "predict" /* PREDICT */) { return { canReturn: false, reason: "Transaction is not a prediction" }; } if (transaction.status !== "pending") { return { canReturn: false, reason: `Transaction status is ${transaction.status}, only pending transactions can be returned` }; } const now = /* @__PURE__ */ new Date(); const custodyDate = new Date(transaction.takenCustodyAt); const ageInMinutes = (now.getTime() - custodyDate.getTime()) / (1e3 * 60); if (ageInMinutes > batchConfig.minAgeMinutes) { return { canReturn: false, reason: `Transaction is ${Math.floor(ageInMinutes)} minutes old, exceeding the ${batchConfig.minAgeMinutes} minute return window` }; } try { const receiptId = transaction.receiptId || transaction.nonce; const receiptExists = await predictionContractStore.doesReceiptExist(receiptId); if (receiptExists) { return { canReturn: false, reason: "Prediction already exists on the blockchain" }; } } catch (error) { custodyLogger.warn( { transactionId, error: error instanceof Error ? error.message : String(error) }, "Error checking on-chain prediction status, continuing with assumption that it is not on chain" ); } return { canReturn: true, transaction }; } catch (error) { custodyLogger.error( { transactionId, error: error instanceof Error ? error.message : String(error) }, "Error checking if prediction can be returned" ); return { canReturn: false, reason: "Error checking prediction status" }; } }, /** * Return a prediction receipt to the user * This will delete all records for the prediction * * @param userId User ID requesting the return * @param transactionId The ID of the transaction to return * @returns Result of the return operation */ async returnPrediction(userId, transactionId) { try { const opLogger = custodyLogger.child({ operation: "returnPrediction", userId, transactionId }); opLogger.info({}, "Starting prediction return process"); const { canReturn, reason, transaction } = await this.canReturnPrediction(transactionId); if (!canReturn || !transaction) { opLogger.warn({ reason }, "Prediction cannot be returned"); return { success: false, error: reason }; } if (transaction.userId !== userId) { const error = "Unauthorized: Only the user who made the prediction can return it"; opLogger.warn({ transactionUserId: transaction.userId }, error); return { success: false, error }; } try { const receiptId = transaction.receiptId || transaction.nonce; const receiptExists = await predictionContractStore.doesReceiptExist(receiptId); if (receiptExists) { const error = "Cannot return prediction: It has already been processed on the blockchain"; opLogger.warn({ transactionId, receiptId }, error); await this.updateTransactionStatus(transactionId, "submitted"); return { success: false, error, transaction: await this.getTransaction(transactionId, { verifyBlockchain: true }) }; } } catch (verificationError) { opLogger.warn({ error: verificationError instanceof Error ? verificationError.message : String(verificationError) }, "Error during final blockchain verification, proceeding with caution"); } opLogger.debug({}, "Deleting transaction records"); const deleteResult = await this.deleteTransaction(transactionId); if (!deleteResult) { const error = "Failed to delete transaction records"; opLogger.error({}, error); return { success: false, error }; } opLogger.info({}, "Prediction successfully returned"); return { success: true, transaction }; } catch (error) { if (error instanceof AppError) { error.log(); return { success: false, error: error.message }; } const appError = new AppError({ message: "Failed to return prediction", context: "custody-store", code: "PREDICTION_RETURN_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { userId, transactionId } }).log(); return { success: false, error: appError.message }; } }, /** * Process pending prediction transactions in batches to send them on-chain * This function is intended to be called by a cron job every hour * It will process up to maxBatchSize predictions at a time, FIFO order * Only processes transactions that are at least minAgeMinutes old by default * @param options Optional parameters to customize batch processing * @returns Results of the batch processing operation */ async batchProcessPredictions(options) { try { if (!batchConfig.enabled) { custodyLogger.info({}, "Batch prediction processing is disabled"); return { success: true, processed: 0, batched: 0, errors: 0 }; } const now = /* @__PURE__ */ new Date(); const cutoffTime = new Date(now.getTime() - batchConfig.minAgeMinutes * 60 * 1e3); const cutoffTimeISO = cutoffTime.toISOString(); custodyLogger.info( { minAgeMinutes: batchConfig.minAgeMinutes, cutoffTime: cutoffTimeISO }, "Starting batch prediction processing" ); let pendingPredictions; if (options?.marketId) { pendingPredictions = await this.getPendingPredictionsForMarket(options.marketId); } else { pendingPredictions = await this.getAllPendingPredictions(); } if (pendingPredictions.length > 0) { custodyLogger.info({ allPendingPredictions: pendingPredictions.map((tx) => ({ id: tx?.id, takenCustodyAt: tx?.takenCustodyAt, status: tx?.status, marketId: tx?.marketId })), cutoffTimeISO, forceProcess: options?.forceProcess }, "Pending predictions before age filtering"); } const shouldApplyAgeFilter = !options?.forceProcess; if (options?.forceProcess) { custodyLogger.info({ forceProcess: true }, "Forcing processing of all pending predictions regardless of age"); } const eligiblePredictions = pendingPredictions.filter((tx) => { if (!shouldApplyAgeFilter) { return true; } const isPastCutoff = tx.takenCustodyAt < cutoffTimeISO; if (!isPastCutoff) { custodyLogger.debug({ transactionId: tx.id, takenCustodyAt: tx.takenCustodyAt, cutoffTimeISO, comparison: `${tx.takenCustodyAt} < ${cutoffTimeISO} = ${isPastCutoff}` }, "Transaction not eligible due to age"); } return isPastCutoff; }).sort((a, b) => a.takenCustodyAt > b.takenCustodyAt ? 1 : -1); const eligibleCount = eligiblePredictions.length; const totalCount = pendingPredictions.length; custodyLogger.info( { totalPending: totalCount, eligibleForProcessing: eligibleCount, maxBatchSize: batchConfig.maxBatchSize }, "Found pending prediction transactions" ); if (eligibleCount === 0) { return { success: true, processed: 0, batched: 0, errors: 0 }; } const transactionsToProcess = eligiblePredictions.slice(0, batchConfig.maxBatchSize); const batchSize = transactionsToProcess.length; custodyLogger.info( { batchSize, oldestTxTime: transactionsToProcess[0]?.takenCustodyAt }, "Processing batch of prediction transactions" ); const operations = transactionsToProcess.map((tx) => ({ signet: { signature: tx.signature, nonce: tx.nonce }, marketId: tx.marketId?.toString() || "", outcomeId: tx.outcomeId || 0, amount: tx.amount || 0 })); const result = await predictionContractStore.batchPredict(operations); if (!result.success || !result.txid) { throw new AppError({ message: result.error || "Failed to process batch prediction transaction", context: "custody-store", code: "BATCH_PREDICT_ERROR", data: { error: result.error } }).log(); } custodyLogger.info( { txid: result.txid, batchSize }, "Successfully submitted batch prediction transaction" ); let updatedCount = 0; for (const tx of transactionsToProcess) { try { await this.updateTransactionStatus(tx.id, "submitted"); updatedCount++; } catch (updateError) { custodyLogger.error( { txId: tx.id, error: updateError }, "Failed to update transaction status to submitted" ); } } return { success: true, processed: eligibleCount, batched: batchSize, errors: batchSize - updatedCount, txid: result.txid }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); custodyLogger.error( { error: errorMessage }, "Error in batch processing predictions" ); if (error instanceof AppError) { return { success: false, processed: 0, batched: 0, errors: 1, error: error.message }; } return { success: false, processed: 0, batched: 0, errors: 1, error: `Failed to process batch predictions: ${errorMessage}` }; } }, // Create a prediction with custody // This combines the transaction custody and prediction functionality async createPredictionWithCustody(data) { try { const opLogger = custodyLogger.child({ operation: "createPredictionWithCustody", marketId: data.marketId, userId: data.userId, amount: data.amount }); opLogger.info({}, "Starting prediction creation with custody"); if (!data.signature || !data.marketId || !data.userId || data.amount <= 0) { const error = new AppError({ message: "Invalid prediction custody data", context: "custody-store", code: "PREDICTION_CUSTODY_VALIDATION_ERROR", data: { hasSignature: !!data.signature, hasMarketId: !!data.marketId, hasUserId: !!data.userId, amount: data.amount } }).log(); return { success: false, error: error.message }; } const market = await marketStore.getMarket(data.marketId); if (!market) { const error = new AppError({ message: `Market not found: ${data.marketId}`, context: "custody-store", code: "MARKET_NOT_FOUND", data: { marketId: data.marketId } }).log(); return { success: false, error: error.message }; } const outcome = market.outcomes.find((o) => o.id === data.outcomeId); if (!outcome) { const error = new AppError({ message: `Outcome ${data.outcomeId} not found in market ${data.marketId}`, context: "custody-store", code: "OUTCOME_NOT_FOUND", data: { marketId: data.marketId, outcomeId: data.outcomeId, availableOutcomes: market.outcomes.map((o) => o.id) } }).log(); return { success: false, error: error.message }; } if (market.resolvedOutcomeId !== void 0) { const error = new AppError({ message: `Market ${data.marketId} is already resolved`, context: "custody-store", code: "MARKET_ALREADY_RESOLVED", data: { marketId: data.marketId, resolvedOutcomeId: market.resolvedOutcomeId } }).log(); return { success: false, error: error.message }; } if (new Date(market.endDate) < /* @__PURE__ */ new Date()) { const error = new AppError({ message: `Market ${data.marketId} has ended`, context: "custody-store", code: "MARKET_ENDED", data: { marketId: data.marketId, endDate: market.endDate, currentDate: (/* @__PURE__ */ new Date()).toISOString() } }).log(); return { success: false, error: error.message }; } opLogger.debug({}, "Market validation completed, taking custody of transaction"); const custodyResult = await this.takeCustody({ signature: data.signature, nonce: data.nonce, signer: data.signer, type: "predict" /* PREDICT */, subnetId: data.subnetId, marketId: data.marketId, outcomeId: data.outcomeId, amount: data.amount, userId: data.userId }); if (!custodyResult.success) { return custodyResult; } opLogger.debug( { transactionId: custodyResult.transaction?.id }, "Custody established, updating user stats" ); try { await userStatsStore.updateStatsForNewPrediction(data.userId, { id: custodyResult.transaction?.id, marketId: data.marketId, outcomeId: data.outcomeId, amount: data.amount }); } catch (statsError) { opLogger.error( { error: statsError }, "Failed to update user stats for custody transaction" ); } opLogger.info( { transactionId: custodyResult.transaction?.id }, "Prediction with custody completed successfully" ); const marketData = { ...market }; return { success: true, transaction: custodyResult.transaction, market: marketData }; } catch (error) { if (error instanceof AppError) { error.log(); return { success: false, error: error.message }; } const appError = new AppError({ message: "Failed to create prediction with custody", context: "custody-store", code: "PREDICTION_CUSTODY_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { marketId: data.marketId, userId: data.userId, amount: data.amount } }).log(); return { success: false, error: appError.message }; } }, /** * Create a claim reward transaction with custody * This function allows users to claim rewards for winning predictions via Signet * * @param data Transaction and claim data * @returns Result with transaction details or error */ async createClaimRewardWithCustody(data) { try { const opLogger = custodyLogger.child({ operation: "createClaimRewardWithCustody", predictionId: data.predictionId, receiptId: data.receiptId, userId: data.userId }); opLogger.info({}, "Starting claim reward process"); if (!data.signature || !data.predictionId || !data.receiptId || !data.userId) { const error = new AppError({ message: "Invalid claim reward data", context: "custody-store", code: "CLAIM_REWARD_VALIDATION_ERROR", data: { hasSignature: !!data.signature, hasPredictionId: !!data.predictionId, hasReceiptId: !!data.receiptId, hasUserId: !!data.userId } }).log(); return { success: false, error: error.message }; } opLogger.debug({ receiptId: data.receiptId }, "Verifying prediction is eligible for claiming"); const isWinner = await predictionContractStore.isPredictionWinner(data.receiptId); if (!isWinner) { const error = new AppError({ message: "Prediction is not eligible for reward claim", context: "custody-store", code: "PREDICTION_NOT_WINNER", data: { predictionId: data.predictionId, receiptId: data.receiptId } }).log(); return { success: false, error: error.message }; } const rewardQuote = await predictionContractStore.getRewardQuote(data.receiptId); if (!rewardQuote || rewardQuote.dy <= 0) { const error = new AppError({ message: "No reward available for this prediction", context: "custody-store", code: "NO_REWARD_AVAILABLE", data: { predictionId: data.predictionId, receiptId: data.receiptId, rewardQuote } }).log(); return { success: false, error: error.message }; } opLogger.debug({ receiptId: data.receiptId, potentialPayout: rewardQuote.dy }, "Prediction verified as winner with available reward"); const id = `claim-${data.signature.substring(0, 10)}-${data.receiptId}`; const now = (/* @__PURE__ */ new Date()).toISOString(); const transaction = { id, signature: data.signature, nonce: data.nonce, signer: data.signer, type: "claim-reward", subnetId: data.subnetId, receiptId: data.receiptId, userId: data.userId, createdAt: now, status: "pending", potentialPayout: rewardQuote.dy, redeemed: true, blockchainStatus: "redeemed" }; console.log(transaction); await storeEntity("CLAIM_REWARD_TRANSACTION", id, transaction); await addToSet("USER_CLAIM_REWARDS", data.userId, id); opLogger.debug( { transactionId: id, receiptId: data.receiptId, potentialPayout: rewardQuote.dy }, "Created claim reward transaction" ); opLogger.info( { transactionId: id }, "Claim reward transaction stored successfully, awaiting batch processing" ); return { success: true, transaction }; } catch (error) { if (error instanceof AppError) { error.log(); return { success: false, error: error.message }; } const appError = new AppError({ message: "Failed to create claim reward transaction", context: "custody-store", code: "CLAIM_REWARD_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { predictionId: data.predictionId, receiptId: data.receiptId, userId: data.userId } }).log(); return { success: false, error: appError.message }; } }, /** * Get all claim reward transactions for a user * * @param userId User ID to get claim reward transactions for * @returns Array of claim reward transactions */ async getUserClaimRewardTransactions(userId) { try { if (!userId) return []; const transactionIds = await getSetMembers("USER_CLAIM_REWARDS", userId); if (transactionIds.length === 0) { return []; } const transactions = await Promise.all( transactionIds.map((id) => getEntity("CLAIM_REWARD_TRANSACTION", id)) ); return transactions.filter(Boolean); } catch (error) { custodyLogger.error({ userId, error }, "Error getting user claim reward transactions"); return []; } }, /** * Get a specific claim reward transaction by ID * * @returns The transaction object */ async getAllClaimRewardTransactions() { try { const transactions = await getEntity("CLAIM_REWARD_TRANSACTION", ""); return transactions; } catch (error) { custodyLogger.error({ error }, "Error getting claim reward transaction"); return []; } }, /** * Get a specific claim reward transaction by ID * * @param id The transaction ID * @returns The transaction object */ async getClaimRewardTransaction(id) { try { if (!id) return null; const transaction = await getEntity("CLAIM_REWARD_TRANSACTION", id); return transaction || null; } catch (error) { custodyLogger.error({ transactionId: id, error }, "Error gettin