From 893016a7c314cfd173df4105dd8856d4e2bc6f83 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 12 Feb 2022 19:25:07 -0600 Subject: [PATCH] Create answer --- common/calculate-multi.ts | 27 +++++++ common/new-bet.ts | 43 ++++++++++- firestore.rules | 8 ++ functions/src/create-answer.ts | 98 +++++++++++++++++++++++++ functions/src/index.ts | 1 + functions/src/place-bet.ts | 4 +- web/components/answers-panel.tsx | 95 ++++++++++++++++++++++++ web/components/contract-feed.tsx | 4 +- web/lib/firebase/api-call.ts | 10 +++ web/lib/firebase/contracts.ts | 6 +- web/pages/[username]/[contractSlug].tsx | 33 +++++++-- 11 files changed, 314 insertions(+), 15 deletions(-) create mode 100644 common/calculate-multi.ts create mode 100644 functions/src/create-answer.ts create mode 100644 web/components/answers-panel.tsx 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 ( + + + {answers.map((answer) => ( +
{answer.text}
+ ))} + + ) +} + +function CreateAnswerInput(props: { contract: Contract<'MULTI'> }) { + 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) + console.log('submitting', { text, betAmount }) + const result = await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }).then((r) => r.data) + + console.log('submit complte', result) + setIsSubmitting(false) + + if (result.status === 'success') { + setText('') + setBetAmount(10) + setAmountError(undefined) + } + } + } + + return ( + +
Add your answer
+