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