From 52172700734e0bd5610adc3819ece894968233ae Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Thu, 26 May 2022 14:37:51 -0700 Subject: [PATCH] Serious business API validation & big cleanup of `createContract`, `placeBet` (#302) * Add the great Zod as a dependency to help us * Tweak eslint * Rewrite a ton of stuff in createContract and placeBet * Clean up error reporting in API * Make sure the UI is enforcing validated limits on lengths * Remove unnecessary Math.abs * Better type on `BetInfo` * Kill `manaLimitPerUser` * Clean up hacky parameters on bet info functions * Validate `closeTime` as a valid timestamp in the future --- common/.eslintrc.js | 2 + common/contract.ts | 10 +- common/new-bet.ts | 59 +++----- common/new-contract.ts | 4 +- functions/.eslintrc.js | 1 + functions/package.json | 3 +- functions/src/api.ts | 49 +++++-- functions/src/create-answer.ts | 31 ++--- functions/src/create-contract.ts | 116 ++++++---------- functions/src/place-bet.ts | 200 +++++++++++---------------- web/components/numeric-bet-panel.tsx | 4 +- web/components/tags-input.tsx | 2 + web/pages/create.tsx | 7 +- yarn.lock | 5 + 14 files changed, 221 insertions(+), 272 deletions(-) diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 6e7b62cd..e03e5ef5 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -21,6 +21,8 @@ module.exports = { }, ], rules: { + 'no-extra-semi': 'off', + 'no-unused-vars': 'off', 'no-constant-condition': ['error', { checkLoops: false }], 'lodash/import-scope': [2, 'member'], }, diff --git a/common/contract.ts b/common/contract.ts index a8f2bd42..e9b768ea 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -31,8 +31,6 @@ export type FullContract< closeEmailsSent?: number - manaLimitPerUser?: number - volume: number volume24Hours: number volume7Days: number @@ -97,8 +95,12 @@ export type Numeric = { } export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' | 'NUMERIC' -export const OUTCOME_TYPES = ['BINARY', 'MULTI', 'FREE_RESPONSE', 'NUMERIC'] - +export const OUTCOME_TYPES = [ + 'BINARY', + 'MULTI', + 'FREE_RESPONSE', + 'NUMERIC', +] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 export const MAX_TAG_LENGTH = 60 diff --git a/common/new-bet.ts b/common/new-bet.ts index b640c0b5..1053ec58 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -18,18 +18,25 @@ import { Multi, NumericContract, } from './contract' -import { User } from './user' import { noFees } from './fees' import { addObjects } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' +export type CandidateBet = Omit +export type BetInfo = { + newBet: CandidateBet + newPool?: { [outcome: string]: number } + newTotalShares?: { [outcome: string]: number } + newTotalBets?: { [outcome: string]: number } + newTotalLiquidity?: number + newP?: number +} + export const getNewBinaryCpmmBetInfo = ( - user: User, outcome: 'YES' | 'NO', amount: number, contract: FullContract, - loanAmount: number, - newBetId: string + loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( contract, @@ -37,15 +44,11 @@ export const getNewBinaryCpmmBetInfo = ( outcome ) - const newBalance = user.balance - (amount - loanAmount) - const { pool, p, totalLiquidity } = contract const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, newP) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount, shares, @@ -60,16 +63,14 @@ export const getNewBinaryCpmmBetInfo = ( const { liquidityFee } = fees const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee - return { newBet, newPool, newP, newBalance, newTotalLiquidity, fees } + return { newBet, newPool, newP, newTotalLiquidity } } export const getNewBinaryDpmBetInfo = ( - user: User, outcome: 'YES' | 'NO', amount: number, contract: FullContract, - loanAmount: number, - newBetId: string + loanAmount: number ) => { const { YES: yesPool, NO: noPool } = contract.pool @@ -97,9 +98,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -111,18 +110,14 @@ export const getNewBinaryDpmBetInfo = ( fees: noFees, } - const newBalance = user.balance - (amount - loanAmount) - - return { newBet, newPool, newTotalShares, newTotalBets, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets } } export const getNewMultiBetInfo = ( - user: User, outcome: string, amount: number, contract: FullContract, - loanAmount: number, - newBetId: string + loanAmount: number ) => { const { pool, totalShares, totalBets } = contract @@ -140,9 +135,7 @@ export const getNewMultiBetInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -154,18 +147,14 @@ export const getNewMultiBetInfo = ( fees: noFees, } - const newBalance = user.balance - (amount - loanAmount) - - return { newBet, newPool, newTotalShares, newTotalBets, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets } } export const getNumericBetsInfo = ( - user: User, value: number, outcome: string, amount: number, - contract: NumericContract, - newBetId: string + contract: NumericContract ) => { const { pool, totalShares, totalBets } = contract @@ -187,9 +176,7 @@ export const getNumericBetsInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: NumericBet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, value, amount, @@ -203,9 +190,7 @@ export const getNumericBetsInfo = ( fees: noFees, } - const newBalance = user.balance - amount - - return { newBet, newPool, newTotalShares, newTotalBets, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets } } export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => { diff --git a/common/new-contract.ts b/common/new-contract.ts index b70dee37..0b7d294a 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -27,8 +27,7 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number, - manaLimitPerUser: number + max: number ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -70,7 +69,6 @@ export function getNewContract( liquidityFee: 0, platformFee: 0, }, - manaLimitPerUser, }) return contract as Contract diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index c5b8e16f..5d66d2c6 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { }, ], rules: { + 'no-extra-semi': 'off', 'no-unused-vars': 'off', 'no-constant-condition': ['error', { checkLoops: false }], 'lodash/import-scope': [2, 'member'], diff --git a/functions/package.json b/functions/package.json index d3ee68e2..49352969 100644 --- a/functions/package.json +++ b/functions/package.json @@ -28,7 +28,8 @@ "mailgun-js": "0.22.0", "module-alias": "2.2.2", "react-query": "3.39.0", - "stripe": "8.194.0" + "stripe": "8.194.0", + "zod": "3.17.2" }, "devDependencies": { "@types/mailgun-js": "0.22.12", diff --git a/functions/src/api.ts b/functions/src/api.ts index fa3a9aa6..31f14257 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -1,6 +1,7 @@ import * as admin from 'firebase-admin' import * as functions from 'firebase-functions' import * as Cors from 'cors' +import { z } from 'zod' import { User, PrivateUser } from '../../common/user' import { @@ -8,10 +9,11 @@ import { CORS_ORIGIN_LOCALHOST, } from '../../common/envs/constants' +type Output = Record type Request = functions.https.Request type Response = functions.Response -type Handler = (req: Request, res: Response) => Promise type AuthedUser = [User, PrivateUser] +type Handler = (req: Request, user: AuthedUser) => Promise type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type KeyCredentials = { kind: 'key'; data: string } type Credentials = JwtCredentials | KeyCredentials @@ -19,10 +21,13 @@ type Credentials = JwtCredentials | KeyCredentials export class APIError { code: number msg: string - constructor(code: number, msg: string) { + details: unknown + constructor(code: number, msg: string, details?: unknown) { this.code = code this.msg = msg + this.details = details } + toJson() {} } export const parseCredentials = async (req: Request): Promise => { @@ -40,14 +45,11 @@ export const parseCredentials = async (req: Request): Promise => { case 'Bearer': try { const jwt = await admin.auth().verifyIdToken(payload) - if (!jwt.user_id) { - throw new APIError(403, 'JWT must contain Manifold user ID.') - } return { kind: 'jwt', data: jwt } } catch (err) { // This is somewhat suspicious, so get it into the firebase console functions.logger.error('Error verifying Firebase JWT: ', err) - throw new APIError(403, `Error validating token: ${err}.`) + throw new APIError(403, 'Error validating token.') } case 'Key': return { kind: 'key', data: payload } @@ -63,6 +65,9 @@ export const lookupUser = async (creds: Credentials): Promise => { switch (creds.kind) { case 'jwt': { const { user_id } = creds.data + if (typeof user_id !== 'string') { + throw new APIError(403, 'JWT must contain Manifold user ID.') + } const [userSnap, privateUserSnap] = await Promise.all([ users.doc(user_id).get(), privateUsers.doc(user_id).get(), @@ -109,6 +114,27 @@ export const applyCors = ( }) } +export const zTimestamp = () => { + return z.preprocess((arg) => { + return typeof arg == 'number' ? new Date(arg) : undefined + }, z.date()) +} + +export const validate = (schema: T, val: unknown) => { + const result = schema.safeParse(val) + if (!result.success) { + const issues = result.error.issues.map((i) => { + return { + field: i.path.join('.') || null, + error: i.message, + } + }) + throw new APIError(400, 'Error validating request.', issues) + } else { + return result.data as z.infer + } +} + export const newEndpoint = (methods: [string], fn: Handler) => functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => { await applyCors(req, res, { @@ -120,12 +146,17 @@ export const newEndpoint = (methods: [string], fn: Handler) => const allowed = methods.join(', ') throw new APIError(405, `This endpoint supports only ${allowed}.`) } - res.status(200).json(await fn(req, res)) + const authedUser = await lookupUser(await parseCredentials(req)) + res.status(200).json(await fn(req, authedUser)) } catch (e) { if (e instanceof APIError) { - // Emit a 200 anyway here for now, for backwards compatibility - res.status(e.code).json({ message: e.msg }) + const output: { [k: string]: unknown } = { message: e.msg } + if (e.details != null) { + output.details = e.details + } + res.status(e.code).json(output) } else { + functions.logger.error(e) res.status(500).json({ message: 'An unknown error occurred.' }) } } diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index bc74dc05..7fbe5416 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -12,7 +12,6 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' -import { Bet } from '../../common/bet' export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -61,11 +60,6 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( if (closeTime && Date.now() > closeTime) return { status: 'error', message: 'Trading is closed' } - const yourBetsSnap = await transaction.get( - contractDoc.collection('bets').where('userId', '==', userId) - ) - const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) - const [lastAnswer] = await getValues( firestore .collection(`contracts/${contractId}/answers`) @@ -99,23 +93,20 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( } transaction.create(newAnswerDoc, answer) - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() + const loanAmount = 0 - const loanAmount = 0 // getLoanAmount(yourBets, amount) - - const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = + const { newBet, newPool, newTotalShares, newTotalBets } = getNewMultiBetInfo( - user, answerId, amount, contract as FullContract, - loanAmount, - newBetDoc.id + loanAmount ) - transaction.create(newBetDoc, newBet) + const newBalance = user.balance - amount + const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() + transaction.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) + transaction.update(userDoc, { balance: newBalance }) transaction.update(contractDoc, { pool: newPool, totalShares: newTotalShares, @@ -124,13 +115,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( volume: volume + amount, }) - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } - - transaction.update(userDoc, { balance: newBalance }) - - return { status: 'success', answerId, betId: newBetDoc.id, answer } + return { status: 'success', answerId, betId: betDoc.id, answer } }) const { answer } = result diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 952d396c..42a1c376 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -1,4 +1,5 @@ import * as admin from 'firebase-admin' +import { z } from 'zod' import { Binary, @@ -17,7 +18,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser } from './utils' -import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' +import { APIError, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, @@ -26,66 +27,45 @@ import { getFreeAnswerAnte, getNumericAnte, HOUSE_LIQUIDITY_PROVIDER_ID, - MINIMUM_ANTE, } from '../../common/antes' import { getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' -export const createContract = newEndpoint(['POST'], async (req, _res) => { - const [creator, _privateUser] = await lookupUser(await parseCredentials(req)) - let { - question, - outcomeType, - description, - initialProb, - closeTime, - tags, - min, - max, - manaLimitPerUser, - } = req.body || {} +const bodySchema = z.object({ + question: z.string().min(1).max(MAX_QUESTION_LENGTH), + description: z.string().max(MAX_DESCRIPTION_LENGTH), + tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(), + closeTime: zTimestamp().refine( + (date) => date.getTime() > new Date().getTime(), + 'Close time must be in the future.' + ), + outcomeType: z.enum(OUTCOME_TYPES), +}) - if (!question || typeof question != 'string') - throw new APIError(400, 'Missing or invalid question field') +const binarySchema = z.object({ + initialProb: z.number().min(1).max(99), +}) - question = question.slice(0, MAX_QUESTION_LENGTH) +const numericSchema = z.object({ + min: z.number(), + max: z.number(), +}) - if (typeof description !== 'string') - throw new APIError(400, 'Invalid description field') - - description = description.slice(0, MAX_DESCRIPTION_LENGTH) - - if (tags !== undefined && !Array.isArray(tags)) - throw new APIError(400, 'Invalid tags field') - - tags = (tags || []).map((tag: string) => - tag.toString().slice(0, MAX_TAG_LENGTH) +export const createContract = newEndpoint(['POST'], async (req, [user, _]) => { + const { question, description, tags, closeTime, outcomeType } = validate( + bodySchema, + req.body ) - outcomeType = outcomeType ?? 'BINARY' - - if (!OUTCOME_TYPES.includes(outcomeType)) - throw new APIError(400, 'Invalid outcomeType') - - if ( - outcomeType === 'NUMERIC' && - !( - min !== undefined && - max !== undefined && - isFinite(min) && - isFinite(max) && - min < max && - max - min > 0.01 - ) - ) - throw new APIError(400, 'Invalid range') - - if ( - outcomeType === 'BINARY' && - (!initialProb || initialProb < 1 || initialProb > 99) - ) - throw new APIError(400, 'Invalid initial probability') + let min, max, initialProb + if (outcomeType === 'NUMERIC') { + ;({ min, max } = validate(numericSchema, req.body)) + if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') + } + if (outcomeType === 'BINARY') { + ;({ initialProb } = validate(binarySchema, req.body)) + } // Uses utc time on server: const today = new Date() @@ -96,7 +76,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { const userContractsCreatedTodaySnapshot = await firestore .collection(`contracts`) - .where('creatorId', '==', creator.id) + .where('creatorId', '==', user.id) .where('createdTime', '>=', freeMarketResetTime) .get() console.log('free market reset time: ', freeMarketResetTime) @@ -104,18 +84,9 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { const ante = FIXED_ANTE - if ( - ante === undefined || - ante < MINIMUM_ANTE || - (ante > creator.balance && !isFree) || - isNaN(ante) || - !isFinite(ante) - ) - throw new APIError(400, 'Invalid ante') - console.log( 'creating contract for', - creator.username, + user.username, 'on', question, 'ante:', @@ -123,31 +94,28 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { ) const slug = await getSlug(question) - const contractRef = firestore.collection('contracts').doc() - const contract = getNewContract( contractRef.id, slug, - creator, + user, question, outcomeType, description, - initialProb, + initialProb ?? 0, ante, - closeTime, + closeTime.getTime(), tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0, - manaLimitPerUser ?? 0 + max ?? 0 ) - if (!isFree && ante) await chargeUser(creator.id, ante, true) + if (!isFree && ante) await chargeUser(user.id, ante, true) await contractRef.create(contract) - const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id + const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') { const yesBetDoc = firestore @@ -157,7 +125,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { const noBetDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() const { yesBet, noBet } = getAnteBets( - creator, + user, contract as FullContract, yesBetDoc.id, noBetDoc.id @@ -183,7 +151,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { .collection(`contracts/${contract.id}/answers`) .doc('0') - const noneAnswer = getNoneAnswer(contract.id, creator) + const noneAnswer = getNoneAnswer(contract.id, user) await noneAnswerDoc.set(noneAnswer) const anteBetDoc = firestore @@ -202,7 +170,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { .doc() const anteBet = getNumericAnte( - creator, + user, contract as FullContract, ante, anteBetDoc.id diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index d97effe4..184ee2df 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -1,146 +1,112 @@ import * as admin from 'firebase-admin' +import { z } from 'zod' -import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' +import { APIError, newEndpoint, validate } from './api' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { + BetInfo, getNewBinaryCpmmBetInfo, getNewBinaryDpmBetInfo, getNewMultiBetInfo, getNumericBetsInfo, } from '../../common/new-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -import { Bet } from '../../common/bet' import { redeemShares } from './redeem-shares' -import { Fees } from '../../common/fees' -export const placeBet = newEndpoint(['POST'], async (req, _res) => { - const [bettor, _privateUser] = await lookupUser(await parseCredentials(req)) - const { amount, outcome, contractId, value } = req.body || {} +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gte(1), +}) - if (amount < 1 || isNaN(amount) || !isFinite(amount)) - throw new APIError(400, 'Invalid amount') +const binarySchema = z.object({ + outcome: z.enum(['YES', 'NO']), +}) - if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) - throw new APIError(400, 'Invalid outcome') +const freeResponseSchema = z.object({ + outcome: z.string(), +}) - if (value !== undefined && !isFinite(value)) - throw new APIError(400, 'Invalid value') +const numericSchema = z.object({ + outcome: z.string(), + value: z.number(), +}) - // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${bettor.id}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) throw new APIError(400, 'User not found') - const user = userSnap.data() as User +export const placeBet = newEndpoint(['POST'], async (req, [bettor, _]) => { + const { amount, contractId } = validate(bodySchema, req.body) - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') - const contract = contractSnap.data() as Contract + const result = await firestore.runTransaction(async (trans) => { + const userDoc = firestore.doc(`users/${bettor.id}`) + const userSnap = await trans.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as User + if (user.balance < amount) throw new APIError(400, 'Insufficient balance.') - const { closeTime, outcomeType, mechanism, collectedFees, volume } = - contract - if (closeTime && Date.now() > closeTime) - throw new APIError(400, 'Trading is closed') + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await trans.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as Contract - const yourBetsSnap = await transaction.get( - contractDoc.collection('bets').where('userId', '==', bettor.id) - ) - const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) + const loanAmount = 0 + const { closeTime, outcomeType, mechanism, collectedFees, volume } = + contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed.') - const loanAmount = 0 // getLoanAmount(yourBets, amount) - if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - - if (outcomeType === 'FREE_RESPONSE') { - const answerSnap = await transaction.get( - contractDoc.collection('answers').doc(outcome) - ) - if (!answerSnap.exists) throw new APIError(400, 'Invalid contract') + const { + newBet, + newPool, + newTotalShares, + newTotalBets, + newTotalLiquidity, + newP, + } = await (async (): Promise => { + if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { + const { outcome } = validate(binarySchema, req.body) + return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) + } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { + const { outcome } = validate(binarySchema, req.body) + return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) + } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { + const { outcome } = validate(freeResponseSchema, req.body) + const answerDoc = contractDoc.collection('answers').doc(outcome) + const answerSnap = await trans.get(answerDoc) + if (!answerSnap.exists) throw new APIError(400, 'Invalid answer') + return getNewMultiBetInfo(outcome, amount, contract, loanAmount) + } else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') { + const { outcome, value } = validate(numericSchema, req.body) + return getNumericBetsInfo(value, outcome, amount, contract) + } else { + throw new APIError(500, 'Contract has invalid type/mechanism.') } + })() - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() + if (newP != null && !isFinite(newP)) { + throw new APIError(400, 'Trade rejected due to overflow error.') + } - const { - newBet, - newPool, - newTotalShares, - newTotalBets, - newBalance, - newTotalLiquidity, - fees, - newP, - } = - outcomeType === 'BINARY' - ? mechanism === 'dpm-2' - ? getNewBinaryDpmBetInfo( - user, - outcome as 'YES' | 'NO', - amount, - contract, - loanAmount, - newBetDoc.id - ) - : (getNewBinaryCpmmBetInfo( - user, - outcome as 'YES' | 'NO', - amount, - contract, - loanAmount, - newBetDoc.id - ) as any) - : outcomeType === 'NUMERIC' && mechanism === 'dpm-2' - ? getNumericBetsInfo( - user, - value, - outcome, - amount, - contract, - newBetDoc.id - ) - : getNewMultiBetInfo( - user, - outcome, - amount, - contract as any, - loanAmount, - newBetDoc.id - ) + const newBalance = user.balance - amount - loanAmount + const betDoc = contractDoc.collection('bets').doc() + trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) + trans.update(userDoc, { balance: newBalance }) + trans.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalShares: newTotalShares, + totalBets: newTotalBets, + totalLiquidity: newTotalLiquidity, + collectedFees: addObjects(newBet.fees, collectedFees), + volume: volume + amount, + }) + ) - if (newP !== undefined && !isFinite(newP)) { - throw new APIError(400, 'Trade rejected due to overflow error.') - } + return { betId: betDoc.id } + }) - transaction.create(newBetDoc, newBet) - - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalShares: newTotalShares, - totalBets: newTotalBets, - totalLiquidity: newTotalLiquidity, - collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), - volume: volume + Math.abs(amount), - }) - ) - - if (!isFinite(newBalance)) { - throw new APIError(500, 'Invalid user balance for ' + user.username) - } - - transaction.update(userDoc, { balance: newBalance }) - - return { betId: newBetDoc.id } - }) - .then(async (result) => { - await redeemShares(bettor.id, contractId) - return result - }) + await redeemShares(bettor.id, contractId) + return result }) const firestore = admin.firestore() diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx index f249e3c3..ebb80dd0 100644 --- a/web/components/numeric-bet-panel.tsx +++ b/web/components/numeric-bet-panel.tsx @@ -102,12 +102,10 @@ function NumericBuyPanel(props: { const betDisabled = isSubmitting || !betAmount || !bucketChoice || error const { newBet, newPool, newTotalShares, newTotalBets } = getNumericBetsInfo( - { id: 'dummy', balance: 0 } as User, // a little hackish value ?? 0, bucketChoice ?? 'NaN', betAmount ?? 0, - contract, - 'dummy id' + contract ) const { probAfter: outcomeProb, shares } = newBet diff --git a/web/components/tags-input.tsx b/web/components/tags-input.tsx index ff9029c9..6a0cdcda 100644 --- a/web/components/tags-input.tsx +++ b/web/components/tags-input.tsx @@ -5,6 +5,7 @@ import { Contract, updateContract } from 'web/lib/firebase/contracts' import { Col } from './layout/col' import { Row } from './layout/row' import { TagsList } from './tags-list' +import { MAX_TAG_LENGTH } from 'common/contract' export function TagsInput(props: { contract: Contract; className?: string }) { const { contract, className } = props @@ -36,6 +37,7 @@ export function TagsInput(props: { contract: Contract; className?: string }) { className="input input-sm input-bordered resize-none" disabled={isSubmitting} value={tagText} + maxLength={MAX_TAG_LENGTH} onChange={(e) => setTagText(e.target.value || '')} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/web/pages/create.tsx b/web/pages/create.tsx index bf035330..342eafeb 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -11,7 +11,11 @@ import { FIXED_ANTE, MINIMUM_ANTE } from 'common/antes' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' import { Row } from 'web/components/layout/row' -import { MAX_DESCRIPTION_LENGTH, outcomeType } from 'common/contract' +import { + MAX_DESCRIPTION_LENGTH, + MAX_QUESTION_LENGTH, + outcomeType, +} from 'common/contract' import { formatMoney } from 'common/util/format' import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today' import { removeUndefinedProps } from 'common/util/object' @@ -37,6 +41,7 @@ export default function Create() { placeholder="e.g. Will the Democrats win the 2024 US presidential election?" className="input input-bordered resize-none" autoFocus + maxLength={MAX_QUESTION_LENGTH} value={question} onChange={(e) => setQuestion(e.target.value || '')} /> diff --git a/yarn.lock b/yarn.lock index 7eecfe4d..982cd49a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5410,3 +5410,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@3.17.2: + version "3.17.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.17.2.tgz#d20b32146a3b5068f8f71768b4f9a4bfe52cddb0" + integrity sha512-L8UPS2J/F3dIA8gsPTvGjd8wSRuwR1Td4AqR2Nw8r8BgcLIbZZ5/tCII7hbTLXTQDhxUnnsFdHwpETGajt5i3A==