UNPKG

wisdom-sdk

Version:

Core business logic and data access layer for prediction markets

1,402 lines (1,399 loc) 64.9 kB
'use strict'; var network = require('@stacks/network'); var blockchainApiClient = require('@stacks/blockchain-api-client'); var transactions = require('@stacks/transactions'); // 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; } }; 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 * @param amount The amount to stake on this prediction * @param senderKey Private key of the sender * @returns Result of the transaction with prediction details */ async makePrediction(marketId, outcomeId, amount, senderKey) { try { contractLogger.info({ marketId, outcomeId, amount }, "Making prediction on-chain"); if (!marketId || outcomeId < 0 || amount <= 0) { throw new AppError({ message: "Invalid prediction data", context: "prediction-contract-store", code: "INVALID_PREDICTION_DATA", data: { marketId, outcomeId, amount } }).log(); } if (!senderKey) { throw new AppError({ message: "No private key provided for transaction", context: "prediction-contract-store", code: "NO_PRIVATE_KEY" }).log(); } const contractCallOptions = { contractAddress: contractConfig.contractAddress, contractName: contractConfig.contractName, functionName: "make-prediction", functionArgs: [ transactions.stringAsciiCV(marketId), transactions.uintCV(outcomeId), transactions.uintCV(amount) ], senderKey, validateWithAbi: true, network: contractConfig.network, postConditionMode: transactions.PostConditionMode.Allow, fee: 500 }; const transaction = await transactions.makeContractCall(contractCallOptions); const result = await broadcastWithFeeAdjustment( transaction, contractCallOptions, { marketId, outcomeId, amount, operation: "makePrediction" } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast prediction transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, marketId, outcomeId, amount }, "Successfully submitted prediction transaction"); return { success: true, txid: result.txid, result: { dx: marketId, dy: Number(amount), dk: 0 // We don't know the actual receipt ID yet } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ marketId, outcomeId, amount, error: errorMessage }, "Error making prediction on blockchain"); if (error instanceof AppError) { return { success: false, error: error.message }; } return { success: false, error: `Failed to make prediction: ${errorMessage}` }; } }, /** * Make a prediction using a signed transaction * @param signet Signature and nonce for the transaction * @param marketId The ID of the market * @param outcomeId The ID of the outcome being predicted * @param amount The amount to stake on this prediction * @param senderKey Private key for sending the transaction (admin key) * @returns Result of the transaction with prediction details */ async signedPredict(signet, marketId, outcomeId, amount, senderKey) { try { contractLogger.info({ marketId, outcomeId, amount, nonce: signet.nonce }, "Making signed prediction on-chain"); if (!marketId || outcomeId < 0 || amount <= 0) { throw new AppError({ message: "Invalid prediction data", context: "prediction-contract-store", code: "INVALID_PREDICTION_DATA", data: { marketId, outcomeId, amount } }).log(); } if (!signet.signature || signet.nonce === void 0) { throw new AppError({ message: "Invalid signet data", context: "prediction-contract-store", code: "INVALID_SIGNET", data: { hasSignature: !!signet.signature, hasNonce: signet.nonce !== void 0 } }).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: "signed-predict", functionArgs: [ transactions.tupleCV({ signature: transactions.bufferCV(Buffer.from(signet.signature, "hex")), nonce: transactions.uintCV(signet.nonce) }), transactions.stringAsciiCV(marketId), transactions.uintCV(outcomeId), transactions.uintCV(amount) ], 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, outcomeId, amount, nonce: signet.nonce, operation: "signedPredict" } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast signed prediction transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, marketId, outcomeId, amount, nonce: signet.nonce }, "Successfully submitted signed prediction transaction"); return { success: true, txid: result.txid, result: { dx: marketId, dy: Number(amount), dk: signet.nonce } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ marketId, outcomeId, amount, nonce: signet.nonce, error: errorMessage }, "Error making signed prediction on blockchain"); if (error instanceof AppError) { return { success: false, error: error.message }; } return { success: false, error: `Failed to make signed prediction: ${errorMessage}` }; } }, /** * Claim a reward for a winning prediction * @param receiptId The ID of the winning prediction receipt * @param senderKey Private key of the receipt owner * @returns Result of the transaction with reward details */ async claimReward(receiptId, senderKey) { try { contractLogger.info({ receiptId }, "Claiming prediction reward on-chain"); if (receiptId <= 0) { throw new AppError({ message: "Invalid receipt ID", context: "prediction-contract-store", code: "INVALID_RECEIPT_ID", data: { receiptId } }).log(); } if (!senderKey) { throw new AppError({ message: "No private key provided for transaction", context: "prediction-contract-store", code: "NO_PRIVATE_KEY" }).log(); } const contractCallOptions = { contractAddress: contractConfig.contractAddress, contractName: contractConfig.contractName, functionName: "claim-reward", functionArgs: [ transactions.uintCV(receiptId) ], senderKey, validateWithAbi: true, network: contractConfig.network, postConditionMode: transactions.PostConditionMode.Allow, fee: 500 }; const transaction = await transactions.makeContractCall(contractCallOptions); const result = await broadcastWithFeeAdjustment( transaction, contractCallOptions, { receiptId, operation: "claimReward" } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast claim reward transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, receiptId }, "Successfully submitted claim reward transaction"); this.cache.clearReceiptInfo(receiptId); return { success: true, txid: result.txid, result: { dx: "", // We don't know the market ID yet dy: 0, // We don't know the reward amount yet dk: receiptId } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); contractLogger.error({ receiptId, error: errorMessage }, "Error claiming reward on blockchain"); if (error instanceof AppError) { return { success: false, error: error.message }; } return { success: false, error: `Failed to claim reward: ${errorMessage}` }; } }, /** * Claim a reward using a signed transaction * @param signet Signature and nonce for the transaction * @param receiptId The ID of the winning prediction receipt * @param senderKey Private key for sending the transaction (admin key) * @returns Result of the transaction with reward details */ async signedClaimReward(signet, receiptId, senderKey) { try { contractLogger.info({ receiptId, nonce: signet.nonce }, "Claiming reward with signed transaction on-chain"); if (receiptId <= 0) { throw new AppError({ message: "Invalid receipt ID", context: "prediction-contract-store", code: "INVALID_RECEIPT_ID", data: { receiptId } }).log(); } if (!signet.signature || signet.nonce === void 0) { throw new AppError({ message: "Invalid signet data", context: "prediction-contract-store", code: "INVALID_SIGNET", data: { hasSignature: !!signet.signature, hasNonce: signet.nonce !== void 0 } }).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: "signed-claim-reward", functionArgs: [ transactions.tupleCV({ signature: transactions.bufferCV(Buffer.from(signet.signature, "hex")), nonce: transactions.uintCV(signet.nonce) }), transactions.uintCV(receiptId) ], 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, { receiptId, nonce: signet.nonce, operation: "signedClaimReward" } ); if (!isBroadcastSuccessful(result)) { throw new AppError({ message: `Failed to broadcast signed claim reward transaction: ${result.error || result.reason || "Unknown error"}`, context: "prediction-contract-store", code: "BROADCAST_ERROR", data: { result } }).log(); } contractLogger.info({ txid: result.txid, receiptId, nonce: signet.nonce }, "Successfully submitted signed claim reward transaction"); this.cache.clearReceiptInfo(receiptId); return { success: true, txid: result.txid,