diff --git a/common/answer.ts b/common/answer.ts new file mode 100644 index 00000000..36dd0415 --- /dev/null +++ b/common/answer.ts @@ -0,0 +1,31 @@ +import { User } from './user' + +export type Answer = { + id: string + number: number + contractId: string + createdTime: number + + userId: string + username: string + name: string + avatarUrl?: string + + text: string +} + +export const getNoneAnswer = (contractId: string, creator: User) => { + const { username, name, avatarUrl } = creator + + return { + id: '0', + number: 0, + contractId, + createdTime: Date.now(), + userId: creator.id, + username, + name, + avatarUrl, + text: 'None', + } +} diff --git a/common/antes.ts b/common/antes.ts index b8fa704c..d36ef584 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -61,3 +61,30 @@ export function getAnteBets( return { yesBet, noBet } } + +export function getFreeAnswerAnte( + creator: User, + contract: Contract, + anteBetId: string +) { + const { totalBets, totalShares } = contract + const amount = totalBets['0'] + const shares = totalShares['0'] + + const { createdTime } = contract + + const anteBet: Bet = { + id: anteBetId, + userId: creator.id, + contractId: contract.id, + amount, + shares, + outcome: '0', + probBefore: 0, + probAfter: 1, + createdTime, + isAnte: true, + } + + return anteBet +} diff --git a/common/bet.ts b/common/bet.ts index a875102c..7da4b18c 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,7 +4,7 @@ export type Bet = { contractId: string amount: number // bet size; negative if SELL bet - outcome: 'YES' | 'NO' + outcome: string shares: number // dynamic parimutuel pool weight; negative if SELL bet probBefore: number diff --git a/common/calculate.ts b/common/calculate.ts index 831b37de..b03868ed 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,69 +1,86 @@ +import * as _ from 'lodash' import { Bet } from './bet' import { Contract } from './contract' import { FEES } from './fees' -export function getProbability(totalShares: { YES: number; NO: number }) { - const { YES: y, NO: n } = totalShares - return y ** 2 / (y ** 2 + n ** 2) +export function getProbability(totalShares: { [outcome: string]: number }) { + // For binary contracts only. + return getOutcomeProbability(totalShares, 'YES') +} + +export function getOutcomeProbability( + totalShares: { + [outcome: string]: number + }, + outcome: string +) { + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + const shares = totalShares[outcome] ?? 0 + return shares ** 2 / squareSum } export function getProbabilityAfterBet( - totalShares: { YES: number; NO: number }, - outcome: 'YES' | 'NO', + totalShares: { + [outcome: string]: number + }, + outcome: string, bet: number ) { const shares = calculateShares(totalShares, bet, outcome) - const [YES, NO] = - outcome === 'YES' - ? [totalShares.YES + shares, totalShares.NO] - : [totalShares.YES, totalShares.NO + shares] + const prevShares = totalShares[outcome] ?? 0 + const newTotalShares = { ...totalShares, [outcome]: prevShares + shares } - return getProbability({ YES, NO }) + return getOutcomeProbability(newTotalShares, outcome) +} + +export function getProbabilityAfterSale( + totalShares: { + [outcome: string]: number + }, + outcome: string, + shares: number +) { + const prevShares = totalShares[outcome] ?? 0 + const newTotalShares = { ...totalShares, [outcome]: prevShares - shares } + + const predictionOutcome = outcome === 'NO' ? 'YES' : outcome + return getOutcomeProbability(newTotalShares, predictionOutcome) } export function calculateShares( - totalShares: { YES: number; NO: number }, + totalShares: { + [outcome: string]: number + }, bet: number, - betChoice: 'YES' | 'NO' + betChoice: string ) { - const [yesShares, noShares] = [totalShares.YES, totalShares.NO] + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + const shares = totalShares[betChoice] ?? 0 - const c = 2 * bet * Math.sqrt(yesShares ** 2 + noShares ** 2) + const c = 2 * bet * Math.sqrt(squareSum) - return betChoice === 'YES' - ? Math.sqrt(bet ** 2 + yesShares ** 2 + c) - yesShares - : Math.sqrt(bet ** 2 + noShares ** 2 + c) - noShares -} - -export function calculateEstimatedWinnings( - totalShares: { YES: number; NO: number }, - shares: number, - betChoice: 'YES' | 'NO' -) { - const ind = betChoice === 'YES' ? 1 : 0 - - const yesShares = totalShares.YES + ind * shares - const noShares = totalShares.NO + (1 - ind) * shares - - const estPool = Math.sqrt(yesShares ** 2 + noShares ** 2) - const total = ind * yesShares + (1 - ind) * noShares - - return ((1 - FEES) * (shares * estPool)) / total + return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares } export function calculateRawShareValue( - totalShares: { YES: number; NO: number }, + totalShares: { + [outcome: string]: number + }, shares: number, - betChoice: 'YES' | 'NO' + betChoice: string ) { - const [yesShares, noShares] = [totalShares.YES, totalShares.NO] - const currentValue = Math.sqrt(yesShares ** 2 + noShares ** 2) + const currentValue = Math.sqrt( + _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + ) - const postSaleValue = - betChoice === 'YES' - ? Math.sqrt(Math.max(0, yesShares - shares) ** 2 + noShares ** 2) - : Math.sqrt(yesShares ** 2 + Math.max(0, noShares - shares) ** 2) + const postSaleValue = Math.sqrt( + _.sumBy(Object.keys(totalShares), (outcome) => + outcome === betChoice + ? Math.max(0, totalShares[outcome] - shares) ** 2 + : totalShares[outcome] ** 2 + ) + ) return currentValue - postSaleValue } @@ -73,17 +90,22 @@ export function calculateMoneyRatio( bet: Bet, shareValue: number ) { - const { totalShares, pool } = contract + const { totalShares, totalBets, pool } = contract + const { outcome, amount } = bet - const p = getProbability(totalShares) + const p = getOutcomeProbability(totalShares, outcome) - const actual = pool.YES + pool.NO - shareValue + const actual = _.sum(Object.values(pool)) - shareValue - const betAmount = - bet.outcome === 'YES' ? p * bet.amount : (1 - p) * bet.amount + const betAmount = p * amount const expected = - p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO - betAmount + _.sumBy( + Object.keys(totalBets), + (outcome) => + getOutcomeProbability(totalShares, outcome) * + (totalBets as { [outcome: string]: number })[outcome] + ) - betAmount if (actual <= 0 || expected <= 0) return 0 @@ -91,14 +113,13 @@ export function calculateMoneyRatio( } export function calculateShareValue(contract: Contract, bet: Bet) { - const shareValue = calculateRawShareValue( - contract.totalShares, - bet.shares, - bet.outcome - ) + const { pool, totalShares } = contract + const { shares, outcome } = bet + + const shareValue = calculateRawShareValue(totalShares, shares, outcome) const f = calculateMoneyRatio(contract, bet, shareValue) - const myPool = contract.pool[bet.outcome] + const myPool = pool[outcome] const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool) return adjShareValue } @@ -109,11 +130,7 @@ export function calculateSaleAmount(contract: Contract, bet: Bet) { return deductFees(amount, winnings) } -export function calculatePayout( - contract: Contract, - bet: Bet, - outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' -) { +export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { if (outcome === 'CANCEL') return calculateCancelPayout(contract, bet) if (outcome === 'MKT') return calculateMktPayout(contract, bet) @@ -121,67 +138,100 @@ export function calculatePayout( } export function calculateCancelPayout(contract: Contract, bet: Bet) { - const totalBets = contract.totalBets.YES + contract.totalBets.NO - const pool = contract.pool.YES + contract.pool.NO + const { totalBets, pool } = contract + const betTotal = _.sum(Object.values(totalBets)) + const poolTotal = _.sum(Object.values(pool)) - return (bet.amount / totalBets) * pool + return (bet.amount / betTotal) * poolTotal } export function calculateStandardPayout( contract: Contract, bet: Bet, - outcome: 'YES' | 'NO' + outcome: string ) { const { amount, outcome: betOutcome, shares } = bet if (betOutcome !== outcome) return 0 - const { totalShares, phantomShares } = contract - if (totalShares[outcome] === 0) return 0 + const { totalShares, phantomShares, pool } = contract + if (!totalShares[outcome]) return 0 - const pool = contract.pool.YES + contract.pool.NO - const total = totalShares[outcome] - phantomShares[outcome] + const poolTotal = _.sum(Object.values(pool)) - const winnings = (shares / total) * pool + const total = + totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0) + + const winnings = (shares / total) * poolTotal // profit can be negative if using phantom shares return amount + (1 - FEES) * Math.max(0, winnings - amount) } export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { const { totalShares, pool, totalBets } = contract + const { shares, amount, outcome } = bet - const ind = bet.outcome === 'YES' ? 1 : 0 - const { shares, amount } = bet + const prevShares = totalShares[outcome] ?? 0 + const prevPool = pool[outcome] ?? 0 + const prevTotalBet = totalBets[outcome] ?? 0 const newContract = { ...contract, totalShares: { - YES: totalShares.YES + ind * shares, - NO: totalShares.NO + (1 - ind) * shares, + ...totalShares, + [outcome]: prevShares + shares, }, pool: { - YES: pool.YES + ind * amount, - NO: pool.NO + (1 - ind) * amount, + ...pool, + [outcome]: prevPool + amount, }, totalBets: { - YES: totalBets.YES + ind * amount, - NO: totalBets.NO + (1 - ind) * amount, + ...totalBets, + [outcome]: prevTotalBet + amount, }, } - return calculateStandardPayout(newContract, bet, bet.outcome) + return calculateStandardPayout(newContract, bet, outcome) } function calculateMktPayout(contract: Contract, bet: Bet) { + if (contract.outcomeType === 'BINARY') + return calculateBinaryMktPayout(contract, bet) + + const { totalShares, pool } = contract + + const totalPool = _.sum(Object.values(pool)) + const sharesSquareSum = _.sumBy( + Object.values(totalShares), + (shares) => shares ** 2 + ) + + const weightedShareTotal = _.sumBy(Object.keys(totalShares), (outcome) => { + // Avoid O(n^2) by reusing sharesSquareSum for prob. + const shares = totalShares[outcome] + const prob = shares ** 2 / sharesSquareSum + return prob * shares + }) + + const { outcome, amount, shares } = bet + + const betP = getOutcomeProbability(totalShares, outcome) + const winnings = ((betP * shares) / weightedShareTotal) * totalPool + + return deductFees(amount, winnings) +} + +function calculateBinaryMktPayout(contract: Contract, bet: Bet) { + const { resolutionProbability, totalShares, phantomShares } = contract const p = - contract.resolutionProbability !== undefined - ? contract.resolutionProbability - : getProbability(contract.totalShares) + resolutionProbability !== undefined + ? resolutionProbability + : getProbability(totalShares) const pool = contract.pool.YES + contract.pool.NO const weightedShareTotal = - p * (contract.totalShares.YES - contract.phantomShares.YES) + - (1 - p) * (contract.totalShares.NO - contract.phantomShares.NO) + p * (totalShares.YES - (phantomShares?.YES ?? 0)) + + (1 - p) * (totalShares.NO - (phantomShares?.NO ?? 0)) const { outcome, amount, shares } = bet @@ -197,15 +247,6 @@ export function resolvedPayout(contract: Contract, bet: Bet) { throw new Error('Contract was not resolved') } -// deprecated use MKT payout -export function currentValue(contract: Contract, bet: Bet) { - const prob = getProbability(contract.pool) - const yesPayout = calculatePayout(contract, bet, 'YES') - const noPayout = calculatePayout(contract, bet, 'NO') - - return prob * yesPayout + (1 - prob) * noPayout -} - export const deductFees = (betAmount: number, winnings: number) => { return winnings > betAmount ? betAmount + (1 - FEES) * (winnings - betAmount) diff --git a/common/contract.ts b/common/contract.ts index ed642cfa..57a8d0b7 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,3 +1,5 @@ +import { Answer } from './answer' + export type Contract = { id: string slug: string // auto-generated; must be unique @@ -11,14 +13,17 @@ export type Contract = { description: string // More info about what the contract is about tags: string[] lowercaseTags: string[] - outcomeType: 'BINARY' // | 'MULTI' | 'interval' | 'date' visibility: 'public' | 'unlisted' + outcomeType: 'BINARY' | 'MULTI' | 'FREE_RESPONSE' + multiOutcomes?: string[] // Used for outcomeType 'MULTI'. + answers?: Answer[] // Used for outcomeType 'FREE_RESPONSE'. + mechanism: 'dpm-2' - phantomShares: { YES: number; NO: number } - pool: { YES: number; NO: number } - totalShares: { YES: number; NO: number } - totalBets: { YES: number; NO: number } + phantomShares?: { [outcome: string]: number } + pool: { [outcome: string]: number } + totalShares: { [outcome: string]: number } + totalBets: { [outcome: string]: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed @@ -26,11 +31,12 @@ export type Contract = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market - resolution?: outcome // Chosen by creator; must be one of outcomes + resolution?: string resolutionProbability?: number + closeEmailsSent?: number volume24Hours: number volume7Days: number } -export type outcome = 'YES' | 'NO' | 'CANCEL' | 'MKT' +export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' diff --git a/common/new-bet.ts b/common/new-bet.ts index 61c72015..29fd421a 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,9 +1,13 @@ import { Bet } from './bet' -import { calculateShares, getProbability } from './calculate' +import { + calculateShares, + getProbability, + getOutcomeProbability, +} from './calculate' import { Contract } from './contract' import { User } from './user' -export const getNewBetInfo = ( +export const getNewBinaryBetInfo = ( user: User, outcome: 'YES' | 'NO', amount: number, @@ -52,3 +56,43 @@ export const getNewBetInfo = ( return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } + +export const getNewMultiBetInfo = ( + user: User, + outcome: string, + amount: number, + contract: Contract, + newBetId: string +) => { + const { pool, totalShares, totalBets } = contract + + const prevOutcomePool = pool[outcome] ?? 0 + const newPool = { ...pool, [outcome]: prevOutcomePool + amount } + + const shares = calculateShares(contract.totalShares, amount, outcome) + + const prevShares = totalShares[outcome] ?? 0 + const newTotalShares = { ...totalShares, [outcome]: prevShares + shares } + + const prevTotalBets = totalBets[outcome] ?? 0 + const newTotalBets = { ...totalBets, [outcome]: prevTotalBets + amount } + + const probBefore = getOutcomeProbability(totalShares, outcome) + const probAfter = getOutcomeProbability(newTotalShares, outcome) + + const newBet: Bet = { + id: newBetId, + userId: user.id, + contractId: contract.id, + amount, + shares, + outcome, + probBefore, + probAfter, + createdTime: Date.now(), + } + + const newBalance = user.balance - amount + + return { newBet, newPool, newTotalShares, newTotalBets, newBalance } +} diff --git a/common/new-contract.ts b/common/new-contract.ts index 99f27874..cc742f2f 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -1,32 +1,37 @@ import { calcStartPool } from './antes' - -import { Contract } from './contract' +import { Contract, outcomeType } from './contract' import { User } from './user' import { parseTags } from './util/parse' +import { removeUndefinedProps } from './util/object' export function getNewContract( id: string, slug: string, creator: User, question: string, + outcomeType: outcomeType, description: string, initialProb: number, ante: number, closeTime: number, extraTags: string[] ) { - const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } = - calcStartPool(initialProb, ante) - const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - const contract: Contract = { + const propsByOutcomeType = + outcomeType === 'BINARY' + ? getBinaryProps(initialProb, ante) + : getFreeAnswerProps(ante) + + const contract: Contract = removeUndefinedProps({ id, slug, - outcomeType: 'BINARY', + mechanism: 'dpm-2', + outcomeType, + ...propsByOutcomeType, creatorId: creator.id, creatorName: creator.name, @@ -38,22 +43,43 @@ export function getNewContract( tags, lowercaseTags, visibility: 'public', + isResolved: false, + createdTime: Date.now(), + lastUpdatedTime: Date.now(), + closeTime, - mechanism: 'dpm-2', + volume24Hours: 0, + volume7Days: 0, + }) + + return contract +} + +const getBinaryProps = (initialProb: number, ante: number) => { + const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } = + calcStartPool(initialProb, ante) + + return { phantomShares: { YES: phantomYes, NO: phantomNo }, pool: { YES: poolYes, NO: poolNo }, totalShares: { YES: sharesYes, NO: sharesNo }, totalBets: { YES: poolYes, NO: poolNo }, - isResolved: false, - - createdTime: Date.now(), - lastUpdatedTime: Date.now(), - - volume24Hours: 0, - volume7Days: 0, } - - if (closeTime) contract.closeTime = closeTime - - return contract +} + +const getFreeAnswerProps = (ante: number) => { + return { + pool: { '0': ante }, + totalShares: { '0': ante }, + totalBets: { '0': ante }, + answers: [], + } +} + +const getMultiProps = ( + outcomes: string[], + initialProbs: number[], + ante: number +) => { + // Not implemented. } diff --git a/common/payouts.ts b/common/payouts.ts index a372f6bc..0b917943 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -2,12 +2,12 @@ import * as _ from 'lodash' import { Bet } from './bet' import { deductFees, getProbability } from './calculate' -import { Contract, outcome } from './contract' +import { Contract } from './contract' import { CREATOR_FEE, FEES } from './fees' export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { const { pool } = contract - const poolTotal = pool.YES + pool.NO + const poolTotal = _.sum(Object.values(pool)) console.log('resolved N/A, pool M$', poolTotal) const betSum = _.sumBy(bets, (b) => b.amount) @@ -19,18 +19,17 @@ export const getCancelPayouts = (contract: Contract, bets: Bet[]) => { } export const getStandardPayouts = ( - outcome: 'YES' | 'NO', + outcome: string, contract: Contract, bets: Bet[] ) => { - const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') - const winningBets = outcome === 'YES' ? yesBets : noBets + const winningBets = bets.filter((bet) => bet.outcome === outcome) - const pool = contract.pool.YES + contract.pool.NO + const poolTotal = _.sum(Object.values(contract.pool)) const totalShares = _.sumBy(winningBets, (b) => b.shares) const payouts = winningBets.map(({ userId, amount, shares }) => { - const winnings = (shares / totalShares) * pool + const winnings = (shares / totalShares) * poolTotal const profit = winnings - amount // profit can be negative if using phantom shares @@ -45,7 +44,7 @@ export const getStandardPayouts = ( 'resolved', outcome, 'pool', - pool, + poolTotal, 'profits', profits, 'creator fee', @@ -101,7 +100,7 @@ export const getMktPayouts = ( } export const getPayouts = ( - outcome: outcome, + outcome: string, contract: Contract, bets: Bet[], resolutionProbability?: number @@ -114,5 +113,8 @@ export const getPayouts = ( return getMktPayouts(contract, bets, resolutionProbability) case 'CANCEL': return getCancelPayouts(contract, bets) + default: + // Multi outcome. + return getStandardPayouts(outcome, contract, bets) } } diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 88191742..cc824386 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -1,7 +1,7 @@ import { Bet } from './bet' import { calculateShareValue, deductFees, getProbability } from './calculate' import { Contract } from './contract' -import { CREATOR_FEE, FEES } from './fees' +import { CREATOR_FEE } from './fees' import { User } from './user' export const getSellBetInfo = ( @@ -10,30 +10,21 @@ export const getSellBetInfo = ( contract: Contract, newBetId: string ) => { + const { pool, totalShares, totalBets } = contract const { id: betId, amount, shares, outcome } = bet - const { YES: yesPool, NO: noPool } = contract.pool - const { YES: yesShares, NO: noShares } = contract.totalShares - const { YES: yesBets, NO: noBets } = contract.totalBets - const adjShareValue = calculateShareValue(contract, bet) - const newPool = - outcome === 'YES' - ? { YES: yesPool - adjShareValue, NO: noPool } - : { YES: yesPool, NO: noPool - adjShareValue } + const newPool = { ...pool, [outcome]: pool[outcome] - adjShareValue } - const newTotalShares = - outcome === 'YES' - ? { YES: yesShares - shares, NO: noShares } - : { YES: yesShares, NO: noShares - shares } + const newTotalShares = { + ...totalShares, + [outcome]: totalShares[outcome] - shares, + } - const newTotalBets = - outcome === 'YES' - ? { YES: yesBets - amount, NO: noBets } - : { YES: yesBets, NO: noBets - amount } + const newTotalBets = { ...totalBets, [outcome]: totalBets[outcome] - amount } - const probBefore = getProbability(contract.totalShares) + const probBefore = getProbability(totalShares) const probAfter = getProbability(newTotalShares) const profit = adjShareValue - amount diff --git a/common/user.ts b/common/user.ts index ccaf2414..73186ac8 100644 --- a/common/user.ts +++ b/common/user.ts @@ -6,6 +6,13 @@ export type User = { username: string avatarUrl?: string + // For their user page + bio?: string + bannerUrl?: string + website?: string + twitterHandle?: string + discordHandle?: string + balance: number totalDeposits: number totalPnLCached: number diff --git a/common/util/object.ts b/common/util/object.ts new file mode 100644 index 00000000..4148b057 --- /dev/null +++ b/common/util/object.ts @@ -0,0 +1,9 @@ +export const removeUndefinedProps = (obj: T): T => { + let newObj: any = {} + + for (let key of Object.keys(obj)) { + if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] + } + + return newObj +} diff --git a/common/util/random.ts b/common/util/random.ts index ce2bc54a..740379e5 100644 --- a/common/util/random.ts +++ b/common/util/random.ts @@ -3,22 +3,22 @@ export const randomString = (length = 12) => .toString(16) .substring(2, length + 2) +export function genHash(str: string) { + // xmur3 + for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353) + h = (h << 13) | (h >>> 19) + } + return function () { + h = Math.imul(h ^ (h >>> 16), 2246822507) + h = Math.imul(h ^ (h >>> 13), 3266489909) + return (h ^= h >>> 16) >>> 0 + } +} + export function createRNG(seed: string) { // https://stackoverflow.com/a/47593316/1592933 - function genHash(str: string) { - // xmur3 - for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { - h = Math.imul(h ^ str.charCodeAt(i), 3432918353) - h = (h << 13) | (h >>> 19) - } - return function () { - h = Math.imul(h ^ (h >>> 16), 2246822507) - h = Math.imul(h ^ (h >>> 13), 3266489909) - return (h ^= h >>> 16) >>> 0 - } - } - const gen = genHash(seed) let [a, b, c, d] = [gen(), gen(), gen(), gen()] diff --git a/firestore.rules b/firestore.rules index 253d57f5..385e2f44 100644 --- a/firestore.rules +++ b/firestore.rules @@ -13,6 +13,9 @@ service cloud.firestore { match /users/{userId} { allow read; + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle']); } match /private-users/{userId} { @@ -27,20 +30,16 @@ service cloud.firestore { allow delete: if resource.data.creatorId == request.auth.uid; } - match /contracts/{contractId}/bets/{betId} { - allow read; - } - match /{somePath=**}/bets/{betId} { allow read; } - match /contracts/{contractId}/comments/{commentId} { + match /{somePath=**}/comments/{commentId} { allow read; allow create: if request.auth != null; } - match /{somePath=**}/comments/{commentId} { + match /{somePath=**}/answers/{answerId} { allow read; } @@ -49,13 +48,9 @@ service cloud.firestore { allow update, delete: if request.auth.uid == resource.data.curatorId; } - match /folds/{foldId}/followers/{userId} { + match /{somePath=**}/followers/{userId} { allow read; allow write: if request.auth.uid == userId; } - - match /{somePath=**}/followers/{userId} { - allow read; - } } } \ No newline at end of file diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index de8a8d6a..ab15eb70 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -1,11 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { getUser, removeUndefinedProps } from './utils' +import { getUser } from './utils' import { Contract } from '../../common/contract' import { Comment } from '../../common/comment' import { User } from '../../common/user' import { cleanUsername } from '../../common/util/clean-username' +import { removeUndefinedProps } from '../../common/util/object' +import { Answer } from '../../common/answer' export const changeUserInfo = functions .runWith({ minInstances: 1 }) @@ -88,12 +90,23 @@ export const changeUser = async ( userAvatarUrl: update.avatarUrl, }) + const answerSnap = await transaction.get( + firestore + .collectionGroup('answers') + .where('username', '==', user.username) + ) + const answerUpdate: Partial = removeUndefinedProps(update) + await transaction.update(userRef, userUpdate) await Promise.all( commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate)) ) + await Promise.all( + answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate)) + ) + await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate)) }) } diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts new file mode 100644 index 00000000..bcb25b03 --- /dev/null +++ b/functions/src/create-answer.ts @@ -0,0 +1,111 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { getNewMultiBetInfo } from '../../common/new-bet' +import { Answer } from '../../common/answer' +import { getValues } from './utils' + +export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + contractId: string + amount: number + text: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId, amount, text } = data + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + return { status: 'error', message: 'Invalid amount' } + + if (!text || typeof text !== 'string' || text.length > 10000) + return { status: 'error', message: 'Invalid text' } + + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${userId}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) + return { status: 'error', message: 'User not found' } + const user = userSnap.data() as User + + if (user.balance < amount) + return { status: 'error', message: 'Insufficient balance' } + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) + return { status: 'error', message: 'Invalid contract' } + const contract = contractSnap.data() as Contract + + if (contract.outcomeType !== 'FREE_RESPONSE') + return { + status: 'error', + message: 'Requires a free response contract', + } + + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + return { status: 'error', message: 'Trading is closed' } + + const [lastAnswer] = await getValues( + firestore + .collection(`contracts/${contractId}/answers`) + .orderBy('number', 'desc') + .limit(1) + ) + + if (!lastAnswer) + return { status: 'error', message: 'Could not fetch last answer' } + + const number = lastAnswer.number + 1 + const id = `${number}` + + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc(id) + + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user + + const answer: Answer = { + id, + number, + contractId, + createdTime: Date.now(), + userId: user.id, + username, + name, + avatarUrl, + text, + } + transaction.create(newAnswerDoc, answer) + + const newBetDoc = firestore + .collection(`contracts/${contractId}/bets`) + .doc() + + const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = + getNewMultiBetInfo(user, answerId, amount, contract, newBetDoc.id) + + transaction.create(newBetDoc, { ...newBet, isAnte: true }) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + answers: [...(contract.answers ?? []), answer], + }) + transaction.update(userDoc, { balance: newBalance }) + + return { status: 'success', answerId, betId: newBetDoc.id } + }) + } +) + +const firestore = admin.firestore() diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 8ab38b88..bb78f134 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -2,11 +2,16 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { chargeUser, getUser } from './utils' -import { Contract } from '../../common/contract' +import { Contract, outcomeType } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { getNewContract } from '../../common/new-contract' -import { getAnteBets, MINIMUM_ANTE } from '../../common/antes' +import { + getAnteBets, + getFreeAnswerAnte, + MINIMUM_ANTE, +} from '../../common/antes' +import { getNoneAnswer } from '../../common/answer' export const createContract = functions .runWith({ minInstances: 1 }) @@ -14,6 +19,7 @@ export const createContract = functions async ( data: { question: string + outcomeType: outcomeType description: string initialProb: number ante: number @@ -30,10 +36,17 @@ export const createContract = functions const { question, description, initialProb, ante, closeTime, tags } = data - if (!question || !initialProb) - return { status: 'error', message: 'Missing contract attributes' } + if (!question) + return { status: 'error', message: 'Missing question field' } - if (initialProb < 1 || initialProb > 99) + let outcomeType = data.outcomeType ?? 'BINARY' + if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) + return { status: 'error', message: 'Invalid outcomeType' } + + if ( + outcomeType === 'BINARY' && + (!initialProb || initialProb < 1 || initialProb > 99) + ) return { status: 'error', message: 'Invalid initial probability' } if ( @@ -63,6 +76,7 @@ export const createContract = functions slug, creator, question, + outcomeType, description, initialProb, ante, @@ -75,22 +89,36 @@ export const createContract = functions await contractRef.create(contract) if (ante) { - const yesBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() + if (outcomeType === 'BINARY') { + const yesBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - const noBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() + const noBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - const { yesBet, noBet } = getAnteBets( - creator, - contract, - yesBetDoc.id, - noBetDoc.id - ) - await yesBetDoc.set(yesBet) - await noBetDoc.set(noBet) + const { yesBet, noBet } = getAnteBets( + creator, + contract, + yesBetDoc.id, + noBetDoc.id + ) + await yesBetDoc.set(yesBet) + await noBetDoc.set(noBet) + } else if (outcomeType === 'FREE_RESPONSE') { + const noneAnswerDoc = firestore + .collection(`contracts/${contract.id}/answers`) + .doc('0') + const noneAnswer = getNoneAnswer(contract.id, creator) + await noneAnswerDoc.set(noneAnswer) + + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + const anteBet = getFreeAnswerAnte(creator, contract, anteBetDoc.id) + await anteBetDoc.set(anteBet) + } } return { status: 'success', contract } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 3dbfeb53..d1c61e3f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,7 +1,9 @@ +import _ = require('lodash') import { getProbability } from '../../common/calculate' import { Contract } from '../../common/contract' +import { CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatPercent } from '../../common/util/format' +import { formatMoney, formatPercent } from '../../common/util/format' import { sendTemplateEmail, sendTextEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -15,12 +17,23 @@ type market_resolved_template = { url: string } +const toDisplayResolution = (outcome: string, prob: number) => { + const display = { + YES: 'YES', + NO: 'NO', + CANCEL: 'N/A', + MKT: formatPercent(prob), + }[outcome] + + return display === undefined ? `#${outcome}` : display +} + export const sendMarketResolutionEmail = async ( userId: string, payout: number, creator: User, contract: Contract, - resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT', + resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string, resolutionProbability?: number ) => { const privateUser = await getPrivateUser(userId) @@ -36,13 +49,7 @@ export const sendMarketResolutionEmail = async ( const prob = resolutionProbability ?? getProbability(contract.totalShares) - const toDisplayResolution = { - YES: 'YES', - NO: 'NO', - CANCEL: 'N/A', - MKT: formatPercent(prob), - } - const outcome = toDisplayResolution[resolution] + const outcome = toDisplayResolution(resolution, prob) const subject = `Resolved ${outcome}: ${contract.question}` @@ -88,3 +95,37 @@ Austin from Manifold https://manifold.markets/` ) } + +export const sendMarketCloseEmail = async ( + user: User, + privateUser: PrivateUser, + contract: Contract +) => { + if ( + !privateUser || + privateUser.unsubscribedFromResolutionEmails || + !privateUser.email + ) + return + + const { username, name, id: userId } = user + const firstName = name.split(' ')[0] + + const { question, pool: pools, slug } = contract + const pool = formatMoney(_.sum(_.values(pools))) + const url = `https://manifold.markets/${username}/${slug}` + + await sendTemplateEmail( + privateUser.email, + 'Your market has closed', + 'market-close', + { + name: firstName, + question, + pool, + url, + userId, + creatorFee: (CREATOR_FEE * 100).toString(), + } + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index f46e72a8..50c748af 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -11,6 +11,7 @@ export * from './sell-bet' export * from './create-contract' export * from './create-user' export * from './create-fold' +export * from './create-answer' export * from './on-fold-follow' export * from './on-fold-delete' export * from './unsubscribe' @@ -18,3 +19,4 @@ export * from './update-contract-metrics' export * from './update-user-metrics' export * from './backup-db' export * from './change-user-info' +export * from './market-close-emails' diff --git a/functions/src/market-close-emails.ts b/functions/src/market-close-emails.ts new file mode 100644 index 00000000..bb144600 --- /dev/null +++ b/functions/src/market-close-emails.ts @@ -0,0 +1,59 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract } from '../../common/contract' +import { getPrivateUser, getUserByUsername } from './utils' +import { sendMarketCloseEmail } from './emails' + +export const marketCloseEmails = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + await sendMarketCloseEmails() + }) + +const firestore = admin.firestore() + +async function sendMarketCloseEmails() { + const contracts = await firestore.runTransaction(async (transaction) => { + const snap = await transaction.get( + firestore.collection('contracts').where('isResolved', '!=', true) + ) + + return snap.docs + .map((doc) => { + const contract = doc.data() as Contract + + if ( + contract.resolution || + (contract.closeEmailsSent ?? 0) >= 1 || + contract.closeTime === undefined || + (contract.closeTime ?? 0) > Date.now() + ) + return undefined + + transaction.update(doc.ref, { + closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1, + }) + + return contract + }) + .filter((x) => !!x) as Contract[] + }) + + for (let contract of contracts) { + console.log( + 'sending close email for', + contract.slug, + 'closed', + contract.closeTime + ) + + const user = await getUserByUsername(contract.creatorUsername) + if (!user) continue + + const privateUser = await getPrivateUser(user.id) + if (!privateUser) continue + + await sendMarketCloseEmail(user, privateUser, contract) + } +} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index dee470d6..e37dc12c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { User } from '../../common/user' -import { getNewBetInfo } from '../../common/new-bet' +import { getNewBinaryBetInfo, getNewMultiBetInfo } from '../../common/new-bet' export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -22,7 +22,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return { status: 'error', message: 'Invalid amount' } - if (outcome !== 'YES' && outcome !== 'NO') + if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) return { status: 'error', message: 'Invalid outcome' } // run as transaction to prevent race conditions @@ -42,16 +42,32 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { closeTime } = contract + const { closeTime, outcomeType } = contract if (closeTime && Date.now() > closeTime) return { status: 'error', message: 'Trading is closed' } + if (outcomeType === 'FREE_RESPONSE') { + const answerSnap = await transaction.get( + contractDoc.collection('answers').doc(outcome) + ) + if (!answerSnap.exists) + return { status: 'error', message: 'Invalid contract' } + } + const newBetDoc = firestore .collection(`contracts/${contractId}/bets`) .doc() const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = - getNewBetInfo(user, outcome, amount, contract, newBetDoc.id) + outcomeType === 'BINARY' + ? getNewBinaryBetInfo( + user, + outcome as 'YES' | 'NO', + amount, + contract, + newBetDoc.id + ) + : getNewMultiBetInfo(user, outcome, amount, contract, newBetDoc.id) transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 5f297595..d0b6d1da 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -25,10 +25,25 @@ export const resolveMarket = functions const { outcome, contractId, probabilityInt } = data - if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + return { status: 'error', message: 'Invalid contract' } + const contract = contractSnap.data() as Contract + const { creatorId, outcomeType } = contract + + if (outcomeType === 'BINARY') { + if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) + return { status: 'error', message: 'Invalid outcome' } + } else if (outcomeType === 'FREE_RESPONSE') { + if (outcome !== 'CANCEL' && isNaN(+outcome)) + return { status: 'error', message: 'Invalid outcome' } + } else { + return { status: 'error', message: 'Invalid contract outcomeType' } + } if ( + outcomeType === 'BINARY' && probabilityInt !== undefined && (probabilityInt < 0 || probabilityInt > 100 || @@ -36,19 +51,13 @@ export const resolveMarket = functions ) return { status: 'error', message: 'Invalid probability' } - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - - if (contract.creatorId !== userId) + if (creatorId !== userId) return { status: 'error', message: 'User not creator of contract' } if (contract.resolution) return { status: 'error', message: 'Contract already resolved' } - const creator = await getUser(contract.creatorId) + const creator = await getUser(creatorId) if (!creator) return { status: 'error', message: 'Creator not found' } const resolutionProbability = @@ -112,7 +121,7 @@ const sendResolutionEmails = async ( userPayouts: { [userId: string]: number }, creator: User, contract: Contract, - outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT', + outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' | string, resolutionProbability?: number ) => { const nonWinners = _.difference( diff --git a/functions/src/scripts/migrate-to-dpm-2.ts b/functions/src/scripts/migrate-to-dpm-2.ts index 8ceef137..80523a95 100644 --- a/functions/src/scripts/migrate-to-dpm-2.ts +++ b/functions/src/scripts/migrate-to-dpm-2.ts @@ -83,11 +83,15 @@ async function recalculateContract( let totalShares = { YES: Math.sqrt(p) * (phantomAnte + realAnte), NO: Math.sqrt(1 - p) * (phantomAnte + realAnte), + } as { [outcome: string]: number } + + let pool = { YES: p * realAnte, NO: (1 - p) * realAnte } as { + [outcome: string]: number } - let pool = { YES: p * realAnte, NO: (1 - p) * realAnte } - - let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } + let totalBets = { YES: p * realAnte, NO: (1 - p) * realAnte } as { + [outcome: string]: number + } const betsRef = contractRef.collection('bets') diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 5924ad1d..9a7c1b5d 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -14,7 +14,7 @@ const pathsToPrivateKey = { stephen: '../../../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-351a65eca3.json', stephenDev: - '../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json', + '../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json', } export const initAdmin = (who: keyof typeof pathsToPrivateKey) => { diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 95881870..9f3777e8 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -77,13 +77,3 @@ export const chargeUser = (userId: string, charge: number) => { return updateUserBalance(userId, -charge) } - -export const removeUndefinedProps = (obj: T): T => { - let newObj: any = {} - - for (let key of Object.keys(obj)) { - if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] - } - - return newObj -} diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index 1f1a6bdc..b8163719 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -1,10 +1,10 @@ -import { IncomingMessage } from "http"; -import { parse } from "url"; -import { ParsedRequest } from "./types"; +import { IncomingMessage } from 'http' +import { parse } from 'url' +import { ParsedRequest } from './types' export function parseRequest(req: IncomingMessage) { - console.log("HTTP " + req.url); - const { pathname, query } = parse(req.url || "/", true); + console.log('HTTP ' + req.url) + const { pathname, query } = parse(req.url || '/', true) const { fontSize, images, @@ -20,73 +20,73 @@ export function parseRequest(req: IncomingMessage) { creatorName, creatorUsername, creatorAvatarUrl, - } = query || {}; + } = query || {} if (Array.isArray(fontSize)) { - throw new Error("Expected a single fontSize"); + throw new Error('Expected a single fontSize') } if (Array.isArray(theme)) { - throw new Error("Expected a single theme"); + throw new Error('Expected a single theme') } - const arr = (pathname || "/").slice(1).split("."); - let extension = ""; - let text = ""; + const arr = (pathname || '/').slice(1).split('.') + let extension = '' + let text = '' if (arr.length === 0) { - text = ""; + text = '' } else if (arr.length === 1) { - text = arr[0]; + text = arr[0] } else { - extension = arr.pop() as string; - text = arr.join("."); + extension = arr.pop() as string + text = arr.join('.') } // Take a url query param and return a single string const getString = (stringOrArray: string[] | string | undefined): string => { if (Array.isArray(stringOrArray)) { // If the query param is an array, return the first element - return stringOrArray[0]; + return stringOrArray[0] } - return stringOrArray || ""; - }; + return stringOrArray || '' + } const parsedRequest: ParsedRequest = { - fileType: extension === "jpeg" ? extension : "png", + fileType: extension === 'jpeg' ? extension : 'png', text: decodeURIComponent(text), - theme: theme === "dark" ? "dark" : "light", - md: md === "1" || md === "true", - fontSize: fontSize || "96px", + theme: theme === 'dark' ? 'dark' : 'light', + md: md === '1' || md === 'true', + fontSize: fontSize || '96px', images: getArray(images), widths: getArray(widths), heights: getArray(heights), question: - getString(question) || "Will you create a prediction market on Manifold?", - probability: getString(probability) || "85%", - metadata: getString(metadata) || "Jan 1  •  M$ 123 pool", - creatorName: getString(creatorName) || "Manifold Markets", - creatorUsername: getString(creatorUsername) || "ManifoldMarkets", - creatorAvatarUrl: getString(creatorAvatarUrl) || "", - }; - parsedRequest.images = getDefaultImages(parsedRequest.images); - return parsedRequest; + getString(question) || 'Will you create a prediction market on Manifold?', + probability: getString(probability), + metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', + creatorName: getString(creatorName) || 'Manifold Markets', + creatorUsername: getString(creatorUsername) || 'ManifoldMarkets', + creatorAvatarUrl: getString(creatorAvatarUrl) || '', + } + parsedRequest.images = getDefaultImages(parsedRequest.images) + return parsedRequest } function getArray(stringOrArray: string[] | string | undefined): string[] { - if (typeof stringOrArray === "undefined") { - return []; + if (typeof stringOrArray === 'undefined') { + return [] } else if (Array.isArray(stringOrArray)) { - return stringOrArray; + return stringOrArray } else { - return [stringOrArray]; + return [stringOrArray] } } function getDefaultImages(images: string[]): string[] { - const defaultImage = "https://manifold.markets/logo.png"; + const defaultImage = 'https://manifold.markets/logo.png' if (!images || !images[0]) { - return [defaultImage]; + return [defaultImage] } - return images; + return images } diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index 2b5a8bb2..73105f6b 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -1,15 +1,15 @@ -import { sanitizeHtml } from "./sanitizer"; -import { ParsedRequest } from "./types"; +import { sanitizeHtml } from './sanitizer' +import { ParsedRequest } from './types' function getCss(theme: string, fontSize: string) { - let background = "white"; - let foreground = "black"; - let radial = "lightgray"; + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' - if (theme === "dark") { - background = "black"; - foreground = "white"; - radial = "dimgray"; + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' } // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` return ` @@ -78,7 +78,7 @@ function getCss(theme: string, fontSize: string) { .text-primary { color: #11b981; } - `; + ` } export function getHtml(parsedReq: ParsedRequest) { @@ -92,8 +92,8 @@ export function getHtml(parsedReq: ParsedRequest) { creatorName, creatorUsername, creatorAvatarUrl, - } = parsedReq; - const hideAvatar = creatorAvatarUrl ? "" : "hidden"; + } = parsedReq + const hideAvatar = creatorAvatarUrl ? '' : 'hidden' return ` @@ -145,7 +145,7 @@ export function getHtml(parsedReq: ParsedRequest) {
${probability}
-
chance
+
${probability !== '' ? 'chance' : ''}
@@ -157,5 +157,5 @@ export function getHtml(parsedReq: ParsedRequest) { -`; +` } diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 697f4d33..84ba850c 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -2,7 +2,7 @@ import Head from 'next/head' export type OgCardProps = { question: string - probability: string + probability?: string metadata: string creatorName: string creatorUsername: string @@ -11,11 +11,16 @@ export type OgCardProps = { } function buildCardUrl(props: OgCardProps) { + const probabilityParam = + props.probability === undefined + ? '' + : `&probability=${encodeURIComponent(props.probability ?? '')}` + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + `?question=${encodeURIComponent(props.question)}` + - `&probability=${encodeURIComponent(props.probability)}` + + probabilityParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` diff --git a/web/components/answers-panel.tsx b/web/components/answers-panel.tsx new file mode 100644 index 00000000..483190ef --- /dev/null +++ b/web/components/answers-panel.tsx @@ -0,0 +1,546 @@ +import clsx from 'clsx' +import _ from 'lodash' +import { useEffect, useRef, useState } from 'react' +import Textarea from 'react-expanding-textarea' +import { XIcon } from '@heroicons/react/solid' + +import { Answer } from '../../common/answer' +import { Contract } from '../../common/contract' +import { AmountInput } from './amount-input' +import { Col } from './layout/col' +import { createAnswer, placeBet, resolveMarket } from '../lib/firebase/api-call' +import { Row } from './layout/row' +import { Avatar } from './avatar' +import { SiteLink } from './site-link' +import { DateTimeTooltip } from './datetime-tooltip' +import dayjs from 'dayjs' +import { BuyButton, ChooseCancelSelector } from './yes-no-selector' +import { Spacer } from './layout/spacer' +import { + formatMoney, + formatPercent, + formatWithCommas, +} from '../../common/util/format' +import { InfoTooltip } from './info-tooltip' +import { useUser } from '../hooks/use-user' +import { + getProbabilityAfterBet, + getOutcomeProbability, + calculateShares, + calculatePayoutAfterCorrectBet, +} from '../../common/calculate' +import { firebaseLogin } from '../lib/firebase/users' +import { Bet } from '../../common/bet' +import { useAnswers } from '../hooks/use-answers' +import { ResolveConfirmationButton } from './confirmation-button' +import { tradingAllowed } from '../lib/firebase/contracts' + +export function AnswersPanel(props: { contract: Contract; answers: Answer[] }) { + const { contract } = props + const { creatorId, resolution } = contract + + const answers = useAnswers(contract.id) ?? props.answers + const [chosenAnswer, otherAnswers] = _.partition( + answers.filter((answer) => answer.id !== '0'), + (answer) => answer.id === resolution + ) + const sortedAnswers = [ + ...chosenAnswer, + ..._.sortBy( + otherAnswers, + (answer) => -1 * getOutcomeProbability(contract.totalShares, answer.id) + ), + ] + + const user = useUser() + + const [resolveOption, setResolveOption] = useState< + 'CHOOSE' | 'CANCEL' | undefined + >() + const [answerChoice, setAnswerChoice] = useState() + + useEffect(() => { + if (resolveOption !== 'CHOOSE' && answerChoice) setAnswerChoice(undefined) + }, [answerChoice, resolveOption]) + + return ( + + {sortedAnswers.map((answer) => ( + setAnswerChoice(answer.id)} + /> + ))} + + {sortedAnswers.length === 0 ? ( +
No answers yet...
+ ) : ( +
+ None of the above:{' '} + {formatPercent(getOutcomeProbability(contract.totalShares, '0'))} +
+ )} + + {tradingAllowed(contract) && } + + {user?.id === creatorId && !resolution && ( + + )} + + ) +} + +function AnswerItem(props: { + answer: Answer + contract: Contract + showChoice: boolean + isChosen: boolean + onChoose: () => void +}) { + const { answer, contract, showChoice, isChosen, onChoose } = props + const { resolution, totalShares } = contract + const { username, avatarUrl, name, createdTime, number, text } = answer + + const createdDate = dayjs(createdTime).format('MMM D') + const prob = getOutcomeProbability(totalShares, answer.id) + const probPercent = formatPercent(prob) + const wasResolvedTo = resolution === answer.id + + const [isBetting, setIsBetting] = useState(false) + + return ( + + +
{text}
+ + + + + +
{name}
+
+
+ +
+ +
+ + {createdDate} + +
+
+
#{number}
+
+ + + {isBetting ? ( + setIsBetting(false)} + /> + ) : ( + + {!wasResolvedTo && ( +
+ {probPercent} +
+ )} + {showChoice ? ( +
+ +
+ ) : ( + <> + {tradingAllowed(contract) && ( + { + setIsBetting(true) + }} + /> + )} + {wasResolvedTo && ( + +
Chosen
+
{probPercent}
+ + )} + + )} +
+ )} + + ) +} + +function AnswerBetPanel(props: { + answer: Answer + contract: Contract + closePanel: () => void +}) { + const { answer, contract, closePanel } = props + const { id: answerId } = answer + + const user = useUser() + const [betAmount, setBetAmount] = useState(undefined) + + const [error, setError] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + + const inputRef = useRef(null) + useEffect(() => { + inputRef.current && inputRef.current.focus() + }, []) + + async function submitBet() { + if (!user || !betAmount) return + + if (user.balance < betAmount) { + setError('Insufficient balance') + return + } + + setError(undefined) + setIsSubmitting(true) + + const result = await placeBet({ + amount: betAmount, + outcome: answerId, + contractId: contract.id, + }).then((r) => r.data as any) + + console.log('placed bet. Result:', result) + + if (result?.status === 'success') { + setIsSubmitting(false) + closePanel() + } else { + setError(result?.error || 'Error placing bet') + setIsSubmitting(false) + } + } + + const betDisabled = isSubmitting || !betAmount || error + + const initialProb = getOutcomeProbability(contract.totalShares, answer.id) + + const resultProb = getProbabilityAfterBet( + contract.totalShares, + answerId, + betAmount ?? 0 + ) + + const shares = calculateShares(contract.totalShares, betAmount ?? 0, answerId) + + const currentPayout = betAmount + ? calculatePayoutAfterCorrectBet(contract, { + outcome: answerId, + amount: betAmount, + shares, + } as Bet) + : 0 + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + + return ( + + +
Buy this answer
+ + +
+
Amount
+ + + + +
Implied probability
+ +
{formatPercent(initialProb)}
+
+
{formatPercent(resultProb)}
+
+ + + + + Payout if chosen + + +
+ {formatMoney(currentPayout)} +   (+{currentReturnPercent}) +
+ + + + {user ? ( + + ) : ( + + )} + + ) +} + +function CreateAnswerInput(props: { contract: Contract }) { + const { contract } = props + const [text, setText] = useState('') + const [betAmount, setBetAmount] = useState(10) + const [amountError, setAmountError] = useState() + const [isSubmitting, setIsSubmitting] = useState(false) + + const canSubmit = text && betAmount && !amountError && !isSubmitting + + const submitAnswer = async () => { + if (canSubmit) { + setIsSubmitting(true) + const result = await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }).then((r) => r.data) + + setIsSubmitting(false) + + if (result.status === 'success') { + setText('') + setBetAmount(10) + setAmountError(undefined) + } + } + } + + const resultProb = getProbabilityAfterBet( + contract.totalShares, + 'new', + betAmount ?? 0 + ) + + const shares = calculateShares(contract.totalShares, betAmount ?? 0, 'new') + + const currentPayout = betAmount + ? calculatePayoutAfterCorrectBet(contract, { + outcome: 'new', + amount: betAmount, + shares, + } as Bet) + : 0 + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + const currentReturnPercent = (currentReturn * 100).toFixed() + '%' + + return ( + + +
Add your answer
+