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:
parent
a5a0a1370a
commit
c5efd5b7d0
|
@ -456,7 +456,6 @@ Requires no authorization.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### `POST /v0/bet`
|
### `POST /v0/bet`
|
||||||
|
|
||||||
Places a new bet on behalf of the authorized user.
|
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}'
|
"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
|
## Changelog
|
||||||
|
|
||||||
- 2022-06-08: Add paging to markets endpoint
|
- 2022-06-08: Add paging to markets endpoint
|
||||||
|
|
|
@ -2,7 +2,11 @@ import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
|
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 { 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'
|
||||||
|
@ -59,10 +63,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
||||||
if (!contractSnap.exists)
|
if (!contractSnap.exists)
|
||||||
throw new APIError(404, 'No contract exists with the provided ID')
|
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, closeTime } = contract
|
||||||
|
|
||||||
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
|
||||||
outcomeType,
|
contract,
|
||||||
req.body
|
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') {
|
if (outcomeType === 'NUMERIC') {
|
||||||
return {
|
return {
|
||||||
...validate(numericSchema, body),
|
...validate(numericSchema, body),
|
||||||
|
@ -225,20 +230,40 @@ function getResolutionParams(outcomeType: string, body: string) {
|
||||||
} else if (outcomeType === 'FREE_RESPONSE') {
|
} else if (outcomeType === 'FREE_RESPONSE') {
|
||||||
const freeResponseParams = validate(freeResponseSchema, body)
|
const freeResponseParams = validate(freeResponseSchema, body)
|
||||||
const { outcome } = freeResponseParams
|
const { outcome } = freeResponseParams
|
||||||
const resolutions =
|
switch (outcome) {
|
||||||
'resolutions' in freeResponseParams
|
case 'CANCEL':
|
||||||
? Object.fromEntries(
|
|
||||||
freeResponseParams.resolutions.map((r) => [r.answer, r.pct])
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
return {
|
return {
|
||||||
// Free Response outcome IDs are numbers by convention,
|
|
||||||
// but treated as strings everywhere else.
|
|
||||||
outcome: outcome.toString(),
|
outcome: outcome.toString(),
|
||||||
resolutions,
|
resolutions: undefined,
|
||||||
value: undefined,
|
value: undefined,
|
||||||
probabilityInt: 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') {
|
} else if (outcomeType === 'BINARY') {
|
||||||
return {
|
return {
|
||||||
...validate(binarySchema, body),
|
...validate(binarySchema, body),
|
||||||
|
@ -249,4 +274,11 @@ function getResolutionParams(outcomeType: string, body: string) {
|
||||||
throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
|
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()
|
const firestore = admin.firestore()
|
||||||
|
|
|
@ -41,7 +41,12 @@ export const fetchBackend = (req: NextApiRequest, name: string) => {
|
||||||
'Origin',
|
'Origin',
|
||||||
])
|
])
|
||||||
const hasBody = req.method != 'HEAD' && req.method != 'GET'
|
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)
|
return fetch(url, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { listAllBets } from 'web/lib/firebase/bets'
|
||||||
import { listAllComments } from 'web/lib/firebase/comments'
|
import { listAllComments } from 'web/lib/firebase/comments'
|
||||||
import { getContractFromId } from 'web/lib/firebase/contracts'
|
import { getContractFromId } from 'web/lib/firebase/contracts'
|
||||||
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
|
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(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
28
web/pages/api/v0/market/[id]/resolve.ts
Normal file
28
web/pages/api/v0/market/[id]/resolve.ts
Normal 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.' })
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user