Create answer
This commit is contained in:
parent
f42f9d9a1a
commit
893016a7c3
27
common/calculate-multi.ts
Normal file
27
common/calculate-multi.ts
Normal file
|
@ -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
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
98
functions/src/create-answer.ts
Normal file
98
functions/src/create-answer.ts
Normal file
|
@ -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()
|
|
@ -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'
|
||||
|
|
|
@ -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, {
|
||||
|
|
95
web/components/answers-panel.tsx
Normal file
95
web/components/answers-panel.tsx
Normal file
|
@ -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 (
|
||||
<Col>
|
||||
<CreateAnswerInput contract={contract} />
|
||||
{answers.map((answer) => (
|
||||
<div>{answer.text}</div>
|
||||
))}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateAnswerInput(props: { contract: Contract<'MULTI'> }) {
|
||||
const { contract } = props
|
||||
const [text, setText] = useState('')
|
||||
const [betAmount, setBetAmount] = useState<number | undefined>(10)
|
||||
const [amountError, setAmountError] = useState<string | undefined>()
|
||||
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 (
|
||||
<Col className="gap-4">
|
||||
<div className="text-xl text-indigo-700">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="textarea textarea-bordered w-full"
|
||||
placeholder="Type your answer..."
|
||||
rows={1}
|
||||
maxLength={10000}
|
||||
/>
|
||||
<Col className="gap-2 justify-between self-end sm:flex-row">
|
||||
{text && (
|
||||
<Col className="gap-2">
|
||||
<div className="text-gray-500 text-sm">Bet amount</div>
|
||||
<AmountInput
|
||||
amount={betAmount}
|
||||
onChange={setBetAmount}
|
||||
error={amountError}
|
||||
setError={setAmountError}
|
||||
minimumAmount={10}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-sm self-start',
|
||||
canSubmit ? 'btn-outline' : 'btn-disabled'
|
||||
)}
|
||||
disabled={!canSubmit}
|
||||
onClick={submitAnswer}
|
||||
>
|
||||
Submit answer
|
||||
</button>
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '@heroicons/react/solid'
|
||||
import dayjs from 'dayjs'
|
||||
import clsx from 'clsx'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { OutcomeLabel } from './outcome-label'
|
||||
import {
|
||||
|
@ -37,7 +38,6 @@ import { useBetsWithoutAntes } from '../hooks/use-bets'
|
|||
import { Bet } from '../lib/firebase/bets'
|
||||
import { Comment, mapCommentsByBetId } from '../lib/firebase/comments'
|
||||
import { JoinSpans } from './join-spans'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { outcome } from '../../common/contract'
|
||||
import { fromNow } from '../lib/util/time'
|
||||
import BetRow from './bet-row'
|
||||
|
@ -204,7 +204,7 @@ function EditContract(props: {
|
|||
) : (
|
||||
<Row>
|
||||
<button
|
||||
className="btn btn-neutral btn-outline btn-sm mt-4"
|
||||
className="btn btn-neutral btn-outline btn-xs mt-4"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{props.buttonText}
|
||||
|
|
|
@ -18,6 +18,16 @@ export const createFold = cloudFunction<
|
|||
|
||||
export const placeBet = cloudFunction('placeBet')
|
||||
|
||||
export const createAnswer = cloudFunction<
|
||||
{ contractId: string; text: string; amount: number },
|
||||
{
|
||||
status: 'error' | 'success'
|
||||
message?: string
|
||||
answerId?: string
|
||||
betId?: string
|
||||
}
|
||||
>('createAnswer')
|
||||
|
||||
export const resolveMarket = cloudFunction('resolveMarket')
|
||||
|
||||
export const sellBet = cloudFunction('sellBet')
|
||||
|
|
|
@ -49,7 +49,7 @@ export function getBinaryProbPercent(contract: Contract) {
|
|||
return probPercent
|
||||
}
|
||||
|
||||
export function tradingAllowed(contract: Contract) {
|
||||
export function tradingAllowed(contract: Contract<'BINARY' | 'MULTI'>) {
|
||||
return (
|
||||
!contract.isResolved &&
|
||||
(!contract.closeTime || contract.closeTime > Date.now())
|
||||
|
@ -84,7 +84,9 @@ export async function getContractFromSlug(slug: string) {
|
|||
const q = query(contractCollection, where('slug', '==', slug))
|
||||
const snapshot = await getDocs(q)
|
||||
|
||||
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
|
||||
return snapshot.empty
|
||||
? undefined
|
||||
: (snapshot.docs[0].data() as Contract<'BINARY' | 'MULTI'>)
|
||||
}
|
||||
|
||||
export async function deleteContract(contractId: string) {
|
||||
|
|
|
@ -26,6 +26,9 @@ import Custom404 from '../404'
|
|||
import { getFoldsByTags } from '../../lib/firebase/folds'
|
||||
import { Fold } from '../../../common/fold'
|
||||
import { useFoldsWithTags } from '../../hooks/use-fold'
|
||||
import { listAllAnswers } from '../../lib/firebase/answers'
|
||||
import { Answer } from '../../../common/answer'
|
||||
import { AnswersPanel } from '../../components/answers-panel'
|
||||
|
||||
export async function getStaticProps(props: {
|
||||
params: { username: string; contractSlug: string }
|
||||
|
@ -36,9 +39,12 @@ export async function getStaticProps(props: {
|
|||
|
||||
const foldsPromise = getFoldsByTags(contract?.tags ?? [])
|
||||
|
||||
const [bets, comments] = await Promise.all([
|
||||
const [bets, comments, answers] = await Promise.all([
|
||||
contractId ? listAllBets(contractId) : [],
|
||||
contractId ? listAllComments(contractId) : [],
|
||||
contractId && contract.outcomes === 'FREE_ANSWER'
|
||||
? listAllAnswers(contractId)
|
||||
: [],
|
||||
])
|
||||
|
||||
const folds = await foldsPromise
|
||||
|
@ -50,6 +56,7 @@ export async function getStaticProps(props: {
|
|||
slug: contractSlug,
|
||||
bets,
|
||||
comments,
|
||||
answers,
|
||||
folds,
|
||||
},
|
||||
|
||||
|
@ -66,6 +73,7 @@ export default function ContractPage(props: {
|
|||
username: string
|
||||
bets: Bet[]
|
||||
comments: Comment[]
|
||||
answers: Answer[]
|
||||
slug: string
|
||||
folds: Fold[]
|
||||
}) {
|
||||
|
@ -112,6 +120,10 @@ export default function ContractPage(props: {
|
|||
comments={comments ?? []}
|
||||
folds={folds}
|
||||
/>
|
||||
|
||||
{contract.outcomes === 'FREE_ANSWER' && (
|
||||
<AnswersPanel contract={contract as any} answers={props.answers} />
|
||||
)}
|
||||
<BetsSection contract={contract} user={user ?? null} bets={bets} />
|
||||
</div>
|
||||
|
||||
|
@ -140,6 +152,7 @@ function BetsSection(props: {
|
|||
bets: Bet[]
|
||||
}) {
|
||||
const { contract, user } = props
|
||||
const isBinary = contract.outcomeType === 'BINARY'
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
|
||||
// Decending creation time.
|
||||
|
@ -152,13 +165,17 @@ function BetsSection(props: {
|
|||
return (
|
||||
<div>
|
||||
<Title className="px-2" text="Your trades" />
|
||||
<MyBetsSummary
|
||||
className="px-2"
|
||||
contract={contract}
|
||||
bets={userBets}
|
||||
showMKT
|
||||
/>
|
||||
<Spacer h={6} />
|
||||
{isBinary && (
|
||||
<>
|
||||
<MyBetsSummary
|
||||
className="px-2"
|
||||
contract={contract}
|
||||
bets={userBets}
|
||||
showMKT
|
||||
/>
|
||||
<Spacer h={6} />
|
||||
</>
|
||||
)}
|
||||
<ContractBetsTable contract={contract} bets={userBets} />
|
||||
<Spacer h={12} />
|
||||
</div>
|
||||
|
|
Loading…
Reference in New Issue
Block a user