diff --git a/common/payouts.ts b/common/payouts.ts index a3f105cf..f2c8d271 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -48,12 +48,12 @@ export type PayoutInfo = { export const getPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: Contract, bets: Bet[], liquidities: LiquidityProvision[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { @@ -67,9 +67,9 @@ export const getPayouts = ( } return getDpmPayouts( outcome, - resolutions, contract, bets, + resolutions, resolutionProbability ) } @@ -100,11 +100,11 @@ export const getFixedPayouts = ( export const getDpmPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: DPMContract, bets: Bet[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { const openBets = bets.filter((b) => !b.isSold && !b.sale) @@ -115,8 +115,8 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' - ? getPayoutsMultiOutcome(resolutions, contract, openBets) + return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': case undefined: diff --git a/common/scoring.ts b/common/scoring.ts index d4e40267..39a342fd 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) { ) const { payouts: resolvePayouts } = getPayouts( resolution as string, - {}, contract, openBets, [], + {}, resolutionProb ) diff --git a/functions/src/api.ts b/functions/src/api.ts index f7efab5a..290ea3d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -108,7 +108,12 @@ export const validate = (schema: T, val: unknown) => { } } -const DEFAULT_OPTS: HttpsOptions = { +interface EndpointOptions extends HttpsOptions { + methods?: string[] +} + +const DEFAULT_OPTS = { + methods: ['POST'], minInstances: 1, concurrency: 100, memory: '2GiB', @@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = { cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], } -export const newEndpoint = (methods: [string], fn: Handler) => - onRequest(DEFAULT_OPTS, async (req, res) => { +export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { + const opts = Object.assign(endpointOpts, DEFAULT_OPTS) + return onRequest(opts, async (req, res) => { log('Request processing started.') try { - if (!methods.includes(req.method)) { - const allowed = methods.join(', ') + if (!opts.methods.includes(req.method)) { + const allowed = opts.methods.join(', ') throw new APIError(405, `This endpoint supports only ${allowed}.`) } const authedUser = await lookupUser(await parseCredentials(req)) @@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) => } } }) +} diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 71d778b3..c9468fdc 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -50,7 +50,7 @@ const numericSchema = z.object({ max: z.number(), }) -export const createmarket = newEndpoint(['POST'], async (req, auth) => { +export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index e7ee0cf5..a9626916 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -20,7 +20,7 @@ const bodySchema = z.object({ about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), }) -export const creategroup = newEndpoint(['POST'], async (req, auth) => { +export const creategroup = newEndpoint({}, async (req, auth) => { const { name, about, memberIds, anyoneCanJoin } = validate( bodySchema, req.body diff --git a/functions/src/health.ts b/functions/src/health.ts index 6f4d73dc..938261db 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,6 +1,6 @@ import { newEndpoint } from './api' -export const health = newEndpoint(['GET'], async (_req, auth) => { +export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => { return { message: 'Server is working.', uid: auth.uid, 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/place-bet.ts b/functions/src/place-bet.ts index 1b5dd8bc..06d27668 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -33,7 +33,7 @@ const numericSchema = z.object({ value: z.number(), }) -export const placebet = newEndpoint(['POST'], async (req, auth) => { +export const placebet = newEndpoint({}, async (req, auth) => { log('Inside endpoint handler.') const { amount, contractId } = validate(bodySchema, req.body) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 43cb4839..b36ec3ef 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,156 +15,150 @@ import { } from '../../common/payouts' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' +import { APIError, 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({ + contractId: z.string(), +}) - const { outcome, contractId, probabilityInt, resolutions, value } = data +const binarySchema = z.object({ + outcome: z.enum(RESOLUTIONS), + probabilityInt: z.number().gte(0).lt(100).optional(), +}) - 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 +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(), + }), +]) - 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' } - } +const numericSchema = z.object({ + outcome: z.union([z.literal('CANCEL'), z.string()]), + value: z.number().optional(), +}) - if (value !== undefined && !isFinite(value)) - return { status: 'error', message: 'Invalid value' } +const opts = { secrets: ['MAILGUN_KEY'] } +export const resolvemarket = newEndpoint(opts, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) + const userId = auth.uid - if ( - outcomeType === 'BINARY' && - probabilityInt !== undefined && - (probabilityInt < 0 || - probabilityInt > 100 || - !isFinite(probabilityInt)) - ) - return { status: 'error', message: 'Invalid probability' } + 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 - 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 - } + 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) @@ -221,4 +215,38 @@ const sendResolutionEmails = async ( ) } +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() diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts index 1686ebd9..a121889f 100644 --- a/functions/src/scripts/pay-out-contract-again.ts +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) { const { payouts } = getPayouts( resolution, - resolutions, contract, openBets, [], + resolutions, resolutionProbability ) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 419206c0..b3362159 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -13,7 +13,7 @@ const bodySchema = z.object({ betId: z.string(), }) -export const sellbet = newEndpoint(['POST'], async (req, auth) => { +export const sellbet = newEndpoint({}, async (req, auth) => { const { contractId, betId } = validate(bodySchema, req.body) // run as transaction to prevent race conditions diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index dd4e2ec5..26374a16 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -16,7 +16,7 @@ const bodySchema = z.object({ outcome: z.enum(['YES', 'NO']), }) -export const sellshares = newEndpoint(['POST'], async (req, auth) => { +export const sellshares = newEndpoint({}, async (req, auth) => { const { contractId, shares, outcome } = validate(bodySchema, req.body) // Run as transaction to prevent race conditions. diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 81b94550..6b8e2885 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' -import { sum, mapValues } from 'lodash' +import { sum } from 'lodash' import { useState } from 'react' import { Contract, FreeResponse } from 'common/contract' import { Col } from '../layout/col' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { Row } from '../layout/row' import { ChooseCancelSelector } from '../yes-no-selector' import { ResolveConfirmationButton } from '../confirmation-button' @@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: { setIsSubmitting(true) const totalProb = sum(Object.values(chosenAnswers)) - const normalizedProbs = mapValues( - chosenAnswers, - (prob) => (100 * prob) / totalProb - ) + const resolutions = Object.entries(chosenAnswers).map(([i, p]) => { + return { answer: parseInt(i), pct: (100 * p) / totalProb } + }) const resolutionProps = removeUndefinedProps({ outcome: resolveOption === 'CHOOSE' - ? answers[0] + ? parseInt(answers[0]) : resolveOption === 'CHOOSE_MULTIPLE' ? 'MKT' : 'CANCEL', resolutions: - resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined, + resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined, contractId: contract.id, }) - const result = await resolveMarket(resolutionProps).then((r) => r.data) - - console.log('resolved', resolutionProps, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket(resolutionProps) + console.log('resolved', resolutionProps, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setResolveOption(undefined) setIsSubmitting(false) } diff --git a/web/components/numeric-resolution-panel.tsx b/web/components/numeric-resolution-panel.tsx index f05a1c0a..ebac68e5 100644 --- a/web/components/numeric-resolution-panel.tsx +++ b/web/components/numeric-resolution-panel.tsx @@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users' import { NumberCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { NumericContract } from 'common/contract' import { BucketInput } from './bucket-input' @@ -37,17 +37,22 @@ export function NumericResolutionPanel(props: { setIsSubmitting(true) - const result = await resolveMarket({ - outcome: finalOutcome, - value, - contractId: contract.id, - }).then((r) => r.data) - - console.log('resolved', outcome, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket({ + outcome: finalOutcome, + value, + contractId: contract.id, + }) + console.log('resolved', outcome, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setIsSubmitting(false) } diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 8b453765..a46d9478 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users' import { YesNoCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ResolveConfirmationButton } from './confirmation-button' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api-call' import { ProbabilitySelector } from './probability-selector' import { DPM_CREATOR_FEE } from 'common/fees' import { getProbability } from 'common/calculate' @@ -42,17 +42,22 @@ export function ResolutionPanel(props: { setIsSubmitting(true) - const result = await resolveMarket({ - outcome, - contractId: contract.id, - probabilityInt: prob, - }).then((r) => r.data) - - console.log('resolved', outcome, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket({ + outcome, + contractId: contract.id, + probabilityInt: prob, + }) + console.log('resolved', outcome, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setIsSubmitting(false) } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 7509a9f1..e02872ae 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -54,6 +54,10 @@ export function createMarket(params: any) { return call(getFunctionUrl('createmarket'), 'POST', params) } +export function resolveMarket(params: any) { + return call(getFunctionUrl('resolvemarket'), 'POST', params) +} + export function placeBet(params: any) { return call(getFunctionUrl('placebet'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index e99bf393..ce78ac3a 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -29,17 +29,6 @@ export const createAnswer = cloudFunction< } >('createAnswer') -export const resolveMarket = cloudFunction< - { - outcome: string - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - { status: 'error' | 'success'; message?: string } ->('resolveMarket') - export const createUser: () => Promise = () => { const local = safeLocalStorage() let deviceToken = local?.getItem('device-token')