manifold/functions/src/resolve-market.ts
Marshall Polaris fc7f19e785
Finalize v2 resolvemarket migration (#598)
* 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>
2022-06-29 16:47:06 -07:00

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