Implement really obvious optimizations on placebet
, sellbet
, sellshares
(#452)
* Change authed endpoints to not look up users unnecessarily * Parallelize some extremely parallelizable DB requests * Clean up overcomplicated sellshares logic
This commit is contained in:
parent
ee816d6552
commit
6956f0d730
|
@ -6,14 +6,17 @@ import { onRequest, Request } from 'firebase-functions/v2/https'
|
||||||
import * as Cors from 'cors'
|
import * as Cors from 'cors'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { User, PrivateUser } from '../../common/user'
|
import { PrivateUser } from '../../common/user'
|
||||||
import {
|
import {
|
||||||
CORS_ORIGIN_MANIFOLD,
|
CORS_ORIGIN_MANIFOLD,
|
||||||
CORS_ORIGIN_LOCALHOST,
|
CORS_ORIGIN_LOCALHOST,
|
||||||
} from '../../common/envs/constants'
|
} from '../../common/envs/constants'
|
||||||
|
|
||||||
type Output = Record<string, unknown>
|
type Output = Record<string, unknown>
|
||||||
type AuthedUser = [User, PrivateUser]
|
type AuthedUser = {
|
||||||
|
uid: string
|
||||||
|
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||||
|
}
|
||||||
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
|
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
|
||||||
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
||||||
type KeyCredentials = { kind: 'key'; data: string }
|
type KeyCredentials = { kind: 'key'; data: string }
|
||||||
|
@ -60,24 +63,13 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||||
|
|
||||||
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const users = firestore.collection('users')
|
|
||||||
const privateUsers = firestore.collection('private-users')
|
const privateUsers = firestore.collection('private-users')
|
||||||
switch (creds.kind) {
|
switch (creds.kind) {
|
||||||
case 'jwt': {
|
case 'jwt': {
|
||||||
const { user_id } = creds.data
|
if (typeof creds.data.user_id !== 'string') {
|
||||||
if (typeof user_id !== 'string') {
|
|
||||||
throw new APIError(403, 'JWT must contain Manifold user ID.')
|
throw new APIError(403, 'JWT must contain Manifold user ID.')
|
||||||
}
|
}
|
||||||
const [userSnap, privateUserSnap] = await Promise.all([
|
return { uid: creds.data.user_id, creds }
|
||||||
users.doc(user_id).get(),
|
|
||||||
privateUsers.doc(user_id).get(),
|
|
||||||
])
|
|
||||||
if (!userSnap.exists || !privateUserSnap.exists) {
|
|
||||||
throw new APIError(403, 'No user exists with the provided ID.')
|
|
||||||
}
|
|
||||||
const user = userSnap.data() as User
|
|
||||||
const privateUser = privateUserSnap.data() as PrivateUser
|
|
||||||
return [user, privateUser]
|
|
||||||
}
|
}
|
||||||
case 'key': {
|
case 'key': {
|
||||||
const key = creds.data
|
const key = creds.data
|
||||||
|
@ -86,13 +78,8 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||||
throw new APIError(403, `No private user exists with API key ${key}.`)
|
throw new APIError(403, `No private user exists with API key ${key}.`)
|
||||||
}
|
}
|
||||||
const privateUserSnap = privateUserQ.docs[0]
|
const privateUserSnap = privateUserQ.docs[0]
|
||||||
const userSnap = await users.doc(privateUserSnap.id).get()
|
|
||||||
if (!userSnap.exists) {
|
|
||||||
throw new APIError(403, `No user exists with ID ${privateUserSnap.id}.`)
|
|
||||||
}
|
|
||||||
const user = userSnap.data() as User
|
|
||||||
const privateUser = privateUserSnap.data() as PrivateUser
|
const privateUser = privateUserSnap.data() as PrivateUser
|
||||||
return [user, privateUser]
|
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new APIError(500, 'Invalid credential type.')
|
throw new APIError(500, 'Invalid credential type.')
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import { getNoneAnswer } from '../../common/answer'
|
import { getNoneAnswer } from '../../common/answer'
|
||||||
import { getNewContract } from '../../common/new-contract'
|
import { getNewContract } from '../../common/new-contract'
|
||||||
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
|
||||||
|
import { User } from '../../common/user'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
question: z.string().min(1).max(MAX_QUESTION_LENGTH),
|
||||||
|
@ -48,7 +49,7 @@ const numericSchema = z.object({
|
||||||
max: z.number(),
|
max: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
|
export const createmarket = newEndpoint(['POST'], async (req, auth) => {
|
||||||
const { question, description, tags, closeTime, outcomeType } = validate(
|
const { question, description, tags, closeTime, outcomeType } = validate(
|
||||||
bodySchema,
|
bodySchema,
|
||||||
req.body
|
req.body
|
||||||
|
@ -70,9 +71,15 @@ export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => {
|
||||||
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
|
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userDoc = await firestore.collection('users').doc(auth.uid).get()
|
||||||
|
if (!userDoc.exists) {
|
||||||
|
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||||
|
}
|
||||||
|
const user = userDoc.data() as User
|
||||||
|
|
||||||
const userContractsCreatedTodaySnapshot = await firestore
|
const userContractsCreatedTodaySnapshot = await firestore
|
||||||
.collection(`contracts`)
|
.collection(`contracts`)
|
||||||
.where('creatorId', '==', user.id)
|
.where('creatorId', '==', auth.uid)
|
||||||
.where('createdTime', '>=', freeMarketResetTime)
|
.where('createdTime', '>=', freeMarketResetTime)
|
||||||
.get()
|
.get()
|
||||||
console.log('free market reset time: ', freeMarketResetTime)
|
console.log('free market reset time: ', freeMarketResetTime)
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import { newEndpoint } from './api'
|
import { newEndpoint } from './api'
|
||||||
|
|
||||||
export const health = newEndpoint(['GET'], async (_req, [user, _]) => {
|
export const health = newEndpoint(['GET'], async (_req, auth) => {
|
||||||
return {
|
return {
|
||||||
message: 'Server is working.',
|
message: 'Server is working.',
|
||||||
user: {
|
uid: auth.uid,
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -32,21 +32,23 @@ const numericSchema = z.object({
|
||||||
value: z.number(),
|
value: z.number(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
export const placebet = newEndpoint(['POST'], async (req, auth) => {
|
||||||
const { amount, contractId } = validate(bodySchema, req.body)
|
const { amount, contractId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
const result = await firestore.runTransaction(async (trans) => {
|
const result = await firestore.runTransaction(async (trans) => {
|
||||||
const userDoc = firestore.doc(`users/${bettor.id}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userSnap = await trans.get(userDoc)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
|
const [contractSnap, userSnap] = await Promise.all([
|
||||||
|
trans.get(contractDoc),
|
||||||
|
trans.get(userDoc),
|
||||||
|
])
|
||||||
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
|
|
||||||
|
const contract = contractSnap.data() as Contract
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
||||||
|
|
||||||
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 loanAmount = 0
|
const loanAmount = 0
|
||||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||||
contract
|
contract
|
||||||
|
@ -110,7 +112,7 @@ export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
||||||
return { betId: betDoc.id }
|
return { betId: betDoc.id }
|
||||||
})
|
})
|
||||||
|
|
||||||
await redeemShares(bettor.id, contractId)
|
await redeemShares(auth.uid, contractId)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -13,20 +13,26 @@ const bodySchema = z.object({
|
||||||
betId: z.string(),
|
betId: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sellbet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
export const sellbet = newEndpoint(['POST'], async (req, auth) => {
|
||||||
const { contractId, betId } = validate(bodySchema, req.body)
|
const { contractId, betId } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// run as transaction to prevent race conditions
|
// run as transaction to prevent race conditions
|
||||||
return await firestore.runTransaction(async (transaction) => {
|
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
|
|
||||||
|
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const contractSnap = await transaction.get(contractDoc)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
|
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
|
||||||
|
const [contractSnap, userSnap, betSnap] = await Promise.all([
|
||||||
|
transaction.get(contractDoc),
|
||||||
|
transaction.get(userDoc),
|
||||||
|
transaction.get(betDoc),
|
||||||
|
])
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
|
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')
|
||||||
|
|
||||||
const contract = contractSnap.data() as Contract
|
const contract = contractSnap.data() as Contract
|
||||||
|
const user = userSnap.data() as User
|
||||||
|
const bet = betSnap.data() as Bet
|
||||||
|
|
||||||
const { closeTime, mechanism, collectedFees, volume } = contract
|
const { closeTime, mechanism, collectedFees, volume } = contract
|
||||||
if (mechanism !== 'dpm-2')
|
if (mechanism !== 'dpm-2')
|
||||||
|
@ -34,12 +40,7 @@ export const sellbet = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
||||||
if (closeTime && Date.now() > closeTime)
|
if (closeTime && Date.now() > closeTime)
|
||||||
throw new APIError(400, 'Trading is closed.')
|
throw new APIError(400, 'Trading is closed.')
|
||||||
|
|
||||||
const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`)
|
if (auth.uid !== bet.userId)
|
||||||
const betSnap = await transaction.get(betDoc)
|
|
||||||
if (!betSnap.exists) throw new APIError(400, 'Bet not found.')
|
|
||||||
const bet = betSnap.data() as Bet
|
|
||||||
|
|
||||||
if (bettor.id !== bet.userId)
|
|
||||||
throw new APIError(400, 'The specified bet does not belong to you.')
|
throw new APIError(400, 'The specified bet does not belong to you.')
|
||||||
if (bet.isSold)
|
if (bet.isSold)
|
||||||
throw new APIError(400, 'The specified bet is already sold.')
|
throw new APIError(400, 'The specified bet is already sold.')
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { partition, sumBy } from 'lodash'
|
import { sumBy } from 'lodash'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
@ -16,20 +16,25 @@ const bodySchema = z.object({
|
||||||
outcome: z.enum(['YES', 'NO']),
|
outcome: z.enum(['YES', 'NO']),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
export const sellshares = newEndpoint(['POST'], async (req, auth) => {
|
||||||
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
const { contractId, shares, outcome } = validate(bodySchema, req.body)
|
||||||
|
|
||||||
// Run as transaction to prevent race conditions.
|
// Run as transaction to prevent race conditions.
|
||||||
return await firestore.runTransaction(async (transaction) => {
|
return await firestore.runTransaction(async (transaction) => {
|
||||||
const userDoc = firestore.doc(`users/${bettor.id}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userSnap = await transaction.get(userDoc)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
|
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||||
|
const [contractSnap, userSnap, userBets] = await Promise.all([
|
||||||
|
transaction.get(contractDoc),
|
||||||
|
transaction.get(userDoc),
|
||||||
|
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
||||||
|
])
|
||||||
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
|
|
||||||
|
const contract = contractSnap.data() as Contract
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
|
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
|
||||||
const contractSnap = await transaction.get(contractDoc)
|
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
|
||||||
const contract = contractSnap.data() as Contract
|
|
||||||
const { closeTime, mechanism, collectedFees, volume } = contract
|
const { closeTime, mechanism, collectedFees, volume } = contract
|
||||||
|
|
||||||
if (mechanism !== 'cpmm-1')
|
if (mechanism !== 'cpmm-1')
|
||||||
|
@ -37,22 +42,11 @@ export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => {
|
||||||
if (closeTime && Date.now() > closeTime)
|
if (closeTime && Date.now() > closeTime)
|
||||||
throw new APIError(400, 'Trading is closed.')
|
throw new APIError(400, 'Trading is closed.')
|
||||||
|
|
||||||
const userBets = await getValues<Bet>(
|
|
||||||
contractDoc.collection('bets').where('userId', '==', bettor.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||||
|
|
||||||
const [yesBets, noBets] = partition(
|
const outcomeBets = userBets.filter((bet) => bet.outcome == outcome)
|
||||||
userBets ?? [],
|
const maxShares = sumBy(outcomeBets, (bet) => bet.shares)
|
||||||
(bet) => bet.outcome === 'YES'
|
|
||||||
)
|
|
||||||
const [yesShares, noShares] = [
|
|
||||||
sumBy(yesBets, (bet) => bet.shares),
|
|
||||||
sumBy(noBets, (bet) => bet.shares),
|
|
||||||
]
|
|
||||||
|
|
||||||
const maxShares = outcome === 'YES' ? yesShares : noShares
|
|
||||||
if (shares > maxShares + 0.000000000001)
|
if (shares > maxShares + 0.000000000001)
|
||||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user