diff --git a/functions/src/index.ts b/functions/src/index.ts index dcd50e66..726aba15 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,7 +6,6 @@ admin.initializeApp() // export * from './keep-awake' export * from './claim-manalink' export * from './transact' -export * from './resolve-market' export * from './stripe' export * from './create-user' export * from './create-answer' @@ -37,3 +36,4 @@ export * from './sell-shares' export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' +export * from './resolve-market' diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 43cb4839..9a58f882 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,8 +1,8 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, resolution, RESOLUTIONS } from '../../common/contract' +import { Contract, RESOLUTIONS } from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -15,155 +15,148 @@ import { } from '../../common/payouts' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' +import { newEndpoint, validate } from './api' -export const resolveMarket = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - outcome: resolution - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + outcome: z.enum(RESOLUTIONS), + value: z.number().optional(), + contractId: z.string(), + probabilityInt: z.number().gte(0).lt(100).optional(), + resolutions: z.map(z.string(), z.number()).optional(), +}) - const { outcome, contractId, probabilityInt, resolutions, value } = data - - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - const { creatorId, outcomeType, closeTime } = contract - - if (outcomeType === 'BINARY') { - if (!RESOLUTIONS.includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'FREE_RESPONSE') { - if ( - isNaN(+outcome) && - !(outcome === 'MKT' && resolutions) && - outcome !== 'CANCEL' - ) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'NUMERIC') { - if (isNaN(+outcome) && outcome !== 'CANCEL') - return { status: 'error', message: 'Invalid outcome' } - } else { - return { status: 'error', message: 'Invalid contract outcomeType' } - } - - if (value !== undefined && !isFinite(value)) - return { status: 'error', message: 'Invalid value' } - - if ( - outcomeType === 'BINARY' && - probabilityInt !== undefined && - (probabilityInt < 0 || - probabilityInt > 100 || - !isFinite(probabilityInt)) - ) - return { status: 'error', message: 'Invalid probability' } - - if (creatorId !== userId) - return { status: 'error', message: 'User not creator of contract' } - - if (contract.resolution) - return { status: 'error', message: 'Contract already resolved' } - - const creator = await getUser(creatorId) - if (!creator) return { status: 'error', message: '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, - resolutions ?? {}, - contract, - bets, - liquidities, - resolutionProbability - ) - - await contractDoc.update( - removeUndefinedProps({ - isResolved: true, - resolution: outcome, - resolutionValue: value, - resolutionTime, - closeTime: newCloseTime, - resolutionProbability, - resolutions, - collectedFees, - }) - ) - - 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) - - const result = await processPayouts([...payouts, ...loanPayouts]) - - const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - - await sendResolutionEmails( - openBets, - userPayoutsWithoutLoans, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - - return result - } +export const resolvemarket = newEndpoint(['POST'], async (req, auth) => { + const { outcome, value, contractId, probabilityInt, resolutions } = validate( + bodySchema, + req.body ) + const userId = auth.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + return { status: 'error', message: 'Invalid contract' } + const contract = contractSnap.data() as Contract + const { creatorId, outcomeType, closeTime } = contract + + if (outcomeType === 'BINARY') { + if (!RESOLUTIONS.includes(outcome)) + return { status: 'error', message: 'Invalid outcome' } + } else if (outcomeType === 'FREE_RESPONSE') { + if ( + isNaN(+outcome) && + !(outcome === 'MKT' && resolutions) && + outcome !== 'CANCEL' + ) + return { status: 'error', message: 'Invalid outcome' } + } else if (outcomeType === 'NUMERIC') { + if (isNaN(+outcome) && outcome !== 'CANCEL') + return { status: 'error', message: 'Invalid outcome' } + } else { + return { status: 'error', message: 'Invalid contract outcomeType' } + } + + if (value !== undefined && !isFinite(value)) + return { status: 'error', message: 'Invalid value' } + + if ( + outcomeType === 'BINARY' && + probabilityInt !== undefined && + (probabilityInt < 0 || probabilityInt > 100 || !isFinite(probabilityInt)) + ) + return { status: 'error', message: 'Invalid probability' } + + if (creatorId !== userId) + return { status: 'error', message: 'User not creator of contract' } + + if (contract.resolution) + return { status: 'error', message: 'Contract already resolved' } + + const creator = await getUser(creatorId) + if (!creator) return { status: 'error', message: '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, + Object.fromEntries(resolutions || []), + contract, + bets, + liquidities, + resolutionProbability + ) + + await contractDoc.update( + removeUndefinedProps({ + isResolved: true, + resolution: outcome, + resolutionValue: value, + resolutionTime, + closeTime: newCloseTime, + resolutionProbability, + resolutions, + collectedFees, + }) + ) + + 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) + + const result = await processPayouts([...payouts, ...loanPayouts]) + + const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) + + await sendResolutionEmails( + openBets, + userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + Object.fromEntries(resolutions || []) + ) + + return result +}) const processPayouts = async (payouts: Payout[], isDeposit = false) => { const userPayouts = groupPayoutsByUser(payouts)