* Update resolve-market to be a v2 function * Cleanup API error responses * Update frontend to use v2 version of resolvemarket * Appease ESLint * Address review comments * Appease ESLint * Remove unnecessary auth check * Fix logic bug in FR market validation * Make it so you can specify runtime opts for v2 functions * Cleanup to resolve market API resolutions input, fixes * Fix up tiny lint * Last minute cleanup to resolvemarket FR API input validation Co-authored-by: Benjamin <ben@congdon.dev>
		
			
				
	
	
		
			253 lines
		
	
	
		
			6.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			253 lines
		
	
	
		
			6.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import * as admin from 'firebase-admin'
 | |
| import { z } from 'zod'
 | |
| import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
 | |
| 
 | |
| import { Contract, RESOLUTIONS } from '../../common/contract'
 | |
| import { User } from '../../common/user'
 | |
| import { Bet } from '../../common/bet'
 | |
| import { getUser, isProd, payUser } from './utils'
 | |
| import { sendMarketResolutionEmail } from './emails'
 | |
| import {
 | |
|   getLoanPayouts,
 | |
|   getPayouts,
 | |
|   groupPayoutsByUser,
 | |
|   Payout,
 | |
| } from '../../common/payouts'
 | |
| import { removeUndefinedProps } from '../../common/util/object'
 | |
| import { LiquidityProvision } from '../../common/liquidity-provision'
 | |
| import { APIError, newEndpoint, validate } from './api'
 | |
| 
 | |
| const bodySchema = z.object({
 | |
|   contractId: z.string(),
 | |
| })
 | |
| 
 | |
| const binarySchema = z.object({
 | |
|   outcome: z.enum(RESOLUTIONS),
 | |
|   probabilityInt: z.number().gte(0).lt(100).optional(),
 | |
| })
 | |
| 
 | |
| const freeResponseSchema = z.union([
 | |
|   z.object({
 | |
|     outcome: z.literal('CANCEL'),
 | |
|   }),
 | |
|   z.object({
 | |
|     outcome: z.literal('MKT'),
 | |
|     resolutions: z.array(
 | |
|       z.object({
 | |
|         answer: z.number().int().nonnegative(),
 | |
|         pct: z.number().gte(0).lt(100),
 | |
|       })
 | |
|     ),
 | |
|   }),
 | |
|   z.object({
 | |
|     outcome: z.number().int().nonnegative(),
 | |
|   }),
 | |
| ])
 | |
| 
 | |
| const numericSchema = z.object({
 | |
|   outcome: z.union([z.literal('CANCEL'), z.string()]),
 | |
|   value: z.number().optional(),
 | |
| })
 | |
| 
 | |
| const opts = { secrets: ['MAILGUN_KEY'] }
 | |
| export const resolvemarket = newEndpoint(opts, async (req, auth) => {
 | |
|   const { contractId } = validate(bodySchema, req.body)
 | |
|   const userId = auth.uid
 | |
| 
 | |
|   const contractDoc = firestore.doc(`contracts/${contractId}`)
 | |
|   const contractSnap = await contractDoc.get()
 | |
|   if (!contractSnap.exists)
 | |
|     throw new APIError(404, 'No contract exists with the provided ID')
 | |
|   const contract = contractSnap.data() as Contract
 | |
|   const { creatorId, outcomeType, closeTime } = contract
 | |
| 
 | |
|   const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
 | |
|     outcomeType,
 | |
|     req.body
 | |
|   )
 | |
| 
 | |
|   if (creatorId !== userId)
 | |
|     throw new APIError(403, 'User is not creator of contract')
 | |
| 
 | |
|   if (contract.resolution) throw new APIError(400, 'Contract already resolved')
 | |
| 
 | |
|   const creator = await getUser(creatorId)
 | |
|   if (!creator) throw new APIError(500, 'Creator not found')
 | |
| 
 | |
|   const resolutionProbability =
 | |
|     probabilityInt !== undefined ? probabilityInt / 100 : undefined
 | |
| 
 | |
|   const resolutionTime = Date.now()
 | |
|   const newCloseTime = closeTime
 | |
|     ? Math.min(closeTime, resolutionTime)
 | |
|     : closeTime
 | |
| 
 | |
|   const betsSnap = await firestore
 | |
|     .collection(`contracts/${contractId}/bets`)
 | |
|     .get()
 | |
| 
 | |
|   const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
 | |
| 
 | |
|   const liquiditiesSnap = await firestore
 | |
|     .collection(`contracts/${contractId}/liquidity`)
 | |
|     .get()
 | |
| 
 | |
|   const liquidities = liquiditiesSnap.docs.map(
 | |
|     (doc) => doc.data() as LiquidityProvision
 | |
|   )
 | |
| 
 | |
|   const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
 | |
|     getPayouts(
 | |
|       outcome,
 | |
|       contract,
 | |
|       bets,
 | |
|       liquidities,
 | |
|       resolutions,
 | |
|       resolutionProbability
 | |
|     )
 | |
| 
 | |
|   const updatedContract = {
 | |
|     ...contract,
 | |
|     ...removeUndefinedProps({
 | |
|       isResolved: true,
 | |
|       resolution: outcome,
 | |
|       resolutionValue: value,
 | |
|       resolutionTime,
 | |
|       closeTime: newCloseTime,
 | |
|       resolutionProbability,
 | |
|       resolutions,
 | |
|       collectedFees,
 | |
|     }),
 | |
|   }
 | |
| 
 | |
|   await contractDoc.update(updatedContract)
 | |
| 
 | |
|   console.log('contract ', contractId, 'resolved to:', outcome)
 | |
| 
 | |
|   const openBets = bets.filter((b) => !b.isSold && !b.sale)
 | |
|   const loanPayouts = getLoanPayouts(openBets)
 | |
| 
 | |
|   if (!isProd())
 | |
|     console.log(
 | |
|       'payouts:',
 | |
|       payouts,
 | |
|       'creator payout:',
 | |
|       creatorPayout,
 | |
|       'liquidity payout:'
 | |
|     )
 | |
| 
 | |
|   if (creatorPayout)
 | |
|     await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
 | |
| 
 | |
|   await processPayouts(liquidityPayouts, true)
 | |
| 
 | |
|   await processPayouts([...payouts, ...loanPayouts])
 | |
| 
 | |
|   const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
 | |
| 
 | |
|   await sendResolutionEmails(
 | |
|     openBets,
 | |
|     userPayoutsWithoutLoans,
 | |
|     creator,
 | |
|     creatorPayout,
 | |
|     contract,
 | |
|     outcome,
 | |
|     resolutionProbability,
 | |
|     resolutions
 | |
|   )
 | |
| 
 | |
|   return updatedContract
 | |
| })
 | |
| 
 | |
| const processPayouts = async (payouts: Payout[], isDeposit = false) => {
 | |
|   const userPayouts = groupPayoutsByUser(payouts)
 | |
| 
 | |
|   const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) =>
 | |
|     payUser(userId, payout, isDeposit)
 | |
|   )
 | |
| 
 | |
|   return await Promise.all(payoutPromises)
 | |
|     .catch((e) => ({ status: 'error', message: e }))
 | |
|     .then(() => ({ status: 'success' }))
 | |
| }
 | |
| 
 | |
| const sendResolutionEmails = async (
 | |
|   openBets: Bet[],
 | |
|   userPayouts: { [userId: string]: number },
 | |
|   creator: User,
 | |
|   creatorPayout: number,
 | |
|   contract: Contract,
 | |
|   outcome: string,
 | |
|   resolutionProbability?: number,
 | |
|   resolutions?: { [outcome: string]: number }
 | |
| ) => {
 | |
|   const nonWinners = difference(
 | |
|     uniq(openBets.map(({ userId }) => userId)),
 | |
|     Object.keys(userPayouts)
 | |
|   )
 | |
|   const investedByUser = mapValues(
 | |
|     groupBy(openBets, (bet) => bet.userId),
 | |
|     (bets) => sumBy(bets, (bet) => bet.amount)
 | |
|   )
 | |
|   const emailPayouts = [
 | |
|     ...Object.entries(userPayouts),
 | |
|     ...nonWinners.map((userId) => [userId, 0] as const),
 | |
|   ].map(([userId, payout]) => ({
 | |
|     userId,
 | |
|     investment: investedByUser[userId] ?? 0,
 | |
|     payout,
 | |
|   }))
 | |
| 
 | |
|   await Promise.all(
 | |
|     emailPayouts.map(({ userId, investment, payout }) =>
 | |
|       sendMarketResolutionEmail(
 | |
|         userId,
 | |
|         investment,
 | |
|         payout,
 | |
|         creator,
 | |
|         creatorPayout,
 | |
|         contract,
 | |
|         outcome,
 | |
|         resolutionProbability,
 | |
|         resolutions
 | |
|       )
 | |
|     )
 | |
|   )
 | |
| }
 | |
| 
 | |
| function getResolutionParams(outcomeType: string, body: string) {
 | |
|   if (outcomeType === 'NUMERIC') {
 | |
|     return {
 | |
|       ...validate(numericSchema, body),
 | |
|       resolutions: undefined,
 | |
|       probabilityInt: undefined,
 | |
|     }
 | |
|   } else if (outcomeType === 'FREE_RESPONSE') {
 | |
|     const freeResponseParams = validate(freeResponseSchema, body)
 | |
|     const { outcome } = freeResponseParams
 | |
|     const resolutions =
 | |
|       'resolutions' in freeResponseParams
 | |
|         ? Object.fromEntries(
 | |
|             freeResponseParams.resolutions.map((r) => [r.answer, r.pct])
 | |
|           )
 | |
|         : undefined
 | |
|     return {
 | |
|       // Free Response outcome IDs are numbers by convention,
 | |
|       // but treated as strings everywhere else.
 | |
|       outcome: outcome.toString(),
 | |
|       resolutions,
 | |
|       value: undefined,
 | |
|       probabilityInt: undefined,
 | |
|     }
 | |
|   } else if (outcomeType === 'BINARY') {
 | |
|     return {
 | |
|       ...validate(binarySchema, body),
 | |
|       value: undefined,
 | |
|       resolutions: undefined,
 | |
|     }
 | |
|   }
 | |
|   throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
 | |
| }
 | |
| 
 | |
| const firestore = admin.firestore()
 |