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
This commit is contained in:
parent
09e93779fb
commit
5217270073
|
@ -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'],
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<T extends Bet> = Omit<T, 'id' | 'userId'>
|
||||
export type BetInfo = {
|
||||
newBet: CandidateBet<Bet>
|
||||
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<CPMM, Binary>,
|
||||
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<Bet> = {
|
||||
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<DPM, Binary>,
|
||||
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<Bet> = {
|
||||
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<DPM, Multi | FreeResponse>,
|
||||
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<Bet> = {
|
||||
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<NumericBet> = {
|
||||
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) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<string, unknown>
|
||||
type Request = functions.https.Request
|
||||
type Response = functions.Response
|
||||
type Handler = (req: Request, res: Response) => Promise<any>
|
||||
type AuthedUser = [User, PrivateUser]
|
||||
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
|
||||
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<Credentials> => {
|
||||
|
@ -40,14 +45,11 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
|||
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<AuthedUser> => {
|
|||
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 = <T extends z.ZodTypeAny>(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<T>
|
||||
}
|
||||
}
|
||||
|
||||
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.' })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Answer>(
|
||||
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<DPM, FreeResponse>,
|
||||
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
|
||||
|
|
|
@ -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<DPM, Binary>,
|
||||
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<DPM, Numeric>,
|
||||
ante,
|
||||
anteBetDoc.id
|
||||
|
|
|
@ -1,122 +1,95 @@
|
|||
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) => {
|
||||
export const placeBet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
||||
const { amount, contractId } = validate(bodySchema, req.body)
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const userDoc = firestore.doc(`users/${bettor.id}`)
|
||||
const userSnap = await transaction.get(userDoc)
|
||||
if (!userSnap.exists) throw new APIError(400, 'User not found')
|
||||
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 contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await transaction.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as Contract
|
||||
|
||||
const loanAmount = 0
|
||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||
contract
|
||||
if (closeTime && Date.now() > closeTime)
|
||||
throw new APIError(400, 'Trading is closed')
|
||||
|
||||
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 // 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 newBetDoc = firestore
|
||||
.collection(`contracts/${contractId}/bets`)
|
||||
.doc()
|
||||
throw new APIError(400, 'Trading is closed.')
|
||||
|
||||
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
|
||||
)
|
||||
} = await (async (): Promise<BetInfo> => {
|
||||
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.')
|
||||
}
|
||||
})()
|
||||
|
||||
if (newP !== undefined && !isFinite(newP)) {
|
||||
if (newP != null && !isFinite(newP)) {
|
||||
throw new APIError(400, 'Trade rejected due to overflow error.')
|
||||
}
|
||||
|
||||
transaction.create(newBetDoc, newBet)
|
||||
|
||||
transaction.update(
|
||||
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,
|
||||
|
@ -124,23 +97,16 @@ export const placeBet = newEndpoint(['POST'], async (req, _res) => {
|
|||
totalShares: newTotalShares,
|
||||
totalBets: newTotalBets,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
|
||||
volume: volume + Math.abs(amount),
|
||||
collectedFees: addObjects(newBet.fees, collectedFees),
|
||||
volume: volume + amount,
|
||||
})
|
||||
)
|
||||
|
||||
if (!isFinite(newBalance)) {
|
||||
throw new APIError(500, 'Invalid user balance for ' + user.username)
|
||||
}
|
||||
|
||||
transaction.update(userDoc, { balance: newBalance })
|
||||
|
||||
return { betId: newBetDoc.id }
|
||||
return { betId: betDoc.id }
|
||||
})
|
||||
.then(async (result) => {
|
||||
|
||||
await redeemShares(bettor.id, contractId)
|
||||
return result
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 || '')}
|
||||
/>
|
||||
|
|
|
@ -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==
|
||||
|
|
Loading…
Reference in New Issue
Block a user