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:
Marshall Polaris 2022-06-29 16:47:06 -07:00 committed by GitHub
parent 2fbbc66029
commit fc7f19e785
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 255 additions and 213 deletions

View File

@ -48,12 +48,12 @@ export type PayoutInfo = {
export const getPayouts = ( export const getPayouts = (
outcome: string | undefined, outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
liquidities: LiquidityProvision[], liquidities: LiquidityProvision[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') {
@ -67,9 +67,9 @@ export const getPayouts = (
} }
return getDpmPayouts( return getDpmPayouts(
outcome, outcome,
resolutions,
contract, contract,
bets, bets,
resolutions,
resolutionProbability resolutionProbability
) )
} }
@ -100,11 +100,11 @@ export const getFixedPayouts = (
export const getDpmPayouts = ( export const getDpmPayouts = (
outcome: string | undefined, outcome: string | undefined,
resolutions: {
[outcome: string]: number
},
contract: DPMContract, contract: DPMContract,
bets: Bet[], bets: Bet[],
resolutions?: {
[outcome: string]: number
},
resolutionProbability?: number resolutionProbability?: number
): PayoutInfo => { ): PayoutInfo => {
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
@ -115,8 +115,8 @@ export const getDpmPayouts = (
return getDpmStandardPayouts(outcome, contract, openBets) return getDpmStandardPayouts(outcome, contract, openBets)
case 'MKT': case 'MKT':
return contract.outcomeType === 'FREE_RESPONSE' return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
? getPayoutsMultiOutcome(resolutions, contract, openBets) ? getPayoutsMultiOutcome(resolutions!, contract, openBets)
: getDpmMktPayouts(contract, openBets, resolutionProbability) : getDpmMktPayouts(contract, openBets, resolutionProbability)
case 'CANCEL': case 'CANCEL':
case undefined: case undefined:

View File

@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
) )
const { payouts: resolvePayouts } = getPayouts( const { payouts: resolvePayouts } = getPayouts(
resolution as string, resolution as string,
{},
contract, contract,
openBets, openBets,
[], [],
{},
resolutionProb resolutionProb
) )

View File

@ -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, minInstances: 1,
concurrency: 100, concurrency: 100,
memory: '2GiB', memory: '2GiB',
@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = {
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
} }
export const newEndpoint = (methods: [string], fn: Handler) => export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
onRequest(DEFAULT_OPTS, async (req, res) => { const opts = Object.assign(endpointOpts, DEFAULT_OPTS)
return onRequest(opts, async (req, res) => {
log('Request processing started.') log('Request processing started.')
try { try {
if (!methods.includes(req.method)) { if (!opts.methods.includes(req.method)) {
const allowed = methods.join(', ') const allowed = opts.methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`) throw new APIError(405, `This endpoint supports only ${allowed}.`)
} }
const authedUser = await lookupUser(await parseCredentials(req)) const authedUser = await lookupUser(await parseCredentials(req))
@ -140,3 +146,4 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
} }
} }
}) })
}

View File

@ -50,7 +50,7 @@ const numericSchema = z.object({
max: z.number(), 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 } = const { question, description, tags, closeTime, outcomeType, groupId } =
validate(bodySchema, req.body) validate(bodySchema, req.body)

View File

@ -20,7 +20,7 @@ const bodySchema = z.object({
about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), 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( const { name, about, memberIds, anyoneCanJoin } = validate(
bodySchema, bodySchema,
req.body req.body

View File

@ -1,6 +1,6 @@
import { newEndpoint } from './api' import { newEndpoint } from './api'
export const health = newEndpoint(['GET'], async (_req, auth) => { export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => {
return { return {
message: 'Server is working.', message: 'Server is working.',
uid: auth.uid, uid: auth.uid,

View File

@ -6,7 +6,6 @@ admin.initializeApp()
// export * from './keep-awake' // export * from './keep-awake'
export * from './claim-manalink' export * from './claim-manalink'
export * from './transact' export * from './transact'
export * from './resolve-market'
export * from './stripe' export * from './stripe'
export * from './create-user' export * from './create-user'
export * from './create-answer' export * from './create-answer'
@ -37,3 +36,4 @@ export * from './sell-shares'
export * from './create-contract' export * from './create-contract'
export * from './withdraw-liquidity' export * from './withdraw-liquidity'
export * from './create-group' export * from './create-group'
export * from './resolve-market'

View File

@ -33,7 +33,7 @@ const numericSchema = z.object({
value: z.number(), value: z.number(),
}) })
export const placebet = newEndpoint(['POST'], async (req, auth) => { export const placebet = newEndpoint({}, async (req, auth) => {
log('Inside endpoint handler.') log('Inside endpoint handler.')
const { amount, contractId } = validate(bodySchema, req.body) const { amount, contractId } = validate(bodySchema, req.body)

View File

@ -1,8 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' 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 { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils' import { getUser, isProd, payUser } from './utils'
@ -15,69 +15,64 @@ import {
} from '../../common/payouts' } from '../../common/payouts'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { APIError, newEndpoint, validate } from './api'
export const resolveMarket = functions const bodySchema = z.object({
.runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) contractId: z.string(),
.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 { 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 contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get() const contractSnap = await contractDoc.get()
if (!contractSnap.exists) 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 contract = contractSnap.data() as Contract
const { creatorId, outcomeType, closeTime } = contract const { creatorId, outcomeType, closeTime } = contract
if (outcomeType === 'BINARY') { const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
if (!RESOLUTIONS.includes(outcome)) outcomeType,
return { status: 'error', message: 'Invalid outcome' } req.body
} 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) 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) if (contract.resolution) throw new APIError(400, 'Contract already resolved')
return { status: 'error', message: 'Contract already resolved' }
const creator = await getUser(creatorId) 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 = const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined probabilityInt !== undefined ? probabilityInt / 100 : undefined
@ -104,15 +99,16 @@ export const resolveMarket = functions
const { payouts, creatorPayout, liquidityPayouts, collectedFees } = const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
getPayouts( getPayouts(
outcome, outcome,
resolutions ?? {},
contract, contract,
bets, bets,
liquidities, liquidities,
resolutions,
resolutionProbability resolutionProbability
) )
await contractDoc.update( const updatedContract = {
removeUndefinedProps({ ...contract,
...removeUndefinedProps({
isResolved: true, isResolved: true,
resolution: outcome, resolution: outcome,
resolutionValue: value, resolutionValue: value,
@ -121,8 +117,10 @@ export const resolveMarket = functions
resolutionProbability, resolutionProbability,
resolutions, resolutions,
collectedFees, collectedFees,
}) }),
) }
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome) console.log('contract ', contractId, 'resolved to:', outcome)
@ -139,14 +137,11 @@ export const resolveMarket = functions
) )
if (creatorPayout) if (creatorPayout)
await processPayouts( await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
[{ userId: creatorId, payout: creatorPayout }],
true
)
await processPayouts(liquidityPayouts, true) await processPayouts(liquidityPayouts, true)
const result = await processPayouts([...payouts, ...loanPayouts]) await processPayouts([...payouts, ...loanPayouts])
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
@ -161,9 +156,8 @@ export const resolveMarket = functions
resolutions resolutions
) )
return result return updatedContract
} })
)
const processPayouts = async (payouts: Payout[], isDeposit = false) => { const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts) 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() const firestore = admin.firestore()

View File

@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
const { payouts } = getPayouts( const { payouts } = getPayouts(
resolution, resolution,
resolutions,
contract, contract,
openBets, openBets,
[], [],
resolutions,
resolutionProbability resolutionProbability
) )

View File

@ -13,7 +13,7 @@ const bodySchema = z.object({
betId: z.string(), 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) const { contractId, betId } = validate(bodySchema, req.body)
// run as transaction to prevent race conditions // run as transaction to prevent race conditions

View File

@ -16,7 +16,7 @@ const bodySchema = z.object({
outcome: z.enum(['YES', 'NO']), 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) const { contractId, shares, outcome } = validate(bodySchema, req.body)
// Run as transaction to prevent race conditions. // Run as transaction to prevent race conditions.

View File

@ -1,10 +1,10 @@
import clsx from 'clsx' import clsx from 'clsx'
import { sum, mapValues } from 'lodash' import { sum } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { Contract, FreeResponse } from 'common/contract' import { Contract, FreeResponse } from 'common/contract'
import { Col } from '../layout/col' 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 { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector' import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button' import { ResolveConfirmationButton } from '../confirmation-button'
@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(true) setIsSubmitting(true)
const totalProb = sum(Object.values(chosenAnswers)) const totalProb = sum(Object.values(chosenAnswers))
const normalizedProbs = mapValues( const resolutions = Object.entries(chosenAnswers).map(([i, p]) => {
chosenAnswers, return { answer: parseInt(i), pct: (100 * p) / totalProb }
(prob) => (100 * prob) / totalProb })
)
const resolutionProps = removeUndefinedProps({ const resolutionProps = removeUndefinedProps({
outcome: outcome:
resolveOption === 'CHOOSE' resolveOption === 'CHOOSE'
? answers[0] ? parseInt(answers[0])
: resolveOption === 'CHOOSE_MULTIPLE' : resolveOption === 'CHOOSE_MULTIPLE'
? 'MKT' ? 'MKT'
: 'CANCEL', : 'CANCEL',
resolutions: resolutions:
resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined, resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined,
contractId: contract.id, contractId: contract.id,
}) })
const result = await resolveMarket(resolutionProps).then((r) => r.data) try {
const result = await resolveMarket(resolutionProps)
console.log('resolved', resolutionProps, 'result:', result) console.log('resolved', resolutionProps, 'result:', result)
} catch (e) {
if (result?.status !== 'success') { if (e instanceof APIError) {
setError(result?.message || 'Error resolving market') setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
} }
}
setResolveOption(undefined) setResolveOption(undefined)
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users'
import { NumberCancelSelector } from './yes-no-selector' import { NumberCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button' 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 { NumericContract } from 'common/contract'
import { BucketInput } from './bucket-input' import { BucketInput } from './bucket-input'
@ -37,17 +37,22 @@ export function NumericResolutionPanel(props: {
setIsSubmitting(true) setIsSubmitting(true)
try {
const result = await resolveMarket({ const result = await resolveMarket({
outcome: finalOutcome, outcome: finalOutcome,
value, value,
contractId: contract.id, contractId: contract.id,
}).then((r) => r.data) })
console.log('resolved', outcome, 'result:', result) console.log('resolved', outcome, 'result:', result)
} catch (e) {
if (result?.status !== 'success') { if (e instanceof APIError) {
setError(result?.message || 'Error resolving market') setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
} }
}
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@ -6,7 +6,7 @@ import { User } from 'web/lib/firebase/users'
import { YesNoCancelSelector } from './yes-no-selector' import { YesNoCancelSelector } from './yes-no-selector'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { ResolveConfirmationButton } from './confirmation-button' 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 { ProbabilitySelector } from './probability-selector'
import { DPM_CREATOR_FEE } from 'common/fees' import { DPM_CREATOR_FEE } from 'common/fees'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
@ -42,17 +42,22 @@ export function ResolutionPanel(props: {
setIsSubmitting(true) setIsSubmitting(true)
try {
const result = await resolveMarket({ const result = await resolveMarket({
outcome, outcome,
contractId: contract.id, contractId: contract.id,
probabilityInt: prob, probabilityInt: prob,
}).then((r) => r.data) })
console.log('resolved', outcome, 'result:', result) console.log('resolved', outcome, 'result:', result)
} catch (e) {
if (result?.status !== 'success') { if (e instanceof APIError) {
setError(result?.message || 'Error resolving market') setError(e.toString())
} else {
console.error(e)
setError('Error resolving market')
} }
}
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@ -54,6 +54,10 @@ export function createMarket(params: any) {
return call(getFunctionUrl('createmarket'), 'POST', params) return call(getFunctionUrl('createmarket'), 'POST', params)
} }
export function resolveMarket(params: any) {
return call(getFunctionUrl('resolvemarket'), 'POST', params)
}
export function placeBet(params: any) { export function placeBet(params: any) {
return call(getFunctionUrl('placebet'), 'POST', params) return call(getFunctionUrl('placebet'), 'POST', params)
} }

View File

@ -29,17 +29,6 @@ export const createAnswer = cloudFunction<
} }
>('createAnswer') >('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> = () => { export const createUser: () => Promise<User | null> = () => {
const local = safeLocalStorage() const local = safeLocalStorage()
let deviceToken = local?.getItem('device-token') let deviceToken = local?.getItem('device-token')