diff --git a/common/calculate-multi.ts b/common/calculate-multi.ts new file mode 100644 index 00000000..5ff23cdb --- /dev/null +++ b/common/calculate-multi.ts @@ -0,0 +1,27 @@ +import * as _ from 'lodash' + +export function getMultiProbability( + totalShares: { + [answerId: string]: number + }, + answerId: string +) { + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + const shares = totalShares[answerId] ?? 0 + return shares ** 2 / squareSum +} + +export function calculateMultiShares( + totalShares: { + [answerId: string]: number + }, + bet: number, + betChoice: string +) { + const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) + const shares = totalShares[betChoice] ?? 0 + + const c = 2 * bet * Math.sqrt(squareSum) + + return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares +} diff --git a/common/new-bet.ts b/common/new-bet.ts index 61c72015..5b23c84e 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,9 +1,10 @@ import { Bet } from './bet' import { calculateShares, getProbability } from './calculate' +import { calculateMultiShares, getMultiProbability } from './calculate-multi' import { Contract } from './contract' import { User } from './user' -export const getNewBetInfo = ( +export const getNewBinaryBetInfo = ( user: User, outcome: 'YES' | 'NO', amount: number, @@ -52,3 +53,43 @@ export const getNewBetInfo = ( return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } + +export const getNewMultiBetInfo = ( + user: User, + outcome: string, + amount: number, + contract: Contract<'MULTI'>, + newBetId: string +) => { + const { pool, totalShares, totalBets } = contract + + const prevOutcomePool = pool[outcome] ?? 0 + const newPool = { ...pool, outcome: prevOutcomePool + amount } + + const shares = calculateMultiShares(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 = getMultiProbability(totalShares, outcome) + const probAfter = getMultiProbability(newTotalShares, outcome) + + const newBet: Bet<'MULTI'> = { + 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/firestore.rules b/firestore.rules index 253d57f5..a48e23c9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -44,6 +44,14 @@ service cloud.firestore { allow read; } + match /contracts/{contractId}/answers/{answerId} { + allow read; + } + + match /{somePath=**}/answers/{answerId} { + allow read; + } + match /folds/{foldId} { allow read; allow update, delete: if request.auth.uid == resource.data.curatorId; diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts new file mode 100644 index 00000000..230c12a3 --- /dev/null +++ b/functions/src/create-answer.ts @@ -0,0 +1,98 @@ +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' + +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 > 1000) + 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<'MULTI'> + + if ( + contract.outcomeType !== 'MULTI' || + contract.outcomes !== 'FREE_ANSWER' + ) + return { + status: 'error', + message: 'Requires a multi, free answer contract', + } + + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + return { status: 'error', message: 'Trading is closed' } + + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc() + + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user + + const answer: Answer = { + id: answerId, + 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) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + }) + transaction.update(userDoc, { balance: newBalance }) + + return { status: 'success', answerId, betId: newBetDoc.id } + }) + } +) + +const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index f46e72a8..7af5b4f4 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' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index dee470d6..333928d8 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 } from '../../common/new-bet' export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -51,7 +51,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( .doc() const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = - getNewBetInfo(user, outcome, amount, contract, newBetDoc.id) + getNewBinaryBetInfo(user, outcome, amount, contract, newBetDoc.id) transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { diff --git a/web/components/answers-panel.tsx b/web/components/answers-panel.tsx new file mode 100644 index 00000000..ccc3a65b --- /dev/null +++ b/web/components/answers-panel.tsx @@ -0,0 +1,95 @@ +import clsx from 'clsx' +import { useState } from 'react' +import Textarea from 'react-expanding-textarea' + +import { Answer } from '../../common/answer' +import { Contract } from '../../common/contract' +import { AmountInput } from './amount-input' +import { Col } from './layout/col' +import { createAnswer } from '../lib/firebase/api-call' + +export function AnswersPanel(props: { + contract: Contract<'MULTI'> + answers: Answer[] +}) { + const { contract, answers } = props + + return ( +