diff --git a/backend/api/knowledge.md b/backend/api/knowledge.md index 932ab5c3b6..5e13991287 100644 --- a/backend/api/knowledge.md +++ b/backend/api/knowledge.md @@ -9,6 +9,15 @@ This directory contains the implementation of various API endpoints for the Mani - We use Supabase for database operations. - Authentication is handled using the `APIHandler` type, which automatically manages user authentication based on the schema definition. +## Mana/Sweepstakes Market Relationships + +- Mana markets can have sweepstakes counterpart markets (siblingContractId) +- The mana market is the source of truth - changes to mana markets should propagate to their sweepstakes counterparts +- Changes that need to propagate include: + - Adding new answers to multiple choice markets + - Market metadata updates + - Market resolution + ## Adding a New API Endpoint To add a new API endpoint, follow these steps: diff --git a/backend/api/src/create-answer-cpmm.ts b/backend/api/src/create-answer-cpmm.ts index 16cd872179..92c64c9cc4 100644 --- a/backend/api/src/create-answer-cpmm.ts +++ b/backend/api/src/create-answer-cpmm.ts @@ -1,5 +1,5 @@ import { groupBy, partition, sumBy } from 'lodash' -import { CPMMMultiContract, add_answers_mode } from 'common/contract' +import { CPMMMultiContract } from 'common/contract' import { User } from 'common/user' import { getBetDownToOneMultiBetInfo } from 'common/new-bet' import { Answer, getMaximumAnswers } from 'common/answer' @@ -53,37 +53,36 @@ export const createAnswerCPMM: APIHandler<'market/:contractId/answer'> = async ( ) => { const { contractId, text } = props return await betsQueue.enqueueFn( - () => createAnswerCpmmMain(contractId, text, auth.uid), + () => createAnswerCpmmFull(contractId, text, auth.uid), [contractId, auth.uid] ) } - -export const createAnswerCpmmMain = async ( +const createAnswerCpmmFull = async ( contractId: string, text: string, - creatorId: string, - options: { - overrideAddAnswersMode?: add_answers_mode - specialLiquidityPerAnswer?: number - loverUserId?: string - } = {} + userId: string ) => { - const { overrideAddAnswersMode, specialLiquidityPerAnswer, loverUserId } = - options log('Received ' + contractId + ' ' + text) + const contract = await verifyContract(contractId, userId) + return await createAnswerCpmmMain(contract, text, userId) +} +const verifyContract = async (contractId: string, creatorId: string) => { const contract = await getContractSupabase(contractId) if (!contract) throw new APIError(404, 'Contract not found') + if (contract.token !== 'MANA') { + throw new APIError(403, 'Cannot add answers to sweepstakes question') + } if (contract.mechanism !== 'cpmm-multi-1') throw new APIError(403, 'Requires a cpmm multiple choice contract') if (contract.outcomeType === 'NUMBER') throw new APIError(403, 'Cannot create new answers for numeric contracts') - const { closeTime, shouldAnswersSumToOne } = contract + const { closeTime } = contract if (closeTime && Date.now() > closeTime) throw new APIError(403, 'Trading is closed') - const addAnswersMode = overrideAddAnswersMode ?? contract.addAnswersMode + const addAnswersMode = contract.addAnswersMode if (!addAnswersMode || addAnswersMode === 'DISABLED') { throw new APIError(400, 'Adding answers is disabled') @@ -97,6 +96,16 @@ export const createAnswerCpmmMain = async ( throw new APIError(403, 'Only the creator or an admin can create an answer') } + return contract +} + +const createAnswerCpmmMain = async ( + contract: Awaited>, + text: string, + creatorId: string +) => { + const { shouldAnswersSumToOne } = contract + const answerCost = getTieredAnswerCost( getTierFromLiquidity(contract, contract.totalLiquidity) ) @@ -107,17 +116,15 @@ export const createAnswerCpmmMain = async ( if (!user) throw new APIError(401, 'Your account was not found') if (user.isBannedFromPosting) throw new APIError(403, 'You are banned') - if (user.balance < answerCost && !specialLiquidityPerAnswer) + if (user.balance < answerCost) throw new APIError(403, 'Insufficient balance, need M' + answerCost) - if (!specialLiquidityPerAnswer) { - await incrementBalance(pgTrans, user.id, { - balance: -answerCost, - totalDeposits: -answerCost, - }) - } + await incrementBalance(pgTrans, user.id, { + balance: -answerCost, + totalDeposits: -answerCost, + }) - const answers = await getAnswersForContract(pgTrans, contractId) + const answers = await getAnswersForContract(pgTrans, contract.id) const unresolvedAnswers = answers.filter((a) => !a.resolution) const maxAnswers = getMaximumAnswers(shouldAnswersSumToOne) if (unresolvedAnswers.length >= maxAnswers) { @@ -127,22 +134,10 @@ export const createAnswerCpmmMain = async ( ) } - let poolYes = answerCost - let poolNo = answerCost - let totalLiquidity = answerCost - let prob = 0.5 - - if (specialLiquidityPerAnswer) { - if (shouldAnswersSumToOne) - throw new APIError( - 500, - "Can't specify specialLiquidityPerAnswer and shouldAnswersSumToOne" - ) - prob = 0.02 - poolYes = specialLiquidityPerAnswer - poolNo = specialLiquidityPerAnswer / (1 / prob - 1) - totalLiquidity = specialLiquidityPerAnswer - } + const poolYes = answerCost + const poolNo = answerCost + const totalLiquidity = answerCost + const prob = 0.5 const id = randomString() const n = answers.length @@ -150,7 +145,7 @@ export const createAnswerCpmmMain = async ( const newAnswer: Answer = removeUndefinedProps({ id, index: n, - contractId, + contractId: contract.id, createdTime, userId: user.id, text, @@ -161,7 +156,6 @@ export const createAnswerCpmmMain = async ( totalLiquidity, subsidyPool: 0, probChanges: { day: 0, week: 0, month: 0 }, - loverUserId, }) const updatedAnswers: Answer[] = [] @@ -180,21 +174,19 @@ export const createAnswerCpmmMain = async ( await insertAnswer(pgTrans, newAnswer) } - if (!specialLiquidityPerAnswer) { - await updateContract(pgTrans, contractId, { - totalLiquidity: FieldVal.increment(answerCost), - }) + await updateContract(pgTrans, contract.id, { + totalLiquidity: FieldVal.increment(answerCost), + }) - const lp = getCpmmInitialLiquidity( - user.id, - contract, - answerCost, - createdTime, - newAnswer.id - ) + const lp = getCpmmInitialLiquidity( + user.id, + contract, + answerCost, + createdTime, + newAnswer.id + ) - await insertLiquidity(pgTrans, lp) - } + await insertLiquidity(pgTrans, lp) return { newAnswer, updatedAnswers, user } } @@ -208,7 +200,7 @@ export const createAnswerCpmmMain = async ( contract ) const pg = createSupabaseDirectClient() - await followContractInternal(pg, contractId, true, creatorId) + await followContractInternal(pg, contract.id, true, creatorId) } return { result: { newAnswerId: newAnswer.id }, continue: continuation } } diff --git a/backend/api/src/create-cash-contract.ts b/backend/api/src/create-cash-contract.ts index 793c3f3d5c..e7a1ee684d 100644 --- a/backend/api/src/create-cash-contract.ts +++ b/backend/api/src/create-cash-contract.ts @@ -15,6 +15,11 @@ export const createCashContract: APIHandler<'create-cash-contract'> = async ( 'Only Manifold team members can create cash contracts' ) - const contract = await createCashContractMain(manaContractId, subsidyAmount) + const contract = await createCashContractMain( + manaContractId, + subsidyAmount, + auth.uid + ) + return toLiteMarket(contract) } diff --git a/backend/api/src/create-market.ts b/backend/api/src/create-market.ts index 3ea007a741..9e2c8665bb 100644 --- a/backend/api/src/create-market.ts +++ b/backend/api/src/create-market.ts @@ -1,6 +1,4 @@ import { onCreateMarket } from 'api/helpers/on-create-market' -import { getNewLiquidityProvision } from 'common/add-liquidity' -import { getCpmmInitialLiquidity } from 'common/antes' import { createBinarySchema, createBountySchema, @@ -12,9 +10,6 @@ import { } from 'common/api/market-types' import { ValidatedAPIParams } from 'common/api/schema' import { - BinaryContract, - CPMMMultiContract, - Contract, MULTI_NUMERIC_CREATION_ENABLED, NO_CLOSE_TIME_TYPES, OutcomeType, @@ -34,7 +29,6 @@ import { getCloseDate } from 'shared/helpers/openai-utils' import { generateContractEmbeddings, getContractsDirect, - updateContract, } from 'shared/supabase/contracts' import { SupabaseDirectClient, @@ -42,7 +36,6 @@ import { createSupabaseDirectClient, pgp, } from 'shared/supabase/init' -import { insertLiquidity } from 'shared/supabase/liquidity' import { anythingToRichText } from 'shared/tiptap' import { runTxnOutsideBetQueue } from 'shared/txn/run-txn' import { @@ -56,10 +49,11 @@ import { } from 'shared/websockets/helpers' import { APIError, AuthedUser, type APIHandler } from './helpers/endpoint' import { Row } from 'common/supabase/utils' -import { bulkInsertQuery, FieldVal } from 'shared/supabase/utils' +import { bulkInsertQuery } from 'shared/supabase/utils' import { z } from 'zod' import { answerToRow } from 'shared/supabase/answers' import { convertAnswer } from 'common/supabase/contracts' +import { generateAntes } from 'shared/create-contract-helpers' type Body = ValidatedAPIParams<'market'> @@ -490,77 +484,3 @@ async function getGroupCheckPermissions( return group } - -export async function generateAntes( - pg: SupabaseDirectClient, - providerId: string, - contract: Contract, - outcomeType: OutcomeType, - ante: number, - totalMarketCost: number -) { - if ( - contract.outcomeType === 'MULTIPLE_CHOICE' && - contract.mechanism === 'cpmm-multi-1' && - !contract.shouldAnswersSumToOne - ) { - const { answers } = contract - for (const answer of answers) { - const ante = Math.sqrt(answer.poolYes * answer.poolNo) - - const lp = getCpmmInitialLiquidity( - providerId, - contract, - ante, - contract.createdTime, - answer.id - ) - - await insertLiquidity(pg, lp) - } - } else if ( - outcomeType === 'BINARY' || - outcomeType === 'PSEUDO_NUMERIC' || - outcomeType === 'STONK' || - outcomeType === 'MULTIPLE_CHOICE' || - outcomeType === 'NUMBER' - ) { - const lp = getCpmmInitialLiquidity( - providerId, - contract as BinaryContract | CPMMMultiContract, - ante, - contract.createdTime - ) - - await insertLiquidity(pg, lp) - } - const drizzledAmount = totalMarketCost - ante - if ( - drizzledAmount > 0 && - (contract.mechanism === 'cpmm-1' || contract.mechanism === 'cpmm-multi-1') - ) { - return await pg.txIf(async (tx) => { - await runTxnOutsideBetQueue(tx, { - fromId: providerId, - amount: drizzledAmount, - toId: contract.id, - toType: 'CONTRACT', - category: 'ADD_SUBSIDY', - token: 'M$', - fromType: 'USER', - }) - const newLiquidityProvision = getNewLiquidityProvision( - providerId, - drizzledAmount, - contract - ) - - await insertLiquidity(tx, newLiquidityProvision) - - await updateContract(tx, contract.id, { - subsidyPool: FieldVal.increment(drizzledAmount), - totalLiquidity: FieldVal.increment(drizzledAmount), - }) - }) - } -} diff --git a/backend/api/src/get-answer.ts b/backend/api/src/get-answer.ts new file mode 100644 index 0000000000..f9711f1ee7 --- /dev/null +++ b/backend/api/src/get-answer.ts @@ -0,0 +1,12 @@ +import { APIHandler } from './helpers/endpoint' +import { getAnswer } from 'shared/supabase/answers' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getSingleAnswer: APIHandler<'answer/:answerId'> = async (props) => { + const pg = createSupabaseDirectClient() + const answer = await getAnswer(pg, props.answerId) + if (!answer) { + throw new Error('Answer not found') + } + return answer +} diff --git a/backend/api/src/get-contract-answers.ts b/backend/api/src/get-contract-answers.ts new file mode 100644 index 0000000000..0086659a6d --- /dev/null +++ b/backend/api/src/get-contract-answers.ts @@ -0,0 +1,8 @@ +import { APIHandler } from './helpers/endpoint' +import { getAnswersForContract } from 'shared/supabase/answers' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export const getContractAnswers: APIHandler<'market/:contractId/answers'> = async (props) => { + const pg = createSupabaseDirectClient() + return await getAnswersForContract(pg, props.contractId) +} diff --git a/backend/api/src/on-create-bet.ts b/backend/api/src/on-create-bet.ts index 208cbb3f97..e9ad2b1d0d 100644 --- a/backend/api/src/on-create-bet.ts +++ b/backend/api/src/on-create-bet.ts @@ -1,4 +1,9 @@ -import { log, getUsers, revalidateContractStaticProps } from 'shared/utils' +import { + log, + getUsers, + revalidateContractStaticProps, + getContract, +} from 'shared/utils' import { Bet, LimitBet } from 'common/bet' import { Contract } from 'common/contract' import { humanish, User } from 'common/user' @@ -257,7 +262,20 @@ const handleBetReplyToComment = async ( if (!comment) return - const allBetReplies = await getBetsRepliedToComment(pg, comment, contract.id) + const manaContract = + contract.token === 'CASH' + ? await getContract(pg, contract.siblingContractId!) + : contract + + if (!manaContract) return + + const allBetReplies = await getBetsRepliedToComment( + pg, + comment, + contract.id, + contract.siblingContractId + ) + const bets = filterDefined(allBetReplies) // This could potentially miss some bets if they're not replicated in time if (!bets.some((b) => b.id === bet.id)) bets.push(bet) diff --git a/backend/api/src/on-create-comment-on-contract.ts b/backend/api/src/on-create-comment-on-contract.ts index 7d86869ea4..68e0c5065b 100644 --- a/backend/api/src/on-create-comment-on-contract.ts +++ b/backend/api/src/on-create-comment-on-contract.ts @@ -16,6 +16,7 @@ import { import { insertModReport } from 'shared/create-mod-report' import { updateContract } from 'shared/supabase/contracts' import { followContractInternal } from 'api/follow-contract' +import { getAnswer } from 'shared/supabase/answers' export const onCreateCommentOnContract = async (props: { contract: Contract @@ -51,12 +52,8 @@ const getReplyInfo = async ( comment: ContractComment, contract: Contract ) => { - if ( - comment.answerOutcome && - contract.outcomeType === 'MULTIPLE_CHOICE' && - contract.answers - ) { - const answer = contract.answers.find((a) => a.id === comment.answerOutcome) + if (comment.answerOutcome && contract.outcomeType === 'MULTIPLE_CHOICE') { + const answer = await getAnswer(pg, comment.answerOutcome) const comments = await pg.manyOrNone( `select comment_id, user_id from contract_comments diff --git a/backend/api/src/routes.ts b/backend/api/src/routes.ts index 096ca1b913..cafbbc2174 100644 --- a/backend/api/src/routes.ts +++ b/backend/api/src/routes.ts @@ -35,6 +35,8 @@ import { getGroup } from './get-group' import { getPositions } from './get-positions' import { getLeagues } from './get-leagues' import { getContract } from './get-contract' +import { getSingleAnswer } from './get-answer' +import { getContractAnswers } from './get-contract-answers' import { addOrRemoveTopicFromContract } from './add-topic-to-market' import { addOrRemoveTopicFromTopic } from './add-topic-to-topic' import { searchUsers } from './search-users' @@ -173,6 +175,8 @@ export const handlers: { [k in APIPath]: APIHandler } = { groups: getGroups, 'market/:id': getMarket, 'market/:id/lite': ({ id }) => getMarket({ id, lite: true }), + 'answer/:answerId': getSingleAnswer, + 'market/:contractId/answers': getContractAnswers, 'markets-by-ids': getMarketsByIds, 'slug/:slug': getMarket, 'market/:contractId/update': updateMarket, diff --git a/backend/scripts/create-cash-contract.ts b/backend/scripts/create-cash-contract.ts index db43d3f119..3d99940a3b 100644 --- a/backend/scripts/create-cash-contract.ts +++ b/backend/scripts/create-cash-contract.ts @@ -1,5 +1,6 @@ import { runScript } from './run-script' import { createCashContractMain } from '../shared/src/create-cash-contract' +import { HOUSE_LIQUIDITY_PROVIDER_ID } from 'common/antes' runScript(async () => { const manaContractId = process.argv[2] @@ -15,7 +16,8 @@ runScript(async () => { try { const cashContract = await createCashContractMain( manaContractId, - subsidyAmount + subsidyAmount, + HOUSE_LIQUIDITY_PROVIDER_ID ) console.log('Success ' + cashContract.id) } catch (error) { diff --git a/backend/shared/src/create-cash-contract.ts b/backend/shared/src/create-cash-contract.ts index 9c65d2596d..3768b87942 100644 --- a/backend/shared/src/create-cash-contract.ts +++ b/backend/shared/src/create-cash-contract.ts @@ -5,14 +5,19 @@ import { log, revalidateContractStaticProps, } from './utils' -import { runTxnFromBank } from './txn/run-txn' +import { runTxnOutsideBetQueue } from './txn/run-txn' import { APIError } from 'common/api/utils' import { updateContract } from './supabase/contracts' import { randomString } from 'common/util/random' import { getNewContract } from 'common/new-contract' -import { convertContract } from 'common/supabase/contracts' +import { convertAnswer, convertContract } from 'common/supabase/contracts' import { clamp } from 'lodash' import { runTransactionWithRetries } from './transact-with-retries' +import { answerToRow, getAnswersForContract } from './supabase/answers' +import { Answer } from 'common/answer' +import { bulkInsertQuery } from './supabase/utils' +import { pgp } from './supabase/init' +import { generateAntes } from 'shared/create-contract-helpers' import { getTierFromLiquidity } from 'common/tier' import { MarketContract } from 'common/contract' @@ -20,7 +25,8 @@ import { MarketContract } from 'common/contract' export async function createCashContractMain( manaContractId: string, - subsidyAmount: number + subsidyAmount: number, + myId: string ) { const { cashContract, manaContract } = await runTransactionWithRetries( async (tx) => { @@ -42,29 +48,52 @@ export async function createCashContractMain( ) } - if (manaContract.outcomeType !== 'BINARY') { + if (manaContract.siblingContractId) { throw new APIError( 400, - `Contract ${manaContractId} is not a binary contract` + `Contract ${manaContractId} already has a sweepstakes sibling contract ${manaContract.siblingContractId}` ) - - // TODO: Add support for multi } - if (manaContract.siblingContractId) { + if ( + manaContract.outcomeType !== 'BINARY' && + manaContract.outcomeType !== 'MULTIPLE_CHOICE' && + manaContract.outcomeType !== 'PSEUDO_NUMERIC' && + manaContract.outcomeType !== 'NUMBER' + ) { throw new APIError( 400, - `Contract ${manaContractId} already has a sweepstakes sibling contract ${manaContract.siblingContractId}` + `Cannot make sweepstakes question for ${manaContract.outcomeType} contract ${manaContractId}` ) } + let answers: Answer[] = [] + if (manaContract.outcomeType === 'MULTIPLE_CHOICE') { + if (manaContract.addAnswersMode !== 'DISABLED') + throw new APIError( + 400, + `Cannot add answers to multi sweepstakes question` + ) + + answers = await getAnswersForContract(tx, manaContractId) + } + + const initialProb = + manaContract.mechanism === 'cpmm-1' + ? clamp(Math.round(manaContract.prob * 100), 1, 99) + : 50 + + const min = 'min' in manaContract ? manaContract.min : 0 + const max = 'max' in manaContract ? manaContract.max : 0 + const isLogScale = + 'isLogScale' in manaContract ? manaContract.isLogScale : false + const contract = getNewContract({ id: randomString(), ante: subsidyAmount, token: 'CASH', description: htmlToRichText('

'), - initialProb: clamp(Math.round(manaContract.prob * 100), 1, 99), - + initialProb, creator, slug: manaContract.slug + '--cash', question: manaContract.question, @@ -72,17 +101,50 @@ export async function createCashContractMain( closeTime: manaContract.closeTime, visibility: manaContract.visibility, isTwitchContract: manaContract.isTwitchContract, - min: 0, - max: 0, - isLogScale: false, - answers: [], + + min, + max, + isLogScale, + + answers: answers.filter((a) => !a.isOther).map((a) => a.text), // Other gets recreated + + ...(manaContract.outcomeType === 'MULTIPLE_CHOICE' + ? { + addAnswersMode: manaContract.addAnswersMode, + shouldAnswersSumToOne: manaContract.shouldAnswersSumToOne, + } + : {}), }) - const newRow = await tx.one( + // copy answer colors and set userId to subsidizer + const answersToInsert = + 'answers' in contract && + contract.answers?.map((a: Answer) => ({ + ...a, + userId: myId, + color: answers.find((b) => b.index === a.index)?.color, + })) + + const insertAnswersQuery = answersToInsert + ? bulkInsertQuery('answers', answersToInsert.map(answerToRow)) + : `select 1 where false` + + // TODO: initialize marke tier? + + const contractQuery = pgp.as.format( `insert into contracts (id, data, token) values ($1, $2, $3) returning *`, [contract.id, JSON.stringify(contract), contract.token] ) - const cashContract = convertContract(newRow) + + const [newContracts, newAnswers] = await tx.multi( + `${contractQuery}; + ${insertAnswersQuery};` + ) + + const cashContract = convertContract(newContracts[0]) + if (newAnswers.length > 0 && cashContract.mechanism === 'cpmm-multi-1') { + cashContract.answers = newAnswers.map(convertAnswer) + } // Set sibling contract IDs await updateContract(tx, manaContractId, { @@ -93,13 +155,14 @@ export async function createCashContractMain( }) // Add initial liquidity - await runTxnFromBank(tx, { - amount: subsidyAmount, - category: 'CREATE_CONTRACT_ANTE', + await runTxnOutsideBetQueue(tx, { + fromId: myId, + fromType: 'USER', toId: cashContract.id, toType: 'CONTRACT', - fromType: 'BANK', + amount: subsidyAmount, token: 'CASH', + category: 'CREATE_CONTRACT_ANTE', }) await updateContract(tx, cashContract.id, { @@ -113,6 +176,15 @@ export async function createCashContractMain( `Created cash contract ${cashContract.id} for mana contract ${manaContractId}` ) + await generateAntes( + tx, + myId, + cashContract, + contract.outcomeType, + subsidyAmount, + subsidyAmount + ) + return { cashContract, manaContract } } ) diff --git a/backend/shared/src/create-contract-helpers.ts b/backend/shared/src/create-contract-helpers.ts new file mode 100644 index 0000000000..7023be3002 --- /dev/null +++ b/backend/shared/src/create-contract-helpers.ts @@ -0,0 +1,87 @@ +import { getNewLiquidityProvision } from 'common/add-liquidity' +import { getCpmmInitialLiquidity } from 'common/antes' +import { + BinaryContract, + Contract, + CPMMMultiContract, + OutcomeType, +} from 'common/contract' +import { updateContract } from './supabase/contracts' +import { SupabaseDirectClient } from './supabase/init' +import { insertLiquidity } from './supabase/liquidity' +import { FieldVal } from './supabase/utils' +import { runTxnOutsideBetQueue } from './txn/run-txn' + +export async function generateAntes( + pg: SupabaseDirectClient, + providerId: string, + contract: Contract, + outcomeType: OutcomeType, + ante: number, + totalMarketCost: number +) { + if ( + contract.outcomeType === 'MULTIPLE_CHOICE' && + contract.mechanism === 'cpmm-multi-1' && + !contract.shouldAnswersSumToOne + ) { + const { answers } = contract + for (const answer of answers) { + const ante = Math.sqrt(answer.poolYes * answer.poolNo) + + const lp = getCpmmInitialLiquidity( + providerId, + contract, + ante, + contract.createdTime, + answer.id + ) + + await insertLiquidity(pg, lp) + } + } else if ( + outcomeType === 'BINARY' || + outcomeType === 'PSEUDO_NUMERIC' || + outcomeType === 'STONK' || + outcomeType === 'MULTIPLE_CHOICE' || + outcomeType === 'NUMBER' + ) { + const lp = getCpmmInitialLiquidity( + providerId, + contract as BinaryContract | CPMMMultiContract, + ante, + contract.createdTime + ) + + await insertLiquidity(pg, lp) + } + const drizzledAmount = totalMarketCost - ante + if ( + drizzledAmount > 0 && + (contract.mechanism === 'cpmm-1' || contract.mechanism === 'cpmm-multi-1') + ) { + return await pg.txIf(async (tx) => { + await runTxnOutsideBetQueue(tx, { + fromId: providerId, + amount: drizzledAmount, + toId: contract.id, + toType: 'CONTRACT', + category: 'ADD_SUBSIDY', + token: 'M$', + fromType: 'USER', + }) + const newLiquidityProvision = getNewLiquidityProvision( + providerId, + drizzledAmount, + contract + ) + + await insertLiquidity(tx, newLiquidityProvision) + + await updateContract(tx, contract.id, { + subsidyPool: FieldVal.increment(drizzledAmount), + totalLiquidity: FieldVal.increment(drizzledAmount), + }) + }) + } +} diff --git a/backend/shared/src/supabase/bets.ts b/backend/shared/src/supabase/bets.ts index b81deb77dc..cd7646248f 100644 --- a/backend/shared/src/supabase/bets.ts +++ b/backend/shared/src/supabase/bets.ts @@ -119,15 +119,20 @@ export const getBetsWithFilter = async ( export const getBetsRepliedToComment = async ( pg: SupabaseDirectClient, comment: ContractComment, - contractId: string + contractId: string, + siblingContractId?: string ) => { return await pg.map( `select * from contract_bets - where data->>'replyToCommentId' = $1 - and contract_id = $2 - and created_time>=$3 - `, - [comment.id, contractId, new Date(comment.createdTime).toISOString()], + where data->>'replyToCommentId' = $1 + and created_time>=$2 + and (contract_id = $3 or contract_id = $4)`, + [ + comment.id, + millisToTs(comment.createdTime), + contractId, + siblingContractId, + ], convertBet ) } diff --git a/backend/shared/src/websockets/helpers.ts b/backend/shared/src/websockets/helpers.ts index 5a70d55dce..88f121aa9c 100644 --- a/backend/shared/src/websockets/helpers.ts +++ b/backend/shared/src/websockets/helpers.ts @@ -109,10 +109,10 @@ export function broadcastUpdatedAnswers( ) { if (answers.length === 0) return - const payload = { answers } - const topics = [`contract/${contractId}/updated-answers`] - // TODO: broadcast to global - broadcastMulti(topics, payload) + broadcast(`contract/${contractId}/updated-answers`, { answers }) + for (const a of answers) { + broadcast(`answer/${a.id}/update`, { answer: a }) + } } export function broadcastTVScheduleUpdate() { diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 3c05b6cbc7..60de34b26b 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -15,6 +15,7 @@ import { FullMarket, updateMarketProps, } from './market-types' +import { type Answer } from 'common/answer' import { MAX_COMMENT_LENGTH, type ContractComment } from 'common/comment' import { CandidateBet } from 'common/new-bet' import type { Bet, LimitBet } from 'common/bet' @@ -59,12 +60,12 @@ import { PendingCashoutStatusData, cashoutParams, } from 'common/gidx/gidx' - import { notification_preference } from 'common/user-notification-preferences' import { PrivateMessageChannel } from 'common/supabase/private-messages' import { Notification } from 'common/notification' import { NON_POINTS_BETS_LIMIT } from 'common/supabase/bets' import { ContractMetric } from 'common/contract-metric' + import { JSONContent } from '@tiptap/core' // mqp: very unscientific, just balancing our willingness to accept load // with user willingness to put up with stale data @@ -160,6 +161,20 @@ export const API = (_apiTypeCheck = { }) .strict(), }, + 'answer/:answerId': { + method: 'GET', + visibility: 'public', + authed: false, + returns: {} as Answer, + props: z.object({ answerId: z.string() }).strict(), + }, + 'market/:contractId/answers': { + method: 'GET', + visibility: 'public', + authed: false, + returns: [] as Answer[], + props: z.object({ contractId: z.string() }).strict(), + }, 'hide-comment': { method: 'POST', visibility: 'public', diff --git a/web/components/bet/sell-row.tsx b/web/components/bet/sell-row.tsx index 97bb39c858..79ad4093bf 100644 --- a/web/components/bet/sell-row.tsx +++ b/web/components/bet/sell-row.tsx @@ -19,6 +19,7 @@ import { MoneyDisplay } from './money-display' import { SellPanel } from './sell-panel' import { ContractMetric } from 'common/contract-metric' import { useSavedContractMetrics } from 'web/hooks/use-saved-contract-metrics' +import { useAnswer } from 'web/hooks/use-answers' export function SellRow(props: { contract: CPMMContract @@ -144,6 +145,7 @@ export function SellSharesModal(props: { } = props const isStonk = contract.outcomeType === 'STONK' const isCashContract = contract.token === 'CASH' + const { answer } = useAnswer(answerId) return ( @@ -164,7 +166,7 @@ export function SellSharesModal(props: { outcome={sharesOutcome} contract={contract} truncate={'short'} - answerId={answerId} + answer={answer} pseudonym={binaryPseudonym} /> . diff --git a/web/components/comments/comment-actions.tsx b/web/components/comments/comment-actions.tsx index 5f957841b6..f542d3b4db 100644 --- a/web/components/comments/comment-actions.tsx +++ b/web/components/comments/comment-actions.tsx @@ -63,7 +63,7 @@ export function CommentActions(props: { buttonClassName={'mr-1 min-w-[60px]'} /> )} - {user && liveContract.outcomeType === 'BINARY' && !isCashContract && ( + {user && liveContract.outcomeType === 'BINARY' && ( { track('bet intent', { diff --git a/web/components/comments/comment-header.tsx b/web/components/comments/comment-header.tsx index 6f1b4af9b2..8c5fb567d3 100644 --- a/web/components/comments/comment-header.tsx +++ b/web/components/comments/comment-header.tsx @@ -47,26 +47,24 @@ import { CommentEditHistoryButton } from './comment-edit-history-button' import DropdownMenu from './dropdown-menu' import { EditCommentModal } from './edit-comment-modal' import { RepostModal } from './repost-modal' +import { type Answer } from 'common/answer' +import { useAnswer, useLiveAnswer } from 'web/hooks/use-answers' export function FeedCommentHeader(props: { comment: ContractComment playContract: Contract - liveContract: Contract - updateComment?: (comment: Partial) => void + menuProps?: { + liveContractId: string + updateComment: (comment: Partial) => void + } inTimeline?: boolean isParent?: boolean isPinned?: boolean className?: string }) { - const { - comment, - updateComment, - playContract, - liveContract, - inTimeline, - isPinned, - className, - } = props + const { comment, playContract, menuProps, inTimeline, isPinned, className } = + props + const { userUsername, userName, @@ -89,6 +87,8 @@ export function FeedCommentHeader(props: { const marketCreator = playContract.creatorId === userId const { bought, money } = getBoughtMoney(betAmount, betOnCashContract) const shouldDisplayOutcome = betOutcome && !answerOutcome + const answer = useLiveAnswer(betAnswerId) + const isReplyToBet = betAmount !== undefined const commenterIsBettor = commenterAndBettorMatch(comment) const isLimitBet = betOrderAmount !== undefined && betLimitProb !== undefined @@ -118,9 +118,9 @@ export function FeedCommentHeader(props: { /> {' '} {' '} at {formatPercent(betLimitProb)} order @@ -129,9 +129,9 @@ export function FeedCommentHeader(props: { {bought} {money}{' '} @@ -147,16 +147,16 @@ export function FeedCommentHeader(props: { {/* Hide my status if replying to a bet, it's too much clutter*/} {!isReplyToBet && !inTimeline && ( - + {bought} {money} {shouldDisplayOutcome && ( <> {' '} of{' '} @@ -178,12 +178,12 @@ export function FeedCommentHeader(props: { {!inTimeline && isApi && ( 🤖 )} - {!inTimeline && updateComment && ( + {!inTimeline && menuProps && ( )} @@ -191,10 +191,7 @@ export function FeedCommentHeader(props: { {bountyAwarded && bountyAwarded > 0 && ( + - + )} {isPinned && } @@ -222,10 +219,10 @@ const getBoughtMoney = ( export function CommentReplyHeaderWithBet(props: { comment: ContractComment + contract: Pick bet: Bet - liveContract: Contract }) { - const { comment, bet, liveContract } = props + const { comment, contract, bet } = props const { outcome, answerId, amount, orderAmount, limitProb } = bet return ( ) } export function CommentReplyHeader(props: { comment: ContractComment - liveContract: Contract + contract: Pick hideBetHeader?: boolean }) { - const { comment, liveContract, hideBetHeader } = props + const { comment, contract, hideBetHeader } = props const { bettorName, bettorId, @@ -259,6 +256,10 @@ export function CommentReplyHeader(props: { betOrderAmount, betLimitProb, } = comment + + const { answer: betAnswer } = useAnswer(betAnswerId) + const { answer: answerToReply } = useAnswer(answerOutcome) + if ( (bettorId || (bettorUsername && bettorName)) && betOutcome && @@ -268,28 +269,27 @@ export function CommentReplyHeader(props: { return ( ) } - if (answerOutcome && 'answers' in liveContract) { - const answer = liveContract.answers.find((a) => a.id === answerOutcome) - if (answer) return + if (answerToReply) { + return } return null } export function ReplyToBetRow(props: { - liveContract: Contract + contract: Pick commenterIsBettor: boolean betOutcome: string betAmount: number @@ -298,7 +298,7 @@ export function ReplyToBetRow(props: { bettorUsername?: string betOrderAmount?: number betLimitProb?: number - betAnswerId?: string + betAnswer?: Answer clearReply?: () => void }) { const { @@ -308,8 +308,8 @@ export function ReplyToBetRow(props: { bettorUsername, bettorName, bettorId, - betAnswerId, - liveContract: contract, + betAnswer, + contract, clearReply, betLimitProb, betOrderAmount, @@ -337,14 +337,14 @@ export function ReplyToBetRow(props: { {!commenterIsBettor && bettorId && ( )} {!commenterIsBettor && !bettorId && bettorName && bettorUsername && ( {' '} @@ -381,8 +381,8 @@ export function ReplyToBetRow(props: { {bought} {money} @@ -404,11 +404,10 @@ export function ReplyToBetRow(props: { } function CommentStatus(props: { - contract: Contract + contract: Pick comment: ContractComment }) { const { contract, comment } = props - const { resolution } = contract const { commenterPositionProb, commenterPositionOutcome, @@ -416,6 +415,8 @@ function CommentStatus(props: { commenterPositionShares, } = comment + const { answer } = useAnswer(commenterPositionAnswerId) + if ( comment.betId == null && commenterPositionProb != null && @@ -425,10 +426,10 @@ function CommentStatus(props: { ) return ( <> - {resolution ? 'predicted ' : `predicts `} + predicted @@ -438,13 +439,13 @@ function CommentStatus(props: { return } -export function DotMenu(props: { +function DotMenu(props: { comment: ContractComment updateComment: (update: Partial) => void playContract: Contract - liveContract: Contract + liveContractId: string }) { - const { comment, updateComment, playContract, liveContract } = props + const { comment, updateComment, playContract, liveContractId } = props const [isModalOpen, setIsModalOpen] = useState(false) const user = useUser() const privateUser = usePrivateUser() @@ -563,7 +564,7 @@ export function DotMenu(props: { @@ -571,7 +572,6 @@ export function DotMenu(props: { {user && reposting && ( {isReplyToBet ? ( @@ -326,8 +329,8 @@ export function ContractCommentInput(props: { bettorId={replyTo.userId} betOrderAmount={replyTo.orderAmount} betLimitProb={replyTo.limitProb} - betAnswerId={replyTo.answerId} - liveContract={liveContract} + betAnswer={betAnswer} + contract={playContract} clearReply={clearReply} /> ) : replyTo ? ( diff --git a/web/components/comments/comment-thread.tsx b/web/components/comments/comment-thread.tsx index 00569649ab..bc73074585 100644 --- a/web/components/comments/comment-thread.tsx +++ b/web/components/comments/comment-thread.tsx @@ -131,7 +131,6 @@ export function FeedCommentThread(props: { {replyToUserInfo && ( @@ -177,9 +180,11 @@ export const FeedComment = memo(function FeedComment(props: { > { - const { playContract, liveContract, bet, size, className, iconClassName } = - props + const { playContract, bet, size, className, iconClassName } = props const [open, setOpen] = useState(false) return ( <> @@ -53,7 +51,6 @@ export const RepostButton = (props: { @@ -64,13 +61,12 @@ export const RepostButton = (props: { export const RepostModal = (props: { playContract: Contract - liveContract: Contract bet?: Bet comment?: ContractComment open: boolean setOpen: (open: boolean) => void }) => { - const { playContract, liveContract, comment, bet, open, setOpen } = props + const { playContract, comment, bet, open, setOpen } = props const [loading, setLoading] = useState(false) const repost = async () => api('post', { @@ -100,15 +96,12 @@ export const RepostModal = (props: { (comment.bettorUsername && !commenterIsBettor)) && (bet ? ( ) : ( - + ))} @@ -128,7 +121,6 @@ export const RepostModal = (props: { @@ -160,7 +152,6 @@ export const RepostModal = (props: { autoFocus replyTo={bet} playContract={playContract} - liveContract={liveContract} trackingLocation={'contract page'} commentTypes={['repost']} onClearInput={() => setOpen(false)} diff --git a/web/components/contract/contract-page.tsx b/web/components/contract/contract-page.tsx index 0e86ad1d51..7f6f2d8fbd 100644 --- a/web/components/contract/contract-page.tsx +++ b/web/components/contract/contract-page.tsx @@ -74,6 +74,7 @@ import { SpiceCoin } from 'web/public/custom-components/spiceCoin' import { YourTrades } from 'web/pages/[username]/[contractSlug]' import { useSweepstakes } from '../sweepstakes-provider' import { useRouter } from 'next/router' +import { precacheAnswers } from 'web/hooks/use-answers' export function ContractPageContent(props: ContractParams) { const { @@ -190,6 +191,15 @@ export function ContractPageContent(props: ContractParams) { ) useSaveContractVisitsLocally(user === null, props.contract.id) + useEffect(() => { + if ('answers' in props.contract) { + precacheAnswers(props.contract.answers) + } + if (props.cash?.contract && 'answers' in props.cash.contract) { + precacheAnswers(props.cash.contract.answers) + } + }, []) + const playBetData = useBetData({ contractId: props.contract.id, outcomeType: props.contract.outcomeType, diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 52fdf8d9d7..1842ac7586 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -375,7 +375,6 @@ export const CommentsTabContent = memo(function CommentsTabContent(props: { replyTo={replyTo} className="mb-4 mr-px mt-px" playContract={playContract} - liveContract={liveContract} clearReply={clearReply} trackingLocation={'contract page'} commentTypes={['comment', 'repost']} diff --git a/web/components/contract/header-actions.tsx b/web/components/contract/header-actions.tsx index 459cfaefe2..6e0dae3896 100644 --- a/web/components/contract/header-actions.tsx +++ b/web/components/contract/header-actions.tsx @@ -287,7 +287,6 @@ export function HeaderActions(props: { {repostOpen && ( diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index 672e9c3b8d..bf7e047819 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -176,7 +176,11 @@ export function BetStatusesText(props: { {bought} {money}{' '} a.id === answerId) + : undefined + } contract={contract} truncate="short" />{' '} @@ -232,6 +236,11 @@ export function BetStatusText(props: { ? getFormattedMappedValue(contract, probAfter) : getFormattedMappedValue(contract, limitProb ?? probAfter) + const answer = + contract.mechanism === 'cpmm-multi-1' + ? contract.answers?.find((a) => a.id === answerId) + : undefined + return (
{!inTimeline ? ( @@ -256,7 +265,7 @@ export function BetStatusText(props: { )}{' '} {' '} @@ -267,7 +276,7 @@ export function BetStatusText(props: { {bought} {money}{' '} {' '} @@ -296,7 +305,6 @@ function BetActions(props: { bet={bet} size={'2xs'} className={'!p-1'} - liveContract={contract} playContract={contract} /> {onReply && ( diff --git a/web/components/feed/good-comment.tsx b/web/components/feed/good-comment.tsx index b793093c55..db2a2df217 100644 --- a/web/components/feed/good-comment.tsx +++ b/web/components/feed/good-comment.tsx @@ -80,9 +80,7 @@ export const GoodComment = memo(function (props: { diff --git a/web/components/feed/scored-feed-repost-item.tsx b/web/components/feed/scored-feed-repost-item.tsx index 187c24f15e..d0aa95b1ab 100644 --- a/web/components/feed/scored-feed-repost-item.tsx +++ b/web/components/feed/scored-feed-repost-item.tsx @@ -97,9 +97,7 @@ export const ScoredFeedRepost = memo(function (props: {
@@ -170,54 +168,48 @@ function RepostLabel(props: { commenterIsBettor, repost, } = props - if (showTopLevelRow && creatorRepostedTheirComment) + if (!showTopLevelRow) return <> + + const dropdown = ( + + ) + + const header = bet && ( + + ) + + if (creatorRepostedTheirComment) { return ( - {bet && ( - - )} - + {header} + {dropdown} ) - - if (showTopLevelRow && !creatorRepostedTheirComment) { - return ( - - - - - - {!commenterIsBettor && bet && ( - - )} - - ) } - return <> + + return ( + + + + {dropdown} + + {!commenterIsBettor && header} + + ) } export const BottomActionRow = (props: { diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index fb07a29b79..8c9ff38198 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -3,8 +3,8 @@ import { getProbability } from 'common/calculate' import { BinaryContract, Contract, - getMainBinaryMCAnswer, - MultiContract, + // getMainBinaryMCAnswer, + // MultiContract, OutcomeType, resolution, } from 'common/contract' @@ -12,12 +12,13 @@ import { formatLargeNumber, formatPercent } from 'common/util/format' import { Bet } from 'common/bet' import { STONK_NO, STONK_YES } from 'common/stonk' import { AnswerLabel } from './answers/answer-components' +import { Answer } from 'common/answer' export function OutcomeLabel(props: { - contract: Contract + contract: Pick outcome: resolution | string truncate: 'short' | 'long' | 'none' - answerId?: string + answer?: Answer pseudonym?: { YES: { pseudonymName: string @@ -29,9 +30,9 @@ export function OutcomeLabel(props: { } } }) { - const { outcome, contract, truncate, answerId, pseudonym } = props + const { outcome, contract, truncate, answer, pseudonym } = props const { outcomeType, mechanism } = contract - const mainBinaryMCAnswer = getMainBinaryMCAnswer(contract) + // const mainBinaryMCAnswer = getMainBinaryMCAnswer(contract) const { pseudonymName, pseudonymColor } = pseudonym?.[outcome as 'YES' | 'NO'] ?? {} @@ -51,18 +52,18 @@ export function OutcomeLabel(props: { ) } - if (mainBinaryMCAnswer && mechanism === 'cpmm-multi-1') { - return ( - - ) - } + // TODO: fix + // if (mainBinaryMCAnswer && mechanism === 'cpmm-multi-1') { + // return ( + // + // ) + // } if (outcomeType === 'PSEUDO_NUMERIC') return @@ -77,10 +78,10 @@ export function OutcomeLabel(props: { if (outcomeType === 'NUMBER') { return ( - {answerId && ( + {answer && ( @@ -93,10 +94,10 @@ export function OutcomeLabel(props: { if (outcomeType === 'MULTIPLE_CHOICE' && mechanism === 'cpmm-multi-1') { return ( - {answerId && ( + {answer && ( @@ -114,14 +115,7 @@ export function OutcomeLabel(props: { return <> } - return ( - - ) + return <>??? } export function BinaryOutcomeLabel(props: { outcome: resolution }) { @@ -165,21 +159,20 @@ export function BinaryContractOutcomeLabel(props: { } export function MultiOutcomeLabel(props: { - contract: MultiContract + answer: Answer resolution: string | 'CANCEL' | 'MKT' truncate: 'short' | 'long' | 'none' answerClassName?: string }) { - const { contract, resolution, truncate, answerClassName } = props + const { answer, resolution, truncate, answerClassName } = props if (resolution === 'CANCEL') return if (resolution === 'MKT' || resolution === 'CHOOSE_MULTIPLE') return - const chosen = contract.answers?.find((answer) => answer.id === resolution) return ( diff --git a/web/hooks/use-answers.ts b/web/hooks/use-answers.ts new file mode 100644 index 0000000000..ecf67b0918 --- /dev/null +++ b/web/hooks/use-answers.ts @@ -0,0 +1,34 @@ +import { type Answer } from 'common/answer' +import { prepopulateCache, useAPIGetter } from './use-api-getter' +import { useApiSubscription } from './use-api-subscription' + +export function useAnswer(answerId: string | undefined) { + const { data: answer, setData: setAnswer } = useAPIGetter( + 'answer/:answerId', + answerId ? { answerId } : undefined + ) + + return { answer, setAnswer } +} + +export function useLiveAnswer(answerId: string | undefined) { + const { answer, setAnswer } = useAnswer(answerId) + + useApiSubscription({ + enabled: answerId != undefined, + topics: [`answer/${answerId}/update`], + onBroadcast: ({ data }) => { + setAnswer((a) => + a ? { ...a, ...(data.answer as Answer) } : (data.answer as Answer) + ) + }, + }) + + return answer +} + +export function precacheAnswers(answers: Answer[]) { + for (const answer of answers) { + prepopulateCache('answer/:answerId', { answerId: answer.id }, answer) + } +} diff --git a/web/hooks/use-api-getter.ts b/web/hooks/use-api-getter.ts index 4b088cbbc1..0d25db7808 100644 --- a/web/hooks/use-api-getter.ts +++ b/web/hooks/use-api-getter.ts @@ -6,6 +6,16 @@ import { useEvent } from './use-event' const promiseCache: Record | undefined> = {} +// Prepopulate cache with data, e.g. from static props +export function prepopulateCache

( + path: P, + props: APIParams

, + data: APIResponse

+) { + const key = `${path}-${JSON.stringify(props)}` + promiseCache[key] = Promise.resolve(data) +} + // react query at home export const useAPIGetter =

( path: P, @@ -24,7 +34,7 @@ export const useAPIGetter =

( APIResponse

| undefined >(undefined, `${overrideKey ?? path}`) - const key = `${path}-${propsString}-error` + const key = `${path}-${propsString}` const [error, setError] = usePersistentInMemoryState( undefined, key diff --git a/web/hooks/use-user-supabase.ts b/web/hooks/use-user-supabase.ts index 38fa686f06..147a299dcb 100644 --- a/web/hooks/use-user-supabase.ts +++ b/web/hooks/use-user-supabase.ts @@ -82,12 +82,5 @@ export function useUsersInStore( export function useDisplayUserByIdOrAnswer(answer: Answer) { const userId = answer.userId - const user = useDisplayUserById(userId) - if (!user) return user - return { - id: userId, - name: user.name, - username: user.username, - avatarUrl: user.avatarUrl, - } + return useDisplayUserById(userId) } diff --git a/web/knowledge.md b/web/knowledge.md index dadae0f072..8a6224d559 100644 --- a/web/knowledge.md +++ b/web/knowledge.md @@ -1,5 +1,24 @@ ## Design Principles +### Mana/Sweepstakes Market Pairs + +Markets can exist in both mana and sweepstakes versions, displayed together on the same page. When building UI components: +- Prefer passing specific data (like answer lists) rather than entire contract objects to reduce prop threading complexity +- Remember components may need to handle data from both market versions +- Consider which contract (mana or sweepstakes) owns the source of truth for shared data + +Component design patterns: +- Break components into small, focused pieces that handle one type of data +- Pass minimal props - e.g. pass answer objects instead of whole contracts +- For shared UI elements like answer displays, prefer passing the specific data needed (answer text, probability, etc.) rather than passing the entire contract +- Keep market-type-specific logic (mana vs sweepstakes) in container components, not shared display components + +Refactoring strategy for dual market support: +- Use grep to find all usages of a component before modifying it +- Start with leaf components that have fewer dependencies +- When threading props becomes complex, consider creating intermediate container components +- Extract market-specific logic into hooks or container components + ### Dark Mode and Component Consistency - Always consider dark mode when adding new UI components. Use color classes that respect the current theme (e.g., `text-ink-700 dark:text-ink-300` instead of fixed color classes).