wisdom-sdk
Version:
Core business logic and data access layer for prediction markets
1,294 lines (1,293 loc) • 56 kB
JavaScript
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