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:
Marshall Polaris 2022-06-10 17:51:55 -07:00 committed by GitHub
parent ee816d6552
commit 6956f0d730
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 59 additions and 71 deletions

View File

@ -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.')

View File

@ -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)

View File

@ -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,
},
} }
}) })

View File

@ -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
}) })

View File

@ -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.')

View File

@ -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.`)