wisdom-sdk
Version:
Core business logic and data access layer for prediction markets
1,414 lines (1,408 loc) • 106 kB
JavaScript
'use strict';
var kv = require('@vercel/kv');
var transactions = require('@stacks/transactions');
var network = require('@stacks/network');
var blockchainApiClient = require('@stacks/blockchain-api-client');
// 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 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
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
* @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,