From c5efd5b7d00770ed78ed572ff2b006b8143300fe Mon Sep 17 00:00:00 2001 From: Ben Congdon Date: Thu, 30 Jun 2022 15:11:45 -0700 Subject: [PATCH] Market Resolution API (#600) * Add market resolution API * Add additional free market resolution validation * Address review comments * Refactor resolution validation code somewhat Co-authored-by: Marshall Polaris --- docs/docs/api.md | 55 +++++++++++++++- functions/src/resolve-market.ts | 66 ++++++++++++++----- web/lib/api/proxy.ts | 7 +- .../api/v0/market/{[id].ts => [id]/index.ts} | 2 +- web/pages/api/v0/market/[id]/resolve.ts | 28 ++++++++ 5 files changed, 138 insertions(+), 20 deletions(-) rename web/pages/api/v0/market/{[id].ts => [id]/index.ts} (93%) create mode 100644 web/pages/api/v0/market/[id]/resolve.ts diff --git a/docs/docs/api.md b/docs/docs/api.md index ffdaa65f..9172fc5a 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -456,7 +456,6 @@ Requires no authorization. } ``` - ### `POST /v0/bet` Places a new bet on behalf of the authorized user. @@ -514,6 +513,60 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/resolve` + +Resolves a market on behalf of the authorized user. + +Parameters: + +For binary markets: + +- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. +- `probabilityInt`: Optional. The probability to use for `MKT` resolution. + +For free response markets: + +- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the Free Response outcome ID. +- `resolutions`: A map of from outcome => number to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. + +For numeric markets: + +- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. +- `value`: The value that the market may resolves to. + +Example request: + +``` +# Resolve a binary market +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"YES"}' + +# Resolve a binary market with a specified probability +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"MKT", + "probabilityInt": 75}' + +# Resolve a free response market with a single answer chosen +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"{...}"}' + +# Resolve a free response market with multiple answers chosen +$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' + --data-raw '{"contractId":"{...}", \ + "outcome":"MKT", + "resolutions": { + "{...}": 1, + "{...}": 2, + }}' +``` + ## Changelog - 2022-06-08: Add paging to markets endpoint diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index b36ec3ef..ee78dfec 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -2,7 +2,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, RESOLUTIONS } from '../../common/contract' +import { + Contract, + FreeResponseContract, + RESOLUTIONS, +} from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -59,10 +63,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { 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 + const { creatorId, closeTime } = contract const { value, resolutions, probabilityInt, outcome } = getResolutionParams( - outcomeType, + contract, req.body ) @@ -215,7 +219,8 @@ const sendResolutionEmails = async ( ) } -function getResolutionParams(outcomeType: string, body: string) { +function getResolutionParams(contract: Contract, body: string) { + const { outcomeType } = contract if (outcomeType === 'NUMERIC') { return { ...validate(numericSchema, body), @@ -225,19 +230,39 @@ function getResolutionParams(outcomeType: string, body: string) { } 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, + 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, + } + } } } else if (outcomeType === 'BINARY') { return { @@ -249,4 +274,11 @@ function getResolutionParams(outcomeType: string, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } +function validateAnswer(contract: FreeResponseContract, answer: number) { + const validIds = contract.answers.map((a) => a.id) + if (!validIds.includes(answer.toString())) { + throw new APIError(400, `${answer} is not a valid answer ID`) + } +} + const firestore = admin.firestore() diff --git a/web/lib/api/proxy.ts b/web/lib/api/proxy.ts index ec027518..294868ac 100644 --- a/web/lib/api/proxy.ts +++ b/web/lib/api/proxy.ts @@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => { 'Origin', ]) const hasBody = req.method != 'HEAD' && req.method != 'GET' - const opts = { headers, method: req.method, body: hasBody ? req : undefined } + const body = req.body ? JSON.stringify(req.body) : req + const opts = { + headers, + method: req.method, + body: hasBody ? body : undefined, + } return fetch(url, opts) } diff --git a/web/pages/api/v0/market/[id].ts b/web/pages/api/v0/market/[id]/index.ts similarity index 93% rename from web/pages/api/v0/market/[id].ts rename to web/pages/api/v0/market/[id]/index.ts index d1d676a3..eb238dab 100644 --- a/web/pages/api/v0/market/[id].ts +++ b/web/pages/api/v0/market/[id]/index.ts @@ -3,7 +3,7 @@ import { listAllBets } from 'web/lib/firebase/bets' import { listAllComments } from 'web/lib/firebase/comments' import { getContractFromId } from 'web/lib/firebase/contracts' import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors' -import { FullMarket, ApiError, toFullMarket } from '../_types' +import { FullMarket, ApiError, toFullMarket } from '../../_types' export default async function handler( req: NextApiRequest, diff --git a/web/pages/api/v0/market/[id]/resolve.ts b/web/pages/api/v0/market/[id]/resolve.ts new file mode 100644 index 00000000..1f291288 --- /dev/null +++ b/web/pages/api/v0/market/[id]/resolve.ts @@ -0,0 +1,28 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { + CORS_ORIGIN_MANIFOLD, + CORS_ORIGIN_LOCALHOST, +} from 'common/envs/constants' +import { applyCorsHeaders } from 'web/lib/api/cors' +import { fetchBackend, forwardResponse } from 'web/lib/api/proxy' + +export const config = { api: { bodyParser: true } } + +export default async function route(req: NextApiRequest, res: NextApiResponse) { + await applyCorsHeaders(req, res, { + origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + methods: 'POST', + }) + + const { id } = req.query + const contractId = id as string + + if (req.body) req.body.contractId = contractId + try { + const backendRes = await fetchBackend(req, 'resolvemarket') + await forwardResponse(res, backendRes) + } catch (err) { + console.error('Error talking to cloud function: ', err) + res.status(500).json({ message: 'Error communicating with backend.' }) + } +}