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",
"dependencies": {
"@react-query-firebase/firestore": "0.4.2",
"cors": "2.8.5",
"fetch": "1.1.0",
"firebase-admin": "10.0.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 _ from 'lodash'
import { chargeUser, getUser } from './utils'
import { chargeUser } from './utils'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import {
Binary,
Contract,
@ -13,7 +12,6 @@ import {
MAX_DESCRIPTION_LENGTH,
MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH,
outcomeType,
} from '../../common/contract'
import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random'
@ -28,169 +26,154 @@ import {
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
export const createContract = functions
.runWith({ minInstances: 1 })
.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' }
export const createContract = newEndpoint(['POST'], async (req, _res) => {
const [creator, _privateUser] = await lookupUser(await parseCredentials(req))
let {
question,
outcomeType,
description,
initialProb,
closeTime,
tags,
manaLimitPerUser,
} = req.body.data || {}
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
if (!question || typeof question != 'string')
throw new APIError(400, 'Missing or invalid question field')
let {
question,
description,
initialProb,
closeTime,
tags,
manaLimitPerUser,
} = data
question = question.slice(0, MAX_QUESTION_LENGTH)
if (!question || typeof question != 'string')
return { status: 'error', message: 'Missing or invalid question field' }
question = question.slice(0, MAX_QUESTION_LENGTH)
if (typeof description !== 'string')
throw new APIError(400, 'Invalid description field')
if (typeof description !== 'string')
return { status: 'error', message: 'Invalid description field' }
description = description.slice(0, MAX_DESCRIPTION_LENGTH)
description = description.slice(0, MAX_DESCRIPTION_LENGTH)
if (tags !== undefined && !_.isArray(tags))
return { status: 'error', message: 'Invalid tags field' }
tags = tags?.map((tag) => tag.toString().slice(0, MAX_TAG_LENGTH))
if (tags !== undefined && !Array.isArray(tags))
throw new APIError(400, 'Invalid tags field')
let outcomeType = data.outcomeType ?? 'BINARY'
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
return { status: 'error', message: 'Invalid outcomeType' }
if (
outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99)
)
return { status: 'error', message: 'Invalid initial probability' }
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', userId)
.where('createdTime', '>=', today)
.get()
const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE // data.ante
if (
ante === undefined ||
ante < MINIMUM_ANTE ||
(ante > creator.balance && !isFree) ||
isNaN(ante) ||
!isFinite(ante)
)
return { status: 'error', message: 'Invalid ante' }
console.log(
'creating contract for',
creator.username,
'on',
question,
'ante:',
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
question,
outcomeType,
description,
initialProb,
ante,
closeTime,
tags ?? [],
manaLimitPerUser ?? 0
)
if (!isFree && ante) await chargeUser(creator.id, ante, true)
await contractRef.create(contract)
if (ante) {
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets(
creator,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
const lp = getCpmmInitialLiquidity(
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante
)
await liquidityDoc.set(lp)
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
const noneAnswer = getNoneAnswer(contract.id, creator)
await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(
creator,
contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
}
return { status: 'success', contract }
}
tags = (tags || []).map((tag: string) =>
tag.toString().slice(0, MAX_TAG_LENGTH)
)
outcomeType = outcomeType ?? 'BINARY'
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType))
throw new APIError(400, 'Invalid outcomeType')
if (
outcomeType === 'BINARY' &&
(!initialProb || initialProb < 1 || initialProb > 99)
)
throw new APIError(400, 'Invalid initial probability')
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', creator.id)
.where('createdTime', '>=', today)
.get()
const isFree = userContractsCreatedTodaySnapshot.size === 0
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,
'on',
question,
'ante:',
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
question,
outcomeType,
description,
initialProb,
ante,
closeTime,
tags ?? [],
manaLimitPerUser ?? 0
)
if (!isFree && ante) await chargeUser(creator.id, ante, true)
await contractRef.create(contract)
if (ante) {
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets(
creator,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id
const lp = getCpmmInitialLiquidity(
providerId,
contract as FullContract<CPMM, Binary>,
liquidityDoc.id,
ante
)
await liquidityDoc.set(lp)
} else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`)
.doc('0')
const noneAnswer = getNoneAnswer(contract.id, creator)
await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getFreeAnswerAnte(
creator,
contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
}
}
return { contract: contract }
})
const getSlug = async (question: string) => {
const proposedSlug = slugify(question)

View File

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

View File

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

View File

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

View File

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

View File

@ -61,7 +61,7 @@ export function NewContract(props: { question: string; tag?: string }) {
}, [creator])
useEffect(() => {
createContract({}).catch() // warm up function
createContract({}).catch(() => {}) // warm up function
}, [])
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"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@^2.8.5:
cors@2.8.5, cors@^2.8.5:
version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==