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 <marshall@pol.rs>
This commit is contained in:
Ben Congdon 2022-06-30 15:11:45 -07:00 committed by GitHub
parent a5a0a1370a
commit c5efd5b7d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 138 additions and 20 deletions

View File

@ -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

View File

@ -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()

View File

@ -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)
}

View File

@ -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,

View File

@ -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.' })
}
}