From f79d69cd6924519f5fe92b67880ae677d6fc8011 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 20 Dec 2021 09:56:27 -0800 Subject: [PATCH 01/81] Increase default fetch size 25 -> 99 --- web/lib/firebase/contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f4c7de2f..4942d721 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -105,7 +105,7 @@ export async function listContracts(creatorId: string): Promise { } export async function listAllContracts(): Promise { - const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(25)) + const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(99)) const snapshot = await getDocs(q) return snapshot.docs.map((doc) => doc.data() as Contract) } @@ -113,7 +113,7 @@ export async function listAllContracts(): Promise { export function listenForContracts( setContracts: (contracts: Contract[]) => void ) { - const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(25)) + const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(99)) return onSnapshot(q, (snap) => { setContracts(snap.docs.map((doc) => doc.data() as Contract)) }) From 05b8ce96b516466cc5626464f139dc0259c4e274 Mon Sep 17 00:00:00 2001 From: jahooma Date: Mon, 20 Dec 2021 18:04:19 -0600 Subject: [PATCH 02/81] Fix about page numbering --- web/pages/about.tsx | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/web/pages/about.tsx b/web/pages/about.tsx index fc3244d8..36165cad 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -84,18 +84,16 @@ function Contents() { Anyone can create a market for any yes-or-no question. - -

- You can ask questions about the future like "Will Taiwan remove its - 14-day COVID quarantine by Jun 01, 2022?" Then use the information - to plan your trip. -

-

- You can also ask subjective, personal questions like "Will I enjoy - my 2022 Taiwan trip?". Then share the market with your family and - friends. -

-
    +

    + You can ask questions about the future like "Will Taiwan remove + its 14-day COVID quarantine by Jun 01, 2022?" Then use the + information to plan your trip. +

    +

    + You can also ask subjective, personal questions like "Will I + enjoy my 2022 Taiwan trip?". Then share the market with your + family and friends. +

  1. Anyone can bet on a market using Mantic Dollars (M$), our platform From 998b01cde7388e16bebeec9a887571a9dd794c4b Mon Sep 17 00:00:00 2001 From: jahooma Date: Mon, 20 Dec 2021 18:06:24 -0600 Subject: [PATCH 03/81] Don't flash no markets when loading on tag page. --- web/pages/tag/[tag].tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx index e47d344f..7f15748a 100644 --- a/web/pages/tag/[tag].tsx +++ b/web/pages/tag/[tag].tsx @@ -21,7 +21,11 @@ export default function TagPage() { return ( - <SearchableGrid contracts={contracts === 'loading' ? [] : contracts} /> + {contracts === 'loading' ? ( + <></> + ) : ( + <SearchableGrid contracts={contracts} /> + )} </Page> ) } From 3841e2a98fc16c4b1cb512cf0ec58fbe8e2acb8c Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 20 Dec 2021 22:29:01 -0800 Subject: [PATCH 04/81] Change Title to use body font --- web/components/mantic-logo.tsx | 1 - web/components/title.tsx | 5 +---- web/tailwind.config.js | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/web/components/mantic-logo.tsx b/web/components/mantic-logo.tsx index afeced49..904844cb 100644 --- a/web/components/mantic-logo.tsx +++ b/web/components/mantic-logo.tsx @@ -17,7 +17,6 @@ export function ManticLogo(props: { darkBackground?: boolean }) { 'font-major-mono lowercase mt-1 sm:text-2xl md:whitespace-nowrap', darkBackground && 'text-white' )} - style={{ fontFamily: 'Major Mono Display,monospace' }} > Mantic Markets </div> diff --git a/web/components/title.tsx b/web/components/title.tsx index 83b39562..6653a9f1 100644 --- a/web/components/title.tsx +++ b/web/components/title.tsx @@ -4,10 +4,7 @@ export function Title(props: { text: string; className?: string }) { const { text, className } = props return ( <h1 - className={clsx( - 'text-3xl font-major-mono text-indigo-700 inline-block my-6', - className - )} + className={clsx('text-3xl text-indigo-700 inline-block my-6', className)} > {text} </h1> diff --git a/web/tailwind.config.js b/web/tailwind.config.js index e899ffcf..4319621d 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -8,7 +8,7 @@ module.exports = { fontFamily: Object.assign( { ...defaultTheme.fontFamily }, { - 'major-mono': ['Courier', 'monospace'], + 'major-mono': ['Major Mono Display', 'monospace'], 'readex-pro': ['Readex Pro', 'sans-serif'], } ), From 9d438dc3568eee8baff2d42c7f7a6894972722e9 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 20 Dec 2021 22:29:32 -0800 Subject: [PATCH 05/81] Make a bunch of predictions at once (#9) * Set up a page to make bulk predictions * Integrate preview into the same card * List created predictions * Make changes per James's comments --- web/lib/firebase/contracts.ts | 4 +- web/pages/make-predictions.tsx | 223 +++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 web/pages/make-predictions.tsx diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 4942d721..652bec4b 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -51,11 +51,13 @@ export function compute(contract: Contract) { const volume = pool.YES + pool.NO - startPool.YES - startPool.NO const prob = pool.YES ** 2 / (pool.YES ** 2 + pool.NO ** 2) const probPercent = Math.round(prob * 100) + '%' + const startProb = + startPool.YES ** 2 / (startPool.YES ** 2 + startPool.NO ** 2) const createdDate = dayjs(createdTime).format('MMM D') const resolvedDate = isResolved ? dayjs(resolutionTime).format('MMM D') : undefined - return { volume, probPercent, createdDate, resolvedDate } + return { volume, probPercent, startProb, createdDate, resolvedDate } } const db = getFirestore(app) diff --git a/web/pages/make-predictions.tsx b/web/pages/make-predictions.tsx new file mode 100644 index 00000000..7b8c8405 --- /dev/null +++ b/web/pages/make-predictions.tsx @@ -0,0 +1,223 @@ +import clsx from 'clsx' +import Link from 'next/link' +import { useState } from 'react' +import { Col } from '../components/layout/col' +import { Row } from '../components/layout/row' +import { Spacer } from '../components/layout/spacer' +import { Linkify } from '../components/linkify' +import { Page } from '../components/page' +import { Title } from '../components/title' +import { useUser } from '../hooks/use-user' +import { compute, Contract, path } from '../lib/firebase/contracts' +import { createContract } from '../lib/service/create-contract' + +type Prediction = { + question: string + description: string + initialProb: number + createdUrl?: string +} + +function toPrediction(contract: Contract): Prediction { + const { startProb } = compute(contract) + return { + question: contract.question, + description: contract.description, + initialProb: startProb * 100, + createdUrl: path(contract), + } +} + +function PredictionRow(props: { prediction: Prediction }) { + const { prediction } = props + return ( + <Row className="gap-4 justify-between hover:bg-gray-300 p-4"> + <Col className="justify-between"> + <div className="font-medium text-indigo-700 mb-2"> + <Linkify text={prediction.question} /> + </div> + <div className="text-gray-500 text-sm">{prediction.description}</div> + </Col> + {/* Initial probability */} + <div className="ml-auto"> + <div className="text-3xl"> + <div className="text-primary"> + {prediction.initialProb.toFixed(0)}% + <div className="text-lg">chance</div> + </div> + </div> + </div> + {/* Current probability; hidden for now */} + {/* <div> + <div className="text-3xl"> + <div className="text-primary"> + {prediction.initialProb}%<div className="text-lg">chance</div> + </div> + </div> + </div> */} + </Row> + ) +} + +function PredictionList(props: { predictions: Prediction[] }) { + const { predictions } = props + return ( + <Col className="divide-gray-300 divide-y border-gray-300 border rounded-md"> + {predictions.map((prediction) => + prediction.createdUrl ? ( + <Link href={prediction.createdUrl}> + <a> + <PredictionRow + key={prediction.question} + prediction={prediction} + /> + </a> + </Link> + ) : ( + <PredictionRow key={prediction.question} prediction={prediction} /> + ) + )} + </Col> + ) +} + +const TEST_VALUE = `1. Biden approval rating (as per 538) is greater than 50%: 80% +2. Court packing is clearly going to happen (new justices don't have to be appointed by end of year): 5% +3. Yang is New York mayor: 80% +4. Newsom recalled as CA governor: 5% +5. At least $250 million in damage from BLM protests this year: 30% +6. Significant capital gains tax hike (above 30% for highest bracket): 20%` + +export default function MakePredictions() { + const user = useUser() + const [predictionsString, setPredictionsString] = useState('') + const [description, setDescription] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [createdContracts, setCreatedContracts] = useState<Contract[]>([]) + + const bulkPlaceholder = `e.g. +${TEST_VALUE} +... +` + + const predictions: Prediction[] = [] + + // Parse bulkContracts, then run createContract for each + const lines = predictionsString ? predictionsString.split('\n') : [] + for (const line of lines) { + // Parse line with regex + const matches = line.match(/^(.*):\s*(\d+)%\s*$/) || ['', '', ''] + const [_, question, prob] = matches + + if (!question || !prob) { + console.error('Invalid prediction: ', line) + continue + } + + predictions.push({ + question, + description, + initialProb: parseInt(prob), + }) + } + + async function createContracts() { + if (!user) { + // TODO: Convey error with snackbar/toast + console.error('You need to be signed in!') + return + } + setIsSubmitting(true) + for (const prediction of predictions) { + const contract = await createContract( + prediction.question, + prediction.description, + prediction.initialProb, + user + ) + setCreatedContracts((prev) => [...prev, contract]) + } + setPredictionsString('') + setIsSubmitting(false) + } + + return ( + <Page> + <Title text="Make Predictions" /> + <div className="w-full bg-gray-100 rounded-lg shadow-xl px-6 py-4"> + <form> + <div className="form-control"> + <label className="label"> + <span className="label-text">Prediction</span> + <div className="text-sm text-gray-500 ml-1"> + One prediction per line, each formatted like "The sun will rise + tomorrow: 99%" + </div> + </label> + + <textarea + className="textarea h-60 textarea-bordered" + placeholder={bulkPlaceholder} + value={predictionsString} + onChange={(e) => setPredictionsString(e.target.value || '')} + ></textarea> + </div> + </form> + + <Spacer h={4} /> + + <div className="form-control w-full"> + <label className="label"> + <span className="label-text">Tags</span> + </label> + + <input + type="text" + placeholder="e.g. #ACX2021 #World" + className="input" + value={description} + onChange={(e) => setDescription(e.target.value || '')} + /> + </div> + + {predictions.length > 0 && ( + <div> + <Spacer h={4} /> + <label className="label"> + <span className="label-text">Preview</span> + </label> + <PredictionList predictions={predictions} /> + </div> + )} + + <Spacer h={4} /> + + <div className="flex justify-end my-4"> + <button + type="submit" + className={clsx('btn btn-primary', { + loading: isSubmitting, + })} + disabled={predictions.length === 0 || isSubmitting} + onClick={(e) => { + e.preventDefault() + createContracts() + }} + > + Create all + </button> + </div> + </div> + + {createdContracts.length > 0 && ( + <> + <Spacer h={16} /> + <Title text="Created Predictions" /> + <div className="w-full bg-gray-100 rounded-lg shadow-xl px-6 py-4"> + <PredictionList predictions={createdContracts.map(toPrediction)} /> + </div> + </> + )} + </Page> + ) +} From ff4550fe511089c5cb99a64ee582edb6259d8b27 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Tue, 21 Dec 2021 13:52:27 -0600 Subject: [PATCH 06/81] Increase the starting balance (#11) --- web/lib/firebase/users.ts | 2 +- web/pages/about.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 7d98cda7..f6cd0366 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -19,7 +19,7 @@ import { signInWithPopup, } from 'firebase/auth' -export const STARTING_BALANCE = 100 +export const STARTING_BALANCE = 1000 export type User = { id: string diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 36165cad..4f2f7578 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -102,7 +102,7 @@ function Contents() { </li> </ol> <p> - You get M$ 100 just for signing up, so you can start betting + You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, you'll win money from people who bet against you. </p> From 856a2453a166ebb7a5be1718ae9a143a2c34daf0 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 21 Dec 2021 14:02:24 -0600 Subject: [PATCH 07/81] Remove references to paying for our Mantic Dollars --- web/pages/about.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 4f2f7578..61d87516 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -104,20 +104,22 @@ function Contents() { <p> You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, - you'll win money from people who bet against you. + you'll win Mantic Dollars from people who bet against you. </p> - <p> + {/* <p> If you run out of money, you can purchase more at a rate of $1 USD to M$ 100. (Note that Mantic Dollars are not convertible to cash and can only be used within our platform.) - </p> + </p> */} <aside> - 💡 We're still in Open Beta; we'll tweak this model and - periodically reset balances before our official launch. If you purchase - any M$ during the beta, we promise to honor that when we launch! + 💡 We're still in Open Beta; we'll tweak the amounts of Mantic + Dollars given out and periodically reset balances before our official + launch. + {/* If you purchase + any M$ during the beta, we promise to honor that when we launch! */} </aside> - <h3 id="why-do-i-want-to-bet-with-play-money-"> + {/* <h3 id="why-do-i-want-to-bet-with-play-money-"> Why do I want to bet with play-money? </h3> <p> @@ -136,7 +138,7 @@ function Contents() { We also have some thoughts on how to reward bettors: physical swag, exclusive conversations with market creators, NFTs...? If you have ideas, let us know! - </p> + </p> */} <h3 id="can-prediction-markets-work-without-real-money-"> Can prediction markets work without real money? </h3> @@ -198,7 +200,7 @@ function Contents() { <h3 id="how-is-this-different-from-metaculus-or-hypermind-"> How is this different from Metaculus or Hypermind? </h3> - <p> + {/* <p> We believe that in order to get the best results, you have to have skin in the game. We require that people use real money to buy the currency they use on our platform. @@ -207,9 +209,9 @@ function Contents() { With Mantic Dollars being a scarce resource, people will bet more carefully and can't rig the outcome by creating multiple accounts. The result is more accurate predictions. - </p> + </p> */} <p> - Mantic Markets is also focused on accessibility and allowing anyone to + Mantic Markets is focused on accessibility and allowing anyone to quickly create and judge a prediction market. When we all have the power to create and share prediction markets in seconds and apply our own judgment on the outcome, it leads to a qualitative shift in the number, From f48ae0170b241e1b6d13149c5d2e4228006b6258 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Fri, 24 Dec 2021 15:06:01 -0600 Subject: [PATCH 08/81] Sell bets (#12) * sell bet * dev mode * single-pot no-refund payoff; bet selling * Increase default fetch size 25 -> 99 * Fix about page numbering * Don't flash no markets when loading on tag page. * Change Title to use body font * Make a bunch of predictions at once (#9) * Set up a page to make bulk predictions * Integrate preview into the same card * List created predictions * Make changes per James's comments * Increase the starting balance (#11) * Remove references to paying for our Mantic Dollars * Update simulator to use new calculations * Change simulator random to be evenly random again * Sell bet UI * Migrate contracts and bets script * Add comment to script * bets => trades; exclude sold bets * change sale formula * Change current value to uncapped sell value. * Disable sell button while selling * Update some 'bet' to 'trade' Co-authored-by: Austin Chen <akrolsmir@gmail.com> Co-authored-by: jahooma <jahooma@gmail.com> --- .firebaserc | 6 +- functions/package.json | 1 + functions/src/index.ts | 3 +- functions/src/place-bet.ts | 33 ++--- functions/src/resolve-market.ts | 23 +-- functions/src/scripts/migrate-contract.ts | 58 ++++++++ functions/src/sell-bet.ts | 162 ++++++++++++++++++++++ functions/src/types/bet.ts | 21 ++- functions/src/types/contract.ts | 2 +- web/components/bet-panel.tsx | 18 +-- web/components/bets-list.tsx | 117 +++++++++++++--- web/components/contract-overview.tsx | 4 +- web/components/contracts-list.tsx | 14 +- web/components/profile-menu.tsx | 6 +- web/lib/calculate.ts | 134 ++++++++++++++++++ web/lib/calculation/contract.ts | 72 ---------- web/lib/firebase/api-call.ts | 6 + web/lib/firebase/bets.ts | 17 ++- web/lib/firebase/contracts.ts | 6 +- web/lib/firebase/init.ts | 40 +++--- web/lib/service/create-contract.ts | 7 +- web/lib/simulator/entries.ts | 17 ++- web/lib/util/format.ts | 4 +- web/pages/[username]/[contractSlug].tsx | 2 +- web/pages/about.tsx | 4 +- web/pages/simulator.tsx | 10 +- web/pages/{bets.tsx => trades.tsx} | 6 +- 27 files changed, 589 insertions(+), 204 deletions(-) create mode 100644 functions/src/scripts/migrate-contract.ts create mode 100644 functions/src/sell-bet.ts create mode 100644 web/lib/calculate.ts delete mode 100644 web/lib/calculation/contract.ts create mode 100644 web/lib/firebase/api-call.ts rename web/pages/{bets.tsx => trades.tsx} (68%) diff --git a/.firebaserc b/.firebaserc index 7594f4c6..0e191214 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1,5 +1,7 @@ { "projects": { - "default": "mantic-markets" + "default": "mantic-markets", + "prod": "mantic-markets", + "dev": "dev-mantic-markets" } -} +} \ No newline at end of file diff --git a/functions/package.json b/functions/package.json index baaac0b4..62dd0c58 100644 --- a/functions/package.json +++ b/functions/package.json @@ -2,6 +2,7 @@ "name": "functions", "scripts": { "build": "tsc", + "watch": "tsc -w", "serve": "yarn build && firebase emulators:start --only functions", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", diff --git a/functions/src/index.ts b/functions/src/index.ts index 5f6cfc99..ee8da84b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,6 +2,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() -export * from './keep-awake' +// export * from './keep-awake' export * from './place-bet' export * from './resolve-market' +export * from './sell-bet' diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 9e773848..e6e4d25a 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -43,7 +43,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() - const { newBet, newPool, newDpmWeights, newBalance } = getNewBetInfo( + const { newBet, newPool, newTotalShares, newBalance } = getNewBetInfo( user, outcome, amount, @@ -54,7 +54,7 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { pool: newPool, - dpmWeights: newDpmWeights, + totalShares: newTotalShares, }) transaction.update(userDoc, { balance: newBalance }) @@ -79,29 +79,19 @@ const getNewBetInfo = ( ? { YES: yesPool + amount, NO: noPool } : { YES: yesPool, NO: noPool + amount } - const dpmWeight = + const shares = outcome === 'YES' - ? (amount * noPool ** 2) / (yesPool ** 2 + amount * yesPool) - : (amount * yesPool ** 2) / (noPool ** 2 + amount * noPool) + ? amount + (amount * noPool ** 2) / (yesPool ** 2 + amount * yesPool) + : amount + (amount * yesPool ** 2) / (noPool ** 2 + amount * noPool) - const { YES: yesWeight, NO: noWeight } = contract.dpmWeights || { - YES: 0, - NO: 0, - } // only nesc for old contracts + const { YES: yesShares, NO: noShares } = contract.totalShares - const newDpmWeights = + const newTotalShares = outcome === 'YES' - ? { YES: yesWeight + dpmWeight, NO: noWeight } - : { YES: yesWeight, NO: noWeight + dpmWeight } + ? { YES: yesShares + shares, NO: noShares } + : { YES: yesShares, NO: noShares + shares } const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) - - const probAverage = - (amount + - noPool * Math.atan(yesPool / noPool) - - noPool * Math.atan((amount + yesPool) / noPool)) / - amount - const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2) const newBet: Bet = { @@ -109,15 +99,14 @@ const getNewBetInfo = ( userId: user.id, contractId: contract.id, amount, - dpmWeight, + shares, outcome, probBefore, - probAverage, probAfter, createdTime: Date.now(), } const newBalance = user.balance - amount - return { newBet, newPool, newDpmWeights, newBalance } + return { newBet, newPool, newTotalShares, newBalance } } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index e20ca354..280af022 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -78,22 +78,27 @@ export const resolveMarket = functions const firestore = admin.firestore() const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => { - const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const [yesBets, noBets] = _.partition( + openBets, + (bet) => bet.outcome === 'YES' + ) - const [pool, winningBets] = + const startPool = contract.startPool.YES + contract.startPool.NO + const truePool = contract.pool.YES + contract.pool.NO - startPool + + const [totalShares, winningBets] = outcome === 'YES' - ? [contract.pool.NO - contract.startPool.NO, yesBets] - : [contract.pool.YES - contract.startPool.YES, noBets] + ? [contract.totalShares.YES, yesBets] + : [contract.totalShares.NO, noBets] - const finalPool = (1 - PLATFORM_FEE - CREATOR_FEE) * pool - const creatorPayout = CREATOR_FEE * pool + const finalPool = (1 - PLATFORM_FEE - CREATOR_FEE) * truePool + const creatorPayout = CREATOR_FEE * truePool console.log('final pool:', finalPool, 'creator fee:', creatorPayout) - const sumWeights = _.sumBy(winningBets, (bet) => bet.dpmWeight) - const winnerPayouts = winningBets.map((bet) => ({ userId: bet.userId, - payout: bet.amount + (bet.dpmWeight / sumWeights) * finalPool, + payout: (bet.shares / totalShares) * finalPool, })) return winnerPayouts.concat([ diff --git a/functions/src/scripts/migrate-contract.ts b/functions/src/scripts/migrate-contract.ts new file mode 100644 index 00000000..eb55783e --- /dev/null +++ b/functions/src/scripts/migrate-contract.ts @@ -0,0 +1,58 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' +import { Bet } from '../types/bet' +import { Contract } from '../types/contract' + +type DocRef = admin.firestore.DocumentReference + +// Generate your own private key, and set the path below: +// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk +const serviceAccount = require('../../../../Downloads/mantic-markets-firebase-adminsdk-1ep46-820891bb87.json') + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}) +const firestore = admin.firestore() + +async function migrateBet(contractRef: DocRef, bet: Bet) { + const { dpmWeight, amount, id } = bet as Bet & { dpmWeight: number } + const shares = dpmWeight + amount + + await contractRef.collection('bets').doc(id).update({ shares }) +} + +async function migrateContract(contractRef: DocRef, contract: Contract) { + const bets = await contractRef + .collection('bets') + .get() + .then((snap) => snap.docs.map((bet) => bet.data() as Bet)) + + const totalShares = { + YES: _.sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)), + NO: _.sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)), + } + + await contractRef.update({ totalShares }) +} + +async function migrateContracts() { + console.log('Migrating contracts') + + const snapshot = await firestore.collection('contracts').get() + const contracts = snapshot.docs.map((doc) => doc.data() as Contract) + + console.log('Loaded contracts', contracts.length) + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + const betsSnapshot = await contractRef.collection('bets').get() + const bets = betsSnapshot.docs.map((bet) => bet.data() as Bet) + + console.log('contract', contract.question, 'bets', bets.length) + + for (const bet of bets) await migrateBet(contractRef, bet) + await migrateContract(contractRef, contract) + } +} + +if (require.main === module) migrateContracts().then(() => process.exit()) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts new file mode 100644 index 00000000..ba60a5e3 --- /dev/null +++ b/functions/src/sell-bet.ts @@ -0,0 +1,162 @@ +import * as admin from 'firebase-admin' +import * as functions from 'firebase-functions' + +import { CREATOR_FEE, PLATFORM_FEE } from './resolve-market' +import { Bet } from './types/bet' +import { Contract } from './types/contract' +import { User } from './types/user' + +export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + contractId: string + betId: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId, betId } = data + + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${userId}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) + return { status: 'error', message: 'User not found' } + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) + return { status: 'error', message: 'Invalid contract' } + const contract = contractSnap.data() as Contract + + const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) + const betSnap = await transaction.get(betDoc) + if (!betSnap.exists) return { status: 'error', message: 'Invalid bet' } + const bet = betSnap.data() as Bet + + if (bet.isSold) return { status: 'error', message: 'Bet already sold' } + + const newBetDoc = firestore + .collection(`contracts/${contractId}/bets`) + .doc() + + const { newBet, newPool, newTotalShares, newBalance, creatorFee } = + getSellBetInfo(user, bet, contract, newBetDoc.id) + + const creatorDoc = firestore.doc(`users/${contract.creatorId}`) + const creatorSnap = await transaction.get(creatorDoc) + if (creatorSnap.exists) { + const creator = creatorSnap.data() as User + const creatorNewBalance = creator.balance + creatorFee + transaction.update(creatorDoc, { balance: creatorNewBalance }) + } + + transaction.update(betDoc, { isSold: true }) + transaction.create(newBetDoc, newBet) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + }) + transaction.update(userDoc, { balance: newBalance }) + + return { status: 'success' } + }) + } +) + +const firestore = admin.firestore() + +const getSellBetInfo = ( + user: User, + bet: Bet, + contract: Contract, + newBetId: string +) => { + const { id: betId, amount, shares, outcome } = bet + + const { YES: yesPool, NO: noPool } = contract.pool + const { YES: yesStart, NO: noStart } = contract.startPool + const { YES: yesShares, NO: noShares } = contract.totalShares + + const [y, n, s] = [yesPool, noPool, shares] + + const shareValue = + outcome === 'YES' + ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b + (n ** 2 + + s * y + + y ** 2 - + Math.sqrt( + n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) + )) / + (2 * y) + : (y ** 2 + + s * n + + n ** 2 - + Math.sqrt( + y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) + )) / + (2 * n) + + const startPool = yesStart + noStart + const pool = yesPool + noPool - startPool + + const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) + + const f = pool / (probBefore * yesShares + (1 - probBefore) * noShares) + + const myPool = outcome === 'YES' ? yesPool - yesStart : noPool - noStart + + const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool) + + const newPool = + outcome === 'YES' + ? { YES: yesPool - adjShareValue, NO: noPool } + : { YES: yesPool, NO: noPool - adjShareValue } + + const newTotalShares = + outcome === 'YES' + ? { YES: yesShares - shares, NO: noShares } + : { YES: yesShares, NO: noShares - shares } + + const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2) + + const creatorFee = CREATOR_FEE * adjShareValue + const saleAmount = (1 - CREATOR_FEE - PLATFORM_FEE) * adjShareValue + + console.log( + 'SELL M$', + amount, + outcome, + 'for M$', + saleAmount, + 'M$/share:', + f, + 'creator fee: M$', + creatorFee + ) + + const newBet: Bet = { + id: newBetId, + userId: user.id, + contractId: contract.id, + amount: -adjShareValue, + shares: -shares, + outcome, + probBefore, + probAfter, + createdTime: Date.now(), + sale: { + amount: saleAmount, + betId, + }, + } + + const newBalance = user.balance + saleAmount + + return { newBet, newPool, newTotalShares, newBalance, creatorFee } +} diff --git a/functions/src/types/bet.ts b/functions/src/types/bet.ts index 2113f812..8b540165 100644 --- a/functions/src/types/bet.ts +++ b/functions/src/types/bet.ts @@ -2,11 +2,20 @@ export type Bet = { id: string userId: string contractId: string - amount: number // Amount of bet - outcome: 'YES' | 'NO' // Chosen outcome - createdTime: number + + amount: number // bet size; negative if SELL bet + outcome: 'YES' | 'NO' + shares: number // dynamic parimutuel pool weight; negative if SELL bet + probBefore: number - probAverage: number probAfter: number - dpmWeight: number // Dynamic Parimutuel weight -} \ No newline at end of file + + sale?: { + amount: number // amount user makes from sale + betId: string // id of bet being sold + } + + isSold?: boolean // true if this BUY bet has been sold + + createdTime: number +} diff --git a/functions/src/types/contract.ts b/functions/src/types/contract.ts index 8278206b..5f354654 100644 --- a/functions/src/types/contract.ts +++ b/functions/src/types/contract.ts @@ -12,7 +12,7 @@ export type Contract = { startPool: { YES: number; NO: number } pool: { YES: number; NO: number } - dpmWeights: { YES: number; NO: number } + totalShares: { YES: number; NO: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f0f34410..42f3abae 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -12,9 +12,9 @@ import { formatMoney, formatPercent } from '../lib/util/format' import { Title } from './title' import { getProbability, - getDpmWeight, + calculateShares, getProbabilityAfterBet, -} from '../lib/calculation/contract' +} from '../lib/calculate' import { firebaseLogin } from '../lib/firebase/users' export function BetPanel(props: { contract: Contract; className?: string }) { @@ -84,9 +84,9 @@ export function BetPanel(props: { contract: Contract; className?: string }) { betChoice, betAmount ?? 0 ) - const dpmWeight = getDpmWeight(contract.pool, betAmount ?? 0, betChoice) + const shares = calculateShares(contract.pool, betAmount ?? 0, betChoice) - const estimatedWinnings = Math.floor((betAmount ?? 0) + dpmWeight) + const estimatedWinnings = Math.floor(shares) const estimatedReturn = betAmount ? (estimatedWinnings - betAmount) / betAmount : 0 @@ -98,7 +98,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) { <Col className={clsx('bg-gray-100 shadow-xl px-8 py-6 rounded-md', className)} > - <Title className="!mt-0 whitespace-nowrap" text="Place a bet" /> + <Title className="!mt-0 whitespace-nowrap" text="Place a trade" /> <div className="mt-2 mb-1 text-sm text-gray-400">Outcome</div> <YesNoSelector @@ -107,7 +107,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) { onSelect={(choice) => onBetChoice(choice)} /> - <div className="mt-3 mb-1 text-sm text-gray-400">Bet amount</div> + <div className="mt-3 mb-1 text-sm text-gray-400">Amount</div> <Col className="my-2"> <label className="input-group"> <span className="text-sm bg-gray-200">M$</span> @@ -168,18 +168,18 @@ export function BetPanel(props: { contract: Contract; className?: string }) { )} onClick={betDisabled ? undefined : submitBet} > - {isSubmitting ? 'Submitting...' : 'Place bet'} + {isSubmitting ? 'Submitting...' : 'Submit trade'} </button> ) : ( <button className="btn mt-4 border-none normal-case text-lg font-medium px-10 bg-gradient-to-r from-teal-500 to-green-500 hover:from-teal-600 hover:to-green-600" onClick={firebaseLogin} > - Sign in to bet! + Sign in to trade! </button> )} - {wasSubmitted && <div className="mt-4">Bet submitted!</div>} + {wasSubmitted && <div className="mt-4">Trade submitted!</div>} </Col> ) } diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 5e51f141..52de070b 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -13,10 +13,13 @@ import { Row } from './layout/row' import { UserLink } from './user-page' import { calculatePayout, + calculateSaleAmount, currentValue, resolvedPayout, -} from '../lib/calculation/contract' +} from '../lib/calculate' import clsx from 'clsx' +import { cloudFunction } from '../lib/firebase/api-call' +import { ConfirmationButton } from './confirmation-button' export function BetsList(props: { user: User }) { const { user } = props @@ -65,19 +68,26 @@ export function BetsList(props: { user: User }) { contracts, (contract) => contract.isResolved ) + const currentBets = _.sumBy(unresolved, (contract) => - _.sumBy(contractBets[contract.id], (bet) => bet.amount) + _.sumBy(contractBets[contract.id], (bet) => { + if (bet.isSold || bet.sale) return 0 + return bet.amount + }) ) const currentBetsValue = _.sumBy(unresolved, (contract) => - _.sumBy(contractBets[contract.id], (bet) => currentValue(contract, bet)) + _.sumBy(contractBets[contract.id], (bet) => { + if (bet.isSold || bet.sale) return 0 + return currentValue(contract, bet) + }) ) return ( <Col className="mt-6 gap-6"> <Row className="gap-8"> <Col> - <div className="text-sm text-gray-500">Active bets</div> + <div className="text-sm text-gray-500">Currently invested</div> <div>{formatMoney(currentBets)}</div> </Col> <Col> @@ -173,16 +183,17 @@ export function MyBetsSummary(props: { const { bets, contract, className } = props const { resolution } = contract - const betsTotal = _.sumBy(bets, (bet) => bet.amount) + const excludeSales = bets.filter((b) => !b.isSold && !b.sale) + const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount) const betsPayout = resolution ? _.sumBy(bets, (bet) => resolvedPayout(contract, bet)) : 0 - const yesWinnings = _.sumBy(bets, (bet) => + const yesWinnings = _.sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'YES') ) - const noWinnings = _.sumBy(bets, (bet) => + const noWinnings = _.sumBy(excludeSales, (bet) => calculatePayout(contract, bet, 'NO') ) @@ -190,7 +201,7 @@ export function MyBetsSummary(props: { <Row className={clsx('gap-4 sm:gap-6', className)}> <Col> <div className="text-sm text-gray-500 whitespace-nowrap"> - Total bets + Amount invested </div> <div className="whitespace-nowrap">{formatMoney(betsTotal)}</div> </Col> @@ -228,6 +239,11 @@ export function ContractBetsTable(props: { }) { const { contract, bets, className } = props + const [sales, buys] = _.partition(bets, (bet) => bet.sale) + const salesDict = _.fromPairs( + sales.map((sale) => [sale.sale?.betId ?? '', sale]) + ) + const { isResolved } = contract return ( @@ -237,15 +253,21 @@ export function ContractBetsTable(props: { <tr className="p-2"> <th>Date</th> <th>Outcome</th> - <th>Bet</th> + <th>Amount</th> <th>Probability</th> {!isResolved && <th>Est. max payout</th>} <th>{isResolved ? <>Payout</> : <>Current value</>}</th> + <th></th> </tr> </thead> <tbody> - {bets.map((bet) => ( - <BetRow key={bet.id} bet={bet} contract={contract} /> + {buys.map((bet) => ( + <BetRow + key={bet.id} + bet={bet} + sale={salesDict[bet.id]} + contract={contract} + /> ))} </tbody> </table> @@ -253,14 +275,22 @@ export function ContractBetsTable(props: { ) } -function BetRow(props: { bet: Bet; contract: Contract }) { - const { bet, contract } = props - const { amount, outcome, createdTime, probBefore, probAfter, dpmWeight } = bet +function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { + const { bet, sale, contract } = props + const { + amount, + outcome, + createdTime, + probBefore, + probAfter, + shares, + isSold, + } = bet const { isResolved } = contract return ( <tr> - <td>{dayjs(createdTime).format('MMM D, H:mma')}</td> + <td>{dayjs(createdTime).format('MMM D, h:mma')}</td> <td> <OutcomeLabel outcome={outcome} /> </td> @@ -268,18 +298,63 @@ function BetRow(props: { bet: Bet; contract: Contract }) { <td> {formatPercent(probBefore)} → {formatPercent(probAfter)} </td> - {!isResolved && <td>{formatMoney(amount + dpmWeight)}</td>} + {!isResolved && <td>{formatMoney(shares)}</td>} <td> - {formatMoney( - isResolved - ? resolvedPayout(contract, bet) - : currentValue(contract, bet) - )} + {bet.isSold + ? 'N/A' + : formatMoney( + isResolved + ? resolvedPayout(contract, bet) + : bet.sale + ? bet.sale.amount ?? 0 + : currentValue(contract, bet) + )} </td> + + {sale ? ( + <td>SOLD for {formatMoney(Math.abs(sale.amount))}</td> + ) : ( + !isResolved && + !isSold && ( + <td className="text-neutral"> + <SellButton contract={contract} bet={bet} /> + </td> + ) + )} </tr> ) } +const sellBet = cloudFunction('sellBet') + +function SellButton(props: { contract: Contract; bet: Bet }) { + const { contract, bet } = props + const [isSubmitting, setIsSubmitting] = useState(false) + + return ( + <ConfirmationButton + id={`sell-${bet.id}`} + openModelBtn={{ + className: clsx('btn-sm', isSubmitting && 'btn-disabled loading'), + label: 'Sell', + }} + submitBtn={{ className: 'btn-primary' }} + onSubmit={async () => { + setIsSubmitting(true) + await sellBet({ contractId: contract.id, betId: bet.id }) + setIsSubmitting(false) + }} + > + <div className="text-2xl mb-4">Sell</div> + <div> + Do you want to sell your {formatMoney(bet.amount)} position on{' '} + <OutcomeLabel outcome={bet.outcome} /> for{' '} + {formatMoney(calculateSaleAmount(contract, bet))}? + </div> + </ConfirmationButton> + ) +} + function OutcomeLabel(props: { outcome: 'YES' | 'NO' | 'CANCEL' }) { const { outcome } = props diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index ad105c9d..aa8dfe6c 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -90,7 +90,7 @@ export const ContractOverview = (props: { }) => { const { contract, className } = props const { resolution, creatorId } = contract - const { probPercent, volume } = compute(contract) + const { probPercent, truePool } = compute(contract) const user = useUser() const isCreator = user?.id === creatorId @@ -140,7 +140,7 @@ export const ContractOverview = (props: { <ContractDescription contract={contract} isCreator={isCreator} /> {/* Show a delete button for contracts without any trading */} - {isCreator && volume === 0 && ( + {isCreator && truePool === 0 && ( <> <Spacer h={8} /> <button diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index f0d15b7b..74c94ad5 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -17,7 +17,7 @@ import { Linkify } from './linkify' export function ContractDetails(props: { contract: Contract }) { const { contract } = props - const { volume, createdDate, resolvedDate } = compute(contract) + const { truePool, createdDate, resolvedDate } = compute(contract) return ( <Row className="flex-wrap text-sm text-gray-500"> @@ -29,7 +29,7 @@ export function ContractDetails(props: { contract: Contract }) { {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} </div> <div className="mx-2">•</div> - <div className="whitespace-nowrap">{formatMoney(volume)} volume</div> + <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> </Row> ) } @@ -110,14 +110,14 @@ function ContractsGrid(props: { contracts: Contract[] }) { ) } -type Sort = 'createdTime' | 'volume' | 'resolved' | 'all' +type Sort = 'createdTime' | 'pool' | 'resolved' | 'all' export function SearchableGrid(props: { contracts: Contract[] defaultSort?: Sort }) { const { contracts, defaultSort } = props const [query, setQuery] = useState('') - const [sort, setSort] = useState(defaultSort || 'volume') + const [sort, setSort] = useState(defaultSort || 'pool') function check(corpus: String) { return corpus.toLowerCase().includes(query.toLowerCase()) @@ -132,8 +132,8 @@ export function SearchableGrid(props: { if (sort === 'createdTime' || sort === 'resolved' || sort === 'all') { matches.sort((a, b) => b.createdTime - a.createdTime) - } else if (sort === 'volume') { - matches.sort((a, b) => compute(b).volume - compute(a).volume) + } else if (sort === 'pool') { + matches.sort((a, b) => compute(b).truePool - compute(a).truePool) } if (sort !== 'all') { @@ -159,7 +159,7 @@ export function SearchableGrid(props: { value={sort} onChange={(e) => setSort(e.target.value as Sort)} > - <option value="volume">Most traded</option> + <option value="pool">Most traded</option> <option value="createdTime">Newest first</option> <option value="resolved">Resolved</option> <option value="all">All markets</option> diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 21a603da..43022a9e 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -41,8 +41,8 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) { ] : []), { - name: 'Your bets', - href: '/bets', + name: 'Your trades', + href: '/trades', }, { name: 'Your markets', @@ -64,7 +64,7 @@ function ProfileSummary(props: { user: User }) { <div className="rounded-full w-10 h-10 mr-4"> <Image src={user.avatarUrl} width={40} height={40} /> </div> - <div className="truncate" style={{ maxWidth: 175 }}> + <div className="truncate text-left" style={{ maxWidth: 175 }}> {user.name} <div className="text-gray-700 text-sm">{formatMoney(user.balance)}</div> </div> diff --git a/web/lib/calculate.ts b/web/lib/calculate.ts new file mode 100644 index 00000000..13649c06 --- /dev/null +++ b/web/lib/calculate.ts @@ -0,0 +1,134 @@ +import { Bet } from './firebase/bets' +import { Contract } from './firebase/contracts' + +const fees = 0.02 + +export function getProbability(pool: { YES: number; NO: number }) { + const [yesPool, noPool] = [pool.YES, pool.NO] + const numerator = Math.pow(yesPool, 2) + const denominator = Math.pow(yesPool, 2) + Math.pow(noPool, 2) + return numerator / denominator +} + +export function getProbabilityAfterBet( + pool: { YES: number; NO: number }, + outcome: 'YES' | 'NO', + bet: number +) { + const [YES, NO] = [ + pool.YES + (outcome === 'YES' ? bet : 0), + pool.NO + (outcome === 'NO' ? bet : 0), + ] + return getProbability({ YES, NO }) +} + +export function calculateShares( + pool: { YES: number; NO: number }, + bet: number, + betChoice: 'YES' | 'NO' +) { + const [yesPool, noPool] = [pool.YES, pool.NO] + + return betChoice === 'YES' + ? bet + (bet * noPool ** 2) / (yesPool ** 2 + bet * yesPool) + : bet + (bet * yesPool ** 2) / (noPool ** 2 + bet * noPool) +} + +export function calculatePayout( + contract: Contract, + bet: Bet, + outcome: 'YES' | 'NO' | 'CANCEL' +) { + const { amount, outcome: betOutcome, shares } = bet + + if (outcome === 'CANCEL') return amount + if (betOutcome !== outcome) return 0 + + const { totalShares } = contract + + if (totalShares[outcome] === 0) return 0 + + const startPool = contract.startPool.YES + contract.startPool.NO + const pool = contract.pool.YES + contract.pool.NO - startPool + + return (1 - fees) * (shares / totalShares[outcome]) * pool +} + +export function resolvedPayout(contract: Contract, bet: Bet) { + if (contract.resolution) + return calculatePayout(contract, bet, contract.resolution) + throw new Error('Contract was not resolved') +} + +export function currentValue(contract: Contract, bet: Bet) { + // const prob = getProbability(contract.pool) + // const yesPayout = calculatePayout(contract, bet, 'YES') + // const noPayout = calculatePayout(contract, bet, 'NO') + + // return prob * yesPayout + (1 - prob) * noPayout + + const { shares, outcome } = bet + + const { YES: yesPool, NO: noPool } = contract.pool + const [y, n, s] = [yesPool, noPool, shares] + + const shareValue = + outcome === 'YES' + ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b + (n ** 2 + + s * y + + y ** 2 - + Math.sqrt( + n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) + )) / + (2 * y) + : (y ** 2 + + s * n + + n ** 2 - + Math.sqrt( + y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) + )) / + (2 * n) + + return (1 - fees) * shareValue +} +export function calculateSaleAmount(contract: Contract, bet: Bet) { + const { shares, outcome } = bet + + const { YES: yesPool, NO: noPool } = contract.pool + const { YES: yesStart, NO: noStart } = contract.startPool + const { YES: yesShares, NO: noShares } = contract.totalShares + + const [y, n, s] = [yesPool, noPool, shares] + + const shareValue = + outcome === 'YES' + ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b + (n ** 2 + + s * y + + y ** 2 - + Math.sqrt( + n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) + )) / + (2 * y) + : (y ** 2 + + s * n + + n ** 2 - + Math.sqrt( + y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) + )) / + (2 * n) + + const startPool = yesStart + noStart + const pool = yesPool + noPool - startPool + + const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) + const f = pool / (probBefore * yesShares + (1 - probBefore) * noShares) + + const myPool = outcome === 'YES' ? yesPool - yesStart : noPool - noStart + + const adjShareValue = Math.min(Math.min(1, f) * shareValue, myPool) + + const saleAmount = (1 - fees) * adjShareValue + return saleAmount +} diff --git a/web/lib/calculation/contract.ts b/web/lib/calculation/contract.ts deleted file mode 100644 index 69464beb..00000000 --- a/web/lib/calculation/contract.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Bet } from '../firebase/bets' -import { Contract } from '../firebase/contracts' - -const fees = 0.02 - -export function getProbability(pool: { YES: number; NO: number }) { - const [yesPool, noPool] = [pool.YES, pool.NO] - const numerator = Math.pow(yesPool, 2) - const denominator = Math.pow(yesPool, 2) + Math.pow(noPool, 2) - return numerator / denominator -} - -export function getProbabilityAfterBet( - pool: { YES: number; NO: number }, - outcome: 'YES' | 'NO', - bet: number -) { - const [YES, NO] = [ - pool.YES + (outcome === 'YES' ? bet : 0), - pool.NO + (outcome === 'NO' ? bet : 0), - ] - return getProbability({ YES, NO }) -} - -export function getDpmWeight( - pool: { YES: number; NO: number }, - bet: number, - betChoice: 'YES' | 'NO' -) { - const [yesPool, noPool] = [pool.YES, pool.NO] - - return betChoice === 'YES' - ? (bet * Math.pow(noPool, 2)) / (Math.pow(yesPool, 2) + bet * yesPool) - : (bet * Math.pow(yesPool, 2)) / (Math.pow(noPool, 2) + bet * noPool) -} - -export function calculatePayout( - contract: Contract, - bet: Bet, - outcome: 'YES' | 'NO' | 'CANCEL' -) { - const { amount, outcome: betOutcome, dpmWeight } = bet - - if (outcome === 'CANCEL') return amount - if (betOutcome !== outcome) return 0 - - let { dpmWeights, pool, startPool } = contract - - // Fake data if not set. - if (!dpmWeights) dpmWeights = { YES: 100, NO: 100 } - - // Fake data if not set. - if (!pool) pool = { YES: 100, NO: 100 } - - const otherOutcome = outcome === 'YES' ? 'NO' : 'YES' - const poolSize = pool[otherOutcome] - startPool[otherOutcome] - - return (1 - fees) * (dpmWeight / dpmWeights[outcome]) * poolSize + amount -} -export function resolvedPayout(contract: Contract, bet: Bet) { - if (contract.resolution) - return calculatePayout(contract, bet, contract.resolution) - throw new Error('Contract was not resolved') -} - -export function currentValue(contract: Contract, bet: Bet) { - const prob = getProbability(contract.pool) - const yesPayout = calculatePayout(contract, bet, 'YES') - const noPayout = calculatePayout(contract, bet, 'NO') - - return prob * yesPayout + (1 - prob) * noPayout -} diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts new file mode 100644 index 00000000..fc054386 --- /dev/null +++ b/web/lib/firebase/api-call.ts @@ -0,0 +1,6 @@ +import { getFunctions, httpsCallable } from 'firebase/functions' + +const functions = getFunctions() + +export const cloudFunction = (name: string) => httpsCallable(functions, name) + diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 92b33010..b09d8f8b 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -11,12 +11,21 @@ export type Bet = { id: string userId: string contractId: string - amount: number // Amount of bet - outcome: 'YES' | 'NO' // Chosen outcome - dpmWeight: number // Dynamic Parimutuel weight + + amount: number // bet size; negative if SELL bet + outcome: 'YES' | 'NO' + shares: number // dynamic parimutuel pool weight; negative if SELL bet + probBefore: number - probAverage: number probAfter: number + + sale?: { + amount: number // amount user makes from sale + betId: string // id of bet being sold + } + + isSold?: boolean // true if this BUY bet has been sold + createdTime: number } diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 652bec4b..f97e111c 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -30,7 +30,7 @@ export type Contract = { startPool: { YES: number; NO: number } pool: { YES: number; NO: number } - dpmWeights: { YES: number; NO: number } + totalShares: { YES: number; NO: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed @@ -48,7 +48,7 @@ export function path(contract: Contract) { export function compute(contract: Contract) { const { pool, startPool, createdTime, resolutionTime, isResolved } = contract - const volume = pool.YES + pool.NO - startPool.YES - startPool.NO + const truePool = pool.YES + pool.NO - startPool.YES - startPool.NO const prob = pool.YES ** 2 / (pool.YES ** 2 + pool.NO ** 2) const probPercent = Math.round(prob * 100) + '%' const startProb = @@ -57,7 +57,7 @@ export function compute(contract: Contract) { const resolvedDate = isResolved ? dayjs(resolutionTime).format('MMM D') : undefined - return { volume, probPercent, startProb, createdDate, resolvedDate } + return { truePool, probPercent, startProb, createdDate, resolvedDate } } const db = getFirestore(app) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index c807de98..c9561861 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,24 +1,28 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp } from 'firebase/app' -const firebaseConfig = { - apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', - authDomain: 'mantic-markets.firebaseapp.com', - projectId: 'mantic-markets', - storageBucket: 'mantic-markets.appspot.com', - messagingSenderId: '128925704902', - appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', - measurementId: 'G-SSFK1Q138D', -} + +export const isProd = process.env.NODE_ENV === 'production' + +const firebaseConfig = isProd + ? { + apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', + authDomain: 'mantic-markets.firebaseapp.com', + projectId: 'mantic-markets', + storageBucket: 'mantic-markets.appspot.com', + messagingSenderId: '128925704902', + appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', + measurementId: 'G-SSFK1Q138D', + } + : { + apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', + authDomain: 'dev-mantic-markets.firebaseapp.com', + projectId: 'dev-mantic-markets', + storageBucket: 'dev-mantic-markets.appspot.com', + messagingSenderId: '134303100058', + appId: '1:134303100058:web:27f9ea8b83347251f80323', + measurementId: 'G-YJC9E37P37', + } // Initialize Firebase export const app = initializeApp(firebaseConfig) export const db = getFirestore(app) - -// try { -// // Note: this is still throwing a console error atm... -// import('firebase/analytics').then((analytics) => { -// analytics.getAnalytics(app) -// }) -// } catch (e) { -// console.warn('Analytics were blocked') -// } diff --git a/web/lib/service/create-contract.ts b/web/lib/service/create-contract.ts index b10cddbf..eba7b0ff 100644 --- a/web/lib/service/create-contract.ts +++ b/web/lib/service/create-contract.ts @@ -2,7 +2,6 @@ import { Contract, getContractFromSlug, pushNewContract, - setContract, } from '../firebase/contracts' import { User } from '../firebase/users' import { randomString } from '../util/random-string' @@ -38,7 +37,7 @@ export async function createContract( startPool: { YES: startYes, NO: startNo }, pool: { YES: startYes, NO: startNo }, - dpmWeights: { YES: 0, NO: 0 }, + totalShares: { YES: 0, NO: 0 }, isResolved: false, // TODO: Set create time to Firestore timestamp @@ -49,8 +48,8 @@ export async function createContract( return await pushNewContract(contract) } -export function calcStartPool(initialProb: number, initialCapital = 200) { - const p = initialProb / 100.0 +export function calcStartPool(initialProbInt: number, initialCapital = 200) { + const p = initialProbInt / 100.0 const startYes = p === 0.5 diff --git a/web/lib/simulator/entries.ts b/web/lib/simulator/entries.ts index df80543b..535a59ad 100644 --- a/web/lib/simulator/entries.ts +++ b/web/lib/simulator/entries.ts @@ -21,10 +21,13 @@ function makeWeights(bids: Bid[]) { // First pass: calculate all the weights for (const { yesBid, noBid } of bids) { const yesWeight = - (yesBid * Math.pow(noPot, 2)) / (Math.pow(yesPot, 2) + yesBid * yesPot) || - 0 + yesBid + + (yesBid * Math.pow(noPot, 2)) / + (Math.pow(yesPot, 2) + yesBid * yesPot) || 0 const noWeight = - (noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || 0 + noBid + + (noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) || + 0 // Note: Need to calculate weights BEFORE updating pot yesPot += yesBid @@ -53,15 +56,15 @@ export function makeEntries(bids: Bid[]): Entry[] { const yesWeightsSum = weights.reduce((sum, entry) => sum + entry.yesWeight, 0) const noWeightsSum = weights.reduce((sum, entry) => sum + entry.noWeight, 0) + const potSize = yesPot + noPot - YES_SEED - NO_SEED + // Second pass: calculate all the payouts const entries: Entry[] = [] for (const weight of weights) { const { yesBid, noBid, yesWeight, noWeight } = weight - // Payout: You get your initial bid back, as well as your share of the - // (noPot - seed) according to your yesWeight - const yesPayout = yesBid + (yesWeight / yesWeightsSum) * (noPot - NO_SEED) - const noPayout = noBid + (noWeight / noWeightsSum) * (yesPot - YES_SEED) + const yesPayout = (yesWeight / yesWeightsSum) * potSize + const noPayout = (noWeight / noWeightsSum) * potSize const yesReturn = (yesPayout - yesBid) / yesBid const noReturn = (noPayout - noBid) / noBid entries.push({ ...weight, yesPayout, noPayout, yesReturn, noReturn }) diff --git a/web/lib/util/format.ts b/web/lib/util/format.ts index 5a9e3020..9d0496b2 100644 --- a/web/lib/util/format.ts +++ b/web/lib/util/format.ts @@ -6,11 +6,11 @@ const formatter = new Intl.NumberFormat('en-US', { }) export function formatMoney(amount: number) { - return 'M$ ' + formatter.format(amount).substring(1) + return 'M$ ' + formatter.format(amount).replace('$', '') } export function formatWithCommas(amount: number) { - return formatter.format(amount).substring(1) + return formatter.format(amount).replace('$', '') } export function formatPercent(zeroToOne: number) { diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 3bb7b5ad..60a1a5fb 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -97,7 +97,7 @@ function BetsSection(props: { contract: Contract; user: User | null }) { return ( <div> - <Title text="Your bets" /> + <Title text="Your trades" /> <MyBetsSummary contract={contract} bets={userBets} /> <Spacer h={6} /> <ContractBetsTable contract={contract} bets={userBets} /> diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 61d87516..54aa9453 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -162,8 +162,8 @@ function Contents() { </p> <h3 id="how-are-markets-resolved-">How are markets resolved?</h3> <p> - The creator of the prediction market decides the outcome and earns 0.5% - of the trade volume for their effort. + The creator of the prediction market decides the outcome and earns 1% of + the betting pool for their effort. </p> <p> This simple resolution mechanism has surprising benefits in allowing a diff --git a/web/pages/simulator.tsx b/web/pages/simulator.tsx index 3294f503..3df8a83d 100644 --- a/web/pages/simulator.tsx +++ b/web/pages/simulator.tsx @@ -86,7 +86,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { return ( <> <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${(entry.yesBid + entry.yesWeight).toFixed(0)}</td> + <td>${entry.yesWeight.toFixed(0)}</td> {!props.isNew && ( <> <td>${entry.yesPayout.toFixed(0)}</td> @@ -99,7 +99,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { return ( <> <td>{(entry.prob * 100).toFixed(1)}%</td> - <td>${(entry.noBid + entry.noWeight).toFixed(0)}</td> + <td>${entry.noWeight.toFixed(0)}</td> {!props.isNew && ( <> <td>${entry.noPayout.toFixed(0)}</td> @@ -149,9 +149,9 @@ function NewBidTable(props: { function randomBid() { const bidType = Math.random() < 0.5 ? 'YES' : 'NO' - const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob + // const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob - const amount = Math.round(p * Math.random() * 300) + 1 + const amount = Math.floor(Math.random() * 300) + 1 const bid = makeBid(bidType, amount) bids.splice(steps, 0, bid) @@ -238,7 +238,7 @@ function NewBidTable(props: { // Show a hello world React page export default function Simulator() { const [steps, setSteps] = useState(1) - const [bids, setBids] = useState([{ yesBid: 550, noBid: 450 }]) + const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }]) const entries = useMemo( () => makeEntries(bids.slice(0, steps)), diff --git a/web/pages/bets.tsx b/web/pages/trades.tsx similarity index 68% rename from web/pages/bets.tsx rename to web/pages/trades.tsx index bebd044e..6e66d001 100644 --- a/web/pages/bets.tsx +++ b/web/pages/trades.tsx @@ -4,13 +4,13 @@ import { SEO } from '../components/SEO' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' -export default function BetsPage() { +export default function TradesPage() { const user = useUser() return ( <Page> - <SEO title="Your bets" description="Your bets" url="/bets" /> - <Title text="Your bets" /> + <SEO title="Your trades" description="Your trades" url="/trades" /> + <Title text="Your trades" /> {user && <BetsList user={user} />} </Page> ) From b2945c24b1f5f1fcce31d7c2d42a171ec63c69f5 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 24 Dec 2021 16:14:02 -0500 Subject: [PATCH 09/81] turn keep awake on --- functions/src/index.ts | 2 +- functions/src/keep-awake.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index ee8da84b..dfb09c89 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() -// export * from './keep-awake' +export * from './keep-awake' export * from './place-bet' export * from './resolve-market' export * from './sell-bet' diff --git a/functions/src/keep-awake.ts b/functions/src/keep-awake.ts index 85aa3475..00799e65 100644 --- a/functions/src/keep-awake.ts +++ b/functions/src/keep-awake.ts @@ -8,6 +8,7 @@ export const keepAwake = functions.pubsub await Promise.all([ callCloudFunction('placeBet'), callCloudFunction('resolveMarket'), + callCloudFunction('sellBet'), ]) await sleep(30) @@ -15,6 +16,7 @@ export const keepAwake = functions.pubsub await Promise.all([ callCloudFunction('placeBet'), callCloudFunction('resolveMarket'), + callCloudFunction('sellBet'), ]) }) From a902dfebcaf972f68bcf6bc2ae4216f94674701b Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 24 Dec 2021 16:29:15 -0500 Subject: [PATCH 10/81] Fit long names on mobile --- web/components/bets-list.tsx | 4 +--- web/components/profile-menu.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 52de070b..6b05c689 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -200,9 +200,7 @@ export function MyBetsSummary(props: { return ( <Row className={clsx('gap-4 sm:gap-6', className)}> <Col> - <div className="text-sm text-gray-500 whitespace-nowrap"> - Amount invested - </div> + <div className="text-sm text-gray-500 whitespace-nowrap">Invested</div> <div className="whitespace-nowrap">{formatMoney(betsTotal)}</div> </Col> {resolution ? ( diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 43022a9e..8f50fbfb 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -64,7 +64,7 @@ function ProfileSummary(props: { user: User }) { <div className="rounded-full w-10 h-10 mr-4"> <Image src={user.avatarUrl} width={40} height={40} /> </div> - <div className="truncate text-left" style={{ maxWidth: 175 }}> + <div className="truncate text-left" style={{ maxWidth: 140 }}> {user.name} <div className="text-gray-700 text-sm">{formatMoney(user.balance)}</div> </div> From b06d41e31a39fef3b33775021fd23815a0d7d757 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 29 Dec 2021 00:49:10 -0500 Subject: [PATCH 11/81] resolve market cancel uses only pool to pay out --- functions/src/resolve-market.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 280af022..ec6cd3bf 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -54,10 +54,7 @@ export const resolveMarket = functions const payouts = outcome === 'CANCEL' - ? bets.map((bet) => ({ - userId: bet.userId, - payout: bet.amount, - })) + ? getCancelPayouts(contract, bets) : getPayouts(outcome, contract, bets) console.log('payouts:', payouts) @@ -77,6 +74,20 @@ export const resolveMarket = functions const firestore = admin.firestore() +const getCancelPayouts = (contract: Contract, bets: Bet[]) => { + const startPool = contract.startPool.YES + contract.startPool.NO + const truePool = contract.pool.YES + contract.pool.NO - startPool + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + + const betSum = _.sumBy(openBets, (b) => b.amount) + + return openBets.map((bet) => ({ + userId: bet.userId, + payout: (bet.amount / betSum) * truePool, + })) +} + const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => { const openBets = bets.filter((b) => !b.isSold && !b.sale) const [yesBets, noBets] = _.partition( From a9e1cbc0beca912bc15d0e612e58bb2c6938a286 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 29 Dec 2021 23:31:26 -0800 Subject: [PATCH 12/81] Point local server to prod Firebase for now (#13) --- web/lib/firebase/init.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index c9561861..98734946 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -1,7 +1,9 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp } from 'firebase/app' -export const isProd = process.env.NODE_ENV === 'production' +// TODO: Reenable this when we have a way to set the Firebase db in dev +// export const isProd = process.env.NODE_ENV === 'production' +export const isProd = true const firebaseConfig = isProd ? { From a5e4411075563e897545759b05020d054c2beffc Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Thu, 30 Dec 2021 13:35:29 -0500 Subject: [PATCH 13/81] Load all contracts, and filter to 99 client-side. --- web/components/contracts-list.tsx | 5 +++++ web/lib/firebase/contracts.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index 74c94ad5..b80ade7c 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -110,6 +110,8 @@ function ContractsGrid(props: { contracts: Contract[] }) { ) } +const MAX_CONTRACTS_DISPLAYED = 99 + type Sort = 'createdTime' | 'pool' | 'resolved' | 'all' export function SearchableGrid(props: { contracts: Contract[] @@ -143,6 +145,9 @@ export function SearchableGrid(props: { ) } + if (matches.length > MAX_CONTRACTS_DISPLAYED) + matches = _.slice(matches, 0, MAX_CONTRACTS_DISPLAYED) + return ( <div> {/* Show a search input next to a sort dropdown */} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index f97e111c..b8ea93e3 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -107,7 +107,7 @@ export async function listContracts(creatorId: string): Promise<Contract[]> { } export async function listAllContracts(): Promise<Contract[]> { - const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(99)) + const q = query(contractCollection, orderBy('createdTime', 'desc')) const snapshot = await getDocs(q) return snapshot.docs.map((doc) => doc.data() as Contract) } @@ -115,7 +115,7 @@ export async function listAllContracts(): Promise<Contract[]> { export function listenForContracts( setContracts: (contracts: Contract[]) => void ) { - const q = query(contractCollection, orderBy('createdTime', 'desc'), limit(99)) + const q = query(contractCollection, orderBy('createdTime', 'desc')) return onSnapshot(q, (snap) => { setContracts(snap.docs.map((doc) => doc.data() as Contract)) }) From 527a8a8b099faaa70617cccd1318b5424e134a7b Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Thu, 30 Dec 2021 13:52:05 -0500 Subject: [PATCH 14/81] White panels, medium shadows --- web/components/bet-panel.tsx | 4 +--- web/components/contracts-list.tsx | 2 +- web/components/resolution-panel.tsx | 4 +--- web/pages/create.tsx | 4 ++-- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 42f3abae..55467527 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -95,9 +95,7 @@ export function BetPanel(props: { contract: Contract; className?: string }) { const remainingBalance = (user?.balance || 0) - (betAmount || 0) return ( - <Col - className={clsx('bg-gray-100 shadow-xl px-8 py-6 rounded-md', className)} - > + <Col className={clsx('bg-white shadow-md px-8 py-6 rounded-md', className)}> <Title className="!mt-0 whitespace-nowrap" text="Place a trade" /> <div className="mt-2 mb-1 text-sm text-gray-400">Outcome</div> diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index b80ade7c..2902cccd 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -55,7 +55,7 @@ function ContractCard(props: { contract: Contract }) { return ( <Link href={path(contract)}> <a> - <li className="col-span-1 bg-white hover:bg-gray-100 shadow-xl rounded-lg divide-y divide-gray-200"> + <li className="col-span-1 bg-white hover:bg-gray-100 shadow-md rounded-lg divide-y divide-gray-200"> <div className="card"> <div className="card-body p-6"> <Row className="justify-between gap-4 mb-2"> diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 4e98a713..626480a7 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -51,9 +51,7 @@ export function ResolutionPanel(props: { : 'btn-disabled' return ( - <Col - className={clsx('bg-gray-100 shadow-xl px-8 py-6 rounded-md', className)} - > + <Col className={clsx('bg-white shadow-md px-8 py-6 rounded-md', className)}> <Title className="mt-0" text="Your market" /> <div className="pt-2 pb-1 text-sm text-gray-400">Resolve outcome</div> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index d7ad6cae..0a66d14d 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -45,7 +45,7 @@ export default function NewContract() { <Page> <Title text="Create a new prediction market" /> - <div className="w-full bg-gray-100 rounded-lg shadow-xl px-6 py-4"> + <div className="w-full bg-white rounded-lg shadow-md px-6 py-4"> {/* Create a Tailwind form that takes in all the fields needed for a new contract */} {/* When the form is submitted, create a new contract in the database */} <form> @@ -57,7 +57,7 @@ export default function NewContract() { <input type="text" placeholder="e.g. Will the FDA approve Paxlovid before Jun 2nd, 2022?" - className="input" + className="input input-bordered" value={question} onChange={(e) => setQuestion(e.target.value || '')} /> From f78920c9122f0fb8e568e0a90d129e7b7bfa6fc6 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Thu, 30 Dec 2021 14:03:32 -0600 Subject: [PATCH 15/81] Organize markets by creator!! (#14) --- web/components/contracts-list.tsx | 73 +++++++++++++++++++++++++++---- web/components/link.tsx | 24 ++++++++++ web/components/user-page.tsx | 21 +++------ web/pages/create.tsx | 4 +- 4 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 web/components/link.tsx diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index 2902cccd..f098173f 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -14,6 +14,8 @@ import { formatMoney } from '../lib/util/format' import { User } from '../lib/firebase/users' import { UserLink } from './user-page' import { Linkify } from './linkify' +import { Col } from './layout/col' +import { SiteLink } from './link' export function ContractDetails(props: { contract: Contract }) { const { contract } = props @@ -110,16 +112,63 @@ function ContractsGrid(props: { contracts: Contract[] }) { ) } +export function CreatorContractsGrid(props: { contracts: Contract[] }) { + const { contracts } = props + + const byCreator = _.groupBy(contracts, (contract) => contract.creatorId) + const creatorIds = _.sortBy(Object.keys(byCreator), (creatorId) => + _.sumBy(byCreator[creatorId], (contract) => -1 * compute(contract).truePool) + ) + + return ( + <Col className="gap-6"> + {creatorIds.map((creatorId) => { + const { creatorUsername, creatorName } = byCreator[creatorId][0] + + return ( + <Col className="gap-6"> + <SiteLink href={`/${creatorUsername}`}>{creatorName}</SiteLink> + + <ul role="list" className="grid grid-cols-1 gap-6 lg:grid-cols-2"> + {byCreator[creatorId].slice(0, 6).map((contract) => ( + <ContractCard contract={contract} key={contract.id} /> + ))} + </ul> + + {byCreator[creatorId].length > 6 ? ( + <Link href={`/${creatorUsername}`}> + <a + className={clsx( + 'self-end hover:underline hover:decoration-indigo-400 hover:decoration-2' + )} + onClick={(e) => e.stopPropagation()} + > + See all + </a> + </Link> + ) : ( + <div /> + )} + </Col> + ) + })} + </Col> + ) +} + const MAX_CONTRACTS_DISPLAYED = 99 -type Sort = 'createdTime' | 'pool' | 'resolved' | 'all' +type Sort = 'creator' | 'createdTime' | 'pool' | 'resolved' | 'all' export function SearchableGrid(props: { contracts: Contract[] defaultSort?: Sort + byOneCreator?: boolean }) { - const { contracts, defaultSort } = props + const { contracts, defaultSort, byOneCreator } = props const [query, setQuery] = useState('') - const [sort, setSort] = useState(defaultSort || 'pool') + const [sort, setSort] = useState( + defaultSort || (byOneCreator ? 'pool' : 'creator') + ) function check(corpus: String) { return corpus.toLowerCase().includes(query.toLowerCase()) @@ -134,7 +183,7 @@ export function SearchableGrid(props: { if (sort === 'createdTime' || sort === 'resolved' || sort === 'all') { matches.sort((a, b) => b.createdTime - a.createdTime) - } else if (sort === 'pool') { + } else if (sort === 'pool' || sort === 'creator') { matches.sort((a, b) => compute(b).truePool - compute(a).truePool) } @@ -164,19 +213,27 @@ export function SearchableGrid(props: { value={sort} onChange={(e) => setSort(e.target.value as Sort)} > + {byOneCreator ? ( + <option value="all">All markets</option> + ) : ( + <option value="creator">By creator</option> + )} <option value="pool">Most traded</option> <option value="createdTime">Newest first</option> <option value="resolved">Resolved</option> - <option value="all">All markets</option> </select> </div> - <ContractsGrid contracts={matches} /> + {!byOneCreator && (sort === 'creator' || sort === 'resolved') ? ( + <CreatorContractsGrid contracts={matches} /> + ) : ( + <ContractsGrid contracts={matches} /> + )} </div> ) } -export function ContractsList(props: { creator: User }) { +export function CreatorContractsList(props: { creator: User }) { const { creator } = props const [contracts, setContracts] = useState<Contract[] | 'loading'>('loading') @@ -189,5 +246,5 @@ export function ContractsList(props: { creator: User }) { if (contracts === 'loading') return <></> - return <SearchableGrid contracts={contracts} defaultSort="all" /> + return <SearchableGrid contracts={contracts} byOneCreator defaultSort="all" /> } diff --git a/web/components/link.tsx b/web/components/link.tsx new file mode 100644 index 00000000..c462d611 --- /dev/null +++ b/web/components/link.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx' +import Link from 'next/link' + +export const SiteLink = (props: { + href: string + children: any + className?: string +}) => { + const { href, children, className } = props + + return ( + <Link href={href}> + <a + className={clsx( + 'hover:underline hover:decoration-indigo-400 hover:decoration-2', + className + )} + onClick={(e) => e.stopPropagation()} + > + {children} + </a> + </Link> + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index b3a6d058..bc531737 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,28 +1,19 @@ import { firebaseLogout, User } from '../lib/firebase/users' -import { ContractsList } from './contracts-list' +import { CreatorContractsList } from './contracts-list' import { Title } from './title' import { Row } from './layout/row' import { formatMoney } from '../lib/util/format' -import Link from 'next/link' -import clsx from 'clsx' import { SEO } from './SEO' import { Page } from './page' +import { SiteLink } from './link' export function UserLink(props: { username: string; className?: string }) { const { username, className } = props return ( - <Link href={`/${username}`}> - <a - className={clsx( - 'hover:underline hover:decoration-indigo-400 hover:decoration-2', - className - )} - onClick={(e) => e.stopPropagation()} - > - @{username} - </a> - </Link> + <SiteLink href={`/${username}`} className={className}> + @{username} + </SiteLink> ) } @@ -81,7 +72,7 @@ export function UserPage(props: { user: User; currentUser?: User }) { <Title text={possesive + 'markets'} /> - <ContractsList creator={user} /> + <CreatorContractsList creator={user} /> </Page> ) } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 0a66d14d..5806f9ec 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -1,7 +1,7 @@ import router from 'next/router' import { useEffect, useState } from 'react' -import { ContractsList } from '../components/contracts-list' +import { CreatorContractsList } from '../components/contracts-list' import { Spacer } from '../components/layout/spacer' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' @@ -119,7 +119,7 @@ export default function NewContract() { <Title text="Your markets" /> - {creator && <ContractsList creator={creator} />} + {creator && <CreatorContractsList creator={creator} />} </Page> ) } From 6a851fe86a30cf51640dea6fc2c8a882593d00d5 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Thu, 30 Dec 2021 15:55:50 -0600 Subject: [PATCH 16/81] Update resolve descriptions --- web/components/resolution-panel.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 626480a7..7f9f74c6 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -68,18 +68,14 @@ export function ResolutionPanel(props: { <div> {outcome === 'YES' ? ( <> - Winnings will be paid out to YES bettors. You earn 1% of the NO - bets. + Winnings will be paid out to YES bettors. You earn 1% of the pool. </> ) : outcome === 'NO' ? ( - <> - Winnings will be paid out to NO bettors. You earn 1% of the YES - bets. - </> + <>Winnings will be paid out to NO bettors. You earn 1% of the pool.</> ) : outcome === 'CANCEL' ? ( - <>All bets will be returned with no fees.</> + <>The pool will be returned to traders with no fees.</> ) : ( - <>Resolving this market will immediately pay out bettors.</> + <>Resolving this market will immediately pay out traders.</> )} </div> From 41a011a9bb37af55dad86bbf1a53771208e2e15f Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Thu, 30 Dec 2021 16:31:04 -0600 Subject: [PATCH 17/81] Increase size of creator name. Decrease spacing --- web/components/contracts-list.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index f098173f..aed8e3f4 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -112,6 +112,8 @@ function ContractsGrid(props: { contracts: Contract[] }) { ) } +const MAX_GROUPED_CONTRACTS_DISPLAYED = 6 + export function CreatorContractsGrid(props: { contracts: Contract[] }) { const { contracts } = props @@ -126,16 +128,20 @@ export function CreatorContractsGrid(props: { contracts: Contract[] }) { const { creatorUsername, creatorName } = byCreator[creatorId][0] return ( - <Col className="gap-6"> - <SiteLink href={`/${creatorUsername}`}>{creatorName}</SiteLink> + <Col className="gap-4"> + <SiteLink className="text-lg" href={`/${creatorUsername}`}> + {creatorName} + </SiteLink> - <ul role="list" className="grid grid-cols-1 gap-6 lg:grid-cols-2"> - {byCreator[creatorId].slice(0, 6).map((contract) => ( - <ContractCard contract={contract} key={contract.id} /> - ))} + <ul role="list" className="grid grid-cols-1 gap-4 lg:grid-cols-2"> + {byCreator[creatorId] + .slice(0, MAX_GROUPED_CONTRACTS_DISPLAYED) + .map((contract) => ( + <ContractCard contract={contract} key={contract.id} /> + ))} </ul> - {byCreator[creatorId].length > 6 ? ( + {byCreator[creatorId].length > MAX_GROUPED_CONTRACTS_DISPLAYED ? ( <Link href={`/${creatorUsername}`}> <a className={clsx( From 6eda71286c38c5e05746aea862e89909442d09be Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Fri, 31 Dec 2021 13:31:41 -0600 Subject: [PATCH 18/81] nav bar: dark background, no shadow --- web/components/nav-bar.tsx | 9 +-------- web/components/page.tsx | 1 + 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/web/components/nav-bar.tsx b/web/components/nav-bar.tsx index 0798b29a..01093e46 100644 --- a/web/components/nav-bar.tsx +++ b/web/components/nav-bar.tsx @@ -22,14 +22,7 @@ export function NavBar(props: { const themeClasses = clsx(darkBackground && 'text-white', hoverClasses) return ( - <nav - className={clsx( - 'w-full p-4 mb-4 shadow-sm', - !darkBackground && 'bg-white', - className - )} - aria-label="Global" - > + <nav className={clsx('w-full p-4 mb-4', className)} aria-label="Global"> <Row className={clsx( 'justify-between items-center mx-auto sm:px-4', diff --git a/web/components/page.tsx b/web/components/page.tsx index 1bc62289..66e9a272 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -7,6 +7,7 @@ export function Page(props: { wide?: boolean; children?: any }) { return ( <div> <NavBar wide={wide} /> + <div className={clsx( 'w-full px-4 pb-8 mx-auto', From f1977f26ead5dc0e7f0290292e9cf7b5ce2c9a12 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 31 Dec 2021 13:17:32 -0600 Subject: [PATCH 19/81] Don't wrap external link with next/link --- web/components/contracts-list.tsx | 2 +- web/components/linkify.tsx | 12 +++++------- web/components/{link.tsx => site-link.tsx} | 13 ++++++++++++- web/components/user-page.tsx | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) rename web/components/{link.tsx => site-link.tsx} (63%) diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index aed8e3f4..b40c926a 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -15,7 +15,7 @@ import { User } from '../lib/firebase/users' import { UserLink } from './user-page' import { Linkify } from './linkify' import { Col } from './layout/col' -import { SiteLink } from './link' +import { SiteLink } from './site-link' export function ContractDetails(props: { contract: Contract }) { const { contract } = props diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index fd1be879..df6bb98d 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -1,5 +1,5 @@ -import Link from 'next/link' import { Fragment } from 'react' +import { SiteLink } from './site-link' // Return a JSX span, linkifying @username, #hashtags, and https://... export function Linkify(props: { text: string }) { @@ -20,12 +20,10 @@ export function Linkify(props: { text: string }) { return ( <> {whitespace} - <Link href={href}> - <a className="text-indigo-700 hover:underline hover:decoration-2"> - {symbol} - {tag} - </a> - </Link> + <SiteLink className="text-indigo-700" href={href}> + {symbol} + {tag} + </SiteLink> </> ) }) diff --git a/web/components/link.tsx b/web/components/site-link.tsx similarity index 63% rename from web/components/link.tsx rename to web/components/site-link.tsx index c462d611..a3a5a01b 100644 --- a/web/components/link.tsx +++ b/web/components/site-link.tsx @@ -8,7 +8,18 @@ export const SiteLink = (props: { }) => { const { href, children, className } = props - return ( + return href.startsWith('http') ? ( + <a + href={href} + className={clsx( + 'hover:underline hover:decoration-indigo-400 hover:decoration-2', + className + )} + onClick={(e) => e.stopPropagation()} + > + {children} + </a> + ) : ( <Link href={href}> <a className={clsx( diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index bc531737..6a93ba88 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -5,7 +5,7 @@ import { Row } from './layout/row' import { formatMoney } from '../lib/util/format' import { SEO } from './SEO' import { Page } from './page' -import { SiteLink } from './link' +import { SiteLink } from './site-link' export function UserLink(props: { username: string; className?: string }) { const { username, className } = props From 96d5ea043766a0045bbb06f64cbb8f4c8362131b Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 31 Dec 2021 13:41:37 -0600 Subject: [PATCH 20/81] Add sort by tag --- web/components/contracts-list.tsx | 65 +++++++++++++++++++++++++++++-- web/lib/util/parse.ts | 8 ++++ 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 web/lib/util/parse.ts diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index b40c926a..07c052b0 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -16,6 +16,7 @@ import { UserLink } from './user-page' import { Linkify } from './linkify' import { Col } from './layout/col' import { SiteLink } from './site-link' +import { parseTags } from '../lib/util/parse' export function ContractDetails(props: { contract: Contract }) { const { contract } = props @@ -162,9 +163,64 @@ export function CreatorContractsGrid(props: { contracts: Contract[] }) { ) } +export function TagContractsGrid(props: { contracts: Contract[] }) { + const { contracts } = props + + const contractTags = _.flatMap(contracts, (contract) => + parseTags(contract.question + ' ' + contract.description).map((tag) => ({ + tag, + contract, + })) + ) + const groupedByTag = _.groupBy(contractTags, ({ tag }) => tag) + const byTag = _.mapValues(groupedByTag, (contractTags) => + contractTags.map(({ contract }) => contract) + ) + const tags = _.sortBy(Object.keys(byTag), (tag) => + _.sumBy(byTag[tag], (contract) => -1 * compute(contract).truePool) + ) + + return ( + <Col className="gap-6"> + {tags.map((tag) => { + return ( + <Col className="gap-4"> + <SiteLink className="text-lg" href={`/tag/${tag}`}> + #{tag} + </SiteLink> + + <ul role="list" className="grid grid-cols-1 gap-4 lg:grid-cols-2"> + {byTag[tag] + .slice(0, MAX_GROUPED_CONTRACTS_DISPLAYED) + .map((contract) => ( + <ContractCard contract={contract} key={contract.id} /> + ))} + </ul> + + {byTag[tag].length > MAX_GROUPED_CONTRACTS_DISPLAYED ? ( + <Link href={`/tag/${tag}`}> + <a + className={clsx( + 'self-end hover:underline hover:decoration-indigo-400 hover:decoration-2' + )} + onClick={(e) => e.stopPropagation()} + > + See all + </a> + </Link> + ) : ( + <div /> + )} + </Col> + ) + })} + </Col> + ) +} + const MAX_CONTRACTS_DISPLAYED = 99 -type Sort = 'creator' | 'createdTime' | 'pool' | 'resolved' | 'all' +type Sort = 'creator' | 'tag' | 'createdTime' | 'pool' | 'resolved' | 'all' export function SearchableGrid(props: { contracts: Contract[] defaultSort?: Sort @@ -189,7 +245,7 @@ export function SearchableGrid(props: { if (sort === 'createdTime' || sort === 'resolved' || sort === 'all') { matches.sort((a, b) => b.createdTime - a.createdTime) - } else if (sort === 'pool' || sort === 'creator') { + } else if (sort === 'pool' || sort === 'creator' || sort === 'tag') { matches.sort((a, b) => compute(b).truePool - compute(a).truePool) } @@ -224,13 +280,16 @@ export function SearchableGrid(props: { ) : ( <option value="creator">By creator</option> )} + <option value="tag">By tag</option> <option value="pool">Most traded</option> <option value="createdTime">Newest first</option> <option value="resolved">Resolved</option> </select> </div> - {!byOneCreator && (sort === 'creator' || sort === 'resolved') ? ( + {sort === 'tag' ? ( + <TagContractsGrid contracts={matches} /> + ) : !byOneCreator && (sort === 'creator' || sort === 'resolved') ? ( <CreatorContractsGrid contracts={matches} /> ) : ( <ContractsGrid contracts={matches} /> diff --git a/web/lib/util/parse.ts b/web/lib/util/parse.ts new file mode 100644 index 00000000..2857b7a1 --- /dev/null +++ b/web/lib/util/parse.ts @@ -0,0 +1,8 @@ +export function parseTags(text: string) { + const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi + const matches = text.match(regex) || [] + return matches.map((match) => { + const tag = match.trim().substring(1) + return tag + }) +} From 0b9b2396f17055984b3fe6c0b57db1f4fcd85fb9 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 31 Dec 2021 14:13:06 -0600 Subject: [PATCH 21/81] Est. Max Payout => Shares in bet table --- web/components/bets-list.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 6b05c689..18c74563 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -5,7 +5,11 @@ import { useEffect, useState } from 'react' import { useUserBets } from '../hooks/use-user-bets' import { Bet } from '../lib/firebase/bets' import { User } from '../lib/firebase/users' -import { formatMoney, formatPercent } from '../lib/util/format' +import { + formatMoney, + formatPercent, + formatWithCommas, +} from '../lib/util/format' import { Col } from './layout/col' import { Spacer } from './layout/spacer' import { Contract, getContractFromId, path } from '../lib/firebase/contracts' @@ -253,7 +257,7 @@ export function ContractBetsTable(props: { <th>Outcome</th> <th>Amount</th> <th>Probability</th> - {!isResolved && <th>Est. max payout</th>} + <th>Shares</th> <th>{isResolved ? <>Payout</> : <>Current value</>}</th> <th></th> </tr> @@ -296,7 +300,7 @@ function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { <td> {formatPercent(probBefore)} → {formatPercent(probAfter)} </td> - {!isResolved && <td>{formatMoney(shares)}</td>} + <td>{formatWithCommas(shares)}</td> <td> {bet.isSold ? 'N/A' From 7d5e02a69c551fa5d85430c48b6fc64f14a8998a Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 31 Dec 2021 14:25:01 -0600 Subject: [PATCH 22/81] username => name on user page --- web/components/user-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 6a93ba88..3bf207b2 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -58,7 +58,7 @@ export function UserPage(props: { user: User; currentUser?: User }) { const isCurrentUser = user.id === currentUser?.id - const possesive = isCurrentUser ? 'Your ' : `${user.username}'s ` + const possesive = isCurrentUser ? 'Your ' : `${user.name}'s ` return ( <Page> From 50e3dc5cd05ab9a6132e0d87c2a53393dcac2998 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 31 Dec 2021 14:33:05 -0600 Subject: [PATCH 23/81] Add Discord link as menu option! --- web/components/menu.tsx | 1 + web/components/profile-menu.tsx | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/web/components/menu.tsx b/web/components/menu.tsx index 5c3794ef..5b8ffd86 100644 --- a/web/components/menu.tsx +++ b/web/components/menu.tsx @@ -34,6 +34,7 @@ export function MenuButton(props: { {({ active }) => ( <a href={item.href} + target={item.href.startsWith('http') ? '_blank' : undefined} onClick={item.onClick} className={clsx( active ? 'bg-gray-100' : '', diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index 8f50fbfb..b5e88d43 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -48,7 +48,10 @@ function getNavigationOptions(user: User, options: { mobile: boolean }) { name: 'Your markets', href: `/${user.username}`, }, - + { + name: 'Discord', + href: 'https://discord.gg/eHQBNBqXuh', + }, { name: 'Sign out', href: '#', From 4db7e03b921eb3a1a05e94184cc66918e963b42d Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Fri, 31 Dec 2021 17:22:15 -0600 Subject: [PATCH 24/81] Switch to old current value calculation --- web/lib/calculate.ts | 34 +++++----------------------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/web/lib/calculate.ts b/web/lib/calculate.ts index 13649c06..b195a65a 100644 --- a/web/lib/calculate.ts +++ b/web/lib/calculate.ts @@ -61,37 +61,13 @@ export function resolvedPayout(contract: Contract, bet: Bet) { } export function currentValue(contract: Contract, bet: Bet) { - // const prob = getProbability(contract.pool) - // const yesPayout = calculatePayout(contract, bet, 'YES') - // const noPayout = calculatePayout(contract, bet, 'NO') + const prob = getProbability(contract.pool) + const yesPayout = calculatePayout(contract, bet, 'YES') + const noPayout = calculatePayout(contract, bet, 'NO') - // return prob * yesPayout + (1 - prob) * noPayout - - const { shares, outcome } = bet - - const { YES: yesPool, NO: noPool } = contract.pool - const [y, n, s] = [yesPool, noPool, shares] - - const shareValue = - outcome === 'YES' - ? // https://www.wolframalpha.com/input/?i=b+%2B+%28b+n%5E2%29%2F%28y+%28-b+%2B+y%29%29+%3D+c+solve+b - (n ** 2 + - s * y + - y ** 2 - - Math.sqrt( - n ** 4 + (s - y) ** 2 * y ** 2 + 2 * n ** 2 * y * (s + y) - )) / - (2 * y) - : (y ** 2 + - s * n + - n ** 2 - - Math.sqrt( - y ** 4 + (s - n) ** 2 * n ** 2 + 2 * y ** 2 * n * (s + n) - )) / - (2 * n) - - return (1 - fees) * shareValue + return prob * yesPayout + (1 - prob) * noPayout } + export function calculateSaleAmount(contract: Contract, bet: Bet) { const { shares, outcome } = bet From afc6f28a49249fdc46d40d8906a4abb9ea36cf97 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sat, 1 Jan 2022 18:08:52 -0600 Subject: [PATCH 25/81] Send email on market resolution with your payout --- functions/package.json | 4 +- functions/src/emails.ts | 35 +++ functions/src/resolve-market.ts | 26 +- functions/src/send-email.ts | 18 ++ functions/yarn.lock | 459 +++++++++++++++++++++++++++++++- 5 files changed, 531 insertions(+), 11 deletions(-) create mode 100644 functions/src/emails.ts create mode 100644 functions/src/send-email.ts diff --git a/functions/package.json b/functions/package.json index 62dd0c58..79de1b65 100644 --- a/functions/package.json +++ b/functions/package.json @@ -17,9 +17,11 @@ "fetch": "1.1.0", "firebase-admin": "10.0.0", "firebase-functions": "3.16.0", - "lodash": "4.17.21" + "lodash": "4.17.21", + "mailgun-js": "0.22.0" }, "devDependencies": { + "@types/mailgun-js": "0.22.12", "firebase-functions-test": "0.3.3", "typescript": "4.5.3" }, diff --git a/functions/src/emails.ts b/functions/src/emails.ts new file mode 100644 index 00000000..14c030f3 --- /dev/null +++ b/functions/src/emails.ts @@ -0,0 +1,35 @@ +import { sendEmail } from './send-email' +import { Contract } from './types/contract' +import { User } from './types/user' +import { getUser } from './utils' + +export const sendMarketResolutionEmail = async ( + userId: string, + payout: number, + creator: User, + contract: Contract, + resolution: 'YES' | 'NO' | 'CANCEL' +) => { + const user = await getUser(userId) + if (!user) return + + const subject = `Resolved ${toDisplayResolution[resolution]}: ${contract.question}` + + const body = `Dear ${user.name}, + +A market you bet in has been resolved! + +Creator: ${contract.creatorName} +Question: ${contract.question} + +Resolution: ${toDisplayResolution[resolution]} + +Your payout is M$ ${Math.round(payout)} + +View the market here: +https://mantic.markets/${creator.username}/${contract.slug} +` + await sendEmail(user.email, subject, body) +} + +const toDisplayResolution = { YES: 'YES', NO: 'NO', CANCEL: 'N/A' } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index ec6cd3bf..0ab9f4cb 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -5,6 +5,8 @@ import * as _ from 'lodash' import { Contract } from './types/contract' import { User } from './types/user' import { Bet } from './types/bet' +import { getUser } from './utils' +import { sendMarketResolutionEmail } from './emails' export const PLATFORM_FEE = 0.01 // 1% export const CREATOR_FEE = 0.01 // 1% @@ -14,7 +16,7 @@ export const resolveMarket = functions .https.onCall( async ( data: { - outcome: string + outcome: 'YES' | 'NO' | 'CANCEL' contractId: string }, context @@ -39,6 +41,9 @@ export const resolveMarket = functions if (contract.resolution) return { status: 'error', message: 'Contract already resolved' } + const creator = await getUser(contract.creatorId) + if (!creator) return { status: 'error', message: 'Creator not found' } + await contractDoc.update({ isResolved: true, resolution: outcome, @@ -66,9 +71,26 @@ export const resolveMarket = functions const payoutPromises = Object.entries(userPayouts).map(payUser) - return await Promise.all(payoutPromises) + const result = await Promise.all(payoutPromises) .catch((e) => ({ status: 'error', message: e })) .then(() => ({ status: 'success' })) + + const activeBets = bets.filter((bet) => !bet.isSold && !bet.sale) + const nonWinners = _.difference( + _.uniq(activeBets.map(({ userId }) => userId)), + Object.keys(userPayouts) + ) + const emailPayouts = [ + ...Object.entries(userPayouts), + ...nonWinners.map((userId) => [userId, 0] as const), + ] + await Promise.all( + emailPayouts.map(([userId, payout]) => + sendMarketResolutionEmail(userId, payout, creator, contract, outcome) + ) + ) + + return result } ) diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts new file mode 100644 index 00000000..98948fcf --- /dev/null +++ b/functions/src/send-email.ts @@ -0,0 +1,18 @@ +import * as mailgun from 'mailgun-js' +import * as functions from 'firebase-functions' + +const DOMAIN = 'mg.mantic.markets' +const mg = mailgun({ apiKey: functions.config().mailgun.key, domain: DOMAIN }) + +export const sendEmail = (to: string, subject: string, text: string) => { + const data = { + from: 'Mantic Markets <no-reply@mantic.markets>', + to, + subject, + text, + } + + return mg.messages().send(data, (error, body) => { + console.log('Sent email', error, body) + }) +} diff --git a/functions/yarn.lock b/functions/yarn.lock index 87ccb0ad..6cec7f41 100644 --- a/functions/yarn.lock +++ b/functions/yarn.lock @@ -301,6 +301,14 @@ resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== +"@types/mailgun-js@0.22.12": + version "0.22.12" + resolved "https://registry.yarnpkg.com/@types/mailgun-js/-/mailgun-js-0.22.12.tgz#b0dcb590b56ef3e599ab1f262882493d318e5510" + integrity sha512-fTjuh2mOPoJF2BN0QAhE5iPOBls333KIJrmrrJHObMDuBCgEfaENLX2LH1FjKqhfuWfGotKOfPFZMuSok5Ow7g== + dependencies: + "@types/node" "*" + form-data "^2.5.0" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -344,6 +352,13 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" +agent-base@4, agent-base@^4.2.0, agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -351,6 +366,13 @@ agent-base@6: dependencies: debug "4" +agent-base@~4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" + integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== + dependencies: + es6-promisify "^5.0.0" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -373,6 +395,13 @@ arrify@^2.0.0, arrify@^2.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +ast-types@0.x.x: + version "0.14.2" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.14.2.tgz#600b882df8583e3cd4f2df5fa20fa83759d4bdfd" + integrity sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA== + dependencies: + tslib "^2.0.1" + async-retry@^1.3.1, async-retry@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/async-retry/-/async-retry-1.3.3.tgz#0e7f36c04d8478e7a58bdbed80cedf977785f280" @@ -380,6 +409,18 @@ async-retry@^1.3.1, async-retry@^1.3.3: dependencies: retry "0.13.1" +async@^2.6.1: + version "2.6.3" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" + integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg== + dependencies: + lodash "^4.17.14" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + base64-js@^1.3.0: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -432,6 +473,11 @@ cliui@^7.0.2: strip-ansi "^6.0.0" wrap-ansi "^7.0.0" +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -444,6 +490,13 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +combined-stream@^1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + compressible@^2.0.12: version "2.0.18" resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" @@ -485,6 +538,11 @@ cookie@0.4.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@^2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" @@ -498,25 +556,63 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +data-uri-to-buffer@1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz#77163ea9c20d8641b4707e8f18abdf9a78f34835" + integrity sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ== + date-and-time@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/date-and-time/-/date-and-time-2.0.1.tgz#bc8b72704980e8a0979bb186118d30d02059ef04" integrity sha512-O7Xe5dLaqvY/aF/MFWArsAM1J4j7w1CSZlPCX9uHgmb+6SbkPd8Q4YOvfvH/cZGvFlJFfHOZKxQtmMUOoZhc/w== -debug@2.6.9: +debug@2, debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" -debug@4, debug@^4.1.1, debug@^4.3.2: +debug@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2: version "4.3.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664" integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q== dependencies: ms "2.1.2" +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +deep-is@~0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +degenerator@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095" + integrity sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU= + dependencies: + ast-types "0.x.x" + escodegen "1.x.x" + esprima "3.x.x" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -592,6 +688,18 @@ ent@^2.2.0: resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0= +es6-promise@^4.0.3: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= + dependencies: + es6-promise "^4.0.3" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -602,6 +710,38 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= +escodegen@1.x.x: + version "1.14.3" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" + integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== + dependencies: + esprima "^4.0.1" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +esprima@3.x.x: + version "3.1.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" + integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= + +esprima@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +estraverse@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" @@ -648,7 +788,7 @@ express@^4.17.1: utils-merge "1.0.1" vary "~1.1.2" -extend@^3.0.2: +extend@^3.0.2, extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -658,6 +798,11 @@ fast-deep-equal@^3.1.1: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-levenshtein@~2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" @@ -678,6 +823,11 @@ fetch@1.1.0: biskviit "1.0.1" encoding "0.1.12" +file-uri-to-path@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" @@ -726,6 +876,15 @@ firebase-functions@3.16.0: express "^4.17.1" lodash "^4.17.14" +form-data@^2.3.3, form-data@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -736,6 +895,14 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +ftp@~0.3.10: + version "0.3.10" + resolved "https://registry.yarnpkg.com/ftp/-/ftp-0.3.10.tgz#9197d861ad8142f3e63d5a83bfe4c59f7330885d" + integrity sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0= + dependencies: + readable-stream "1.1.x" + xregexp "2.0.0" + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" @@ -784,6 +951,18 @@ get-stream@^6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-uri@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-2.0.4.tgz#d4937ab819e218d4cb5ae18e4f5962bef169cc6a" + integrity sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q== + dependencies: + data-uri-to-buffer "1" + debug "2" + extend "~3.0.2" + file-uri-to-path "1" + ftp "~0.3.10" + readable-stream "2" + google-auth-library@^7.0.0, google-auth-library@^7.6.1, google-auth-library@^7.9.2: version "7.11.0" resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.11.0.tgz#b63699c65037310a424128a854ba7e736704cbdb" @@ -860,6 +1039,14 @@ http-parser-js@>=0.5.1: resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.5.tgz#d7c30d5d3c90d865b4a2e870181f9d6f22ac7ac5" integrity sha512-x+JVEkO2PoM8qqpbPbOL3cqHPwerep7OwzK7Ay+sMQjKzaKCqWvjoXm5tqMP9tXWWTnTzAjIhXg+J99XYuPhPA== +http-proxy-agent@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405" + integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg== + dependencies: + agent-base "4" + debug "3.1.0" + http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" @@ -869,6 +1056,14 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" +https-proxy-agent@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81" + integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg== + dependencies: + agent-base "^4.3.0" + debug "^3.1.0" + https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -889,11 +1084,26 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -inherits@2.0.4, inherits@^2.0.3: +inflection@~1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416" + integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY= + +inflection@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.3.8.tgz#cbd160da9f75b14c3cc63578d4f396784bf3014e" + integrity sha1-y9Fg2p91sUw8xjV41POWeEvzAU4= + +inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== +ip@1.1.5, ip@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" + integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" @@ -914,6 +1124,11 @@ is-stream-ended@^0.1.4: resolved "https://registry.yarnpkg.com/is-stream-ended/-/is-stream-ended-0.1.4.tgz#f50224e95e06bce0e356d440a4827cd35b267eda" integrity sha512-xj0XPvmr7bQFTvirqnFr50o0hQIh6ZItDqloxt5aJrR4NQsYeSsyFQERYGCAzfindAcnKjINnwEEgLx4IqVzQw== +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -924,6 +1139,16 @@ is-typedarray@^1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + jose@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.5.tgz#29746a18d9fff7dcf9d5d2a6f62cb0c7cd27abd3" @@ -999,6 +1224,14 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + limiter@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/limiter/-/limiter-1.1.5.tgz#8f92a25b3b16c6131293a0cc834b4a838a2aa7c2" @@ -1059,6 +1292,13 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -1082,6 +1322,21 @@ lru-memoizer@^2.1.4: lodash.clonedeep "^4.5.0" lru-cache "~4.0.0" +mailgun-js@0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/mailgun-js/-/mailgun-js-0.22.0.tgz#128942b5e47a364a470791608852bf68c96b3a05" + integrity sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA== + dependencies: + async "^2.6.1" + debug "^4.1.0" + form-data "^2.3.3" + inflection "~1.12.0" + is-stream "^1.1.0" + path-proxy "~1.0.0" + promisify-call "^2.0.2" + proxy-agent "^3.0.3" + tsscmp "^1.0.6" + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -1109,7 +1364,7 @@ mime-db@1.51.0, "mime-db@>= 1.43.0 < 2": resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c" integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g== -mime-types@^2.0.8, mime-types@~2.1.24: +mime-types@^2.0.8, mime-types@^2.1.12, mime-types@~2.1.24: version "2.1.34" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24" integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A== @@ -1146,6 +1401,11 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +netmask@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35" + integrity sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU= + node-fetch@^2.6.1: version "2.6.6" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" @@ -1182,6 +1442,18 @@ once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +optionator@^0.8.1: + version "0.8.3" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" + integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.6" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + word-wrap "~1.2.3" + p-limit@^3.0.1: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -1189,16 +1461,65 @@ p-limit@^3.0.1: dependencies: yocto-queue "^0.1.0" +pac-proxy-agent@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz#115b1e58f92576cac2eba718593ca7b0e37de2ad" + integrity sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ== + dependencies: + agent-base "^4.2.0" + debug "^4.1.1" + get-uri "^2.0.0" + http-proxy-agent "^2.1.0" + https-proxy-agent "^3.0.0" + pac-resolver "^3.0.0" + raw-body "^2.2.0" + socks-proxy-agent "^4.0.1" + +pac-resolver@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-3.0.0.tgz#6aea30787db0a891704deb7800a722a7615a6f26" + integrity sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA== + dependencies: + co "^4.6.0" + degenerator "^1.0.4" + ip "^1.1.5" + netmask "^1.0.6" + thunkify "^2.1.2" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +path-proxy@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/path-proxy/-/path-proxy-1.0.0.tgz#18e8a36859fc9d2f1a53b48dee138543c020de5e" + integrity sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4= + dependencies: + inflection "~1.3.0" + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +promisify-call@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/promisify-call/-/promisify-call-2.0.4.tgz#d48c2d45652ccccd52801ddecbd533a6d4bd5fba" + integrity sha1-1IwtRWUszM1SgB3ey9UzptS9X7o= + dependencies: + with-callback "^1.0.2" + proto3-json-serializer@^0.1.5: version "0.1.6" resolved "https://registry.yarnpkg.com/proto3-json-serializer/-/proto3-json-serializer-0.1.6.tgz#67cf3b8d5f4c8bebfc410698ad3b1ed64da39c7b" @@ -1233,6 +1554,25 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-agent@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-3.1.1.tgz#7e04e06bf36afa624a1540be247b47c970bd3014" + integrity sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw== + dependencies: + agent-base "^4.2.0" + debug "4" + http-proxy-agent "^2.1.0" + https-proxy-agent "^3.0.0" + lru-cache "^5.1.1" + pac-proxy-agent "^3.0.1" + proxy-from-env "^1.0.0" + socks-proxy-agent "^4.0.1" + +proxy-from-env@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + pseudomap@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" @@ -1270,7 +1610,7 @@ range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.4.2: +raw-body@2.4.2, raw-body@^2.2.0: version "2.4.2" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.2.tgz#baf3e9c21eebced59dd6533ac872b71f7b61cb32" integrity sha512-RPMAFUJP19WIet/99ngh6Iv8fzAbqum4Li7AD6DtGaW2RpMB/11xDoalPiJMTbu6I3hkbMVkATvZrqb9EEqeeQ== @@ -1280,6 +1620,29 @@ raw-body@2.4.2: iconv-lite "0.4.24" unpipe "1.0.0" +readable-stream@1.1.x: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@2: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.1.1: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" @@ -1312,6 +1675,11 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -1366,11 +1734,37 @@ signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.6.tgz#24e630c4b0f03fea446a2bd299e62b4a6ca8d0af" integrity sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ== +smart-buffer@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + snakeize@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/snakeize/-/snakeize-0.1.0.tgz#10c088d8b58eb076b3229bb5a04e232ce126422d" integrity sha1-EMCI2LWOsHazIpu1oE4jLOEmQi0= +socks-proxy-agent@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386" + integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg== + dependencies: + agent-base "~4.2.1" + socks "~2.3.2" + +socks@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.3.tgz#01129f0a5d534d2b897712ed8aceab7ee65d78e3" + integrity sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA== + dependencies: + ip "1.1.5" + smart-buffer "^4.1.0" + +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + "statuses@>= 1.5.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" @@ -1409,6 +1803,18 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -1432,6 +1838,11 @@ teeny-request@^7.0.0: stream-events "^1.0.5" uuid "^8.0.0" +thunkify@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/thunkify/-/thunkify-2.1.2.tgz#faa0e9d230c51acc95ca13a361ac05ca7e04553d" + integrity sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0= + toidentifier@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" @@ -1442,11 +1853,23 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -tslib@^2.1.0: +tslib@^2.0.1, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tsscmp@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -1479,7 +1902,7 @@ unpipe@1.0.0, unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= -util-deprecate@^1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= @@ -1526,6 +1949,16 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" +with-callback@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/with-callback/-/with-callback-1.0.2.tgz#a09629b9a920028d721404fb435bdcff5c91bc21" + integrity sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE= + +word-wrap@~1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" + integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -1555,6 +1988,11 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== +xregexp@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-2.0.0.tgz#52a63e56ca0b84a7f3a5f3d61872f126ad7a5943" + integrity sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM= + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -1565,6 +2003,11 @@ yallist@^2.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + yallist@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" From 5890b74225ca405fc7d01361170f82666dd8484b Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sat, 1 Jan 2022 19:03:18 -0600 Subject: [PATCH 26/81] Mkt resolution: new standard resolution (pay back bets first) (#15) * new standard resolution; contract.totalBets; MKT resolution * recalculate script * Fix one bug and change script name Co-authored-by: jahooma <jahooma@gmail.com> --- functions/src/place-bet.ts | 19 +++-- functions/src/resolve-market.ts | 77 +++++++++++++------ .../scripts/recalculate-contract-totals.ts | 62 +++++++++++++++ functions/src/sell-bet.ts | 26 ++++++- functions/src/types/contract.ts | 1 + web/lib/calculate.ts | 12 ++- web/lib/firebase/contracts.ts | 1 + web/lib/service/create-contract.ts | 1 + 8 files changed, 160 insertions(+), 39 deletions(-) create mode 100644 functions/src/scripts/recalculate-contract-totals.ts diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index e6e4d25a..d26c07de 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -43,18 +43,14 @@ export const placeBet = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() - const { newBet, newPool, newTotalShares, newBalance } = getNewBetInfo( - user, - outcome, - amount, - contract, - newBetDoc.id - ) + const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = + getNewBetInfo(user, outcome, amount, contract, newBetDoc.id) transaction.create(newBetDoc, newBet) transaction.update(contractDoc, { pool: newPool, totalShares: newTotalShares, + totalBets: newTotalBets, }) transaction.update(userDoc, { balance: newBalance }) @@ -91,6 +87,13 @@ const getNewBetInfo = ( ? { YES: yesShares + shares, NO: noShares } : { YES: yesShares, NO: noShares + shares } + const { YES: yesBets, NO: noBets } = contract.totalBets + + const newTotalBets = + outcome === 'YES' + ? { YES: yesBets + amount, NO: noBets } + : { YES: yesBets, NO: noBets + amount } + const probBefore = yesPool ** 2 / (yesPool ** 2 + noPool ** 2) const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2) @@ -108,5 +111,5 @@ const getNewBetInfo = ( const newBalance = user.balance - amount - return { newBet, newPool, newTotalShares, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets, newBalance } } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 0ab9f4cb..656c73b5 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -55,12 +55,19 @@ export const resolveMarket = functions const betsSnap = await firestore .collection(`contracts/${contractId}/bets`) .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + const openBets = bets.filter((b) => !b.isSold && !b.sale) + + const startPool = contract.startPool.YES + contract.startPool.NO + const truePool = contract.pool.YES + contract.pool.NO - startPool const payouts = outcome === 'CANCEL' - ? getCancelPayouts(contract, bets) - : getPayouts(outcome, contract, bets) + ? getCancelPayouts(truePool, openBets) + : outcome === 'MKT' + ? getMktPayouts(truePool, contract, openBets) + : getStandardPayouts(outcome, truePool, contract, openBets) console.log('payouts:', payouts) @@ -96,42 +103,51 @@ export const resolveMarket = functions const firestore = admin.firestore() -const getCancelPayouts = (contract: Contract, bets: Bet[]) => { - const startPool = contract.startPool.YES + contract.startPool.NO - const truePool = contract.pool.YES + contract.pool.NO - startPool +const getCancelPayouts = (truePool: number, bets: Bet[]) => { + console.log('resolved N/A, pool M$', truePool) - const openBets = bets.filter((b) => !b.isSold && !b.sale) + const betSum = _.sumBy(bets, (b) => b.amount) - const betSum = _.sumBy(openBets, (b) => b.amount) - - return openBets.map((bet) => ({ + return bets.map((bet) => ({ userId: bet.userId, payout: (bet.amount / betSum) * truePool, })) } -const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => { - const openBets = bets.filter((b) => !b.isSold && !b.sale) - const [yesBets, noBets] = _.partition( - openBets, - (bet) => bet.outcome === 'YES' +const getStandardPayouts = ( + outcome: string, + truePool: number, + contract: Contract, + bets: Bet[] +) => { + const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') + const winningBets = outcome === 'YES' ? yesBets : noBets + + const betSum = _.sumBy(winningBets, (b) => b.amount) + + if (betSum >= truePool) return getCancelPayouts(truePool, winningBets) + + const creatorPayout = CREATOR_FEE * truePool + console.log( + 'resolved', + outcome, + 'pool: M$', + truePool, + 'creator fee: M$', + creatorPayout ) - const startPool = contract.startPool.YES + contract.startPool.NO - const truePool = contract.pool.YES + contract.pool.NO - startPool + const shareDifferenceSum = _.sumBy(winningBets, (b) => b.shares - b.amount) - const [totalShares, winningBets] = - outcome === 'YES' - ? [contract.totalShares.YES, yesBets] - : [contract.totalShares.NO, noBets] - - const finalPool = (1 - PLATFORM_FEE - CREATOR_FEE) * truePool - const creatorPayout = CREATOR_FEE * truePool - console.log('final pool:', finalPool, 'creator fee:', creatorPayout) + const winningsPool = truePool - betSum + const fees = PLATFORM_FEE + CREATOR_FEE const winnerPayouts = winningBets.map((bet) => ({ userId: bet.userId, - payout: (bet.shares / totalShares) * finalPool, + payout: + (1 - fees) * + (bet.amount + + ((bet.shares - bet.amount) / shareDifferenceSum) * winningsPool), })) return winnerPayouts.concat([ @@ -139,6 +155,17 @@ const getPayouts = (outcome: string, contract: Contract, bets: Bet[]) => { ]) // add creator fee } +const getMktPayouts = (truePool: number, contract: Contract, bets: Bet[]) => { + const p = + contract.pool.YES ** 2 / (contract.pool.YES ** 2 + contract.pool.NO ** 2) + console.log('Resolved MKT at p=', p) + + return [ + ...getStandardPayouts('YES', p * truePool, contract, bets), + ...getStandardPayouts('NO', (1 - p) * truePool, contract, bets), + ] +} + const payUser = ([userId, payout]: [string, number]) => { return firestore.runTransaction(async (transaction) => { const userDoc = firestore.doc(`users/${userId}`) diff --git a/functions/src/scripts/recalculate-contract-totals.ts b/functions/src/scripts/recalculate-contract-totals.ts new file mode 100644 index 00000000..d1b33bb4 --- /dev/null +++ b/functions/src/scripts/recalculate-contract-totals.ts @@ -0,0 +1,62 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' +import { Bet } from '../types/bet' +import { Contract } from '../types/contract' + +type DocRef = admin.firestore.DocumentReference + +// Generate your own private key, and set the path below: +// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk +const serviceAccount = require('../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json') + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}) +const firestore = admin.firestore() + +async function recalculateContract(contractRef: DocRef, contract: Contract) { + const bets = await contractRef + .collection('bets') + .get() + .then((snap) => snap.docs.map((bet) => bet.data() as Bet)) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + + const totalShares = { + YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)), + NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)), + } + + const totalBets = { + YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)), + NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)), + } + + await contractRef.update({ totalShares, totalBets }) + + console.log( + 'calculating totals for "', + contract.question, + '" total bets:', + totalBets + ) + console.log() +} + +async function recalculateContractTotals() { + console.log('Recalculating contract info') + + const snapshot = await firestore.collection('contracts').get() + const contracts = snapshot.docs.map((doc) => doc.data() as Contract) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + await recalculateContract(contractRef, contract) + } +} + +if (require.main === module) + recalculateContractTotals().then(() => process.exit()) diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index ba60a5e3..3d37d3c3 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -44,8 +44,14 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( .collection(`contracts/${contractId}/bets`) .doc() - const { newBet, newPool, newTotalShares, newBalance, creatorFee } = - getSellBetInfo(user, bet, contract, newBetDoc.id) + const { + newBet, + newPool, + newTotalShares, + newTotalBets, + newBalance, + creatorFee, + } = getSellBetInfo(user, bet, contract, newBetDoc.id) const creatorDoc = firestore.doc(`users/${contract.creatorId}`) const creatorSnap = await transaction.get(creatorDoc) @@ -60,6 +66,7 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( transaction.update(contractDoc, { pool: newPool, totalShares: newTotalShares, + totalBets: newTotalBets, }) transaction.update(userDoc, { balance: newBalance }) @@ -81,6 +88,7 @@ const getSellBetInfo = ( const { YES: yesPool, NO: noPool } = contract.pool const { YES: yesStart, NO: noStart } = contract.startPool const { YES: yesShares, NO: noShares } = contract.totalShares + const { YES: yesBets, NO: noBets } = contract.totalBets const [y, n, s] = [yesPool, noPool, shares] @@ -123,6 +131,11 @@ const getSellBetInfo = ( ? { YES: yesShares - shares, NO: noShares } : { YES: yesShares, NO: noShares - shares } + const newTotalBets = + outcome === 'YES' + ? { YES: yesBets - amount, NO: noBets } + : { YES: yesBets, NO: noBets - amount } + const probAfter = newPool.YES ** 2 / (newPool.YES ** 2 + newPool.NO ** 2) const creatorFee = CREATOR_FEE * adjShareValue @@ -158,5 +171,12 @@ const getSellBetInfo = ( const newBalance = user.balance + saleAmount - return { newBet, newPool, newTotalShares, newBalance, creatorFee } + return { + newBet, + newPool, + newTotalShares, + newTotalBets, + newBalance, + creatorFee, + } } diff --git a/functions/src/types/contract.ts b/functions/src/types/contract.ts index 5f354654..2cb86ac2 100644 --- a/functions/src/types/contract.ts +++ b/functions/src/types/contract.ts @@ -13,6 +13,7 @@ export type Contract = { startPool: { YES: number; NO: number } pool: { YES: number; NO: number } totalShares: { YES: number; NO: number } + totalBets: { YES: number; NO: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed diff --git a/web/lib/calculate.ts b/web/lib/calculate.ts index b195a65a..642473df 100644 --- a/web/lib/calculate.ts +++ b/web/lib/calculate.ts @@ -44,14 +44,20 @@ export function calculatePayout( if (outcome === 'CANCEL') return amount if (betOutcome !== outcome) return 0 - const { totalShares } = contract + const { totalShares, totalBets } = contract if (totalShares[outcome] === 0) return 0 const startPool = contract.startPool.YES + contract.startPool.NO - const pool = contract.pool.YES + contract.pool.NO - startPool + const truePool = contract.pool.YES + contract.pool.NO - startPool - return (1 - fees) * (shares / totalShares[outcome]) * pool + if (totalBets[outcome] >= truePool) + return (amount / totalBets[outcome]) * truePool + + const total = totalShares[outcome] - totalBets[outcome] + const winningsPool = truePool - totalBets[outcome] + + return (1 - fees) * (amount + ((shares - amount) / total) * winningsPool) } export function resolvedPayout(contract: Contract, bet: Bet) { diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index b8ea93e3..1db5d00e 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -31,6 +31,7 @@ export type Contract = { startPool: { YES: number; NO: number } pool: { YES: number; NO: number } totalShares: { YES: number; NO: number } + totalBets: { YES: number; NO: number } createdTime: number // Milliseconds since epoch lastUpdatedTime: number // If the question or description was changed diff --git a/web/lib/service/create-contract.ts b/web/lib/service/create-contract.ts index eba7b0ff..966115b0 100644 --- a/web/lib/service/create-contract.ts +++ b/web/lib/service/create-contract.ts @@ -38,6 +38,7 @@ export async function createContract( startPool: { YES: startYes, NO: startNo }, pool: { YES: startYes, NO: startNo }, totalShares: { YES: 0, NO: 0 }, + totalBets: { YES: 0, NO: 0 }, isResolved: false, // TODO: Set create time to Firestore timestamp From 0950a281f221ccd4812f0a5acfbe501765f77fd2 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sat, 1 Jan 2022 19:13:30 -0600 Subject: [PATCH 27/81] Fix build error from merge. Extract resolution emails to function --- functions/src/emails.ts | 4 +-- functions/src/resolve-market.ts | 43 ++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 14c030f3..adf65235 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -8,7 +8,7 @@ export const sendMarketResolutionEmail = async ( payout: number, creator: User, contract: Contract, - resolution: 'YES' | 'NO' | 'CANCEL' + resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' ) => { const user = await getUser(userId) if (!user) return @@ -32,4 +32,4 @@ https://mantic.markets/${creator.username}/${contract.slug} await sendEmail(user.email, subject, body) } -const toDisplayResolution = { YES: 'YES', NO: 'NO', CANCEL: 'N/A' } +const toDisplayResolution = { YES: 'YES', NO: 'NO', CANCEL: 'N/A', MKT: 'MKT' } diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 656c73b5..164551bd 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -16,7 +16,7 @@ export const resolveMarket = functions .https.onCall( async ( data: { - outcome: 'YES' | 'NO' | 'CANCEL' + outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' contractId: string }, context @@ -82,25 +82,40 @@ export const resolveMarket = functions .catch((e) => ({ status: 'error', message: e })) .then(() => ({ status: 'success' })) - const activeBets = bets.filter((bet) => !bet.isSold && !bet.sale) - const nonWinners = _.difference( - _.uniq(activeBets.map(({ userId }) => userId)), - Object.keys(userPayouts) - ) - const emailPayouts = [ - ...Object.entries(userPayouts), - ...nonWinners.map((userId) => [userId, 0] as const), - ] - await Promise.all( - emailPayouts.map(([userId, payout]) => - sendMarketResolutionEmail(userId, payout, creator, contract, outcome) - ) + await sendResolutionEmails( + openBets, + userPayouts, + creator, + contract, + outcome ) return result } ) +const sendResolutionEmails = async ( + openBets: Bet[], + userPayouts: { [userId: string]: number }, + creator: User, + contract: Contract, + outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' +) => { + const nonWinners = _.difference( + _.uniq(openBets.map(({ userId }) => userId)), + Object.keys(userPayouts) + ) + const emailPayouts = [ + ...Object.entries(userPayouts), + ...nonWinners.map((userId) => [userId, 0] as const), + ] + await Promise.all( + emailPayouts.map(([userId, payout]) => + sendMarketResolutionEmail(userId, payout, creator, contract, outcome) + ) + ) +} + const firestore = admin.firestore() const getCancelPayouts = (truePool: number, bets: Bet[]) => { From b74a0c02c551034ce0ac0d415de80382f3d7a254 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sat, 1 Jan 2022 19:20:25 -0600 Subject: [PATCH 28/81] Remove a new line in email --- functions/src/emails.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/functions/src/emails.ts b/functions/src/emails.ts index adf65235..ba4874d3 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -21,7 +21,6 @@ A market you bet in has been resolved! Creator: ${contract.creatorName} Question: ${contract.question} - Resolution: ${toDisplayResolution[resolution]} Your payout is M$ ${Math.round(payout)} From 8c644da10db07dc868994357137de8ae856acf1c Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sat, 1 Jan 2022 22:52:55 -0600 Subject: [PATCH 29/81] Update contract title with resolution or percent chance --- web/pages/[username]/[contractSlug].tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 60a1a5fb..4fe92741 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -11,7 +11,11 @@ import { useBets } from '../../hooks/use-bets' import { Title } from '../../components/title' import { Spacer } from '../../components/layout/spacer' import { User } from '../../lib/firebase/users' -import { Contract, getContractFromSlug } from '../../lib/firebase/contracts' +import { + compute, + Contract, + getContractFromSlug, +} from '../../lib/firebase/contracts' import { SEO } from '../../components/SEO' import { Page } from '../../components/page' @@ -47,13 +51,18 @@ export default function ContractPage(props: { return <div>Contract not found...</div> } - const { creatorId, isResolved } = contract + const { creatorId, isResolved, resolution, question } = contract const isCreator = user?.id === creatorId + const { probPercent } = compute(contract) + const title = resolution + ? `Resolved ${resolution}: ${question}` + : `${probPercent} chance: ${question}` + return ( <Page wide={!isResolved}> <SEO - title={contract.question} + title={title} description={contract.description} url={`/${props.username}/${props.slug}`} /> From a9e8b4c1e72d91bcf7330d507e1463b7c488b103 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sat, 1 Jan 2022 23:52:32 -0600 Subject: [PATCH 30/81] Attempt to fix external link in description --- web/components/linkify.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index df6bb98d..3ee4650f 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -15,7 +15,7 @@ export function Linkify(props: { text: string }) { { '@': `/${tag}`, '#': `/tag/${tag}`, - }[symbol] ?? match + }[symbol] ?? match.trim() return ( <> From 907acec601b58fd12b7e3c82489e69645b413d2a Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Sun, 2 Jan 2022 00:27:58 -0600 Subject: [PATCH 31/81] Mkt resolution 2: Enable MKT resolution (#16) * new standard resolution; contract.totalBets; MKT resolution * recalculate script * enable resolve MKT * different approach to resolve MKT * comment out init * Count payouts for bets with exluded sales Co-authored-by: jahooma <jahooma@gmail.com> --- functions/src/resolve-market.ts | 53 ++++++++++++++++++++-- functions/src/scripts/recalculate.ts | 61 +++++++++++++++++++++++++ web/components/bets-list.tsx | 9 +++- web/components/contract-overview.tsx | 1 + web/components/contracts-list.tsx | 2 + web/components/resolution-panel.tsx | 11 ++++- web/components/yes-no-selector.tsx | 68 +++++++++++++++++----------- web/lib/calculate.ts | 32 ++++++++++++- 8 files changed, 201 insertions(+), 36 deletions(-) create mode 100644 functions/src/scripts/recalculate.ts diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 164551bd..4862703f 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -26,7 +26,7 @@ export const resolveMarket = functions const { outcome, contractId } = data - if (!['YES', 'NO', 'CANCEL'].includes(outcome)) + if (!['YES', 'NO', 'MKT', 'CANCEL'].includes(outcome)) return { status: 'error', message: 'Invalid outcome' } const contractDoc = firestore.doc(`contracts/${contractId}`) @@ -155,7 +155,6 @@ const getStandardPayouts = ( const shareDifferenceSum = _.sumBy(winningBets, (b) => b.shares - b.amount) const winningsPool = truePool - betSum - const fees = PLATFORM_FEE + CREATOR_FEE const winnerPayouts = winningBets.map((bet) => ({ userId: bet.userId, @@ -173,11 +172,53 @@ const getStandardPayouts = ( const getMktPayouts = (truePool: number, contract: Contract, bets: Bet[]) => { const p = contract.pool.YES ** 2 / (contract.pool.YES ** 2 + contract.pool.NO ** 2) - console.log('Resolved MKT at p=', p) + console.log('Resolved MKT at p=', p, 'pool: $M', truePool) + + const [yesBets, noBets] = _.partition(bets, (bet) => bet.outcome === 'YES') + + const weightedBetTotal = + p * _.sumBy(yesBets, (b) => b.amount) + + (1 - p) * _.sumBy(noBets, (b) => b.amount) + + if (weightedBetTotal >= truePool) { + return bets.map((bet) => ({ + userId: bet.userId, + payout: + (((bet.outcome === 'YES' ? p : 1 - p) * bet.amount) / + weightedBetTotal) * + truePool, + })) + } + + const winningsPool = truePool - weightedBetTotal + + const weightedShareTotal = + p * _.sumBy(yesBets, (b) => b.shares - b.amount) + + (1 - p) * _.sumBy(noBets, (b) => b.shares - b.amount) + + const yesPayouts = yesBets.map((bet) => ({ + userId: bet.userId, + payout: + (1 - fees) * + (p * bet.amount + + ((p * (bet.shares - bet.amount)) / weightedShareTotal) * winningsPool), + })) + + const noPayouts = noBets.map((bet) => ({ + userId: bet.userId, + payout: + (1 - fees) * + ((1 - p) * bet.amount + + (((1 - p) * (bet.shares - bet.amount)) / weightedShareTotal) * + winningsPool), + })) + + const creatorPayout = CREATOR_FEE * truePool return [ - ...getStandardPayouts('YES', p * truePool, contract, bets), - ...getStandardPayouts('NO', (1 - p) * truePool, contract, bets), + ...yesPayouts, + ...noPayouts, + { userId: contract.creatorId, payout: creatorPayout }, ] } @@ -192,3 +233,5 @@ const payUser = ([userId, payout]: [string, number]) => { transaction.update(userDoc, { balance: newUserBalance }) }) } + +const fees = PLATFORM_FEE + CREATOR_FEE diff --git a/functions/src/scripts/recalculate.ts b/functions/src/scripts/recalculate.ts new file mode 100644 index 00000000..103152c9 --- /dev/null +++ b/functions/src/scripts/recalculate.ts @@ -0,0 +1,61 @@ +import * as admin from 'firebase-admin' +import * as _ from 'lodash' +import { Bet } from '../types/bet' +import { Contract } from '../types/contract' + +type DocRef = admin.firestore.DocumentReference + +// Generate your own private key, and set the path below: +// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk +const serviceAccount = require('../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json') + +admin.initializeApp({ + credential: admin.credential.cert(serviceAccount), +}) +const firestore = admin.firestore() + +async function recalculateContract(contractRef: DocRef, contract: Contract) { + const bets = await contractRef + .collection('bets') + .get() + .then((snap) => snap.docs.map((bet) => bet.data() as Bet)) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + + const totalShares = { + YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)), + NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)), + } + + const totalBets = { + YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)), + NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)), + } + + await contractRef.update({ totalShares, totalBets }) + + console.log( + 'calculating totals for "', + contract.question, + '" total bets:', + totalBets + ) + console.log() +} + +async function migrateContracts() { + console.log('Recalculating contract info') + + const snapshot = await firestore.collection('contracts').get() + const contracts = snapshot.docs.map((doc) => doc.data() as Contract) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + await recalculateContract(contractRef, contract) + } +} + +if (require.main === module) migrateContracts().then(() => process.exit()) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 18c74563..29d51346 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -191,7 +191,7 @@ export function MyBetsSummary(props: { const betsTotal = _.sumBy(excludeSales, (bet) => bet.amount) const betsPayout = resolution - ? _.sumBy(bets, (bet) => resolvedPayout(contract, bet)) + ? _.sumBy(excludeSales, (bet) => resolvedPayout(contract, bet)) : 0 const yesWinnings = _.sumBy(excludeSales, (bet) => @@ -357,11 +357,12 @@ function SellButton(props: { contract: Contract; bet: Bet }) { ) } -function OutcomeLabel(props: { outcome: 'YES' | 'NO' | 'CANCEL' }) { +function OutcomeLabel(props: { outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' }) { const { outcome } = props if (outcome === 'YES') return <YesLabel /> if (outcome === 'NO') return <NoLabel /> + if (outcome === 'MKT') return <MarketLabel /> return <CancelLabel /> } @@ -376,3 +377,7 @@ function NoLabel() { function CancelLabel() { return <span className="text-yellow-400">N/A</span> } + +function MarketLabel() { + return <span className="text-blue-400">MKT</span> +} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index aa8dfe6c..ec2fc4cb 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -98,6 +98,7 @@ export const ContractOverview = (props: { const resolutionColor = { YES: 'text-primary', NO: 'text-red-400', + MKT: 'text-blue-400', CANCEL: 'text-yellow-400', '': '', // Empty if unresolved }[contract.resolution || ''] diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index 07c052b0..dc83bafb 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -44,6 +44,7 @@ function ContractCard(props: { contract: Contract }) { const resolutionColor = { YES: 'text-primary', NO: 'text-red-400', + MKT: 'text-blue-400', CANCEL: 'text-yellow-400', '': '', // Empty if unresolved }[contract.resolution || ''] @@ -51,6 +52,7 @@ function ContractCard(props: { contract: Contract }) { const resolutionText = { YES: 'YES', NO: 'NO', + MKT: 'MKT', CANCEL: 'N/A', '': '', }[contract.resolution || ''] diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index 7f9f74c6..d5ccbfd5 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -20,7 +20,9 @@ export function ResolutionPanel(props: { }) { const { contract, className } = props - const [outcome, setOutcome] = useState<'YES' | 'NO' | 'CANCEL' | undefined>() + const [outcome, setOutcome] = useState< + 'YES' | 'NO' | 'MKT' | 'CANCEL' | undefined + >() const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState<string | undefined>(undefined) @@ -48,6 +50,8 @@ export function ResolutionPanel(props: { ? 'bg-red-400 hover:bg-red-500' : outcome === 'CANCEL' ? 'bg-yellow-400 hover:bg-yellow-500' + : outcome === 'MKT' + ? 'bg-blue-400 hover:bg-blue-500' : 'btn-disabled' return ( @@ -74,6 +78,11 @@ export function ResolutionPanel(props: { <>Winnings will be paid out to NO bettors. You earn 1% of the pool.</> ) : outcome === 'CANCEL' ? ( <>The pool will be returned to traders with no fees.</> + ) : outcome === 'MKT' ? ( + <> + Traders will be paid out at the current implied probability. You + earn 1% of the pool. + </> ) : ( <>Resolving this market will immediately pay out traders.</> )} diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index 391db469..528b199d 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx' import React from 'react' +import { Col } from './layout/col' import { Row } from './layout/row' export function YesNoSelector(props: { @@ -29,48 +30,60 @@ export function YesNoSelector(props: { } export function YesNoCancelSelector(props: { - selected: 'YES' | 'NO' | 'CANCEL' | undefined - onSelect: (selected: 'YES' | 'NO' | 'CANCEL') => void + selected: 'YES' | 'NO' | 'MKT' | 'CANCEL' | undefined + onSelect: (selected: 'YES' | 'NO' | 'MKT' | 'CANCEL') => void className?: string btnClassName?: string }) { const { selected, onSelect, className } = props - const btnClassName = clsx('px-6', props.btnClassName) + const btnClassName = clsx('px-6 flex-1', props.btnClassName) return ( - <Row className={clsx('space-x-3', className)}> - <Button - color={selected === 'YES' ? 'green' : 'gray'} - onClick={() => onSelect('YES')} - className={btnClassName} - > - YES - </Button> + <Col> + <Row className={clsx('space-x-3 w-full', className)}> + <Button + color={selected === 'YES' ? 'green' : 'gray'} + onClick={() => onSelect('YES')} + className={btnClassName} + > + YES + </Button> - <Button - color={selected === 'NO' ? 'red' : 'gray'} - onClick={() => onSelect('NO')} - className={btnClassName} - > - NO - </Button> + <Button + color={selected === 'NO' ? 'red' : 'gray'} + onClick={() => onSelect('NO')} + className={btnClassName} + > + NO + </Button> + </Row> - <Button - color={selected === 'CANCEL' ? 'yellow' : 'gray'} - onClick={() => onSelect('CANCEL')} - className={btnClassName} - > - N/A - </Button> - </Row> + <Row className={clsx('space-x-3 w-full', className)}> + <Button + color={selected === 'MKT' ? 'blue' : 'gray'} + onClick={() => onSelect('MKT')} + className={clsx(btnClassName, 'btn-sm')} + > + MKT + </Button> + + <Button + color={selected === 'CANCEL' ? 'yellow' : 'gray'} + onClick={() => onSelect('CANCEL')} + className={clsx(btnClassName, 'btn-sm')} + > + N/A + </Button> + </Row> + </Col> ) } function Button(props: { className?: string onClick?: () => void - color: 'green' | 'red' | 'yellow' | 'gray' + color: 'green' | 'red' | 'blue' | 'yellow' | 'gray' children?: any }) { const { className, onClick, children, color } = props @@ -83,6 +96,7 @@ function Button(props: { color === 'green' && 'btn-primary', color === 'red' && 'bg-red-400 hover:bg-red-500', color === 'yellow' && 'bg-yellow-400 hover:bg-yellow-500', + color === 'blue' && 'bg-blue-400 hover:bg-blue-500', color === 'gray' && 'text-gray-700 bg-gray-300 hover:bg-gray-400', className )} diff --git a/web/lib/calculate.ts b/web/lib/calculate.ts index 642473df..b265f228 100644 --- a/web/lib/calculate.ts +++ b/web/lib/calculate.ts @@ -37,11 +37,13 @@ export function calculateShares( export function calculatePayout( contract: Contract, bet: Bet, - outcome: 'YES' | 'NO' | 'CANCEL' + outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' ) { const { amount, outcome: betOutcome, shares } = bet if (outcome === 'CANCEL') return amount + if (outcome === 'MKT') return calculateMktPayout(contract, bet) + if (betOutcome !== outcome) return 0 const { totalShares, totalBets } = contract @@ -60,6 +62,34 @@ export function calculatePayout( return (1 - fees) * (amount + ((shares - amount) / total) * winningsPool) } +function calculateMktPayout(contract: Contract, bet: Bet) { + const p = + contract.pool.YES ** 2 / (contract.pool.YES ** 2 + contract.pool.NO ** 2) + const weightedTotal = + p * contract.totalBets.YES + (1 - p) * contract.totalBets.NO + + const startPool = contract.startPool.YES + contract.startPool.NO + const truePool = contract.pool.YES + contract.pool.NO - startPool + + const betP = bet.outcome === 'YES' ? p : 1 - p + + if (weightedTotal >= truePool) { + return ((betP * bet.amount) / weightedTotal) * truePool + } + + const winningsPool = truePool - weightedTotal + + const weightedShareTotal = + p * (contract.totalShares.YES - contract.totalBets.YES) + + (1 - p) * (contract.totalShares.NO - contract.totalBets.NO) + + return ( + (1 - fees) * + (betP * bet.amount + + ((betP * (bet.shares - bet.amount)) / weightedShareTotal) * winningsPool) + ) +} + export function resolvedPayout(contract: Contract, bet: Bet) { if (contract.resolution) return calculatePayout(contract, bet, contract.resolution) From cdf25ba65971042a5c065d98da37d07da41fbf6f Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Sun, 2 Jan 2022 12:57:52 -0600 Subject: [PATCH 32/81] contract page seo tags --- web/pages/[username]/[contractSlug].tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 4fe92741..fa9bbac6 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -55,15 +55,16 @@ export default function ContractPage(props: { const isCreator = user?.id === creatorId const { probPercent } = compute(contract) - const title = resolution - ? `Resolved ${resolution}: ${question}` - : `${probPercent} chance: ${question}` + + const description = resolution + ? `Resolved ${resolution}. ${contract.description}` + : `${probPercent} chance. ${contract.description}` return ( <Page wide={!isResolved}> <SEO - title={title} - description={contract.description} + title={question} + description={description} url={`/${props.username}/${props.slug}`} /> From 657b6b2763282134a4a76bd08b1e69ca6476cf9a Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sun, 2 Jan 2022 13:09:01 -0600 Subject: [PATCH 33/81] Improve positioning of x% chance on mobile, break words in description. --- web/components/contract-card.tsx | 74 ++++++++++++++++++++++++++ web/components/contract-overview.tsx | 78 ++++++++++++++++++---------- web/components/contracts-list.tsx | 76 ++------------------------- 3 files changed, 128 insertions(+), 100 deletions(-) create mode 100644 web/components/contract-card.tsx diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx new file mode 100644 index 00000000..bcfd0935 --- /dev/null +++ b/web/components/contract-card.tsx @@ -0,0 +1,74 @@ +import clsx from 'clsx' +import Link from 'next/link' +import { Row } from '../components/layout/row' +import { formatMoney } from '../lib/util/format' +import { UserLink } from './user-page' +import { Linkify } from './linkify' +import { Contract, compute, path } from '../lib/firebase/contracts' + +export function ContractCard(props: { contract: Contract }) { + const { contract } = props + const { probPercent } = compute(contract) + + const resolutionColor = { + YES: 'text-primary', + NO: 'text-red-400', + MKT: 'text-blue-400', + CANCEL: 'text-yellow-400', + '': '', // Empty if unresolved + }[contract.resolution || ''] + + const resolutionText = { + YES: 'YES', + NO: 'NO', + MKT: 'MKT', + CANCEL: 'N/A', + '': '', + }[contract.resolution || ''] + + return ( + <Link href={path(contract)}> + <a> + <li className="col-span-1 bg-white hover:bg-gray-100 shadow-md rounded-lg divide-y divide-gray-200"> + <div className="card"> + <div className="card-body p-6"> + <Row className="justify-between gap-4 mb-2"> + <p className="font-medium text-indigo-700"> + <Linkify text={contract.question} /> + </p> + <div className={clsx('text-4xl', resolutionColor)}> + {resolutionText || ( + <div className="text-primary"> + {probPercent} + <div className="text-lg">chance</div> + </div> + )} + </div> + </Row> + <ContractDetails contract={contract} /> + </div> + </div> + </li> + </a> + </Link> + ) +} + +export function ContractDetails(props: { contract: Contract }) { + const { contract } = props + const { truePool, createdDate, resolvedDate } = compute(contract) + + return ( + <Row className="flex-wrap text-sm text-gray-500"> + <div className="whitespace-nowrap"> + <UserLink username={contract.creatorUsername} /> + </div> + <div className="mx-2">•</div> + <div className="whitespace-nowrap"> + {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} + </div> + <div className="mx-2">•</div> + <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> + </Row> + ) +} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index ec2fc4cb..3dc5230c 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -8,13 +8,13 @@ import { import { Col } from './layout/col' import { Spacer } from './layout/spacer' import { ContractProbGraph } from './contract-prob-graph' -import { ContractDetails } from './contracts-list' import router from 'next/router' import { useUser } from '../hooks/use-user' import { Row } from './layout/row' import dayjs from 'dayjs' import { Linkify } from './linkify' import clsx from 'clsx' +import { ContractDetails } from './contract-card' function ContractDescription(props: { contract: Contract @@ -35,7 +35,7 @@ function ContractDescription(props: { } return ( - <div className="whitespace-pre-line"> + <div className="whitespace-pre-line break-words"> <Linkify text={contract.description} /> <br /> {isCreator && @@ -84,6 +84,40 @@ function ContractDescription(props: { ) } +function ResolutionOrChance(props: { + resolution?: 'YES' | 'NO' | 'MKT' | 'CANCEL' + probPercent: string + className?: string +}) { + const { resolution, probPercent, className } = props + + const resolutionColor = { + YES: 'text-primary', + NO: 'text-red-400', + MKT: 'text-blue-400', + CANCEL: 'text-yellow-400', + '': '', // Empty if unresolved + }[resolution || ''] + + return ( + <Col className={clsx('text-3xl md:text-4xl', className)}> + {resolution ? ( + <> + <div className="text-lg md:text-xl text-gray-500">Resolved</div> + <div className={resolutionColor}> + {resolution === 'CANCEL' ? 'N/A' : resolution} + </div> + </> + ) : ( + <> + <div className="text-primary">{probPercent}</div> + <div className="text-lg md:text-xl text-primary">chance</div> + </> + )} + </Col> + ) +} + export const ContractOverview = (props: { contract: Contract className?: string @@ -95,39 +129,29 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId - const resolutionColor = { - YES: 'text-primary', - NO: 'text-red-400', - MKT: 'text-blue-400', - CANCEL: 'text-yellow-400', - '': '', // Empty if unresolved - }[contract.resolution || ''] - return ( <Col className={clsx('mb-6', className)}> - <Col className="justify-between md:flex-row"> - <Col> - <div className="text-3xl text-indigo-700 mb-4"> + <Row className="justify-between gap-4"> + <Col className="gap-4"> + <div className="text-2xl md:text-3xl text-indigo-700"> <Linkify text={contract.question} /> </div> + <ResolutionOrChance + className="md:hidden" + resolution={resolution} + probPercent={probPercent} + /> + <ContractDetails contract={contract} /> </Col> - {resolution ? ( - <Col className="text-4xl mt-8 md:mt-0 md:ml-4 md:mr-6 items-end self-center md:self-start"> - <div className="text-xl text-gray-500">Resolved</div> - <div className={resolutionColor}> - {resolution === 'CANCEL' ? 'N/A' : resolution} - </div> - </Col> - ) : ( - <Col className="text-4xl mt-8 md:mt-0 md:ml-4 md:mr-6 text-primary items-end self-center md:self-start"> - {probPercent} - <div className="text-xl">chance</div> - </Col> - )} - </Col> + <ResolutionOrChance + className="hidden md:flex md:items-end" + resolution={resolution} + probPercent={probPercent} + /> + </Row> <Spacer h={4} /> diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index dc83bafb..715a7ffe 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -3,87 +3,17 @@ import Link from 'next/link' import clsx from 'clsx' import { useEffect, useState } from 'react' -import { Row } from '../components/layout/row' import { compute, Contract, listContracts, path, } from '../lib/firebase/contracts' -import { formatMoney } from '../lib/util/format' import { User } from '../lib/firebase/users' -import { UserLink } from './user-page' -import { Linkify } from './linkify' import { Col } from './layout/col' import { SiteLink } from './site-link' import { parseTags } from '../lib/util/parse' - -export function ContractDetails(props: { contract: Contract }) { - const { contract } = props - const { truePool, createdDate, resolvedDate } = compute(contract) - - return ( - <Row className="flex-wrap text-sm text-gray-500"> - <div className="whitespace-nowrap"> - <UserLink username={contract.creatorUsername} /> - </div> - <div className="mx-2">•</div> - <div className="whitespace-nowrap"> - {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} - </div> - <div className="mx-2">•</div> - <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> - </Row> - ) -} - -function ContractCard(props: { contract: Contract }) { - const { contract } = props - const { probPercent } = compute(contract) - - const resolutionColor = { - YES: 'text-primary', - NO: 'text-red-400', - MKT: 'text-blue-400', - CANCEL: 'text-yellow-400', - '': '', // Empty if unresolved - }[contract.resolution || ''] - - const resolutionText = { - YES: 'YES', - NO: 'NO', - MKT: 'MKT', - CANCEL: 'N/A', - '': '', - }[contract.resolution || ''] - - return ( - <Link href={path(contract)}> - <a> - <li className="col-span-1 bg-white hover:bg-gray-100 shadow-md rounded-lg divide-y divide-gray-200"> - <div className="card"> - <div className="card-body p-6"> - <Row className="justify-between gap-4 mb-2"> - <p className="font-medium text-indigo-700"> - <Linkify text={contract.question} /> - </p> - <div className={clsx('text-4xl', resolutionColor)}> - {resolutionText || ( - <div className="text-primary"> - {probPercent} - <div className="text-lg">chance</div> - </div> - )} - </div> - </Row> - <ContractDetails contract={contract} /> - </div> - </div> - </li> - </a> - </Link> - ) -} +import { ContractCard } from './contract-card' function ContractsGrid(props: { contracts: Contract[] }) { const [resolvedContracts, activeContracts] = _.partition( @@ -117,7 +47,7 @@ function ContractsGrid(props: { contracts: Contract[] }) { const MAX_GROUPED_CONTRACTS_DISPLAYED = 6 -export function CreatorContractsGrid(props: { contracts: Contract[] }) { +function CreatorContractsGrid(props: { contracts: Contract[] }) { const { contracts } = props const byCreator = _.groupBy(contracts, (contract) => contract.creatorId) @@ -165,7 +95,7 @@ export function CreatorContractsGrid(props: { contracts: Contract[] }) { ) } -export function TagContractsGrid(props: { contracts: Contract[] }) { +function TagContractsGrid(props: { contracts: Contract[] }) { const { contracts } = props const contractTags = _.flatMap(contracts, (contract) => From b375256e96824c7205103cd100bc384723386b0d Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sun, 2 Jan 2022 14:53:42 -0600 Subject: [PATCH 34/81] Reuse ResolutionOrChance component. Make smaller for card, larger for contract page. --- web/components/contract-card.tsx | 80 +++++++++++++++++++--------- web/components/contract-overview.tsx | 38 ++----------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index bcfd0935..8fc729f7 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -5,27 +5,13 @@ import { formatMoney } from '../lib/util/format' import { UserLink } from './user-page' import { Linkify } from './linkify' import { Contract, compute, path } from '../lib/firebase/contracts' +import { Col } from './layout/col' export function ContractCard(props: { contract: Contract }) { const { contract } = props + const { resolution } = contract const { probPercent } = compute(contract) - const resolutionColor = { - YES: 'text-primary', - NO: 'text-red-400', - MKT: 'text-blue-400', - CANCEL: 'text-yellow-400', - '': '', // Empty if unresolved - }[contract.resolution || ''] - - const resolutionText = { - YES: 'YES', - NO: 'NO', - MKT: 'MKT', - CANCEL: 'N/A', - '': '', - }[contract.resolution || ''] - return ( <Link href={path(contract)}> <a> @@ -36,14 +22,11 @@ export function ContractCard(props: { contract: Contract }) { <p className="font-medium text-indigo-700"> <Linkify text={contract.question} /> </p> - <div className={clsx('text-4xl', resolutionColor)}> - {resolutionText || ( - <div className="text-primary"> - {probPercent} - <div className="text-lg">chance</div> - </div> - )} - </div> + <ResolutionOrChance + className="items-center" + resolution={resolution} + probPercent={probPercent} + /> </Row> <ContractDetails contract={contract} /> </div> @@ -54,6 +37,55 @@ export function ContractCard(props: { contract: Contract }) { ) } +export function ResolutionOrChance(props: { + resolution?: 'YES' | 'NO' | 'MKT' | 'CANCEL' + probPercent: string + large?: boolean + className?: string +}) { + const { resolution, probPercent, large, className } = props + + const resolutionColor = { + YES: 'text-primary', + NO: 'text-red-400', + MKT: 'text-blue-400', + CANCEL: 'text-yellow-400', + '': '', // Empty if unresolved + }[resolution || ''] + + const resolutionText = { + YES: 'YES', + NO: 'NO', + MKT: 'MKT', + CANCEL: 'N/A', + '': '', + }[resolution || ''] + + return ( + <Col className={clsx(large ? 'text-4xl' : 'text-3xl', className)}> + {resolution ? ( + <> + <div + className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')} + > + Resolved + </div> + <div className={resolutionColor}>{resolutionText}</div> + </> + ) : ( + <> + <div className="text-primary">{probPercent}</div> + <div + className={clsx('text-primary', large ? 'text-xl' : 'text-base')} + > + chance + </div> + </> + )} + </Col> + ) +} + export function ContractDetails(props: { contract: Contract }) { const { contract } = props const { truePool, createdDate, resolvedDate } = compute(contract) diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 3dc5230c..23f2e7fb 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -14,7 +14,7 @@ import { Row } from './layout/row' import dayjs from 'dayjs' import { Linkify } from './linkify' import clsx from 'clsx' -import { ContractDetails } from './contract-card' +import { ContractDetails, ResolutionOrChance } from './contract-card' function ContractDescription(props: { contract: Contract @@ -84,40 +84,6 @@ function ContractDescription(props: { ) } -function ResolutionOrChance(props: { - resolution?: 'YES' | 'NO' | 'MKT' | 'CANCEL' - probPercent: string - className?: string -}) { - const { resolution, probPercent, className } = props - - const resolutionColor = { - YES: 'text-primary', - NO: 'text-red-400', - MKT: 'text-blue-400', - CANCEL: 'text-yellow-400', - '': '', // Empty if unresolved - }[resolution || ''] - - return ( - <Col className={clsx('text-3xl md:text-4xl', className)}> - {resolution ? ( - <> - <div className="text-lg md:text-xl text-gray-500">Resolved</div> - <div className={resolutionColor}> - {resolution === 'CANCEL' ? 'N/A' : resolution} - </div> - </> - ) : ( - <> - <div className="text-primary">{probPercent}</div> - <div className="text-lg md:text-xl text-primary">chance</div> - </> - )} - </Col> - ) -} - export const ContractOverview = (props: { contract: Contract className?: string @@ -141,6 +107,7 @@ export const ContractOverview = (props: { className="md:hidden" resolution={resolution} probPercent={probPercent} + large /> <ContractDetails contract={contract} /> @@ -150,6 +117,7 @@ export const ContractOverview = (props: { className="hidden md:flex md:items-end" resolution={resolution} probPercent={probPercent} + large /> </Row> From bad7a2b54331df91e40f4fafe4c9d711db7df843 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Sun, 2 Jan 2022 16:46:04 -0600 Subject: [PATCH 35/81] Sort & query url params (#17) * Sort query in progress * Search and query url params! --- web/components/contracts-list.tsx | 47 ++++++++++++++----------- web/hooks/use-sort-and-query-params.tsx | 40 +++++++++++++++++++++ web/pages/landing-page.tsx | 10 +++++- web/pages/markets.tsx | 6 ++++ web/pages/tag/[tag].tsx | 13 ++++++- 5 files changed, 94 insertions(+), 22 deletions(-) create mode 100644 web/hooks/use-sort-and-query-params.tsx diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index 715a7ffe..69908523 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -3,17 +3,13 @@ import Link from 'next/link' import clsx from 'clsx' import { useEffect, useState } from 'react' -import { - compute, - Contract, - listContracts, - path, -} from '../lib/firebase/contracts' +import { compute, Contract, listContracts } from '../lib/firebase/contracts' import { User } from '../lib/firebase/users' import { Col } from './layout/col' import { SiteLink } from './site-link' import { parseTags } from '../lib/util/parse' import { ContractCard } from './contract-card' +import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' function ContractsGrid(props: { contracts: Contract[] }) { const [resolvedContracts, activeContracts] = _.partition( @@ -61,7 +57,7 @@ function CreatorContractsGrid(props: { contracts: Contract[] }) { const { creatorUsername, creatorName } = byCreator[creatorId][0] return ( - <Col className="gap-4"> + <Col className="gap-4" key={creatorUsername}> <SiteLink className="text-lg" href={`/${creatorUsername}`}> {creatorName} </SiteLink> @@ -116,7 +112,7 @@ function TagContractsGrid(props: { contracts: Contract[] }) { <Col className="gap-6"> {tags.map((tag) => { return ( - <Col className="gap-4"> + <Col className="gap-4" key={tag}> <SiteLink className="text-lg" href={`/tag/${tag}`}> #{tag} </SiteLink> @@ -152,17 +148,15 @@ function TagContractsGrid(props: { contracts: Contract[] }) { const MAX_CONTRACTS_DISPLAYED = 99 -type Sort = 'creator' | 'tag' | 'createdTime' | 'pool' | 'resolved' | 'all' export function SearchableGrid(props: { contracts: Contract[] - defaultSort?: Sort + query: string + setQuery: (query: string) => void + sort: Sort + setSort: (sort: Sort) => void byOneCreator?: boolean }) { - const { contracts, defaultSort, byOneCreator } = props - const [query, setQuery] = useState('') - const [sort, setSort] = useState( - defaultSort || (byOneCreator ? 'pool' : 'creator') - ) + const { contracts, query, setQuery, sort, setSort, byOneCreator } = props function check(corpus: String) { return corpus.toLowerCase().includes(query.toLowerCase()) @@ -175,9 +169,9 @@ export function SearchableGrid(props: { check(c.creatorUsername) ) - if (sort === 'createdTime' || sort === 'resolved' || sort === 'all') { + if (sort === 'newest' || sort === 'resolved' || sort === 'all') { matches.sort((a, b) => b.createdTime - a.createdTime) - } else if (sort === 'pool' || sort === 'creator' || sort === 'tag') { + } else if (sort === 'most-traded' || sort === 'creator' || sort === 'tag') { matches.sort((a, b) => compute(b).truePool - compute(a).truePool) } @@ -213,8 +207,8 @@ export function SearchableGrid(props: { <option value="creator">By creator</option> )} <option value="tag">By tag</option> - <option value="pool">Most traded</option> - <option value="createdTime">Newest first</option> + <option value="most-traded">Most traded</option> + <option value="newest">Newest</option> <option value="resolved">Resolved</option> </select> </div> @@ -234,6 +228,10 @@ export function CreatorContractsList(props: { creator: User }) { const { creator } = props const [contracts, setContracts] = useState<Contract[] | 'loading'>('loading') + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort: 'all', + }) + useEffect(() => { if (creator?.id) { // TODO: stream changes from firestore @@ -243,5 +241,14 @@ export function CreatorContractsList(props: { creator: User }) { if (contracts === 'loading') return <></> - return <SearchableGrid contracts={contracts} byOneCreator defaultSort="all" /> + return ( + <SearchableGrid + contracts={contracts} + byOneCreator + query={query} + setQuery={setQuery} + sort={sort} + setSort={setSort} + /> + ) } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx new file mode 100644 index 00000000..ee0db506 --- /dev/null +++ b/web/hooks/use-sort-and-query-params.tsx @@ -0,0 +1,40 @@ +import { useRouter } from 'next/router' + +export type Sort = + | 'creator' + | 'tag' + | 'newest' + | 'most-traded' + | 'resolved' + | 'all' + +export function useQueryAndSortParams(options?: { defaultSort: Sort }) { + const router = useRouter() + + const { s: sort, q: query } = router.query as { + q?: string + s?: Sort + } + + const setSort = (sort: Sort | undefined) => { + router.query.s = sort + router.push(router, undefined, { shallow: true }) + } + + const setQuery = (query: string | undefined) => { + if (query) { + router.query.q = query + } else { + delete router.query.q + } + + router.push(router, undefined, { shallow: true }) + } + + return { + sort: sort ?? options?.defaultSort ?? 'creator', + query: query ?? '', + setSort, + setQuery, + } +} diff --git a/web/pages/landing-page.tsx b/web/pages/landing-page.tsx index f983a6ac..13d209b6 100644 --- a/web/pages/landing-page.tsx +++ b/web/pages/landing-page.tsx @@ -13,6 +13,7 @@ import { SearchableGrid } from '../components/contracts-list' import { Col } from '../components/layout/col' import { NavBar } from '../components/nav-bar' import Link from 'next/link' +import { useQueryAndSortParams } from '../hooks/use-sort-and-query-params' export default function LandingPage() { return ( @@ -159,13 +160,20 @@ function FeaturesSection() { function ExploreMarketsSection() { const contracts = useContracts() + const { query, setQuery, sort, setSort } = useQueryAndSortParams() return ( <div className="max-w-4xl px-4 py-8 mx-auto"> <p className="my-12 text-3xl leading-8 font-extrabold tracking-tight text-indigo-700 sm:text-4xl"> Explore our markets </p> - <SearchableGrid contracts={contracts === 'loading' ? [] : contracts} /> + <SearchableGrid + contracts={contracts === 'loading' ? [] : contracts} + query={query} + setQuery={setQuery} + sort={sort} + setSort={setSort} + /> </div> ) } diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index f14a1906..96f3c7a4 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -1,6 +1,7 @@ import { SearchableGrid } from '../components/contracts-list' import { Page } from '../components/page' import { useContracts } from '../hooks/use-contracts' +import { useQueryAndSortParams } from '../hooks/use-sort-and-query-params' import { Contract, listAllContracts } from '../lib/firebase/contracts' export async function getStaticProps() { @@ -17,12 +18,17 @@ export async function getStaticProps() { export default function Markets(props: { contracts: Contract[] }) { const contracts = useContracts() + const { query, setQuery, sort, setSort } = useQueryAndSortParams() return ( <Page> {(props.contracts || contracts !== 'loading') && ( <SearchableGrid contracts={contracts === 'loading' ? props.contracts : contracts} + query={query} + setQuery={setQuery} + sort={sort} + setSort={setSort} /> )} </Page> diff --git a/web/pages/tag/[tag].tsx b/web/pages/tag/[tag].tsx index 7f15748a..ae56f1c5 100644 --- a/web/pages/tag/[tag].tsx +++ b/web/pages/tag/[tag].tsx @@ -3,6 +3,7 @@ import { SearchableGrid } from '../../components/contracts-list' import { Page } from '../../components/page' import { Title } from '../../components/title' import { useContracts } from '../../hooks/use-contracts' +import { useQueryAndSortParams } from '../../hooks/use-sort-and-query-params' export default function TagPage() { const router = useRouter() @@ -18,13 +19,23 @@ export default function TagPage() { ) } + const { query, setQuery, sort, setSort } = useQueryAndSortParams({ + defaultSort: 'most-traded', + }) + return ( <Page> <Title text={`#${tag}`} /> {contracts === 'loading' ? ( <></> ) : ( - <SearchableGrid contracts={contracts} /> + <SearchableGrid + contracts={contracts} + query={query} + setQuery={setQuery} + sort={sort} + setSort={setSort} + /> )} </Page> ) From 3ba96028f41ac8e9428e9881d78c6829d55ee85b Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Sun, 2 Jan 2022 16:53:20 -0600 Subject: [PATCH 36/81] Parse tags to get unique ignoring case --- web/lib/util/parse.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web/lib/util/parse.ts b/web/lib/util/parse.ts index 2857b7a1..4b20778f 100644 --- a/web/lib/util/parse.ts +++ b/web/lib/util/parse.ts @@ -1,8 +1,9 @@ +import _ from 'lodash' + export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi - const matches = text.match(regex) || [] - return matches.map((match) => { - const tag = match.trim().substring(1) - return tag - }) + const matches = (text.match(regex) || []).map((match) => + match.trim().substring(1) + ) + return _.uniqBy(matches, (tag) => tag.toLowerCase()) } From fb0e16d6196e88e02c3aae5463894e027c877a5f Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Sun, 2 Jan 2022 21:21:25 -0800 Subject: [PATCH 37/81] Add a closing date to Create Market (#10) * Preview a slimmed-down version of /Create * Rework dropdown to be on bottom * Parse the close time as just before midnight * Prevent invalid contracts from being created * Prevent trading after contract has closed --- web/components/contract-overview.tsx | 18 +++ web/lib/service/create-contract.ts | 6 +- web/pages/[username]/[contractSlug].tsx | 12 +- web/pages/create.tsx | 160 +++++++++++++++++------- 4 files changed, 144 insertions(+), 52 deletions(-) diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 23f2e7fb..114ba9b9 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -16,6 +16,19 @@ import { Linkify } from './linkify' import clsx from 'clsx' import { ContractDetails, ResolutionOrChance } from './contract-card' +function ContractCloseTime(props: { contract: Contract }) { + const closeTime = props.contract.closeTime + if (!closeTime) { + return null + } + return ( + <div className="text-gray-500 text-sm"> + Trading {closeTime > Date.now() ? 'closes' : 'closed'} at{' '} + {dayjs(closeTime).format('MMM D, h:mma')} + </div> + ) +} + function ContractDescription(props: { contract: Contract isCreator: boolean @@ -127,9 +140,14 @@ export const ContractOverview = (props: { <Spacer h={12} /> + <ContractCloseTime contract={contract} /> + + <Spacer h={4} /> + {((isCreator && !contract.resolution) || contract.description) && ( <label className="text-gray-500 mb-2 text-sm">Description</label> )} + <ContractDescription contract={contract} isCreator={isCreator} /> {/* Show a delete button for contracts without any trading */} diff --git a/web/lib/service/create-contract.ts b/web/lib/service/create-contract.ts index 966115b0..7c7a59df 100644 --- a/web/lib/service/create-contract.ts +++ b/web/lib/service/create-contract.ts @@ -12,7 +12,8 @@ export async function createContract( question: string, description: string, initialProb: number, - creator: User + creator: User, + closeTime?: number ) { const proposedSlug = slugify(question).substring(0, 35) @@ -45,6 +46,9 @@ export async function createContract( createdTime: Date.now(), lastUpdatedTime: Date.now(), } + if (closeTime) { + contract.closeTime = closeTime + } return await pushNewContract(contract) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index fa9bbac6..8b824c44 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -53,6 +53,9 @@ export default function ContractPage(props: { const { creatorId, isResolved, resolution, question } = contract const isCreator = user?.id === creatorId + const allowTrade = + !isResolved && (!contract.closeTime || contract.closeTime > Date.now()) + const allowResolve = !isResolved && isCreator && user const { probPercent } = compute(contract) @@ -61,7 +64,7 @@ export default function ContractPage(props: { : `${probPercent} chance. ${contract.description}` return ( - <Page wide={!isResolved}> + <Page wide={allowTrade}> <SEO title={question} description={description} @@ -74,14 +77,13 @@ export default function ContractPage(props: { <BetsSection contract={contract} user={user ?? null} /> </div> - {!isResolved && ( + {(allowTrade || allowResolve) && ( <> <div className="md:ml-8" /> <Col className="flex-1"> - <BetPanel contract={contract} /> - - {isCreator && user && ( + {allowTrade && <BetPanel contract={contract} />} + {allowResolve && ( <ResolutionPanel creator={user} contract={contract} /> )} </Col> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 5806f9ec..6ad2bcb1 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -8,6 +8,9 @@ import { useUser } from '../hooks/use-user' import { path } from '../lib/firebase/contracts' import { createContract } from '../lib/service/create-contract' import { Page } from '../components/page' +import { Row } from '../components/layout/row' +import clsx from 'clsx' +import dayjs from 'dayjs' // Allow user to create a new contract export default function NewContract() { @@ -20,11 +23,33 @@ export default function NewContract() { const [initialProb, setInitialProb] = useState(50) const [question, setQuestion] = useState('') const [description, setDescription] = useState('') + const [closeDate, setCloseDate] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [collapsed, setCollapsed] = useState(true) + + // Given a date string like '2022-04-02', + // return the time just before midnight on that date (in the user's local time), as millis since epoch + function dateToMillis(date: string) { + return dayjs(date) + .set('hour', 23) + .set('minute', 59) + .set('second', 59) + .valueOf() + } + const closeTime = dateToMillis(closeDate) + // We'd like this to look like "Apr 2, 2022, 23:59:59 PM PT" but timezones are hard with dayjs + const formattedCloseTime = new Date(closeTime).toString() + + const isValid = + initialProb > 0 && + initialProb < 100 && + question.length > 0 && + // If set, closeTime must be in the future + (!closeDate || closeTime > Date.now()) async function submit() { - // TODO: add more rigorous error handling for question - if (!creator || !question) return + // TODO: Tell users why their contract is invalid + if (!creator || !isValid) return setIsSubmitting(true) @@ -32,7 +57,8 @@ export default function NewContract() { question, description, initialProb, - creator + creator, + closeTime ) await router.push(path(contract)) } @@ -49,52 +75,94 @@ export default function NewContract() { {/* Create a Tailwind form that takes in all the fields needed for a new contract */} {/* When the form is submitted, create a new contract in the database */} <form> - <div className="form-control"> - <label className="label"> - <span className="label-text">Question</span> - </label> + <div className="flex justify-between gap-4 items-center"> + <div className="form-control w-full"> + <label className="label"> + <span className="label-text">Prediction</span> + </label> - <input - type="text" - placeholder="e.g. Will the FDA approve Paxlovid before Jun 2nd, 2022?" - className="input input-bordered" - value={question} - onChange={(e) => setQuestion(e.target.value || '')} - /> + <input + type="text" + placeholder="e.g. The FDA will approve Paxlovid before Jun 2nd, 2022" + className="input input-bordered" + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + </div> + + <div className="form-control"> + <label className="label"> + <span className="label-text">Chance</span> + </label> + <label className="input-group input-group-md w-fit"> + <input + type="number" + value={initialProb} + className="input input-bordered input-md" + min={1} + max={99} + onChange={(e) => setInitialProb(parseInt(e.target.value))} + /> + <span>%</span> + </label> + </div> </div> - <Spacer h={4} /> + {/* Collapsible "Advanced" section */} + <div + tabIndex={0} + className={clsx( + 'cursor-pointer relative collapse collapse-arrow', + collapsed ? 'collapse-close' : 'collapse-open' + )} + > + <div + className="mt-4 mr-6 text-sm text-gray-400 text-right" + onClick={() => setCollapsed((collapsed) => !collapsed)} + > + Advanced + </div> + <Row> + <div + className="collapse-title p-0 absolute w-0 h-0 min-h-0" + style={{ top: -2, right: -15, color: '#9ca3af' /* gray-400 */ }} + /> + </Row> + <div className="collapse-content !p-0 m-0 !bg-transparent"> + <div className="form-control"> + <label className="label"> + <span className="label-text">Description (optional)</span> + </label> + <textarea + className="textarea w-full h-24 textarea-bordered" + placeholder={descriptionPlaceholder} + value={description} + onClick={(e) => e.stopPropagation()} + onChange={(e) => setDescription(e.target.value || '')} + ></textarea> + </div> - <div className="form-control"> - <label className="label"> - <span className="label-text">Description (optional)</span> - </label> - - <textarea - className="textarea h-24 textarea-bordered" - placeholder={descriptionPlaceholder} - value={description} - onChange={(e) => setDescription(e.target.value || '')} - ></textarea> - </div> - - <Spacer h={4} /> - - <div className="form-control"> - <label className="label"> - <span className="label-text"> - Initial probability: {initialProb}% - </span> - </label> - - <input - type="range" - className="range range-lg range-primary" - min="1" - max={99} - value={initialProb} - onChange={(e) => setInitialProb(parseInt(e.target.value))} - /> + <div className="form-control"> + <label className="label"> + <span className="label-text">Close date (optional)</span> + </label> + <input + type="date" + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setCloseDate(e.target.value || '')} + min={new Date().toISOString().split('T')[0]} + value={closeDate} + /> + </div> + <label> + {closeDate && ( + <span className="label-text text-gray-400 ml-1"> + No new trades will be allowed after {formattedCloseTime} + </span> + )} + </label> + </div> </div> <Spacer h={4} /> @@ -103,7 +171,7 @@ export default function NewContract() { <button type="submit" className="btn btn-primary" - disabled={isSubmitting || !question} + disabled={isSubmitting || !isValid} onClick={(e) => { e.preventDefault() submit() From f7d4926d224740122a744c8c6c4978a2cd853915 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Mon, 3 Jan 2022 00:57:22 -0600 Subject: [PATCH 38/81] Update to show sale price in column --- web/components/bets-list.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 29d51346..0d0f588f 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -258,7 +258,7 @@ export function ContractBetsTable(props: { <th>Amount</th> <th>Probability</th> <th>Shares</th> - <th>{isResolved ? <>Payout</> : <>Current value</>}</th> + <th>{isResolved ? <>Payout</> : <>Sale price</>}</th> <th></th> </tr> </thead> @@ -309,7 +309,7 @@ function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { ? resolvedPayout(contract, bet) : bet.sale ? bet.sale.amount ?? 0 - : currentValue(contract, bet) + : calculateSaleAmount(contract, bet) )} </td> @@ -347,9 +347,11 @@ function SellButton(props: { contract: Contract; bet: Bet }) { setIsSubmitting(false) }} > - <div className="text-2xl mb-4">Sell</div> + <div className="text-2xl mb-4"> + Sell <OutcomeLabel outcome={bet.outcome} /> + </div> <div> - Do you want to sell your {formatMoney(bet.amount)} position on{' '} + Do you want to sell {formatWithCommas(bet.shares)} shares of{' '} <OutcomeLabel outcome={bet.outcome} /> for{' '} {formatMoney(calculateSaleAmount(contract, bet))}? </div> From a331faa1a76b1d2dc72fbc7176122236b20968d9 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Mon, 3 Jan 2022 12:39:44 -0600 Subject: [PATCH 39/81] Create page: Chance => Initial probabilty, description out of advanced, advanced arrow clickable --- web/pages/create.tsx | 102 ++++++++++++++++++++++--------------------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 6ad2bcb1..0601c484 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -8,7 +8,6 @@ import { useUser } from '../hooks/use-user' import { path } from '../lib/firebase/contracts' import { createContract } from '../lib/service/create-contract' import { Page } from '../components/page' -import { Row } from '../components/layout/row' import clsx from 'clsx' import dayjs from 'dayjs' @@ -75,37 +74,52 @@ export default function NewContract() { {/* Create a Tailwind form that takes in all the fields needed for a new contract */} {/* When the form is submitted, create a new contract in the database */} <form> - <div className="flex justify-between gap-4 items-center"> - <div className="form-control w-full"> - <label className="label"> - <span className="label-text">Prediction</span> - </label> + <div className="form-control w-full"> + <label className="label"> + <span className="label-text">Prediction</span> + </label> + <input + type="text" + placeholder="e.g. The FDA will approve Paxlovid before Jun 2nd, 2022" + className="input input-bordered" + value={question} + onChange={(e) => setQuestion(e.target.value || '')} + /> + </div> + + <Spacer h={4} /> + + <div className="form-control"> + <label className="label"> + <span className="label-text">Initial probability</span> + </label> + <label className="input-group input-group-md w-fit"> <input - type="text" - placeholder="e.g. The FDA will approve Paxlovid before Jun 2nd, 2022" - className="input input-bordered" - value={question} - onChange={(e) => setQuestion(e.target.value || '')} + type="number" + value={initialProb} + className="input input-bordered input-md" + min={1} + max={99} + onChange={(e) => setInitialProb(parseInt(e.target.value))} /> - </div> + <span>%</span> + </label> + </div> - <div className="form-control"> - <label className="label"> - <span className="label-text">Chance</span> - </label> - <label className="input-group input-group-md w-fit"> - <input - type="number" - value={initialProb} - className="input input-bordered input-md" - min={1} - max={99} - onChange={(e) => setInitialProb(parseInt(e.target.value))} - /> - <span>%</span> - </label> - </div> + <Spacer h={4} /> + + <div className="form-control"> + <label className="label"> + <span className="label-text">Description (optional)</span> + </label> + <textarea + className="textarea w-full h-24 textarea-bordered" + placeholder={descriptionPlaceholder} + value={description} + onClick={(e) => e.stopPropagation()} + onChange={(e) => setDescription(e.target.value || '')} + /> </div> {/* Collapsible "Advanced" section */} @@ -116,32 +130,20 @@ export default function NewContract() { collapsed ? 'collapse-close' : 'collapse-open' )} > - <div - className="mt-4 mr-6 text-sm text-gray-400 text-right" - onClick={() => setCollapsed((collapsed) => !collapsed)} - > - Advanced - </div> - <Row> + <div onClick={() => setCollapsed((collapsed) => !collapsed)}> + <div className="mt-4 mr-6 text-sm text-gray-400 text-right"> + Advanced + </div> <div className="collapse-title p-0 absolute w-0 h-0 min-h-0" - style={{ top: -2, right: -15, color: '#9ca3af' /* gray-400 */ }} + style={{ + top: -2, + right: -15, + color: '#9ca3af' /* gray-400 */, + }} /> - </Row> + </div> <div className="collapse-content !p-0 m-0 !bg-transparent"> - <div className="form-control"> - <label className="label"> - <span className="label-text">Description (optional)</span> - </label> - <textarea - className="textarea w-full h-24 textarea-bordered" - placeholder={descriptionPlaceholder} - value={description} - onClick={(e) => e.stopPropagation()} - onChange={(e) => setDescription(e.target.value || '')} - ></textarea> - </div> - <div className="form-control"> <label className="label"> <span className="label-text">Close date (optional)</span> From 24e873b6de92716583113f07de294d193e187b0e Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Mon, 3 Jan 2022 12:56:02 -0600 Subject: [PATCH 40/81] Show "sold for" under sale price / payout column. Outcome label to new file --- web/components/bets-list.tsx | 57 ++++++++------------------------ web/components/outcome-label.tsx | 26 +++++++++++++++ 2 files changed, 40 insertions(+), 43 deletions(-) create mode 100644 web/components/outcome-label.tsx diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 0d0f588f..c7f8cc86 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -24,6 +24,7 @@ import { import clsx from 'clsx' import { cloudFunction } from '../lib/firebase/api-call' import { ConfirmationButton } from './confirmation-button' +import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' export function BetsList(props: { user: User }) { const { user } = props @@ -302,26 +303,21 @@ function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { </td> <td>{formatWithCommas(shares)}</td> <td> - {bet.isSold - ? 'N/A' - : formatMoney( - isResolved - ? resolvedPayout(contract, bet) - : bet.sale - ? bet.sale.amount ?? 0 - : calculateSaleAmount(contract, bet) - )} + {sale ? ( + <>SOLD for {formatMoney(Math.abs(sale.amount))}</> + ) : ( + formatMoney( + isResolved + ? resolvedPayout(contract, bet) + : calculateSaleAmount(contract, bet) + ) + )} </td> - {sale ? ( - <td>SOLD for {formatMoney(Math.abs(sale.amount))}</td> - ) : ( - !isResolved && - !isSold && ( - <td className="text-neutral"> - <SellButton contract={contract} bet={bet} /> - </td> - ) + {!isResolved && !isSold && ( + <td className="text-neutral"> + <SellButton contract={contract} bet={bet} /> + </td> )} </tr> ) @@ -358,28 +354,3 @@ function SellButton(props: { contract: Contract; bet: Bet }) { </ConfirmationButton> ) } - -function OutcomeLabel(props: { outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' }) { - const { outcome } = props - - if (outcome === 'YES') return <YesLabel /> - if (outcome === 'NO') return <NoLabel /> - if (outcome === 'MKT') return <MarketLabel /> - return <CancelLabel /> -} - -function YesLabel() { - return <span className="text-primary">YES</span> -} - -function NoLabel() { - return <span className="text-red-400">NO</span> -} - -function CancelLabel() { - return <span className="text-yellow-400">N/A</span> -} - -function MarketLabel() { - return <span className="text-blue-400">MKT</span> -} diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx new file mode 100644 index 00000000..1eb8ac4f --- /dev/null +++ b/web/components/outcome-label.tsx @@ -0,0 +1,26 @@ +export function OutcomeLabel(props: { + outcome: 'YES' | 'NO' | 'CANCEL' | 'MKT' +}) { + const { outcome } = props + + if (outcome === 'YES') return <YesLabel /> + if (outcome === 'NO') return <NoLabel /> + if (outcome === 'MKT') return <MarketLabel /> + return <CancelLabel /> +} + +export function YesLabel() { + return <span className="text-primary">YES</span> +} + +export function NoLabel() { + return <span className="text-red-400">NO</span> +} + +export function CancelLabel() { + return <span className="text-yellow-400">N/A</span> +} + +export function MarketLabel() { + return <span className="text-blue-400">MKT</span> +} From 44f44272ff116c8e1ead3420c321904f22df0744 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 3 Jan 2022 13:00:24 -0600 Subject: [PATCH 41/81] create page: minor changes --- web/pages/create.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 0601c484..591e21c7 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -101,7 +101,9 @@ export default function NewContract() { className="input input-bordered input-md" min={1} max={99} - onChange={(e) => setInitialProb(parseInt(e.target.value))} + onChange={(e) => + setInitialProb(parseInt(e.target.value.substring(0, 2))) + } /> <span>%</span> </label> @@ -158,11 +160,10 @@ export default function NewContract() { /> </div> <label> - {closeDate && ( - <span className="label-text text-gray-400 ml-1"> - No new trades will be allowed after {formattedCloseTime} - </span> - )} + <span className="label-text text-gray-400 ml-1"> + No new trades will be allowed after{' '} + {closeDate ? formattedCloseTime : 'this time'} + </span> </label> </div> </div> From 4d0646a200928a9b4dded898bafd751ad16c38b7 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Mon, 3 Jan 2022 23:44:54 -0600 Subject: [PATCH 42/81] Add payout if MKT. Current value uses MKT payout. --- web/components/bets-list.tsx | 44 +++++++++++++++++-------- web/pages/[username]/[contractSlug].tsx | 2 +- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index c7f8cc86..58d9aaf9 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -18,13 +18,12 @@ import { UserLink } from './user-page' import { calculatePayout, calculateSaleAmount, - currentValue, resolvedPayout, } from '../lib/calculate' import clsx from 'clsx' import { cloudFunction } from '../lib/firebase/api-call' import { ConfirmationButton } from './confirmation-button' -import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' +import { OutcomeLabel, YesLabel, NoLabel, MarketLabel } from './outcome-label' export function BetsList(props: { user: User }) { const { user } = props @@ -84,7 +83,7 @@ export function BetsList(props: { user: User }) { const currentBetsValue = _.sumBy(unresolved, (contract) => _.sumBy(contractBets[contract.id], (bet) => { if (bet.isSold || bet.sale) return 0 - return currentValue(contract, bet) + return calculatePayout(contract, bet, 'MKT') }) ) @@ -183,9 +182,10 @@ function MyContractBets(props: { contract: Contract; bets: Bet[] }) { export function MyBetsSummary(props: { contract: Contract bets: Bet[] + showMKT?: boolean className?: string }) { - const { bets, contract, className } = props + const { bets, contract, showMKT, className } = props const { resolution } = contract const excludeSales = bets.filter((b) => !b.isSold && !b.sale) @@ -202,21 +202,29 @@ export function MyBetsSummary(props: { calculatePayout(contract, bet, 'NO') ) + const marketWinnings = _.sumBy(excludeSales, (bet) => + calculatePayout(contract, bet, 'MKT') + ) + return ( - <Row className={clsx('gap-4 sm:gap-6', className)}> + <Row + className={clsx( + 'gap-4 sm:gap-6', + showMKT && 'flex-wrap sm:flex-nowrap', + className + )} + > <Col> <div className="text-sm text-gray-500 whitespace-nowrap">Invested</div> <div className="whitespace-nowrap">{formatMoney(betsTotal)}</div> </Col> {resolution ? ( - <> - <Col> - <div className="text-sm text-gray-500">Payout</div> - <div className="whitespace-nowrap">{formatMoney(betsPayout)}</div> - </Col> - </> + <Col> + <div className="text-sm text-gray-500">Payout</div> + <div className="whitespace-nowrap">{formatMoney(betsPayout)}</div> + </Col> ) : ( - <> + <Row className="gap-4 sm:gap-6"> <Col> <div className="text-sm text-gray-500 whitespace-nowrap"> Payout if <YesLabel /> @@ -229,7 +237,17 @@ export function MyBetsSummary(props: { </div> <div className="whitespace-nowrap">{formatMoney(noWinnings)}</div> </Col> - </> + {showMKT && ( + <Col> + <div className="text-sm text-gray-500 whitespace-nowrap"> + Payout if <MarketLabel /> + </div> + <div className="whitespace-nowrap"> + {formatMoney(marketWinnings)} + </div> + </Col> + )} + </Row> )} </Row> ) diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 8b824c44..f467578a 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -110,7 +110,7 @@ function BetsSection(props: { contract: Contract; user: User | null }) { return ( <div> <Title text="Your trades" /> - <MyBetsSummary contract={contract} bets={userBets} /> + <MyBetsSummary contract={contract} bets={userBets} showMKT /> <Spacer h={6} /> <ContractBetsTable contract={contract} bets={userBets} /> <Spacer h={12} /> From 7d5b4359f864dba92a2f71f40a36b4e18be9ce4d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 3 Jan 2022 23:05:41 -0600 Subject: [PATCH 43/81] align sales price --- web/components/bets-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 58d9aaf9..3cbf4ace 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -322,7 +322,7 @@ function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { <td>{formatWithCommas(shares)}</td> <td> {sale ? ( - <>SOLD for {formatMoney(Math.abs(sale.amount))}</> + <>{formatMoney(Math.abs(sale.amount))} (sold)</> ) : ( formatMoney( isResolved From f0e045694a0a57ca6880d8b87b84eb365c73b550 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 3 Jan 2022 23:56:34 -0600 Subject: [PATCH 44/81] update about page with more info about basic betting mechanics --- web/pages/about.tsx | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 54aa9453..fd810ed6 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -217,17 +217,35 @@ function Contents() { judgment on the outcome, it leads to a qualitative shift in the number, variety, and usefulness of prediction markets. </p> - <h3 id="how-does-betting-in-a-market-work-on-a-technical-level-"> - How does betting in a market work on a technical level? - </h3> + + <h3 id="how-does-betting-work">How does betting work?</h3> + <ul> + <li> + Markets are structured around a question with a binary outcome (either + YES or NO) + </li> + <li> + Traders can place a bet on either YES or NO. The bet amount is added + to the corresponding bet pool for the outcome. The trader receives + some shares of the final pool. The number of shares depends on the + current implied probability. + </li> + <li> + When the market is resolved, the traders who bet on the correct + outcome are paid out of the total pool (YES pool + NO pool) in + proportion to the amount of shares they own, minus any fees. + </li> + </ul> + + <h3 id="type-of-market-maker">What kind of betting system do you use?</h3> <p> Mantic Markets uses a special type of automated market marker based on a dynamic pari-mutuel (DPM) betting system. </p> <p> Like traditional pari-mutuel systems, your payoff is not known at the - time you place your bet (it's dependent on the size of the pot when - the event ends). + time you place your bet (it's dependent on the size of the pool when + the event is resolved). </p> <p> Unlike traditional pari-mutuel systems, the price or probability that @@ -238,6 +256,7 @@ function Contents() { The result is a market that can function well when trading volume is low without any risk to the market creator. </p> + <h3 id="who-are-we-">Who are we?</h3> <p>Mantic Markets is currently a team of three:</p> <ul> @@ -272,9 +291,16 @@ function Contents() { Join the Mantic Markets Discord Server! </a> </p> + <h1 id="further-reading">Further Reading</h1> <hr /> + <ul> + <li> + <a href="https://mantic.notion.site/Technical-Overview-b9b48a09ea1f45b88d991231171730c5"> + Technical Overview of Mantic Markets + </a> + </li> <li> <a href="https://en.wikipedia.org/wiki/Prediction_market"> Wikipedia: Prediction markets From 07ce27f20b9105699180be40ed87c654fde340e3 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 3 Jan 2022 23:21:14 -0800 Subject: [PATCH 45/81] Show activity feed on each market & allow comments on your bets (#18) * Copy feed template from TailwindUI * Show all bets in a feed-like manner * Tweak design of individual trades * Allow traders to comment on their bets * Code cleanups * Incorporate contract description into the feed * Support description editing from contract feed * Group together bets placed within 24h * Fix build error * Add a feed item for market resolution * Add a feed item for markets that have closed * Comment on a separate subcollection --- web/components/contract-feed.tsx | 472 +++++++++++++++++++++++++++ web/components/contract-overview.tsx | 89 +---- web/hooks/use-comments.ts | 12 + web/lib/firebase/bets.ts | 1 + web/lib/firebase/comments.ts | 60 ++++ 5 files changed, 551 insertions(+), 83 deletions(-) create mode 100644 web/components/contract-feed.tsx create mode 100644 web/hooks/use-comments.ts create mode 100644 web/lib/firebase/comments.ts diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx new file mode 100644 index 00000000..49671d4c --- /dev/null +++ b/web/components/contract-feed.tsx @@ -0,0 +1,472 @@ +// From https://tailwindui.com/components/application-ui/lists/feeds +import { useState } from 'react' +import { + BanIcon, + ChatAltIcon, + CheckIcon, + LockClosedIcon, + StarIcon, + ThumbDownIcon, + ThumbUpIcon, + UserIcon, + UsersIcon, + XIcon, +} from '@heroicons/react/solid' +import { useBets } from '../hooks/use-bets' +import { Bet } from '../lib/firebase/bets' +import { Comment, mapCommentsByBetId } from '../lib/firebase/comments' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { OutcomeLabel } from './outcome-label' +import { Contract, setContract } from '../lib/firebase/contracts' +import { useUser } from '../hooks/use-user' +import { Linkify } from './linkify' +import { Row } from './layout/row' +import { createComment } from '../lib/firebase/comments' +import { useComments } from '../hooks/use-comments' +dayjs.extend(relativeTime) + +function FeedComment(props: { activityItem: any }) { + const { activityItem } = props + const { person, text, amount, outcome, createdTime } = activityItem + return ( + <> + <div className="relative"> + <img + className="h-10 w-10 rounded-full bg-gray-400 flex items-center justify-center ring-8 ring-gray-50" + src={person.avatarUrl} + alt="" + /> + + <span className="absolute -bottom-3 -right-2 bg-gray-50 rounded-tl px-0.5 py-px"> + <ChatAltIcon className="h-5 w-5 text-gray-400" aria-hidden="true" /> + </span> + </div> + <div className="min-w-0 flex-1"> + <div> + <p className="mt-0.5 text-sm text-gray-500"> + <a href={person.href} className="font-medium text-gray-900"> + {person.name} + </a>{' '} + placed M$ {amount} on <OutcomeLabel outcome={outcome} />{' '} + <Timestamp time={createdTime} /> + </p> + </div> + <div className="mt-2 text-gray-700"> + <p className="whitespace-pre-wrap"> + <Linkify text={text} /> + </p> + </div> + </div> + </> + ) +} + +function Timestamp(props: { time: number }) { + const { time } = props + return ( + <span + className="whitespace-nowrap text-gray-300" + title={dayjs(time).format('MMM D, h:mma')} + > + {dayjs(time).fromNow()} + </span> + ) +} + +function FeedBet(props: { activityItem: any }) { + const { activityItem } = props + const { id, contractId, amount, outcome, createdTime } = activityItem + const user = useUser() + const isCreator = user?.id == activityItem.userId + + const [comment, setComment] = useState('') + async function submitComment() { + if (!user || !comment) return + await createComment(contractId, id, comment, user) + } + return ( + <> + <div> + <div className="relative px-1"> + <div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center"> + <UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> + </div> + </div> + </div> + <div className="min-w-0 flex-1 py-1.5"> + <div className="text-sm text-gray-500"> + <span className="text-gray-900"> + {isCreator ? 'You' : 'A trader'} + </span>{' '} + placed M$ {amount} on <OutcomeLabel outcome={outcome} />{' '} + <Timestamp time={createdTime} /> + {isCreator && ( + // Allow user to comment in an textarea if they are the creator + <div className="mt-2"> + <textarea + value={comment} + onChange={(e) => setComment(e.target.value)} + className="textarea textarea-bordered w-full" + placeholder="Add a comment..." + /> + <button + className="btn btn-outline btn-sm mt-1" + onClick={submitComment} + > + Comment + </button> + </div> + )} + </div> + </div> + </> + ) +} + +export function ContractDescription(props: { + contract: Contract + isCreator: boolean +}) { + const { contract, isCreator } = props + const [editing, setEditing] = useState(false) + const editStatement = () => `${dayjs().format('MMM D, h:mma')}: ` + const [description, setDescription] = useState(editStatement()) + + // Append the new description (after a newline) + async function saveDescription(e: any) { + e.preventDefault() + setEditing(false) + contract.description = `${contract.description}\n${description}`.trim() + await setContract(contract) + setDescription(editStatement()) + } + + return ( + <div className="whitespace-pre-line break-words mt-2 text-gray-700"> + <Linkify text={contract.description} /> + <br /> + {isCreator && + !contract.resolution && + (editing ? ( + <form className="mt-4"> + <textarea + className="textarea h-24 textarea-bordered w-full mb-1" + value={description} + onChange={(e) => setDescription(e.target.value || '')} + autoFocus + onFocus={(e) => + // Focus starts at end of description. + e.target.setSelectionRange( + description.length, + description.length + ) + } + /> + <Row className="gap-2"> + <button + className="btn btn-neutral btn-outline btn-sm" + onClick={saveDescription} + > + Save + </button> + <button + className="btn btn-error btn-outline btn-sm" + onClick={() => setEditing(false)} + > + Cancel + </button> + </Row> + </form> + ) : ( + <Row> + <button + className="btn btn-neutral btn-outline btn-sm mt-4" + onClick={() => setEditing(true)} + > + Add to description + </button> + </Row> + ))} + </div> + ) +} + +function FeedStart(props: { contract: Contract }) { + const { contract } = props + const user = useUser() + const isCreator = user?.id === contract.creatorId + + return ( + <> + <div> + <div className="relative px-1"> + <div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center"> + <StarIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> + </div> + </div> + </div> + <div className="min-w-0 flex-1 py-1.5"> + <div className="text-sm text-gray-500"> + <span className="text-gray-900">{contract.creatorName}</span> created + this market <Timestamp time={contract.createdTime} /> + </div> + <ContractDescription contract={contract} isCreator={isCreator} /> + </div> + </> + ) +} + +function OutcomeIcon(props: { outcome?: 'YES' | 'NO' | 'CANCEL' }) { + const { outcome } = props + switch (outcome) { + case 'YES': + return <CheckIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> + case 'NO': + return <XIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> + case 'CANCEL': + default: + return <BanIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> + } +} + +function FeedResolve(props: { contract: Contract }) { + const { contract } = props + const resolution = contract.resolution || 'CANCEL' + + return ( + <> + <div> + <div className="relative px-1"> + <div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center"> + <OutcomeIcon outcome={resolution} /> + </div> + </div> + </div> + <div className="min-w-0 flex-1 py-1.5"> + <div className="text-sm text-gray-500"> + <span className="text-gray-900">{contract.creatorName}</span> resolved + this market to <OutcomeLabel outcome={resolution} />{' '} + <Timestamp time={contract.resolutionTime || 0} /> + </div> + </div> + </> + ) +} + +function FeedClose(props: { contract: Contract }) { + const { contract } = props + + return ( + <> + <div> + <div className="relative px-1"> + <div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center"> + <LockClosedIcon + className="h-5 w-5 text-gray-500" + aria-hidden="true" + /> + </div> + </div> + </div> + <div className="min-w-0 flex-1 py-1.5"> + <div className="text-sm text-gray-500"> + Trading closed in this market{' '} + <Timestamp time={contract.closeTime || 0} /> + </div> + </div> + </> + ) +} + +function toFeedBet(bet: Bet) { + return { + id: bet.id, + contractId: bet.contractId, + userId: bet.userId, + type: 'bet', + amount: bet.amount, + outcome: bet.outcome, + createdTime: bet.createdTime, + date: dayjs(bet.createdTime).fromNow(), + } +} + +function toFeedComment(bet: Bet, comment: Comment) { + return { + id: bet.id, + contractId: bet.contractId, + userId: bet.userId, + type: 'comment', + amount: bet.amount, + outcome: bet.outcome, + createdTime: bet.createdTime, + date: dayjs(bet.createdTime).fromNow(), + + // Invariant: bet.comment exists + text: comment.text, + person: { + href: `/${comment.userUsername}`, + name: comment.userName, + avatarUrl: comment.userAvatarUrl, + }, + } +} + +// Group together bets that are: +// - Within 24h of the first in the group +// - Do not have a comment +// - Were not created by this user +// Return a list of ActivityItems +function group(bets: Bet[], comments: Comment[], userId?: string) { + const commentsMap = mapCommentsByBetId(comments) + const items: any[] = [] + let group: Bet[] = [] + + // Turn the current group into an ActivityItem + function pushGroup() { + if (group.length == 1) { + items.push(toActivityItem(group[0])) + } else if (group.length > 1) { + items.push({ type: 'betgroup', bets: [...group], id: group[0].id }) + } + group = [] + } + + function toActivityItem(bet: Bet) { + const comment = commentsMap[bet.id] + return comment ? toFeedComment(bet, comment) : toFeedBet(bet) + } + + for (const bet of bets) { + const isCreator = userId === bet.userId + + if (commentsMap[bet.id] || isCreator) { + pushGroup() + // Create a single item for this + items.push(toActivityItem(bet)) + } else { + if ( + group.length > 0 && + dayjs(bet.createdTime).diff(dayjs(group[0].createdTime), 'hour') > 24 + ) { + // More than 24h has passed; start a new group + pushGroup() + } + group.push(bet) + } + } + if (group.length > 0) { + pushGroup() + } + return items as ActivityItem[] +} + +// TODO: Make this expandable to show all grouped bets? +function FeedBetGroup(props: { activityItem: any }) { + const { activityItem } = props + const bets: Bet[] = activityItem.bets + + const yesAmount = bets + .filter((b) => b.outcome == 'YES') + .reduce((acc, bet) => acc + bet.amount, 0) + const yesSpan = yesAmount ? ( + <span> + M$ {yesAmount} on <OutcomeLabel outcome={'YES'} /> + </span> + ) : null + const noAmount = bets + .filter((b) => b.outcome == 'NO') + .reduce((acc, bet) => acc + bet.amount, 0) + const noSpan = noAmount ? ( + <span> + M$ {noAmount} on <OutcomeLabel outcome={'NO'} /> + </span> + ) : null + const traderCount = bets.length + const createdTime = bets[0].createdTime + + return ( + <> + <div> + <div className="relative px-1"> + <div className="h-8 w-8 bg-gray-200 rounded-full ring-8 ring-gray-50 flex items-center justify-center"> + <UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" /> + </div> + </div> + </div> + <div className="min-w-0 flex-1 py-1.5"> + <div className="text-sm text-gray-500"> + <span className="text-gray-900">{traderCount} traders</span> placed{' '} + {yesSpan} + {yesAmount && noAmount ? ' and ' : ''} + {noSpan} <Timestamp time={createdTime} /> + </div> + </div> + </> + ) +} + +// Missing feed items: +// - Bet sold? +type ActivityItem = { + id: string + type: 'bet' | 'comment' | 'start' | 'betgroup' | 'close' | 'resolve' +} + +export function ContractFeed(props: { contract: Contract }) { + const { contract } = props + const { id } = contract + const user = useUser() + + let bets = useBets(id) + if (bets === 'loading') bets = [] + + let comments = useComments(id) + if (comments === 'loading') comments = [] + + const allItems = [ + { type: 'start', id: 0 }, + ...group(bets, comments, user?.id), + ] + if (contract.closeTime) { + allItems.push({ type: 'close', id: `${contract.closeTime}` }) + } + if (contract.resolution) { + allItems.push({ type: 'resolve', id: `${contract.resolutionTime}` }) + } + + return ( + <div className="flow-root"> + <ul role="list" className="-mb-8"> + {allItems.map((activityItem, activityItemIdx) => ( + <li key={activityItem.id}> + <div className="relative pb-8"> + {activityItemIdx !== allItems.length - 1 ? ( + <span + className="absolute top-5 left-5 -ml-px h-full w-0.5 bg-gray-200" + aria-hidden="true" + /> + ) : null} + <div className="relative flex items-start space-x-3"> + {activityItem.type === 'start' ? ( + <FeedStart contract={contract} /> + ) : activityItem.type === 'comment' ? ( + <FeedComment activityItem={activityItem} /> + ) : activityItem.type === 'bet' ? ( + <FeedBet activityItem={activityItem} /> + ) : activityItem.type === 'betgroup' ? ( + <FeedBetGroup activityItem={activityItem} /> + ) : activityItem.type === 'close' ? ( + <FeedClose contract={contract} /> + ) : activityItem.type === 'resolve' ? ( + <FeedResolve contract={contract} /> + ) : null} + </div> + </div> + </li> + ))} + </ul> + </div> + ) +} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 114ba9b9..85dd97df 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -1,10 +1,4 @@ -import { useState } from 'react' -import { - compute, - Contract, - deleteContract, - setContract, -} from '../lib/firebase/contracts' +import { compute, Contract, deleteContract } from '../lib/firebase/contracts' import { Col } from './layout/col' import { Spacer } from './layout/spacer' import { ContractProbGraph } from './contract-prob-graph' @@ -15,6 +9,7 @@ import dayjs from 'dayjs' import { Linkify } from './linkify' import clsx from 'clsx' import { ContractDetails, ResolutionOrChance } from './contract-card' +import { ContractFeed } from './contract-feed' function ContractCloseTime(props: { contract: Contract }) { const closeTime = props.contract.closeTime @@ -29,74 +24,6 @@ function ContractCloseTime(props: { contract: Contract }) { ) } -function ContractDescription(props: { - contract: Contract - isCreator: boolean -}) { - const { contract, isCreator } = props - const [editing, setEditing] = useState(false) - const editStatement = () => `${dayjs().format('MMM D, h:mma')}: ` - const [description, setDescription] = useState(editStatement()) - - // Append the new description (after a newline) - async function saveDescription(e: any) { - e.preventDefault() - setEditing(false) - contract.description = `${contract.description}\n${description}`.trim() - await setContract(contract) - setDescription(editStatement()) - } - - return ( - <div className="whitespace-pre-line break-words"> - <Linkify text={contract.description} /> - <br /> - {isCreator && - !contract.resolution && - (editing ? ( - <form className="mt-4"> - <textarea - className="textarea h-24 textarea-bordered w-full mb-2" - value={description} - onChange={(e) => setDescription(e.target.value || '')} - autoFocus - onFocus={(e) => - // Focus starts at end of description. - e.target.setSelectionRange( - description.length, - description.length - ) - } - /> - <Row className="gap-4 justify-end"> - <button - className="btn btn-error btn-outline btn-sm mt-2" - onClick={() => setEditing(false)} - > - Cancel - </button> - <button - className="btn btn-neutral btn-outline btn-sm mt-2" - onClick={saveDescription} - > - Save - </button> - </Row> - </form> - ) : ( - <Row className="justify-end"> - <button - className="btn btn-neutral btn-outline btn-sm mt-4" - onClick={() => setEditing(true)} - > - Add to description - </button> - </Row> - ))} - </div> - ) -} - export const ContractOverview = (props: { contract: Contract className?: string @@ -142,14 +69,6 @@ export const ContractOverview = (props: { <ContractCloseTime contract={contract} /> - <Spacer h={4} /> - - {((isCreator && !contract.resolution) || contract.description) && ( - <label className="text-gray-500 mb-2 text-sm">Description</label> - )} - - <ContractDescription contract={contract} isCreator={isCreator} /> - {/* Show a delete button for contracts without any trading */} {isCreator && truePool === 0 && ( <> @@ -166,6 +85,10 @@ export const ContractOverview = (props: { </button> </> )} + + <Spacer h={4} /> + + <ContractFeed contract={contract} /> </Col> ) } diff --git a/web/hooks/use-comments.ts b/web/hooks/use-comments.ts new file mode 100644 index 00000000..0a9dead3 --- /dev/null +++ b/web/hooks/use-comments.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react' +import { Comment, listenForComments } from '../lib/firebase/comments' + +export const useComments = (contractId: string) => { + const [comments, setComments] = useState<Comment[] | 'loading'>('loading') + + useEffect(() => { + if (contractId) return listenForComments(contractId, setComments) + }, [contractId]) + + return comments +} diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index b09d8f8b..49d92e0e 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -22,6 +22,7 @@ export type Bet = { sale?: { amount: number // amount user makes from sale betId: string // id of bet being sold + // TODO: add sale time? } isSold?: boolean // true if this BUY bet has been sold diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts new file mode 100644 index 00000000..d46c6ee9 --- /dev/null +++ b/web/lib/firebase/comments.ts @@ -0,0 +1,60 @@ +import { doc, collection, onSnapshot, setDoc } from 'firebase/firestore' +import { db } from './init' +import { User } from './users' + +// Currently, comments are created after the bet, not atomically with the bet. +// They're uniquely identified by the pair contractId/betId. +export type Comment = { + contractId: string + betId: string + text: string + createdTime: number + // Denormalized, for rendering comments + userName?: string + userUsername?: string + userAvatarUrl?: string +} + +export async function createComment( + contractId: string, + betId: string, + text: string, + commenter: User +) { + const ref = doc(getCommentsCollection(contractId), betId) + return await setDoc(ref, { + contractId, + betId, + text, + createdTime: Date.now(), + userName: commenter.name, + userUsername: commenter.username, + userAvatarUrl: commenter.avatarUrl, + }) +} + +function getCommentsCollection(contractId: string) { + return collection(db, 'contracts', contractId, 'comments') +} + +export function listenForComments( + contractId: string, + setComments: (comments: Comment[]) => void +) { + return onSnapshot(getCommentsCollection(contractId), (snap) => { + const comments = snap.docs.map((doc) => doc.data() as Comment) + + comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + + setComments(comments) + }) +} + +// Return a map of betId -> comment +export function mapCommentsByBetId(comments: Comment[]) { + const map: Record<string, Comment> = {} + for (const comment of comments) { + map[comment.betId] = comment + } + return map +} From 583dc10e6ac0668442da46e0a8cdabda50076767 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Mon, 3 Jan 2022 23:41:52 -0800 Subject: [PATCH 46/81] Don't prematurely show close times in feed --- web/components/contract-feed.tsx | 2 +- web/components/contract-overview.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 49671d4c..c663fa46 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -429,7 +429,7 @@ export function ContractFeed(props: { contract: Contract }) { { type: 'start', id: 0 }, ...group(bets, comments, user?.id), ] - if (contract.closeTime) { + if (contract.closeTime && contract.closeTime <= Date.now()) { allItems.push({ type: 'close', id: `${contract.closeTime}` }) } if (contract.resolution) { diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 85dd97df..72fa3dff 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -67,8 +67,6 @@ export const ContractOverview = (props: { <Spacer h={12} /> - <ContractCloseTime contract={contract} /> - {/* Show a delete button for contracts without any trading */} {isCreator && truePool === 0 && ( <> @@ -86,9 +84,11 @@ export const ContractOverview = (props: { </> )} + <ContractFeed contract={contract} /> + <Spacer h={4} /> - <ContractFeed contract={contract} /> + <ContractCloseTime contract={contract} /> </Col> ) } From db1543ea71bca4d9ded538efeb18c1f14d2b13c1 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 4 Jan 2022 09:55:34 -0600 Subject: [PATCH 47/81] Round bet amounts in feed --- web/components/contract-feed.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index c663fa46..23995fa5 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -6,8 +6,6 @@ import { CheckIcon, LockClosedIcon, StarIcon, - ThumbDownIcon, - ThumbUpIcon, UserIcon, UsersIcon, XIcon, @@ -24,6 +22,7 @@ import { Linkify } from './linkify' import { Row } from './layout/row' import { createComment } from '../lib/firebase/comments' import { useComments } from '../hooks/use-comments' +import { formatMoney } from '../lib/util/format' dayjs.extend(relativeTime) function FeedComment(props: { activityItem: any }) { @@ -99,7 +98,7 @@ function FeedBet(props: { activityItem: any }) { <span className="text-gray-900"> {isCreator ? 'You' : 'A trader'} </span>{' '} - placed M$ {amount} on <OutcomeLabel outcome={outcome} />{' '} + placed {formatMoney(amount)} on <OutcomeLabel outcome={outcome} />{' '} <Timestamp time={createdTime} /> {isCreator && ( // Allow user to comment in an textarea if they are the creator @@ -372,7 +371,7 @@ function FeedBetGroup(props: { activityItem: any }) { .reduce((acc, bet) => acc + bet.amount, 0) const yesSpan = yesAmount ? ( <span> - M$ {yesAmount} on <OutcomeLabel outcome={'YES'} /> + {formatMoney(yesAmount)} on <OutcomeLabel outcome={'YES'} /> </span> ) : null const noAmount = bets @@ -380,7 +379,7 @@ function FeedBetGroup(props: { activityItem: any }) { .reduce((acc, bet) => acc + bet.amount, 0) const noSpan = noAmount ? ( <span> - M$ {noAmount} on <OutcomeLabel outcome={'NO'} /> + {formatMoney(noAmount)} on <OutcomeLabel outcome={'NO'} /> </span> ) : null const traderCount = bets.length From 73f1116b8f7272283d7edfa1b209b6d54d833a70 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 4 Jan 2022 14:57:48 -0600 Subject: [PATCH 48/81] Add Tweet button to share market --- web/components/contract-overview.tsx | 25 +++++++++++++++-- web/components/tweet-button.tsx | 42 ++++++++++++++++++++++++++++ web/pages/_document.tsx | 3 ++ 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 web/components/tweet-button.tsx diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 72fa3dff..e36b3d79 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -1,4 +1,9 @@ -import { compute, Contract, deleteContract } from '../lib/firebase/contracts' +import { + compute, + Contract, + deleteContract, + path, +} from '../lib/firebase/contracts' import { Col } from './layout/col' import { Spacer } from './layout/spacer' import { ContractProbGraph } from './contract-prob-graph' @@ -10,6 +15,7 @@ import { Linkify } from './linkify' import clsx from 'clsx' import { ContractDetails, ResolutionOrChance } from './contract-card' import { ContractFeed } from './contract-feed' +import { TweetButton } from './tweet-button' function ContractCloseTime(props: { contract: Contract }) { const closeTime = props.contract.closeTime @@ -29,12 +35,23 @@ export const ContractOverview = (props: { className?: string }) => { const { contract, className } = props - const { resolution, creatorId } = contract + const { resolution, creatorId, creatorName } = contract const { probPercent, truePool } = compute(contract) const user = useUser() const isCreator = user?.id === creatorId + const tweetQuestion = isCreator + ? contract.question + : `${creatorName}: ${contract.question}` + const tweetDescription = resolution + ? isCreator + ? `Resolved ${resolution}!` + : `Resolved ${resolution} by ${creatorName}:` + : `Currently ${probPercent} chance, place your bets here:` + const url = `https://mantic.markets${path(contract)}` + const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` + return ( <Col className={clsx('mb-6', className)}> <Row className="justify-between gap-4"> @@ -63,6 +80,10 @@ export const ContractOverview = (props: { <Spacer h={4} /> + <TweetButton tweetText={tweetText} /> + + <Spacer h={4} /> + <ContractProbGraph contract={contract} /> <Spacer h={12} /> diff --git a/web/components/tweet-button.tsx b/web/components/tweet-button.tsx new file mode 100644 index 00000000..fb20fc53 --- /dev/null +++ b/web/components/tweet-button.tsx @@ -0,0 +1,42 @@ +export function TweetButton(props: { tweetText?: string }) { + const { tweetText } = props + + return ( + <a + className="twitter-share-button" + href={`https://twitter.com/intent/tweet?text=${encodeURI( + tweetText ?? '' + )}`} + data-size="large" + > + Tweet + </a> + ) +} + +export function TwitterScript() { + return ( + <script + dangerouslySetInnerHTML={{ + __html: ` + window.twttr = (function(d, s, id) { + var js, fjs = d.getElementsByTagName(s)[0], + t = window.twttr || {}; + if (d.getElementById(id)) return t; + js = d.createElement(s); + js.id = id; + js.src = "https://platform.twitter.com/widgets.js"; + fjs.parentNode.insertBefore(js, fjs); + + t._e = []; + t.ready = function(f) { + t._e.push(f); + }; + + return t; + }(document, "script", "twitter-wjs")); + `, + }} + /> + ) +} diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index d2ccc8e7..e1b00a59 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -1,4 +1,5 @@ import { Html, Head, Main, NextScript } from 'next/document' +import { TwitterScript } from '../components/tweet-button' export default function Document() { return ( @@ -31,6 +32,8 @@ export default function Document() { `, }} /> + + <TwitterScript /> </Head> <body className="min-h-screen font-readex-pro bg-base-200"> From 596c6fe33d8045aed40af22a9c68d1bc0f20e34e Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 4 Jan 2022 16:09:03 -0600 Subject: [PATCH 49/81] Show tags in contract details. --- web/components/contract-card.tsx | 56 +++++++++++++++++++--------- web/components/contract-overview.tsx | 2 +- web/components/linkify.tsx | 9 +++-- web/components/tweet-button.tsx | 2 +- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 8fc729f7..70e5df9f 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -6,10 +6,11 @@ import { UserLink } from './user-page' import { Linkify } from './linkify' import { Contract, compute, path } from '../lib/firebase/contracts' import { Col } from './layout/col' +import { parseTags } from '../lib/util/parse' export function ContractCard(props: { contract: Contract }) { const { contract } = props - const { resolution } = contract + const { question, resolution } = contract const { probPercent } = compute(contract) return ( @@ -19,9 +20,7 @@ export function ContractCard(props: { contract: Contract }) { <div className="card"> <div className="card-body p-6"> <Row className="justify-between gap-4 mb-2"> - <p className="font-medium text-indigo-700"> - <Linkify text={contract.question} /> - </p> + <p className="font-medium text-indigo-700">{question}</p> <ResolutionOrChance className="items-center" resolution={resolution} @@ -86,21 +85,44 @@ export function ResolutionOrChance(props: { ) } -export function ContractDetails(props: { contract: Contract }) { - const { contract } = props +export function ContractDetails(props: { + contract: Contract + inlineTags?: boolean +}) { + const { contract, inlineTags } = props + const { question, description } = contract const { truePool, createdDate, resolvedDate } = compute(contract) + const tags = parseTags(`${question} ${description}`).map((tag) => `#${tag}`) + return ( - <Row className="flex-wrap text-sm text-gray-500"> - <div className="whitespace-nowrap"> - <UserLink username={contract.creatorUsername} /> - </div> - <div className="mx-2">•</div> - <div className="whitespace-nowrap"> - {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} - </div> - <div className="mx-2">•</div> - <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> - </Row> + <Col + className={clsx( + 'text-sm text-gray-500 gap-2', + inlineTags && 'sm:flex-row sm:flex-wrap' + )} + > + <Row className="gap-2 flex-wrap"> + <div className="whitespace-nowrap"> + <UserLink username={contract.creatorUsername} /> + </div> + <div className="">•</div> + <div className="whitespace-nowrap"> + {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} + </div> + <div className="">•</div> + <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> + </Row> + + {inlineTags && <div className="hidden sm:block">•</div>} + + <Row className="gap-2 flex-wrap"> + {tags.map((tag) => ( + <div className="bg-gray-100 px-1"> + <Linkify text={tag} gray /> + </div> + ))} + </Row> + </Col> ) } diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index e36b3d79..b3703dd3 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -67,7 +67,7 @@ export const ContractOverview = (props: { large /> - <ContractDetails contract={contract} /> + <ContractDetails contract={contract} inlineTags /> </Col> <ResolutionOrChance diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index 3ee4650f..ccb63a61 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -2,8 +2,8 @@ import { Fragment } from 'react' import { SiteLink } from './site-link' // Return a JSX span, linkifying @username, #hashtags, and https://... -export function Linkify(props: { text: string }) { - const { text } = props +export function Linkify(props: { text: string; gray?: boolean }) { + const { text, gray } = props const regex = /(?:^|\s)(?:[@#][a-z0-9_]+|https?:\/\/\S+)/gi const matches = text.match(regex) || [] const links = matches.map((match) => { @@ -20,7 +20,10 @@ export function Linkify(props: { text: string }) { return ( <> {whitespace} - <SiteLink className="text-indigo-700" href={href}> + <SiteLink + className={gray ? 'text-gray-500' : 'text-indigo-700'} + href={href} + > {symbol} {tag} </SiteLink> diff --git a/web/components/tweet-button.tsx b/web/components/tweet-button.tsx index fb20fc53..b9db405e 100644 --- a/web/components/tweet-button.tsx +++ b/web/components/tweet-button.tsx @@ -4,7 +4,7 @@ export function TweetButton(props: { tweetText?: string }) { return ( <a className="twitter-share-button" - href={`https://twitter.com/intent/tweet?text=${encodeURI( + href={`https://twitter.com/intent/tweet?text=${encodeURIComponent( tweetText ?? '' )}`} data-size="large" From 6d97b82aee749a2ee3e031e320d6216f1d6edf59 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 4 Jan 2022 16:34:07 -0600 Subject: [PATCH 50/81] Use our own Tweet button instead of loading Twitter script --- web/components/tweet-button.tsx | 38 ++++++++----------------------- web/pages/_document.tsx | 3 --- web/public/twitter-icon-white.svg | 16 +++++++++++++ 3 files changed, 25 insertions(+), 32 deletions(-) create mode 100644 web/public/twitter-icon-white.svg diff --git a/web/components/tweet-button.tsx b/web/components/tweet-button.tsx index b9db405e..b3e69150 100644 --- a/web/components/tweet-button.tsx +++ b/web/components/tweet-button.tsx @@ -3,40 +3,20 @@ export function TweetButton(props: { tweetText?: string }) { return ( <a - className="twitter-share-button" + className="btn btn-sm normal-case self-start border-none" + style={{ backgroundColor: '#1da1f2' }} href={`https://twitter.com/intent/tweet?text=${encodeURIComponent( tweetText ?? '' )}`} - data-size="large" + target="_blank" > + <img + className="mr-2" + src={'/twitter-icon-white.svg'} + width={18} + height={18} + /> Tweet </a> ) } - -export function TwitterScript() { - return ( - <script - dangerouslySetInnerHTML={{ - __html: ` - window.twttr = (function(d, s, id) { - var js, fjs = d.getElementsByTagName(s)[0], - t = window.twttr || {}; - if (d.getElementById(id)) return t; - js = d.createElement(s); - js.id = id; - js.src = "https://platform.twitter.com/widgets.js"; - fjs.parentNode.insertBefore(js, fjs); - - t._e = []; - t.ready = function(f) { - t._e.push(f); - }; - - return t; - }(document, "script", "twitter-wjs")); - `, - }} - /> - ) -} diff --git a/web/pages/_document.tsx b/web/pages/_document.tsx index e1b00a59..d2ccc8e7 100644 --- a/web/pages/_document.tsx +++ b/web/pages/_document.tsx @@ -1,5 +1,4 @@ import { Html, Head, Main, NextScript } from 'next/document' -import { TwitterScript } from '../components/tweet-button' export default function Document() { return ( @@ -32,8 +31,6 @@ export default function Document() { `, }} /> - - <TwitterScript /> </Head> <body className="min-h-screen font-readex-pro bg-base-200"> diff --git a/web/public/twitter-icon-white.svg b/web/public/twitter-icon-white.svg new file mode 100644 index 00000000..d16b6a7d --- /dev/null +++ b/web/public/twitter-icon-white.svg @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 248 204" style="enable-background:new 0 0 248 204;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} +</style> +<g id="Logo_1_"> + <path id="white_background" class="st0" d="M221.95,51.29c0.15,2.17,0.15,4.34,0.15,6.53c0,66.73-50.8,143.69-143.69,143.69v-0.04 + C50.97,201.51,24.1,193.65,1,178.83c3.99,0.48,8,0.72,12.02,0.73c22.74,0.02,44.83-7.61,62.72-21.66 + c-21.61-0.41-40.56-14.5-47.18-35.07c7.57,1.46,15.37,1.16,22.8-0.87C27.8,117.2,10.85,96.5,10.85,72.46c0-0.22,0-0.43,0-0.64 + c7.02,3.91,14.88,6.08,22.92,6.32C11.58,63.31,4.74,33.79,18.14,10.71c25.64,31.55,63.47,50.73,104.08,52.76 + c-4.07-17.54,1.49-35.92,14.61-48.25c20.34-19.12,52.33-18.14,71.45,2.19c11.31-2.23,22.15-6.38,32.07-12.26 + c-3.77,11.69-11.66,21.62-22.2,27.93c10.01-1.18,19.79-3.86,29-7.95C240.37,35.29,231.83,44.14,221.95,51.29z"/> +</g> +</svg> From 67007c589748144f09980a0c3fc418117999fb39 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 4 Jan 2022 16:47:56 -0600 Subject: [PATCH 51/81] Make tweet button smaller --- web/components/contract-card.tsx | 4 +++- web/components/contract-overview.tsx | 5 +---- web/components/tweet-button.tsx | 15 ++++++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 70e5df9f..63eaf8aa 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -114,7 +114,9 @@ export function ContractDetails(props: { <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> </Row> - {inlineTags && <div className="hidden sm:block">•</div>} + {inlineTags && tags.length > 0 && ( + <div className="hidden sm:block">•</div> + )} <Row className="gap-2 flex-wrap"> {tags.map((tag) => ( diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index b3703dd3..a3b1077e 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -68,6 +68,7 @@ export const ContractOverview = (props: { /> <ContractDetails contract={contract} inlineTags /> + <TweetButton tweetText={tweetText} /> </Col> <ResolutionOrChance @@ -80,10 +81,6 @@ export const ContractOverview = (props: { <Spacer h={4} /> - <TweetButton tweetText={tweetText} /> - - <Spacer h={4} /> - <ContractProbGraph contract={contract} /> <Spacer h={12} /> diff --git a/web/components/tweet-button.tsx b/web/components/tweet-button.tsx index b3e69150..2fd4adcd 100644 --- a/web/components/tweet-button.tsx +++ b/web/components/tweet-button.tsx @@ -1,9 +1,14 @@ -export function TweetButton(props: { tweetText?: string }) { - const { tweetText } = props +import clsx from 'clsx' + +export function TweetButton(props: { className?: string; tweetText?: string }) { + const { tweetText, className } = props return ( <a - className="btn btn-sm normal-case self-start border-none" + className={clsx( + 'btn btn-xs normal-case self-start border-none', + className + )} style={{ backgroundColor: '#1da1f2' }} href={`https://twitter.com/intent/tweet?text=${encodeURIComponent( tweetText ?? '' @@ -13,8 +18,8 @@ export function TweetButton(props: { tweetText?: string }) { <img className="mr-2" src={'/twitter-icon-white.svg'} - width={18} - height={18} + width={15} + height={15} /> Tweet </a> From 57ee53e133f090ce348e49a75b251073a458ba71 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Tue, 4 Jan 2022 21:03:00 -0600 Subject: [PATCH 52/81] Create page: Change "Prediction" to "Question" and make it gray --- web/pages/create.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 591e21c7..d14df94b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -70,18 +70,18 @@ export default function NewContract() { <Page> <Title text="Create a new prediction market" /> - <div className="w-full bg-white rounded-lg shadow-md px-6 py-4"> + <div className="w-full bg-gray-100 rounded-lg shadow-md px-6 py-4"> {/* Create a Tailwind form that takes in all the fields needed for a new contract */} {/* When the form is submitted, create a new contract in the database */} <form> <div className="form-control w-full"> <label className="label"> - <span className="label-text">Prediction</span> + <span className="label-text">Question</span> </label> <input type="text" - placeholder="e.g. The FDA will approve Paxlovid before Jun 2nd, 2022" + placeholder="e.g. Will the FDA will approve Paxlovid before Jun 2nd, 2022?" className="input input-bordered" value={question} onChange={(e) => setQuestion(e.target.value || '')} @@ -146,7 +146,7 @@ export default function NewContract() { /> </div> <div className="collapse-content !p-0 m-0 !bg-transparent"> - <div className="form-control"> + <div className="form-control mb-1"> <label className="label"> <span className="label-text">Close date (optional)</span> </label> @@ -160,7 +160,7 @@ export default function NewContract() { /> </div> <label> - <span className="label-text text-gray-400 ml-1"> + <span className="label-text text-gray-400 ml-2"> No new trades will be allowed after{' '} {closeDate ? formattedCloseTime : 'this time'} </span> From 7c875f80da0f1feabbe5d792206b8562c4f5d74c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Tue, 4 Jan 2022 23:51:26 -0600 Subject: [PATCH 53/81] subsidized markets; create contract cloud function --- functions/src/create-contract.ts | 149 ++++++++++++++++++ functions/src/index.ts | 1 + functions/src/resolve-market.ts | 2 +- functions/src/types/contract.ts | 1 + .../src}/util/random-string.ts | 0 {web/lib => functions/src}/util/slugify.ts | 0 web/lib/firebase/init.ts | 4 +- web/lib/service/create-contract.ts | 67 -------- web/pages/create.tsx | 114 +++++++++++--- 9 files changed, 244 insertions(+), 94 deletions(-) create mode 100644 functions/src/create-contract.ts rename {web/lib => functions/src}/util/random-string.ts (100%) rename {web/lib => functions/src}/util/slugify.ts (100%) delete mode 100644 web/lib/service/create-contract.ts diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts new file mode 100644 index 00000000..0b0796f6 --- /dev/null +++ b/functions/src/create-contract.ts @@ -0,0 +1,149 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { randomString } from './util/random-string' +import { slugify } from './util/slugify' +import { Contract } from './types/contract' +import { getUser } from './utils' +import { payUser } from '.' +import { User } from './types/user' + +export const createContract = functions + .runWith({ minInstances: 1 }) + .https.onCall( + async ( + data: { + question: string + description: string + initialProb: number + ante?: number + closeTime?: number + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const creator = await getUser(userId) + if (!creator) return { status: 'error', message: 'User not found' } + + const { question, description, initialProb, ante, closeTime } = data + + if (ante !== undefined && (ante < 0 || ante > creator.balance)) + return { status: 'error', message: 'Invalid ante' } + + console.log( + 'creating contract for', + creator.username, + 'on', + question, + 'ante:', + ante || 0 + ) + + const slug = await getSlug(question) + + const contractRef = firestore.collection('contracts').doc() + + const contract = getNewContract( + contractRef.id, + slug, + creator, + question, + description, + initialProb, + ante, + closeTime + ) + + if (ante) await payUser([creator.id, -ante]) + + await contractRef.create(contract) + return { status: 'success', contract } + } + ) + +const getSlug = async (question: string) => { + const proposedSlug = slugify(question).substring(0, 35) + + const preexistingContract = await getContractFromSlug(proposedSlug) + + return preexistingContract + ? proposedSlug + '-' + randomString() + : proposedSlug +} + +function getNewContract( + id: string, + slug: string, + creator: User, + question: string, + description: string, + initialProb: number, + ante?: number, + closeTime?: number +) { + const { startYes, startNo, poolYes, poolNo } = calcStartPool( + initialProb, + ante + ) + + const contract: Contract = { + id, + slug, + outcomeType: 'BINARY', + + creatorId: creator.id, + creatorName: creator.name, + creatorUsername: creator.username, + + question: question.trim(), + description: description.trim(), + + startPool: { YES: startYes, NO: startNo }, + pool: { YES: poolYes, NO: poolNo }, + totalShares: { YES: 0, NO: 0 }, + totalBets: { YES: 0, NO: 0 }, + isResolved: false, + + createdTime: Date.now(), + lastUpdatedTime: Date.now(), + } + + if (closeTime) contract.closeTime = closeTime + + return contract +} + +const calcStartPool = ( + initialProbInt: number, + ante?: number, + phantomAnte = 200 +) => { + const p = initialProbInt / 100.0 + const totalAnte = phantomAnte + (ante || 0) + + const poolYes = + p === 0.5 + ? p * totalAnte + : -(totalAnte * (-p + Math.sqrt((-1 + p) * -p))) / (-1 + 2 * p) + + const poolNo = totalAnte - poolYes + + const f = phantomAnte / totalAnte + const startYes = f * poolYes + const startNo = f * poolNo + + return { startYes, startNo, poolYes, poolNo } +} + +const firestore = admin.firestore() + +export async function getContractFromSlug(slug: string) { + const snap = await firestore + .collection('contracts') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Contract) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index dfb09c89..1d462422 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,3 +6,4 @@ export * from './keep-awake' export * from './place-bet' export * from './resolve-market' export * from './sell-bet' +export * from './create-contract' diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 4862703f..aad9d32c 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -222,7 +222,7 @@ const getMktPayouts = (truePool: number, contract: Contract, bets: Bet[]) => { ] } -const payUser = ([userId, payout]: [string, number]) => { +export const payUser = ([userId, payout]: [string, number]) => { return firestore.runTransaction(async (transaction) => { const userDoc = firestore.doc(`users/${userId}`) const userSnap = await transaction.get(userDoc) diff --git a/functions/src/types/contract.ts b/functions/src/types/contract.ts index 2cb86ac2..816cb9f7 100644 --- a/functions/src/types/contract.ts +++ b/functions/src/types/contract.ts @@ -4,6 +4,7 @@ export type Contract = { creatorId: string creatorName: string + creatorUsername: string question: string description: string // More info about what the contract is about diff --git a/web/lib/util/random-string.ts b/functions/src/util/random-string.ts similarity index 100% rename from web/lib/util/random-string.ts rename to functions/src/util/random-string.ts diff --git a/web/lib/util/slugify.ts b/functions/src/util/slugify.ts similarity index 100% rename from web/lib/util/slugify.ts rename to functions/src/util/slugify.ts diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 98734946..ba10545a 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -2,8 +2,8 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp } from 'firebase/app' // TODO: Reenable this when we have a way to set the Firebase db in dev -// export const isProd = process.env.NODE_ENV === 'production' -export const isProd = true +export const isProd = process.env.NODE_ENV === 'production' +// export const isProd = true const firebaseConfig = isProd ? { diff --git a/web/lib/service/create-contract.ts b/web/lib/service/create-contract.ts deleted file mode 100644 index 7c7a59df..00000000 --- a/web/lib/service/create-contract.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - Contract, - getContractFromSlug, - pushNewContract, -} from '../firebase/contracts' -import { User } from '../firebase/users' -import { randomString } from '../util/random-string' -import { slugify } from '../util/slugify' - -// consider moving to cloud function for security -export async function createContract( - question: string, - description: string, - initialProb: number, - creator: User, - closeTime?: number -) { - const proposedSlug = slugify(question).substring(0, 35) - - const preexistingContract = await getContractFromSlug(proposedSlug) - - const slug = preexistingContract - ? proposedSlug + '-' + randomString() - : proposedSlug - - const { startYes, startNo } = calcStartPool(initialProb) - - const contract: Omit<Contract, 'id'> = { - slug, - outcomeType: 'BINARY', - - creatorId: creator.id, - creatorName: creator.name, - creatorUsername: creator.username, - - question: question.trim(), - description: description.trim(), - - startPool: { YES: startYes, NO: startNo }, - pool: { YES: startYes, NO: startNo }, - totalShares: { YES: 0, NO: 0 }, - totalBets: { YES: 0, NO: 0 }, - isResolved: false, - - // TODO: Set create time to Firestore timestamp - createdTime: Date.now(), - lastUpdatedTime: Date.now(), - } - if (closeTime) { - contract.closeTime = closeTime - } - - return await pushNewContract(contract) -} - -export function calcStartPool(initialProbInt: number, initialCapital = 200) { - const p = initialProbInt / 100.0 - - const startYes = - p === 0.5 - ? p * initialCapital - : -(initialCapital * (-p + Math.sqrt((-1 + p) * -p))) / (-1 + 2 * p) - - const startNo = initialCapital - startYes - - return { startYes, startNo } -} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index d14df94b..c4303148 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -1,15 +1,16 @@ import router from 'next/router' import { useEffect, useState } from 'react' +import clsx from 'clsx' +import dayjs from 'dayjs' +import { getFunctions, httpsCallable } from 'firebase/functions' import { CreatorContractsList } from '../components/contracts-list' import { Spacer } from '../components/layout/spacer' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' -import { path } from '../lib/firebase/contracts' -import { createContract } from '../lib/service/create-contract' +import { Contract, path } from '../lib/firebase/contracts' import { Page } from '../components/page' -import clsx from 'clsx' -import dayjs from 'dayjs' +import { formatMoney } from '../lib/util/format' // Allow user to create a new contract export default function NewContract() { @@ -22,29 +23,28 @@ export default function NewContract() { const [initialProb, setInitialProb] = useState(50) const [question, setQuestion] = useState('') const [description, setDescription] = useState('') + + const [ante, setAnte] = useState<number | undefined>(0) + const [anteError, setAnteError] = useState('') const [closeDate, setCloseDate] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) const [collapsed, setCollapsed] = useState(true) - // Given a date string like '2022-04-02', - // return the time just before midnight on that date (in the user's local time), as millis since epoch - function dateToMillis(date: string) { - return dayjs(date) - .set('hour', 23) - .set('minute', 59) - .set('second', 59) - .valueOf() - } - const closeTime = dateToMillis(closeDate) + const closeTime = dateToMillis(closeDate) || undefined // We'd like this to look like "Apr 2, 2022, 23:59:59 PM PT" but timezones are hard with dayjs - const formattedCloseTime = new Date(closeTime).toString() + const formattedCloseTime = closeTime ? new Date(closeTime).toString() : '' + + const user = useUser() + const remainingBalance = (user?.balance || 0) - (ante || 0) const isValid = initialProb > 0 && initialProb < 100 && question.length > 0 && + (ante === undefined || (ante >= 0 && ante <= remainingBalance)) && // If set, closeTime must be in the future - (!closeDate || closeTime > Date.now()) + (!closeTime || closeTime > Date.now()) async function submit() { // TODO: Tell users why their contract is invalid @@ -52,14 +52,31 @@ export default function NewContract() { setIsSubmitting(true) - const contract = await createContract( + const result: any = await createContract({ question, description, initialProb, - creator, - closeTime - ) - await router.push(path(contract)) + ante, + closeTime: closeTime || undefined, + }).then((r) => r.data || {}) + + if (result.status !== 'success') { + console.log('error creating contract', result) + return + } + + await router.push(path(result.contract as Contract)) + } + + function onAnteChange(str: string) { + const amount = parseInt(str) + + if (str && isNaN(amount)) return + + setAnte(str ? amount : undefined) + + if (user && user.balance < amount) setAnteError('Insufficient balance') + else setAnteError('') } const descriptionPlaceholder = `e.g. This market will resolve to “Yes” if, by June 2, 2021, 11:59:59 PM ET, Paxlovid (also known under PF-07321332)...` @@ -113,7 +130,7 @@ export default function NewContract() { <div className="form-control"> <label className="label"> - <span className="label-text">Description (optional)</span> + <span className="label-text">Description</span> </label> <textarea className="textarea w-full h-24 textarea-bordered" @@ -145,8 +162,40 @@ export default function NewContract() { }} /> </div> + <div className="collapse-content !p-0 m-0 !bg-transparent"> <div className="form-control mb-1"> + <label className="label"> + <span className="label-text">Subsidize your market</span> + </label> + + <label className="input-group"> + <span className="text-sm bg-gray-200">M$</span> + <input + className={clsx( + 'input input-bordered', + anteError && 'input-error' + )} + type="text" + placeholder="0" + maxLength={9} + value={ante ?? ''} + disabled={isSubmitting} + onChange={(e) => onAnteChange(e.target.value)} + /> + </label> + + <div className="mt-3 mb-1 text-sm text-gray-400"> + Remaining balance + </div> + <div> + {formatMoney(remainingBalance > 0 ? remainingBalance : 0)} + </div> + </div> + + <Spacer h={4} /> + + <div className="form-control"> <label className="label"> <span className="label-text">Close date (optional)</span> </label> @@ -156,6 +205,7 @@ export default function NewContract() { onClick={(e) => e.stopPropagation()} onChange={(e) => setCloseDate(e.target.value || '')} min={new Date().toISOString().split('T')[0]} + disabled={isSubmitting} value={closeDate} /> </div> @@ -173,14 +223,17 @@ export default function NewContract() { <div className="flex justify-end my-4"> <button type="submit" - className="btn btn-primary" + className={clsx( + 'btn btn-primary', + isSubmitting && 'loading disabled' + )} disabled={isSubmitting || !isValid} onClick={(e) => { e.preventDefault() submit() }} > - Create market + {isSubmitting ? 'Creating...' : 'Create market'} </button> </div> </form> @@ -194,3 +247,16 @@ export default function NewContract() { </Page> ) } + +const functions = getFunctions() +export const createContract = httpsCallable(functions, 'createContract') + +// Given a date string like '2022-04-02', +// return the time just before midnight on that date (in the user's local time), as millis since epoch +function dateToMillis(date: string) { + return dayjs(date) + .set('hour', 23) + .set('minute', 59) + .set('second', 59) + .valueOf() +} From 4386422f026da85d859a8a159c4018e4c479d1b3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 00:07:36 -0600 Subject: [PATCH 54/81] fix make-predictions --- web/pages/make-predictions.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/web/pages/make-predictions.tsx b/web/pages/make-predictions.tsx index 7b8c8405..23144ff0 100644 --- a/web/pages/make-predictions.tsx +++ b/web/pages/make-predictions.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { getFunctions, httpsCallable } from 'firebase/functions' import Link from 'next/link' import { useState } from 'react' import { Col } from '../components/layout/col' @@ -9,7 +10,9 @@ import { Page } from '../components/page' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' import { compute, Contract, path } from '../lib/firebase/contracts' -import { createContract } from '../lib/service/create-contract' + +const functions = getFunctions() +export const createContract = httpsCallable(functions, 'createContract') type Prediction = { question: string @@ -129,12 +132,12 @@ ${TEST_VALUE} } setIsSubmitting(true) for (const prediction of predictions) { - const contract = await createContract( - prediction.question, - prediction.description, - prediction.initialProb, - user - ) + const contract = await createContract({ + question: prediction.question, + description: prediction.description, + initialProb: prediction.initialProb, + }).then((r) => (r.data as any).contract) + setCreatedContracts((prev) => [...prev, contract]) } setPredictionsString('') From 1bc323d57554658c01048fb26806fc0aee13654b Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Wed, 5 Jan 2022 00:32:52 -0600 Subject: [PATCH 55/81] =?UTF-8?q?Hot=20markets!=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/contract-card.tsx | 2 +- web/components/contracts-list.tsx | 2 +- web/hooks/use-contracts.ts | 21 ++++++++++++++- web/lib/firebase/bets.ts | 33 ++++++++++++++++++++++++ web/lib/firebase/contracts.ts | 17 +++++++++++- web/pages/index.tsx | 24 ++++++++++++++--- web/pages/markets.tsx | 43 ++++++++++++++++++++++++++----- 7 files changed, 128 insertions(+), 14 deletions(-) diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 63eaf8aa..e1dc939b 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -120,7 +120,7 @@ export function ContractDetails(props: { <Row className="gap-2 flex-wrap"> {tags.map((tag) => ( - <div className="bg-gray-100 px-1"> + <div key={tag} className="bg-gray-100 px-1"> <Linkify text={tag} gray /> </div> ))} diff --git a/web/components/contracts-list.tsx b/web/components/contracts-list.tsx index 69908523..e1044d65 100644 --- a/web/components/contracts-list.tsx +++ b/web/components/contracts-list.tsx @@ -11,7 +11,7 @@ import { parseTags } from '../lib/util/parse' import { ContractCard } from './contract-card' import { Sort, useQueryAndSortParams } from '../hooks/use-sort-and-query-params' -function ContractsGrid(props: { contracts: Contract[] }) { +export function ContractsGrid(props: { contracts: Contract[] }) { const [resolvedContracts, activeContracts] = _.partition( props.contracts, (c) => c.isResolved diff --git a/web/hooks/use-contracts.ts b/web/hooks/use-contracts.ts index 674c570c..bec304a9 100644 --- a/web/hooks/use-contracts.ts +++ b/web/hooks/use-contracts.ts @@ -1,5 +1,11 @@ +import _ from 'lodash' import { useEffect, useState } from 'react' -import { Contract, listenForContracts } from '../lib/firebase/contracts' +import { Bet, listenForRecentBets } from '../lib/firebase/bets' +import { + computeHotContracts, + Contract, + listenForContracts, +} from '../lib/firebase/contracts' export const useContracts = () => { const [contracts, setContracts] = useState<Contract[] | 'loading'>('loading') @@ -10,3 +16,16 @@ export const useContracts = () => { return contracts } + +export const useHotContracts = () => { + const [recentBets, setRecentBets] = useState<Bet[] | 'loading'>('loading') + + useEffect(() => { + const oneDay = 1000 * 60 * 60 * 24 + return listenForRecentBets(oneDay, setRecentBets) + }, []) + + if (recentBets === 'loading') return 'loading' + + return computeHotContracts(recentBets) +} diff --git a/web/lib/firebase/bets.ts b/web/lib/firebase/bets.ts index 49d92e0e..818c09bc 100644 --- a/web/lib/firebase/bets.ts +++ b/web/lib/firebase/bets.ts @@ -4,7 +4,9 @@ import { query, onSnapshot, where, + getDocs, } from 'firebase/firestore' +import _ from 'lodash' import { db } from './init' export type Bet = { @@ -62,3 +64,34 @@ export function listenForUserBets( setBets(bets) }) } + +export function listenForRecentBets( + timePeriodMs: number, + setBets: (bets: Bet[]) => void +) { + const recentQuery = query( + collectionGroup(db, 'bets'), + where('createdTime', '>', Date.now() - timePeriodMs) + ) + return onSnapshot(recentQuery, (snap) => { + const bets = snap.docs.map((doc) => doc.data() as Bet) + + bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) + + setBets(bets) + }) +} + +export async function getRecentBets(timePeriodMs: number) { + const recentQuery = query( + collectionGroup(db, 'bets'), + where('createdTime', '>', Date.now() - timePeriodMs) + ) + + const snapshot = await getDocs(recentQuery) + const bets = snapshot.docs.map((doc) => doc.data() as Bet) + + bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime) + + return bets +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 1db5d00e..891831c4 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -11,9 +11,10 @@ import { onSnapshot, orderBy, getDoc, - limit, } from 'firebase/firestore' import dayjs from 'dayjs' +import { Bet, getRecentBets } from './bets' +import _ from 'lodash' export type Contract = { id: string @@ -131,3 +132,17 @@ export function listenForContract( setContract((contractSnap.data() ?? null) as Contract | null) }) } + +export function computeHotContracts(recentBets: Bet[]) { + const contractBets = _.groupBy(recentBets, (bet) => bet.contractId) + const hotContractIds = _.sortBy(Object.keys(contractBets), (contractId) => + _.sumBy(contractBets[contractId], (bet) => -1 * bet.amount) + ).slice(0, 4) + return hotContractIds +} + +export async function getHotContracts() { + const oneDay = 1000 * 60 * 60 * 24 + const recentBets = await getRecentBets(oneDay) + return computeHotContracts(recentBets) +} diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 6e4ba609..cd409aba 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -3,26 +3,42 @@ import React from 'react' import { useUser } from '../hooks/use-user' import Markets from './markets' import LandingPage from './landing-page' -import { Contract, listAllContracts } from '../lib/firebase/contracts' +import { + Contract, + getHotContracts, + listAllContracts, +} from '../lib/firebase/contracts' +import _ from 'lodash' export async function getStaticProps() { - const contracts = await listAllContracts().catch((_) => []) + const [contracts, hotContractIds] = await Promise.all([ + listAllContracts().catch((_) => []), + getHotContracts().catch(() => []), + ]) return { props: { contracts, + hotContractIds, }, revalidate: 60, // regenerate after a minute } } -const Home = (props: { contracts: Contract[] }) => { +const Home = (props: { contracts: Contract[]; hotContractIds: string[] }) => { const user = useUser() if (user === undefined) return <></> - return user ? <Markets contracts={props.contracts} /> : <LandingPage /> + return user ? ( + <Markets + contracts={props.contracts} + hotContractIds={props.hotContractIds} + /> + ) : ( + <LandingPage /> + ) } export default Home diff --git a/web/pages/markets.tsx b/web/pages/markets.tsx index 96f3c7a4..f28c493d 100644 --- a/web/pages/markets.tsx +++ b/web/pages/markets.tsx @@ -1,30 +1,61 @@ -import { SearchableGrid } from '../components/contracts-list' +import _ from 'lodash' +import { ContractsGrid, SearchableGrid } from '../components/contracts-list' +import { Spacer } from '../components/layout/spacer' import { Page } from '../components/page' -import { useContracts } from '../hooks/use-contracts' +import { Title } from '../components/title' +import { useContracts, useHotContracts } from '../hooks/use-contracts' import { useQueryAndSortParams } from '../hooks/use-sort-and-query-params' -import { Contract, listAllContracts } from '../lib/firebase/contracts' +import { + Contract, + getHotContracts, + listAllContracts, +} from '../lib/firebase/contracts' export async function getStaticProps() { - const contracts = await listAllContracts().catch((_) => []) + const [contracts, hotContractIds] = await Promise.all([ + listAllContracts().catch((_) => []), + getHotContracts().catch(() => []), + ]) return { props: { contracts, + hotContractIds, }, revalidate: 60, // regenerate after a minute } } -export default function Markets(props: { contracts: Contract[] }) { +export default function Markets(props: { + contracts: Contract[] + hotContractIds: string[] +}) { const contracts = useContracts() const { query, setQuery, sort, setSort } = useQueryAndSortParams() + const hotContractIds = useHotContracts() + + const readyHotContractIds = + hotContractIds === 'loading' ? props.hotContractIds : hotContractIds + const readyContracts = contracts === 'loading' ? props.contracts : contracts + + const hotContracts = readyHotContractIds.map( + (hotId) => + _.find(readyContracts, (contract) => contract.id === hotId) as Contract + ) return ( <Page> + <div className="w-full bg-indigo-50 border-2 border-indigo-100 p-6 rounded-lg shadow-md"> + <Title className="mt-0" text="🔥 Markets" /> + <ContractsGrid contracts={hotContracts} /> + </div> + + <Spacer h={10} /> + {(props.contracts || contracts !== 'loading') && ( <SearchableGrid - contracts={contracts === 'loading' ? props.contracts : contracts} + contracts={readyContracts} query={query} setQuery={setQuery} sort={sort} From e20537bf491ad20f447bb59231089d2cbc2bc029 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Wed, 5 Jan 2022 01:06:30 -0600 Subject: [PATCH 56/81] Remove tags and date from card. --- web/components/contract-card.tsx | 55 +++++++++++++++++----------- web/components/contract-overview.tsx | 2 +- web/lib/firebase/init.ts | 4 +- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index e1dc939b..438f7ab2 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -27,7 +27,7 @@ export function ContractCard(props: { contract: Contract }) { probPercent={probPercent} /> </Row> - <ContractDetails contract={contract} /> + <AbbrContractDetails contract={contract} /> </div> </div> </li> @@ -85,23 +85,32 @@ export function ResolutionOrChance(props: { ) } -export function ContractDetails(props: { - contract: Contract - inlineTags?: boolean -}) { - const { contract, inlineTags } = props +export function AbbrContractDetails(props: { contract: Contract }) { + const { contract } = props + const { truePool } = compute(contract) + + return ( + <Col className={clsx('text-sm text-gray-500 gap-2')}> + <Row className="gap-2 flex-wrap"> + <div className="whitespace-nowrap"> + <UserLink username={contract.creatorUsername} /> + </div> + <div className="">•</div> + <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> + </Row> + </Col> + ) +} + +export function ContractDetails(props: { contract: Contract }) { + const { contract } = props const { question, description } = contract const { truePool, createdDate, resolvedDate } = compute(contract) const tags = parseTags(`${question} ${description}`).map((tag) => `#${tag}`) return ( - <Col - className={clsx( - 'text-sm text-gray-500 gap-2', - inlineTags && 'sm:flex-row sm:flex-wrap' - )} - > + <Col className="text-sm text-gray-500 gap-2 sm:flex-row sm:flex-wrap"> <Row className="gap-2 flex-wrap"> <div className="whitespace-nowrap"> <UserLink username={contract.creatorUsername} /> @@ -114,17 +123,19 @@ export function ContractDetails(props: { <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> </Row> - {inlineTags && tags.length > 0 && ( - <div className="hidden sm:block">•</div> - )} + {tags.length > 0 && ( + <> + <div className="hidden sm:block">•</div> - <Row className="gap-2 flex-wrap"> - {tags.map((tag) => ( - <div key={tag} className="bg-gray-100 px-1"> - <Linkify text={tag} gray /> - </div> - ))} - </Row> + <Row className="gap-2 flex-wrap"> + {tags.map((tag) => ( + <div key={tag} className="bg-gray-100 px-1"> + <Linkify text={tag} gray /> + </div> + ))} + </Row> + </> + )} </Col> ) } diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index a3b1077e..88f788e9 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -67,7 +67,7 @@ export const ContractOverview = (props: { large /> - <ContractDetails contract={contract} inlineTags /> + <ContractDetails contract={contract} /> <TweetButton tweetText={tweetText} /> </Col> diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index ba10545a..98734946 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -2,8 +2,8 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp } from 'firebase/app' // TODO: Reenable this when we have a way to set the Firebase db in dev -export const isProd = process.env.NODE_ENV === 'production' -// export const isProd = true +// export const isProd = process.env.NODE_ENV === 'production' +export const isProd = true const firebaseConfig = isProd ? { From 2453fcf1efd9c7a342acdc7eacc2d4351371f116 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 11:42:43 -0600 Subject: [PATCH 57/81] basic firestore rules --- firestore.rules | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 firestore.rules diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 00000000..14047e59 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,25 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + + match /users/{userId} { + allow read; + allow create: if request.auth != null; + } + + match /contracts/{contractId} { + allow read; + } + + match /contracts/bets/{betId} { + allow read; + } + + match /contracts/comments/{commentId} { + allow read; + allow create: if request.auth != null; + } + + } +} \ No newline at end of file From 8f3a002840968c18352fa6aa27185b579b156d0e Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 11:47:39 -0600 Subject: [PATCH 58/81] rules: handle querying all bets --- firestore.rules | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/firestore.rules b/firestore.rules index 14047e59..3ba7d2cf 100644 --- a/firestore.rules +++ b/firestore.rules @@ -16,6 +16,10 @@ service cloud.firestore { allow read; } + match /{somePath=**}/bets/{betId} { + allow read; + } + match /contracts/comments/{commentId} { allow read; allow create: if request.auth != null; From 3302cbddbd44a774805ae02b88d11f8c101f249f Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Wed, 5 Jan 2022 11:49:20 -0600 Subject: [PATCH 59/81] Move contract close time into contract details --- web/components/contract-card.tsx | 12 +++++++++++- web/components/contract-feed.tsx | 2 +- web/components/contract-overview.tsx | 18 ------------------ 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/web/components/contract-card.tsx b/web/components/contract-card.tsx index 438f7ab2..1920c494 100644 --- a/web/components/contract-card.tsx +++ b/web/components/contract-card.tsx @@ -7,6 +7,7 @@ import { Linkify } from './linkify' import { Contract, compute, path } from '../lib/firebase/contracts' import { Col } from './layout/col' import { parseTags } from '../lib/util/parse' +import dayjs from 'dayjs' export function ContractCard(props: { contract: Contract }) { const { contract } = props @@ -104,7 +105,7 @@ export function AbbrContractDetails(props: { contract: Contract }) { export function ContractDetails(props: { contract: Contract }) { const { contract } = props - const { question, description } = contract + const { question, description, closeTime } = contract const { truePool, createdDate, resolvedDate } = compute(contract) const tags = parseTags(`${question} ${description}`).map((tag) => `#${tag}`) @@ -119,6 +120,15 @@ export function ContractDetails(props: { contract: Contract }) { <div className="whitespace-nowrap"> {resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate} </div> + {!resolvedDate && closeTime && ( + <> + <div className="">•</div> + <div className="whitespace-nowrap"> + {closeTime > Date.now() ? 'Closes' : 'Closed'}{' '} + {dayjs(closeTime).format('MMM D, h:mma')} + </div> + </> + )} <div className="">•</div> <div className="whitespace-nowrap">{formatMoney(truePool)} pool</div> </Row> diff --git a/web/components/contract-feed.tsx b/web/components/contract-feed.tsx index 23995fa5..3e028e76 100644 --- a/web/components/contract-feed.tsx +++ b/web/components/contract-feed.tsx @@ -65,7 +65,7 @@ function Timestamp(props: { time: number }) { const { time } = props return ( <span - className="whitespace-nowrap text-gray-300" + className="whitespace-nowrap text-gray-300 ml-1" title={dayjs(time).format('MMM D, h:mma')} > {dayjs(time).fromNow()} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 88f788e9..0e0b7ecc 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -10,26 +10,12 @@ import { ContractProbGraph } from './contract-prob-graph' import router from 'next/router' import { useUser } from '../hooks/use-user' import { Row } from './layout/row' -import dayjs from 'dayjs' import { Linkify } from './linkify' import clsx from 'clsx' import { ContractDetails, ResolutionOrChance } from './contract-card' import { ContractFeed } from './contract-feed' import { TweetButton } from './tweet-button' -function ContractCloseTime(props: { contract: Contract }) { - const closeTime = props.contract.closeTime - if (!closeTime) { - return null - } - return ( - <div className="text-gray-500 text-sm"> - Trading {closeTime > Date.now() ? 'closes' : 'closed'} at{' '} - {dayjs(closeTime).format('MMM D, h:mma')} - </div> - ) -} - export const ContractOverview = (props: { contract: Contract className?: string @@ -103,10 +89,6 @@ export const ContractOverview = (props: { )} <ContractFeed contract={contract} /> - - <Spacer h={4} /> - - <ContractCloseTime contract={contract} /> </Col> ) } From 5eaf50612d283db81c7babbdc11913a62223d114 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Wed, 5 Jan 2022 12:23:44 -0600 Subject: [PATCH 60/81] Advanced metrics for bet panel --- web/components/advanced-panel.tsx | 35 +++++++++ web/components/bet-panel.tsx | 33 +++++++- web/pages/create.tsx | 125 ++++++++++++------------------ 3 files changed, 118 insertions(+), 75 deletions(-) create mode 100644 web/components/advanced-panel.tsx diff --git a/web/components/advanced-panel.tsx b/web/components/advanced-panel.tsx new file mode 100644 index 00000000..491e9c3b --- /dev/null +++ b/web/components/advanced-panel.tsx @@ -0,0 +1,35 @@ +import clsx from 'clsx' +import { useState } from 'react' + +export function AdvancedPanel(props: { children: any }) { + const { children } = props + const [collapsed, setCollapsed] = useState(true) + + return ( + <div + tabIndex={0} + className={clsx( + 'cursor-pointer relative collapse collapse-arrow', + collapsed ? 'collapse-close' : 'collapse-open' + )} + > + <div onClick={() => setCollapsed((collapsed) => !collapsed)}> + <div className="mt-4 mr-6 text-sm text-gray-400 text-right"> + Advanced + </div> + <div + className="collapse-title p-0 absolute w-0 h-0 min-h-0" + style={{ + top: -2, + right: -15, + color: '#9ca3af' /* gray-400 */, + }} + /> + </div> + + <div className="collapse-content !p-0 m-0 !bg-transparent"> + {children} + </div> + </div> + ) +} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 55467527..4bb5702d 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -8,14 +8,22 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' import { YesNoSelector } from './yes-no-selector' -import { formatMoney, formatPercent } from '../lib/util/format' +import { + formatMoney, + formatPercent, + formatWithCommas, +} from '../lib/util/format' import { Title } from './title' import { getProbability, calculateShares, getProbabilityAfterBet, + calculatePayout, } from '../lib/calculate' import { firebaseLogin } from '../lib/firebase/users' +import { OutcomeLabel } from './outcome-label' +import { AdvancedPanel } from './advanced-panel' +import { Bet } from '../lib/firebase/bets' export function BetPanel(props: { contract: Contract; className?: string }) { const { contract, className } = props @@ -151,6 +159,29 @@ export function BetPanel(props: { contract: Contract; className?: string }) { {formatMoney(estimatedWinnings)}   (+{estimatedReturnPercent}) </div> + <AdvancedPanel> + <div className="mt-2 mb-1 text-sm text-gray-400"> + <OutcomeLabel outcome={betChoice} /> shares + </div> + <div> + {formatWithCommas(shares)} of{' '} + {formatWithCommas(shares + contract.totalShares[betChoice])} + </div> + + <div className="mt-2 mb-1 text-sm text-gray-400"> + Current payout if <OutcomeLabel outcome={betChoice} /> + </div> + <div> + {formatMoney( + calculatePayout( + contract, + { outcome: betChoice, amount: betAmount ?? 0, shares } as Bet, + betChoice + ) + )} + </div> + </AdvancedPanel> + <Spacer h={6} /> {user ? ( diff --git a/web/pages/create.tsx b/web/pages/create.tsx index c4303148..2d6c2027 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -11,6 +11,7 @@ import { useUser } from '../hooks/use-user' import { Contract, path } from '../lib/firebase/contracts' import { Page } from '../components/page' import { formatMoney } from '../lib/util/format' +import { AdvancedPanel } from '../components/advanced-panel' // Allow user to create a new contract export default function NewContract() { @@ -29,7 +30,6 @@ export default function NewContract() { const [closeDate, setCloseDate] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const [collapsed, setCollapsed] = useState(true) const closeTime = dateToMillis(closeDate) || undefined // We'd like this to look like "Apr 2, 2022, 23:59:59 PM PT" but timezones are hard with dayjs @@ -141,82 +141,59 @@ export default function NewContract() { /> </div> - {/* Collapsible "Advanced" section */} - <div - tabIndex={0} - className={clsx( - 'cursor-pointer relative collapse collapse-arrow', - collapsed ? 'collapse-close' : 'collapse-open' - )} - > - <div onClick={() => setCollapsed((collapsed) => !collapsed)}> - <div className="mt-4 mr-6 text-sm text-gray-400 text-right"> - Advanced + <AdvancedPanel> + <div className="form-control mb-1"> + <label className="label"> + <span className="label-text">Subsidize your market</span> + </label> + + <label className="input-group"> + <span className="text-sm bg-gray-200">M$</span> + <input + className={clsx( + 'input input-bordered', + anteError && 'input-error' + )} + type="text" + placeholder="0" + maxLength={9} + value={ante ?? ''} + disabled={isSubmitting} + onChange={(e) => onAnteChange(e.target.value)} + /> + </label> + + <div className="mt-3 mb-1 text-sm text-gray-400"> + Remaining balance </div> - <div - className="collapse-title p-0 absolute w-0 h-0 min-h-0" - style={{ - top: -2, - right: -15, - color: '#9ca3af' /* gray-400 */, - }} + <div> + {formatMoney(remainingBalance > 0 ? remainingBalance : 0)} + </div> + </div> + + <Spacer h={4} /> + + <div className="form-control"> + <label className="label"> + <span className="label-text">Close date (optional)</span> + </label> + <input + type="date" + className="input input-bordered" + onClick={(e) => e.stopPropagation()} + onChange={(e) => setCloseDate(e.target.value || '')} + min={new Date().toISOString().split('T')[0]} + disabled={isSubmitting} + value={closeDate} /> </div> - - <div className="collapse-content !p-0 m-0 !bg-transparent"> - <div className="form-control mb-1"> - <label className="label"> - <span className="label-text">Subsidize your market</span> - </label> - - <label className="input-group"> - <span className="text-sm bg-gray-200">M$</span> - <input - className={clsx( - 'input input-bordered', - anteError && 'input-error' - )} - type="text" - placeholder="0" - maxLength={9} - value={ante ?? ''} - disabled={isSubmitting} - onChange={(e) => onAnteChange(e.target.value)} - /> - </label> - - <div className="mt-3 mb-1 text-sm text-gray-400"> - Remaining balance - </div> - <div> - {formatMoney(remainingBalance > 0 ? remainingBalance : 0)} - </div> - </div> - - <Spacer h={4} /> - - <div className="form-control"> - <label className="label"> - <span className="label-text">Close date (optional)</span> - </label> - <input - type="date" - className="input input-bordered" - onClick={(e) => e.stopPropagation()} - onChange={(e) => setCloseDate(e.target.value || '')} - min={new Date().toISOString().split('T')[0]} - disabled={isSubmitting} - value={closeDate} - /> - </div> - <label> - <span className="label-text text-gray-400 ml-2"> - No new trades will be allowed after{' '} - {closeDate ? formattedCloseTime : 'this time'} - </span> - </label> - </div> - </div> + <label> + <span className="label-text text-gray-400 ml-2"> + No new trades will be allowed after{' '} + {closeDate ? formattedCloseTime : 'this time'} + </span> + </label> + </AdvancedPanel> <Spacer h={4} /> From 0b8ad76b0f1fb2adef8a7db3f8fd7bd84c3389b4 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 12:23:58 -0600 Subject: [PATCH 61/81] global warming: warm up all cloud functions on client --- functions/src/create-contract.ts | 3 +++ web/components/bet-panel.tsx | 11 +++++++---- web/components/bets-list.tsx | 12 ++++++++---- web/components/resolution-panel.tsx | 12 +++++++----- web/lib/firebase/api-call.ts | 7 +++++++ web/pages/create.tsx | 12 +++++++----- web/pages/make-predictions.tsx | 6 ++---- 7 files changed, 41 insertions(+), 22 deletions(-) diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 0b0796f6..d37919ab 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -29,6 +29,9 @@ export const createContract = functions const { question, description, initialProb, ante, closeTime } = data + if (!question || !initialProb) + return { status: 'error', message: 'Missing contract attributes' } + if (ante !== undefined && (ante < 0 || ante > creator.balance)) return { status: 'error', message: 'Invalid ante' } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 4bb5702d..f6f35676 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,6 @@ import { getFunctions, httpsCallable } from 'firebase/functions' import clsx from 'clsx' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { useUser } from '../hooks/use-user' import { Contract } from '../lib/firebase/contracts' @@ -24,8 +24,14 @@ import { firebaseLogin } from '../lib/firebase/users' import { OutcomeLabel } from './outcome-label' import { AdvancedPanel } from './advanced-panel' import { Bet } from '../lib/firebase/bets' +import { placeBet } from '../lib/firebase/api-call' export function BetPanel(props: { contract: Contract; className?: string }) { + useEffect(() => { + // warm up cloud function + placeBet({}).catch() + }, []) + const { contract, className } = props const user = useUser() @@ -212,6 +218,3 @@ export function BetPanel(props: { contract: Contract; className?: string }) { </Col> ) } - -const functions = getFunctions() -export const placeBet = httpsCallable(functions, 'placeBet') diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 3cbf4ace..b622c58e 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -2,6 +2,8 @@ import Link from 'next/link' import _ from 'lodash' import dayjs from 'dayjs' import { useEffect, useState } from 'react' +import clsx from 'clsx' + import { useUserBets } from '../hooks/use-user-bets' import { Bet } from '../lib/firebase/bets' import { User } from '../lib/firebase/users' @@ -20,8 +22,7 @@ import { calculateSaleAmount, resolvedPayout, } from '../lib/calculate' -import clsx from 'clsx' -import { cloudFunction } from '../lib/firebase/api-call' +import { sellBet } from '../lib/firebase/api-call' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel, MarketLabel } from './outcome-label' @@ -341,9 +342,12 @@ function BetRow(props: { bet: Bet; contract: Contract; sale?: Bet }) { ) } -const sellBet = cloudFunction('sellBet') - function SellButton(props: { contract: Contract; bet: Bet }) { + useEffect(() => { + // warm up cloud function + sellBet({}).catch() + }, []) + const { contract, bet } = props const [isSubmitting, setIsSubmitting] = useState(false) diff --git a/web/components/resolution-panel.tsx b/web/components/resolution-panel.tsx index d5ccbfd5..3e282fa8 100644 --- a/web/components/resolution-panel.tsx +++ b/web/components/resolution-panel.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx' -import React, { useState } from 'react' -import { getFunctions, httpsCallable } from 'firebase/functions' +import React, { useEffect, useState } from 'react' import { Contract } from '../lib/firebase/contracts' import { Col } from './layout/col' @@ -9,15 +8,18 @@ import { User } from '../lib/firebase/users' import { YesNoCancelSelector } from './yes-no-selector' import { Spacer } from './layout/spacer' import { ConfirmationButton as ConfirmationButton } from './confirmation-button' - -const functions = getFunctions() -export const resolveMarket = httpsCallable(functions, 'resolveMarket') +import { resolveMarket } from '../lib/firebase/api-call' export function ResolutionPanel(props: { creator: User contract: Contract className?: string }) { + useEffect(() => { + // warm up cloud function + resolveMarket({}).catch() + }, []) + const { contract, className } = props const [outcome, setOutcome] = useState< diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index fc054386..3d541291 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -4,3 +4,10 @@ const functions = getFunctions() export const cloudFunction = (name: string) => httpsCallable(functions, name) +export const createContract = cloudFunction('createContract') + +export const placeBet = cloudFunction('placeBet') + +export const resolveMarket = cloudFunction('resolveMarket') + +export const sellBet = cloudFunction('sellBet') diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 2d6c2027..ab76e667 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -2,7 +2,6 @@ import router from 'next/router' import { useEffect, useState } from 'react' import clsx from 'clsx' import dayjs from 'dayjs' -import { getFunctions, httpsCallable } from 'firebase/functions' import { CreatorContractsList } from '../components/contracts-list' import { Spacer } from '../components/layout/spacer' @@ -12,6 +11,7 @@ import { Contract, path } from '../lib/firebase/contracts' import { Page } from '../components/page' import { formatMoney } from '../lib/util/format' import { AdvancedPanel } from '../components/advanced-panel' +import { createContract } from '../lib/firebase/api-call' // Allow user to create a new contract export default function NewContract() { @@ -19,7 +19,12 @@ export default function NewContract() { useEffect(() => { if (creator === null) router.push('/') - }) + }, [creator]) + + useEffect(() => { + // warm up function + createContract({}).catch() + }, []) const [initialProb, setInitialProb] = useState(50) const [question, setQuestion] = useState('') @@ -225,9 +230,6 @@ export default function NewContract() { ) } -const functions = getFunctions() -export const createContract = httpsCallable(functions, 'createContract') - // Given a date string like '2022-04-02', // return the time just before midnight on that date (in the user's local time), as millis since epoch function dateToMillis(date: string) { diff --git a/web/pages/make-predictions.tsx b/web/pages/make-predictions.tsx index 23144ff0..c2a2fe5f 100644 --- a/web/pages/make-predictions.tsx +++ b/web/pages/make-predictions.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' -import { getFunctions, httpsCallable } from 'firebase/functions' import Link from 'next/link' import { useState } from 'react' + import { Col } from '../components/layout/col' import { Row } from '../components/layout/row' import { Spacer } from '../components/layout/spacer' @@ -9,11 +9,9 @@ import { Linkify } from '../components/linkify' import { Page } from '../components/page' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' +import { createContract } from '../lib/firebase/api-call' import { compute, Contract, path } from '../lib/firebase/contracts' -const functions = getFunctions() -export const createContract = httpsCallable(functions, 'createContract') - type Prediction = { question: string description: string From 8594700fb421e2a548ec18480a6f20a1b1bc1a5c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 12:24:59 -0600 Subject: [PATCH 62/81] turn off keepAwake --- functions/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 1d462422..088ca534 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,7 +2,7 @@ import * as admin from 'firebase-admin' admin.initializeApp() -export * from './keep-awake' +// export * from './keep-awake' export * from './place-bet' export * from './resolve-market' export * from './sell-bet' From fbc61fe28fce261945cd66a6a98af4f1c9693d5c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 16:52:54 -0600 Subject: [PATCH 63/81] rules fix --- firestore.rules | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/firestore.rules b/firestore.rules index 3ba7d2cf..be39de5f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,7 +12,7 @@ service cloud.firestore { allow read; } - match /contracts/bets/{betId} { + match /contracts/{contractId}/bets/{betId} { allow read; } @@ -20,10 +20,14 @@ service cloud.firestore { allow read; } - match /contracts/comments/{commentId} { + match /contracts/{contractId}/comments/{commentId} { allow read; allow create: if request.auth != null; } + match /{somePath=**}/comments/{commentId} { + allow read; + } + } } \ No newline at end of file From 75e9d419eeac67dc62fba0b1746309149ff6a711 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Wed, 5 Jan 2022 17:03:26 -0600 Subject: [PATCH 64/81] Fix payout calculation for correct bet in bet panel. --- web/components/bet-panel.tsx | 14 ++++++++------ web/lib/calculate.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index f6f35676..be89621f 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -18,7 +18,7 @@ import { getProbability, calculateShares, getProbabilityAfterBet, - calculatePayout, + calculatePayoutAfterCorrectBet, } from '../lib/calculate' import { firebaseLogin } from '../lib/firebase/users' import { OutcomeLabel } from './outcome-label' @@ -179,11 +179,13 @@ export function BetPanel(props: { contract: Contract; className?: string }) { </div> <div> {formatMoney( - calculatePayout( - contract, - { outcome: betChoice, amount: betAmount ?? 0, shares } as Bet, - betChoice - ) + betAmount + ? calculatePayoutAfterCorrectBet(contract, { + outcome: betChoice, + amount: betAmount, + shares, + } as Bet) + : 0 )} </div> </AdvancedPanel> diff --git a/web/lib/calculate.ts b/web/lib/calculate.ts index b265f228..6e446155 100644 --- a/web/lib/calculate.ts +++ b/web/lib/calculate.ts @@ -62,6 +62,25 @@ export function calculatePayout( return (1 - fees) * (amount + ((shares - amount) / total) * winningsPool) } +export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { + const { amount, outcome, shares } = bet + const { totalShares, totalBets } = contract + + const startPool = contract.startPool.YES + contract.startPool.NO + const truePool = amount + contract.pool.YES + contract.pool.NO - startPool + + const totalBetsOutcome = totalBets[outcome] + amount + const totalSharesOutcome = totalShares[outcome] + shares + + if (totalBetsOutcome >= truePool) + return (amount / totalBetsOutcome) * truePool + + const total = totalSharesOutcome - totalBetsOutcome + const winningsPool = truePool - totalBetsOutcome + + return (1 - fees) * (amount + ((shares - amount) / total) * winningsPool) +} + function calculateMktPayout(contract: Contract, bet: Bet) { const p = contract.pool.YES ** 2 / (contract.pool.YES ** 2 + contract.pool.NO ** 2) From 22894cb44e35cdf041c80f99f2312bbe6580d4a6 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Wed, 5 Jan 2022 15:51:56 -0800 Subject: [PATCH 65/81] Update Notion link --- web/pages/about.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/about.tsx b/web/pages/about.tsx index fd810ed6..1f5d9666 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -297,7 +297,7 @@ function Contents() { <ul> <li> - <a href="https://mantic.notion.site/Technical-Overview-b9b48a09ea1f45b88d991231171730c5"> + <a href="https://manifoldmarkets.notion.site/Technical-Overview-b9b48a09ea1f45b88d991231171730c5"> Technical Overview of Mantic Markets </a> </li> From 3d780be70aa5cc2e58e0424c4591c1cc3da8665d Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 21:04:10 -0600 Subject: [PATCH 66/81] rules: allow contract deletion by creator --- firestore.rules | 1 + 1 file changed, 1 insertion(+) diff --git a/firestore.rules b/firestore.rules index be39de5f..d7149344 100644 --- a/firestore.rules +++ b/firestore.rules @@ -10,6 +10,7 @@ service cloud.firestore { match /contracts/{contractId} { allow read; + allow delete: if resource.data.creatorId == request.auth.uid; } match /contracts/{contractId}/bets/{betId} { From 95b28bd536ae4a7f22d01392db1f36544c8aae9b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Wed, 5 Jan 2022 21:05:46 -0600 Subject: [PATCH 67/81] create page: disable form elements after submitting --- web/pages/create.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab76e667..467cb407 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -105,6 +105,7 @@ export default function NewContract() { type="text" placeholder="e.g. Will the FDA will approve Paxlovid before Jun 2nd, 2022?" className="input input-bordered" + disabled={isSubmitting} value={question} onChange={(e) => setQuestion(e.target.value || '')} /> @@ -121,6 +122,7 @@ export default function NewContract() { type="number" value={initialProb} className="input input-bordered input-md" + disabled={isSubmitting} min={1} max={99} onChange={(e) => @@ -141,6 +143,7 @@ export default function NewContract() { className="textarea w-full h-24 textarea-bordered" placeholder={descriptionPlaceholder} value={description} + disabled={isSubmitting} onClick={(e) => e.stopPropagation()} onChange={(e) => setDescription(e.target.value || '')} /> From 477878a09fe91c44fa5405628d5bd8a362f0fd1d Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Wed, 5 Jan 2022 23:02:36 -0600 Subject: [PATCH 68/81] Right align tweet button --- web/components/contract-overview.tsx | 17 ++++++++++------- web/components/tweet-button.tsx | 7 ++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index 0e0b7ecc..e044ec18 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -54,15 +54,18 @@ export const ContractOverview = (props: { /> <ContractDetails contract={contract} /> - <TweetButton tweetText={tweetText} /> + <TweetButton className="self-end md:hidden" tweetText={tweetText} /> </Col> - <ResolutionOrChance - className="hidden md:flex md:items-end" - resolution={resolution} - probPercent={probPercent} - large - /> + <Col className="hidden md:flex justify-between items-end"> + <ResolutionOrChance + className="items-end" + resolution={resolution} + probPercent={probPercent} + large + /> + <TweetButton className="mt-6" tweetText={tweetText} /> + </Col> </Row> <Spacer h={4} /> diff --git a/web/components/tweet-button.tsx b/web/components/tweet-button.tsx index 2fd4adcd..60a9f732 100644 --- a/web/components/tweet-button.tsx +++ b/web/components/tweet-button.tsx @@ -5,11 +5,8 @@ export function TweetButton(props: { className?: string; tweetText?: string }) { return ( <a - className={clsx( - 'btn btn-xs normal-case self-start border-none', - className - )} - style={{ backgroundColor: '#1da1f2' }} + className={clsx('btn btn-xs normal-case border-none', className)} + style={{ backgroundColor: '#1da1f2', width: 75 }} href={`https://twitter.com/intent/tweet?text=${encodeURIComponent( tweetText ?? '' )}`} From f218a74a0c4985d76d4ffa526ff60591476060f7 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 6 Jan 2022 00:45:30 -0800 Subject: [PATCH 69/81] Add probability slider to Create Market --- web/package.json | 3 ++- web/pages/create.tsx | 61 ++++++++++++++++++++++++++------------------ web/yarn.lock | 23 ++++++++++++++++- 3 files changed, 60 insertions(+), 27 deletions(-) diff --git a/web/package.json b/web/package.json index d21da96b..f5ad181f 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,8 @@ "lodash": "4.17.21", "next": "12.0.7", "react": "17.0.2", - "react-dom": "17.0.2" + "react-dom": "17.0.2", + "react-expanding-textarea": "^2.3.4" }, "devDependencies": { "@tailwindcss/forms": "0.4.0", diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 467cb407..980443cc 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -2,6 +2,7 @@ import router from 'next/router' import { useEffect, useState } from 'react' import clsx from 'clsx' import dayjs from 'dayjs' +import Textarea from 'react-expanding-textarea' import { CreatorContractsList } from '../components/contracts-list' import { Spacer } from '../components/layout/spacer' @@ -12,6 +13,7 @@ import { Page } from '../components/page' import { formatMoney } from '../lib/util/format' import { AdvancedPanel } from '../components/advanced-panel' import { createContract } from '../lib/firebase/api-call' +import { Row } from '../components/layout/row' // Allow user to create a new contract export default function NewContract() { @@ -101,10 +103,9 @@ export default function NewContract() { <span className="label-text">Question</span> </label> - <input - type="text" + <Textarea placeholder="e.g. Will the FDA will approve Paxlovid before Jun 2nd, 2022?" - className="input input-bordered" + className="input input-bordered resize-none" disabled={isSubmitting} value={question} onChange={(e) => setQuestion(e.target.value || '')} @@ -117,20 +118,30 @@ export default function NewContract() { <label className="label"> <span className="label-text">Initial probability</span> </label> - <label className="input-group input-group-md w-fit"> + <Row className="items-center gap-2"> + <label className="input-group input-group-lg w-fit text-xl"> + <input + type="number" + value={initialProb} + className="input input-bordered input-md text-primary text-4xl w-24" + disabled={isSubmitting} + min={1} + max={99} + onChange={(e) => + setInitialProb(parseInt(e.target.value.substring(0, 2))) + } + /> + <span>%</span> + </label> <input - type="number" - value={initialProb} - className="input input-bordered input-md" - disabled={isSubmitting} + type="range" + className="range range-primary" min={1} max={99} - onChange={(e) => - setInitialProb(parseInt(e.target.value.substring(0, 2))) - } + value={initialProb} + onChange={(e) => setInitialProb(parseInt(e.target.value))} /> - <span>%</span> - </label> + </Row> </div> <Spacer h={4} /> @@ -139,8 +150,9 @@ export default function NewContract() { <label className="label"> <span className="label-text">Description</span> </label> - <textarea - className="textarea w-full h-24 textarea-bordered" + <Textarea + className="textarea w-full textarea-bordered" + rows={3} placeholder={descriptionPlaceholder} value={description} disabled={isSubmitting} @@ -156,7 +168,7 @@ export default function NewContract() { </label> <label className="input-group"> - <span className="text-sm bg-gray-200">M$</span> + <span className="text-sm ">M$</span> <input className={clsx( 'input input-bordered', @@ -170,20 +182,19 @@ export default function NewContract() { onChange={(e) => onAnteChange(e.target.value)} /> </label> - - <div className="mt-3 mb-1 text-sm text-gray-400"> - Remaining balance - </div> - <div> - {formatMoney(remainingBalance > 0 ? remainingBalance : 0)} - </div> + <label> + <span className="label-text text-gray-400 ml-1"> + Remaining balance:{' '} + {formatMoney(remainingBalance > 0 ? remainingBalance : 0)} + </span> + </label> </div> <Spacer h={4} /> <div className="form-control"> <label className="label"> - <span className="label-text">Close date (optional)</span> + <span className="label-text">Close date</span> </label> <input type="date" @@ -196,7 +207,7 @@ export default function NewContract() { /> </div> <label> - <span className="label-text text-gray-400 ml-2"> + <span className="label-text text-gray-400 ml-1"> No new trades will be allowed after{' '} {closeDate ? formattedCloseTime : 'this time'} </span> diff --git a/web/yarn.lock b/web/yarn.lock index f1f30d9d..ade299cf 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -2289,6 +2289,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-shallow-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" + integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== + fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -3837,6 +3842,15 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-expanding-textarea@^2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/react-expanding-textarea/-/react-expanding-textarea-2.3.4.tgz#3eee788cf3b36798d0f9aed5a50f752278d44a49" + integrity sha512-zg/14CyPrIbjPgjfQGxcCmSv9nCrSNpmYpnqyOnNwaOJb8zxWD/GXN8Dgnp5jx8CPU6uIfZVhxs7h2hiOXiSHQ== + dependencies: + fast-shallow-equal "^1.0.0" + react-with-forwarded-ref "^0.3.3" + tslib "^2.0.3" + react-is@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -3866,6 +3880,13 @@ react-refresh@0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-with-forwarded-ref@^0.3.3: + version "0.3.4" + resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5" + integrity sha512-SRq/uTdTh+02JDwYzEEhY2aNNWl/CP2EKP2nQtXzhJw06w6PgYnJt2ObrebvFJu6+wGzX3vDHU3H/ux9hxyZUQ== + dependencies: + tslib "^2.0.3" + react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -4508,7 +4529,7 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0: +tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== From 4659cab18c04d35703387246bc8ab44faedc1fe1 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 6 Jan 2022 01:48:27 -0800 Subject: [PATCH 70/81] Only "Advanced" label should look clickable --- web/components/advanced-panel.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/components/advanced-panel.tsx b/web/components/advanced-panel.tsx index 491e9c3b..c607a75d 100644 --- a/web/components/advanced-panel.tsx +++ b/web/components/advanced-panel.tsx @@ -9,11 +9,14 @@ export function AdvancedPanel(props: { children: any }) { <div tabIndex={0} className={clsx( - 'cursor-pointer relative collapse collapse-arrow', + 'relative collapse collapse-arrow', collapsed ? 'collapse-close' : 'collapse-open' )} > - <div onClick={() => setCollapsed((collapsed) => !collapsed)}> + <div + onClick={() => setCollapsed((collapsed) => !collapsed)} + className="cursor-pointer" + > <div className="mt-4 mr-6 text-sm text-gray-400 text-right"> Advanced </div> From 95472c5faf4e6b0c600dbfcd9097ffa01008bbd6 Mon Sep 17 00:00:00 2001 From: Austin Chen <akrolsmir@gmail.com> Date: Thu, 6 Jan 2022 01:49:41 -0800 Subject: [PATCH 71/81] Tweak bet panel UI --- web/components/bet-panel.tsx | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index be89621f..bf92f033 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -109,8 +109,10 @@ export function BetPanel(props: { contract: Contract; className?: string }) { const remainingBalance = (user?.balance || 0) - (betAmount || 0) return ( - <Col className={clsx('bg-white shadow-md px-8 py-6 rounded-md', className)}> - <Title className="!mt-0 whitespace-nowrap" text="Place a trade" /> + <Col + className={clsx('bg-gray-100 shadow-md px-8 py-6 rounded-md', className)} + > + <Title className="!mt-0 whitespace-nowrap" text={`Buy ${betChoice}`} /> <div className="mt-2 mb-1 text-sm text-gray-400">Outcome</div> <YesNoSelector @@ -119,7 +121,14 @@ export function BetPanel(props: { contract: Contract; className?: string }) { onSelect={(choice) => onBetChoice(choice)} /> - <div className="mt-3 mb-1 text-sm text-gray-400">Amount</div> + <div className="mt-3 mb-1 text-sm text-gray-400"> + Amount{' '} + {user && ( + <span className="float-right"> + {formatMoney(remainingBalance > 0 ? remainingBalance : 0)} left + </span> + )} + </div> <Col className="my-2"> <label className="input-group"> <span className="text-sm bg-gray-200">M$</span> @@ -142,15 +151,6 @@ export function BetPanel(props: { contract: Contract; className?: string }) { )} </Col> - {user && ( - <> - <div className="mt-3 mb-1 text-sm text-gray-400"> - Remaining balance - </div> - <div>{formatMoney(remainingBalance > 0 ? remainingBalance : 0)}</div> - </> - )} - <div className="mt-2 mb-1 text-sm text-gray-400">Implied probability</div> <Row> <div>{formatPercent(initialProb)}</div> @@ -159,10 +159,11 @@ export function BetPanel(props: { contract: Contract; className?: string }) { </Row> <div className="mt-2 mb-1 text-sm text-gray-400"> - Max payout (estimated) + Estimated max payout </div> <div> - {formatMoney(estimatedWinnings)}   (+{estimatedReturnPercent}) + {formatMoney(estimatedWinnings)}  {' '} + {estimatedWinnings ? <span>(+{estimatedReturnPercent})</span> : null} </div> <AdvancedPanel> From 0c9984287c01d9433ffb7c3eb5ba3b7916a94615 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Thu, 6 Jan 2022 11:57:20 -0600 Subject: [PATCH 72/81] Update logo and about page to manifold --- web/components/mantic-logo.tsx | 4 ++-- web/components/profile-menu.tsx | 2 +- web/pages/about.tsx | 34 +++++++++++++++++---------------- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/web/components/mantic-logo.tsx b/web/components/mantic-logo.tsx index 904844cb..070838dc 100644 --- a/web/components/mantic-logo.tsx +++ b/web/components/mantic-logo.tsx @@ -14,11 +14,11 @@ export function ManticLogo(props: { darkBackground?: boolean }) { /> <div className={clsx( - 'font-major-mono lowercase mt-1 sm:text-2xl md:whitespace-nowrap', + 'hidden sm:flex font-major-mono lowercase mt-1 sm:text-2xl md:whitespace-nowrap', darkBackground && 'text-white' )} > - Mantic Markets + Manifold Markets </div> </a> </Link> diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index b5e88d43..2c8ad379 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -67,7 +67,7 @@ function ProfileSummary(props: { user: User }) { <div className="rounded-full w-10 h-10 mr-4"> <Image src={user.avatarUrl} width={40} height={40} /> </div> - <div className="truncate text-left" style={{ maxWidth: 140 }}> + <div className="truncate text-left" style={{ maxWidth: 170 }}> {user.name} <div className="text-gray-700 text-sm">{formatMoney(user.balance)}</div> </div> diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 1f5d9666..d3421020 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -42,7 +42,7 @@ function Contents() { <h1 id="about">About</h1> <hr /> <p> - Mantic Markets is creating better forecasting through user-created + Manifold Markets is creating better forecasting through user-created prediction markets. </p> <p> @@ -77,7 +77,9 @@ function Contents() { </a> . This is the power of prediction markets! </p> - <h3 id="how-does-mantic-markets-work-">How does Mantic Markets work?</h3> + <h3 id="how-does-manifold-markets-work-"> + How does Manifold Markets work? + </h3> <ol> <li> <strong> @@ -96,7 +98,7 @@ function Contents() { </p> <li> <strong> - Anyone can bet on a market using Mantic Dollars (M$), our platform + Anyone can bet on a market using Manifold Dollars (M$), our platform currency. </strong> </li> @@ -104,15 +106,15 @@ function Contents() { <p> You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, - you'll win Mantic Dollars from people who bet against you. + you'll win Manifold Dollars from people who bet against you. </p> {/* <p> If you run out of money, you can purchase more at a rate of $1 USD to M$ - 100. (Note that Mantic Dollars are not convertible to cash and can only + 100. (Note that Manifold Dollars are not convertible to cash and can only be used within our platform.) </p> */} <aside> - 💡 We're still in Open Beta; we'll tweak the amounts of Mantic + 💡 We're still in Open Beta; we'll tweak the amounts of Manifold Dollars given out and periodically reset balances before our official launch. {/* If you purchase @@ -130,7 +132,7 @@ function Contents() { </p> <p>By buying M$, you support:</p> <ul> - <li>The continued development of Mantic Markets</li> + <li>The continued development of Manifold Markets</li> <li>Cash payouts to market creators (TBD)</li> <li>Forecasting tournaments for bettors (TBD)</li> </ul> @@ -206,12 +208,12 @@ function Contents() { they use on our platform. </p> <p> - With Mantic Dollars being a scarce resource, people will bet more + With Manifold Dollars being a scarce resource, people will bet more carefully and can't rig the outcome by creating multiple accounts. The result is more accurate predictions. </p> */} <p> - Mantic Markets is focused on accessibility and allowing anyone to + Manifold Markets is focused on accessibility and allowing anyone to quickly create and judge a prediction market. When we all have the power to create and share prediction markets in seconds and apply our own judgment on the outcome, it leads to a qualitative shift in the number, @@ -239,8 +241,8 @@ function Contents() { <h3 id="type-of-market-maker">What kind of betting system do you use?</h3> <p> - Mantic Markets uses a special type of automated market marker based on a - dynamic pari-mutuel (DPM) betting system. + Manifold Markets uses a special type of automated market marker based on + a dynamic pari-mutuel (DPM) betting system. </p> <p> Like traditional pari-mutuel systems, your payoff is not known at the @@ -258,7 +260,7 @@ function Contents() { </p> <h3 id="who-are-we-">Who are we?</h3> - <p>Mantic Markets is currently a team of three:</p> + <p>Manifold Markets is currently a team of three:</p> <ul> <li>James Grugett</li> <li>Stephen Grugett</li> @@ -278,7 +280,7 @@ function Contents() { </p> <ul> <li> - Email: <code>info@mantic.markets</code> + Email: <code>info@manifold.markets</code> </li> <li> Office hours:{' '} @@ -288,7 +290,7 @@ function Contents() { </ul> <p> <a href="https://discord.gg/eHQBNBqXuh"> - Join the Mantic Markets Discord Server! + Join the Manifold Markets Discord Server! </a> </p> @@ -298,7 +300,7 @@ function Contents() { <ul> <li> <a href="https://manifoldmarkets.notion.site/Technical-Overview-b9b48a09ea1f45b88d991231171730c5"> - Technical Overview of Mantic Markets + Technical Overview of Manifold Markets </a> </li> <li> @@ -322,7 +324,7 @@ function Contents() { </a> </li> <li> - <a href="https://mantic.markets/simulator"> + <a href="https://manifold.markets/simulator"> Dynamic parimutuel market simulator </a> </li> From b928d0e70ccd6d838b2421074de7a17f80a584b8 Mon Sep 17 00:00:00 2001 From: jahooma <jahooma@gmail.com> Date: Thu, 6 Jan 2022 12:40:27 -0600 Subject: [PATCH 73/81] Change urls, titles / metatags, landing page --- README.md | 3 ++- functions/src/emails.ts | 2 +- web/README.md | 2 +- web/components/SEO.tsx | 4 ++-- web/components/contract-overview.tsx | 2 +- web/pages/_app.tsx | 14 +++++++------- web/pages/landing-page.tsx | 4 ++-- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c21c87ac..496fa34c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # mantic -Mantic Markets + +Manifold Markets diff --git a/functions/src/emails.ts b/functions/src/emails.ts index ba4874d3..0f85638f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -26,7 +26,7 @@ Resolution: ${toDisplayResolution[resolution]} Your payout is M$ ${Math.round(payout)} View the market here: -https://mantic.markets/${creator.username}/${contract.slug} +https://manifold.markets/${creator.username}/${contract.slug} ` await sendEmail(user.email, subject, body) } diff --git a/web/README.md b/web/README.md index ff9d1ec0..e16f0e9c 100644 --- a/web/README.md +++ b/web/README.md @@ -1,4 +1,4 @@ -# Mantic Markets web +# Manifold Markets web ## Getting Started diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 6e4b76b6..e8d6547f 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -10,7 +10,7 @@ export function SEO(props: { return ( <Head> - <title>{title} | Mantic Markets + {title} | Manifold Markets )} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index e044ec18..6eff1bda 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -35,7 +35,7 @@ export const ContractOverview = (props: { ? `Resolved ${resolution}!` : `Resolved ${resolution} by ${creatorName}:` : `Currently ${probPercent} chance, place your bets here:` - const url = `https://mantic.markets${path(contract)}` + const url = `https://manifold.markets${path(contract)}` const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` return ( diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 1448a27e..b9c8d164 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -6,32 +6,32 @@ function MyApp({ Component, pageProps }: AppProps) { return ( <> - Mantic Markets + Manifold Markets - + - + diff --git a/web/pages/landing-page.tsx b/web/pages/landing-page.tsx index 13d209b6..c85aa5f3 100644 --- a/web/pages/landing-page.tsx +++ b/web/pages/landing-page.tsx @@ -94,7 +94,7 @@ function FeaturesSection() { { name: 'Play money, real results', description: - 'Get accurate predictions by betting with Mantic Dollars, our virtual currency.', + 'Get accurate predictions by betting with Manifold Dollars, our virtual currency.', icon: LightningBoltIcon, }, { @@ -117,7 +117,7 @@ function FeaturesSection() {

    - Mantic Markets + Manifold Markets

    Better forecasting for everyone From 85b5ee5cbc10b0024afcb54a6645b5658e0bc1ea Mon Sep 17 00:00:00 2001 From: James Grugett Date: Thu, 6 Jan 2022 12:48:30 -0600 Subject: [PATCH 74/81] Rename to Manifold Markets! (#19) * Update logo and about page to manifold * Change urls, titles / metatags, landing page --- README.md | 3 ++- functions/src/emails.ts | 2 +- web/README.md | 2 +- web/components/SEO.tsx | 4 ++-- web/components/contract-overview.tsx | 2 +- web/components/mantic-logo.tsx | 4 ++-- web/components/profile-menu.tsx | 2 +- web/pages/_app.tsx | 14 ++++++------ web/pages/about.tsx | 34 +++++++++++++++------------- web/pages/landing-page.tsx | 4 ++-- 10 files changed, 37 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c21c87ac..496fa34c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # mantic -Mantic Markets + +Manifold Markets diff --git a/functions/src/emails.ts b/functions/src/emails.ts index ba4874d3..0f85638f 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -26,7 +26,7 @@ Resolution: ${toDisplayResolution[resolution]} Your payout is M$ ${Math.round(payout)} View the market here: -https://mantic.markets/${creator.username}/${contract.slug} +https://manifold.markets/${creator.username}/${contract.slug} ` await sendEmail(user.email, subject, body) } diff --git a/web/README.md b/web/README.md index ff9d1ec0..e16f0e9c 100644 --- a/web/README.md +++ b/web/README.md @@ -1,4 +1,4 @@ -# Mantic Markets web +# Manifold Markets web ## Getting Started diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 6e4b76b6..e8d6547f 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -10,7 +10,7 @@ export function SEO(props: { return ( - {title} | Mantic Markets + {title} | Manifold Markets )} diff --git a/web/components/contract-overview.tsx b/web/components/contract-overview.tsx index e044ec18..6eff1bda 100644 --- a/web/components/contract-overview.tsx +++ b/web/components/contract-overview.tsx @@ -35,7 +35,7 @@ export const ContractOverview = (props: { ? `Resolved ${resolution}!` : `Resolved ${resolution} by ${creatorName}:` : `Currently ${probPercent} chance, place your bets here:` - const url = `https://mantic.markets${path(contract)}` + const url = `https://manifold.markets${path(contract)}` const tweetText = `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` return ( diff --git a/web/components/mantic-logo.tsx b/web/components/mantic-logo.tsx index 904844cb..070838dc 100644 --- a/web/components/mantic-logo.tsx +++ b/web/components/mantic-logo.tsx @@ -14,11 +14,11 @@ export function ManticLogo(props: { darkBackground?: boolean }) { />

    - Mantic Markets + Manifold Markets
    diff --git a/web/components/profile-menu.tsx b/web/components/profile-menu.tsx index b5e88d43..2c8ad379 100644 --- a/web/components/profile-menu.tsx +++ b/web/components/profile-menu.tsx @@ -67,7 +67,7 @@ function ProfileSummary(props: { user: User }) {
    -
    +
    {user.name}
    {formatMoney(user.balance)}
    diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 1448a27e..b9c8d164 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -6,32 +6,32 @@ function MyApp({ Component, pageProps }: AppProps) { return ( <> - Mantic Markets + Manifold Markets - + - + diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 1f5d9666..d3421020 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -42,7 +42,7 @@ function Contents() {

    About


    - Mantic Markets is creating better forecasting through user-created + Manifold Markets is creating better forecasting through user-created prediction markets.

    @@ -77,7 +77,9 @@ function Contents() { . This is the power of prediction markets!

    -

    How does Mantic Markets work?

    +

    + How does Manifold Markets work? +

    1. @@ -96,7 +98,7 @@ function Contents() {

    2. - Anyone can bet on a market using Mantic Dollars (M$), our platform + Anyone can bet on a market using Manifold Dollars (M$), our platform currency.
    3. @@ -104,15 +106,15 @@ function Contents() {

      You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, - you'll win Mantic Dollars from people who bet against you. + you'll win Manifold Dollars from people who bet against you.

      {/*

      If you run out of money, you can purchase more at a rate of $1 USD to M$ - 100. (Note that Mantic Dollars are not convertible to cash and can only + 100. (Note that Manifold Dollars are not convertible to cash and can only be used within our platform.)

      */}