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/bet.ts b/common/bet.ts index d5072c0f..56e050a7 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -26,6 +26,7 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean + challengeSlug?: string } & Partial export type NumericBet = Bet & { 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/challenge.ts b/common/challenge.ts new file mode 100644 index 00000000..1a227f94 --- /dev/null +++ b/common/challenge.ts @@ -0,0 +1,63 @@ +export type Challenge = { + // The link to send: https://manifold.markets/challenges/username/market-slug/{slug} + // Also functions as the unique id for the link. + slug: string + + // The user that created the challenge. + creatorId: string + creatorUsername: string + creatorName: string + creatorAvatarUrl?: string + + // Displayed to people claiming the challenge + message: string + + // How much to put up + creatorAmount: number + + // YES or NO for now + creatorOutcome: string + + // Different than the creator + acceptorOutcome: string + acceptorAmount: number + + // The probability the challenger thinks + creatorOutcomeProb: number + + contractId: string + contractSlug: string + contractQuestion: string + contractCreatorUsername: string + + createdTime: number + // If null, the link is valid forever + expiresTime: number | null + + // How many times the challenge can be used + maxUses: number + + // Used for simpler caching + acceptedByUserIds: string[] + // Successful redemptions of the link + acceptances: Acceptance[] + + // TODO: will have to fill this on resolve contract + isResolved: boolean + resolutionOutcome?: string +} + +export type Acceptance = { + // User that accepted the challenge + userId: string + userUsername: string + userName: string + userAvatarUrl: string + + // The ID of the successful bet that tracks the money moved + betId: string + + createdTime: number +} + +export const CHALLENGES_ENABLED = true 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/notification.ts b/common/notification.ts index 5fd4236b..fa4cd90a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -37,6 +37,7 @@ export type notification_source_types = | 'group' | 'user' | 'bonus' + | 'challenge' export type notification_source_update_types = | 'created' @@ -64,3 +65,4 @@ export type notification_reason_types = | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' + | 'challenge_accepted' 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/user.ts b/common/user.ts index 0dac5a19..2aeb7122 100644 --- a/common/user.ts +++ b/common/user.ts @@ -40,12 +40,14 @@ export type User = { referredByContractId?: string referredByGroupId?: string lastPingTime?: number + shouldShowWelcome?: boolean } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 // for sus users, i.e. multiple sign ups for same person export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 + export type PrivateUser = { id: string // same as User.id username: string // denormalized from User @@ -55,6 +57,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string apiKey?: string diff --git a/common/util/parse.ts b/common/util/parse.ts index cdaa6a6c..f07e4097 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -20,7 +20,9 @@ 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' +import { uniq } from 'lodash' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -60,6 +62,15 @@ const checkAgainstQuery = (query: string, corpus: string) => export const searchInAny = (query: string, ...fields: string[]) => fields.some((field) => checkAgainstQuery(query, field)) +/** @return user ids of all \@mentions */ +export function parseMentions(data: JSONContent): string[] { + const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs + if (data.type === 'mention' && data.attrs) { + mentions.push(data.attrs.id as string) + } + return uniq(mentions) +} + // can't just do [StarterKit, Image...] because it doesn't work with cjs imports export const exhibitExts = [ Blockquote, @@ -81,9 +92,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..48564cb3 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -46,6 +46,28 @@ Gets a user by their unique ID. Many other API endpoints return this as the `use Requires no authorization. +### GET /v0/me + +Returns the authenticated user. + +### `GET /v0/groups` + +Gets all groups, in no particular order. + +Requires no authorization. + +### `GET /v0/groups/[slug]` + +Gets a group by its slug. + +Requires no authorization. + +### `GET /v0/groups/by-id/[id]` + +Gets a group by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -481,6 +503,20 @@ Parameters: answer. For numeric markets, this is a string representing the target bucket, and an additional `value` parameter is required which is a number representing the target value. (Bet on numeric markets at your own peril.) +- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing + the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the + probability percentage). + The bet will execute immediately in the direction of `outcome`, but not beyond this + specified limit. If not all the bet is filled, the bet will remain as an open offer + that can later be matched against an opposite direction bet. + - For example, if the current market probability is `50%`: + - A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market + probability moves down to `40%` and someone bets `M$15` of `NO` to match your + bet odds. + - A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely + depending on current unfilled limit bets and the AMM's liquidity. Any remaining + portion of the bet not filled would remain to be matched against in the future. + - An unfilled limit order bet can be cancelled using the cancel API. Example request: @@ -579,6 +615,26 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ ]}' ``` +### `POST /v0/market/[marketId]/sell` + +Sells some quantity of shares in a binary market on behalf of the authorized user. + +Parameters: + +- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only + own one kind of shares, you will sell that kind of shares. +- `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. @@ -597,7 +653,7 @@ Requires no authorization. - Example request ``` - https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-california-abolish-daylight-sa + https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord ``` - Response type: A `Bet[]`. @@ -605,31 +661,60 @@ Requires no authorization. ```json [ + // Limit bet, partially filled. { - "probAfter": 0.44418877319153904, - "shares": -645.8346334931828, + "isFilled": false, + "amount": 15.596681605353808, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "contractId": "Tz5dA01GkK5QKiQfZeDL", + "probBefore": 0.5730753474948571, + "isCancelled": false, "outcome": "YES", - "contractId": "tgB1XmvFXZNhjr3xMNLp", - "sale": { - "betId": "RcOtarI3d1DUUTjiE0rx", - "amount": 474.9999999999998 - }, - "createdTime": 1644602886293, - "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", - "probBefore": 0.7229189477449224, - "id": "x9eNmCaqQeXW8AgJ8Zmp", - "amount": -499.9999999999998 + "fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 }, + "shares": 31.193363210707616, + "limitProb": 0.5, + "id": "yXB8lVbs86TKkhWA1FVi", + "loanAmount": 0, + "orderAmount": 100, + "probAfter": 0.5730753474948571, + "createdTime": 1659482775970, + "fills": [ + { + "timestamp": 1659483249648, + "matchedBetId": "MfrMd5HTiGASDXzqibr7", + "amount": 15.596681605353808, + "shares": 31.193363210707616 + } + ] }, + // Normal bet (no limitProb specified). { - "probAfter": 0.9901970375647697, - "contractId": "zdeaYVAfHlo9jKzWh57J", - "outcome": "YES", - "amount": 1, - "id": "8PqxKYwXCcLYoXy2m2Nm", - "shares": 1.0049875638533763, - "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", - "probBefore": 0.9900000000000001, - "createdTime": 1644705818872 + "shares": 17.350459904608414, + "probBefore": 0.5304358279113885, + "isFilled": true, + "probAfter": 0.5730753474948571, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "amount": 10, + "contractId": "Tz5dA01GkK5QKiQfZeDL", + "id": "1LPJHNz5oAX4K6YtJlP1", + "fees": { + "platformFee": 0, + "liquidityFee": 0, + "creatorFee": 0.4251333951457593 + }, + "isCancelled": false, + "loanAmount": 0, + "orderAmount": 10, + "fills": [ + { + "amount": 10, + "matchedBetId": null, + "shares": 17.350459904608414, + "timestamp": 1659482757271 + } + ], + "createdTime": 1659482757271, + "outcome": "YES" } ] ``` diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index ade5caee..0871be52 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -10,13 +10,16 @@ A list of community-created projects built on, or related to, Manifold Markets. - [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government - [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold +- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$. ## API / Dev - [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 - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon +- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets diff --git a/firestore.rules b/firestore.rules index 0f28ca80..b0befc85 100644 --- a/firestore.rules +++ b/firestore.rules @@ -22,7 +22,7 @@ service cloud.firestore { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); // User referral rules allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() @@ -39,6 +39,17 @@ service cloud.firestore { allow read; } + match /{somePath=**}/challenges/{challengeId}{ + allow read; + } + + match /contracts/{contractId}/challenges/{challengeId}{ + allow read; + allow create: if request.auth.uid == request.resource.data.creatorId; + // allow update if there have been no claims yet and if the challenge is still open + allow update: if request.auth.uid == resource.data.creatorId; + } + match /users/{userId}/follows/{followUserId} { allow read; allow write: if request.auth.uid == userId; diff --git a/functions/package.json b/functions/package.json index f8657516..b0d8e458 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,11 @@ "@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", + "dayjs": "1.11.4", + "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/accept-challenge.ts b/functions/src/accept-challenge.ts new file mode 100644 index 00000000..eae6ab55 --- /dev/null +++ b/functions/src/accept-challenge.ts @@ -0,0 +1,167 @@ +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' +import { log } from './utils' +import { Contract, CPMMBinaryContract } from '../../common/contract' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { FieldValue } from 'firebase-admin/firestore' +import { removeUndefinedProps } from '../../common/util/object' +import { Acceptance, Challenge } from '../../common/challenge' +import { CandidateBet } from '../../common/new-bet' +import { createChallengeAcceptedNotification } from './create-notification' +import { noFees } from '../../common/fees' +import { formatMoney, formatPercent } from '../../common/util/format' + +const bodySchema = z.object({ + contractId: z.string(), + challengeSlug: z.string(), + outcomeType: z.literal('BINARY'), + closeTime: z.number().gte(Date.now()), +}) +const firestore = admin.firestore() + +export const acceptchallenge = newEndpoint({}, async (req, auth) => { + const { challengeSlug, contractId } = validate(bodySchema, req.body) + + const result = await firestore.runTransaction(async (trans) => { + const contractDoc = firestore.doc(`contracts/${contractId}`) + const userDoc = firestore.doc(`users/${auth.uid}`) + const challengeDoc = firestore.doc( + `contracts/${contractId}/challenges/${challengeSlug}` + ) + const [contractSnap, userSnap, challengeSnap] = await trans.getAll( + contractDoc, + userDoc, + challengeDoc + ) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + if (!userSnap.exists) throw new APIError(400, 'User not found.') + if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.') + + const anyContract = contractSnap.data() as Contract + const user = userSnap.data() as User + const challenge = challengeSnap.data() as Challenge + + if (challenge.acceptances.length > 0) + throw new APIError(400, 'Challenge already accepted.') + + const creatorDoc = firestore.doc(`users/${challenge.creatorId}`) + const creatorSnap = await trans.get(creatorDoc) + if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.') + const creator = creatorSnap.data() as User + + const { + creatorAmount, + acceptorOutcome, + creatorOutcome, + creatorOutcomeProb, + acceptorAmount, + } = challenge + + if (user.balance < acceptorAmount) + throw new APIError(400, 'Insufficient balance.') + + if (creator.balance < creatorAmount) + throw new APIError(400, 'Creator has insufficient balance.') + + const contract = anyContract as CPMMBinaryContract + const shares = (1 / creatorOutcomeProb) * creatorAmount + const createdTime = Date.now() + const probOfYes = + creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb + + log( + 'Creating challenge bet for', + user.username, + shares, + acceptorOutcome, + 'shares', + 'at', + formatPercent(creatorOutcomeProb), + 'for', + formatMoney(acceptorAmount) + ) + + const yourNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: acceptorAmount, + amount: acceptorAmount, + shares, + isCancelled: false, + contractId: contract.id, + outcome: acceptorOutcome, + probBefore: probOfYes, + probAfter: probOfYes, + loanAmount: 0, + createdTime, + fees: noFees, + challengeSlug: challenge.slug, + }) + + const yourNewBetDoc = contractDoc.collection('bets').doc() + trans.create(yourNewBetDoc, { + id: yourNewBetDoc.id, + userId: user.id, + ...yourNewBet, + }) + + trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) }) + + const creatorNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: creatorAmount, + amount: creatorAmount, + shares, + isCancelled: false, + contractId: contract.id, + outcome: creatorOutcome, + probBefore: probOfYes, + probAfter: probOfYes, + loanAmount: 0, + createdTime, + fees: noFees, + challengeSlug: challenge.slug, + }) + const creatorBetDoc = contractDoc.collection('bets').doc() + trans.create(creatorBetDoc, { + id: creatorBetDoc.id, + userId: creator.id, + ...creatorNewBet, + }) + + trans.update(creatorDoc, { + balance: FieldValue.increment(-creatorNewBet.amount), + }) + + const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount + trans.update(contractDoc, { volume }) + + trans.update( + challengeDoc, + removeUndefinedProps({ + acceptedByUserIds: [user.id], + acceptances: [ + { + userId: user.id, + betId: yourNewBetDoc.id, + createdTime, + amount: acceptorAmount, + userUsername: user.username, + userName: user.name, + userAvatarUrl: user.avatarUrl, + } as Acceptance, + ], + }) + ) + + await createChallengeAcceptedNotification( + user, + creator, + challenge, + acceptorAmount, + contract + ) + log('Done, sent notification.') + return yourNewBetDoc + }) + + return { betId: result.id } +}) 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..44ced6a8 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -2,33 +2,37 @@ 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' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser } from './utils' +import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' 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 { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group' import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' +import { uniq, 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.') @@ -118,27 +136,6 @@ export const createmarket = newEndpoint({}, async (req, auth) => { const slug = await getSlug(question) const contractRef = firestore.collection('contracts').doc() - let group = null - if (groupId) { - const groupDocRef = await 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.') - } - - group = groupDoc.data() as Group - if (!group.memberIds.includes(user.id)) { - throw new APIError( - 400, - 'User must be a member of the group to add markets to it.' - ) - } - if (!group.contractIds.includes(contractRef.id)) - await groupDocRef.update({ - contractIds: [...group.contractIds, contractRef.id], - }) - } - console.log( 'creating contract for', user.username, @@ -162,13 +159,41 @@ 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) await contractRef.create(contract) + let group = null + if (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.') + } + + group = groupDoc.data() as Group + if ( + !group.memberIds.includes(user.id) && + !group.anyoneCanJoin && + group.creatorId !== user.id + ) { + throw new APIError( + 400, + 'User must be a member/creator of the group or group must be open to add markets to it.' + ) + } + if (!group.contractIds.includes(contractRef.id)) { + await createGroupLinks(group, [contractRef.id], auth.uid) + await groupDocRef.update({ + contractIds: uniq([...group.contractIds, contractRef.id]), + }) + } + } + const providerId = user.id if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { @@ -184,6 +209,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`) @@ -240,3 +290,38 @@ export async function getContractFromSlug(slug: string) { return snap.empty ? undefined : (snap.docs[0].data() as Contract) } + +async function createGroupLinks( + group: Group, + contractIds: string[], + userId: string +) { + for (const contractId of contractIds) { + const contract = await getContract(contractId) + if (!contract?.groupSlugs?.includes(group.slug)) { + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), + }) + } + if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupLinks: [ + { + groupId: group.id, + name: group.name, + slug: group.slug, + userId, + createdTime: Date.now(), + } as GroupLink, + ...(contract?.groupLinks ?? []), + ], + }) + } + } +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 7cc05760..e16920f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -16,6 +16,7 @@ import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Challenge } from '../../common/challenge' const firestore = admin.firestore() type user_to_reason_texts = { @@ -32,7 +33,7 @@ export const createNotification = async ( miscData?: { contract?: Contract relatedSourceType?: notification_source_types - relatedUserId?: string + recipients?: string[] slug?: string title?: string } @@ -40,7 +41,7 @@ export const createNotification = async ( const { contract: sourceContract, relatedSourceType, - relatedUserId, + recipients, slug, title, } = miscData ?? {} @@ -127,7 +128,7 @@ export const createNotification = async ( }) } - const notifyRepliedUsers = async ( + const notifyRepliedUser = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string, relatedSourceType: notification_source_types @@ -144,7 +145,7 @@ export const createNotification = async ( } } - const notifyFollowedUser = async ( + const notifyFollowedUser = ( userToReasonTexts: user_to_reason_texts, followedUserId: string ) => { @@ -154,21 +155,24 @@ export const createNotification = async ( } } - const notifyTaggedUsers = async ( - userToReasonTexts: user_to_reason_texts, - sourceText: string - ) => { - const taggedUsers = sourceText.match(/@\w+/g) - if (!taggedUsers) return - // await all get tagged users: - const users = await Promise.all( - taggedUsers.map(async (username) => { - return await getUserByUsername(username.slice(1)) - }) + /** @deprecated parse from rich text instead */ + const parseMentions = async (source: string) => { + const mentions = source.match(/@\w+/g) + if (!mentions) return [] + return Promise.all( + mentions.map( + async (username) => (await getUserByUsername(username.slice(1)))?.id + ) ) - users.forEach((taggedUser) => { - if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts)) - userToReasonTexts[taggedUser.id] = { + } + + const notifyTaggedUsers = ( + userToReasonTexts: user_to_reason_texts, + userIds: (string | undefined)[] + ) => { + userIds.forEach((id) => { + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { reason: 'tagged_user', } }) @@ -253,7 +257,7 @@ export const createNotification = async ( }) } - const notifyUserAddedToGroup = async ( + const notifyUserAddedToGroup = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string ) => { @@ -275,11 +279,14 @@ export const createNotification = async ( const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. - if (sourceType === 'follow' && relatedUserId) { - await notifyFollowedUser(userToReasonTexts, relatedUserId) - } else if (sourceType === 'group' && relatedUserId) { - if (sourceUpdateType === 'created') - await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + if (sourceType === 'follow' && recipients?.[0]) { + notifyFollowedUser(userToReasonTexts, recipients[0]) + } else if ( + sourceType === 'group' && + sourceUpdateType === 'created' && + recipients + ) { + recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) } // The following functions need sourceContract to be defined. @@ -292,13 +299,10 @@ export const createNotification = async ( (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) ) { if (sourceType === 'comment') { - if (relatedUserId && relatedSourceType) - await notifyRepliedUsers( - userToReasonTexts, - relatedUserId, - relatedSourceType - ) - if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) + if (recipients?.[0] && relatedSourceType) + notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) + if (sourceText) + notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -307,6 +311,7 @@ export const createNotification = async ( await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) } else if (sourceType === 'contract' && sourceUpdateType === 'created') { await notifyUsersFollowers(userToReasonTexts) + notifyTaggedUsers(userToReasonTexts, recipients ?? []) } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { await notifyContractCreator(userToReasonTexts, sourceContract, { force: true, @@ -478,3 +483,35 @@ export const createReferralNotification = async ( } const groupPath = (groupSlug: string) => `/group/${groupSlug}` + +export const createChallengeAcceptedNotification = async ( + challenger: User, + challengeCreator: User, + challenge: Challenge, + acceptedAmount: number, + contract: Contract +) => { + const notificationRef = firestore + .collection(`/users/${challengeCreator.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: challengeCreator.id, + reason: 'challenge_accepted', + createdTime: Date.now(), + isSeen: false, + sourceId: challenge.slug, + sourceType: 'challenge', + sourceUpdateType: 'updated', + sourceUserName: challenger.name, + sourceUserUsername: challenger.username, + sourceUserAvatarUrl: challenger.avatarUrl, + sourceText: acceptedAmount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 1f413b6d..c30e78c3 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,5 +1,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' +import { uniq } from 'lodash' + import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, @@ -24,7 +26,6 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' -import { uniq } from 'lodash' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -63,10 +64,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, @@ -80,6 +78,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, + shouldShowWelcome: true, } await firestore.collection('users').doc(auth.uid).create(user) @@ -95,8 +94,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await sendWelcomeEmail(user, privateUser) await addUserToDefaultGroups(user) + await sendWelcomeEmail(user, privateUser) await track(auth.uid, 'create user', { username }, { ip: req.ip }) return user @@ -113,7 +112,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/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 5f0c450e..1ef9dbb7 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -1,12 +1,48 @@ - + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

Thanks for + using Manifold Markets. Running low + on mana (M$)? Click the link below to receive a one time gift of M$500!

+
+
+

+
+ + + + +
+ + + + +
+ + Claim M$500 + +
+
+
+
+

+ +

 

+

Cheers,

+

David from Manifold

+

 

+
+
+
+ +
+
+ +
+ + + +
+ +
+ + + +
+ + + +
+
+ + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

This e-mail has been sent to {{name}}, click here to unsubscribe.

+
+
+
+
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html new file mode 100644 index 00000000..64273e7c --- /dev/null +++ b/functions/src/email-templates/creating-market.html @@ -0,0 +1,738 @@ + + + + (no subject) + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
+

+ On Manifold Markets, several important factors + go into making a good question. These lead to + more people betting on them and allowing a more + accurate prediction to be formed! +

+

+   +

+

+ Manifold also gives its creators 10 Mana for + each unique trader that bets on your + market! +

+

+   +

+

+ What makes a good question? +

+
    +
  • + Clear resolution criteria. This is + needed so users know how you are going to + decide on what the correct answer is. +
  • +
  • + Clear resolution date. This is + sometimes slightly different from the closing + date. We recommend leaving the market open up + until you resolve it, but if it is different + make sure you say what day you intend to + resolve it in the description! +
  • +
  • + Detailed description. Use the rich + text editor to create an easy to read + description. Include any context or background + information that could be useful to people who + are interested in learning more that are + uneducated on the subject. +
  • +
  • + Add it to a group. Groups are the + primary way users filter for relevant markets. + Also, consider making your own groups and + inviting friends/interested communities to + them from other sites! +
  • +
  • + Bonus: Add a comment on your + prediction and explain (with links and + sources) supporting it. +
  • +
+

+   +

+

+ Examples of markets you should + emulate!  +

+ +

+   +

+

+ Why not + + + + create a market + while it is still fresh on your mind? +

+

+ Thanks for reading! +

+

+ David from Manifold +

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

+ This e-mail has been sent to {{name}}, + click here to unsubscribe. +

+
+
+
+
+ +
+
+ + + + diff --git a/functions/src/emails.ts b/functions/src/emails.ts index a29f982c..b7469e9f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -165,7 +165,6 @@ export const sendWelcomeEmail = async ( ) } -// TODO: use manalinks to give out M$500 export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -185,12 +184,12 @@ export const sendOneWeekBonusEmail = async ( await sendTemplateEmail( privateUser.email, - 'Manifold one week anniversary gift', + 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, unsubscribeLink, - manalink: '', // TODO + manalink: 'https://manifold.markets/link/lj4JbBvE', }, { from: 'David from Manifold ', diff --git a/functions/src/get-current-user.ts b/functions/src/get-current-user.ts new file mode 100644 index 00000000..409f897f --- /dev/null +++ b/functions/src/get-current-user.ts @@ -0,0 +1,18 @@ +import { User } from 'common/user' +import * as admin from 'firebase-admin' +import { newEndpoint, APIError } from './api' + +export const getcurrentuser = newEndpoint( + { method: 'GET' }, + async (_req, auth) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const [userSnap] = await firestore.getAll(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + + const user = userSnap.data() as User + + return user + } +) + +const firestore = admin.firestore() 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..125cdea4 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() @@ -42,3 +44,71 @@ export * from './create-group' export * from './resolve-market' export * from './unsubscribe' export * from './stripe' +export * from './mana-bonus-email' + +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' +import { getcurrentuser } from './get-current-user' +import { acceptchallenge } from './accept-challenge' + +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) +const getCurrentUserFunction = toCloudFunction(getcurrentuser) +const acceptChallenge = toCloudFunction(acceptchallenge) + +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, + getCurrentUserFunction as getcurrentuser, + acceptChallenge as acceptchallenge, +} diff --git a/functions/src/mana-bonus-email.ts b/functions/src/mana-bonus-email.ts new file mode 100644 index 00000000..29a7e6e0 --- /dev/null +++ b/functions/src/mana-bonus-email.ts @@ -0,0 +1,42 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as dayjs from 'dayjs' + +import { getPrivateUser } from './utils' +import { sendOneWeekBonusEmail } from './emails' +import { User } from 'common/user' + +export const manabonusemail = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('0 9 * * 1-7') + .onRun(async () => { + await sendOneWeekEmails() + }) + +const firestore = admin.firestore() + +async function sendOneWeekEmails() { + const oneWeekAgo = dayjs().subtract(1, 'week').valueOf() + const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf() + + const userDocs = await firestore + .collection('users') + .where('createdTime', '<=', oneWeekAgo) + .get() + + for (const user of userDocs.docs.map((d) => d.data() as User)) { + if (user.createdTime < twoWeekAgo) continue + + const privateUser = await getPrivateUser(user.id) + if (!privateUser || privateUser.manaBonusEmailSent) continue + + await firestore + .collection('private-users') + .doc(user.id) + .update({ manaBonusEmailSent: true }) + + console.log('sending m$ bonus email to', user.username) + await sendOneWeekBonusEmail(user, privateUser) + return + } +} 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-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 8d841ac0..4719fd08 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -68,9 +68,10 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUserId = comment.replyToCommentId + const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const recipients = repliedUserId ? [repliedUserId] : [] await createNotification( comment.id, @@ -79,7 +80,7 @@ export const onCreateCommentOnContract = functions commentCreator, eventId, comment.text, - { contract, relatedSourceType, relatedUserId } + { contract, relatedSourceType, recipients } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index a43beda7..6b57a9a0 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' -import { richTextToString } from '../../common/util/parse' +import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' export const onCreateContract = functions.firestore @@ -14,13 +14,16 @@ export const onCreateContract = functions.firestore const contractCreator = await getUser(contract.creatorId) if (!contractCreator) throw new Error('Could not find contract creator') + const desc = contract.description as JSONContent + const mentioned = parseMentions(desc) + await createNotification( contract.id, 'contract', 'created', contractCreator, eventId, - richTextToString(contract.description as JSONContent), - { contract } + richTextToString(desc), + { contract, recipients: mentioned } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 47618d7a..5209788d 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore const groupCreator = await getUser(group.creatorId) if (!groupCreator) throw new Error('Could not find group creator') // create notifications for all members of the group - for (const memberId of group.memberIds) { - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - { - relatedUserId: memberId, - slug: group.slug, - title: group.name, - } - ) - } + await createNotification( + group.id, + 'group', + 'created', + groupCreator, + eventId, + group.about, + { + recipients: group.memberIds, + slug: group.slug, + title: group.name, + } + ) }) 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-follow-user.ts b/functions/src/on-follow-user.ts index 9a6e6dce..52042345 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,7 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - { relatedUserId: follow.userId } + { recipients: [follow.userId] } ) }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index 3ab2a249..7e6a5697 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -1,6 +1,8 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Group } from '../../common/group' +import { getContract } from './utils' +import { uniq } from 'lodash' const firestore = admin.firestore() export const onUpdateGroup = functions.firestore @@ -9,7 +11,7 @@ export const onUpdateGroup = functions.firestore const prevGroup = change.before.data() as Group const group = change.after.data() as Group - // ignore the update we just made + // Ignore the activity update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return @@ -27,3 +29,23 @@ export const onUpdateGroup = functions.firestore .doc(group.id) .update({ mostRecentActivityTime: Date.now() }) }) + +export async function removeGroupLinks(group: Group, contractIds: string[]) { + for (const contractId of contractIds) { + const contract = await getContract(contractId) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([ + ...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ?? + []), + ]), + groupLinks: [ + ...(contract?.groupLinks?.filter( + (link) => link.groupId !== group.id + ) ?? []), + ], + }) + } +} 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..cc07d4be 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' @@ -17,6 +18,7 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' +import { isAdmin } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' @@ -68,8 +70,6 @@ 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 contractSnap = await contractDoc.get() if (!contractSnap.exists) @@ -82,7 +82,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { req.body ) - if (creatorId !== userId) + if (creatorId !== auth.uid && !isAdmin(auth.uid)) throw new APIError(403, 'User is not creator of contract') if (contract.resolution) throw new APIError(400, 'Contract already resolved') @@ -245,7 +245,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 +295,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/backfill-group-ids.ts b/functions/src/scripts/backfill-group-ids.ts new file mode 100644 index 00000000..ddce5d99 --- /dev/null +++ b/functions/src/scripts/backfill-group-ids.ts @@ -0,0 +1,25 @@ +// We have some groups without IDs. Let's fill them in. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' + +initAdmin() +const firestore = admin.firestore() + +if (require.main === module) { + const groupsQuery = firestore.collection('groups') + groupsQuery.get().then(async (groupSnaps) => { + log(`Loaded ${groupSnaps.size} groups.`) + const needsFilling = groupSnaps.docs.filter((ct) => { + return !('id' in ct.data()) + }) + log(`${needsFilling.length} groups need IDs.`) + const updates = needsFilling.map((group) => { + return { doc: group.ref, fields: { id: group.id } } + }) + log(`Updating ${updates.length} groups.`) + await writeAsync(firestore, updates) + log(`Updated all groups.`) + }) +} 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..ec08ab86 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,4 +1,4 @@ -import { sumBy, uniq } from 'lodash' +import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' @@ -9,15 +9,15 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { getValues, log } from './utils' import { Bet } from '../../common/bet' -import { floatingLesserEqual } from '../../common/util/math' +import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), - shares: z.number(), - outcome: z.enum(['YES', 'NO']), + shares: z.number().optional(), // leave it out to sell all shares + outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have }) export const sellshares = newEndpoint({}, async (req, auth) => { @@ -46,14 +46,37 @@ export const sellshares = newEndpoint({}, async (req, auth) => { throw new APIError(400, 'Trading is closed.') const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) + const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) + const sharesByOutcome = mapValues(betsByOutcome, (bets) => + sumBy(bets, (b) => b.shares) + ) - const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) - const maxShares = sumBy(outcomeBets, (bet) => bet.shares) + let chosenOutcome: 'YES' | 'NO' + if (outcome != null) { + chosenOutcome = outcome + } else { + const nonzeroShares = Object.entries(sharesByOutcome).filter( + ([_k, v]) => !floatingEqual(0, v) + ) + if (nonzeroShares.length == 0) { + throw new APIError(400, "You don't own any shares in this market.") + } + if (nonzeroShares.length > 1) { + throw new APIError( + 400, + `You own multiple kinds of shares, but did not specify which to sell.` + ) + } + chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO' + } - if (!floatingLesserEqual(shares, maxShares)) + const maxShares = sharesByOutcome[chosenOutcome] + const sharesToSell = 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) @@ -62,7 +85,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( soldShares, - outcome, + chosenOutcome, contract, prevLoanAmount, unfilledBets diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index f97234f6..d081997f 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -26,9 +26,10 @@ export const sendTemplateEmail = ( subject: string, templateId: string, templateData: Record, - options?: { from: string } + options?: Partial ) => { - const data = { + const data: mailgun.messages.SendTemplateData = { + ...options, from: options?.from ?? 'Manifold Markets ', to, subject, @@ -36,6 +37,7 @@ export const sendTemplateEmail = ( 'h:X-Mailgun-Variables': JSON.stringify(templateData), } const mg = initMailgun() + return mg.messages().send(data, (error) => { if (error) console.log('Error sending email', error) else console.log('Sent template email', templateId, to, subject) diff --git a/functions/src/serve.ts b/functions/src/serve.ts new file mode 100644 index 00000000..0064b69f --- /dev/null +++ b/functions/src/serve.ts @@ -0,0 +1,70 @@ +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' +import { getcurrentuser } from './get-current-user' + +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) +addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +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/og-image/README.md b/og-image/README.md index 7d0d2f92..6ecc4e82 100644 --- a/og-image/README.md +++ b/og-image/README.md @@ -1,32 +1,35 @@ +# Installing +1. `yarn install` +2. `yarn start` +3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]` +4. `Manifold Markets` to `Which scope should contain your project? [Y/n] ` +5. `Y` to `Link to existing project? [Y/n] ` +6. `opengraph-image` to `What’s the name of your existing project?` + # Quickstart -1. To get started: `yarn install` -2. To test locally: `yarn start` +1. To test locally: `yarn start` The local image preview is broken for some reason; but the service works. E.g. try `http://localhost:3000/manifold.png` -3. To deploy: push to Github - -For more info, see Contributing.md - -- note2: You may have to configure Vercel the first time: - - ``` - $ yarn start - yarn run v1.22.10 - $ cd .. && vercel dev - Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback - ? Set up and develop “~/Code/mantic”? [Y/n] y - ? Which scope should contain your project? Mantic Markets - ? Found project “mantic/mantic”. Link to it? [Y/n] n - ? Link to different existing project? [Y/n] y - ? What’s the name of your existing project? manifold-og-image - ``` - -- note2: (Not `dev` because that's reserved for Vercel) -- note3: (Or `cd .. && vercel --prod`, I think) +2. To deploy: push to Github +- note: (Not `dev` because that's reserved for Vercel) +- note2: (Or `cd .. && vercel --prod`, I think) +For more info, see Contributing.md (Everything below is from the original repo) +# Development +- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI. +- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters. +- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to +`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch. +You have to find your opengraph-image branch's url and replace the part before `m.png` with it. + - You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.` + - Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached. +- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github: +![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png) + + # [Open Graph Image as a Service](https://og-image.vercel.app) diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts new file mode 100644 index 00000000..6dc43ac1 --- /dev/null +++ b/og-image/api/_lib/challenge-template.ts @@ -0,0 +1,203 @@ +import { sanitizeHtml } from './sanitizer' +import { ParsedRequest } from './types' + +function getCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} + +export function getChallengeHtml(parsedReq: ParsedRequest) { + const { + theme, + fontSize, + question, + creatorName, + creatorAvatarUrl, + challengerAmount, + challengerOutcome, + creatorAmount, + creatorOutcome, + acceptedName, + acceptedAvatarUrl, + } = parsedReq + const MAX_QUESTION_CHARS = 78 + const truncatedQuestion = + question.length > MAX_QUESTION_CHARS + ? question.slice(0, MAX_QUESTION_CHARS) + '...' + : question + const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden' + const accepted = acceptedName !== '' + return ` + + + + Generated Image + + + + + +
+ + +
+
+ ${truncatedQuestion} +
+
+
+ + +
+

${creatorName}

+ +
+
+
${'M$' + creatorAmount}
+
${'on'}
+
${creatorOutcome}
+
+
+ + +
+ VS +
+
+ + +
+

You

+ +
+ +
+

${acceptedName}

+ +
+
+
${'M$' + challengerAmount}
+
${'on'}
+
${challengerOutcome}
+
+
+
+ +
+
+ +
+ + + +` +} diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index b8163719..6d5c9b3d 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -16,10 +16,19 @@ export function parseRequest(req: IncomingMessage) { // Attributes for Manifold card: question, probability, + numericValue, metadata, creatorName, creatorUsername, creatorAvatarUrl, + + // Challenge attributes: + challengerAmount, + challengerOutcome, + creatorAmount, + creatorOutcome, + acceptedName, + acceptedAvatarUrl, } = query || {} if (Array.isArray(fontSize)) { @@ -63,10 +72,17 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', probability: getString(probability), + numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', creatorName: getString(creatorName) || 'Manifold Markets', creatorUsername: getString(creatorUsername) || 'ManifoldMarkets', creatorAvatarUrl: getString(creatorAvatarUrl) || '', + challengerAmount: getString(challengerAmount) || '', + challengerOutcome: getString(challengerOutcome) || '', + creatorAmount: getString(creatorAmount) || '', + creatorOutcome: getString(creatorOutcome) || '', + acceptedName: getString(acceptedName) || '', + acceptedAvatarUrl: getString(acceptedAvatarUrl) || '', } parsedRequest.images = getDefaultImages(parsedRequest.images) return parsedRequest diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index a6b0336c..f59740c5 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -91,6 +91,7 @@ export function getHtml(parsedReq: ParsedRequest) { creatorName, creatorUsername, creatorAvatarUrl, + numericValue, } = parsedReq const MAX_QUESTION_CHARS = 100 const truncatedQuestion = @@ -126,7 +127,7 @@ export function getHtml(parsedReq: ParsedRequest) { - + diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index c0126a3b..ef0a8135 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -1,21 +1,29 @@ -export type FileType = "png" | "jpeg"; -export type Theme = "light" | "dark"; +export type FileType = 'png' | 'jpeg' +export type Theme = 'light' | 'dark' export interface ParsedRequest { - fileType: FileType; - text: string; - theme: Theme; - md: boolean; - fontSize: string; - images: string[]; - widths: string[]; - heights: string[]; + fileType: FileType + text: string + theme: Theme + md: boolean + fontSize: string + images: string[] + widths: string[] + heights: string[] // Attributes for Manifold card: - question: string; - probability: string; - metadata: string; - creatorName: string; - creatorUsername: string; - creatorAvatarUrl: string; + question: string + probability: string + numericValue: string + metadata: string + creatorName: string + creatorUsername: string + creatorAvatarUrl: string + // Challenge attributes: + challengerAmount: string + challengerOutcome: string + creatorAmount: string + creatorOutcome: string + acceptedName: string + acceptedAvatarUrl: string } diff --git a/og-image/api/index.ts b/og-image/api/index.ts index 467afcc9..1f1a837c 100644 --- a/og-image/api/index.ts +++ b/og-image/api/index.ts @@ -1,36 +1,38 @@ -import { IncomingMessage, ServerResponse } from "http"; -import { parseRequest } from "./_lib/parser"; -import { getScreenshot } from "./_lib/chromium"; -import { getHtml } from "./_lib/template"; +import { IncomingMessage, ServerResponse } from 'http' +import { parseRequest } from './_lib/parser' +import { getScreenshot } from './_lib/chromium' +import { getHtml } from './_lib/template' +import { getChallengeHtml } from './_lib/challenge-template' -const isDev = !process.env.AWS_REGION; -const isHtmlDebug = process.env.OG_HTML_DEBUG === "1"; +const isDev = !process.env.AWS_REGION +const isHtmlDebug = process.env.OG_HTML_DEBUG === '1' export default async function handler( req: IncomingMessage, res: ServerResponse ) { try { - const parsedReq = parseRequest(req); - const html = getHtml(parsedReq); + const parsedReq = parseRequest(req) + let html = getHtml(parsedReq) + if (parsedReq.challengerOutcome) html = getChallengeHtml(parsedReq) if (isHtmlDebug) { - res.setHeader("Content-Type", "text/html"); - res.end(html); - return; + res.setHeader('Content-Type', 'text/html') + res.end(html) + return } - const { fileType } = parsedReq; - const file = await getScreenshot(html, fileType, isDev); - res.statusCode = 200; - res.setHeader("Content-Type", `image/${fileType}`); + const { fileType } = parsedReq + const file = await getScreenshot(html, fileType, isDev) + res.statusCode = 200 + res.setHeader('Content-Type', `image/${fileType}`) res.setHeader( - "Cache-Control", + 'Cache-Control', `public, immutable, no-transform, s-maxage=31536000, max-age=31536000` - ); - res.end(file); + ) + res.end(file) } catch (e) { - res.statusCode = 500; - res.setHeader("Content-Type", "text/html"); - res.end("

Internal Error

Sorry, there was a problem

"); - console.error(e); + res.statusCode = 500 + res.setHeader('Content-Type', 'text/html') + res.end('

Internal Error

Sorry, there was a problem

') + console.error(e) } } 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/SEO.tsx b/web/components/SEO.tsx index 11e24c99..08dee31e 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' import Head from 'next/head' +import { Challenge } from 'common/challenge' export type OgCardProps = { question: string @@ -8,27 +9,51 @@ export type OgCardProps = { creatorName: string creatorUsername: string creatorAvatarUrl?: string + numericValue?: string } -function buildCardUrl(props: OgCardProps) { +function buildCardUrl(props: OgCardProps, challenge?: Challenge) { + const { + creatorAmount, + acceptances, + acceptorAmount, + creatorOutcome, + acceptorOutcome, + } = challenge || {} + const { userName, userAvatarUrl } = acceptances?.[0] ?? {} + const probabilityParam = props.probability === undefined ? '' : `&probability=${encodeURIComponent(props.probability ?? '')}` + + const numericValueParam = + props.numericValue === undefined + ? '' + : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + const creatorAvatarUrlParam = props.creatorAvatarUrl === undefined ? '' : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + const challengeUrlParams = challenge + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + + `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + + `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` + : '' + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + `?question=${encodeURIComponent(props.question)}` + probabilityParam + + numericValueParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + + challengeUrlParams ) } @@ -38,8 +63,9 @@ export function SEO(props: { url?: string children?: ReactNode ogCardProps?: OgCardProps + challenge?: Challenge }) { - const { title, description, url, children, ogCardProps } = props + const { title, description, url, children, ogCardProps, challenge } = props return ( @@ -71,13 +97,13 @@ export function SEO(props: { <> 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 ( -
-
-
-
+