Implement onRequest versions of createContract, placeBet functions (#227)

* Reimplement createContract and placeBet cloud functions

* Fix broken warmup function error handling
This commit is contained in:
Marshall Polaris 2022-05-16 21:43:40 -07:00 committed by GitHub
parent aafd2a226f
commit cd7efb03ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 383 additions and 285 deletions

View File

@ -20,6 +20,7 @@
"main": "lib/functions/src/index.js", "main": "lib/functions/src/index.js",
"dependencies": { "dependencies": {
"@react-query-firebase/firestore": "0.4.2", "@react-query-firebase/firestore": "0.4.2",
"cors": "2.8.5",
"fetch": "1.1.0", "fetch": "1.1.0",
"firebase-admin": "10.0.0", "firebase-admin": "10.0.0",
"firebase-functions": "3.16.0", "firebase-functions": "3.16.0",

129
functions/src/api.ts Normal file
View File

@ -0,0 +1,129 @@
import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions'
import * as Cors from 'cors'
import { User, PrivateUser } from 'common/user'
type Request = functions.https.Request
type Response = functions.Response
type Handler = (req: Request, res: Response) => Promise<any>
type AuthedUser = [User, PrivateUser]
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials
export class APIError {
code: number
msg: string
constructor(code: number, msg: string) {
this.code = code
this.msg = msg
}
}
export const parseCredentials = async (req: Request): Promise<Credentials> => {
const authHeader = req.get('Authorization')
if (!authHeader) {
throw new APIError(403, 'Missing Authorization header.')
}
const authParts = authHeader.split(' ')
if (authParts.length !== 2) {
throw new APIError(403, 'Invalid Authorization header.')
}
const [scheme, payload] = authParts
switch (scheme) {
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}.`)
}
case 'Key':
return { kind: 'key', data: payload }
default:
throw new APIError(403, 'Invalid auth scheme; must be "Key" or "Bearer".')
}
}
export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
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
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]
}
case 'key': {
const key = creds.data
const privateUserQ = await privateUsers.where('apiKey', '==', key).get()
if (privateUserQ.empty) {
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]
}
default:
throw new APIError(500, 'Invalid credential type.')
}
}
export const CORS_ORIGIN_MANIFOLD = /^https?:\/\/.+\.manifold\.markets$/
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/
export const applyCors = (req: any, res: any, params: object) => {
return new Promise((resolve, reject) => {
Cors(params)(req, res, (result) => {
if (result instanceof Error) {
return reject(result)
}
return resolve(result)
})
})
}
export const newEndpoint = (methods: [string], fn: Handler) =>
functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => {
await applyCors(req, res, {
origins: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
methods: methods,
})
try {
if (!methods.includes(req.method)) {
const allowed = methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`)
}
const data = await fn(req, res)
data.status = 'success'
res.status(200).json({ data: data })
} catch (e) {
if (e instanceof APIError) {
// Emit a 200 anyway here for now, for backwards compatibility
res.status(200).json({ data: { status: 'error', message: e.msg } })
} else {
res.status(500).json({ data: { status: 'error', message: '???' } })
}
}
})

View File

@ -1,8 +1,7 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { chargeUser, getUser } from './utils' import { chargeUser } from './utils'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import { import {
Binary, Binary,
Contract, Contract,
@ -13,7 +12,6 @@ import {
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MAX_QUESTION_LENGTH, MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH, MAX_TAG_LENGTH,
outcomeType,
} from '../../common/contract' } from '../../common/contract'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
@ -28,69 +26,55 @@ import {
} from '../../common/antes' } from '../../common/antes'
import { getNoneAnswer } from '../../common/answer' import { getNoneAnswer } from '../../common/answer'
export const createContract = functions export const createContract = newEndpoint(['POST'], async (req, _res) => {
.runWith({ minInstances: 1 }) const [creator, _privateUser] = await lookupUser(await parseCredentials(req))
.https.onCall(
async (
data: {
question: string
outcomeType: outcomeType
description: string
initialProb: number
ante: number
closeTime: number
tags?: string[]
manaLimitPerUser?: number
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
let { let {
question, question,
outcomeType,
description, description,
initialProb, initialProb,
closeTime, closeTime,
tags, tags,
manaLimitPerUser, manaLimitPerUser,
} = data } = req.body.data || {}
if (!question || typeof question != 'string') if (!question || typeof question != 'string')
return { status: 'error', message: 'Missing or invalid question field' } throw new APIError(400, 'Missing or invalid question field')
question = question.slice(0, MAX_QUESTION_LENGTH) question = question.slice(0, MAX_QUESTION_LENGTH)
if (typeof description !== 'string') if (typeof description !== 'string')
return { status: 'error', message: 'Invalid description field' } throw new APIError(400, 'Invalid description field')
description = description.slice(0, MAX_DESCRIPTION_LENGTH) description = description.slice(0, MAX_DESCRIPTION_LENGTH)
if (tags !== undefined && !_.isArray(tags)) if (tags !== undefined && !Array.isArray(tags))
return { status: 'error', message: 'Invalid tags field' } throw new APIError(400, 'Invalid tags field')
tags = tags?.map((tag) => tag.toString().slice(0, MAX_TAG_LENGTH))
let outcomeType = data.outcomeType ?? 'BINARY' tags = (tags || []).map((tag: string) =>
tag.toString().slice(0, MAX_TAG_LENGTH)
)
outcomeType = outcomeType ?? 'BINARY'
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
return { status: 'error', message: 'Invalid outcomeType' } throw new APIError(400, 'Invalid outcomeType')
if ( if (
outcomeType === 'BINARY' && outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99) (!initialProb || initialProb < 1 || initialProb > 99)
) )
return { status: 'error', message: 'Invalid initial probability' } throw new APIError(400, 'Invalid initial probability')
// uses utc time on server: // uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0) const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`) .collection(`contracts`)
.where('creatorId', '==', userId) .where('creatorId', '==', creator.id)
.where('createdTime', '>=', today) .where('createdTime', '>=', today)
.get() .get()
const isFree = userContractsCreatedTodaySnapshot.size === 0 const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE // data.ante const ante = FIXED_ANTE
if ( if (
ante === undefined || ante === undefined ||
@ -99,7 +83,7 @@ export const createContract = functions
isNaN(ante) || isNaN(ante) ||
!isFinite(ante) !isFinite(ante)
) )
return { status: 'error', message: 'Invalid ante' } throw new APIError(400, 'Invalid ante')
console.log( console.log(
'creating contract for', 'creating contract for',
@ -187,9 +171,8 @@ export const createContract = functions
} }
} }
return { status: 'success', contract } return { contract: contract }
} })
)
const getSlug = async (question: string) => { const getSlug = async (question: string) => {
const proposedSlug = slugify(question) const proposedSlug = slugify(question)

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { import {
@ -14,68 +14,54 @@ import { redeemShares } from './redeem-shares'
import { Fees } from '../../common/fees' import { Fees } from '../../common/fees'
import { hasUserHitManaLimit } from '../../common/calculate' import { hasUserHitManaLimit } from '../../common/calculate'
export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( export const placeBet = newEndpoint(['POST'], async (req, _res) => {
async ( const [bettor, _privateUser] = await lookupUser(await parseCredentials(req))
data: { const { amount, outcome, contractId } = req.body.data || {}
amount: number
outcome: string
contractId: string
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const { amount, outcome, contractId } = data
if (amount <= 0 || isNaN(amount) || !isFinite(amount)) if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' } throw new APIError(400, 'Invalid amount')
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome))
return { status: 'error', message: 'Invalid outcome' } throw new APIError(400, 'Invalid outcome')
// run as transaction to prevent race conditions // run as transaction to prevent race conditions
return await firestore return await firestore
.runTransaction(async (transaction) => { .runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`) const userDoc = firestore.doc(`users/${bettor.id}`)
const userSnap = await transaction.get(userDoc) const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) if (!userSnap.exists) throw new APIError(400, 'User not found')
return { status: 'error', message: 'User not found' }
const user = userSnap.data() as User 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 contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
return { status: 'error', message: 'Invalid contract' }
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { closeTime, outcomeType, mechanism, collectedFees, volume } = const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract contract
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } throw new APIError(400, 'Trading is closed')
const yourBetsSnap = await transaction.get( const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId) contractDoc.collection('bets').where('userId', '==', bettor.id)
) )
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const loanAmount = 0 // getLoanAmount(yourBets, amount) const loanAmount = 0 // getLoanAmount(yourBets, amount)
if (user.balance < amount) if (user.balance < amount) throw new APIError(400, 'Insufficient balance')
return { status: 'error', message: 'Insufficient balance' }
if (outcomeType === 'FREE_RESPONSE') { if (outcomeType === 'FREE_RESPONSE') {
const answerSnap = await transaction.get( const answerSnap = await transaction.get(
contractDoc.collection('answers').doc(outcome) contractDoc.collection('answers').doc(outcome)
) )
if (!answerSnap.exists) if (!answerSnap.exists) throw new APIError(400, 'Invalid contract')
return { status: 'error', message: 'Invalid contract' }
const { status, message } = hasUserHitManaLimit( const { status, message } = hasUserHitManaLimit(
contract, contract,
yourBets, yourBets,
amount amount
) )
if (status === 'error') return { status, message: message } if (status === 'error') throw new APIError(400, message)
} }
const newBetDoc = firestore const newBetDoc = firestore
@ -120,10 +106,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
) )
if (newP !== undefined && !isFinite(newP)) { if (newP !== undefined && !isFinite(newP)) {
return { throw new APIError(400, 'Trade rejected due to overflow error.')
status: 'error',
message: 'Trade rejected due to overflow error.',
}
} }
transaction.create(newBetDoc, newBet) transaction.create(newBetDoc, newBet)
@ -142,18 +125,17 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall(
) )
if (!isFinite(newBalance)) { if (!isFinite(newBalance)) {
throw new Error('Invalid user balance for ' + user.username) throw new APIError(500, 'Invalid user balance for ' + user.username)
} }
transaction.update(userDoc, { balance: newBalance }) transaction.update(userDoc, { balance: newBalance })
return { status: 'success', betId: newBetDoc.id } return { betId: newBetDoc.id }
}) })
.then(async (result) => { .then(async (result) => {
await redeemShares(userId, contractId) await redeemShares(bettor.id, contractId)
return result return result
}) })
} })
)
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -214,7 +214,7 @@ function BuyPanel(props: {
useEffect(() => { useEffect(() => {
// warm up cloud function // warm up cloud function
placeBet({}).catch() placeBet({}).catch(() => {})
}, []) }, [])
useEffect(() => { useEffect(() => {

View File

@ -553,7 +553,10 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
) )
} }
const warmUpSellBet = _.throttle(() => sellBet({}).catch(), 5000 /* ms */) const warmUpSellBet = _.throttle(
() => sellBet({}).catch(() => {}),
5000 /* ms */
)
function SellButton(props: { contract: Contract; bet: Bet }) { function SellButton(props: { contract: Contract; bet: Bet }) {
useEffect(() => { useEffect(() => {

View File

@ -20,7 +20,7 @@ export function ResolutionPanel(props: {
}) { }) {
useEffect(() => { useEffect(() => {
// warm up cloud function // warm up cloud function
resolveMarket({} as any).catch() resolveMarket({} as any).catch(() => {})
}, []) }, [])
const { contract, className } = props const { contract, className } = props

View File

@ -23,7 +23,7 @@
"@nivo/line": "0.74.0", "@nivo/line": "0.74.0",
"algoliasearch": "4.13.0", "algoliasearch": "4.13.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"cors": "^2.8.5", "cors": "2.8.5",
"daisyui": "1.16.4", "daisyui": "1.16.4",
"dayjs": "1.10.7", "dayjs": "1.10.7",
"firebase": "9.6.0", "firebase": "9.6.0",

View File

@ -61,7 +61,7 @@ export function NewContract(props: { question: string; tag?: string }) {
}, [creator]) }, [creator])
useEffect(() => { useEffect(() => {
createContract({}).catch() // warm up function createContract({}).catch(() => {}) // warm up function
}, []) }, [])
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY') const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')

View File

@ -1874,7 +1874,7 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@^2.8.5: cors@2.8.5, cors@^2.8.5:
version "2.8.5" version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==