UNPKG

wisdom-sdk

Version:

Core business logic and data access layer for prediction markets

830 lines (828 loc) 30.8 kB
import { predictionContractStore } from './chunk-5SR5XDZR.js'; import { generateUUID, filterMarkets, sortMarkets, paginateResults } from './chunk-7CBIP22I.js'; import { getSetMembers, addToSet, isSetMember, startTransaction, deleteEntity, removeFromSet, getEntity, storeEntity } from './chunk-FIJAO3BQ.js'; import { logger, AppError } from './chunk-2OHF4QSJ.js'; import { makeContractCall, PostConditionMode, stringAsciiCV, broadcastTransaction, listCV } from '@stacks/transactions'; import { STACKS_MAINNET } from '@stacks/network'; var marketLogger = logger.child({ context: "market-store" }); var onChainConfig = { enabled: process.env.ENABLE_ONCHAIN_MARKETS === "true", privateKey: process.env.MARKET_CREATOR_PRIVATE_KEY || "", network: STACKS_MAINNET, contractAddress: process.env.PREDICTION_CONTRACT_ADDRESS || "SP2ZNGJ85ENDY6QRHQ5P2D4FXKGZWCKTB2T0Z55KS", contractName: process.env.PREDICTION_CONTRACT_NAME || "blaze-welsh-predictions-v1" }; var autoCloseConfig = { enabled: process.env.ENABLE_AUTO_CLOSE_MARKETS !== "false", // Enabled by default batchSize: Number(process.env.AUTO_CLOSE_BATCH_SIZE || "50"), closeOnChain: process.env.AUTO_CLOSE_ON_CHAIN === "true" }; var marketStore = { // Get all markets async getMarkets(options) { let marketIds = []; if (options?.category) { marketIds = await getSetMembers("MARKET_CATEGORY", options.category); } else if (options?.status && options.status !== "all") { marketIds = await getSetMembers("MARKET_STATUS", options.status); } else { marketIds = await getSetMembers("MARKET_IDS", ""); } if (marketIds.length === 0) { return { items: [], total: 0, hasMore: false }; } const limit = options?.limit || 100; const offset = options?.offset || (options?.cursor ? parseInt(options.cursor, 10) : 0); let idsToFetch = marketIds; if (options?.sortBy === "createdAt") { idsToFetch = marketIds.slice(offset, offset + limit * 2); } const markets = await Promise.all( idsToFetch.map((id) => this.getMarket(id)) ); const validMarkets = markets.filter(Boolean); if (validMarkets.length < idsToFetch.length) { marketLogger.warn( { expected: idsToFetch.length, found: validMarkets.length }, "Some markets could not be retrieved" ); } let filteredMarkets = validMarkets; if (options) { const filterOpts = { ...options, category: options.category && idsToFetch === marketIds ? options.category : void 0, status: options.status && idsToFetch === marketIds ? options.status : void 0 }; console.log(filterOpts); filteredMarkets = filterMarkets(validMarkets, filterOpts); const sortedMarkets = sortMarkets( filteredMarkets, options.sortBy || "createdAt", options.sortDirection || "desc" ); return paginateResults(sortedMarkets, { limit: options.limit, offset: options.offset || (options.cursor ? parseInt(options.cursor, 10) : 0) }); } return { items: validMarkets, total: validMarkets.length, hasMore: false }; }, // Get a specific market by ID and verify its state with blockchain async getMarket(id, options) { try { const market = await getEntity("MARKET", id); if (!market) { return void 0; } if (options?.verifyWithBlockchain) { try { const onChainMarket = await this.getMarketInfo(id); if (onChainMarket) { const isOpenOnChain = onChainMarket["is-open"]; const isResolvedOnChain = onChainMarket["is-resolved"]; const winningOutcomeOnChain = Number(onChainMarket["winning-outcome"]); const verifiedMarket = { ...market }; if (isResolvedOnChain && market.status !== "resolved") { marketLogger.info({ marketId: id, localStatus: market.status, blockchainStatus: "resolved" }, "Local market status differs from blockchain state"); verifiedMarket.status = "resolved"; verifiedMarket.resolvedOutcomeId = winningOutcomeOnChain; if (!verifiedMarket.resolvedAt) { verifiedMarket.resolvedAt = (/* @__PURE__ */ new Date()).toISOString(); } if (!verifiedMarket.resolvedBy) { verifiedMarket.resolvedBy = "blockchain-verification"; } } else if (!isOpenOnChain && !isResolvedOnChain && market.status !== "closed") { marketLogger.info({ marketId: id, localStatus: market.status, blockchainStatus: "closed" }, "Local market status differs from blockchain state"); verifiedMarket.status = "closed"; } else if (isOpenOnChain && !isResolvedOnChain && market.status !== "active") { marketLogger.info({ marketId: id, localStatus: market.status, blockchainStatus: "active" }, "Local market status differs from blockchain state"); verifiedMarket.status = "active"; } if (isResolvedOnChain && verifiedMarket.resolvedOutcomeId !== winningOutcomeOnChain) { marketLogger.warn({ marketId: id, localOutcome: verifiedMarket.resolvedOutcomeId, blockchainOutcome: winningOutcomeOnChain }, "Local winning outcome differs from blockchain state"); verifiedMarket.resolvedOutcomeId = winningOutcomeOnChain; } if (JSON.stringify(market) !== JSON.stringify(verifiedMarket)) { marketLogger.info({ marketId: id }, "Updating market in KV store to match blockchain state"); await storeEntity("MARKET", id, verifiedMarket); } return verifiedMarket; } } catch (verificationError) { marketLogger.error({ marketId: id, error: verificationError instanceof Error ? verificationError.message : String(verificationError) }, "Error verifying market with blockchain"); } } return market; } catch (error) { if (error instanceof AppError) { throw error; } else { throw new AppError({ message: `Failed to retrieve market ${id}`, context: "market-store", code: "MARKET_GET_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { marketId: id } }).log(); } } }, /** * Get market information directly from the blockchain * This calls the prediction contract store to get the on-chain market data * @param id Market ID * @returns Market information from blockchain or null if not found */ async getMarketInfo(id) { try { const marketInfo = await predictionContractStore.getMarketInfo(id); return marketInfo; } catch (error) { marketLogger.error( { marketId: id, error: error instanceof Error ? error.message : String(error) }, "Failed to get market info from blockchain" ); return null; } }, /** * Helper function to create a market on-chain * @param marketId Unique ID for the market * @param name Name of the market * @param description Description of the market * @param outcomes List of outcome names * @returns Promise resolving to the broadcast transaction result */ async createMarketOnChain(marketId, name, description, outcomes) { try { if (!onChainConfig.enabled) { marketLogger.info({ marketId }, "On-chain market creation is disabled"); return null; } if (!onChainConfig.privateKey) { throw new Error("Private key is required for on-chain market creation"); } const outcomeNames = outcomes.map((outcome) => outcome.name); const transaction = await makeContractCall({ contractAddress: onChainConfig.contractAddress, contractName: onChainConfig.contractName, functionName: "create-market", functionArgs: [ stringAsciiCV(marketId), stringAsciiCV(name.substring(0, 64)), // Limit to 64 chars for Clarity string-ascii 64 stringAsciiCV(description.substring(0, 128)), // Limit to 128 chars for Clarity string-ascii 128 listCV(outcomeNames.map((name2) => stringAsciiCV(name2.substring(0, 32)))) // Limit each name to 32 chars ], senderKey: onChainConfig.privateKey, validateWithAbi: true, network: onChainConfig.network, postConditionMode: PostConditionMode.Allow, fee: 1e3 // Set appropriate fee }); const result = await broadcastTransaction({ transaction }); marketLogger.info({ marketId, txId: result.txid || "unknown", successful: !!result.txid }, "On-chain market creation transaction broadcast"); return result; } catch (error) { marketLogger.error({ marketId, error: error instanceof Error ? error.message : String(error) }, "Failed to create market on-chain"); return null; } }, // Create a new market async createMarket(data) { try { if (!data.name || !data.description || !data.outcomes || data.outcomes.length === 0) { throw new AppError({ message: "Missing required market data", context: "market-store", code: "MARKET_VALIDATION_ERROR", data: { hasName: !!data.name, hasDescription: !!data.description, outcomeCount: data.outcomes?.length || 0 } }).log(); } const tx = await startTransaction(); const id = generateUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); const market = { id, type: data.type, name: data.name, description: data.description, outcomes: data.outcomes, createdBy: data.createdBy, category: data.category, endDate: data.endDate, imageUrl: data.imageUrl, createdAt: now, participants: 0, poolAmount: 0, status: "active" }; await tx.addEntity("MARKET", id, market); await tx.addToSetInTransaction("MARKET_IDS", "", id); if (data.createdBy) { await tx.addToSetInTransaction("USER_MARKETS", data.createdBy, id); } if (data.category) { await tx.addToSetInTransaction("MARKET_CATEGORY", data.category, id); } await tx.addToSetInTransaction("MARKET_STATUS", "active", id); const success = await tx.execute(); if (!success) { throw new AppError({ message: "Failed to create market - transaction failed", context: "market-store", code: "MARKET_CREATE_TRANSACTION_ERROR", data: { marketId: id } }).log(); } this.createMarketOnChain(id, data.name, data.description, data.outcomes).then((result) => { if (result?.txid) { marketLogger.info({ marketId: id, txId: result.txid }, "Market created on-chain successfully"); } }).catch((error) => { marketLogger.error({ marketId: id, error: error instanceof Error ? error.message : String(error) }, "Error in on-chain market creation"); }); marketLogger.info({ marketId: id }, `Created new market: ${market.name}`); return market; } catch (error) { if (error instanceof AppError) { throw error; } else { throw new AppError({ message: "Failed to create market", context: "market-store", code: "MARKET_CREATE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { marketName: data.name } }).log(); } } }, /** * Resolve a market on-chain with the winning outcome * @param marketId Unique ID for the market * @param winningOutcomeId The ID of the winning outcome * @returns Promise resolving to the broadcast transaction result */ async resolveMarketOnChain(marketId, winningOutcomeId) { try { if (!onChainConfig.enabled) { marketLogger.info({ marketId }, "On-chain market resolution is disabled"); return null; } if (!onChainConfig.privateKey) { throw new Error("Private key is required for on-chain market resolution"); } const { uintCV } = await import('@stacks/transactions'); const transaction = await makeContractCall({ contractAddress: onChainConfig.contractAddress, contractName: onChainConfig.contractName, functionName: "resolve-market", functionArgs: [ stringAsciiCV(marketId), uintCV(winningOutcomeId) ], senderKey: onChainConfig.privateKey, validateWithAbi: true, network: onChainConfig.network, postConditionMode: PostConditionMode.Allow, fee: 1e3 // Set appropriate fee }); const result = await broadcastTransaction({ transaction }); marketLogger.info({ marketId, txId: result.txid || "unknown", successful: !!result?.txid }, "On-chain market resolution transaction broadcast"); return result; } catch (error) { marketLogger.error({ marketId, error: error instanceof Error ? error.message : String(error) }, "Failed to resolve market on-chain"); return null; } }, // Update a market async updateMarket(id, marketData) { try { const market = await this.getMarket(id); if (!market) { marketLogger.warn({ marketId: id }, `Cannot update non-existent market with ID ${id}`); return void 0; } const safeData = { ...marketData }; if (safeData.id && safeData.id !== id) { delete safeData.id; marketLogger.warn( { marketId: id, attemptedId: marketData.id }, "Attempted to change market ID during update - ignoring" ); } const updatedMarket = { ...market, ...safeData }; const tx = await startTransaction(); await tx.addEntity("MARKET", id, updatedMarket); if (marketData.category && marketData.category !== market.category) { await removeFromSet("MARKET_CATEGORY", market.category, id); await tx.addToSetInTransaction("MARKET_CATEGORY", marketData.category, id); } if (marketData.status && marketData.status !== market.status) { await removeFromSet("MARKET_STATUS", market.status, id); await tx.addToSetInTransaction("MARKET_STATUS", marketData.status, id); } await tx.execute(); if (marketData.is_resolved === true && marketData.winning_outcome !== void 0 && (!market.is_resolved || market.is_resolved === false)) { this.resolveMarketOnChain(id, marketData.winning_outcome).then((result) => { if (result?.txid) { marketLogger.info({ marketId: id, txId: result.txid, winningOutcome: marketData.winning_outcome }, "Market resolved on-chain successfully"); } }).catch((error) => { marketLogger.error({ marketId: id, error: error instanceof Error ? error.message : String(error) }, "Error in on-chain market resolution"); }); } marketLogger.debug( { marketId: id }, `Updated market: ${market.name}` ); return updatedMarket; } catch (error) { if (error instanceof AppError) { throw error; } else { throw new AppError({ message: `Failed to update market ${id}`, context: "market-store", code: "MARKET_UPDATE_ERROR", originalError: error instanceof Error ? error : new Error(String(error)), data: { marketId: id } }).log(); } } }, // Delete a market async deleteMarket(id) { try { const market = await this.getMarket(id); if (!market) { return false; } await startTransaction(); await deleteEntity("MARKET", id); await removeFromSet("MARKET_IDS", "", id); if (market.category) { await removeFromSet("MARKET_CATEGORY", market.category, id); } if (market.status) { await removeFromSet("MARKET_STATUS", market.status, id); } if (market.createdBy) { await removeFromSet("USER_MARKETS", market.createdBy, id); } return true; } catch (error) { console.error(`Error deleting market ${id}:`, error); return false; } }, // Update market stats when a prediction is made async updateMarketStats(marketId, outcomeId, amount, userId) { const market = await this.getMarket(marketId); if (!market) return void 0; const userParticipated = await isSetMember("MARKET_PARTICIPANTS", marketId, userId); if (!userParticipated) { market.participants = (market.participants || 0) + 1; await addToSet("MARKET_PARTICIPANTS", marketId, userId); } market.poolAmount = (market.poolAmount || 0) + amount; const outcome = market.outcomes.find((o) => o.id === outcomeId); if (outcome) { outcome.votes = (outcome.votes || 0) + 1; outcome.amount = (outcome.amount || 0) + amount; } return this.updateMarket(marketId, market); }, // Get related markets based on category and similarity async getRelatedMarkets(marketId, limit = 3) { try { const market = await this.getMarket(marketId); if (!market) return []; const result = await this.getMarkets({ status: "active", limit: 50 // Get enough markets to find good related ones }); const allMarkets = result.items; const candidates = allMarkets.filter( (m) => m.id !== marketId && // Same category (m.category === market.category || // Or contains similar keywords in name/description this.calculateSimilarity(m, market) > 0.3) ); const sortedMarkets = candidates.sort( (a, b) => this.calculateSimilarity(b, market) - this.calculateSimilarity(a, market) ); return sortedMarkets.slice(0, limit); } catch (error) { console.error("Error getting related markets:", error); return []; } }, // Get markets by category async getMarketsByCategory(category, options) { return this.getMarkets({ ...options, category }); }, // Search markets by text async searchMarkets(searchText, options) { return this.getMarkets({ ...options, search: searchText }); }, // Get trending markets (highest participation or pool amount) async getTrendingMarkets(limit = 10) { const result = await this.getMarkets({ status: "active", sortBy: "poolAmount", sortDirection: "desc", limit }); return result.items; }, // Calculate similarity score between two markets calculateSimilarity(market1, market2) { const text1 = `${market1.name} ${market1.description}`.toLowerCase(); const text2 = `${market2.name} ${market2.description}`.toLowerCase(); const words1 = new Set(text1.split(/\W+/)); const words2 = new Set(text2.split(/\W+/)); const intersection = new Set(Array.from(words1).filter((x) => words2.has(x))); const union = new Set(Array.from(words1).concat(Array.from(words2))); return intersection.size / union.size; }, // Migration: Build indexes for existing markets async buildMarketIndexes() { try { const marketIds = await getSetMembers("MARKET_IDS", ""); const markets = await Promise.all( marketIds.map((id) => this.getMarket(id)) ); const validMarkets = markets.filter(Boolean); let indexedCount = 0; for (const market of validMarkets) { if (market.category) { await addToSet("MARKET_CATEGORY", market.category, market.id); } if (market.status) { await addToSet("MARKET_STATUS", market.status, market.id); } indexedCount++; } marketLogger.info( { total: marketIds.length, indexed: indexedCount }, "Market indexes built successfully" ); return { success: true, indexed: indexedCount }; } catch (error) { marketLogger.error( { error: error instanceof Error ? error.message : String(error) }, "Error building market indexes" ); return { success: false, indexed: 0 }; } }, /** * Close a market on-chain * @param marketId Unique ID for the market * @returns Promise resolving to the broadcast transaction result */ async closeMarketOnChain(marketId) { try { if (!onChainConfig.enabled) { marketLogger.info({ marketId }, "On-chain market close is disabled"); return null; } if (!onChainConfig.privateKey) { throw new Error("Private key is required for on-chain market close"); } const transaction = await makeContractCall({ contractAddress: onChainConfig.contractAddress, contractName: onChainConfig.contractName, functionName: "close-market", functionArgs: [ stringAsciiCV(marketId) ], senderKey: onChainConfig.privateKey, validateWithAbi: true, network: onChainConfig.network, postConditionMode: PostConditionMode.Allow, fee: 1e3 }); const result = await broadcastTransaction({ transaction }); marketLogger.info({ marketId, txId: result.txid || "unknown", successful: !!result.txid }, "On-chain market close transaction broadcast"); return result; } catch (error) { marketLogger.error({ marketId, error: error instanceof Error ? error.message : String(error) }, "Failed to close market on-chain"); return null; } }, /** * Automatically close markets that have passed their end date * This function is meant to be called by a cron job * @returns Object with stats about markets that were closed */ async autoCloseExpiredMarkets() { try { if (!autoCloseConfig.enabled) { return { success: true, processed: 0, closed: 0, errors: 0 }; } const activeMarkets = await getSetMembers("MARKET_STATUS", "active"); let processed = 0; let closed = 0; let errors = 0; let onChainSucceeded = 0; let onChainFailed = 0; const now = (/* @__PURE__ */ new Date()).toISOString(); for (let i = 0; i < activeMarkets.length; i += autoCloseConfig.batchSize) { const batch = activeMarkets.slice(i, i + autoCloseConfig.batchSize); const markets = await Promise.all( batch.map((id) => this.getMarket(id)) ); const expiredMarkets = markets.filter(Boolean).filter((market) => market.endDate < now && market.status === "active"); for (const market of expiredMarkets) { processed++; try { await this.updateMarket(market.id, { status: "closed", is_open: false }); closed++; if (autoCloseConfig.closeOnChain) { const onChainResult = await this.closeMarketOnChain(market.id); if (onChainResult?.txid) { onChainSucceeded++; marketLogger.info({ marketId: market.id, txId: onChainResult.txid }, "Market closed on-chain successfully"); } else { onChainFailed++; marketLogger.warn({ marketId: market.id }, "Failed to close market on-chain"); } } marketLogger.info({ marketId: market.id, name: market.name, endDate: market.endDate }, "Automatically closed expired market"); } catch (error) { errors++; marketLogger.error({ marketId: market.id, error: error instanceof Error ? error.message : String(error) }, "Error closing expired market"); } } } marketLogger.info({ processed, closed, errors, onChainSucceeded, onChainFailed }, "Completed auto-close of expired markets"); return { success: true, processed, closed, errors, onChainSucceeded, onChainFailed }; } catch (error) { marketLogger.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to auto-close expired markets"); return { success: false, processed: 0, closed: 0, errors: 1 }; } }, /** * Synchronize market statuses with blockchain state * This checks all markets against their on-chain state and updates them if they don't match * @returns Results of the synchronization operation */ async syncMarketsWithBlockchain() { try { marketLogger.info({}, "Starting market synchronization with blockchain"); const marketsResult = await this.getMarkets({ limit: 500 }); const markets = marketsResult.items; if (markets.length === 0) { return { success: true, processed: 0, updated: 0, errors: 0, syncResults: [] }; } let processed = 0; let updated = 0; let errors = 0; const syncResults = []; for (const market of markets) { try { processed++; marketLogger.debug({ marketId: market.id }, `Checking on-chain state for market ${market.name}`); const onChainMarket = await this.getMarketInfo(market.id); if (!onChainMarket) { marketLogger.warn({ marketId: market.id }, `Market ${market.name} not found on blockchain`); syncResults.push({ marketId: market.id, name: market.name, status: "error", error: "Market not found on blockchain" }); errors++; continue; } const isOpenOnChain = onChainMarket["is-open"]; const isResolvedOnChain = onChainMarket["is-resolved"]; const winningOutcomeOnChain = onChainMarket["winning-outcome"]; const isStatusMatch = market.status === "active" && isOpenOnChain && !isResolvedOnChain || market.status === "resolved" && isResolvedOnChain || market.status === "closed" && !isOpenOnChain; const isOutcomeMatch = !isResolvedOnChain || isResolvedOnChain && market.resolvedOutcomeId !== void 0 && market.resolvedOutcomeId === winningOutcomeOnChain; if (isStatusMatch && isOutcomeMatch) { marketLogger.debug({ marketId: market.id }, `Market ${market.name} is already synced with blockchain`); syncResults.push({ marketId: market.id, name: market.name, status: "already_synced", onChainData: { "is-open": isOpenOnChain, "is-resolved": isResolvedOnChain, "winning-outcome": winningOutcomeOnChain }, localData: { status: market.status, resolvedOutcomeId: market.resolvedOutcomeId } }); continue; } const updates = {}; if (!isStatusMatch) { if (isResolvedOnChain) { updates.status = "resolved"; updates.resolvedAt = (/* @__PURE__ */ new Date()).toISOString(); if (!market.resolvedBy) { updates.resolvedBy = "blockchain-sync"; } } else if (!isOpenOnChain) { updates.status = "closed"; } else { updates.status = "active"; } } if (isResolvedOnChain && (market.resolvedOutcomeId === void 0 || market.resolvedOutcomeId !== winningOutcomeOnChain)) { updates.resolvedOutcomeId = winningOutcomeOnChain; if (!updates.status) { updates.status = "resolved"; } if (!updates.resolvedAt) { updates.resolvedAt = (/* @__PURE__ */ new Date()).toISOString(); } if (!market.resolvedBy && !updates.resolvedBy) { updates.resolvedBy = "blockchain-sync"; } } if (Object.keys(updates).length > 0) { marketLogger.info({ marketId: market.id, updates }, `Updating market ${market.name} to match blockchain state`); await this.updateMarket(market.id, updates); updated++; syncResults.push({ marketId: market.id, name: market.name, status: "updated", onChainData: { "is-open": isOpenOnChain, "is-resolved": isResolvedOnChain, "winning-outcome": winningOutcomeOnChain }, localData: { status: market.status, resolvedOutcomeId: market.resolvedOutcomeId } }); } } catch (error) { errors++; marketLogger.error({ marketId: market.id, error: error instanceof Error ? error.message : String(error) }, `Error syncing market ${market.name} with blockchain`); syncResults.push({ marketId: market.id, name: market.name, status: "error", error: error instanceof Error ? error.message : String(error) }); } } marketLogger.info({ processed, updated, errors }, "Completed market synchronization with blockchain"); return { success: true, processed, updated, errors, syncResults }; } catch (error) { marketLogger.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to synchronize markets with blockchain"); return { success: false, processed: 0, updated: 0, errors: 1, syncResults: [] }; } } }; export { marketStore }; //# sourceMappingURL=chunk-IA2KNKYT.js.map //# sourceMappingURL=chunk-IA2KNKYT.js.map