From 6956f0d730303d009934e0d0669f0931018aec41 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Fri, 10 Jun 2022 17:51:55 -0700 Subject: [PATCH] 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 --- functions/src/api.ts | 29 +++++++------------------ functions/src/create-contract.ts | 11 ++++++++-- functions/src/health.ts | 7 ++----- functions/src/place-bet.ts | 20 ++++++++++-------- functions/src/sell-bet.ts | 27 ++++++++++++------------ functions/src/sell-shares.ts | 36 +++++++++++++------------------- 6 files changed, 59 insertions(+), 71 deletions(-) diff --git a/functions/src/api.ts b/functions/src/api.ts index 81c2ce76..6da6dbd7 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -6,14 +6,17 @@ import { onRequest, Request } from 'firebase-functions/v2/https' import * as Cors from 'cors' import { z } from 'zod' -import { User, PrivateUser } from '../../common/user' +import { PrivateUser } from '../../common/user' import { CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST, } from '../../common/envs/constants' type Output = Record -type AuthedUser = [User, PrivateUser] +type AuthedUser = { + uid: string + creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) +} type Handler = (req: Request, user: AuthedUser) => Promise type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type KeyCredentials = { kind: 'key'; data: string } @@ -60,24 +63,13 @@ export const parseCredentials = async (req: Request): Promise => { export const lookupUser = async (creds: Credentials): Promise => { const firestore = admin.firestore() - const users = firestore.collection('users') const privateUsers = firestore.collection('private-users') switch (creds.kind) { case 'jwt': { - const { user_id } = creds.data - if (typeof user_id !== 'string') { + if (typeof creds.data.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(), - ]) - 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] + return { uid: creds.data.user_id, creds } } case 'key': { const key = creds.data @@ -86,13 +78,8 @@ export const lookupUser = async (creds: Credentials): Promise => { throw new APIError(403, `No private user exists with API key ${key}.`) } 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 - return [user, privateUser] + return { uid: privateUser.id, creds: { privateUser, ...creds } } } default: throw new APIError(500, 'Invalid credential type.') diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 4d7df7d8..e8ac0307 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -27,6 +27,7 @@ import { import { getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' +import { User } from '../../common/user' const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), @@ -48,7 +49,7 @@ const numericSchema = z.object({ 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( bodySchema, req.body @@ -70,9 +71,15 @@ export const createmarket = newEndpoint(['POST'], async (req, [user, _]) => { 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 .collection(`contracts`) - .where('creatorId', '==', user.id) + .where('creatorId', '==', auth.uid) .where('createdTime', '>=', freeMarketResetTime) .get() console.log('free market reset time: ', freeMarketResetTime) diff --git a/functions/src/health.ts b/functions/src/health.ts index 944f8677..6f4d73dc 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,11 +1,8 @@ import { newEndpoint } from './api' -export const health = newEndpoint(['GET'], async (_req, [user, _]) => { +export const health = newEndpoint(['GET'], async (_req, auth) => { return { message: 'Server is working.', - user: { - id: user.id, - username: user.username, - }, + uid: auth.uid, } }) diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 26678f24..2effc90c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -32,21 +32,23 @@ const numericSchema = z.object({ 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 result = await firestore.runTransaction(async (trans) => { - const userDoc = firestore.doc(`users/${bettor.id}`) - const userSnap = await trans.get(userDoc) + const contractDoc = firestore.doc(`contracts/${contractId}`) + 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.') + + const contract = contractSnap.data() as Contract 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 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 @@ -110,7 +112,7 @@ export const placebet = newEndpoint(['POST'], async (req, [bettor, _]) => { return { betId: betDoc.id } }) - await redeemShares(bettor.id, contractId) + await redeemShares(auth.uid, contractId) return result }) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 4bad754d..419206c0 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -13,20 +13,26 @@ const bodySchema = z.object({ 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) // 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 - 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 (!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 user = userSnap.data() as User + const bet = betSnap.data() as Bet const { closeTime, mechanism, collectedFees, volume } = contract if (mechanism !== 'dpm-2') @@ -34,12 +40,7 @@ export const sellbet = newEndpoint(['POST'], async (req, [bettor, _]) => { if (closeTime && Date.now() > closeTime) throw new APIError(400, 'Trading is closed.') - const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) - 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) + if (auth.uid !== bet.userId) throw new APIError(400, 'The specified bet does not belong to you.') if (bet.isSold) throw new APIError(400, 'The specified bet is already sold.') diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 9a2240a5..1d0a4c23 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,4 +1,4 @@ -import { partition, sumBy } from 'lodash' +import { sumBy } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' @@ -16,20 +16,25 @@ const bodySchema = z.object({ 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) // 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) + const contractDoc = firestore.doc(`contracts/${contractId}`) + 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(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.') + + const contract = contractSnap.data() as Contract 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 if (mechanism !== 'cpmm-1') @@ -37,22 +42,11 @@ export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => { if (closeTime && Date.now() > closeTime) throw new APIError(400, 'Trading is closed.') - const userBets = await getValues( - contractDoc.collection('bets').where('userId', '==', bettor.id) - ) - const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) - const [yesBets, noBets] = partition( - userBets ?? [], - (bet) => bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - sumBy(yesBets, (bet) => bet.shares), - sumBy(noBets, (bet) => bet.shares), - ] + const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) + const maxShares = sumBy(outcomeBets, (bet) => bet.shares) - const maxShares = outcome === 'YES' ? yesShares : noShares if (shares > maxShares + 0.000000000001) throw new APIError(400, `You can only sell up to ${maxShares} shares.`)