diff --git a/common/antes.ts b/common/antes.ts index b3dd990b..b9914451 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -5,12 +5,14 @@ import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, + MultipleChoiceContract, NumericContract, } from './contract' import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' import { ENV_CONFIG } from './envs/constants' +import { Answer } from './answer' export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 @@ -111,6 +113,50 @@ export function getFreeAnswerAnte( return anteBet } +export function getMultipleChoiceAntes( + creator: User, + contract: MultipleChoiceContract, + answers: string[], + betDocIds: string[] +) { + const { totalBets, totalShares } = contract + const amount = totalBets['0'] + const shares = totalShares['0'] + const p = 1 / answers.length + + const { createdTime } = contract + + const bets: Bet[] = answers.map((answer, i) => ({ + id: betDocIds[i], + userId: creator.id, + contractId: contract.id, + amount, + shares, + outcome: i.toString(), + probBefore: p, + probAfter: p, + createdTime, + isAnte: true, + fees: noFees, + })) + + const { username, name, avatarUrl } = creator + + const answerObjects: Answer[] = answers.map((answer, i) => ({ + id: i.toString(), + number: i, + contractId: contract.id, + createdTime, + userId: creator.id, + username, + name, + avatarUrl, + text: answer, + })) + + return { bets, answerObjects } +} + export function getNumericAnte( anteBettorId: string, contract: NumericContract, diff --git a/common/api.ts b/common/api.ts index b9376be5..1ae9a5fd 100644 --- a/common/api.ts +++ b/common/api.ts @@ -12,7 +12,9 @@ export class APIError extends Error { } export function getFunctionUrl(name: string) { - if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { + if (process.env.NEXT_PUBLIC_FUNCTIONS_URL) { + return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}` + } else if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { const { projectId, region } = ENV_CONFIG.firebaseConfig return `http://localhost:5001/${projectId}/${region}/${name}` } else { diff --git a/common/calculate.ts b/common/calculate.ts index e1f3e239..d25fd313 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -23,6 +23,7 @@ import { BinaryContract, FreeResponseContract, PseudoNumericContract, + MultipleChoiceContract, } from './contract' import { floatingEqual } from './util/math' @@ -200,7 +201,9 @@ export function getContractBetNullMetrics() { } } -export function getTopAnswer(contract: FreeResponseContract) { +export function getTopAnswer( + contract: FreeResponseContract | MultipleChoiceContract +) { const { answers } = contract const top = maxBy( answers?.map((answer) => ({ diff --git a/common/charity.ts b/common/charity.ts index f1223b04..c18c6ba1 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -169,7 +169,7 @@ export const charities: Charity[] = [ { name: "Founder's Pledge Climate Change Fund", website: 'https://founderspledge.com/funds/climate-change-fund', - photo: 'https://i.imgur.com/ZAhzHu4.png', + photo: 'https://i.imgur.com/9turaJW.png', preview: 'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.', description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally. @@ -183,7 +183,7 @@ export const charities: Charity[] = [ { name: "Founder's Pledge Patient Philanthropy Fund", website: 'https://founderspledge.com/funds/patient-philanthropy-fund', - photo: 'https://i.imgur.com/ZAhzHu4.png', + photo: 'https://i.imgur.com/LLR6CI6.png', preview: 'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity', description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future. @@ -551,6 +551,20 @@ With an emphasis on approval voting, we bring better elections to people across The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`, }, + { + name: 'Founders Pledge Global Health and Development Fund', + website: 'https://founderspledge.com/funds/global-health-and-development', + photo: 'https://i.imgur.com/EXbxH7T.png', + preview: + 'Tackling the vast global inequalities in health, wealth and opportunity', + description: `Nearly half the world lives on less than $2.50 a day, yet giving by the world’s richest often overlooks the world’s poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally. + +This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to: + +Improve the lives of the world's most vulnerable people. +Reduce the number of easily preventable deaths worldwide. +Work towards sustainable, systemic change.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/contract.ts b/common/contract.ts index 177af862..8bdab6fe 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -4,13 +4,19 @@ import { JSONContent } from '@tiptap/core' import { GroupLink } from 'common/group' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric +export type AnyOutcomeType = + | Binary + | MultipleChoice + | PseudoNumeric + | FreeResponse + | Numeric export type AnyContractType = | (CPMM & Binary) | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) + | (DPM & MultipleChoice) export type Contract = { id: string @@ -57,6 +63,7 @@ export type BinaryContract = Contract & Binary export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse +export type MultipleChoiceContract = Contract & MultipleChoice export type DPMContract = Contract & DPM export type CPMMContract = Contract & CPMM export type DPMBinaryContract = BinaryContract & DPM @@ -104,6 +111,13 @@ export type FreeResponse = { resolutions?: { [outcome: string]: number } // Used for MKT resolution. } +export type MultipleChoice = { + outcomeType: 'MULTIPLE_CHOICE' + answers: Answer[] + resolution?: string | 'MKT' | 'CANCEL' + resolutions?: { [outcome: string]: number } // Used for MKT resolution. +} + export type Numeric = { outcomeType: 'NUMERIC' bucketCount: number @@ -118,6 +132,7 @@ export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const OUTCOME_TYPES = [ 'BINARY', + 'MULTIPLE_CHOICE', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC', diff --git a/common/new-bet.ts b/common/new-bet.ts index 1f5c0340..576f35f8 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -18,6 +18,7 @@ import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, + MultipleChoiceContract, NumericContract, PseudoNumericContract, } from './contract' @@ -322,7 +323,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, loanAmount: number ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/new-contract.ts b/common/new-contract.ts index abfafaf8..ad7dc5a2 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -5,6 +5,7 @@ import { CPMM, DPM, FreeResponse, + MultipleChoice, Numeric, outcomeType, PseudoNumeric, @@ -30,7 +31,10 @@ export function getNewContract( bucketCount: number, min: number, max: number, - isLogScale: boolean + isLogScale: boolean, + + // for multiple choice + answers: string[] ) { const tags = parseTags( [ @@ -48,6 +52,8 @@ export function getNewContract( ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) + : outcomeType === 'MULTIPLE_CHOICE' + ? getMultipleChoiceProps(ante, answers) : getFreeAnswerProps(ante) const contract: Contract = removeUndefinedProps({ @@ -151,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => { return system } +const getMultipleChoiceProps = (ante: number, answers: string[]) => { + const numAnswers = answers.length + const betAnte = ante / numAnswers + const betShares = Math.sqrt(ante ** 2 / numAnswers) + + const defaultValues = (x: any) => + Object.fromEntries(range(0, numAnswers).map((k) => [k, x])) + + const system: DPM & MultipleChoice = { + mechanism: 'dpm-2', + outcomeType: 'MULTIPLE_CHOICE', + pool: defaultValues(betAnte), + totalShares: defaultValues(betShares), + totalBets: defaultValues(betAnte), + answers: [], + } + + return system +} + const getNumericProps = ( ante: number, bucketCount: number, diff --git a/common/package.json b/common/package.json index 6f0f5b29..c324379f 100644 --- a/common/package.json +++ b/common/package.json @@ -10,6 +10,7 @@ "dependencies": { "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", + "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.190", "lodash": "4.17.21" }, diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index 6cecddff..7d4a0185 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { deductDpmFees, getDpmProbability } from './calculate-dpm' -import { DPMContract, FreeResponseContract } from './contract' +import { + DPMContract, + FreeResponseContract, + MultipleChoiceContract, +} from './contract' import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees' import { addObjects } from './util/object' @@ -180,7 +184,7 @@ export const getDpmMktPayouts = ( export const getPayoutsMultiOutcome = ( resolutions: { [outcome: string]: number }, - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { const poolTotal = sum(Object.values(contract.pool)) diff --git a/common/payouts.ts b/common/payouts.ts index 1469cf4e..cc6c338d 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -117,6 +117,7 @@ export const getDpmPayouts = ( resolutionProbability?: number ): PayoutInfo => { const openBets = bets.filter((b) => !b.isSold && !b.sale) + const { outcomeType } = contract switch (outcome) { case 'YES': @@ -124,7 +125,8 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': @@ -132,7 +134,7 @@ export const getDpmPayouts = ( return getDpmCancelPayouts(contract, openBets) default: - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[]) // Outcome is a free response answer id. diff --git a/common/util/parse.ts b/common/util/parse.ts index cdaa6a6c..cacd0862 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -20,6 +20,7 @@ import { Text } from '@tiptap/extension-text' // other tiptap extensions import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' +import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' export function parseTags(text: string) { @@ -81,9 +82,9 @@ export const exhibitExts = [ Image, Link, + Mention, Iframe, ] -// export const exhibitExts = [StarterKit as unknown as Extension, Image] export function richTextToString(text?: JSONContent) { return !text ? '' : generateText(text, exhibitExts) diff --git a/dev.sh b/dev.sh new file mode 100755 index 00000000..ca3246ac --- /dev/null +++ b/dev.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +ENV=${1:-dev} +case $ENV in + dev) + FIREBASE_PROJECT=dev + NEXT_ENV=DEV ;; + prod) + FIREBASE_PROJECT=prod + NEXT_ENV=PROD ;; + localdb) + FIREBASE_PROJECT=dev + NEXT_ENV=DEV + EMULATOR=true ;; + *) + echo "Invalid environment; must be dev, prod, or localdb." + exit 1 +esac + +firebase use $FIREBASE_PROJECT + +if [ ! -z $EMULATOR ] +then + npx concurrently \ + -n FIRESTORE,FUNCTIONS,NEXT,TS \ + -c green,white,magenta,cyan \ + "yarn --cwd=functions firestore" \ + "cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \ + "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ + NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \ + NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \ + yarn --cwd=web serve" \ + "cross-env yarn --cwd=web ts-watch" +else + npx concurrently \ + -n FUNCTIONS,NEXT,TS \ + -c white,magenta,cyan \ + "yarn --cwd=functions dev" \ + "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ + NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \ + yarn --cwd=web serve" \ + "cross-env yarn --cwd=web ts-watch" +fi diff --git a/docs/docs/api.md b/docs/docs/api.md index 667c68b8..8b7dce30 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -579,6 +579,26 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ ]}' ``` +### `POST /v0/market/[marketId]/sell` + +Sells some quantity of shares in a market on behalf of the authorized user. + +Parameters: + +- `outcome`: Required. One of `YES`, `NO`, or a `number` indicating the numeric + bucket ID, depending on the market type. +- `shares`: Optional. The amount of shares to sell of the outcome given + above. If not provided, all the shares you own will be sold. + +Example request: + +``` +$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES", "shares": 10}' +``` + ### `GET /v0/bets` Gets a list of bets, ordered by creation date descending. diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index ade5caee..44167bcb 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets. - [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) +- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets ## Bots diff --git a/functions/package.json b/functions/package.json index f8657516..b20a8fd0 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,6 +12,8 @@ "start": "yarn shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", + "dev": "nodemon src/serve.ts", + "firestore": "firebase emulators:start --only firestore --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", @@ -27,7 +29,10 @@ "@tiptap/core": "2.0.0-beta.181", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", + "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.190", + "cors": "2.8.5", + "express": "4.18.1", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/api.ts b/functions/src/api.ts index 8c01ea05..fdda0ad5 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -1,6 +1,7 @@ import * as admin from 'firebase-admin' -import { logger } from 'firebase-functions/v2' -import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https' +import { Request, RequestHandler, Response } from 'express' +import { error } from 'firebase-functions/logger' +import { HttpsOptions } from 'firebase-functions/v2/https' import { log } from './utils' import { z } from 'zod' import { APIError } from '../../common/api' @@ -45,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise => { return { kind: 'jwt', data: await auth.verifyIdToken(payload) } } catch (err) { // This is somewhat suspicious, so get it into the firebase console - logger.error('Error verifying Firebase JWT: ', err) + error('Error verifying Firebase JWT: ', err) throw new APIError(403, 'Error validating token.') } case 'Key': @@ -83,6 +84,11 @@ export const zTimestamp = () => { }, z.date()) } +export type EndpointDefinition = { + opts: EndpointOptions & { method: string } + handler: RequestHandler +} + export const validate = (schema: T, val: unknown) => { const result = schema.safeParse(val) if (!result.success) { @@ -99,12 +105,12 @@ export const validate = (schema: T, val: unknown) => { } } -interface EndpointOptions extends HttpsOptions { - methods?: string[] +export interface EndpointOptions extends HttpsOptions { + method?: string } const DEFAULT_OPTS = { - methods: ['POST'], + method: 'POST', minInstances: 1, concurrency: 100, memory: '2GiB', @@ -113,28 +119,29 @@ const DEFAULT_OPTS = { } 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 (!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)) - log('User credentials processed.') - res.status(200).json(await fn(req, authedUser)) - } catch (e) { - if (e instanceof APIError) { - const output: { [k: string]: unknown } = { message: e.message } - if (e.details != null) { - output.details = e.details + const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts) + return { + opts, + handler: async (req: Request, res: Response) => { + log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`) + try { + if (opts.method !== req.method) { + throw new APIError(405, `This endpoint supports only ${opts.method}.`) + } + const authedUser = await lookupUser(await parseCredentials(req)) + res.status(200).json(await fn(req, authedUser)) + } catch (e) { + if (e instanceof APIError) { + const output: { [k: string]: unknown } = { message: e.message } + if (e.details != null) { + output.details = e.details + } + res.status(e.code).json(output) + } else { + error(e) + res.status(500).json({ message: 'An unknown error occurred.' }) } - res.status(e.code).json(output) - } else { - logger.error(e) - res.status(500).json({ message: 'An unknown error occurred.' }) } - } - }) + }, + } as EndpointDefinition } diff --git a/functions/src/cancel-bet.ts b/functions/src/cancel-bet.ts index d29a6cee..0b7a42aa 100644 --- a/functions/src/cancel-bet.ts +++ b/functions/src/cancel-bet.ts @@ -10,7 +10,7 @@ const bodySchema = z.object({ export const cancelbet = newEndpoint({}, async (req, auth) => { const { betId } = validate(bodySchema, req.body) - const result = await firestore.runTransaction(async (trans) => { + return await firestore.runTransaction(async (trans) => { const snap = await trans.get( firestore.collectionGroup('bets').where('id', '==', betId) ) @@ -28,8 +28,6 @@ export const cancelbet = newEndpoint({}, async (req, auth) => { return { ...bet, isCancelled: true } }) - - return result }) const firestore = admin.firestore() diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index c8cfc7c4..786ee8ae 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -2,11 +2,12 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { - CPMMBinaryContract, Contract, + CPMMBinaryContract, FreeResponseContract, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, + MultipleChoiceContract, NumericContract, OUTCOME_TYPES, } from '../../common/contract' @@ -20,15 +21,18 @@ import { FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, + getMultipleChoiceAntes, getNumericAnte, } from '../../common/antes' -import { getNoneAnswer } from '../../common/answer' +import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' import { Group, MAX_ID_LENGTH } from '../../common/group' import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' +import { zip } from 'lodash' +import { Bet } from 'common/bet' const descScehma: z.ZodType = z.lazy(() => z.intersection( @@ -79,11 +83,15 @@ const numericSchema = z.object({ isLogScale: z.boolean().optional(), }) +const multipleChoiceSchema = z.object({ + answers: z.string().trim().min(1).array().min(2), +}) + export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb, isLogScale + let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue @@ -97,12 +105,22 @@ export const createmarket = newEndpoint({}, async (req, auth) => { initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 if (initialProb < 1 || initialProb > 99) - throw new APIError(400, 'Invalid initial value.') + if (outcomeType === 'PSEUDO_NUMERIC') + throw new APIError( + 400, + `Initial value is too ${initialProb < 1 ? 'low' : 'high'}` + ) + else throw new APIError(400, 'Invalid initial probability.') } + if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) } + if (outcomeType === 'MULTIPLE_CHOICE') { + ;({ answers } = validate(multipleChoiceSchema, req.body)) + } + const userDoc = await firestore.collection('users').doc(auth.uid).get() if (!userDoc.exists) { throw new APIError(400, 'No user exists with the authenticated user ID.') @@ -120,7 +138,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { let group = null if (groupId) { - const groupDocRef = await firestore.collection('groups').doc(groupId) + const groupDocRef = firestore.collection('groups').doc(groupId) const groupDoc = await groupDocRef.get() if (!groupDoc.exists) { throw new APIError(400, 'No group exists with the given group ID.') @@ -162,7 +180,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { NUMERIC_BUCKET_COUNT, min ?? 0, max ?? 0, - isLogScale ?? false + isLogScale ?? false, + answers ?? [] ) if (ante) await chargeUser(user.id, ante, true) @@ -184,6 +203,31 @@ export const createmarket = newEndpoint({}, async (req, auth) => { ) await liquidityDoc.set(lp) + } else if (outcomeType === 'MULTIPLE_CHOICE') { + const betCol = firestore.collection(`contracts/${contract.id}/bets`) + const betDocs = (answers ?? []).map(() => betCol.doc()) + + const answerCol = firestore.collection(`contracts/${contract.id}/answers`) + const answerDocs = (answers ?? []).map((_, i) => + answerCol.doc(i.toString()) + ) + + const { bets, answerObjects } = getMultipleChoiceAntes( + user, + contract as MultipleChoiceContract, + answers ?? [], + betDocs.map((bd) => bd.id) + ) + + await Promise.all( + zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet)) + ) + await Promise.all( + zip(answerObjects, answerDocs).map(([answer, doc]) => + doc?.create(answer as Answer) + ) + ) + await contractRef.update({ answers: answerObjects }) } else if (outcomeType === 'FREE_RESPONSE') { const noneAnswerDoc = firestore .collection(`contracts/${contract.id}/answers`) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 1f413b6d..ab7c8e9a 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -63,10 +63,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { const deviceUsedBefore = !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) - const ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0 - - const balance = - deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE + const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE const user: User = { id: auth.uid, @@ -113,7 +110,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => { return !snap.empty } -const numberUsersWithIp = async (ipAddress: string) => { +export const numberUsersWithIp = async (ipAddress: string) => { const snap = await firestore .collection('private-users') .where('initialIpAddress', '==', ipAddress) diff --git a/functions/src/health.ts b/functions/src/health.ts index 938261db..4ce04e05 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,6 +1,6 @@ import { newEndpoint } from './api' -export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => { +export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => { return { message: 'Server is working.', uid: auth.uid, diff --git a/functions/src/index.ts b/functions/src/index.ts index df311886..239806de 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,4 +1,6 @@ import * as admin from 'firebase-admin' +import { onRequest } from 'firebase-functions/v2/https' +import { EndpointDefinition } from './api' admin.initializeApp() @@ -25,20 +27,63 @@ export * from './on-delete-group' export * from './score-contracts' // v2 -export * from './health' -export * from './transact' -export * from './change-user-info' -export * from './create-user' -export * from './create-answer' -export * from './place-bet' -export * from './cancel-bet' -export * from './sell-bet' -export * from './sell-shares' -export * from './claim-manalink' -export * from './create-contract' -export * from './add-liquidity' -export * from './withdraw-liquidity' -export * from './create-group' -export * from './resolve-market' -export * from './unsubscribe' -export * from './stripe' +import { health } from './health' +import { transact } from './transact' +import { changeuserinfo } from './change-user-info' +import { createuser } from './create-user' +import { createanswer } from './create-answer' +import { placebet } from './place-bet' +import { cancelbet } from './cancel-bet' +import { sellbet } from './sell-bet' +import { sellshares } from './sell-shares' +import { claimmanalink } from './claim-manalink' +import { createmarket } from './create-contract' +import { addliquidity } from './add-liquidity' +import { withdrawliquidity } from './withdraw-liquidity' +import { creategroup } from './create-group' +import { resolvemarket } from './resolve-market' +import { unsubscribe } from './unsubscribe' +import { stripewebhook, createcheckoutsession } from './stripe' + +const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { + return onRequest(opts, handler as any) +} +const healthFunction = toCloudFunction(health) +const transactFunction = toCloudFunction(transact) +const changeUserInfoFunction = toCloudFunction(changeuserinfo) +const createUserFunction = toCloudFunction(createuser) +const createAnswerFunction = toCloudFunction(createanswer) +const placeBetFunction = toCloudFunction(placebet) +const cancelBetFunction = toCloudFunction(cancelbet) +const sellBetFunction = toCloudFunction(sellbet) +const sellSharesFunction = toCloudFunction(sellshares) +const claimManalinkFunction = toCloudFunction(claimmanalink) +const createMarketFunction = toCloudFunction(createmarket) +const addLiquidityFunction = toCloudFunction(addliquidity) +const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) +const createGroupFunction = toCloudFunction(creategroup) +const resolveMarketFunction = toCloudFunction(resolvemarket) +const unsubscribeFunction = toCloudFunction(unsubscribe) +const stripeWebhookFunction = toCloudFunction(stripewebhook) +const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) + +export { + healthFunction as health, + transactFunction as transact, + changeUserInfoFunction as changeuserinfo, + createUserFunction as createuser, + createAnswerFunction as createanswer, + placeBetFunction as placebet, + cancelBetFunction as cancelbet, + sellBetFunction as sellbet, + sellSharesFunction as sellshares, + claimManalinkFunction as claimmanalink, + createMarketFunction as createmarket, + addLiquidityFunction as addliquidity, + withdrawLiquidityFunction as withdrawliquidity, + createGroupFunction as creategroup, + resolveMarketFunction as resolvemarket, + unsubscribeFunction as unsubscribe, + stripeWebhookFunction as stripewebhook, + createCheckoutSessionFunction as createcheckoutsession, +} diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index af4690b0..6af5e699 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore contractId: string } const { eventId } = context - const contract = await getContract(contractId) - if (!contract) - throw new Error('Could not find contract corresponding with answer') - const answer = change.data() as Answer // Ignore ante answer. if (answer.number === 0) return + const contract = await getContract(contractId) + if (!contract) + throw new Error('Could not find contract corresponding with answer') + const answerCreator = await getUser(answer.userId) if (!answerCreator) throw new Error('Could not find answer creator') diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index ba17f3e7..6ec092a5 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore .onCreate(async (change, context) => { const liquidity = change.data() as LiquidityProvision const { eventId } = context - const contract = await getContract(liquidity.contractId) - - if (!contract) - throw new Error('Could not find contract corresponding with liquidity') // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return + const contract = await getContract(liquidity.contractId) + if (!contract) + throw new Error('Could not find contract corresponding with liquidity') + const liquidityProvider = await getUser(liquidity.userId) if (!liquidityProvider) throw new Error('Could not find liquidity provider') diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index f5558730..a76132b5 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -103,8 +103,8 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`, } - const txnDoc = await firestore.collection(`txns/`).doc(txn.id) - await transaction.set(txnDoc, txn) + const txnDoc = firestore.collection(`txns/`).doc(txn.id) + transaction.set(txnDoc, txn) console.log('created referral with txn id:', txn.id) // We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes. transaction.update(referredByUserDoc, { diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 97ff9780..7501309a 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -96,7 +96,10 @@ export const placebet = newEndpoint({}, async (req, auth) => { limitProb, unfilledBets ) - } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { + } else if ( + (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && + mechanism == 'dpm-2' + ) { const { outcome } = validate(freeResponseSchema, req.body) const answerDoc = contractDoc.collection('answers').doc(outcome) const answerSnap = await trans.get(answerDoc) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index f8976cb3..08778a41 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -5,6 +5,7 @@ import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' import { Contract, FreeResponseContract, + MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' import { User } from '../../common/user' @@ -245,7 +246,10 @@ function getResolutionParams(contract: Contract, body: string) { ...validate(pseudoNumericSchema, body), resolutions: undefined, } - } else if (outcomeType === 'FREE_RESPONSE') { + } else if ( + outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE' + ) { const freeResponseParams = validate(freeResponseSchema, body) const { outcome } = freeResponseParams switch (outcome) { @@ -292,7 +296,10 @@ function getResolutionParams(contract: Contract, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } -function validateAnswer(contract: FreeResponseContract, answer: number) { +function validateAnswer( + contract: FreeResponseContract | MultipleChoiceContract, + 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`) diff --git a/functions/src/scripts/backfill-comment-ids.ts b/functions/src/scripts/backfill-comment-ids.ts new file mode 100644 index 00000000..e6bb6902 --- /dev/null +++ b/functions/src/scripts/backfill-comment-ids.ts @@ -0,0 +1,55 @@ +// We have some old comments without IDs and user IDs. Let's fill them in. +// Luckily, this was back when all comments had associated bets, so it's possible +// to retrieve the user IDs through the bets. + +import * as admin from 'firebase-admin' +import { QueryDocumentSnapshot } from 'firebase-admin/firestore' +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' + +initAdmin() +const firestore = admin.firestore() + +const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => { + const bets = await firestore.collectionGroup('bets').get() + log(`Loaded ${bets.size} bets.`) + const betsById = Object.fromEntries( + bets.docs.map((b) => [b.id, b.data() as Bet]) + ) + return Object.fromEntries( + comments.map((c) => [c.id, betsById[c.data().betId].userId]) + ) +} + +if (require.main === module) { + const commentsQuery = firestore.collectionGroup('comments') + commentsQuery.get().then(async (commentSnaps) => { + log(`Loaded ${commentSnaps.size} comments.`) + const needsFilling = commentSnaps.docs.filter((ct) => { + return !('id' in ct.data()) || !('userId' in ct.data()) + }) + log(`${needsFilling.length} comments need IDs.`) + const userIdNeedsFilling = needsFilling.filter((ct) => { + return !('userId' in ct.data()) + }) + log(`${userIdNeedsFilling.length} comments need user IDs.`) + const userIdsByCommentId = + userIdNeedsFilling.length > 0 + ? await getUserIdsByCommentId(userIdNeedsFilling) + : {} + const updates = needsFilling.map((ct) => { + const fields: { [k: string]: unknown } = {} + if (!ct.data().id) { + fields.id = ct.id + } + if (!ct.data().userId && userIdsByCommentId[ct.id]) { + fields.userId = userIdsByCommentId[ct.id] + } + return { doc: ct.ref, fields } + }) + log(`Updating ${updates.length} comments.`) + await writeAsync(firestore, updates) + log(`Updated all comments.`) + }) +} diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index cc17a620..5f7dc410 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -66,10 +66,18 @@ export const getServiceAccountCredentials = (env?: string) => { } export const initAdmin = (env?: string) => { - const serviceAccount = getServiceAccountCredentials(env) - console.log(`Initializing connection to ${serviceAccount.project_id}...`) - return admin.initializeApp({ - projectId: serviceAccount.project_id, - credential: admin.credential.cert(serviceAccount), - }) + try { + const serviceAccount = getServiceAccountCredentials(env) + console.log( + `Initializing connection to ${serviceAccount.project_id} Firebase...` + ) + return admin.initializeApp({ + projectId: serviceAccount.project_id, + credential: admin.credential.cert(serviceAccount), + }) + } catch (err) { + console.error(err) + console.log(`Initializing connection to default Firebase...`) + return admin.initializeApp() + } } diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 40ea0f4a..b6238434 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -16,7 +16,7 @@ import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), - shares: z.number(), + shares: z.number().optional(), // leave it out to sell all shares outcome: z.enum(['YES', 'NO']), }) @@ -49,11 +49,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const maxShares = sumBy(outcomeBets, (bet) => bet.shares) + const sharesToSell = shares ?? maxShares - if (!floatingLesserEqual(shares, maxShares)) + if (!floatingLesserEqual(sharesToSell, maxShares)) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) - const soldShares = Math.min(shares, maxShares) + const soldShares = Math.min(sharesToSell, maxShares) const unfilledBetsSnap = await transaction.get( getUnfilledBetsQuery(contractDoc) diff --git a/functions/src/serve.ts b/functions/src/serve.ts new file mode 100644 index 00000000..77282951 --- /dev/null +++ b/functions/src/serve.ts @@ -0,0 +1,68 @@ +import * as cors from 'cors' +import * as express from 'express' +import { Express, Request, Response, NextFunction } from 'express' +import { EndpointDefinition } from './api' + +const PORT = 8088 + +import { initAdmin } from './scripts/script-init' +initAdmin() + +import { health } from './health' +import { transact } from './transact' +import { changeuserinfo } from './change-user-info' +import { createuser } from './create-user' +import { createanswer } from './create-answer' +import { placebet } from './place-bet' +import { cancelbet } from './cancel-bet' +import { sellbet } from './sell-bet' +import { sellshares } from './sell-shares' +import { claimmanalink } from './claim-manalink' +import { createmarket } from './create-contract' +import { addliquidity } from './add-liquidity' +import { withdrawliquidity } from './withdraw-liquidity' +import { creategroup } from './create-group' +import { resolvemarket } from './resolve-market' +import { unsubscribe } from './unsubscribe' +import { stripewebhook, createcheckoutsession } from './stripe' + +type Middleware = (req: Request, res: Response, next: NextFunction) => void +const app = express() + +const addEndpointRoute = ( + path: string, + endpoint: EndpointDefinition, + ...middlewares: Middleware[] +) => { + const method = endpoint.opts.method.toLowerCase() as keyof Express + const corsMiddleware = cors({ origin: endpoint.opts.cors }) + const allMiddleware = [...middlewares, corsMiddleware] + app.options(path, corsMiddleware) // preflight requests + app[method](path, ...allMiddleware, endpoint.handler) +} + +const addJsonEndpointRoute = (name: string, endpoint: EndpointDefinition) => { + addEndpointRoute(name, endpoint, express.json()) +} + +addEndpointRoute('/health', health) +addJsonEndpointRoute('/transact', transact) +addJsonEndpointRoute('/changeuserinfo', changeuserinfo) +addJsonEndpointRoute('/createuser', createuser) +addJsonEndpointRoute('/createanswer', createanswer) +addJsonEndpointRoute('/placebet', placebet) +addJsonEndpointRoute('/cancelbet', cancelbet) +addJsonEndpointRoute('/sellbet', sellbet) +addJsonEndpointRoute('/sellshares', sellshares) +addJsonEndpointRoute('/claimmanalink', claimmanalink) +addJsonEndpointRoute('/createmarket', createmarket) +addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) +addJsonEndpointRoute('/creategroup', creategroup) +addJsonEndpointRoute('/resolvemarket', resolvemarket) +addJsonEndpointRoute('/unsubscribe', unsubscribe) +addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) +addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) + +app.listen(PORT) +console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index 450bbe35..79f0ad53 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -1,7 +1,7 @@ -import { onRequest } from 'firebase-functions/v2/https' import * as admin from 'firebase-admin' import Stripe from 'stripe' +import { EndpointDefinition } from './api' import { getPrivateUser, getUser, isProd, payUser } from './utils' import { sendThankYouEmail } from './emails' import { track } from './analytics' @@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd() 10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE', } -export const createcheckoutsession = onRequest( - { minInstances: 1, secrets: ['STRIPE_APIKEY'] }, - async (req, res) => { +export const createcheckoutsession: EndpointDefinition = { + opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] }, + handler: async (req, res) => { const userId = req.query.userId?.toString() const manticDollarQuantity = req.query.manticDollarQuantity?.toString() @@ -86,21 +86,24 @@ export const createcheckoutsession = onRequest( }) res.redirect(303, session.url || '') - } -) + }, +} -export const stripewebhook = onRequest( - { +export const stripewebhook: EndpointDefinition = { + opts: { + method: 'POST', minInstances: 1, secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], }, - async (req, res) => { + handler: async (req, res) => { const stripe = initStripe() let event try { + // Cloud Functions jam the raw body into a special `rawBody` property + const rawBody = (req as any).rawBody ?? req.body event = stripe.webhooks.constructEvent( - req.rawBody, + rawBody, req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOKSECRET as string ) @@ -116,8 +119,8 @@ export const stripewebhook = onRequest( } res.status(200).send('success') - } -) + }, +} const issueMoneys = async (session: StripeSession) => { const { id: sessionId } = session diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 48dd29c0..fda20e16 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,66 +1,72 @@ -import { onRequest } from 'firebase-functions/v2/https' import * as admin from 'firebase-admin' +import { EndpointDefinition } from './api' import { getUser } from './utils' import { PrivateUser } from '../../common/user' -export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => { - const id = req.query.id as string - let type = req.query.type as string - if (!id || !type) { - res.status(400).send('Empty id or type parameter.') - return - } +export const unsubscribe: EndpointDefinition = { + opts: { method: 'GET', minInstances: 1 }, + handler: async (req, res) => { + const id = req.query.id as string + let type = req.query.type as string + if (!id || !type) { + res.status(400).send('Empty id or type parameter.') + return + } - if (type === 'market-resolved') type = 'market-resolve' + if (type === 'market-resolved') type = 'market-resolve' - if ( - !['market-resolve', 'market-comment', 'market-answer', 'generic'].includes( - type - ) - ) { - res.status(400).send('Invalid type parameter.') - return - } + if ( + ![ + 'market-resolve', + 'market-comment', + 'market-answer', + 'generic', + ].includes(type) + ) { + res.status(400).send('Invalid type parameter.') + return + } - const user = await getUser(id) + const user = await getUser(id) - if (!user) { - res.send('This user is not currently subscribed or does not exist.') - return - } + if (!user) { + res.send('This user is not currently subscribed or does not exist.') + return + } - const { name } = user + const { name } = user - const update: Partial = { - ...(type === 'market-resolve' && { - unsubscribedFromResolutionEmails: true, - }), - ...(type === 'market-comment' && { - unsubscribedFromCommentEmails: true, - }), - ...(type === 'market-answer' && { - unsubscribedFromAnswerEmails: true, - }), - ...(type === 'generic' && { - unsubscribedFromGenericEmails: true, - }), - } + const update: Partial = { + ...(type === 'market-resolve' && { + unsubscribedFromResolutionEmails: true, + }), + ...(type === 'market-comment' && { + unsubscribedFromCommentEmails: true, + }), + ...(type === 'market-answer' && { + unsubscribedFromAnswerEmails: true, + }), + ...(type === 'generic' && { + unsubscribedFromGenericEmails: true, + }), + } - await firestore.collection('private-users').doc(id).update(update) + await firestore.collection('private-users').doc(id).update(update) - if (type === 'market-resolve') - res.send( - `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` - ) - else if (type === 'market-comment') - res.send( - `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` - ) - else if (type === 'market-answer') - res.send( - `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` - ) - else res.send(`${name}, you have been unsubscribed.`) -}) + if (type === 'market-resolve') + res.send( + `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` + ) + else if (type === 'market-comment') + res.send( + `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` + ) + else if (type === 'market-answer') + res.send( + `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` + ) + else res.send(`${name}, you have been unsubscribed.`) + }, +} const firestore = admin.firestore() diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 76570f54..cc9f8ebe 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -11,8 +11,6 @@ import { last } from 'lodash' const firestore = admin.firestore() -const oneDay = 1000 * 60 * 60 * 24 - const computeInvestmentValue = ( bets: Bet[], contractsDict: { [k: string]: Contract } @@ -59,8 +57,8 @@ export const updateMetricsCore = async () => { return { doc: firestore.collection('contracts').doc(contract.id), fields: { - volume24Hours: computeVolume(contractBets, now - oneDay), - volume7Days: computeVolume(contractBets, now - oneDay * 7), + volume24Hours: computeVolume(contractBets, now - DAY_MS), + volume7Days: computeVolume(contractBets, now - DAY_MS * 7), }, } }) diff --git a/package.json b/package.json index e4aee3fd..77420607 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,13 @@ "devDependencies": { "@typescript-eslint/eslint-plugin": "5.25.0", "@typescript-eslint/parser": "5.25.0", + "concurrently": "6.5.1", "eslint": "8.15.0", "eslint-plugin-lodash": "^7.4.0", "prettier": "2.5.0", - "typescript": "4.6.4" + "typescript": "4.6.4", + "ts-node": "10.9.1", + "nodemon": "2.0.19" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/components/alert-box.tsx b/web/components/alert-box.tsx index a8306583..b908b180 100644 --- a/web/components/alert-box.tsx +++ b/web/components/alert-box.tsx @@ -1,24 +1,26 @@ import { ExclamationIcon } from '@heroicons/react/solid' +import { Col } from './layout/col' +import { Row } from './layout/row' import { Linkify } from './linkify' export function AlertBox(props: { title: string; text: string }) { const { title, text } = props return ( -
-
-
-
+ + + + +
+
-
+ ) } diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 8c1d0430..6dcba79b 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react' import { XIcon } from '@heroicons/react/solid' import { Answer } from 'common/answer' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' import { APIError, placeBet } from 'web/lib/firebase/api' @@ -29,7 +29,7 @@ import { isIOS } from 'web/lib/util/device' export function AnswerBetPanel(props: { answer: Answer - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract closePanel: () => void className?: string isModal?: boolean diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index 87756a07..f1ab2f88 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { Answer } from 'common/answer' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { Row } from '../layout/row' import { Avatar } from '../avatar' @@ -13,7 +13,7 @@ import { Linkify } from '../linkify' export function AnswerItem(props: { answer: Answer - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract showChoice: 'radio' | 'checkbox' | undefined chosenProb: number | undefined totalChosenProb?: number diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 5b59f050..0a4ac1e1 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx' import { sum } from 'lodash' import { useState } from 'react' -import { Contract, FreeResponse } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { APIError, resolveMarket } from 'web/lib/firebase/api' import { Row } from '../layout/row' @@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button' import { removeUndefinedProps } from 'common/util/object' export function AnswerResolvePanel(props: { - contract: Contract & FreeResponse + contract: FreeResponseContract | MultipleChoiceContract resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined setResolveOption: ( option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 3e16a4c2..27152db9 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash' import { memo } from 'react' import { Bet } from 'common/bet' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useWindowSize } from 'web/hooks/use-window-size' const NUM_LINES = 6 export const AnswersGraph = memo(function AnswersGraph(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract bets: Bet[] height?: number }) { @@ -178,15 +178,22 @@ function formatTime( return d.format(format) } -const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => { - const { totalBets } = contract +const computeProbsByOutcome = ( + bets: Bet[], + contract: FreeResponseContract | MultipleChoiceContract +) => { + const { totalBets, outcomeType } = contract const betsByOutcome = groupBy(bets, (bet) => bet.outcome) const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const maxProb = Math.max( ...betsByOutcome[outcome].map((bet) => bet.probAfter) ) - return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001 + return ( + (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + maxProb > 0.02 && + totalBets[outcome] > 0.000000001 + ) }) const trackedOutcomes = sortBy( diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index e7bf4da8..6e0bfef6 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,7 +1,7 @@ import { sortBy, partition, sum, uniq } from 'lodash' import { useEffect, useState } from 'react' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { useUser } from 'web/hooks/use-user' import { getDpmOutcomeProbability } from 'common/calculate-dpm' @@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' -export function AnswersPanel(props: { contract: FreeResponseContract }) { +export function AnswersPanel(props: { + contract: FreeResponseContract | MultipleChoiceContract +}) { const { contract } = props - const { creatorId, resolution, resolutions, totalBets } = contract + const { creatorId, resolution, resolutions, totalBets, outcomeType } = + contract const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( answers.filter( - (answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001 + (answer) => + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + totalBets[answer.id] > 0.000000001 ), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) @@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
No answers yet...
)} - {tradingAllowed(contract) && + {outcomeType === 'FREE_RESPONSE' && + tradingAllowed(contract) && (!resolveOption || resolveOption === 'CANCEL') && ( )} @@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) { } function getAnswerItems( - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, answers: Answer[], user: User | undefined | null ) { @@ -178,7 +184,7 @@ function getAnswerItems( } function OpenAnswer(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract answer: Answer items: ActivityItem[] type: string diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx new file mode 100644 index 00000000..450c221a --- /dev/null +++ b/web/components/answers/multiple-choice-answers.tsx @@ -0,0 +1,65 @@ +import { MAX_ANSWER_LENGTH } from 'common/answer' +import { useState } from 'react' +import Textarea from 'react-expanding-textarea' +import { XIcon } from '@heroicons/react/solid' + +import { Col } from '../layout/col' +import { Row } from '../layout/row' + +export function MultipleChoiceAnswers(props: { + setAnswers: (answers: string[]) => void +}) { + const [answers, setInternalAnswers] = useState(['', '', '']) + + const setAnswer = (i: number, answer: string) => { + const newAnswers = setElement(answers, i, answer) + setInternalAnswers(newAnswers) + props.setAnswers(newAnswers) + } + + const removeAnswer = (i: number) => { + const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1)) + setInternalAnswers(newAnswers) + props.setAnswers(newAnswers) + } + + const addAnswer = () => setAnswer(answers.length, '') + + return ( + + {answers.map((answer, i) => ( + + {i + 1}.{' '} +