2021-12-14 07:02:50 +00:00
|
|
|
import * as admin from 'firebase-admin'
|
2022-06-29 23:47:06 +00:00
|
|
|
import { z } from 'zod'
|
2022-09-12 16:34:56 +00:00
|
|
|
import { mapValues, groupBy, sumBy } from 'lodash'
|
2021-12-14 07:02:50 +00:00
|
|
|
|
2022-06-30 22:11:45 +00:00
|
|
|
import {
|
|
|
|
Contract,
|
|
|
|
FreeResponseContract,
|
2022-07-28 02:40:33 +00:00
|
|
|
MultipleChoiceContract,
|
2022-06-30 22:11:45 +00:00
|
|
|
RESOLUTIONS,
|
|
|
|
} from '../../common/contract'
|
2022-05-15 17:39:42 +00:00
|
|
|
import { Bet } from '../../common/bet'
|
2022-03-15 22:27:51 +00:00
|
|
|
import { getUser, isProd, payUser } from './utils'
|
2022-05-09 21:04:40 +00:00
|
|
|
import {
|
|
|
|
getLoanPayouts,
|
|
|
|
getPayouts,
|
|
|
|
groupPayoutsByUser,
|
|
|
|
Payout,
|
2022-05-15 17:39:42 +00:00
|
|
|
} from '../../common/payouts'
|
2022-08-11 05:38:15 +00:00
|
|
|
import { isManifoldId } from '../../common/envs/constants'
|
2022-05-15 17:39:42 +00:00
|
|
|
import { removeUndefinedProps } from '../../common/util/object'
|
|
|
|
import { LiquidityProvision } from '../../common/liquidity-provision'
|
2022-06-29 23:47:06 +00:00
|
|
|
import { APIError, newEndpoint, validate } from './api'
|
2022-08-16 02:48:00 +00:00
|
|
|
import { getContractBetMetrics } from '../../common/calculate'
|
2022-09-12 16:34:56 +00:00
|
|
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
2022-06-29 23:47:06 +00:00
|
|
|
|
|
|
|
const bodySchema = z.object({
|
|
|
|
contractId: z.string(),
|
|
|
|
})
|
|
|
|
|
|
|
|
const binarySchema = z.object({
|
|
|
|
outcome: z.enum(RESOLUTIONS),
|
2022-07-02 19:37:59 +00:00
|
|
|
probabilityInt: z.number().gte(0).lte(100).optional(),
|
2022-06-29 23:47:06 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
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(),
|
2022-07-02 19:37:59 +00:00
|
|
|
pct: z.number().gte(0).lte(100),
|
2022-06-29 23:47:06 +00:00
|
|
|
})
|
|
|
|
),
|
|
|
|
}),
|
|
|
|
z.object({
|
|
|
|
outcome: z.number().int().nonnegative(),
|
|
|
|
}),
|
|
|
|
])
|
|
|
|
|
|
|
|
const numericSchema = z.object({
|
|
|
|
outcome: z.union([z.literal('CANCEL'), z.string()]),
|
|
|
|
value: z.number().optional(),
|
|
|
|
})
|
|
|
|
|
2022-07-02 19:37:59 +00:00
|
|
|
const pseudoNumericSchema = z.union([
|
|
|
|
z.object({
|
|
|
|
outcome: z.literal('CANCEL'),
|
|
|
|
}),
|
|
|
|
z.object({
|
|
|
|
outcome: z.literal('MKT'),
|
|
|
|
value: z.number(),
|
|
|
|
probabilityInt: z.number().gte(0).lte(100),
|
|
|
|
}),
|
|
|
|
])
|
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const opts = { secrets: ['MAILGUN_KEY'] }
|
2022-07-02 19:37:59 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|
|
|
const { contractId } = validate(bodySchema, req.body)
|
|
|
|
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
|
2022-06-30 22:11:45 +00:00
|
|
|
const { creatorId, closeTime } = contract
|
2022-06-29 23:47:06 +00:00
|
|
|
|
|
|
|
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
2022-06-30 22:11:45 +00:00
|
|
|
contract,
|
2022-06-29 23:47:06 +00:00
|
|
|
req.body
|
|
|
|
)
|
2021-12-14 07:02:50 +00:00
|
|
|
|
2022-08-11 05:38:15 +00:00
|
|
|
if (creatorId !== auth.uid && !isManifoldId(auth.uid))
|
2022-06-29 23:47:06 +00:00
|
|
|
throw new APIError(403, 'User is not creator of contract')
|
2022-03-15 22:27:51 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
2021-12-17 22:15:09 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const creator = await getUser(creatorId)
|
|
|
|
if (!creator) throw new APIError(500, 'Creator not found')
|
2022-01-02 00:08:52 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const resolutionProbability =
|
|
|
|
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
2022-06-14 20:01:32 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const resolutionTime = Date.now()
|
|
|
|
const newCloseTime = closeTime
|
|
|
|
? Math.min(closeTime, resolutionTime)
|
|
|
|
: closeTime
|
2022-06-14 20:01:32 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const betsSnap = await firestore
|
|
|
|
.collection(`contracts/${contractId}/bets`)
|
|
|
|
.get()
|
2022-06-14 20:01:32 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
2022-06-14 20:01:32 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const liquiditiesSnap = await firestore
|
|
|
|
.collection(`contracts/${contractId}/liquidity`)
|
|
|
|
.get()
|
2022-06-14 20:01:32 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const liquidities = liquiditiesSnap.docs.map(
|
|
|
|
(doc) => doc.data() as LiquidityProvision
|
|
|
|
)
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
|
|
|
getPayouts(
|
|
|
|
outcome,
|
|
|
|
contract,
|
|
|
|
bets,
|
|
|
|
liquidities,
|
|
|
|
resolutions,
|
|
|
|
resolutionProbability
|
|
|
|
)
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
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:'
|
|
|
|
)
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
if (creatorPayout)
|
|
|
|
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
await processPayouts(liquidityPayouts, true)
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
await processPayouts([...payouts, ...loanPayouts])
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
2022-06-15 04:31:20 +00:00
|
|
|
|
2022-09-12 16:34:56 +00:00
|
|
|
const userInvestments = mapValues(
|
|
|
|
groupBy(bets, (bet) => bet.userId),
|
|
|
|
(bets) => getContractBetMetrics(contract, bets).invested
|
|
|
|
)
|
|
|
|
let resolutionText = outcome ?? contract.question
|
2022-09-13 13:54:37 +00:00
|
|
|
if (
|
|
|
|
contract.outcomeType === 'FREE_RESPONSE' ||
|
|
|
|
contract.outcomeType === 'MULTIPLE_CHOICE'
|
|
|
|
) {
|
2022-09-12 16:34:56 +00:00
|
|
|
const answerText = contract.answers.find(
|
|
|
|
(answer) => answer.id === outcome
|
|
|
|
)?.text
|
|
|
|
if (answerText) resolutionText = answerText
|
|
|
|
} else if (contract.outcomeType === 'BINARY') {
|
|
|
|
if (resolutionText === 'MKT' && probabilityInt)
|
|
|
|
resolutionText = `${probabilityInt}%`
|
|
|
|
else if (resolutionText === 'MKT') resolutionText = 'PROB'
|
|
|
|
} else if (contract.outcomeType === 'PSEUDO_NUMERIC') {
|
|
|
|
if (resolutionText === 'MKT' && value) resolutionText = `${value}`
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: this actually may be too slow to complete with a ton of users to notify?
|
|
|
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
|
|
|
contract.id,
|
|
|
|
'contract',
|
|
|
|
'resolved',
|
2022-06-29 23:47:06 +00:00
|
|
|
creator,
|
2022-09-12 16:34:56 +00:00
|
|
|
contract.id + '-resolution',
|
|
|
|
resolutionText,
|
2022-06-29 23:47:06 +00:00
|
|
|
contract,
|
2022-09-12 16:34:56 +00:00
|
|
|
undefined,
|
|
|
|
{
|
|
|
|
bets,
|
|
|
|
userInvestments,
|
|
|
|
userPayouts: userPayoutsWithoutLoans,
|
|
|
|
creator,
|
|
|
|
creatorPayout,
|
|
|
|
contract,
|
|
|
|
outcome,
|
|
|
|
resolutionProbability,
|
|
|
|
resolutions,
|
|
|
|
}
|
2021-12-17 22:15:09 +00:00
|
|
|
)
|
2021-12-14 07:02:50 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
return updatedContract
|
|
|
|
})
|
|
|
|
|
2022-05-09 21:04:40 +00:00
|
|
|
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' }))
|
|
|
|
}
|
|
|
|
|
2022-06-30 22:11:45 +00:00
|
|
|
function getResolutionParams(contract: Contract, body: string) {
|
|
|
|
const { outcomeType } = contract
|
2022-07-02 19:37:59 +00:00
|
|
|
|
2022-06-29 23:47:06 +00:00
|
|
|
if (outcomeType === 'NUMERIC') {
|
|
|
|
return {
|
|
|
|
...validate(numericSchema, body),
|
|
|
|
resolutions: undefined,
|
|
|
|
probabilityInt: undefined,
|
|
|
|
}
|
2022-07-02 19:37:59 +00:00
|
|
|
} else if (outcomeType === 'PSEUDO_NUMERIC') {
|
|
|
|
return {
|
|
|
|
...validate(pseudoNumericSchema, body),
|
|
|
|
resolutions: undefined,
|
|
|
|
}
|
2022-07-28 02:40:33 +00:00
|
|
|
} else if (
|
|
|
|
outcomeType === 'FREE_RESPONSE' ||
|
|
|
|
outcomeType === 'MULTIPLE_CHOICE'
|
|
|
|
) {
|
2022-06-29 23:47:06 +00:00
|
|
|
const freeResponseParams = validate(freeResponseSchema, body)
|
|
|
|
const { outcome } = freeResponseParams
|
2022-06-30 22:11:45 +00:00
|
|
|
switch (outcome) {
|
|
|
|
case 'CANCEL':
|
|
|
|
return {
|
|
|
|
outcome: outcome.toString(),
|
|
|
|
resolutions: undefined,
|
|
|
|
value: undefined,
|
|
|
|
probabilityInt: undefined,
|
|
|
|
}
|
|
|
|
case 'MKT': {
|
|
|
|
const { resolutions } = freeResponseParams
|
|
|
|
resolutions.forEach(({ answer }) => validateAnswer(contract, answer))
|
|
|
|
const pctSum = sumBy(resolutions, ({ pct }) => pct)
|
|
|
|
if (Math.abs(pctSum - 100) > 0.1) {
|
|
|
|
throw new APIError(400, 'Resolution percentages must sum to 100')
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
outcome: outcome.toString(),
|
|
|
|
resolutions: Object.fromEntries(
|
|
|
|
resolutions.map((r) => [r.answer, r.pct])
|
|
|
|
),
|
|
|
|
value: undefined,
|
|
|
|
probabilityInt: undefined,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
default: {
|
|
|
|
validateAnswer(contract, outcome)
|
|
|
|
return {
|
|
|
|
outcome: outcome.toString(),
|
|
|
|
resolutions: undefined,
|
|
|
|
value: undefined,
|
|
|
|
probabilityInt: undefined,
|
|
|
|
}
|
|
|
|
}
|
2022-06-29 23:47:06 +00:00
|
|
|
}
|
|
|
|
} else if (outcomeType === 'BINARY') {
|
|
|
|
return {
|
|
|
|
...validate(binarySchema, body),
|
|
|
|
value: undefined,
|
|
|
|
resolutions: undefined,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
|
|
|
}
|
|
|
|
|
2022-07-28 02:40:33 +00:00
|
|
|
function validateAnswer(
|
|
|
|
contract: FreeResponseContract | MultipleChoiceContract,
|
|
|
|
answer: number
|
|
|
|
) {
|
2022-06-30 22:11:45 +00:00
|
|
|
const validIds = contract.answers.map((a) => a.id)
|
|
|
|
if (!validIds.includes(answer.toString())) {
|
|
|
|
throw new APIError(400, `${answer} is not a valid answer ID`)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-12-14 07:02:50 +00:00
|
|
|
const firestore = admin.firestore()
|