UNPKG

wisdom-sdk

Version:

Core business logic and data access layer for prediction markets

1,423 lines (1,418 loc) 141 kB
'use strict'; var kv = require('@vercel/kv'); var transactions = require('@stacks/transactions'); var network = require('@stacks/network'); var blockchainApiClient = require('@stacks/blockchain-api-client'); var backend = require('@clerk/backend'); // src/kv-store.ts // src/logger.ts var createLogger = () => { const logLevel = process.env.LOG_LEVEL || "info"; const logLevels = { debug: 0, info: 1, warn: 2, error: 3 }; const currentLevelValue = logLevel in logLevels ? logLevels[logLevel] : logLevels.info; const formatLog = (level, obj, msg) => { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); const service = "wisdom-sdk"; const objStr = JSON.stringify(obj); return `[${timestamp}] ${level.toUpperCase()} [${service}] ${msg || ""} ${objStr}`; }; return { debug: (obj, msg) => { if (currentLevelValue <= 0) { console.debug(formatLog("debug", obj, msg)); } }, info: (obj, msg) => { if (currentLevelValue <= 1) { console.info(formatLog("info", obj, msg)); } }, warn: (obj, msg) => { if (currentLevelValue <= 2) { console.warn(formatLog("warn", obj, msg)); } }, error: (obj, msg) => { if (currentLevelValue <= 3) { console.error(formatLog("error", obj, msg)); } }, child: (bindings) => { const childLogger = createLogger(); return { debug: (obj, msg) => childLogger.debug({ ...obj, ...bindings }, msg), info: (obj, msg) => childLogger.info({ ...obj, ...bindings }, msg), warn: (obj, msg) => childLogger.warn({ ...obj, ...bindings }, msg), error: (obj, msg) => childLogger.error({ ...obj, ...bindings }, msg), child: (nestedBindings) => childLogger.child({ ...bindings, ...nestedBindings }) }; } }; }; var logger = createLogger(); function getContextLogger(context) { return logger.child({ context }); } var AppError = class extends Error { constructor({ message, context = "general", code = "INTERNAL_ERROR", originalError, data }) { super(message); this.name = "AppError"; this.context = context; this.code = code; this.originalError = originalError; this.data = data; Error.captureStackTrace(this, this.constructor); } // Logs this error with appropriate context and returns it log() { const contextLogger = getContextLogger(this.context); const logObj = { code: this.code, error: this.message, ...this.originalError && { originalError: this.originalError.message }, ...this.data && { data: this.data } }; contextLogger.error(logObj, this.message); return this; } }; // src/kv-store.ts var kvLogger = logger.child({ context: "kv-store" }); var KV_PREFIXES = { MARKET: "market", MARKET_IDS: "market_ids", USER_MARKETS: "user_markets", MARKET_PARTICIPANTS: "market_participants", MARKET_CATEGORY: "market_category", // Index for markets by category MARKET_STATUS: "market_status", // Index for markets by status PREDICTION: "prediction", USER_PREDICTIONS: "user_predictions", MARKET_PREDICTIONS: "market_predictions", PREDICTION_NFT: "prediction_nft", USER_BALANCE: "user_balance", USER_STATS: "user_stats", LEADERBOARD: "leaderboard", LEADERBOARD_EARNINGS: "leaderboard_earnings", LEADERBOARD_ACCURACY: "leaderboard_accuracy", BUG_REPORT: "bug_report", BUG_REPORT_IDS: "bug_report_ids", USER_BUG_REPORTS: "user_bug_reports", // Transaction custody-related prefixes CUSTODY_TRANSACTION: "custody_transaction", CUSTODY_TRANSACTION_IDS: "custody_transaction_ids", USER_TRANSACTIONS: "user_transactions", SIGNER_TRANSACTIONS: "signer_transactions", MARKET_TRANSACTIONS: "market_transactions", CUSTODY_NFT_RECEIPT: "custody_nft_receipt", // Claim reward transaction prefixes CLAIM_REWARD_TRANSACTION: "claim_reward_transaction", USER_CLAIM_REWARDS: "user_claim_rewards" }; function getKey(entityType, id) { const prefix = KV_PREFIXES[entityType]; if (entityType === "MARKET_IDS" && !id) { return prefix; } return id ? `${prefix}:${id}` : prefix; } async function storeEntity(entityType, id, data) { try { const key = getKey(entityType, id); await kv.kv.set(key, JSON.stringify(data)); return data; } catch (error) { throw new AppError({ message: `Failed to store ${entityType} with ID ${id}`, context: "kv-store", code: "KV_STORE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { entityType, id, operation: "store" } }).log(); } } async function getEntity(entityType, id) { try { const key = getKey(entityType, id); let data = await kv.kv.get(key); if (!data && entityType === "MARKET") { data = await kv.kv.get(`markets:${id}`); } if (!data) { kvLogger.debug({ entityType, id }, `Entity not found: ${entityType}:${id}`); return null; } if (typeof data !== "string") { return data; } try { return JSON.parse(data); } catch (e) { throw new AppError({ message: `Error parsing JSON for ${entityType} with ID ${id}`, context: "kv-store", code: "KV_JSON_PARSE_ERROR", originalError: e instanceof Error ? e : new Error(String(e)), data: { entityType, id, operation: "parse" } }).log(); } } catch (error) { if (error instanceof AppError) { throw error; } throw new AppError({ message: `Failed to retrieve ${entityType} with ID ${id}`, context: "kv-store", code: "KV_RETRIEVE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { entityType, id, operation: "get" } }).log(); } } async function deleteEntity(entityType, id) { try { const key = getKey(entityType, id); await kv.kv.del(key); if (entityType === "MARKET") { await kv.kv.del(`markets:${id}`); } return true; } catch (error) { new AppError({ message: `Failed to delete ${entityType} with ID ${id}`, context: "kv-store", code: "KV_DELETE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { entityType, id, operation: "delete" } }).log(); return false; } } async function addToSet(setType, id, memberId) { try { const key = getKey(setType, id); await kv.kv.sadd(key, memberId); if (setType === "MARKET_IDS") { await kv.kv.sadd("market_ids", memberId); } return true; } catch (error) { new AppError({ message: `Failed to add member ${memberId} to set ${setType}:${id}`, context: "kv-store", code: "KV_SET_ADD_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { setType, id, memberId, operation: "sadd" } }).log(); return false; } } async function removeFromSet(setType, id, memberId) { try { const key = getKey(setType, id); await kv.kv.srem(key, memberId); if (setType === "MARKET_IDS") { await kv.kv.srem("market_ids", memberId); } return true; } catch (error) { new AppError({ message: `Failed to remove member ${memberId} from set ${setType}:${id}`, context: "kv-store", code: "KV_SET_REMOVE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { setType, id, memberId, operation: "srem" } }).log(); return false; } } async function getSetMembers(setType, id) { try { const key = getKey(setType, id); let members = await kv.kv.smembers(key); if (setType === "MARKET_IDS" && members.length === 0) { const legacyMembers = await kv.kv.smembers("market_ids"); if (legacyMembers.length > 0) { for (const marketId of legacyMembers) { await addToSet("MARKET_IDS", "", marketId); } members = legacyMembers; } } return members; } catch (error) { new AppError({ message: `Failed to get members from set ${setType}:${id}`, context: "kv-store", code: "KV_SET_MEMBERS_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { setType, id, operation: "smembers" } }).log(); return []; } } async function isSetMember(setType, id, memberId) { try { const key = getKey(setType, id); const result = await kv.kv.sismember(key, memberId); return !!result; } catch (error) { console.error(`Error checking set membership for ${setType} with ID ${id}:`, error); return false; } } async function addToSortedSet(setType, memberId, score) { try { const key = getKey(setType); await kv.kv.zadd(key, { score, member: memberId }); return true; } catch (error) { console.error(`Error adding to sorted set ${setType}:`, error); return false; } } async function getTopFromSortedSet(setType, limit = 10, reverse = true) { try { const key = getKey(setType); return await kv.kv.zrange(key, 0, limit - 1, { rev: reverse }); } catch (error) { console.error(`Error getting top members from sorted set ${setType}:`, error); return []; } } async function getScoresFromSortedSet(setType, memberIds) { try { const key = getKey(setType); const result = {}; const batchSize = 50; for (let i = 0; i < memberIds.length; i += batchSize) { const batch = memberIds.slice(i, i + batchSize); const batchScores = await Promise.all( batch.map(async (memberId) => { const score = await kv.kv.zscore(key, memberId); return { memberId, score: score ? Number(score) : null }; }) ); batchScores.forEach(({ memberId, score }) => { if (score !== null) { result[memberId] = score; } }); } return result; } catch (error) { console.error(`Error getting scores from sorted set ${setType}:`, error); return {}; } } async function startTransaction() { try { const transaction = kv.kv.multi(); const operations = []; const txObject = { operations, // Add entity to transaction async addEntity(entityType, id, data) { const key = getKey(entityType, id); transaction.set(key, JSON.stringify(data)); operations.push({ type: "entity", entityType, id, data }); }, // Add to set in transaction async addToSetInTransaction(setType, id, memberId) { const key = getKey(setType, id); transaction.sadd(key, memberId); operations.push({ type: "set", entityType: setType, id: memberId }); if (setType === "MARKET_IDS") { transaction.sadd("market_ids", memberId); } }, // Add to sorted set in transaction async addToSortedSetInTransaction(setType, memberId, score) { const key = getKey(setType); transaction.zadd(key, { score, member: memberId }); operations.push({ type: "sortedSet", entityType: setType, id: memberId, data: score }); }, // Execute all queued commands atomically async execute() { try { kvLogger.debug( { operationCount: operations.length }, `Executing transaction with ${operations.length} operations` ); await transaction.exec(); return true; } catch (error) { const appError = new AppError({ message: "Transaction execution failed", context: "kv-store", code: "TRANSACTION_FAILED", originalError: error instanceof Error ? error : new Error(String(error)), data: { operationCount: operations.length } }); appError.log(); return false; } } }; return txObject; } catch (error) { throw new AppError({ message: "Failed to start transaction", context: "kv-store", code: "TRANSACTION_START_ERROR", originalError: error instanceof Error ? error : new Error(String(error)) }).log(); } } // src/utils.ts var ADMIN_USER_IDS = [ "user_2tjVcbojjJk2bkQd856eNE1Ax0S", // rozar "user_2tkBcBEVGanm3LHkg6XK7j91DRj" // kraken ]; function isAdmin(userId) { return ADMIN_USER_IDS.includes(userId); } function generateUUID() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const getRandomBytes = (n) => { const bytes = new Uint8Array(n); if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { crypto.getRandomValues(bytes); } else { for (let i = 0; i < n; i++) { bytes[i] = Math.floor(Math.random() * 256); } } return bytes; }; const randomBytes = getRandomBytes(16); randomBytes[6] = randomBytes[6] & 15 | 64; randomBytes[8] = randomBytes[8] & 63 | 128; let hex = ""; for (let i = 0; i < 16; i++) { hex += randomBytes[i].toString(16).padStart(2, "0"); if (i === 3 || i === 5 || i === 7 || i === 9) { hex += "-"; } } return hex; } function searchMarketText(market, searchText) { if (!searchText) return true; const text = `${market.name} ${market.description}`.toLowerCase(); const terms = searchText.toLowerCase().split(/\s+/).filter(Boolean); return terms.every((term) => text.includes(term)); } function filterMarkets(markets, options = {}) { return markets.filter((market) => { console.log(options.status, market.status); if (options.status && options.status !== "all" && market.status !== options.status) { return false; } if (options.category && market.category !== options.category) { return false; } if (options.type && options.type !== "all" && market.type !== options.type) { return false; } if (options.creatorId && market.createdBy !== options.creatorId) { return false; } if (options.search && !searchMarketText(market, options.search)) { return false; } return true; }); } function sortMarkets(markets, sortBy = "createdAt", sortDirection = "desc") { return [...markets].sort((a, b) => { let comparison = 0; if (sortBy === "createdAt" || sortBy === "endDate") { const dateA = new Date(a[sortBy] || 0).getTime(); const dateB = new Date(b[sortBy] || 0).getTime(); comparison = dateA - dateB; } else { const valA = a[sortBy] || 0; const valB = b[sortBy] || 0; comparison = valA - valB; } return sortDirection === "asc" ? comparison : -comparison; }); } function paginateResults(items, options) { const limit = options.limit || 20; const offset = options.offset || 0; const paginatedItems = items.slice(offset, offset + limit); return { items: paginatedItems, total: items.length, hasMore: offset + paginatedItems.length < items.length, nextCursor: offset + paginatedItems.length < items.length ? `${offset + limit}` : void 0 }; } var API_ENDPOINTS = [ "https://api.hiro.so/", "https://api.mainnet.hiro.so/", "https://stacks-node-api.mainnet.stacks.co/" ]; var contractConfig = { contractAddress: process.env.PREDICTION_CONTRACT_ADDRESS || "SP2ZNGJ85ENDY6QRHQ5P2D4FXKGZWCKTB2T0Z55KS", contractName: process.env.PREDICTION_CONTRACT_NAME || "blaze-welsh-predictions-v1", network: network.STACKS_MAINNET, privateKey: process.env.MARKET_CREATOR_PRIVATE_KEY || "", apiKey: process.env.HIRO_API_KEY || "", apiKeys: process.env.HIRO_API_KEYS ? process.env.HIRO_API_KEYS.split(",") : [], apiKeyRotation: process.env.API_KEY_ROTATION || "loop", // 'loop' or 'random' retryCount: parseInt(process.env.API_RETRY_COUNT || "3", 10), retryDelay: parseInt(process.env.API_RETRY_DELAY || "1000", 10) }; var contractLogger = logger.child({ context: "prediction-contract-store" }); var stacksClients = []; var currentClientIndex = 0; var currentKeyIndex = 0; var CACHE_EXPIRATION = 15 * 1e3; var metadataCache = { marketInfo: /* @__PURE__ */ new Map(), receiptInfo: /* @__PURE__ */ new Map(), rewardQuote: /* @__PURE__ */ new Map(), receiptOwner: /* @__PURE__ */ new Map() }; var isCacheValid = (timestamp) => { return Date.now() - timestamp < CACHE_EXPIRATION; }; var isBroadcastSuccessful = (result) => { if (!result.txid) { return false; } if (result.error || result.reason) { return false; } if (result.tx_status && result.tx_status !== "success" && result.tx_status !== "pending") { return false; } return true; }; var broadcastWithFeeAdjustment = async (transaction, makeContractCallOptions, logContext = {}) => { try { const result = await transactions.broadcastTransaction({ transaction }); if (!isBroadcastSuccessful(result)) { if (result.txid && (result.error || result.reason)) { contractLogger.warn({ ...logContext, txid: result.txid, error: result.error, reason: result.reason, status: result.tx_status }, "Transaction broadcast returned a txid but has error indicators"); } throw new AppError({ message: `Transaction broadcast failed: ${result.reason || result.error || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result, ...logContext, error: result.error, reason: result.reason } }); } return result; } catch (error) { if (error.data.result.reason === "FeeTooLow" && error.data.result.reason_data) { const actualFee = error.data.result.reason_data.actual || 0; const expectedFee = error.data.result.reason_data.expected || 0; const feePadding = 10; contractLogger.warn({ ...logContext, error: "Fee too low", txid: error.txid, actualFee, expectedFee, newFee: expectedFee + feePadding }, "Transaction rejected due to fee too low, retrying with adjusted fee"); const adjustedOptions = { ...makeContractCallOptions, fee: expectedFee + feePadding }; const newTransaction = await transactions.makeContractCall(adjustedOptions); const retryResult = await transactions.broadcastTransaction({ transaction: newTransaction }); if (!isBroadcastSuccessful(retryResult)) { throw new AppError({ message: `Retry transaction broadcast failed: ${retryResult.error || retryResult.reason || "Unknown error"}`, context: "prediction-contract-store", code: "RETRY_BROADCAST_ERROR", data: { retryResult, adjustedFee: expectedFee + feePadding, ...logContext } }).log(); } return retryResult; } throw error; } }; var initClients = () => { if (stacksClients.length > 0) return; for (const endpoint of API_ENDPOINTS) { const client = blockchainApiClient.createClient({ baseUrl: endpoint }); client.use({ onRequest({ request }) { const apiKeys = contractConfig.apiKeys.length ? contractConfig.apiKeys : contractConfig.apiKey ? [contractConfig.apiKey] : []; if (!apiKeys.length) return; const rotationStrategy = contractConfig.apiKeyRotation; let key; if (rotationStrategy === "random") { const randomIndex = Math.floor(Math.random() * apiKeys.length); key = apiKeys[randomIndex]; } else { key = apiKeys[currentKeyIndex]; currentKeyIndex = (currentKeyIndex + 1) % apiKeys.length; } request.headers.set("x-api-key", key); } }); stacksClients.push(client); } contractLogger.info({ endpointCount: API_ENDPOINTS.length, apiKeyCount: contractConfig.apiKeys.length + (contractConfig.apiKey ? 1 : 0) }, "Initialized Stacks API clients"); }; var getNextClient = () => { if (stacksClients.length === 0) { initClients(); } const client = stacksClients[currentClientIndex]; currentClientIndex = (currentClientIndex + 1) % stacksClients.length; return client; }; var getTransactionStatus = async (txid) => { try { const client = getNextClient(); contractLogger.debug({ txid }, "Getting transaction status"); try { const response = await client.GET( "/extended/v1/tx/{tx_id}", { params: { path: { tx_id: txid } } } ); return response.data; } catch (error) { if (error.status === 404) { return { status: "not_found" }; } throw error; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ txid, error: errorMessage }, "Error getting transaction status"); throw new AppError({ message: `Failed to get transaction status for ${txid}`, context: "prediction-contract-store", code: "TX_STATUS_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { txid } }).log(); } }; var enhancedReadOnlyCall = async (contractAddress, contractName, functionName, functionArgs = [], senderAddress) => { const retryCount = contractConfig.retryCount; const retryDelay = contractConfig.retryDelay; let lastError; for (let attempt = 0; attempt < retryCount; attempt++) { try { const client = getNextClient(); contractLogger.debug({ contractId: `${contractAddress}.${contractName}`, function: functionName, attempt: attempt + 1 }, "Calling read-only function"); const args = functionArgs.map((arg) => transactions.cvToHex(arg)); const response = await client.POST( `/v2/contracts/call-read/${contractAddress}/${contractName}/${functionName}`, { body: { sender: senderAddress || contractAddress, arguments: args } } ); if (!response?.data?.result) { throw new Error(`No result from contract call ${functionName}`); } const result = transactions.cvToValue(transactions.hexToCV(response.data.result)); return result; } catch (error) { lastError = error; const errorMessage2 = error instanceof Error ? error.message : String(error); contractLogger.warn({ contractId: `${contractAddress}.${contractName}`, function: functionName, attempt: attempt + 1, maxAttempts: retryCount, error: errorMessage2 }, "Read-only function call failed, retrying"); if (attempt < retryCount - 1) { await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt))); } } } const errorMessage = lastError instanceof Error ? lastError.message : String(lastError); contractLogger.error({ contractId: `${contractAddress}.${contractName}`, function: functionName, attempts: retryCount, error: errorMessage }, "All read-only function call attempts failed"); throw new AppError({ message: `Failed to call read-only function ${functionName} after ${retryCount} attempts`, context: "prediction-contract-store", code: "READ_ONLY_CALL_FAILED", originalError: lastError instanceof Error ? lastError : new Error(errorMessage), data: { contractAddress, contractName, functionName } }).log(); }; var predictionContractStore = { /** * Utility functions for managing the metadata cache */ cache: { /** * Clear all metadata caches */ clearAll() { metadataCache.marketInfo.clear(); metadataCache.receiptInfo.clear(); metadataCache.rewardQuote.clear(); metadataCache.receiptOwner.clear(); contractLogger.info({ message: "Cleared all metadata caches" }); }, /** * Clear market info cache for a specific market * @param marketId The ID of the market to clear from cache */ clearMarketInfo(marketId) { metadataCache.marketInfo.delete(marketId); contractLogger.debug({ marketId }, "Cleared market info from cache"); }, /** * Clear receipt info cache for a specific receipt * @param receiptId The ID of the receipt to clear from cache */ clearReceiptInfo(receiptId) { metadataCache.receiptInfo.delete(receiptId); metadataCache.receiptOwner.delete(receiptId); metadataCache.rewardQuote.delete(receiptId); contractLogger.debug({ receiptId }, "Cleared receipt data from cache"); }, /** * Get cache statistics * @returns Object with cache counts */ getStats() { return { marketInfoCount: metadataCache.marketInfo.size, receiptInfoCount: metadataCache.receiptInfo.size, rewardQuoteCount: metadataCache.rewardQuote.size, receiptOwnerCount: metadataCache.receiptOwner.size, cacheExpirationMs: CACHE_EXPIRATION }; } }, /** * Check if a receipt ID exists on the blockchain by checking if it has an owner * @param receiptId The ID of the receipt to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns True if the receipt exists and has an owner, false otherwise */ async doesReceiptExist(receiptId, skipCache = false) { try { const owner = await this.getReceiptOwner(receiptId, skipCache); return owner !== null; } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error checking if receipt exists"); return false; } }, /** * Get the owner of a receipt from the contract * @param receiptId The ID of the receipt to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns The principal address of the owner, or null if not found or error */ async getReceiptOwner(receiptId, skipCache = false) { try { if (!skipCache) { const cached = metadataCache.receiptOwner.get(receiptId); if (cached && isCacheValid(cached.timestamp)) { contractLogger.debug({ receiptId, fromCache: true }, "Got receipt owner from cache"); return cached.data; } } contractLogger.debug({ receiptId }, "Getting receipt owner from chain"); const result = await enhancedReadOnlyCall( contractConfig.contractAddress, contractConfig.contractName, "get-owner", [transactions.uintCV(receiptId)] ); let owner = null; if (result && result.value && result.value.type !== "none") { owner = result.value.value; contractLogger.debug({ receiptId, owner }, "Found receipt owner"); } else { contractLogger.debug({ receiptId }, "No owner found for receipt"); } metadataCache.receiptOwner.set(receiptId, { data: owner, timestamp: Date.now() }); return owner; } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error getting receipt owner"); return null; } }, /** * Get information about a specific market from the contract * @param marketId The ID of the market to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns Market information or null if not found or error */ async getMarketInfo(marketId, skipCache = false) { try { if (!skipCache) { const cached = metadataCache.marketInfo.get(marketId); if (cached && isCacheValid(cached.timestamp)) { contractLogger.debug({ marketId, fromCache: true }, "Got market info from cache"); return cached.data; } } contractLogger.debug({ marketId }, "Getting market info from chain"); const result = await enhancedReadOnlyCall( contractConfig.contractAddress, contractConfig.contractName, "get-market-info", [transactions.stringAsciiCV(marketId)] ); let marketInfo = null; if (result) { marketInfo = { creator: result.value.creator.value, name: result.value.name.value, description: result.value.description.value, "outcome-names": result.value["outcome-names"].value, "outcome-pools": result.value["outcome-pools"].value, "total-pool": Number(result.value["total-pool"].value), "is-open": result.value["is-open"].value, "is-resolved": result.value["is-resolved"].value, "winning-outcome": Number(result.value["winning-outcome"].value), resolver: result.value.resolver.value, "creation-time": result.value["creation-time"].value, "resolution-time": result.value["resolution-time"].value }; contractLogger.debug({ marketId, name: marketInfo.name, isResolved: marketInfo["is-resolved"] }, "Found market info"); } else { contractLogger.debug({ marketId }, "Market not found on chain"); } metadataCache.marketInfo.set(marketId, { data: marketInfo, timestamp: Date.now() }); return marketInfo; } catch (error) { contractLogger.error({ marketId, error: error instanceof Error ? error.message : String(error) }, "Error getting market info"); return null; } }, /** * Get information about a specific prediction receipt from the contract * @param receiptId The ID of the receipt to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns Receipt information or null if not found or error */ async getReceiptInfo(receiptId, skipCache = false) { try { if (!skipCache) { const cached = metadataCache.receiptInfo.get(receiptId); if (cached && isCacheValid(cached.timestamp)) { contractLogger.debug({ receiptId, fromCache: true }, "Got receipt info from cache"); return cached.data; } } contractLogger.debug({ receiptId }, "Getting receipt info from chain"); const result = await enhancedReadOnlyCall( contractConfig.contractAddress, contractConfig.contractName, "get-receipt-info", [transactions.uintCV(receiptId)] ); let receiptInfo = null; if (result.success) { receiptInfo = { "market-id": result.value["market-id"].value, "outcome-id": result.value["outcome-id"].value, amount: result.value.amount.value, predictor: result.value.predictor.value }; contractLogger.debug({ receiptId, marketId: receiptInfo["market-id"], outcomeId: receiptInfo["outcome-id"], predictor: receiptInfo.predictor }, "Found receipt info"); } else { contractLogger.debug({ receiptId }, "Receipt not found on chain"); } metadataCache.receiptInfo.set(receiptId, { data: receiptInfo, timestamp: Date.now() }); return receiptInfo; } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error getting receipt info"); return null; } }, /** * Check if a prediction is eligible for reward based on the resolved market * This calls the quote-reward function to see if there's any reward available * * @param receiptId The ID of the receipt to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns Object with reward info or null if not found or error */ async getRewardQuote(receiptId, skipCache = false) { try { if (!skipCache) { const cached = metadataCache.rewardQuote.get(receiptId); if (cached && isCacheValid(cached.timestamp)) { contractLogger.debug({ receiptId, fromCache: true }, "Got reward quote from cache"); return cached.data; } } contractLogger.debug({ receiptId }, "Getting reward quote from chain"); const result = await enhancedReadOnlyCall( contractConfig.contractAddress, contractConfig.contractName, "quote-reward", [transactions.uintCV(receiptId)] ); let quote = null; if (result) { quote = { dx: result.value.dx.value, dy: Number(result.value.dy.value), dk: Number(result.value.dk.value) }; contractLogger.debug({ receiptId, marketId: quote.dx, reward: quote.dy }, "Got reward quote"); } else { contractLogger.debug({ receiptId }, "Failed to get reward quote"); } metadataCache.rewardQuote.set(receiptId, { data: quote, timestamp: Date.now() }); return quote; } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error getting reward quote"); return null; } }, /** * Check if a prediction has won in a resolved market * * @param receiptId The ID of the receipt to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns Boolean indicating if the prediction is a winner (with reward > 0) */ async isPredictionWinner(receiptId, skipCache = false) { try { const quote = await this.getRewardQuote(receiptId, skipCache); if (quote && quote.dy > 0) { contractLogger.debug({ receiptId, reward: quote.dy }, "Prediction is a winner"); return true; } contractLogger.debug({ receiptId }, "Prediction is not a winner"); return false; } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error checking if prediction is a winner"); return false; } }, /** * Get the status of a prediction based on market resolution and outcome * This combines multiple contract calls to determine the full status * * @param receiptId The ID of the receipt to check * @param skipCache Whether to skip the cache and force a fresh lookup * @returns 'unresolved' | 'won' | 'lost' | 'redeemed' | null (if error or not found) */ async getPredictionStatus(receiptId, skipCache = false) { try { contractLogger.debug({ receiptId, skipCache }, "Determining prediction status from chain"); const owner = await this.getReceiptOwner(receiptId, skipCache); if (!owner) { const receiptInfo2 = await this.getReceiptInfo(receiptId, skipCache); if (receiptInfo2) { contractLogger.debug({ receiptId }, "Prediction has been redeemed (NFT burned)"); return "redeemed"; } else { contractLogger.debug({ receiptId }, "Prediction not found on chain"); return null; } } const receiptInfo = await this.getReceiptInfo(receiptId, skipCache); if (!receiptInfo) { contractLogger.debug({ receiptId }, "Receipt info not found even though owner exists"); return null; } const marketInfo = await this.getMarketInfo(receiptInfo["market-id"], skipCache); if (!marketInfo) { contractLogger.debug({ receiptId, marketId: receiptInfo["market-id"] }, "Market not found"); return null; } if (!marketInfo["is-resolved"]) { contractLogger.debug({ receiptId, marketId: receiptInfo["market-id"] }, "Market not resolved, prediction is unresolved"); return "unresolved"; } const isWinner = await this.isPredictionWinner(receiptId, skipCache); if (isWinner) { return "won"; } else { return "lost"; } } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error determining prediction status"); return null; } }, /** * Get predictions that should be marked as won or lost based on market resolution * This checks for receipts with 'pending' status in custody but that should be 'won' or 'lost' * based on the blockchain state * * @param pendingIds List of receipt IDs that are currently 'pending' in custody * @param skipCache Whether to skip the cache and force a fresh lookup for all predictions * @returns Object containing arrays of 'won' and 'lost' IDs */ async getStatusUpdatesForPendingPredictions(pendingIds, skipCache = false) { try { contractLogger.info({ pendingCount: pendingIds.length, skipCache }, "Getting status updates for pending predictions"); const results = { won: [], lost: [], errors: [] }; const batchSize = 10; for (let i = 0; i < pendingIds.length; i += batchSize) { const batch = pendingIds.slice(i, i + batchSize); const statusPromises = batch.map(async (receiptId) => { try { const status = await this.getPredictionStatus(receiptId, skipCache); return { receiptId, status }; } catch (error) { contractLogger.error({ receiptId, error: error instanceof Error ? error.message : String(error) }, "Error getting prediction status in batch"); return { receiptId, status: "error" }; } }); const statuses = await Promise.all(statusPromises); for (const { receiptId, status } of statuses) { if (status === "won") { results.won.push(receiptId); } else if (status === "lost") { results.lost.push(receiptId); } else if (status === "error" || status === null) { results.errors.push(receiptId); } } if (i + batchSize < pendingIds.length) { await new Promise((resolve) => setTimeout(resolve, 100)); } } contractLogger.info({ pendingCount: pendingIds.length, wonCount: results.won.length, lostCount: results.lost.length, errorCount: results.errors.length, cacheUsed: !skipCache }, "Finished getting status updates for pending predictions"); return results; } catch (error) { contractLogger.error({ error: error instanceof Error ? error.message : String(error) }, "Error getting status updates for pending predictions"); throw new AppError({ message: "Failed to get status updates for pending predictions", context: "prediction-contract-store", code: "STATUS_UPDATE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { pendingCount: pendingIds.length, skipCache } }).log(); } }, /** * Create a new prediction market on the blockchain * @param marketId Unique identifier for the market (max 64 ASCII chars) * @param name Name of the market (max 64 ASCII chars) * @param description Description of the market (max 128 ASCII chars) * @param outcomeNames List of possible outcome names (max 16 outcomes, each max 32 chars) * @param senderKey Private key of the sender (defaults to contract config) * @returns Result of the transaction with market creation details */ async createMarket(marketId, name, description, outcomeNames, senderKey) { try { contractLogger.info({ marketId, name, descriptionLength: description.length, outcomeCount: outcomeNames.length }, "Creating new prediction market on-chain"); if (!marketId || !name || !description || !outcomeNames.length) { throw new AppError({ message: "Invalid market data", context: "prediction-contract-store", code: "INVALID_MARKET_DATA", data: { marketId, name, descriptionLength: description.length, outcomeCount: outcomeNames.length } }).log(); } if (marketId.length > 64) { throw new AppError({ message: "Market ID exceeds maximum length of 64 ASCII characters", context: "prediction-contract-store", code: "INVALID_MARKET_ID_LENGTH", data: { marketId, length: marketId.length } }).log(); } if (name.length > 64) { throw new AppError({ message: "Market name exceeds maximum length of 64 ASCII characters", context: "prediction-contract-store", code: "INVALID_MARKET_NAME_LENGTH", data: { name, length: name.length } }).log(); } if (description.length > 128) { throw new AppError({ message: "Market description exceeds maximum length of 128 ASCII characters", context: "prediction-contract-store", code: "INVALID_MARKET_DESCRIPTION_LENGTH", data: { descriptionLength: description.length } }).log(); } if (outcomeNames.length > 16) { throw new AppError({ message: "Too many outcomes, maximum is 16", context: "prediction-contract-store", code: "TOO_MANY_OUTCOMES", data: { outcomeCount: outcomeNames.length } }).log(); } const longOutcomes = outcomeNames.filter((name2) => name2.length > 32); if (longOutcomes.length > 0) { throw new AppError({ message: "One or more outcome names exceed maximum length of 32 ASCII characters", context: "prediction-contract-store", code: "INVALID_OUTCOME_NAME_LENGTH", data: { longOutcomes: longOutcomes.map((name2) => ({ name: name2, length: name2.length })) } }).log(); } const key = senderKey || contractConfig.privateKey; if (!key) { throw new AppError({ message: "No private key available for transaction", context: "prediction-contract-store", code: "NO_PRIVATE_KEY" }).log(); } const contractCallOptions = { contractAddress: contractConfig.contractAddress, contractName: contractConfig.contractName, functionName: "create-market", functionArgs: [ transactions.stringAsciiCV(marketId), transactions.stringAsciiCV(name), transactions.stringAsciiCV(description), transactions.listCV(outcomeNames.map((name2) => transactions.stringAsciiCV(name2))) ], senderKey: key, validateWithAbi: true, network: contractConfig.network, postConditionMode: transactions.PostConditionMode.Allow, fee: 500 }; const transaction = await transactions.makeContractCall(contractCallOptions); const result = await broadcastWithFeeAdjustment( transaction, contractCallOptions, { marketId, name } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast market creation transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, marketId, name }, "Successfully submitted market creation transaction"); this.cache.clearMarketInfo(marketId); return { success: true, txid: result.txid, result: { marketId, creator: transaction.auth.spendingCondition.signer, creationTime: Math.floor(Date.now() / 1e3) } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ marketId, error: errorMessage }, "Error creating market on blockchain"); if (error instanceof AppError) { return { success: false, error: error.message }; } return { success: false, error: `Failed to create market: ${errorMessage}` }; } }, /** * Close a market (no more predictions allowed) * @param marketId The ID of the market to close * @param senderKey Private key of the sender (must be admin or deployer) * @returns Result of the transaction */ async closeMarket(marketId, senderKey) { try { contractLogger.info({ marketId }, "Closing market on-chain"); if (!marketId) { throw new AppError({ message: "Invalid market ID", context: "prediction-contract-store", code: "INVALID_MARKET_ID", data: { marketId } }).log(); } const key = senderKey || contractConfig.privateKey; if (!key) { throw new AppError({ message: "No private key available for transaction", context: "prediction-contract-store", code: "NO_PRIVATE_KEY" }).log(); } const contractCallOptions = { contractAddress: contractConfig.contractAddress, contractName: contractConfig.contractName, functionName: "close-market", functionArgs: [ transactions.stringAsciiCV(marketId) ], senderKey: key, validateWithAbi: true, network: contractConfig.network, postConditionMode: transactions.PostConditionMode.Allow, fee: 500 }; const transaction = await transactions.makeContractCall(contractCallOptions); const result = await broadcastWithFeeAdjustment( transaction, contractCallOptions, { marketId, operation: "closeMarket" } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast market close transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, marketId }, "Successfully submitted market close transaction"); this.cache.clearMarketInfo(marketId); return { success: true, txid: result.txid }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ marketId, error: errorMessage }, "Error closing market on blockchain"); if (error instanceof AppError) { return { success: false, error: error.message }; } return { success: false, error: `Failed to close market: ${errorMessage}` }; } }, /** * Resolve a market by setting the winning outcome * @param marketId The ID of the market to resolve * @param winningOutcomeId The ID of the winning outcome * @param senderKey Private key of the sender (must be admin or deployer) * @returns Result of the transaction */ async resolveMarket(marketId, winningOutcomeId, senderKey) { try { contractLogger.info({ marketId, winningOutcomeId }, "Resolving market on-chain"); if (!marketId) { throw new AppError({ message: "Invalid market ID", context: "prediction-contract-store", code: "INVALID_MARKET_ID", data: { marketId } }).log(); } if (winningOutcomeId < 0) { throw new AppError({ message: "Invalid winning outcome ID", context: "prediction-contract-store", code: "INVALID_OUTCOME_ID", data: { winningOutcomeId } }).log(); } const key = senderKey || contractConfig.privateKey; if (!key) { throw new AppError({ message: "No private key available for transaction", context: "prediction-contract-store", code: "NO_PRIVATE_KEY" }).log(); } const contractCallOptions = { contractAddress: contractConfig.contractAddress, contractName: contractConfig.contractName, functionName: "resolve-market", functionArgs: [ transactions.stringAsciiCV(marketId), transactions.uintCV(winningOutcomeId) ], senderKey: key, validateWithAbi: true, network: contractConfig.network, postConditionMode: transactions.PostConditionMode.Allow, fee: 500 }; const transaction = await transactions.makeContractCall(contractCallOptions); const result = await broadcastWithFeeAdjustment( transaction, contractCallOptions, { marketId, winningOutcomeId, operation: "resolveMarket" } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast market resolution transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, marketId, winningOutcomeId }, "Successfully submitted market resolution transaction"); this.cache.clearMarketInfo(marketId); return { success: true, txid: result.txid }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ marketId, winningOutcomeId, error: errorMessage }, "Error resolving market on blockchain"); if (error instanceof AppError) { return { success: false, error: error.message }; } return { success: false, error: `Failed to resolve market: ${errorMessage}` }; } }, /** * Make a prediction on a market outcome (direct transaction) * @param marketId The ID of the market * @param outcomeId The ID of the outcome being predicted * @