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>
This commit is contained in:
parent
2fbbc66029
commit
fc7f19e785
|
@ -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:
|
||||
|
|
|
@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
|
|||
)
|
||||
const { payouts: resolvePayouts } = getPayouts(
|
||||
resolution as string,
|
||||
{},
|
||||
contract,
|
||||
openBets,
|
||||
[],
|
||||
{},
|
||||
resolutionProb
|
||||
)
|
||||
|
||||
|
|
|
@ -108,7 +108,12 @@ export const validate = <T extends z.ZodTypeAny>(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) =>
|
|||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,69 +15,64 @@ 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 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)
|
||||
return { status: 'error', message: 'Invalid contract' }
|
||||
throw new APIError(404, 'No contract exists with the provided ID')
|
||||
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'
|
||||
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||
outcomeType,
|
||||
req.body
|
||||
)
|
||||
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' }
|
||||
throw new APIError(403, 'User is not creator of contract')
|
||||
|
||||
if (contract.resolution)
|
||||
return { status: 'error', message: 'Contract already resolved' }
|
||||
if (contract.resolution) throw new APIError(400, 'Contract already resolved')
|
||||
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) return { status: 'error', message: 'Creator not found' }
|
||||
if (!creator) throw new APIError(500, 'Creator not found')
|
||||
|
||||
const resolutionProbability =
|
||||
probabilityInt !== undefined ? probabilityInt / 100 : undefined
|
||||
|
@ -104,15 +99,16 @@ export const resolveMarket = functions
|
|||
const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
|
||||
getPayouts(
|
||||
outcome,
|
||||
resolutions ?? {},
|
||||
contract,
|
||||
bets,
|
||||
liquidities,
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
|
||||
await contractDoc.update(
|
||||
removeUndefinedProps({
|
||||
const updatedContract = {
|
||||
...contract,
|
||||
...removeUndefinedProps({
|
||||
isResolved: true,
|
||||
resolution: outcome,
|
||||
resolutionValue: value,
|
||||
|
@ -121,8 +117,10 @@ export const resolveMarket = functions
|
|||
resolutionProbability,
|
||||
resolutions,
|
||||
collectedFees,
|
||||
})
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
await contractDoc.update(updatedContract)
|
||||
|
||||
console.log('contract ', contractId, 'resolved to:', outcome)
|
||||
|
||||
|
@ -139,14 +137,11 @@ export const resolveMarket = functions
|
|||
)
|
||||
|
||||
if (creatorPayout)
|
||||
await processPayouts(
|
||||
[{ userId: creatorId, payout: creatorPayout }],
|
||||
true
|
||||
)
|
||||
await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
|
||||
|
||||
await processPayouts(liquidityPayouts, true)
|
||||
|
||||
const result = await processPayouts([...payouts, ...loanPayouts])
|
||||
await processPayouts([...payouts, ...loanPayouts])
|
||||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
|
||||
|
@ -161,9 +156,8 @@ export const resolveMarket = functions
|
|||
resolutions
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
)
|
||||
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()
|
||||
|
|
|
@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
|
|||
|
||||
const { payouts } = getPayouts(
|
||||
resolution,
|
||||
resolutions,
|
||||
contract,
|
||||
openBets,
|
||||
[],
|
||||
resolutions,
|
||||
resolutionProbability
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
||||
try {
|
||||
const result = await resolveMarket(resolutionProps)
|
||||
console.log('resolved', resolutionProps, 'result:', result)
|
||||
|
||||
if (result?.status !== 'success') {
|
||||
setError(result?.message || 'Error resolving market')
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
setError(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setError('Error resolving market')
|
||||
}
|
||||
}
|
||||
|
||||
setResolveOption(undefined)
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
try {
|
||||
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')
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
setError(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setError('Error resolving market')
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
try {
|
||||
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')
|
||||
} catch (e) {
|
||||
if (e instanceof APIError) {
|
||||
setError(e.toString())
|
||||
} else {
|
||||
console.error(e)
|
||||
setError('Error resolving market')
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<User | null> = () => {
|
||||
const local = safeLocalStorage()
|
||||
let deviceToken = local?.getItem('device-token')
|
||||
|
|
Loading…
Reference in New Issue
Block a user