Update to 1.3.1, merge branch 'main' into link-summoner

This commit is contained in:
Austin Chen 2022-05-27 12:23:26 -07:00
commit 7caaa69b6f
180 changed files with 5174 additions and 3503 deletions

55
.github/workflows/check.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Check PRs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
FORCE_COLOR: 3
NEXT_TELEMETRY_DISABLED: 1
jobs:
check:
name: Static analysis
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Restore cached node_modules
uses: actions/cache@v2
with:
path: '**/node_modules'
key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
- name: Install missing dependencies
run: yarn install --prefer-offline --frozen-lockfile
- name: Run Prettier on web client
working-directory: web
run: npx prettier --check .
- name: Run ESLint on common
if: ${{ success() || failure() }}
working-directory: common
run: npx eslint . --max-warnings 0
- name: Run ESLint on web client
if: ${{ success() || failure() }}
working-directory: web
run: yarn lint --max-warnings 0
- name: Run ESLint on cloud functions
if: ${{ success() || failure() }}
working-directory: functions
run: npx eslint . --max-warnings 0
- name: Run Typescript checker on web client
if: ${{ success() || failure() }}
working-directory: web
run: tsc --pretty --project tsconfig.json --noEmit
- name: Run Typescript checker on cloud functions
if: ${{ success() || failure() }}
working-directory: functions
run: tsc --pretty --project tsconfig.json --noEmit

29
common/.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
env: {
browser: true,
node: true,
},
overrides: [
{
files: ['**/*.ts'],
plugins: ['@typescript-eslint'],
extends: ['plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
],
rules: {
'no-extra-semi': 'off',
'no-unused-vars': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -1,6 +1,14 @@
import { Bet } from './bet' import { range } from 'lodash'
import { getDpmProbability } from './calculate-dpm' import { Bet, NumericBet } from './bet'
import { Binary, CPMM, DPM, FreeResponse, FullContract } from './contract' import { getDpmProbability, getValueFromBucket } from './calculate-dpm'
import {
Binary,
CPMM,
DPM,
FreeResponse,
FullContract,
Numeric,
} from './contract'
import { User } from './user' import { User } from './user'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { noFees } from './fees' import { noFees } from './fees'
@ -80,7 +88,7 @@ export function getAnteBets(
} }
export function getFreeAnswerAnte( export function getFreeAnswerAnte(
creator: User, anteBettorId: string,
contract: FullContract<DPM, FreeResponse>, contract: FullContract<DPM, FreeResponse>,
anteBetId: string anteBetId: string
) { ) {
@ -92,7 +100,7 @@ export function getFreeAnswerAnte(
const anteBet: Bet = { const anteBet: Bet = {
id: anteBetId, id: anteBetId,
userId: creator.id, userId: anteBettorId,
contractId: contract.id, contractId: contract.id,
amount, amount,
shares, shares,
@ -106,3 +114,42 @@ export function getFreeAnswerAnte(
return anteBet return anteBet
} }
export function getNumericAnte(
creator: User,
contract: FullContract<DPM, Numeric>,
ante: number,
newBetId: string
) {
const { bucketCount, createdTime } = contract
const betAnte = ante / bucketCount
const betShares = Math.sqrt(ante ** 2 / bucketCount)
const allOutcomeShares = Object.fromEntries(
range(0, bucketCount).map((_, i) => [i, betShares])
)
const allBetAmounts = Object.fromEntries(
range(0, bucketCount).map((_, i) => [i, betAnte])
)
const anteBet: NumericBet = {
id: newBetId,
userId: creator.id,
contractId: contract.id,
amount: ante,
allBetAmounts,
outcome: '0',
value: getValueFromBucket('0', contract),
shares: betShares,
allOutcomeShares,
probBefore: 0,
probAfter: 1 / bucketCount,
createdTime,
isAnte: true,
fees: noFees,
}
return anteBet
}

View File

@ -29,4 +29,10 @@ export type Bet = {
createdTime: number createdTime: number
} }
export type NumericBet = Bet & {
value: number
allOutcomeShares: { [outcome: string]: number }
allBetAmounts: { [outcome: string]: number }
}
export const MAX_LOAN_PER_CONTRACT = 20 export const MAX_LOAN_PER_CONTRACT = 20

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash' import { sum, groupBy, mapValues, sumBy } from 'lodash'
import { Binary, CPMM, FullContract } from './contract' import { Binary, CPMM, FullContract } from './contract'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
@ -63,10 +63,8 @@ export function getCpmmLiquidityFee(
bet: number, bet: number,
outcome: string outcome: string
) { ) {
const probBefore = getCpmmProbability(contract.pool, contract.p) const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet)
const probAfter = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet) const betP = outcome === 'YES' ? 1 - prob : prob
const probMid = Math.sqrt(probBefore * probAfter)
const betP = outcome === 'YES' ? 1 - probMid : probMid
const liquidityFee = LIQUIDITY_FEE * betP * bet const liquidityFee = LIQUIDITY_FEE * betP * bet
const platformFee = PLATFORM_FEE * betP * bet const platformFee = PLATFORM_FEE * betP * bet
@ -278,16 +276,16 @@ export function getCpmmLiquidityPoolWeights(
return liquidity return liquidity
}) })
const shareSum = _.sum(liquidityShares) const shareSum = sum(liquidityShares)
const weights = liquidityShares.map((s, i) => ({ const weights = liquidityShares.map((s, i) => ({
weight: s / shareSum, weight: s / shareSum,
providerId: liquidities[i].userId, providerId: liquidities[i].userId,
})) }))
const userWeights = _.groupBy(weights, (w) => w.providerId) const userWeights = groupBy(weights, (w) => w.providerId)
const totalUserWeights = _.mapValues(userWeights, (userWeight) => const totalUserWeights = mapValues(userWeights, (userWeight) =>
_.sumBy(userWeight, (w) => w.weight) sumBy(userWeight, (w) => w.weight)
) )
return totalUserWeights return totalUserWeights
} }

View File

@ -1,7 +1,16 @@
import * as _ from 'lodash' import { cloneDeep, range, sum, sumBy, sortBy, mapValues } from 'lodash'
import { Bet } from './bet' import { Bet, NumericBet } from './bet'
import { Binary, DPM, FreeResponse, FullContract } from './contract' import {
Binary,
DPM,
FreeResponse,
FullContract,
Numeric,
NumericContract,
} from './contract'
import { DPM_FEES } from './fees' import { DPM_FEES } from './fees'
import { normpdf } from '../common/util/math'
import { addObjects } from './util/object'
export function getDpmProbability(totalShares: { [outcome: string]: number }) { export function getDpmProbability(totalShares: { [outcome: string]: number }) {
// For binary contracts only. // For binary contracts only.
@ -14,11 +23,94 @@ export function getDpmOutcomeProbability(
}, },
outcome: string outcome: string
) { ) {
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) const squareSum = sumBy(Object.values(totalShares), (shares) => shares ** 2)
const shares = totalShares[outcome] ?? 0 const shares = totalShares[outcome] ?? 0
return shares ** 2 / squareSum return shares ** 2 / squareSum
} }
export function getDpmOutcomeProbabilities(totalShares: {
[outcome: string]: number
}) {
const squareSum = sumBy(Object.values(totalShares), (shares) => shares ** 2)
return mapValues(totalShares, (shares) => shares ** 2 / squareSum)
}
export function getNumericBets(
contract: NumericContract,
bucket: string,
betAmount: number,
variance: number
) {
const { bucketCount } = contract
const bucketNumber = parseInt(bucket)
const buckets = range(0, bucketCount)
const mean = bucketNumber / bucketCount
const allDensities = buckets.map((i) =>
normpdf(i / bucketCount, mean, variance)
)
const densitySum = sum(allDensities)
const rawBetAmounts = allDensities
.map((d) => (d / densitySum) * betAmount)
.map((x) => (x >= 1 / bucketCount ? x : 0))
const rawSum = sum(rawBetAmounts)
const scaledBetAmounts = rawBetAmounts.map((x) => (x / rawSum) * betAmount)
const bets = scaledBetAmounts
.map((x, i) => (x > 0 ? [i.toString(), x] : undefined))
.filter((x) => x != undefined) as [string, number][]
return bets
}
export const getMappedBucket = (value: number, contract: NumericContract) => {
const { bucketCount, min, max } = contract
const index = Math.floor(((value - min) / (max - min)) * bucketCount)
const bucket = Math.max(Math.min(index, bucketCount - 1), 0)
return `${bucket}`
}
export const getValueFromBucket = (
bucket: string,
contract: NumericContract
) => {
const { bucketCount, min, max } = contract
const index = parseInt(bucket)
const value = min + (index / bucketCount) * (max - min)
const rounded = Math.round(value * 1e4) / 1e4
return rounded
}
export const getExpectedValue = (contract: NumericContract) => {
const { bucketCount, min, max, totalShares } = contract
const totalShareSum = sumBy(
Object.values(totalShares),
(shares) => shares ** 2
)
const probs = range(0, bucketCount).map(
(i) => totalShares[i] ** 2 / totalShareSum
)
const values = range(0, bucketCount).map(
(i) =>
// use mid point within bucket
0.5 * (min + (i / bucketCount) * (max - min)) +
0.5 * (min + ((i + 1) / bucketCount) * (max - min))
)
const weightedValues = range(0, bucketCount).map((i) => probs[i] * values[i])
const expectation = sum(weightedValues)
const rounded = Math.round(expectation * 1e2) / 1e2
return rounded
}
export function getDpmOutcomeProbabilityAfterBet( export function getDpmOutcomeProbabilityAfterBet(
totalShares: { totalShares: {
[outcome: string]: number [outcome: string]: number
@ -55,7 +147,7 @@ export function calculateDpmShares(
bet: number, bet: number,
betChoice: string betChoice: string
) { ) {
const squareSum = _.sumBy(Object.values(totalShares), (shares) => shares ** 2) const squareSum = sumBy(Object.values(totalShares), (shares) => shares ** 2)
const shares = totalShares[betChoice] ?? 0 const shares = totalShares[betChoice] ?? 0
const c = 2 * bet * Math.sqrt(squareSum) const c = 2 * bet * Math.sqrt(squareSum)
@ -63,6 +155,30 @@ export function calculateDpmShares(
return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares return Math.sqrt(bet ** 2 + shares ** 2 + c) - shares
} }
export function calculateNumericDpmShares(
totalShares: {
[outcome: string]: number
},
bets: [string, number][]
) {
const shares: number[] = []
totalShares = cloneDeep(totalShares)
const order = sortBy(
bets.map(([, amount], i) => [amount, i]),
([amount]) => amount
).map(([, i]) => i)
for (const i of order) {
const [bucket, bet] = bets[i]
shares[i] = calculateDpmShares(totalShares, bet, bucket)
totalShares = addObjects(totalShares, { [bucket]: shares[i] })
}
return { shares, totalShares }
}
export function calculateDpmRawShareValue( export function calculateDpmRawShareValue(
totalShares: { totalShares: {
[outcome: string]: number [outcome: string]: number
@ -71,11 +187,11 @@ export function calculateDpmRawShareValue(
betChoice: string betChoice: string
) { ) {
const currentValue = Math.sqrt( const currentValue = Math.sqrt(
_.sumBy(Object.values(totalShares), (shares) => shares ** 2) sumBy(Object.values(totalShares), (shares) => shares ** 2)
) )
const postSaleValue = Math.sqrt( const postSaleValue = Math.sqrt(
_.sumBy(Object.keys(totalShares), (outcome) => sumBy(Object.keys(totalShares), (outcome) =>
outcome === betChoice outcome === betChoice
? Math.max(0, totalShares[outcome] - shares) ** 2 ? Math.max(0, totalShares[outcome] - shares) ** 2
: totalShares[outcome] ** 2 : totalShares[outcome] ** 2
@ -95,12 +211,12 @@ export function calculateDpmMoneyRatio(
const p = getDpmOutcomeProbability(totalShares, outcome) const p = getDpmOutcomeProbability(totalShares, outcome)
const actual = _.sum(Object.values(pool)) - shareValue const actual = sum(Object.values(pool)) - shareValue
const betAmount = p * amount const betAmount = p * amount
const expected = const expected =
_.sumBy( sumBy(
Object.keys(totalBets), Object.keys(totalBets),
(outcome) => (outcome) =>
getDpmOutcomeProbability(totalShares, outcome) * getDpmOutcomeProbability(totalShares, outcome) *
@ -152,8 +268,8 @@ export function calculateDpmCancelPayout(
bet: Bet bet: Bet
) { ) {
const { totalBets, pool } = contract const { totalBets, pool } = contract
const betTotal = _.sum(Object.values(totalBets)) const betTotal = sum(Object.values(totalBets))
const poolTotal = _.sum(Object.values(pool)) const poolTotal = sum(Object.values(pool))
return (bet.amount / betTotal) * poolTotal return (bet.amount / betTotal) * poolTotal
} }
@ -163,27 +279,39 @@ export function calculateStandardDpmPayout(
bet: Bet, bet: Bet,
outcome: string outcome: string
) { ) {
const { amount, outcome: betOutcome, shares } = bet const { outcome: betOutcome } = bet
if (betOutcome !== outcome) return 0 const isNumeric = contract.outcomeType === 'NUMERIC'
if (!isNumeric && betOutcome !== outcome) return 0
const shares = isNumeric
? ((bet as NumericBet).allOutcomeShares ?? {})[outcome]
: bet.shares
if (!shares) return 0
const { totalShares, phantomShares, pool } = contract const { totalShares, phantomShares, pool } = contract
if (!totalShares[outcome]) return 0 if (!totalShares[outcome]) return 0
const poolTotal = _.sum(Object.values(pool)) const poolTotal = sum(Object.values(pool))
const total = const total =
totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0) totalShares[outcome] - (phantomShares ? phantomShares[outcome] : 0)
const winnings = (shares / total) * poolTotal const winnings = (shares / total) * poolTotal
// profit can be negative if using phantom shares
return amount + (1 - DPM_FEES) * Math.max(0, winnings - amount) const amount = isNumeric
? (bet as NumericBet).allBetAmounts[outcome]
: bet.amount
const payout = amount + (1 - DPM_FEES) * Math.max(0, winnings - amount)
return payout
} }
export function calculateDpmPayoutAfterCorrectBet( export function calculateDpmPayoutAfterCorrectBet(
contract: FullContract<DPM, any>, contract: FullContract<DPM, any>,
bet: Bet bet: Bet
) { ) {
const { totalShares, pool, totalBets } = contract const { totalShares, pool, totalBets, outcomeType } = contract
const { shares, amount, outcome } = bet const { shares, amount, outcome } = bet
const prevShares = totalShares[outcome] ?? 0 const prevShares = totalShares[outcome] ?? 0
@ -204,45 +332,60 @@ export function calculateDpmPayoutAfterCorrectBet(
...totalBets, ...totalBets,
[outcome]: prevTotalBet + amount, [outcome]: prevTotalBet + amount,
}, },
outcomeType:
outcomeType === 'NUMERIC'
? 'FREE_RESPONSE' // hack to show payout at particular bet point estimate
: outcomeType,
} }
return calculateStandardDpmPayout(newContract, bet, outcome) return calculateStandardDpmPayout(newContract, bet, outcome)
} }
function calculateMktDpmPayout(contract: FullContract<DPM, any>, bet: Bet) { function calculateMktDpmPayout(
contract: FullContract<DPM, Binary | FreeResponse | Numeric>,
bet: Bet
) {
if (contract.outcomeType === 'BINARY') if (contract.outcomeType === 'BINARY')
return calculateBinaryMktDpmPayout(contract, bet) return calculateBinaryMktDpmPayout(contract, bet)
const { totalShares, pool, resolutions } = contract as FullContract< const { totalShares, pool, resolutions, outcomeType } = contract
DPM,
FreeResponse
>
let probs: { [outcome: string]: number } let probs: { [outcome: string]: number }
if (resolutions) { if (resolutions) {
const probTotal = _.sum(Object.values(resolutions)) const probTotal = sum(Object.values(resolutions))
probs = _.mapValues( probs = mapValues(
totalShares, totalShares,
(_, outcome) => (resolutions[outcome] ?? 0) / probTotal (_, outcome) => (resolutions[outcome] ?? 0) / probTotal
) )
} else { } else {
const squareSum = _.sum( const squareSum = sum(
Object.values(totalShares).map((shares) => shares ** 2) Object.values(totalShares).map((shares) => shares ** 2)
) )
probs = _.mapValues(totalShares, (shares) => shares ** 2 / squareSum) probs = mapValues(totalShares, (shares) => shares ** 2 / squareSum)
} }
const weightedShareTotal = _.sumBy(Object.keys(totalShares), (outcome) => { const weightedShareTotal = sumBy(Object.keys(totalShares), (outcome) => {
return probs[outcome] * totalShares[outcome] return probs[outcome] * totalShares[outcome]
}) })
const { outcome, amount, shares } = bet const { outcome, amount, shares } = bet
const totalPool = _.sum(Object.values(pool)) const poolFrac =
const poolFrac = (probs[outcome] * shares) / weightedShareTotal outcomeType === 'NUMERIC'
const winnings = poolFrac * totalPool ? sumBy(
Object.keys((bet as NumericBet).allOutcomeShares ?? {}),
(outcome) => {
return (
(probs[outcome] * (bet as NumericBet).allOutcomeShares[outcome]) /
weightedShareTotal
)
}
)
: (probs[outcome] * shares) / weightedShareTotal
const totalPool = sum(Object.values(pool))
const winnings = poolFrac * totalPool
return deductDpmFees(amount, winnings) return deductDpmFees(amount, winnings)
} }

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash' import { maxBy } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { import {
calculateCpmmSale, calculateCpmmSale,
@ -161,7 +161,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return { return {
invested: Math.max(0, currentInvested), invested: Math.max(0, currentInvested),
currentInvested,
payout, payout,
netPayout, netPayout,
profit, profit,
@ -181,7 +180,7 @@ export function getContractBetNullMetrics() {
export function getTopAnswer(contract: FreeResponseContract) { export function getTopAnswer(contract: FreeResponseContract) {
const { answers } = contract const { answers } = contract
const top = _.maxBy( const top = maxBy(
answers?.map((answer) => ({ answers?.map((answer) => ({
answer, answer,
prob: getOutcomeProbability(contract, answer.id), prob: getOutcomeProbability(contract, answer.id),
@ -190,29 +189,3 @@ export function getTopAnswer(contract: FreeResponseContract) {
) )
return top?.answer return top?.answer
} }
export function hasUserHitManaLimit(
contract: FreeResponseContract,
bets: Bet[],
amount: number
) {
const { manaLimitPerUser } = contract
if (manaLimitPerUser) {
const contractMetrics = getContractBetMetrics(contract, bets)
const currentInvested = contractMetrics.currentInvested
console.log('user current invested amount', currentInvested)
console.log('mana limit:', manaLimitPerUser)
if (currentInvested + amount > manaLimitPerUser) {
const manaAllowed = manaLimitPerUser - currentInvested
return {
status: 'error',
message: `Market bet cap is M$${manaLimitPerUser}, you've M$${manaAllowed} left`,
}
}
}
return {
status: 'success',
message: '',
}
}

View File

@ -393,6 +393,23 @@ Future plans: We expect to focus on similar theoretical problems in alignment un
description: description:
'The mission of the Alliance to Feed the Earth in Disasters is to help create resilience to global food shocks. We seek to identify various resilient food solutions and to help governments implement these solutions, to increase the chances that people have enough to eat in the event of a global catastrophe. We focus on events that could deplete food supplies or access to 5% of the global population or more.Our ultimate goal is to feed everyone, no matter what. An important aspect of this goal is that we need to establish equitable solutions so that all people can access the nutrition they need, regardless of wealth or location.ALLFED is inspired by effective altruism, using reason and evidence to identify how to do the most good. Our solutions are backed by science and research, and we also identify the most cost-effective solutions, to be able to provide more nutrition in catastrophes.', 'The mission of the Alliance to Feed the Earth in Disasters is to help create resilience to global food shocks. We seek to identify various resilient food solutions and to help governments implement these solutions, to increase the chances that people have enough to eat in the event of a global catastrophe. We focus on events that could deplete food supplies or access to 5% of the global population or more.Our ultimate goal is to feed everyone, no matter what. An important aspect of this goal is that we need to establish equitable solutions so that all people can access the nutrition they need, regardless of wealth or location.ALLFED is inspired by effective altruism, using reason and evidence to identify how to do the most good. Our solutions are backed by science and research, and we also identify the most cost-effective solutions, to be able to provide more nutrition in catastrophes.',
}, },
{
name: 'The Trevor Project',
website: 'https://www.thetrevorproject.org/',
photo: 'https://i.imgur.com/QN4mVNn.jpeg',
preview: 'The Trevor Project is the worlds largest suicide prevention and crisis intervention organization for LGBTQ (lesbian, gay, bisexual, transgender, queer, and questioning) young people.',
description:
`Two decades ago, we responded to a health crisis. Now were building a safer, more-inclusive world. LGBTQ young people are four times more likely to attempt suicide, and suicide remains the second leading cause of death among all young people in the U.S.
Our Mission
To end suicide among lesbian, gay, bisexual, transgender, queer & questioning young people.
Our Vision
A world where all LGBTQ young people see a bright future for themselves.
Our Goal
To serve 1.8 million crisis contacts annually, by the end of our 25th year, while continuing to innovate on our core services.`,
},
].map((charity) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -3,7 +3,7 @@ import { Fees } from './fees'
export type FullContract< export type FullContract<
M extends DPM | CPMM, M extends DPM | CPMM,
T extends Binary | Multi | FreeResponse T extends Binary | Multi | FreeResponse | Numeric
> = { > = {
id: string id: string
slug: string // auto-generated; must be unique slug: string // auto-generated; must be unique
@ -11,7 +11,7 @@ export type FullContract<
creatorId: string creatorId: string
creatorName: string creatorName: string
creatorUsername: string creatorUsername: string
creatorAvatarUrl?: string // Start requiring after 2022-03-01 creatorAvatarUrl?: string
question: string question: string
description: string // More info about what the contract is about description: string // More info about what the contract is about
@ -31,8 +31,6 @@ export type FullContract<
closeEmailsSent?: number closeEmailsSent?: number
manaLimitPerUser?: number
volume: number volume: number
volume24Hours: number volume24Hours: number
volume7Days: number volume7Days: number
@ -41,9 +39,13 @@ export type FullContract<
} & M & } & M &
T T
export type Contract = FullContract<DPM | CPMM, Binary | Multi | FreeResponse> export type Contract = FullContract<
DPM | CPMM,
Binary | Multi | FreeResponse | Numeric
>
export type BinaryContract = FullContract<DPM | CPMM, Binary> export type BinaryContract = FullContract<DPM | CPMM, Binary>
export type FreeResponseContract = FullContract<DPM | CPMM, FreeResponse> export type FreeResponseContract = FullContract<DPM | CPMM, FreeResponse>
export type NumericContract = FullContract<DPM, Numeric>
export type DPM = { export type DPM = {
mechanism: 'dpm-2' mechanism: 'dpm-2'
@ -83,8 +85,22 @@ export type FreeResponse = {
resolutions?: { [outcome: string]: number } // Used for MKT resolution. resolutions?: { [outcome: string]: number } // Used for MKT resolution.
} }
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' export type Numeric = {
outcomeType: 'NUMERIC'
bucketCount: number
min: number
max: number
resolutions?: { [outcome: string]: number } // Used for MKT resolution.
resolutionValue?: number
}
export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' | 'NUMERIC'
export const OUTCOME_TYPES = [
'BINARY',
'MULTI',
'FREE_RESPONSE',
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480 export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000 export const MAX_DESCRIPTION_LENGTH = 10000
export const MAX_TAG_LENGTH = 60 export const MAX_TAG_LENGTH = 60

View File

@ -1,16 +1,17 @@
import { escapeRegExp } from 'lodash'
import { DEV_CONFIG } from './dev' import { DEV_CONFIG } from './dev'
import { EnvConfig, PROD_CONFIG } from './prod' import { EnvConfig, PROD_CONFIG } from './prod'
import { THEOREMONE_CONFIG } from './theoremone' import { THEOREMONE_CONFIG } from './theoremone'
export const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'DEV' export const ENV = process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'DEV'
const CONFIGS = { const CONFIGS: { [env: string]: EnvConfig } = {
PROD: PROD_CONFIG, PROD: PROD_CONFIG,
DEV: DEV_CONFIG, DEV: DEV_CONFIG,
THEOREMONE: THEOREMONE_CONFIG, THEOREMONE: THEOREMONE_CONFIG,
} }
// @ts-ignore
export const ENV_CONFIG: EnvConfig = CONFIGS[ENV] export const ENV_CONFIG = CONFIGS[ENV]
export function isWhitelisted(email?: string) { export function isWhitelisted(email?: string) {
if (!ENV_CONFIG.whitelistEmail) { if (!ENV_CONFIG.whitelistEmail) {
@ -28,3 +29,10 @@ export const DOMAIN = ENV_CONFIG.domain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
// Manifold's domain or any subdomains thereof
export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
)
// Any localhost server on any port
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/

View File

@ -6,6 +6,7 @@ export const DEV_CONFIG: EnvConfig = {
apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw',
authDomain: 'dev-mantic-markets.firebaseapp.com', authDomain: 'dev-mantic-markets.firebaseapp.com',
projectId: 'dev-mantic-markets', projectId: 'dev-mantic-markets',
region: 'us-central1',
storageBucket: 'dev-mantic-markets.appspot.com', storageBucket: 'dev-mantic-markets.appspot.com',
messagingSenderId: '134303100058', messagingSenderId: '134303100058',
appId: '1:134303100058:web:27f9ea8b83347251f80323', appId: '1:134303100058:web:27f9ea8b83347251f80323',

View File

@ -18,6 +18,7 @@ type FirebaseConfig = {
apiKey: string apiKey: string
authDomain: string authDomain: string
projectId: string projectId: string
region: string
storageBucket: string storageBucket: string
messagingSenderId: string messagingSenderId: string
appId: string appId: string
@ -30,6 +31,7 @@ export const PROD_CONFIG: EnvConfig = {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
authDomain: 'mantic-markets.firebaseapp.com', authDomain: 'mantic-markets.firebaseapp.com',
projectId: 'mantic-markets', projectId: 'mantic-markets',
region: 'us-central1',
storageBucket: 'mantic-markets.appspot.com', storageBucket: 'mantic-markets.appspot.com',
messagingSenderId: '128925704902', messagingSenderId: '128925704902',
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
@ -39,6 +41,7 @@ export const PROD_CONFIG: EnvConfig = {
'akrolsmir@gmail.com', // Austin 'akrolsmir@gmail.com', // Austin
'jahooma@gmail.com', // James 'jahooma@gmail.com', // James
'taowell@gmail.com', // Stephen 'taowell@gmail.com', // Stephen
'abc.sinclair@gmail.com', // Sinclair
'manticmarkets@gmail.com', // Manifold 'manticmarkets@gmail.com', // Manifold
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',

View File

@ -6,6 +6,7 @@ export const THEOREMONE_CONFIG: EnvConfig = {
apiKey: 'AIzaSyBSXL6Ys7InNHnCKSy-_E_luhh4Fkj4Z6M', apiKey: 'AIzaSyBSXL6Ys7InNHnCKSy-_E_luhh4Fkj4Z6M',
authDomain: 'theoremone-manifold.firebaseapp.com', authDomain: 'theoremone-manifold.firebaseapp.com',
projectId: 'theoremone-manifold', projectId: 'theoremone-manifold',
region: 'us-central1',
storageBucket: 'theoremone-manifold.appspot.com', storageBucket: 'theoremone-manifold.appspot.com',
messagingSenderId: '698012149198', messagingSenderId: '698012149198',
appId: '1:698012149198:web:b342af75662831aa84b79f', appId: '1:698012149198:web:b342af75662831aa84b79f',

View File

@ -1,10 +1,12 @@
import * as _ from 'lodash' import { sumBy } from 'lodash'
import { Bet, MAX_LOAN_PER_CONTRACT } from './bet' import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
import { import {
calculateDpmShares, calculateDpmShares,
getDpmProbability, getDpmProbability,
getDpmOutcomeProbability, getDpmOutcomeProbability,
getNumericBets,
calculateNumericDpmShares,
} from './calculate-dpm' } from './calculate-dpm'
import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm'
import { import {
@ -14,17 +16,27 @@ import {
FreeResponse, FreeResponse,
FullContract, FullContract,
Multi, Multi,
NumericContract,
} from './contract' } from './contract'
import { User } from './user'
import { noFees } from './fees' import { noFees } from './fees'
import { addObjects } from './util/object'
import { NUMERIC_FIXED_VAR } from './numeric-constants'
export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
export type BetInfo = {
newBet: CandidateBet<Bet>
newPool?: { [outcome: string]: number }
newTotalShares?: { [outcome: string]: number }
newTotalBets?: { [outcome: string]: number }
newTotalLiquidity?: number
newP?: number
}
export const getNewBinaryCpmmBetInfo = ( export const getNewBinaryCpmmBetInfo = (
user: User,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: FullContract<CPMM, Binary>, contract: FullContract<CPMM, Binary>,
loanAmount: number, loanAmount: number
newBetId: string
) => { ) => {
const { shares, newPool, newP, fees } = calculateCpmmPurchase( const { shares, newPool, newP, fees } = calculateCpmmPurchase(
contract, contract,
@ -32,15 +44,11 @@ export const getNewBinaryCpmmBetInfo = (
outcome outcome
) )
const newBalance = user.balance - (amount - loanAmount)
const { pool, p, totalLiquidity } = contract const { pool, p, totalLiquidity } = contract
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
const probAfter = getCpmmProbability(newPool, newP) const probAfter = getCpmmProbability(newPool, newP)
const newBet: Bet = { const newBet: CandidateBet<Bet> = {
id: newBetId,
userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
shares, shares,
@ -55,16 +63,14 @@ export const getNewBinaryCpmmBetInfo = (
const { liquidityFee } = fees const { liquidityFee } = fees
const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee
return { newBet, newPool, newP, newBalance, newTotalLiquidity, fees } return { newBet, newPool, newP, newTotalLiquidity }
} }
export const getNewBinaryDpmBetInfo = ( export const getNewBinaryDpmBetInfo = (
user: User,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
amount: number, amount: number,
contract: FullContract<DPM, Binary>, contract: FullContract<DPM, Binary>,
loanAmount: number, loanAmount: number
newBetId: string
) => { ) => {
const { YES: yesPool, NO: noPool } = contract.pool const { YES: yesPool, NO: noPool } = contract.pool
@ -92,9 +98,7 @@ export const getNewBinaryDpmBetInfo = (
const probBefore = getDpmProbability(contract.totalShares) const probBefore = getDpmProbability(contract.totalShares)
const probAfter = getDpmProbability(newTotalShares) const probAfter = getDpmProbability(newTotalShares)
const newBet: Bet = { const newBet: CandidateBet<Bet> = {
id: newBetId,
userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount, loanAmount,
@ -106,18 +110,14 @@ export const getNewBinaryDpmBetInfo = (
fees: noFees, fees: noFees,
} }
const newBalance = user.balance - (amount - loanAmount) return { newBet, newPool, newTotalShares, newTotalBets }
return { newBet, newPool, newTotalShares, newTotalBets, newBalance }
} }
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
user: User,
outcome: string, outcome: string,
amount: number, amount: number,
contract: FullContract<DPM, Multi | FreeResponse>, contract: FullContract<DPM, Multi | FreeResponse>,
loanAmount: number, loanAmount: number
newBetId: string
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract
@ -135,9 +135,7 @@ export const getNewMultiBetInfo = (
const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: Bet = { const newBet: CandidateBet<Bet> = {
id: newBetId,
userId: user.id,
contractId: contract.id, contractId: contract.id,
amount, amount,
loanAmount, loanAmount,
@ -149,14 +147,55 @@ export const getNewMultiBetInfo = (
fees: noFees, fees: noFees,
} }
const newBalance = user.balance - (amount - loanAmount) return { newBet, newPool, newTotalShares, newTotalBets }
}
return { newBet, newPool, newTotalShares, newTotalBets, newBalance } export const getNumericBetsInfo = (
value: number,
outcome: string,
amount: number,
contract: NumericContract
) => {
const { pool, totalShares, totalBets } = contract
const bets = getNumericBets(contract, outcome, amount, NUMERIC_FIXED_VAR)
const allBetAmounts = Object.fromEntries(bets)
const newTotalBets = addObjects(totalBets, allBetAmounts)
const newPool = addObjects(pool, allBetAmounts)
const { shares, totalShares: newTotalShares } = calculateNumericDpmShares(
contract.totalShares,
bets
)
const allOutcomeShares = Object.fromEntries(
bets.map(([outcome], i) => [outcome, shares[i]])
)
const probBefore = getDpmOutcomeProbability(totalShares, outcome)
const probAfter = getDpmOutcomeProbability(newTotalShares, outcome)
const newBet: CandidateBet<NumericBet> = {
contractId: contract.id,
value,
amount,
allBetAmounts,
shares: shares.find((s, i) => bets[i][0] === outcome) ?? 0,
allOutcomeShares,
outcome,
probBefore,
probAfter,
createdTime: Date.now(),
fees: noFees,
}
return { newBet, newPool, newTotalShares, newTotalBets }
} }
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => { export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale) const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
const prevLoanAmount = _.sumBy(openBets, (bet) => bet.loanAmount ?? 0) const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
const loanAmount = Math.min( const loanAmount = Math.min(
newBetAmount, newBetAmount,
MAX_LOAN_PER_CONTRACT - prevLoanAmount MAX_LOAN_PER_CONTRACT - prevLoanAmount

View File

@ -1,16 +1,16 @@
import { PHANTOM_ANTE } from './antes' import { range } from 'lodash'
import { import {
Binary, Binary,
Contract, Contract,
CPMM, CPMM,
DPM, DPM,
FreeResponse, FreeResponse,
Numeric,
outcomeType, outcomeType,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags } from './util/parse' import { parseTags } from './util/parse'
import { removeUndefinedProps } from './util/object' import { removeUndefinedProps } from './util/object'
import { calcDpmInitialPool } from './calculate-dpm'
export function getNewContract( export function getNewContract(
id: string, id: string,
@ -23,7 +23,11 @@ export function getNewContract(
ante: number, ante: number,
closeTime: number, closeTime: number,
extraTags: string[], extraTags: string[],
manaLimitPerUser: number
// used for numeric markets
bucketCount: number,
min: number,
max: number
) { ) {
const tags = parseTags( const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
@ -33,6 +37,8 @@ export function getNewContract(
const propsByOutcomeType = const propsByOutcomeType =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
: outcomeType === 'NUMERIC'
? getNumericProps(ante, bucketCount, min, max)
: getFreeAnswerProps(ante) : getFreeAnswerProps(ante)
const contract: Contract = removeUndefinedProps({ const contract: Contract = removeUndefinedProps({
@ -63,12 +69,14 @@ export function getNewContract(
liquidityFee: 0, liquidityFee: 0,
platformFee: 0, platformFee: 0,
}, },
manaLimitPerUser,
}) })
return contract as Contract return contract as Contract
} }
/*
import { PHANTOM_ANTE } from './antes'
import { calcDpmInitialPool } from './calculate-dpm'
const getBinaryDpmProps = (initialProb: number, ante: number) => { const getBinaryDpmProps = (initialProb: number, ante: number) => {
const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } = const { sharesYes, sharesNo, poolYes, poolNo, phantomYes, phantomNo } =
calcDpmInitialPool(initialProb, ante, PHANTOM_ANTE) calcDpmInitialPool(initialProb, ante, PHANTOM_ANTE)
@ -85,6 +93,7 @@ const getBinaryDpmProps = (initialProb: number, ante: number) => {
return system return system
} }
*/
const getBinaryCpmmProps = (initialProb: number, ante: number) => { const getBinaryCpmmProps = (initialProb: number, ante: number) => {
const pool = { YES: ante, NO: ante } const pool = { YES: ante, NO: ante }
@ -115,10 +124,33 @@ const getFreeAnswerProps = (ante: number) => {
return system return system
} }
const getMultiProps = ( const getNumericProps = (
outcomes: string[], ante: number,
initialProbs: number[], bucketCount: number,
ante: number min: number,
max: number
) => { ) => {
// Not implemented. const buckets = range(0, bucketCount).map((i) => i.toString())
const betAnte = ante / bucketCount
const pool = Object.fromEntries(buckets.map((answer) => [answer, betAnte]))
const totalBets = pool
const betShares = Math.sqrt(ante ** 2 / bucketCount)
const totalShares = Object.fromEntries(
buckets.map((answer) => [answer, betShares])
)
const system: DPM & Numeric = {
mechanism: 'dpm-2',
outcomeType: 'NUMERIC',
pool,
totalBets,
totalShares,
bucketCount,
min,
max,
}
return system
} }

View File

@ -0,0 +1,5 @@
export const NUMERIC_BUCKET_COUNT = 200
export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'

View File

@ -3,6 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": {}, "scripts": {},
"sideEffects": false,
"dependencies": { "dependencies": {
"lodash": "4.17.21" "lodash": "4.17.21"
}, },

View File

@ -1,6 +1,6 @@
import * as _ from 'lodash' import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet } from './bet' import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm' import { deductDpmFees, getDpmProbability } from './calculate-dpm'
import { DPM, FreeResponse, FullContract, Multi } from './contract' import { DPM, FreeResponse, FullContract, Multi } from './contract'
import { import {
@ -17,10 +17,10 @@ export const getDpmCancelPayouts = (
bets: Bet[] bets: Bet[]
) => { ) => {
const { pool } = contract const { pool } = contract
const poolTotal = _.sum(Object.values(pool)) const poolTotal = sum(Object.values(pool))
console.log('resolved N/A, pool M$', poolTotal) console.log('resolved N/A, pool M$', poolTotal)
const betSum = _.sumBy(bets, (b) => b.amount) const betSum = sumBy(bets, (b) => b.amount)
const payouts = bets.map((bet) => ({ const payouts = bets.map((bet) => ({
userId: bet.userId, userId: bet.userId,
@ -42,8 +42,8 @@ export const getDpmStandardPayouts = (
) => { ) => {
const winningBets = bets.filter((bet) => bet.outcome === outcome) const winningBets = bets.filter((bet) => bet.outcome === outcome)
const poolTotal = _.sum(Object.values(contract.pool)) const poolTotal = sum(Object.values(contract.pool))
const totalShares = _.sumBy(winningBets, (b) => b.shares) const totalShares = sumBy(winningBets, (b) => b.shares)
const payouts = winningBets.map(({ userId, amount, shares }) => { const payouts = winningBets.map(({ userId, amount, shares }) => {
const winnings = (shares / totalShares) * poolTotal const winnings = (shares / totalShares) * poolTotal
@ -54,7 +54,7 @@ export const getDpmStandardPayouts = (
return { userId, profit, payout } return { userId, profit, payout }
}) })
const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit)) const profits = sumBy(payouts, (po) => Math.max(0, po.profit))
const creatorFee = DPM_CREATOR_FEE * profits const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits const platformFee = DPM_PLATFORM_FEE * profits
@ -88,6 +88,64 @@ export const getDpmStandardPayouts = (
} }
} }
export const getNumericDpmPayouts = (
outcome: string,
contract: FullContract<DPM, any>,
bets: NumericBet[]
) => {
const totalShares = sumBy(bets, (bet) => bet.allOutcomeShares[outcome] ?? 0)
const winningBets = bets.filter((bet) => !!bet.allOutcomeShares[outcome])
const poolTotal = sum(Object.values(contract.pool))
const payouts = winningBets.map(
({ userId, allBetAmounts, allOutcomeShares }) => {
const shares = allOutcomeShares[outcome] ?? 0
const winnings = (shares / totalShares) * poolTotal
const amount = allBetAmounts[outcome] ?? 0
const profit = winnings - amount
// profit can be negative if using phantom shares
const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
return { userId, profit, payout }
}
)
const profits = sumBy(payouts, (po) => Math.max(0, po.profit))
const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits
const finalFees: Fees = {
creatorFee,
platformFee,
liquidityFee: 0,
}
const collectedFees = addObjects<Fees>(
finalFees,
contract.collectedFees ?? {}
)
console.log(
'resolved numeric bucket: ',
outcome,
'pool',
poolTotal,
'profits',
profits,
'creator fee',
creatorFee
)
return {
payouts: payouts.map(({ userId, payout }) => ({ userId, payout })),
creatorPayout: creatorFee,
liquidityPayouts: [],
collectedFees,
}
}
export const getDpmMktPayouts = ( export const getDpmMktPayouts = (
contract: FullContract<DPM, any>, contract: FullContract<DPM, any>,
bets: Bet[], bets: Bet[],
@ -98,7 +156,7 @@ export const getDpmMktPayouts = (
? getDpmProbability(contract.totalShares) ? getDpmProbability(contract.totalShares)
: resolutionProbability : resolutionProbability
const weightedShareTotal = _.sumBy(bets, (b) => const weightedShareTotal = sumBy(bets, (b) =>
b.outcome === 'YES' ? p * b.shares : (1 - p) * b.shares b.outcome === 'YES' ? p * b.shares : (1 - p) * b.shares
) )
@ -112,7 +170,7 @@ export const getDpmMktPayouts = (
return { userId, profit, payout } return { userId, profit, payout }
}) })
const profits = _.sumBy(payouts, (po) => Math.max(0, po.profit)) const profits = sumBy(payouts, (po) => Math.max(0, po.profit))
const creatorFee = DPM_CREATOR_FEE * profits const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits const platformFee = DPM_PLATFORM_FEE * profits
@ -152,15 +210,15 @@ export const getPayoutsMultiOutcome = (
contract: FullContract<DPM, Multi | FreeResponse>, contract: FullContract<DPM, Multi | FreeResponse>,
bets: Bet[] bets: Bet[]
) => { ) => {
const poolTotal = _.sum(Object.values(contract.pool)) const poolTotal = sum(Object.values(contract.pool))
const winningBets = bets.filter((bet) => resolutions[bet.outcome]) const winningBets = bets.filter((bet) => resolutions[bet.outcome])
const betsByOutcome = _.groupBy(winningBets, (bet) => bet.outcome) const betsByOutcome = groupBy(winningBets, (bet) => bet.outcome)
const sharesByOutcome = _.mapValues(betsByOutcome, (bets) => const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
_.sumBy(bets, (bet) => bet.shares) sumBy(bets, (bet) => bet.shares)
) )
const probTotal = _.sum(Object.values(resolutions)) const probTotal = sum(Object.values(resolutions))
const payouts = winningBets.map(({ userId, outcome, amount, shares }) => { const payouts = winningBets.map(({ userId, outcome, amount, shares }) => {
const prob = resolutions[outcome] / probTotal const prob = resolutions[outcome] / probTotal
@ -171,7 +229,7 @@ export const getPayoutsMultiOutcome = (
return { userId, profit, payout } return { userId, profit, payout }
}) })
const profits = _.sumBy(payouts, (po) => po.profit) const profits = sumBy(payouts, (po) => po.profit)
const creatorFee = DPM_CREATOR_FEE * profits const creatorFee = DPM_CREATOR_FEE * profits
const platformFee = DPM_PLATFORM_FEE * profits const platformFee = DPM_PLATFORM_FEE * profits

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash' import { sum } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { getProbability } from './calculate' import { getProbability } from './calculate'
@ -50,7 +50,7 @@ export const getStandardFixedPayouts = (
'pool', 'pool',
contract.pool[outcome], contract.pool[outcome],
'payouts', 'payouts',
_.sum(payouts), sum(payouts),
'creator fee', 'creator fee',
creatorPayout creatorPayout
) )
@ -105,7 +105,7 @@ export const getMktFixedPayouts = (
'pool', 'pool',
p * contract.pool.YES + (1 - p) * contract.pool.NO, p * contract.pool.YES + (1 - p) * contract.pool.NO,
'payouts', 'payouts',
_.sum(payouts), sum(payouts),
'creator fee', 'creator fee',
creatorPayout creatorPayout
) )

View File

@ -1,6 +1,6 @@
import * as _ from 'lodash' import { sumBy, groupBy, mapValues } from 'lodash'
import { Bet } from './bet' import { Bet, NumericBet } from './bet'
import { import {
Binary, Binary,
Contract, Contract,
@ -16,6 +16,7 @@ import {
getDpmCancelPayouts, getDpmCancelPayouts,
getDpmMktPayouts, getDpmMktPayouts,
getDpmStandardPayouts, getDpmStandardPayouts,
getNumericDpmPayouts,
getPayoutsMultiOutcome, getPayoutsMultiOutcome,
} from './payouts-dpm' } from './payouts-dpm'
import { import {
@ -31,16 +32,19 @@ export type Payout = {
export const getLoanPayouts = (bets: Bet[]): Payout[] => { export const getLoanPayouts = (bets: Bet[]): Payout[] => {
const betsWithLoans = bets.filter((bet) => bet.loanAmount) const betsWithLoans = bets.filter((bet) => bet.loanAmount)
const betsByUser = _.groupBy(betsWithLoans, (bet) => bet.userId) const betsByUser = groupBy(betsWithLoans, (bet) => bet.userId)
const loansByUser = _.mapValues(betsByUser, (bets) => const loansByUser = mapValues(betsByUser, (bets) =>
_.sumBy(bets, (bet) => -(bet.loanAmount ?? 0)) sumBy(bets, (bet) => -(bet.loanAmount ?? 0))
) )
return _.toPairs(loansByUser).map(([userId, payout]) => ({ userId, payout })) return Object.entries(loansByUser).map(([userId, payout]) => ({
userId,
payout,
}))
} }
export const groupPayoutsByUser = (payouts: Payout[]) => { export const groupPayoutsByUser = (payouts: Payout[]) => {
const groups = _.groupBy(payouts, (payout) => payout.userId) const groups = groupBy(payouts, (payout) => payout.userId)
return _.mapValues(groups, (group) => _.sumBy(group, (g) => g.payout)) return mapValues(groups, (group) => sumBy(group, (g) => g.payout))
} }
export type PayoutInfo = { export type PayoutInfo = {
@ -131,6 +135,9 @@ export const getDpmPayouts = (
return getDpmCancelPayouts(contract, openBets) return getDpmCancelPayouts(contract, openBets)
default: default:
if (contract.outcomeType === 'NUMERIC')
return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[])
// Outcome is a free response answer id. // Outcome is a free response answer id.
return getDpmStandardPayouts(outcome, contract, openBets) return getDpmStandardPayouts(outcome, contract, openBets)
} }

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash' import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { Contract } from './contract' import { Contract } from './contract'
import { ClickEvent } from './tracking' import { ClickEvent } from './tracking'
@ -21,13 +21,13 @@ export const getRecommendedContracts = (
const yourWordFrequency = contractsToWordFrequency(yourContracts) const yourWordFrequency = contractsToWordFrequency(yourContracts)
const otherWordFrequency = contractsToWordFrequency(notYourContracts) const otherWordFrequency = contractsToWordFrequency(notYourContracts)
const words = _.union( const words = union(
Object.keys(yourWordFrequency), Object.keys(yourWordFrequency),
Object.keys(otherWordFrequency) Object.keys(otherWordFrequency)
) )
const yourWeightedFrequency = _.fromPairs( const yourWeightedFrequency = Object.fromEntries(
_.map(words, (word) => { words.map((word) => {
const [yourFreq, otherFreq] = [ const [yourFreq, otherFreq] = [
yourWordFrequency[word] ?? 0, yourWordFrequency[word] ?? 0,
otherWordFrequency[word] ?? 0, otherWordFrequency[word] ?? 0,
@ -47,7 +47,7 @@ export const getRecommendedContracts = (
const scoredContracts = contracts.map((contract) => { const scoredContracts = contracts.map((contract) => {
const wordFrequency = contractToWordFrequency(contract) const wordFrequency = contractToWordFrequency(contract)
const score = _.sumBy(Object.keys(wordFrequency), (word) => { const score = sumBy(Object.keys(wordFrequency), (word) => {
const wordFreq = wordFrequency[word] ?? 0 const wordFreq = wordFrequency[word] ?? 0
const weight = yourWeightedFrequency[word] ?? 0 const weight = yourWeightedFrequency[word] ?? 0
return wordFreq * weight return wordFreq * weight
@ -59,7 +59,7 @@ export const getRecommendedContracts = (
} }
}) })
return _.sortBy(scoredContracts, (scored) => -scored.score).map( return sortBy(scoredContracts, (scored) => -scored.score).map(
(scored) => scored.contract (scored) => scored.contract
) )
} }
@ -87,8 +87,8 @@ const getWordsCount = (text: string) => {
} }
const toFrequency = (counts: { [word: string]: number }) => { const toFrequency = (counts: { [word: string]: number }) => {
const total = _.sum(Object.values(counts)) const total = sum(Object.values(counts))
return _.mapValues(counts, (count) => count / total) return mapValues(counts, (count) => count / total)
} }
const contractToWordFrequency = (contract: Contract) => const contractToWordFrequency = (contract: Contract) =>
@ -108,8 +108,8 @@ export const getWordScores = (
clicks: ClickEvent[], clicks: ClickEvent[],
bets: Bet[] bets: Bet[]
) => { ) => {
const contractClicks = _.groupBy(clicks, (click) => click.contractId) const contractClicks = groupBy(clicks, (click) => click.contractId)
const contractBets = _.groupBy(bets, (bet) => bet.contractId) const contractBets = groupBy(bets, (bet) => bet.contractId)
const yourContracts = contracts.filter( const yourContracts = contracts.filter(
(c) => (c) =>
@ -117,25 +117,22 @@ export const getWordScores = (
) )
const yourTfIdf = calculateContractTfIdf(yourContracts) const yourTfIdf = calculateContractTfIdf(yourContracts)
const contractWordScores = _.mapValues( const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => {
yourTfIdf, const viewCount = contractViewCounts[contractId] ?? 0
(wordsTfIdf, contractId) => { const clickCount = contractClicks[contractId]?.length ?? 0
const viewCount = contractViewCounts[contractId] ?? 0 const betCount = contractBets[contractId]?.length ?? 0
const clickCount = contractClicks[contractId]?.length ?? 0
const betCount = contractBets[contractId]?.length ?? 0
const factor = const factor =
-1 * Math.log(viewCount + 1) + -1 * Math.log(viewCount + 1) +
10 * Math.log(betCount + clickCount / 4 + 1) 10 * Math.log(betCount + clickCount / 4 + 1)
return _.mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor) return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor)
} })
)
const wordScores = Object.values(contractWordScores).reduce(addObjects, {}) const wordScores = Object.values(contractWordScores).reduce(addObjects, {})
const minScore = Math.min(...Object.values(wordScores)) const minScore = Math.min(...Object.values(wordScores))
const maxScore = Math.max(...Object.values(wordScores)) const maxScore = Math.max(...Object.values(wordScores))
const normalizedWordScores = _.mapValues( const normalizedWordScores = mapValues(
wordScores, wordScores,
(score) => (score - minScore) / (maxScore - minScore) (score) => (score - minScore) / (maxScore - minScore)
) )
@ -156,7 +153,7 @@ export function getContractScore(
if (Object.keys(wordScores).length === 0) return 1 if (Object.keys(wordScores).length === 0) return 1
const wordFrequency = contractToWordFrequency(contract) const wordFrequency = contractToWordFrequency(contract)
const score = _.sumBy(Object.keys(wordFrequency), (word) => { const score = sumBy(Object.keys(wordFrequency), (word) => {
const wordFreq = wordFrequency[word] ?? 0 const wordFreq = wordFrequency[word] ?? 0
const weight = wordScores[word] ?? 0 const weight = wordScores[word] ?? 0
return wordFreq * weight return wordFreq * weight
@ -178,11 +175,13 @@ function calculateContractTfIdf(contracts: Contract[]) {
} }
} }
const wordIdf = _.mapValues(wordsCount, (count) => const wordIdf = mapValues(wordsCount, (count) =>
Math.log(contracts.length / count) Math.log(contracts.length / count)
) )
const contractWordsTfIdf = _.map(contractFreq, (wordFreq) => const contractWordsTfIdf = contractFreq.map((wordFreq) =>
_.mapValues(wordFreq, (freq, word) => freq * wordIdf[word]) mapValues(wordFreq, (freq, word) => freq * wordIdf[word])
)
return Object.fromEntries(
contracts.map((c, i) => [c.id, contractWordsTfIdf[i]])
) )
return _.fromPairs(contracts.map((c, i) => [c.id, contractWordsTfIdf[i]]))
} }

View File

@ -1,13 +1,13 @@
import * as _ from 'lodash' import { groupBy, sumBy, mapValues, partition } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { Binary, Contract, FullContract } from './contract' import { Binary, Contract, FullContract } from './contract'
import { getPayouts } from './payouts' import { getPayouts } from './payouts'
export function scoreCreators(contracts: Contract[], bets: Bet[][]) { export function scoreCreators(contracts: Contract[]) {
const creatorScore = _.mapValues( const creatorScore = mapValues(
_.groupBy(contracts, ({ creatorId }) => creatorId), groupBy(contracts, ({ creatorId }) => creatorId),
(contracts) => _.sumBy(contracts, ({ pool }) => pool.YES + pool.NO) (contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO)
) )
return creatorScore return creatorScore
@ -30,7 +30,7 @@ export function scoreUsersByContract(
) { ) {
const { resolution, resolutionProbability } = contract const { resolution, resolutionProbability } = contract
const [closedBets, openBets] = _.partition( const [closedBets, openBets] = partition(
bets, bets,
(bet) => bet.isSold || bet.sale (bet) => bet.isSold || bet.sale
) )
@ -58,9 +58,9 @@ export function scoreUsersByContract(
const netPayouts = [...resolvePayouts, ...salePayouts, ...investments] const netPayouts = [...resolvePayouts, ...salePayouts, ...investments]
const userScore = _.mapValues( const userScore = mapValues(
_.groupBy(netPayouts, (payout) => payout.userId), groupBy(netPayouts, (payout) => payout.userId),
(payouts) => _.sumBy(payouts, ({ payout }) => payout) (payouts) => sumBy(payouts, ({ payout }) => payout)
) )
return userScore return userScore

12
common/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"baseUrl": "../",
"moduleResolution": "node",
"noImplicitReturns": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"include": ["**/*.ts"]
}

View File

@ -29,6 +29,20 @@ export function formatPercent(zeroToOne: number) {
return (zeroToOne * 100).toFixed(decimalPlaces) + '%' return (zeroToOne * 100).toFixed(decimalPlaces) + '%'
} }
// Eg 1234567.89 => 1.23M; 5678 => 5.68K
export function formatLargeNumber(num: number, sigfigs = 2): string {
const absNum = Math.abs(num)
if (absNum < 1000) {
return '' + Number(num.toPrecision(sigfigs))
}
const suffix = ['', 'K', 'M', 'B', 'T', 'Q']
const suffixIdx = Math.floor(Math.log10(absNum) / 3)
const suffixStr = suffix[suffixIdx]
const numStr = (num / Math.pow(10, 3 * suffixIdx)).toPrecision(sigfigs)
return `${Number(numStr)}${suffixStr}`
}
export function toCamelCase(words: string) { export function toCamelCase(words: string) {
const camelCase = words const camelCase = words
.split(' ') .split(' ')

View File

@ -4,3 +4,16 @@ export const logInterpolation = (min: number, max: number, value: number) => {
return Math.log(value - min + 1) / Math.log(max - min + 1) return Math.log(value - min + 1) / Math.log(max - min + 1)
} }
export function normpdf(x: number, mean = 0, variance = 1) {
if (variance === 0) {
return x === mean ? Infinity : 0
}
return (
Math.exp((-0.5 * Math.pow(x - mean, 2)) / variance) /
Math.sqrt(TAU * variance)
)
}
const TAU = Math.PI * 2

View File

@ -1,9 +1,9 @@
import * as _ from 'lodash' import { union } from 'lodash'
export const removeUndefinedProps = <T>(obj: T): T => { export const removeUndefinedProps = <T>(obj: T): T => {
let newObj: any = {} const newObj: any = {}
for (let key of Object.keys(obj)) { for (const key of Object.keys(obj)) {
if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key]
} }
@ -14,10 +14,10 @@ export const addObjects = <T extends { [key: string]: number }>(
obj1: T, obj1: T,
obj2: T obj2: T
) => { ) => {
const keys = _.union(Object.keys(obj1), Object.keys(obj2)) const keys = union(Object.keys(obj1), Object.keys(obj2))
const newObj = {} as any const newObj = {} as any
for (let key of keys) { for (const key of keys) {
newObj[key] = (obj1[key] ?? 0) + (obj2[key] ?? 0) newObj[key] = (obj1[key] ?? 0) + (obj2[key] ?? 0)
} }

View File

@ -5,7 +5,8 @@ export const randomString = (length = 12) =>
export function genHash(str: string) { export function genHash(str: string) {
// xmur3 // xmur3
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) { let h: number
for (let i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353) h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
h = (h << 13) | (h >>> 19) h = (h << 13) | (h >>> 19)
} }
@ -28,7 +29,7 @@ export function createRNG(seed: string) {
b >>>= 0 b >>>= 0
c >>>= 0 c >>>= 0
d >>>= 0 d >>>= 0
var t = (a + b) | 0 let t = (a + b) | 0
a = b ^ (b >>> 9) a = b ^ (b >>> 9)
b = (c + (c << 3)) | 0 b = (c + (c << 3)) | 0
c = (c << 21) | (c >>> 11) c = (c << 21) | (c >>> 11)
@ -39,7 +40,7 @@ export function createRNG(seed: string) {
} }
} }
export const shuffle = (array: any[], rand: () => number) => { export const shuffle = (array: unknown[], rand: () => number) => {
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
const swapIndex = Math.floor(rand() * (array.length - i)) const swapIndex = Math.floor(rand() * (array.length - i))
;[array[i], array[swapIndex]] = [array[swapIndex], array[i]] ;[array[i], array[swapIndex]] = [array[swapIndex], array[i]]

25
functions/.eslintrc.js Normal file
View File

@ -0,0 +1,25 @@
module.exports = {
plugins: ['lodash'],
extends: ['eslint:recommended'],
ignorePatterns: ['lib'],
env: {
node: true,
},
overrides: [
{
files: ['**/*.ts'],
plugins: ['@typescript-eslint'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
},
],
rules: {
'no-extra-semi': 'off',
'no-unused-vars': 'off',
'no-constant-condition': ['error', { checkLoops: false }],
'lodash/import-scope': [2, 'member'],
},
}

View File

@ -24,8 +24,9 @@ Adapted from https://firebase.google.com/docs/functions/get-started
0. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev 0. `$ firebase functions:config:get > .runtimeconfig.json` to cache secrets for local dev
1. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 1. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
2. `$ brew install java` to install java if you don't already have it 2. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
1. `$ echo 'export PATH="/usr/local/opt/openjdk/bin:$PATH"' >> ~/.zshrc` to add java to your path 1. `$ brew install java`
2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk`
3. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud
4. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options) 4. `$ gcloud config set project <project-id>` to choose the project (`$ gcloud projects list` to see options)
5. `$ mkdir firestore_export` to create a folder to store the exported database 5. `$ mkdir firestore_export` to create a folder to store the exported database

View File

@ -26,15 +26,15 @@
"firebase-functions": "3.16.0", "firebase-functions": "3.16.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"react-query": "3.39.0",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"stripe": "8.194.0" "react-query": "3.39.0",
"stripe": "8.194.0",
"zod": "3.17.2"
}, },
"devDependencies": { "devDependencies": {
"@types/module-alias": "2.0.1",
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"firebase-functions-test": "0.3.3", "@types/module-alias": "2.0.1",
"typescript": "4.5.3" "firebase-functions-test": "0.3.3"
}, },
"private": true "private": true
} }

View File

@ -1,13 +1,19 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as Cors from 'cors' import * as Cors from 'cors'
import { z } from 'zod'
import { User, PrivateUser } from 'common/user' import { User, PrivateUser } from '../../common/user'
import {
CORS_ORIGIN_MANIFOLD,
CORS_ORIGIN_LOCALHOST,
} from '../../common/envs/constants'
type Output = Record<string, unknown>
type Request = functions.https.Request type Request = functions.https.Request
type Response = functions.Response type Response = functions.Response
type Handler = (req: Request, res: Response) => Promise<any>
type AuthedUser = [User, PrivateUser] type AuthedUser = [User, PrivateUser]
type Handler = (req: Request, user: AuthedUser) => Promise<Output>
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string } type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials type Credentials = JwtCredentials | KeyCredentials
@ -15,10 +21,13 @@ type Credentials = JwtCredentials | KeyCredentials
export class APIError { export class APIError {
code: number code: number
msg: string msg: string
constructor(code: number, msg: string) { details: unknown
constructor(code: number, msg: string, details?: unknown) {
this.code = code this.code = code
this.msg = msg this.msg = msg
this.details = details
} }
toJson() {}
} }
export const parseCredentials = async (req: Request): Promise<Credentials> => { export const parseCredentials = async (req: Request): Promise<Credentials> => {
@ -36,14 +45,11 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
case 'Bearer': case 'Bearer':
try { try {
const jwt = await admin.auth().verifyIdToken(payload) const jwt = await admin.auth().verifyIdToken(payload)
if (!jwt.user_id) {
throw new APIError(403, 'JWT must contain Manifold user ID.')
}
return { kind: 'jwt', data: jwt } return { kind: 'jwt', data: jwt }
} catch (err) { } catch (err) {
// This is somewhat suspicious, so get it into the firebase console // This is somewhat suspicious, so get it into the firebase console
functions.logger.error('Error verifying Firebase JWT: ', err) functions.logger.error('Error verifying Firebase JWT: ', err)
throw new APIError(403, `Error validating token: ${err}.`) throw new APIError(403, 'Error validating token.')
} }
case 'Key': case 'Key':
return { kind: 'key', data: payload } return { kind: 'key', data: payload }
@ -59,6 +65,9 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
switch (creds.kind) { switch (creds.kind) {
case 'jwt': { case 'jwt': {
const { user_id } = creds.data const { user_id } = creds.data
if (typeof user_id !== 'string') {
throw new APIError(403, 'JWT must contain Manifold user ID.')
}
const [userSnap, privateUserSnap] = await Promise.all([ const [userSnap, privateUserSnap] = await Promise.all([
users.doc(user_id).get(), users.doc(user_id).get(),
privateUsers.doc(user_id).get(), privateUsers.doc(user_id).get(),
@ -90,10 +99,11 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
} }
} }
export const CORS_ORIGIN_MANIFOLD = /^https?:\/\/.+\.manifold\.markets$/ export const applyCors = (
export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ req: Request,
res: Response,
export const applyCors = (req: any, res: any, params: object) => { params: Cors.CorsOptions
) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
Cors(params)(req, res, (result) => { Cors(params)(req, res, (result) => {
if (result instanceof Error) { if (result instanceof Error) {
@ -104,10 +114,31 @@ export const applyCors = (req: any, res: any, params: object) => {
}) })
} }
export const zTimestamp = () => {
return z.preprocess((arg) => {
return typeof arg == 'number' ? new Date(arg) : undefined
}, z.date())
}
export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const result = schema.safeParse(val)
if (!result.success) {
const issues = result.error.issues.map((i) => {
return {
field: i.path.join('.') || null,
error: i.message,
}
})
throw new APIError(400, 'Error validating request.', issues)
} else {
return result.data as z.infer<T>
}
}
export const newEndpoint = (methods: [string], fn: Handler) => export const newEndpoint = (methods: [string], fn: Handler) =>
functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => { functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => {
await applyCors(req, res, { await applyCors(req, res, {
origins: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], origin: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
methods: methods, methods: methods,
}) })
try { try {
@ -115,15 +146,18 @@ export const newEndpoint = (methods: [string], fn: Handler) =>
const allowed = methods.join(', ') const allowed = methods.join(', ')
throw new APIError(405, `This endpoint supports only ${allowed}.`) throw new APIError(405, `This endpoint supports only ${allowed}.`)
} }
const data = await fn(req, res) const authedUser = await lookupUser(await parseCredentials(req))
data.status = 'success' res.status(200).json(await fn(req, authedUser))
res.status(200).json({ data: data })
} catch (e) { } catch (e) {
if (e instanceof APIError) { if (e instanceof APIError) {
// Emit a 200 anyway here for now, for backwards compatibility const output: { [k: string]: unknown } = { message: e.msg }
res.status(200).json({ data: { status: 'error', message: e.msg } }) if (e.details != null) {
output.details = e.details
}
res.status(e.code).json(output)
} else { } else {
res.status(500).json({ data: { status: 'error', message: '???' } }) functions.logger.error(e)
res.status(500).json({ message: 'An unknown error occurred.' })
} }
} }
}) })

View File

@ -12,8 +12,6 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getContract, getValues } from './utils' import { getContract, getValues } from './utils'
import { sendNewAnswerEmail } from './emails' import { sendNewAnswerEmail } from './emails'
import { Bet } from '../../common/bet'
import { hasUserHitManaLimit } from '../../common/calculate'
export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
async ( async (
@ -62,18 +60,6 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
if (closeTime && Date.now() > closeTime) if (closeTime && Date.now() > closeTime)
return { status: 'error', message: 'Trading is closed' } return { status: 'error', message: 'Trading is closed' }
const yourBetsSnap = await transaction.get(
contractDoc.collection('bets').where('userId', '==', userId)
)
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet)
const { status, message } = hasUserHitManaLimit(
contract,
yourBets,
amount
)
if (status === 'error') return { status, message: message }
const [lastAnswer] = await getValues<Answer>( const [lastAnswer] = await getValues<Answer>(
firestore firestore
.collection(`contracts/${contractId}/answers`) .collection(`contracts/${contractId}/answers`)
@ -107,23 +93,20 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
} }
transaction.create(newAnswerDoc, answer) transaction.create(newAnswerDoc, answer)
const newBetDoc = firestore const loanAmount = 0
.collection(`contracts/${contractId}/bets`)
.doc()
const loanAmount = 0 // getLoanAmount(yourBets, amount) const { newBet, newPool, newTotalShares, newTotalBets } =
const { newBet, newPool, newTotalShares, newTotalBets, newBalance } =
getNewMultiBetInfo( getNewMultiBetInfo(
user,
answerId, answerId,
amount, amount,
contract as FullContract<DPM, FreeResponse>, contract as FullContract<DPM, FreeResponse>,
loanAmount, loanAmount
newBetDoc.id
) )
transaction.create(newBetDoc, newBet) const newBalance = user.balance - amount
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
transaction.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
transaction.update(userDoc, { balance: newBalance })
transaction.update(contractDoc, { transaction.update(contractDoc, {
pool: newPool, pool: newPool,
totalShares: newTotalShares, totalShares: newTotalShares,
@ -132,13 +115,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall(
volume: volume + amount, volume: volume + amount,
}) })
if (!isFinite(newBalance)) { return { status: 'success', answerId, betId: betDoc.id, answer }
throw new Error('Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
return { status: 'success', answerId, betId: newBetDoc.id, answer }
}) })
const { answer } = result const { answer } = result

View File

@ -1,7 +1,6 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod'
import { chargeUser } from './utils'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api'
import { import {
Binary, Binary,
Contract, Contract,
@ -12,82 +11,82 @@ import {
MAX_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH,
MAX_QUESTION_LENGTH, MAX_QUESTION_LENGTH,
MAX_TAG_LENGTH, MAX_TAG_LENGTH,
Numeric,
OUTCOME_TYPES,
} from '../../common/contract' } from '../../common/contract'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { getNewContract } from '../../common/new-contract'
import { chargeUser } from './utils'
import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { import {
FIXED_ANTE, FIXED_ANTE,
getAnteBets, getAnteBets,
getCpmmInitialLiquidity, getCpmmInitialLiquidity,
getFreeAnswerAnte, getFreeAnswerAnte,
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
MINIMUM_ANTE,
} from '../../common/antes' } from '../../common/antes'
import { getNoneAnswer } from '../../common/answer' import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
export const createContract = newEndpoint(['POST'], async (req, _res) => { const bodySchema = z.object({
const [creator, _privateUser] = await lookupUser(await parseCredentials(req)) question: z.string().min(1).max(MAX_QUESTION_LENGTH),
let { description: z.string().max(MAX_DESCRIPTION_LENGTH),
question, tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(),
outcomeType, closeTime: zTimestamp().refine(
description, (date) => date.getTime() > new Date().getTime(),
initialProb, 'Close time must be in the future.'
closeTime, ),
tags, outcomeType: z.enum(OUTCOME_TYPES),
manaLimitPerUser, })
} = req.body.data || {}
if (!question || typeof question != 'string') const binarySchema = z.object({
throw new APIError(400, 'Missing or invalid question field') initialProb: z.number().min(1).max(99),
})
question = question.slice(0, MAX_QUESTION_LENGTH) const numericSchema = z.object({
min: z.number(),
max: z.number(),
})
if (typeof description !== 'string') export const createContract = newEndpoint(['POST'], async (req, [user, _]) => {
throw new APIError(400, 'Invalid description field') const { question, description, tags, closeTime, outcomeType } = validate(
bodySchema,
description = description.slice(0, MAX_DESCRIPTION_LENGTH) req.body
if (tags !== undefined && !Array.isArray(tags))
throw new APIError(400, 'Invalid tags field')
tags = (tags || []).map((tag: string) =>
tag.toString().slice(0, MAX_TAG_LENGTH)
) )
outcomeType = outcomeType ?? 'BINARY' let min, max, initialProb
if (!['BINARY', 'MULTI', 'FREE_RESPONSE'].includes(outcomeType)) if (outcomeType === 'NUMERIC') {
throw new APIError(400, 'Invalid outcomeType') ;({ min, max } = validate(numericSchema, req.body))
if (max - min <= 0.01) throw new APIError(400, 'Invalid range.')
}
if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, req.body))
}
if ( // Uses utc time on server:
outcomeType === 'BINARY' && const today = new Date()
(!initialProb || initialProb < 1 || initialProb > 99) let freeMarketResetTime = today.setUTCHours(16, 0, 0, 0)
) if (today.getTime() < freeMarketResetTime) {
throw new APIError(400, 'Invalid initial probability') freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
}
// uses utc time on server:
const today = new Date().setHours(0, 0, 0, 0)
const userContractsCreatedTodaySnapshot = await firestore const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`) .collection(`contracts`)
.where('creatorId', '==', creator.id) .where('creatorId', '==', user.id)
.where('createdTime', '>=', today) .where('createdTime', '>=', freeMarketResetTime)
.get() .get()
console.log('free market reset time: ', freeMarketResetTime)
const isFree = userContractsCreatedTodaySnapshot.size === 0 const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE const ante = FIXED_ANTE
if (
ante === undefined ||
ante < MINIMUM_ANTE ||
(ante > creator.balance && !isFree) ||
isNaN(ante) ||
!isFinite(ante)
)
throw new APIError(400, 'Invalid ante')
console.log( console.log(
'creating contract for', 'creating contract for',
creator.username, user.username,
'on', 'on',
question, question,
'ante:', 'ante:',
@ -95,83 +94,92 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => {
) )
const slug = await getSlug(question) const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc() const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract( const contract = getNewContract(
contractRef.id, contractRef.id,
slug, slug,
creator, user,
question, question,
outcomeType, outcomeType,
description, description,
initialProb, initialProb ?? 0,
ante, ante,
closeTime, closeTime.getTime(),
tags ?? [], tags ?? [],
manaLimitPerUser ?? 0 NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0
) )
if (!isFree && ante) await chargeUser(creator.id, ante, true) if (!isFree && ante) await chargeUser(user.id, ante, true)
await contractRef.create(contract) await contractRef.create(contract)
if (ante) { const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id
if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
const yesBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const noBetDoc = firestore if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') {
.collection(`contracts/${contract.id}/bets`) const yesBetDoc = firestore
.doc() .collection(`contracts/${contract.id}/bets`)
.doc()
const { yesBet, noBet } = getAnteBets( const noBetDoc = firestore.collection(`contracts/${contract.id}/bets`).doc()
creator,
contract as FullContract<DPM, Binary>,
yesBetDoc.id,
noBetDoc.id
)
await yesBetDoc.set(yesBet) const { yesBet, noBet } = getAnteBets(
await noBetDoc.set(noBet) user,
} else if (outcomeType === 'BINARY') { contract as FullContract<DPM, Binary>,
const liquidityDoc = firestore yesBetDoc.id,
.collection(`contracts/${contract.id}/liquidity`) noBetDoc.id
.doc() )
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id await yesBetDoc.set(yesBet)
await noBetDoc.set(noBet)
} else if (outcomeType === 'BINARY') {
const liquidityDoc = firestore
.collection(`contracts/${contract.id}/liquidity`)
.doc()
const lp = getCpmmInitialLiquidity( const lp = getCpmmInitialLiquidity(
providerId, providerId,
contract as FullContract<CPMM, Binary>, contract as FullContract<CPMM, Binary>,
liquidityDoc.id, liquidityDoc.id,
ante ante
) )
await liquidityDoc.set(lp) await liquidityDoc.set(lp)
} else if (outcomeType === 'FREE_RESPONSE') { } else if (outcomeType === 'FREE_RESPONSE') {
const noneAnswerDoc = firestore const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`) .collection(`contracts/${contract.id}/answers`)
.doc('0') .doc('0')
const noneAnswer = getNoneAnswer(contract.id, creator) const noneAnswer = getNoneAnswer(contract.id, user)
await noneAnswerDoc.set(noneAnswer) await noneAnswerDoc.set(noneAnswer)
const anteBetDoc = firestore const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
.doc() .doc()
const anteBet = getFreeAnswerAnte( const anteBet = getFreeAnswerAnte(
creator, providerId,
contract as FullContract<DPM, FreeResponse>, contract as FullContract<DPM, FreeResponse>,
anteBetDoc.id anteBetDoc.id
) )
await anteBetDoc.set(anteBet) await anteBetDoc.set(anteBet)
} } else if (outcomeType === 'NUMERIC') {
const anteBetDoc = firestore
.collection(`contracts/${contract.id}/bets`)
.doc()
const anteBet = getNumericAnte(
user,
contract as FullContract<DPM, Numeric>,
ante,
anteBetDoc.id
)
await anteBetDoc.set(anteBet)
} }
return { contract: contract } return contract
}) })
const getSlug = async (question: string) => { const getSlug = async (question: string) => {

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getUser } from './utils' import { getUser } from './utils'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -34,7 +33,7 @@ export const createFold = functions.runWith({ minInstances: 1 }).https.onCall(
return { status: 'error', message: 'About must be a string' } return { status: 'error', message: 'About must be a string' }
about = about.trim().slice(0, 140) about = about.trim().slice(0, 140)
if (!_.isArray(tags)) if (!Array.isArray(tags))
return { status: 'error', message: 'Tags must be an array of strings' } return { status: 'error', message: 'Tags must be an array of strings' }
console.log( console.log(

View File

@ -1,5 +1,3 @@
import * as _ from 'lodash'
import { DOMAIN, PROJECT_ID } from '../../common/envs/constants' import { DOMAIN, PROJECT_ID } from '../../common/envs/constants'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
@ -9,6 +7,8 @@ import { Contract, FreeResponseContract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees' import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { formatMoney, formatPercent } from '../../common/util/format' import { formatMoney, formatPercent } from '../../common/util/format'
import { getValueFromBucket } from '../../common/calculate-dpm'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail } from './send-email'
import { getPrivateUser, getUser } from './utils' import { getPrivateUser, getUser } from './utils'
@ -104,6 +104,12 @@ const toDisplayResolution = (
if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'MKT' && resolutions) return 'MULTI'
if (resolution === 'CANCEL') return 'N/A' if (resolution === 'CANCEL') return 'N/A'
if (contract.outcomeType === 'NUMERIC' && contract.mechanism === 'dpm-2')
return (
contract.resolutionValue?.toString() ??
getValueFromBucket(resolution, contract).toString()
)
const answer = (contract as FreeResponseContract).answers?.find( const answer = (contract as FreeResponseContract).answers?.find(
(a) => a.id === resolution (a) => a.id === resolution
) )
@ -244,7 +250,8 @@ export const sendNewCommentEmail = async (
contract: Contract, contract: Contract,
comment: Comment, comment: Comment,
bet?: Bet, bet?: Bet,
answer?: Answer answerText?: string,
answerId?: string
) => { ) => {
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if ( if (
@ -255,7 +262,7 @@ export const sendNewCommentEmail = async (
return return
const { question, creatorUsername, slug } = contract const { question, creatorUsername, slug } = contract
const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}`
const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment`
@ -273,9 +280,8 @@ export const sendNewCommentEmail = async (
const subject = `Comment on ${question}` const subject = `Comment on ${question}`
const from = `${commentorName} <info@manifold.markets>` const from = `${commentorName} <info@manifold.markets>`
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
const answerText = answer?.text ?? '' const answerNumber = `#${answerId}`
const answerNumber = `#${answer?.id ?? ''}`
await sendTemplateEmail( await sendTemplateEmail(
privateUser.email, privateUser.email,

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getContract } from './utils' import { getContract } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { uniq } from 'lodash'
import { getContract, getUser, getValues } from './utils' import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
@ -34,7 +34,14 @@ export const onCreateComment = functions.firestore
let bet: Bet | undefined let bet: Bet | undefined
let answer: Answer | undefined let answer: Answer | undefined
if (comment.betId) { if (comment.answerOutcome) {
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers?.find(
(answer) => answer.id === comment.answerOutcome
)
: undefined
} else if (comment.betId) {
const betSnapshot = await firestore const betSnapshot = await firestore
.collection('contracts') .collection('contracts')
.doc(contractId) .doc(contractId)
@ -53,7 +60,7 @@ export const onCreateComment = functions.firestore
firestore.collection('contracts').doc(contractId).collection('comments') firestore.collection('contracts').doc(contractId).collection('comments')
) )
const recipientUserIds = _.uniq([ const recipientUserIds = uniq([
contract.creatorId, contract.creatorId,
...comments.map((comment) => comment.userId), ...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId) ]).filter((id) => id !== comment.userId)
@ -66,7 +73,8 @@ export const onCreateComment = functions.firestore
contract, contract,
comment, comment,
bet, bet,
answer answer?.text,
answer?.id
) )
) )
) )

View File

@ -1,141 +1,112 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod'
import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { import {
BetInfo,
getNewBinaryCpmmBetInfo, getNewBinaryCpmmBetInfo,
getNewBinaryDpmBetInfo, getNewBinaryDpmBetInfo,
getNewMultiBetInfo, getNewMultiBetInfo,
getNumericBetsInfo,
} from '../../common/new-bet' } from '../../common/new-bet'
import { addObjects, removeUndefinedProps } from '../../common/util/object' import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { Bet } from '../../common/bet'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { Fees } from '../../common/fees'
import { hasUserHitManaLimit } from '../../common/calculate'
export const placeBet = newEndpoint(['POST'], async (req, _res) => { const bodySchema = z.object({
const [bettor, _privateUser] = await lookupUser(await parseCredentials(req)) contractId: z.string(),
const { amount, outcome, contractId } = req.body.data || {} amount: z.number().gte(1),
})
if (amount <= 0 || isNaN(amount) || !isFinite(amount)) const binarySchema = z.object({
throw new APIError(400, 'Invalid amount') outcome: z.enum(['YES', 'NO']),
})
if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) const freeResponseSchema = z.object({
throw new APIError(400, 'Invalid outcome') outcome: z.string(),
})
// run as transaction to prevent race conditions const numericSchema = z.object({
return await firestore outcome: z.string(),
.runTransaction(async (transaction) => { value: z.number(),
const userDoc = firestore.doc(`users/${bettor.id}`) })
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`) export const placeBet = newEndpoint(['POST'], async (req, [bettor, _]) => {
const contractSnap = await transaction.get(contractDoc) const { amount, contractId } = validate(bodySchema, req.body)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
const { closeTime, outcomeType, mechanism, collectedFees, volume } = const result = await firestore.runTransaction(async (trans) => {
contract const userDoc = firestore.doc(`users/${bettor.id}`)
if (closeTime && Date.now() > closeTime) const userSnap = await trans.get(userDoc)
throw new APIError(400, 'Trading is closed') if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as User
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
const yourBetsSnap = await transaction.get( const contractDoc = firestore.doc(`contracts/${contractId}`)
contractDoc.collection('bets').where('userId', '==', bettor.id) const contractSnap = await trans.get(contractDoc)
) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) const contract = contractSnap.data() as Contract
const loanAmount = 0 // getLoanAmount(yourBets, amount) const loanAmount = 0
if (user.balance < amount) throw new APIError(400, 'Insufficient balance') const { closeTime, outcomeType, mechanism, collectedFees, volume } =
contract
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Trading is closed.')
if (outcomeType === 'FREE_RESPONSE') { const {
const answerSnap = await transaction.get( newBet,
contractDoc.collection('answers').doc(outcome) newPool,
) newTotalShares,
if (!answerSnap.exists) throw new APIError(400, 'Invalid contract') newTotalBets,
newTotalLiquidity,
const { status, message } = hasUserHitManaLimit( newP,
contract, } = await (async (): Promise<BetInfo> => {
yourBets, if (outcomeType == 'BINARY' && mechanism == 'dpm-2') {
amount const { outcome } = validate(binarySchema, req.body)
) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount)
if (status === 'error') throw new APIError(400, message) } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') {
const { outcome } = validate(binarySchema, req.body)
return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
const { outcome } = validate(freeResponseSchema, req.body)
const answerDoc = contractDoc.collection('answers').doc(outcome)
const answerSnap = await trans.get(answerDoc)
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
const { outcome, value } = validate(numericSchema, req.body)
return getNumericBetsInfo(value, outcome, amount, contract)
} else {
throw new APIError(500, 'Contract has invalid type/mechanism.')
} }
})()
const newBetDoc = firestore if (newP != null && !isFinite(newP)) {
.collection(`contracts/${contractId}/bets`) throw new APIError(400, 'Trade rejected due to overflow error.')
.doc() }
const { const newBalance = user.balance - amount - loanAmount
newBet, const betDoc = contractDoc.collection('bets').doc()
newPool, trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
newTotalShares, trans.update(userDoc, { balance: newBalance })
newTotalBets, trans.update(
newBalance, contractDoc,
newTotalLiquidity, removeUndefinedProps({
fees, pool: newPool,
newP, p: newP,
} = totalShares: newTotalShares,
outcomeType === 'BINARY' totalBets: newTotalBets,
? mechanism === 'dpm-2' totalLiquidity: newTotalLiquidity,
? getNewBinaryDpmBetInfo( collectedFees: addObjects(newBet.fees, collectedFees),
user, volume: volume + amount,
outcome as 'YES' | 'NO', })
amount, )
contract,
loanAmount,
newBetDoc.id
)
: (getNewBinaryCpmmBetInfo(
user,
outcome as 'YES' | 'NO',
amount,
contract,
loanAmount,
newBetDoc.id
) as any)
: getNewMultiBetInfo(
user,
outcome,
amount,
contract as any,
loanAmount,
newBetDoc.id
)
if (newP !== undefined && !isFinite(newP)) { return { betId: betDoc.id }
throw new APIError(400, 'Trade rejected due to overflow error.') })
}
transaction.create(newBetDoc, newBet) await redeemShares(bettor.id, contractId)
return result
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalShares: newTotalShares,
totalBets: newTotalBets,
totalLiquidity: newTotalLiquidity,
collectedFees: addObjects<Fees>(fees ?? {}, collectedFees ?? {}),
volume: volume + Math.abs(amount),
})
)
if (!isFinite(newBalance)) {
throw new APIError(500, 'Invalid user balance for ' + user.username)
}
transaction.update(userDoc, { balance: newBalance })
return { betId: newBetDoc.id }
})
.then(async (result) => {
await redeemShares(bettor.id, contractId)
return result
})
}) })
const firestore = admin.firestore() const firestore = admin.firestore()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { partition, sumBy } from 'lodash'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
@ -25,14 +25,14 @@ export const redeemShares = async (userId: string, contractId: string) => {
.where('userId', '==', userId) .where('userId', '==', userId)
) )
const bets = betsSnap.docs.map((doc) => doc.data() as Bet) const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const [yesBets, noBets] = _.partition(bets, (b) => b.outcome === 'YES') const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES')
const yesShares = _.sumBy(yesBets, (b) => b.shares) const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = _.sumBy(noBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares)
const amount = Math.min(yesShares, noShares) const amount = Math.min(yesShares, noShares)
if (amount <= 0) return if (amount <= 0) return
const prevLoanAmount = _.sumBy(bets, (bet) => bet.loanAmount ?? 0) const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPaid = Math.min(prevLoanAmount, amount) const loanPaid = Math.min(prevLoanAmount, amount)
const netAmount = amount - loanPaid const netAmount = amount - loanPaid

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
@ -22,6 +22,7 @@ export const resolveMarket = functions
async ( async (
data: { data: {
outcome: string outcome: string
value?: number
contractId: string contractId: string
probabilityInt?: number probabilityInt?: number
resolutions?: { [outcome: string]: number } resolutions?: { [outcome: string]: number }
@ -31,7 +32,7 @@ export const resolveMarket = functions
const userId = context?.auth?.uid const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' } if (!userId) return { status: 'error', message: 'Not authorized' }
const { outcome, contractId, probabilityInt, resolutions } = data const { outcome, contractId, probabilityInt, resolutions, value } = data
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await contractDoc.get() const contractSnap = await contractDoc.get()
@ -50,10 +51,16 @@ export const resolveMarket = functions
outcome !== 'CANCEL' outcome !== 'CANCEL'
) )
return { status: 'error', message: 'Invalid outcome' } return { status: 'error', message: 'Invalid outcome' }
} else if (outcomeType === 'NUMERIC') {
if (isNaN(+outcome) && outcome !== 'CANCEL')
return { status: 'error', message: 'Invalid outcome' }
} else { } else {
return { status: 'error', message: 'Invalid contract outcomeType' } return { status: 'error', message: 'Invalid contract outcomeType' }
} }
if (value !== undefined && !isFinite(value))
return { status: 'error', message: 'Invalid value' }
if ( if (
outcomeType === 'BINARY' && outcomeType === 'BINARY' &&
probabilityInt !== undefined && probabilityInt !== undefined &&
@ -108,6 +115,7 @@ export const resolveMarket = functions
removeUndefinedProps({ removeUndefinedProps({
isResolved: true, isResolved: true,
resolution: outcome, resolution: outcome,
resolutionValue: value,
resolutionTime, resolutionTime,
closeTime: newCloseTime, closeTime: newCloseTime,
resolutionProbability, resolutionProbability,
@ -179,13 +187,13 @@ const sendResolutionEmails = async (
resolutionProbability?: number, resolutionProbability?: number,
resolutions?: { [outcome: string]: number } resolutions?: { [outcome: string]: number }
) => { ) => {
const nonWinners = _.difference( const nonWinners = difference(
_.uniq(openBets.map(({ userId }) => userId)), uniq(openBets.map(({ userId }) => userId)),
Object.keys(userPayouts) Object.keys(userPayouts)
) )
const investedByUser = _.mapValues( const investedByUser = mapValues(
_.groupBy(openBets, (bet) => bet.userId), groupBy(openBets, (bet) => bet.userId),
(bets) => _.sumBy(bets, (bet) => bet.amount) (bets) => sumBy(bets, (bet) => bet.amount)
) )
const emailPayouts = [ const emailPayouts = [
...Object.entries(userPayouts), ...Object.entries(userPayouts),

View File

@ -0,0 +1,25 @@
// We have many old contracts without a collectedFees data structure. Let's fill them in.
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { noFees } from '../../../common/fees'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const contractsRef = firestore.collection('contracts')
contractsRef.get().then((contractsSnaps) => {
let n = 0
console.log(`Loaded ${contractsSnaps.size} contracts.`)
contractsSnaps.forEach((ct) => {
const data = ct.data()
if (!('collectedFees' in data)) {
n += 1
console.log(`Filling in missing fees on contract ${data.id}...`)
ct.ref.update({ collectedFees: noFees })
}
})
console.log(`Updated ${n} contracts.`)
})
}

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sortBy } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -20,7 +20,7 @@ async function migrateContract(
.get() .get()
.then((snap) => snap.docs.map((bet) => bet.data() as Bet)) .then((snap) => snap.docs.map((bet) => bet.data() as Bet))
const lastBet = _.sortBy(bets, (bet) => -bet.createdTime)[0] const lastBet = sortBy(bets, (bet) => -bet.createdTime)[0]
if (lastBet) { if (lastBet) {
const probAfter = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(contract.totalShares)

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import * as fs from 'fs' import * as fs from 'fs'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { uniq } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -19,7 +19,7 @@ async function lowercaseFoldTags() {
const foldRef = firestore.doc(`folds/${fold.id}`) const foldRef = firestore.doc(`folds/${fold.id}`)
const { tags } = fold const { tags } = fold
const lowercaseTags = _.uniq(tags.map((tag) => tag.toLowerCase())) const lowercaseTags = uniq(tags.map((tag) => tag.toLowerCase()))
console.log('Adding lowercase tags', fold.slug, lowercaseTags) console.log('Adding lowercase tags', fold.slug, lowercaseTags)

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sumBy } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -25,8 +25,8 @@ async function migrateContract(contractRef: DocRef, contract: Contract) {
.then((snap) => snap.docs.map((bet) => bet.data() as Bet)) .then((snap) => snap.docs.map((bet) => bet.data() as Bet))
const totalShares = { const totalShares = {
YES: _.sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)), YES: sumBy(bets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: _.sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)), NO: sumBy(bets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
} }
await contractRef.update({ totalShares }) await contractRef.update({ totalShares })

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sortBy } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -48,7 +48,7 @@ async function recalculateContract(contractRef: DocRef, isCommit = false) {
const betsRef = contractRef.collection('bets') const betsRef = contractRef.collection('bets')
const betDocs = await transaction.get(betsRef) const betDocs = await transaction.get(betsRef)
const bets = _.sortBy( const bets = sortBy(
betDocs.docs.map((d) => d.data() as Bet), betDocs.docs.map((d) => d.data() as Bet),
(b) => b.createdTime (b) => b.createdTime
) )

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sortBy, sumBy } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -35,7 +35,7 @@ async function recalculateContract(
const contract = contractDoc.data() as FullContract<DPM, Binary> const contract = contractDoc.data() as FullContract<DPM, Binary>
const betDocs = await transaction.get(contractRef.collection('bets')) const betDocs = await transaction.get(contractRef.collection('bets'))
const bets = _.sortBy( const bets = sortBy(
betDocs.docs.map((d) => d.data() as Bet), betDocs.docs.map((d) => d.data() as Bet),
(b) => b.createdTime (b) => b.createdTime
) )
@ -43,8 +43,8 @@ async function recalculateContract(
const phantomAnte = startPool.YES + startPool.NO const phantomAnte = startPool.YES + startPool.NO
const leftovers = const leftovers =
_.sumBy(bets, (b) => b.amount) - sumBy(bets, (b) => b.amount) -
_.sumBy(bets, (b) => { sumBy(bets, (b) => {
if (!b.sale) return b.amount if (!b.sale) return b.amount
const soldBet = bets.find((bet) => bet.id === b.sale?.betId) const soldBet = bets.find((bet) => bet.id === b.sale?.betId)
return soldBet?.amount || 0 return soldBet?.amount || 0

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { flatten, groupBy, sumBy, mapValues } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -35,12 +35,12 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) {
) )
const loanPayouts = getLoanPayouts(openBets) const loanPayouts = getLoanPayouts(openBets)
const groups = _.groupBy( const groups = groupBy(
[...payouts, ...loanPayouts], [...payouts, ...loanPayouts],
(payout) => payout.userId (payout) => payout.userId
) )
const userPayouts = _.mapValues(groups, (group) => const userPayouts = mapValues(groups, (group) =>
_.sumBy(group, (g) => g.payout) sumBy(group, (g) => g.payout)
) )
const entries = Object.entries(userPayouts) const entries = Object.entries(userPayouts)
@ -93,7 +93,7 @@ async function payOutContractAgain() {
) )
) )
const flattened = _.flatten(toPayOutAgain.map((d) => d.toBePaidOut)) const flattened = flatten(toPayOutAgain.map((d) => d.toBePaidOut))
for (const [userId, payout] of flattened) { for (const [userId, payout] of flattened) {
console.log('Paying out', userId, payout) console.log('Paying out', userId, payout)

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sumBy } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -20,13 +20,13 @@ async function recalculateContract(contractRef: DocRef, contract: Contract) {
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
const totalShares = { const totalShares = {
YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)), YES: sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.shares : 0)),
NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)), NO: sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.shares : 0)),
} }
const totalBets = { const totalBets = {
YES: _.sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)), YES: sumBy(openBets, (bet) => (bet.outcome === 'YES' ? bet.amount : 0)),
NO: _.sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)), NO: sumBy(openBets, (bet) => (bet.outcome === 'NO' ? bet.amount : 0)),
} }
await contractRef.update({ totalShares, totalBets }) await contractRef.update({ totalShares, totalBets })

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { uniq } from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()
@ -19,7 +19,7 @@ async function updateContractTags() {
for (const contract of contracts) { for (const contract of contracts) {
const contractRef = firestore.doc(`contracts/${contract.id}`) const contractRef = firestore.doc(`contracts/${contract.id}`)
const tags = _.uniq([ const tags = uniq([
...parseTags(contract.question + contract.description), ...parseTags(contract.question + contract.description),
...(contract.tags ?? []), ...(contract.tags ?? []),
]) ])

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,5 +1,4 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { initAdmin } from './script-init' import { initAdmin } from './script-init'
initAdmin() initAdmin()

View File

@ -1,4 +1,4 @@
import * as _ from 'lodash' import { partition, sumBy } from 'lodash'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
@ -51,15 +51,15 @@ export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall(
contractDoc.collection('bets').where('userId', '==', userId) contractDoc.collection('bets').where('userId', '==', userId)
) )
const prevLoanAmount = _.sumBy(userBets, (bet) => bet.loanAmount ?? 0) const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
const [yesBets, noBets] = _.partition( const [yesBets, noBets] = partition(
userBets ?? [], userBets ?? [],
(bet) => bet.outcome === 'YES' (bet) => bet.outcome === 'YES'
) )
const [yesShares, noShares] = [ const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares), sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares), sumBy(noBets, (bet) => bet.shares),
] ]
const maxShares = outcome === 'YES' ? yesShares : noShares const maxShares = outcome === 'YES' ? yesShares : noShares

View File

@ -5,11 +5,13 @@ import Stripe from 'stripe'
import { getPrivateUser, getUser, isProd, payUser } from './utils' import { getPrivateUser, getUser, isProd, payUser } from './utils'
import { sendThankYouEmail } from './emails' import { sendThankYouEmail } from './emails'
export type StripeSession = Stripe.Event.Data.Object & { id: any, metadata: any}
export type StripeTransaction = { export type StripeTransaction = {
userId: string userId: string
manticDollarQuantity: number manticDollarQuantity: number
sessionId: string sessionId: string
session: any session: StripeSession
timestamp: number timestamp: number
} }
@ -96,14 +98,14 @@ export const stripeWebhook = functions
} }
if (event.type === 'checkout.session.completed') { if (event.type === 'checkout.session.completed') {
const session = event.data.object as any const session = event.data.object as StripeSession
await issueMoneys(session) await issueMoneys(session)
} }
res.status(200).send('success') res.status(200).send('success')
}) })
const issueMoneys = async (session: any) => { const issueMoneys = async (session: StripeSession) => {
const { id: sessionId } = session const { id: sessionId } = session
const query = await firestore const query = await firestore

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getUser } from './utils' import { getUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sumBy } from 'lodash'
import { getValues } from './utils' import { getValues } from './utils'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -39,5 +39,5 @@ const computeVolumeFrom = async (contract: Contract, timeAgoMs: number) => {
.where('createdTime', '>', Date.now() - timeAgoMs) .where('createdTime', '>', Date.now() - timeAgoMs)
) )
return _.sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount))) return sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount)))
} }

View File

@ -1,6 +1,6 @@
import * as _ from 'lodash'
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { shuffle, sortBy } from 'lodash'
import { getValue, getValues } from './utils' import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -30,7 +30,7 @@ const BATCH_SIZE = 30
const MAX_BATCHES = 50 const MAX_BATCHES = 50
const getUserBatches = async () => { const getUserBatches = async () => {
const users = _.shuffle(await getValues<User>(firestore.collection('users'))) const users = shuffle(await getValues<User>(firestore.collection('users')))
let userBatches: User[][] = [] let userBatches: User[][] = []
for (let i = 0; i < users.length; i += BATCH_SIZE) { for (let i = 0; i < users.length; i += BATCH_SIZE) {
userBatches.push(users.slice(i, i + BATCH_SIZE)) userBatches.push(users.slice(i, i + BATCH_SIZE))
@ -42,7 +42,7 @@ const getUserBatches = async () => {
} }
export const updateFeed = functions.pubsub export const updateFeed = functions.pubsub
.schedule('every 15 minutes') .schedule('every 60 minutes')
.onRun(async () => { .onRun(async () => {
const userBatches = await getUserBatches() const userBatches = await getUserBatches()
@ -128,7 +128,7 @@ export const computeFeed = async (user: User, contracts: Contract[]) => {
return [contract, score] as [Contract, number] return [contract, score] as [Contract, number]
}) })
const sortedContracts = _.sortBy( const sortedContracts = sortBy(
scoredContracts, scoredContracts,
([_, score]) => score ([_, score]) => score
).reverse() ).reverse()

View File

@ -1,6 +1,5 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getValue, getValues } from './utils' import { getValue, getValues } from './utils'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import * as _ from 'lodash' import { sum, sumBy } from 'lodash'
import { getValues } from './utils' import { getValues } from './utils'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -19,7 +19,7 @@ export const updateUserMetrics = functions.pubsub
getValues<Contract>(firestore.collection('contracts')), getValues<Contract>(firestore.collection('contracts')),
]) ])
const contractsDict = _.fromPairs( const contractsDict = Object.fromEntries(
contracts.map((contract) => [contract.id, contract]) contracts.map((contract) => [contract.id, contract])
) )
@ -43,12 +43,12 @@ export const updateUserMetrics = functions.pubsub
const computeInvestmentValue = async ( const computeInvestmentValue = async (
user: User, user: User,
contractsDict: _.Dictionary<Contract> contractsDict: { [k: string]: Contract }
) => { ) => {
const query = firestore.collectionGroup('bets').where('userId', '==', user.id) const query = firestore.collectionGroup('bets').where('userId', '==', user.id)
const bets = await getValues<Bet>(query) const bets = await getValues<Bet>(query)
return _.sumBy(bets, (bet) => { return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId] const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0 if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0 if (bet.sale || bet.isSold) return 0
@ -60,20 +60,20 @@ const computeInvestmentValue = async (
const computeTotalPool = async ( const computeTotalPool = async (
user: User, user: User,
contractsDict: _.Dictionary<Contract> contractsDict: { [k: string]: Contract }
) => { ) => {
const creatorContracts = Object.values(contractsDict).filter( const creatorContracts = Object.values(contractsDict).filter(
(contract) => contract.creatorId === user.id (contract) => contract.creatorId === user.id
) )
const pools = creatorContracts.map((contract) => const pools = creatorContracts.map((contract) =>
_.sum(Object.values(contract.pool)) sum(Object.values(contract.pool))
) )
return _.sum(pools) return sum(pools)
} }
const computeVolume = async (contract: Contract) => { const computeVolume = async (contract: Contract) => {
const bets = await getValues<Bet>( const bets = await getValues<Bet>(
firestore.collection(`contracts/${contract.id}/bets`) firestore.collection(`contracts/${contract.id}/bets`)
) )
return _.sumBy(bets, (bet) => Math.abs(bet.amount)) return sumBy(bets, (bet) => Math.abs(bet.amount))
} }

View File

@ -8,5 +8,12 @@
], ],
"scripts": {}, "scripts": {},
"dependencies": {}, "dependencies": {},
"devDependencies": {} "devDependencies": {
"@typescript-eslint/eslint-plugin": "5.25.0",
"@typescript-eslint/parser": "5.25.0",
"eslint": "8.15.0",
"eslint-plugin-lodash": "^7.4.0",
"prettier": "2.5.0",
"typescript": "4.6.4"
}
} }

View File

@ -1,9 +1,21 @@
module.exports = { module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
extends: ['plugin:react-hooks/recommended', 'plugin:@next/next/recommended'], plugins: ['lodash'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@next/next/recommended',
],
rules: { rules: {
// Add or disable rules here. '@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@next/next/no-img-element': 'off', '@next/next/no-img-element': 'off',
'@next/next/no-typos': 'off', '@next/next/no-typos': 'off',
'lodash/import-scope': [2, 'member'],
},
env: {
browser: true,
node: true,
}, },
} }

View File

@ -1,7 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx pretty-quick --staged
# Disable tsc lint for now, cuz it's been annoying
# cd web
# npx lint-staged

View File

@ -1,3 +1,4 @@
import { ReactNode } from 'react'
import Head from 'next/head' import Head from 'next/head'
export type OgCardProps = { export type OgCardProps = {
@ -35,7 +36,7 @@ export function SEO(props: {
title: string title: string
description: string description: string
url?: string url?: string
children?: any[] children?: ReactNode
ogCardProps?: OgCardProps ogCardProps?: OgCardProps
}) { }) {
const { title, description, url, children, ogCardProps } = props const { title, description, url, children, ogCardProps } = props

View File

@ -4,7 +4,7 @@ import { useState } from 'react'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { addLiquidity } from 'web/lib/firebase/api-call' import { addLiquidity } from 'web/lib/firebase/fn-call'
import { AmountInput } from './amount-input' import { AmountInput } from './amount-input'
import { Row } from './layout/row' import { Row } from './layout/row'

View File

@ -1,7 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useState } from 'react' import { useState, ReactNode } from 'react'
export function AdvancedPanel(props: { children: any }) { export function AdvancedPanel(props: { children: ReactNode }) {
const { children } = props const { children } = props
const [collapsed, setCollapsed] = useState(true) const [collapsed, setCollapsed] = useState(true)

View File

@ -1,4 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import React from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { Col } from './layout/col' import { Col } from './layout/col'

View File

@ -1,7 +1,8 @@
import { ResponsiveLine } from '@nivo/line' import { Point, ResponsiveLine } from '@nivo/line'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import _ from 'lodash' import { zip } from 'lodash'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
import { Col } from '../layout/col'
export function DailyCountChart(props: { export function DailyCountChart(props: {
startDate: number startDate: number
@ -15,7 +16,7 @@ export function DailyCountChart(props: {
dayjs(startDate).add(i, 'day').toDate() dayjs(startDate).add(i, 'day').toDate()
) )
const points = _.zip(dates, dailyCounts).map(([date, betCount]) => ({ const points = zip(dates, dailyCounts).map(([date, betCount]) => ({
x: date, x: date,
y: betCount, y: betCount,
})) }))
@ -46,6 +47,10 @@ export function DailyCountChart(props: {
enableGridX={!!width && width >= 800} enableGridX={!!width && width >= 800}
enableArea enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }} margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/> />
</div> </div>
) )
@ -63,7 +68,7 @@ export function DailyPercentChart(props: {
dayjs(startDate).add(i, 'day').toDate() dayjs(startDate).add(i, 'day').toDate()
) )
const points = _.zip(dates, dailyPercent).map(([date, betCount]) => ({ const points = zip(dates, dailyPercent).map(([date, betCount]) => ({
x: date, x: date,
y: betCount, y: betCount,
})) }))
@ -97,7 +102,28 @@ export function DailyPercentChart(props: {
enableGridX={!!width && width >= 800} enableGridX={!!width && width >= 800}
enableArea enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 40 }} margin={{ top: 20, right: 28, bottom: 22, left: 40 }}
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
/> />
</div> </div>
) )
} }
function Tooltip(props: { point: Point }) {
const { point } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
</div>
<div>{dayjs(point.data.x).format('MMM DD')}</div>
</Col>
)
}

View File

@ -6,7 +6,7 @@ import { Answer } from 'common/answer'
import { DPM, FreeResponse, FullContract } from 'common/contract' import { DPM, FreeResponse, FullContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input' import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { placeBet } from 'web/lib/firebase/api-call' import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { import {
@ -52,22 +52,26 @@ export function AnswerBetPanel(props: {
setError(undefined) setError(undefined)
setIsSubmitting(true) setIsSubmitting(true)
const result = await placeBet({ placeBet({
amount: betAmount, amount: betAmount,
outcome: answerId, outcome: answerId,
contractId: contract.id, contractId: contract.id,
}).then((r) => r.data as any) })
.then((r) => {
console.log('placed bet. Result:', result) console.log('placed bet. Result:', r)
setIsSubmitting(false)
if (result?.status === 'success') { setBetAmount(undefined)
setIsSubmitting(false) props.closePanel()
setBetAmount(undefined) })
props.closePanel() .catch((e) => {
} else { if (e instanceof APIError) {
setError(result?.message || 'Error placing bet') setError(e.toString())
setIsSubmitting(false) } else {
} console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
} }
const betDisabled = isSubmitting || !betAmount || error const betDisabled = isSubmitting || !betAmount || error

View File

@ -1,10 +1,10 @@
import clsx from 'clsx' import clsx from 'clsx'
import _ from 'lodash' import { sum, mapValues } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { DPM, FreeResponse, FullContract } from 'common/contract' import { DPM, FreeResponse, FullContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { resolveMarket } from 'web/lib/firebase/api-call' import { resolveMarket } from 'web/lib/firebase/fn-call'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { ChooseCancelSelector } from '../yes-no-selector' import { ChooseCancelSelector } from '../yes-no-selector'
import { ResolveConfirmationButton } from '../confirmation-button' import { ResolveConfirmationButton } from '../confirmation-button'
@ -30,8 +30,8 @@ export function AnswerResolvePanel(props: {
setIsSubmitting(true) setIsSubmitting(true)
const totalProb = _.sum(Object.values(chosenAnswers)) const totalProb = sum(Object.values(chosenAnswers))
const normalizedProbs = _.mapValues( const normalizedProbs = mapValues(
chosenAnswers, chosenAnswers,
(prob) => (100 * prob) / totalProb (prob) => (100 * prob) / totalProb
) )

View File

@ -1,7 +1,7 @@
import { DatumValue } from '@nivo/core' import { DatumValue } from '@nivo/core'
import { ResponsiveLine } from '@nivo/line' import { ResponsiveLine } from '@nivo/line'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import _ from 'lodash' import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react' import { memo } from 'react'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
@ -48,7 +48,7 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
// to the right. // to the right.
latestTime.add(1, 'month').valueOf() latestTime.add(1, 'month').valueOf()
const times = _.sortBy([ const times = sortBy([
createdTime, createdTime,
...bets.map((bet) => bet.createdTime), ...bets.map((bet) => bet.createdTime),
endTime, endTime,
@ -167,7 +167,7 @@ const computeProbsByOutcome = (
) => { ) => {
const { totalBets } = contract const { totalBets } = contract
const betsByOutcome = _.groupBy(bets, (bet) => bet.outcome) const betsByOutcome = groupBy(bets, (bet) => bet.outcome)
const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const outcomes = Object.keys(betsByOutcome).filter((outcome) => {
const maxProb = Math.max( const maxProb = Math.max(
...betsByOutcome[outcome].map((bet) => bet.probAfter) ...betsByOutcome[outcome].map((bet) => bet.probAfter)
@ -175,15 +175,15 @@ const computeProbsByOutcome = (
return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001 return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001
}) })
const trackedOutcomes = _.sortBy( const trackedOutcomes = sortBy(
outcomes, outcomes,
(outcome) => -1 * getOutcomeProbability(contract, outcome) (outcome) => -1 * getOutcomeProbability(contract, outcome)
).slice(0, NUM_LINES) ).slice(0, NUM_LINES)
const probsByOutcome = _.fromPairs( const probsByOutcome = Object.fromEntries(
trackedOutcomes.map((outcome) => [outcome, [] as number[]]) trackedOutcomes.map((outcome) => [outcome, [] as number[]])
) )
const sharesByOutcome = _.fromPairs( const sharesByOutcome = Object.fromEntries(
Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) Object.keys(betsByOutcome).map((outcome) => [outcome, 0])
) )
@ -191,7 +191,7 @@ const computeProbsByOutcome = (
const { outcome, shares } = bet const { outcome, shares } = bet
sharesByOutcome[outcome] += shares sharesByOutcome[outcome] += shares
const sharesSquared = _.sumBy( const sharesSquared = sumBy(
Object.values(sharesByOutcome).map((shares) => shares ** 2) Object.values(sharesByOutcome).map((shares) => shares ** 2)
) )

View File

@ -1,5 +1,5 @@
import _ from 'lodash' import { sortBy, partition, sum, uniq } from 'lodash'
import React, { useLayoutEffect, useState } from 'react' import { useLayoutEffect, useState } from 'react'
import { DPM, FreeResponse, FullContract } from 'common/contract' import { DPM, FreeResponse, FullContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -32,7 +32,7 @@ export function AnswersPanel(props: {
const { creatorId, resolution, resolutions, totalBets } = contract const { creatorId, resolution, resolutions, totalBets } = contract
const answers = useAnswers(contract.id) ?? contract.answers const answers = useAnswers(contract.id) ?? contract.answers
const [winningAnswers, losingAnswers] = _.partition( const [winningAnswers, losingAnswers] = partition(
answers.filter( answers.filter(
(answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001 (answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001
), ),
@ -40,10 +40,10 @@ export function AnswersPanel(props: {
answer.id === resolution || (resolutions && resolutions[answer.id]) answer.id === resolution || (resolutions && resolutions[answer.id])
) )
const sortedAnswers = [ const sortedAnswers = [
..._.sortBy(winningAnswers, (answer) => ...sortBy(winningAnswers, (answer) =>
resolutions ? -1 * resolutions[answer.id] : 0 resolutions ? -1 * resolutions[answer.id] : 0
), ),
..._.sortBy( ...sortBy(
resolution ? [] : losingAnswers, resolution ? [] : losingAnswers,
(answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id) (answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id)
), ),
@ -58,7 +58,7 @@ export function AnswersPanel(props: {
[answerId: string]: number [answerId: string]: number
}>({}) }>({})
const chosenTotal = _.sum(Object.values(chosenAnswers)) const chosenTotal = sum(Object.values(chosenAnswers))
const answerItems = getAnswerItems( const answerItems = getAnswerItems(
contract, contract,
@ -158,10 +158,10 @@ function getAnswerItems(
answers: Answer[], answers: Answer[],
user: User | undefined | null user: User | undefined | null
) { ) {
let outcomes = _.uniq( let outcomes = uniq(answers.map((answer) => answer.number.toString())).filter(
answers.map((answer) => answer.number.toString()) (outcome) => getOutcomeProbability(contract, outcome) > 0.0001
).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001) )
outcomes = _.sortBy(outcomes, (outcome) => outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome) getOutcomeProbability(contract, outcome)
).reverse() ).reverse()

View File

@ -5,7 +5,7 @@ import Textarea from 'react-expanding-textarea'
import { DPM, FreeResponse, FullContract } from 'common/contract' import { DPM, FreeResponse, FullContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input' import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { createAnswer } from 'web/lib/firebase/api-call' import { createAnswer } from 'web/lib/firebase/fn-call'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { import {
formatMoney, formatMoney,

View File

@ -1,5 +1,6 @@
import Router from 'next/router' import Router from 'next/router'
import clsx from 'clsx' import clsx from 'clsx'
import { MouseEvent } from 'react'
import { UserCircleIcon } from '@heroicons/react/solid' import { UserCircleIcon } from '@heroicons/react/solid'
export function Avatar(props: { export function Avatar(props: {
@ -15,7 +16,7 @@ export function Avatar(props: {
const onClick = const onClick =
noLink && username noLink && username
? undefined ? undefined
: (e: any) => { : (e: MouseEvent) => {
e.stopPropagation() e.stopPropagation()
Router.push(`/${username}`) Router.push(`/${username}`)
} }

View File

@ -1,6 +1,6 @@
import clsx from 'clsx' import clsx from 'clsx'
import _ from 'lodash'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { partition, sumBy } from 'lodash'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { Binary, CPMM, DPM, FullContract } from 'common/contract' import { Binary, CPMM, DPM, FullContract } from 'common/contract'
@ -14,9 +14,10 @@ import {
formatWithCommas, formatWithCommas,
} from 'common/util/format' } from 'common/util/format'
import { Title } from './title' import { Title } from './title'
import { firebaseLogin, User } from 'web/lib/firebase/users' import { User } from 'web/lib/firebase/users'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { placeBet, sellShares } from 'web/lib/firebase/api-call' import { APIError, placeBet } from 'web/lib/firebase/api-call'
import { sellShares } from 'web/lib/firebase/fn-call'
import { AmountInput, BuyAmountInput } from './amount-input' import { AmountInput, BuyAmountInput } from './amount-input'
import { InfoTooltip } from './info-tooltip' import { InfoTooltip } from './info-tooltip'
import { BinaryOutcomeLabel } from './outcome-label' import { BinaryOutcomeLabel } from './outcome-label'
@ -35,6 +36,7 @@ import {
} from 'common/calculate-cpmm' } from 'common/calculate-cpmm'
import { SellRow } from './sell-row' import { SellRow } from './sell-row'
import { useSaveShares } from './use-save-shares' import { useSaveShares } from './use-save-shares'
import { SignUpPrompt } from './sign-up-prompt'
export function BetPanel(props: { export function BetPanel(props: {
contract: FullContract<DPM | CPMM, Binary> contract: FullContract<DPM | CPMM, Binary>
@ -69,14 +71,7 @@ export function BetPanel(props: {
<BuyPanel contract={contract} user={user} /> <BuyPanel contract={contract} user={user} />
{user === null && ( <SignUpPrompt />
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up to bet!
</button>
)}
</Col> </Col>
</Col> </Col>
) )
@ -182,14 +177,7 @@ export function BetPanelSwitcher(props: {
/> />
)} )}
{user === null && ( <SignUpPrompt />
<button
className="btn flex-1 whitespace-nowrap border-none bg-gradient-to-r from-teal-500 to-green-500 px-10 text-lg font-medium normal-case hover:from-teal-600 hover:to-green-600"
onClick={firebaseLogin}
>
Sign up to bet!
</button>
)}
</Col> </Col>
</Col> </Col>
) )
@ -240,23 +228,27 @@ function BuyPanel(props: {
setError(undefined) setError(undefined)
setIsSubmitting(true) setIsSubmitting(true)
const result = await placeBet({ placeBet({
amount: betAmount, amount: betAmount,
outcome: betChoice, outcome: betChoice,
contractId: contract.id, contractId: contract.id,
}).then((r) => r.data as any) })
.then((r) => {
console.log('placed bet. Result:', result) console.log('placed bet. Result:', r)
setIsSubmitting(false)
if (result?.status === 'success') { setWasSubmitted(true)
setIsSubmitting(false) setBetAmount(undefined)
setWasSubmitted(true) if (onBuySuccess) onBuySuccess()
setBetAmount(undefined) })
if (onBuySuccess) onBuySuccess() .catch((e) => {
} else { if (e instanceof APIError) {
setError(result?.message || 'Error placing bet') setError(e.toString())
setIsSubmitting(false) } else {
} console.error(e)
setError('Error placing bet')
}
setIsSubmitting(false)
})
} }
const betDisabled = isSubmitting || !betAmount || error const betDisabled = isSubmitting || !betAmount || error
@ -436,13 +428,13 @@ export function SellPanel(props: {
const resultProb = getCpmmProbability(newPool, contract.p) const resultProb = getCpmmProbability(newPool, contract.p)
const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale) const openUserBets = userBets.filter((bet) => !bet.isSold && !bet.sale)
const [yesBets, noBets] = _.partition( const [yesBets, noBets] = partition(
openUserBets, openUserBets,
(bet) => bet.outcome === 'YES' (bet) => bet.outcome === 'YES'
) )
const [yesShares, noShares] = [ const [yesShares, noShares] = [
_.sumBy(yesBets, (bet) => bet.shares), sumBy(yesBets, (bet) => bet.shares),
_.sumBy(noBets, (bet) => bet.shares), sumBy(noBets, (bet) => bet.shares),
] ]
const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined const sellOutcome = yesShares ? 'YES' : noShares ? 'NO' : undefined

View File

@ -1,5 +1,13 @@
import Link from 'next/link' import Link from 'next/link'
import _ from 'lodash' import {
uniq,
groupBy,
mapValues,
sortBy,
partition,
sumBy,
throttle,
} from 'lodash'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@ -22,7 +30,7 @@ import {
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Row } from './layout/row' import { Row } from './layout/row'
import { UserLink } from './user-page' import { UserLink } from './user-page'
import { sellBet } from 'web/lib/firebase/api-call' import { sellBet } from 'web/lib/firebase/fn-call'
import { ConfirmationButton } from './confirmation-button' import { ConfirmationButton } from './confirmation-button'
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
@ -39,14 +47,14 @@ import {
} from 'common/calculate' } from 'common/calculate'
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking' import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'closed' | 'resolved' | 'all' type BetFilter = 'open' | 'closed' | 'resolved' | 'all'
export function BetsList(props: { user: User }) { export function BetsList(props: { user: User }) {
const { user } = props const { user } = props
const bets = useUserBets(user.id) const bets = useUserBets(user.id, { includeRedemptions: true })
const [contracts, setContracts] = useState<Contract[] | undefined>() const [contracts, setContracts] = useState<Contract[] | undefined>()
const [sort, setSort] = useState<BetSort>('newest') const [sort, setSort] = useState<BetSort>('newest')
@ -54,7 +62,7 @@ export function BetsList(props: { user: User }) {
useEffect(() => { useEffect(() => {
if (bets) { if (bets) {
const contractIds = _.uniq(bets.map((bet) => bet.contractId)) const contractIds = uniq(bets.map((bet) => bet.contractId))
let disposed = false let disposed = false
Promise.all(contractIds.map((id) => getContractFromId(id))).then( Promise.all(contractIds.map((id) => getContractFromId(id))).then(
@ -84,10 +92,10 @@ export function BetsList(props: { user: User }) {
if (bets.length === 0) return <NoBets /> if (bets.length === 0) return <NoBets />
// Decending creation time. // Decending creation time.
bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime) bets.sort((bet1, bet2) => bet2.createdTime - bet1.createdTime)
const contractBets = _.groupBy(bets, 'contractId') const contractBets = groupBy(bets, 'contractId')
const contractsById = _.fromPairs(contracts.map((c) => [c.id, c])) const contractsById = Object.fromEntries(contracts.map((c) => [c.id, c]))
const contractsMetrics = _.mapValues(contractBets, (bets, contractId) => { const contractsMetrics = mapValues(contractBets, (bets, contractId) => {
const contract = contractsById[contractId] const contract = contractsById[contractId]
if (!contract) return getContractBetNullMetrics() if (!contract) return getContractBetNullMetrics()
return getContractBetMetrics(contract, bets) return getContractBetMetrics(contract, bets)
@ -110,7 +118,7 @@ export function BetsList(props: { user: User }) {
(filter === 'open' ? -1 : 1) * (filter === 'open' ? -1 : 1) *
(c.resolutionTime ?? c.closeTime ?? Infinity), (c.resolutionTime ?? c.closeTime ?? Infinity),
} }
const displayedContracts = _.sortBy(contracts, SORTS[sort]) const displayedContracts = sortBy(contracts, SORTS[sort])
.reverse() .reverse()
.filter(FILTERS[filter]) .filter(FILTERS[filter])
.filter((c) => { .filter((c) => {
@ -121,20 +129,20 @@ export function BetsList(props: { user: User }) {
return metrics.payout > 0 return metrics.payout > 0
}) })
const [settled, unsettled] = _.partition( const [settled, unsettled] = partition(
contracts, contracts,
(c) => c.isResolved || contractsMetrics[c.id].invested === 0 (c) => c.isResolved || contractsMetrics[c.id].invested === 0
) )
const currentInvested = _.sumBy( const currentInvested = sumBy(
unsettled, unsettled,
(c) => contractsMetrics[c.id].invested (c) => contractsMetrics[c.id].invested
) )
const currentBetsValue = _.sumBy( const currentBetsValue = sumBy(
unsettled, unsettled,
(c) => contractsMetrics[c.id].payout (c) => contractsMetrics[c.id].payout
) )
const currentNetInvestment = _.sumBy( const currentNetInvestment = sumBy(
unsettled, unsettled,
(c) => contractsMetrics[c.id].netPayout (c) => contractsMetrics[c.id].netPayout
) )
@ -228,6 +236,8 @@ function MyContractBets(props: {
const { bets, contract, metric } = props const { bets, contract, metric } = props
const { resolution, outcomeType } = contract const { resolution, outcomeType } = contract
const resolutionValue = (contract as NumericContract).resolutionValue
const [collapsed, setCollapsed] = useState(true) const [collapsed, setCollapsed] = useState(true)
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
@ -273,6 +283,7 @@ function MyContractBets(props: {
Resolved{' '} Resolved{' '}
<OutcomeLabel <OutcomeLabel
outcome={resolution} outcome={resolution}
value={resolutionValue}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />
@ -327,16 +338,20 @@ export function MyBetsSummary(props: {
bets: Bet[] bets: Bet[]
className?: string className?: string
}) { }) {
const { bets, contract, className } = props const { contract, className } = props
const { resolution, outcomeType, mechanism } = contract const { resolution, outcomeType, mechanism } = contract
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isCpmm = mechanism === 'cpmm-1' const isCpmm = mechanism === 'cpmm-1'
const excludeSales = bets.filter((b) => !b.isSold && !b.sale) const bets = props.bets.filter((b) => !b.isAnte)
const yesWinnings = _.sumBy(excludeSales, (bet) =>
const excludeSalesAndAntes = bets.filter(
(b) => !b.isAnte && !b.isSold && !b.sale
)
const yesWinnings = sumBy(excludeSalesAndAntes, (bet) =>
calculatePayout(contract, bet, 'YES') calculatePayout(contract, bet, 'YES')
) )
const noWinnings = _.sumBy(excludeSales, (bet) => const noWinnings = sumBy(excludeSalesAndAntes, (bet) =>
calculatePayout(contract, bet, 'NO') calculatePayout(contract, bet, 'NO')
) )
const { invested, profitPercent, payout, profit } = getContractBetMetrics( const { invested, profitPercent, payout, profit } = getContractBetMetrics(
@ -410,29 +425,30 @@ export function ContractBetsTable(props: {
bets: Bet[] bets: Bet[]
className?: string className?: string
}) { }) {
const { contract, bets, className } = props const { contract, className } = props
const [sales, buys] = _.partition(bets, (bet) => bet.sale) const bets = props.bets.filter((b) => !b.isAnte)
const salesDict = _.fromPairs( const [sales, buys] = partition(bets, (bet) => bet.sale)
const salesDict = Object.fromEntries(
sales.map((sale) => [sale.sale?.betId ?? '', sale]) sales.map((sale) => [sale.sale?.betId ?? '', sale])
) )
const [redemptions, normalBets] = _.partition( const [redemptions, normalBets] = partition(
contract.mechanism === 'cpmm-1' ? bets : buys, contract.mechanism === 'cpmm-1' ? bets : buys,
(b) => b.isRedemption (b) => b.isRedemption
) )
const amountRedeemed = Math.floor( const amountRedeemed = Math.floor(-0.5 * sumBy(redemptions, (b) => b.shares))
-0.5 * _.sumBy(redemptions, (b) => b.shares)
)
const amountLoaned = _.sumBy( const amountLoaned = sumBy(
bets.filter((bet) => !bet.isSold && !bet.sale), bets.filter((bet) => !bet.isSold && !bet.sale),
(bet) => bet.loanAmount ?? 0 (bet) => bet.loanAmount ?? 0
) )
const { isResolved, mechanism } = contract const { isResolved, mechanism, outcomeType } = contract
const isCPMM = mechanism === 'cpmm-1' const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC'
return ( return (
<div className={clsx('overflow-x-auto', className)}> <div className={clsx('overflow-x-auto', className)}>
@ -462,7 +478,9 @@ export function ContractBetsTable(props: {
{isCPMM && <th>Type</th>} {isCPMM && <th>Type</th>}
<th>Outcome</th> <th>Outcome</th>
<th>Amount</th> <th>Amount</th>
{!isCPMM && <th>{isResolved ? <>Payout</> : <>Sale price</>}</th>} {!isCPMM && !isNumeric && (
<th>{isResolved ? <>Payout</> : <>Sale price</>}</th>
)}
{!isCPMM && !isResolved && <th>Payout if chosen</th>} {!isCPMM && !isResolved && <th>Payout if chosen</th>}
<th>Shares</th> <th>Shares</th>
<th>Probability</th> <th>Probability</th>
@ -497,11 +515,12 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
isAnte, isAnte,
} = bet } = bet
const { isResolved, closeTime, mechanism } = contract const { isResolved, closeTime, mechanism, outcomeType } = contract
const isClosed = closeTime && Date.now() > closeTime const isClosed = closeTime && Date.now() > closeTime
const isCPMM = mechanism === 'cpmm-1' const isCPMM = mechanism === 'cpmm-1'
const isNumeric = outcomeType === 'NUMERIC'
const saleAmount = saleBet?.sale?.amount const saleAmount = saleBet?.sale?.amount
@ -518,31 +537,35 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
) )
const payoutIfChosenDisplay = const payoutIfChosenDisplay =
bet.outcome === '0' && bet.isAnte bet.isAnte && outcomeType === 'FREE_RESPONSE' && bet.outcome === '0'
? 'N/A' ? 'N/A'
: formatMoney(calculatePayout(contract, bet, bet.outcome)) : formatMoney(calculatePayout(contract, bet, bet.outcome))
return ( return (
<tr> <tr>
<td className="text-neutral"> <td className="text-neutral">
{!isCPMM && !isResolved && !isClosed && !isSold && !isAnte && ( {!isCPMM &&
<SellButton contract={contract} bet={bet} /> !isResolved &&
)} !isClosed &&
!isSold &&
!isAnte &&
!isNumeric && <SellButton contract={contract} bet={bet} />}
</td> </td>
{isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>} {isCPMM && <td>{shares >= 0 ? 'BUY' : 'SELL'}</td>}
<td> <td>
{outcome === '0' ? ( {bet.isAnte ? (
'ANTE' 'ANTE'
) : ( ) : (
<OutcomeLabel <OutcomeLabel
outcome={outcome} outcome={outcome}
value={(bet as any).value}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />
)} )}
</td> </td>
<td>{formatMoney(Math.abs(amount))}</td> <td>{formatMoney(Math.abs(amount))}</td>
{!isCPMM && <td>{saleDisplay}</td>} {!isCPMM && !isNumeric && <td>{saleDisplay}</td>}
{!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>} {!isCPMM && !isResolved && <td>{payoutIfChosenDisplay}</td>}
<td>{formatWithCommas(Math.abs(shares))}</td> <td>{formatWithCommas(Math.abs(shares))}</td>
<td> <td>
@ -553,10 +576,7 @@ function BetRow(props: { bet: Bet; contract: Contract; saleBet?: Bet }) {
) )
} }
const warmUpSellBet = _.throttle( const warmUpSellBet = throttle(() => sellBet({}).catch(() => {}), 5000 /* ms */)
() => sellBet({}).catch(() => {}),
5000 /* ms */
)
function SellButton(props: { contract: Contract; bet: Bet }) { function SellButton(props: { contract: Contract; bet: Bet }) {
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,42 @@
import { useState } from 'react'
import { NumericContract } from 'common/contract'
import { getMappedBucket } from 'common/calculate-dpm'
import { NumberInput } from './number-input'
export function BucketInput(props: {
contract: NumericContract
isSubmitting?: boolean
onBucketChange: (value?: number, bucket?: string) => void
}) {
const { contract, isSubmitting, onBucketChange } = props
const [numberString, setNumberString] = useState('')
const onChange = (s: string) => {
setNumberString(s)
const value = parseFloat(s)
if (!isFinite(value)) {
onBucketChange(undefined, undefined)
return
}
const bucket = getMappedBucket(value, contract)
onBucketChange(value, bucket)
}
return (
<NumberInput
inputClassName="w-full max-w-none"
onChange={onChange}
error={undefined}
disabled={isSubmitting}
numberString={numberString}
label="Value"
/>
)
}

View File

@ -1,5 +1,5 @@
import { StarIcon } from '@heroicons/react/solid' import { StarIcon } from '@heroicons/react/solid'
import _ from 'lodash' import { sumBy } from 'lodash'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { Charity } from 'common/charity' import { Charity } from 'common/charity'
@ -11,7 +11,7 @@ export function CharityCard(props: { charity: Charity }) {
const { name, slug, photo, preview, id, tags } = props.charity const { name, slug, photo, preview, id, tags } = props.charity
const txns = useCharityTxns(id) const txns = useCharityTxns(id)
const raised = _.sumBy(txns, (txn) => txn.amount) const raised = sumBy(txns, (txn) => txn.amount)
return ( return (
<Link href={`/charity/${slug}`} passHref> <Link href={`/charity/${slug}`} passHref>

View File

@ -0,0 +1,56 @@
import { Row } from './layout/row'
import { RadioGroup } from '@headlessui/react'
import clsx from 'clsx'
import React from 'react'
export function ChoicesToggleGroup(props: {
currentChoice: number | string
choicesMap: { [key: string]: string | number }
isSubmitting?: boolean
setChoice: (p: number | string) => void
className?: string
children?: React.ReactNode
}) {
const {
currentChoice,
setChoice,
isSubmitting,
choicesMap,
className,
children,
} = props
return (
<Row className={'mt-2 items-center gap-2'}>
<RadioGroup
value={currentChoice.toString()}
onChange={(str) => null}
className="mt-2"
>
<div className={`grid grid-cols-12 gap-3`}>
{Object.keys(choicesMap).map((choiceKey) => (
<RadioGroup.Option
key={choiceKey}
value={choicesMap[choiceKey]}
onClick={() => setChoice(choicesMap[choiceKey])}
className={({ active }) =>
clsx(
active ? 'ring-2 ring-indigo-500 ring-offset-2' : '',
currentChoice === choicesMap[choiceKey]
? 'border-transparent bg-indigo-500 text-white hover:bg-indigo-600'
: 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case',
"hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500",
className
)
}
disabled={isSubmitting}
>
<RadioGroup.Label as="span">{choiceKey}</RadioGroup.Label>
</RadioGroup.Option>
))}
{children}
</div>
</RadioGroup>
</Row>
)
}

View File

@ -1,5 +1,5 @@
// Adapted from https://stackoverflow.com/a/50884055/1222351 // Adapted from https://stackoverflow.com/a/50884055/1222351
import { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
export function ClientRender(props: { children: React.ReactNode }) { export function ClientRender(props: { children: React.ReactNode }) {
const { children } = props const { children } = props

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useState } from 'react' import { ReactNode, useState } from 'react'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Row } from './layout/row' import { Row } from './layout/row'
@ -20,7 +20,7 @@ export function ConfirmationButton(props: {
className?: string className?: string
} }
onSubmit: () => void onSubmit: () => void
children: any children: ReactNode
}) { }) {
const { id, openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props const { id, openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props

View File

@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import algoliasearch from 'algoliasearch/lite' import algoliasearch from 'algoliasearch/lite'
import { import {
InstantSearch, InstantSearch,
@ -8,7 +9,6 @@ import {
useRange, useRange,
useRefinementList, useRefinementList,
useSortBy, useSortBy,
useToggleRefinement,
} from 'react-instantsearch-hooks-web' } from 'react-instantsearch-hooks-web'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import {
@ -37,6 +37,7 @@ const sortIndexes = [
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' }, { label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' }, { label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' }, { label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
{ label: 'Close date', value: indexPrefix + 'contracts-close-date' }, { label: 'Close date', value: indexPrefix + 'contracts-close-date' },
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' }, { label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
] ]
@ -82,54 +83,49 @@ export function ContractSearch(props: {
additionalFilter?.tag ?? additionalFilter?.creatorId ?? '' additionalFilter?.tag ?? additionalFilter?.creatorId ?? ''
}`} }`}
> >
<Row className="flex-wrap gap-2"> <Row className="gap-1 sm:gap-2">
<SearchBox <SearchBox
className="flex-1" className="flex-1"
classNames={{ classNames={{
form: 'before:top-6', form: 'before:top-6',
input: '!pl-10 !input !input-bordered shadow-none', input: '!pl-10 !input !input-bordered shadow-none w-[100px]',
resetIcon: 'mt-2', resetIcon: 'mt-2 hidden sm:flex',
}}
/>
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}} }}
placeholder="Search markets"
/> />
<Row className="mt-2 gap-2 sm:mt-0">
<select
className="!select !select-bordered"
value={filter}
onChange={(e) => setFilter(e.target.value as filter)}
>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="all">All</option>
</select>
<SortBy
items={sortIndexes}
classNames={{
select: '!select !select-bordered',
}}
/>
</Row>
</Row> </Row>
<div>
{showCategorySelector && (
<>
<Spacer h={4} />
<CategorySelector
user={user}
category={category}
setCategory={setCategory}
/>
</>
)}
<Spacer h={4} />
<ContractSearchInner <Spacer h={3} />
querySortOptions={querySortOptions}
filter={filter} {showCategorySelector && (
additionalFilter={{ category, ...additionalFilter }} <CategorySelector
className="mb-2"
user={user}
category={category}
setCategory={setCategory}
/> />
</div> )}
<ContractSearchInner
querySortOptions={querySortOptions}
filter={filter}
additionalFilter={{ category, ...additionalFilter }}
/>
</InstantSearch> </InstantSearch>
) )
} }
@ -195,20 +191,23 @@ export function ContractSearchInner(props: {
filter === 'resolved' ? true : filter === 'all' ? undefined : false filter === 'resolved' ? true : filter === 'all' ? undefined : false
) )
const { showMore, hits, isLastPage, results } = useInfiniteHits() const [isInitialLoad, setIsInitialLoad] = useState(true)
useEffect(() => {
const id = setTimeout(() => setIsInitialLoad(false), 1000)
return () => clearTimeout(id)
}, [])
const { showMore, hits, isLastPage } = useInfiniteHits()
const contracts = hits as any as Contract[] const contracts = hits as any as Contract[]
const router = useRouter() if (isInitialLoad && contracts.length === 0) return <></>
const hasLoaded = contracts.length > 0 || router.isReady
if (!hasLoaded || !results) return <></>
return ( return (
<ContractsGrid <ContractsGrid
contracts={contracts} contracts={contracts}
loadMore={showMore} loadMore={showMore}
hasMore={!isLastPage} hasMore={!isLastPage}
showCloseTime={index === 'contracts-closing-soon'} showCloseTime={index.endsWith('close-date')}
/> />
) )
} }
@ -242,13 +241,16 @@ const useFilterClosed = (value: boolean | undefined) => {
} }
const useFilterResolved = (value: boolean | undefined) => { const useFilterResolved = (value: boolean | undefined) => {
// Note (James): I don't know why this works. const { items, refine: deleteRefinement } = useCurrentRefinements({
const { refine: refineResolved } = useToggleRefinement({ includedAttributes: ['isResolved'],
attribute: value === undefined ? 'non-existant-field' : 'isResolved',
on: true,
off: value === undefined ? undefined : false,
}) })
const { refine } = useRefinementList({ attribute: 'isResolved' })
useEffect(() => { useEffect(() => {
refineResolved({ isRefined: !value }) const refinements = items[0]?.refinements ?? []
if (value !== undefined) refine(`${value}`)
refinements.forEach((refinement) => deleteRefinement(refinement))
}, [value]) }, [value])
} }

View File

@ -1,12 +1,11 @@
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatPercent } from 'common/util/format' import { formatLargeNumber, formatPercent } from 'common/util/format'
import { import {
Contract, Contract,
contractPath, contractPath,
getBinaryProbPercent, getBinaryProbPercent,
getBinaryProb,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import {
@ -16,47 +15,19 @@ import {
FreeResponse, FreeResponse,
FreeResponseContract, FreeResponseContract,
FullContract, FullContract,
NumericContract,
} from 'common/contract' } from 'common/contract'
import { import {
AnswerLabel, AnswerLabel,
BinaryContractOutcomeLabel, BinaryContractOutcomeLabel,
CancelLabel,
FreeResponseOutcomeLabel, FreeResponseOutcomeLabel,
OUTCOME_TO_COLOR,
} from '../outcome-label' } from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate' import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
import { AbbrContractDetails } from './contract-details' import { AvatarDetails, MiscDetails } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
// Return a number from 0 to 1 for this contract import { QuickBet, ProbBar, getColor } from './quick-bet'
// Resolved contracts are set to 1, for coloring purposes (even if NO) import { useContractWithPreload } from 'web/hooks/use-contract'
function getProb(contract: Contract) {
const { outcomeType, resolution } = contract
return resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: 1 // Should not happen
}
function getColor(contract: Contract) {
const { resolution } = contract
if (resolution) {
return (
// @ts-ignore; TODO: Have better typing for contract.resolution?
OUTCOME_TO_COLOR[resolution] ||
// If resolved to a FR answer, use 'primary'
'primary'
)
}
const marketClosed = (contract.closeTime || Infinity) < Date.now()
return marketClosed
? 'gray-400'
: getProb(contract) >= 0.5
? 'primary'
: 'red-400'
}
export function ContractCard(props: { export function ContractCard(props: {
contract: Contract contract: Contract
@ -64,73 +35,95 @@ export function ContractCard(props: {
showCloseTime?: boolean showCloseTime?: boolean
className?: string className?: string
}) { }) {
const { contract, showHotVolume, showCloseTime, className } = props const { showHotVolume, showCloseTime, className } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract const { question, outcomeType } = contract
const { resolution } = contract
const prob = getProb(contract)
const color = getColor(contract)
const marketClosed = (contract.closeTime || Infinity) < Date.now() const marketClosed = (contract.closeTime || Infinity) < Date.now()
const showTopBar = prob >= 0.5 || marketClosed const showQuickBet = !(
marketClosed ||
(outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined)
)
return ( return (
<div> <div>
<Col <Col
className={clsx( className={clsx(
'relative gap-3 rounded-lg bg-white p-6 pr-7 shadow-md hover:bg-gray-100', 'relative gap-3 rounded-lg bg-white py-4 pl-6 pr-5 shadow-md hover:cursor-pointer hover:bg-gray-100',
className className
)} )}
> >
<Link href={contractPath(contract)}> <Row>
<a className="absolute left-0 right-0 top-0 bottom-0" /> <Col className="relative flex-1 gap-3 pr-1">
</Link> <div
className={clsx(
<AbbrContractDetails 'peer absolute -left-6 -top-4 -bottom-4 z-10',
contract={contract} // Hack: Extend the clickable area for closed markets
showHotVolume={showHotVolume} showQuickBet ? 'right-0' : 'right-[-6.5rem]'
showCloseTime={showCloseTime} )}
/> >
<Link href={contractPath(contract)}>
<Row className={clsx('justify-between gap-4')}> <a className="absolute top-0 left-0 right-0 bottom-0" />
<Col className="gap-3"> </Link>
</div>
<AvatarDetails contract={contract} />
<p <p
className="break-words font-medium text-indigo-700" className="break-words font-semibold text-indigo-700 peer-hover:underline peer-hover:decoration-indigo-400 peer-hover:decoration-2"
style={{ /* For iOS safari */ wordBreak: 'break-word' }} style={{ /* For iOS safari */ wordBreak: 'break-word' }}
> >
{question} {question}
</p> </p>
</Col>
{outcomeType === 'BINARY' && ( {outcomeType === 'FREE_RESPONSE' &&
<BinaryResolutionOrChance (resolution ? (
className="items-center" <FreeResponseOutcomeLabel
contract={contract as FreeResponseContract}
resolution={resolution}
truncate={'long'}
/>
) : (
<FreeResponseTopAnswer
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
))}
<MiscDetails
contract={contract} contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/> />
</Col>
{showQuickBet ? (
<QuickBet contract={contract} />
) : (
<Col className="m-auto pl-2">
{outcomeType === 'BINARY' && (
<BinaryResolutionOrChance
className="items-center"
contract={contract}
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
className="items-center"
contract={contract as NumericContract}
/>
)}
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
<ProbBar contract={contract} />
</Col>
)} )}
</Row> </Row>
{outcomeType === 'FREE_RESPONSE' && (
<FreeResponseResolutionOrChance
className="self-end text-gray-600"
contract={contract as FullContract<DPM, FreeResponse>}
truncate="long"
/>
)}
<div
className={clsx(
'absolute right-0 top-0 w-2 rounded-tr-md',
'bg-gray-200'
)}
style={{ height: `${100 * (1 - prob)}%` }}
></div>
<div
className={clsx(
'absolute right-0 bottom-0 w-2 rounded-br-md',
`bg-${color}`,
// If we're showing the full bar, also round the top
prob === 1 ? 'rounded-tr-md' : ''
)}
style={{ height: `${100 * prob}%` }}
></div>
</Col> </Col>
</div> </div>
) )
@ -171,6 +164,24 @@ export function BinaryResolutionOrChance(props: {
) )
} }
function FreeResponseTopAnswer(props: {
contract: FreeResponseContract
truncate: 'short' | 'long' | 'none'
className?: string
}) {
const { contract, truncate } = props
const topAnswer = getTopAnswer(contract)
return topAnswer ? (
<AnswerLabel
className="!text-gray-600"
answer={topAnswer}
truncate={truncate}
/>
) : null
}
export function FreeResponseResolutionOrChance(props: { export function FreeResponseResolutionOrChance(props: {
contract: FreeResponseContract contract: FreeResponseContract
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
@ -186,22 +197,21 @@ export function FreeResponseResolutionOrChance(props: {
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}> <Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? ( {resolution ? (
<> <>
<div className={clsx('text-base text-gray-500')}>Resolved</div> <div className={clsx('text-base text-gray-500 sm:hidden')}>
<FreeResponseOutcomeLabel Resolved
contract={contract} </div>
resolution={resolution} {(resolution === 'CANCEL' || resolution === 'MKT') && (
truncate={truncate} <FreeResponseOutcomeLabel
answerClassName="text-xl" contract={contract}
/> resolution={resolution}
truncate={truncate}
answerClassName="text-3xl uppercase text-blue-500"
/>
)}
</> </>
) : ( ) : (
topAnswer && ( topAnswer && (
<Row className="items-center gap-6"> <Row className="items-center gap-6">
<AnswerLabel
className="!text-gray-600"
answer={topAnswer}
truncate={truncate}
/>
<Col className={clsx('text-3xl', textColor)}> <Col className={clsx('text-3xl', textColor)}>
<div> <div>
{formatPercent(getOutcomeProbability(contract, topAnswer.id))} {formatPercent(getOutcomeProbability(contract, topAnswer.id))}
@ -214,3 +224,38 @@ export function FreeResponseResolutionOrChance(props: {
</Col> </Col>
) )
} }
export function NumericResolutionOrExpectation(props: {
contract: NumericContract
className?: string
}) {
const { contract, className } = props
const { resolution } = contract
const textColor = `text-${getColor(contract)}`
const resolutionValue =
contract.resolutionValue ?? getValueFromBucket(resolution ?? '', contract)
return (
<Col className={clsx(resolution ? 'text-3xl' : 'text-xl', className)}>
{resolution ? (
<>
<div className={clsx('text-base text-gray-500')}>Resolved</div>
{resolution === 'CANCEL' ? (
<CancelLabel />
) : (
<div className="text-blue-400">{resolutionValue}</div>
)}
</>
) : (
<>
<div className={clsx('text-3xl', textColor)}>
{formatLargeNumber(getExpectedValue(contract))}
</div>
<div className={clsx('text-base', textColor)}>expected</div>
</>
)}
</Col>
)
}

View File

@ -5,13 +5,16 @@ import {
PencilIcon, PencilIcon,
CurrencyDollarIcon, CurrencyDollarIcon,
TrendingUpIcon, TrendingUpIcon,
StarIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import { StarIcon as SolidStarIcon } from '@heroicons/react/solid'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { UserLink } from '../user-page' import { UserLink } from '../user-page'
import { import {
Contract, Contract,
contractMetrics, contractMetrics,
contractPool,
updateContract, updateContract,
} from 'web/lib/firebase/contracts' } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -26,20 +29,13 @@ import NewContractBadge from '../new-contract-badge'
import { CATEGORY_LIST } from 'common/categories' import { CATEGORY_LIST } from 'common/categories'
import { TagsList } from '../tags-list' import { TagsList } from '../tags-list'
export function AbbrContractDetails(props: { export function MiscDetails(props: {
contract: Contract contract: Contract
showHotVolume?: boolean showHotVolume?: boolean
showCloseTime?: boolean showCloseTime?: boolean
}) { }) {
const { contract, showHotVolume, showCloseTime } = props const { contract, showHotVolume, showCloseTime } = props
const { const { volume, volume24Hours, closeTime, tags } = contract
volume,
volume24Hours,
creatorName,
creatorUsername,
closeTime,
tags,
} = contract
const { volumeLabel } = contractMetrics(contract) const { volumeLabel } = contractMetrics(contract)
// Show at most one category that this contract is tagged by // Show at most one category that this contract is tagged by
const categories = CATEGORY_LIST.filter((category) => const categories = CATEGORY_LIST.filter((category) =>
@ -47,45 +43,62 @@ export function AbbrContractDetails(props: {
).slice(0, 1) ).slice(0, 1)
return ( return (
<Col className={clsx('gap-2 text-sm text-gray-500')}> <Row className="items-center gap-3 text-sm text-gray-400">
<Row className="items-center justify-between"> {showHotVolume ? (
<Row className="items-center gap-2"> <Row className="gap-0.5">
<Avatar <TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink
className="whitespace-nowrap"
name={creatorName}
username={creatorUsername}
/>
</Row> </Row>
) : showCloseTime ? (
<Row className="gap-3 text-gray-400"> <Row className="gap-0.5">
{categories.length > 0 && ( <ClockIcon className="h-5 w-5" />
<TagsList className="text-gray-400" tags={categories} noLabel /> {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
)} {fromNow(closeTime || 0)}
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" />{' '}
{formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
<Row className="gap-0.5">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : volume > 0 ? (
<Row>{volumeLabel}</Row>
) : (
<NewContractBadge />
)}
</Row> </Row>
</Row> ) : volume > 0 ? (
</Col> <Row>{contractPool(contract)} pool</Row>
) : (
<NewContractBadge />
)}
{categories.length > 0 && (
<TagsList className="text-gray-400" tags={categories} noLabel />
)}
</Row>
)
}
export function AvatarDetails(props: { contract: Contract }) {
const { contract } = props
const { creatorName, creatorUsername } = contract
return (
<Row className="items-center gap-2 text-sm text-gray-400">
<Avatar
username={creatorUsername}
avatarUrl={contract.creatorAvatarUrl}
size={6}
/>
<UserLink name={creatorName} username={creatorUsername} />
</Row>
)
}
export function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
}) {
const { contract, showHotVolume, showCloseTime } = props
return (
<Row className="items-center justify-between">
<AvatarDetails contract={contract} />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
/>
</Row>
) )
} }
@ -97,7 +110,7 @@ export function ContractDetails(props: {
}) { }) {
const { contract, bets, isCreator, disabled } = props const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername } = contract const { closeTime, creatorName, creatorUsername } = contract
const { volumeLabel, createdDate, resolvedDate } = contractMetrics(contract) const { volumeLabel, resolvedDate } = contractMetrics(contract)
return ( return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500"> <Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
@ -191,7 +204,7 @@ function EditableCloseDate(props: {
const [isEditingCloseTime, setIsEditingCloseTime] = useState(false) const [isEditingCloseTime, setIsEditingCloseTime] = useState(false)
const [closeDate, setCloseDate] = useState( const [closeDate, setCloseDate] = useState(
closeTime && dayjs(closeTime).format('YYYY-MM-DDT23:59') closeTime && dayjs(closeTime).format('YYYY-MM-DDTHH:mm')
) )
const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year') const isSameYear = dayjs(closeTime).isSame(dayjs(), 'year')

View File

@ -1,13 +1,18 @@
import { DotsHorizontalIcon } from '@heroicons/react/outline' import { DotsHorizontalIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import _ from 'lodash' import { uniqBy, sum } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import {
contractMetrics,
contractPath,
contractPool,
getBinaryProbPercent,
} from 'web/lib/firebase/contracts'
import { AddLiquidityPanel } from '../add-liquidity-panel' import { AddLiquidityPanel } from '../add-liquidity-panel'
import { CopyLinkButton } from '../copy-link-button' import { CopyLinkButton } from '../copy-link-button'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -26,7 +31,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z') const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a z')
const { createdTime, closeTime, resolutionTime } = contract const { createdTime, closeTime, resolutionTime } = contract
const tradersCount = _.uniqBy(bets, 'userId').length const tradersCount = uniqBy(bets, 'userId').length
return ( return (
<> <>
@ -98,19 +103,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
<td>{tradersCount}</td> <td>{tradersCount}</td>
</tr> </tr>
{contract.mechanism === 'cpmm-1' && ( <tr>
<tr> <td>Pool</td>
<td>Liquidity</td> <td>{contractPool(contract)}</td>
<td>{formatMoney(contract.totalLiquidity)}</td> </tr>
</tr>
)}
{contract.mechanism === 'dpm-2' && (
<tr>
<td>Pool</td>
<td>{formatMoney(_.sum(Object.values(contract.pool)))}</td>
</tr>
)}
</tbody> </tbody>
</table> </table>

View File

@ -6,18 +6,26 @@ import { useUser } from 'web/hooks/use-user'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
FreeResponseResolutionOrChance, FreeResponseResolutionOrChance,
BinaryResolutionOrChance, BinaryResolutionOrChance,
NumericResolutionOrExpectation,
} from './contract-card' } from './contract-card'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import BetRow from '../bet-row' import BetRow from '../bet-row'
import { AnswersGraph } from '../answers/answers-graph' import { AnswersGraph } from '../answers/answers-graph'
import { DPM, FreeResponse, FullContract } from 'common/contract' import {
DPM,
FreeResponse,
FullContract,
NumericContract,
} from 'common/contract'
import { ContractDescription } from './contract-description' import { ContractDescription } from './contract-description'
import { ContractDetails } from './contract-details' import { ContractDetails } from './contract-details'
import { ShareMarket } from '../share-market' import { ShareMarket } from '../share-market'
import { NumericGraph } from './numeric-graph'
export const ContractOverview = (props: { export const ContractOverview = (props: {
contract: Contract contract: Contract
@ -47,6 +55,13 @@ export const ContractOverview = (props: {
large large
/> />
)} )}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract as NumericContract}
className="hidden items-end xl:flex"
/>
)}
</Row> </Row>
{isBinary ? ( {isBinary ? (
@ -65,28 +80,33 @@ export const ContractOverview = (props: {
) )
)} )}
{outcomeType === 'NUMERIC' && (
<Row className="items-center justify-between gap-4 xl:hidden">
<NumericResolutionOrExpectation
contract={contract as NumericContract}
/>
</Row>
)}
<ContractDetails <ContractDetails
contract={contract} contract={contract}
bets={bets} bets={bets}
isCreator={isCreator} isCreator={isCreator}
/> />
</Col> </Col>
<Spacer h={4} /> <Spacer h={4} />
{isBinary && <ContractProbGraph contract={contract} bets={bets} />}{' '}
{isBinary ? ( {outcomeType === 'FREE_RESPONSE' && (
<ContractProbGraph contract={contract} bets={bets} />
) : (
<AnswersGraph <AnswersGraph
contract={contract as FullContract<DPM, FreeResponse>} contract={contract as FullContract<DPM, FreeResponse>}
bets={bets} bets={bets}
/> />
)} )}
{outcomeType === 'NUMERIC' && (
<NumericGraph contract={contract as NumericContract} />
)}
{(contract.description || isCreator) && <Spacer h={6} />} {(contract.description || isCreator) && <Spacer h={6} />}
{isCreator && <ShareMarket className="px-2" contract={contract} />} {isCreator && <ShareMarket className="px-2" contract={contract} />}
<ContractDescription <ContractDescription
className="px-2" className="px-2"
contract={contract} contract={contract}

View File

@ -16,6 +16,7 @@ export function ContractTabs(props: {
comments: Comment[] comments: Comment[]
}) { }) {
const { contract, user, comments } = props const { contract, user, comments } = props
const { outcomeType } = contract
const bets = useBets(contract.id) ?? props.bets const bets = useBets(contract.id) ?? props.bets
// Decending creation time. // Decending creation time.
@ -47,7 +48,7 @@ export function ContractTabs(props: {
} }
betRowClassName="!mt-0 xl:hidden" betRowClassName="!mt-0 xl:hidden"
/> />
{contract.outcomeType === 'FREE_RESPONSE' && ( {outcomeType === 'FREE_RESPONSE' && (
<Col className={'mt-8 flex w-full '}> <Col className={'mt-8 flex w-full '}>
<div className={'text-md mt-8 mb-2 text-left'}>General Comments</div> <div className={'text-md mt-8 mb-2 text-left'}>General Comments</div>
<div className={'mb-4 w-full border-b border-gray-200'} /> <div className={'mb-4 w-full border-b border-gray-200'} />

View File

@ -19,7 +19,6 @@ export function ContractsGrid(props: {
const isBottomVisible = useIsVisible(elem) const isBottomVisible = useIsVisible(elem)
useEffect(() => { useEffect(() => {
console.log({ isBottomVisible, hasMore })
if (isBottomVisible) { if (isBottomVisible) {
loadMore() loadMore()
} }
@ -29,7 +28,7 @@ export function ContractsGrid(props: {
return ( return (
<p className="mx-2 text-gray-500"> <p className="mx-2 text-gray-500">
No markets found. Why not{' '} No markets found. Why not{' '}
<SiteLink href="/home" className="font-bold text-gray-700"> <SiteLink href="/create" className="font-bold text-gray-700">
create one? create one?
</SiteLink> </SiteLink>
</p> </p>
@ -38,7 +37,7 @@ export function ContractsGrid(props: {
return ( return (
<Col className="gap-8"> <Col className="gap-8">
<ul className="grid w-full grid-cols-1 gap-6 md:grid-cols-2"> <ul className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
{contracts.map((contract) => ( {contracts.map((contract) => (
<ContractCard <ContractCard
contract={contract} contract={contract}

View File

@ -0,0 +1,99 @@
import { DatumValue } from '@nivo/core'
import { Point, ResponsiveLine } from '@nivo/line'
import { NUMERIC_GRAPH_COLOR } from 'common/numeric-constants'
import { memo } from 'react'
import { range } from 'lodash'
import { getDpmOutcomeProbabilities } from '../../../common/calculate-dpm'
import { NumericContract } from '../../../common/contract'
import { useWindowSize } from '../../hooks/use-window-size'
import { Col } from '../layout/col'
import { formatLargeNumber } from 'common/util/format'
export const NumericGraph = memo(function NumericGraph(props: {
contract: NumericContract
height?: number
}) {
const { contract, height } = props
const { totalShares, bucketCount, min, max } = contract
const bucketProbs = getDpmOutcomeProbabilities(totalShares)
const xs = range(bucketCount).map(
(i) => min + ((max - min) * i) / bucketCount
)
const probs = range(bucketCount).map((i) => bucketProbs[`${i}`] * 100)
const points = probs.map((prob, i) => ({ x: xs[i], y: prob }))
const maxProb = Math.max(...probs)
const data = [{ id: 'Probability', data: points, color: NUMERIC_GRAPH_COLOR }]
const yTickValues = [
0,
0.25 * maxProb,
0.5 & maxProb,
0.75 * maxProb,
maxProb,
]
const { width } = useWindowSize()
const numXTickValues = !width || width < 800 ? 2 : 5
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
yScale={{ min: 0, max: maxProb, type: 'linear' }}
yFormat={formatPercent}
axisLeft={{
tickValues: yTickValues,
format: formatPercent,
}}
xScale={{
type: 'linear',
min: min,
max: max,
}}
xFormat={(d) => `${formatLargeNumber(+d, 3)}`}
axisBottom={{
tickValues: numXTickValues,
format: (d) => `${formatLargeNumber(+d, 3)}`,
}}
colors={{ datum: 'color' }}
pointSize={0}
enableSlices="x"
sliceTooltip={({ slice }) => {
const point = slice.points[0]
return <Tooltip point={point} />
}}
enableGridX={!!width && width >= 800}
enableArea
margin={{ top: 20, right: 28, bottom: 22, left: 50 }}
/>
</div>
)
})
function formatPercent(y: DatumValue) {
const p = Math.round(+y * 100) / 100
return `${p}%`
}
function Tooltip(props: { point: Point }) {
const { point } = props
return (
<Col className="border border-gray-300 bg-white py-2 px-3">
<div
className="pb-1"
style={{
color: point.serieColor,
}}
>
<strong>{point.serieId}</strong> {point.data.yFormatted}
</div>
<div>{formatLargeNumber(+point.data.x)}</div>
</Col>
)
}

View File

@ -0,0 +1,290 @@
import clsx from 'clsx'
import {
getOutcomeProbability,
getOutcomeProbabilityAfterBet,
getTopAnswer,
} from 'common/calculate'
import { getExpectedValue } from 'common/calculate-dpm'
import {
Contract,
FullContract,
CPMM,
DPM,
Binary,
NumericContract,
FreeResponseContract,
} from 'common/contract'
import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { placeBet } from 'web/lib/firebase/api-call'
import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon'
import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon'
import { Col } from '../layout/col'
import { OUTCOME_TO_COLOR } from '../outcome-label'
import { useSaveShares } from '../use-save-shares'
const BET_SIZE = 10
export function QuickBet(props: { contract: Contract }) {
const { contract } = props
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const topAnswer =
contract.outcomeType === 'FREE_RESPONSE'
? getTopAnswer(contract as FreeResponseContract)
: undefined
// TODO: yes/no from useSaveShares doesn't work on numeric contracts
const { yesFloorShares, noFloorShares } = useSaveShares(
contract as FullContract<DPM | CPMM, Binary | FreeResponseContract>,
userBets,
topAnswer?.number.toString() || undefined
)
const hasUpShares =
yesFloorShares || (noFloorShares && contract.outcomeType === 'NUMERIC')
const hasDownShares =
noFloorShares && yesFloorShares <= 0 && contract.outcomeType !== 'NUMERIC'
const [upHover, setUpHover] = useState(false)
const [downHover, setDownHover] = useState(false)
let previewProb = undefined
try {
previewProb = upHover
? getOutcomeProbabilityAfterBet(
contract,
quickOutcome(contract, 'UP') || '',
BET_SIZE
)
: downHover
? 1 -
getOutcomeProbabilityAfterBet(
contract,
quickOutcome(contract, 'DOWN') || '',
BET_SIZE
)
: undefined
} catch (e) {
// Catch any errors from hovering on an invalid option
}
const color = getColor(contract, previewProb)
async function placeQuickBet(direction: 'UP' | 'DOWN') {
const betPromise = async () => {
const outcome = quickOutcome(contract, direction)
return await placeBet({
amount: BET_SIZE,
outcome,
contractId: contract.id,
})
}
const shortQ = contract.question.slice(0, 20)
toast.promise(betPromise(), {
loading: `${formatMoney(BET_SIZE)} on "${shortQ}"...`,
success: `${formatMoney(BET_SIZE)} on "${shortQ}"...`,
error: (err) => `${err.message}`,
})
}
function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') {
if (contract.outcomeType === 'BINARY') {
return direction === 'UP' ? 'YES' : 'NO'
}
if (contract.outcomeType === 'FREE_RESPONSE') {
// TODO: Implement shorting of free response answers
if (direction === 'DOWN') {
throw new Error("Can't bet against free response answers")
}
return getTopAnswer(contract)?.id
}
if (contract.outcomeType === 'NUMERIC') {
// TODO: Ideally an 'UP' bet would be a uniform bet between [current, max]
throw new Error("Can't quick bet on numeric markets")
}
}
return (
<Col
className={clsx(
'relative -my-4 -mr-5 min-w-[5.5rem] justify-center gap-2 pr-5 pl-1 align-middle'
// Use this for colored QuickBet panes
// `bg-opacity-10 bg-${color}`
)}
>
{/* Up bet triangle */}
<div>
<div
className="peer absolute top-0 left-0 right-0 h-[50%]"
onMouseEnter={() => setUpHover(true)}
onMouseLeave={() => setUpHover(false)}
onClick={() => placeQuickBet('UP')}
/>
<div className="mt-2 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(10)}
</div>
{hasUpShares > 0 ? (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? `text-${color}` : 'text-gray-400'
)}
/>
) : (
<TriangleFillIcon
className={clsx(
'mx-auto h-5 w-5',
upHover ? `text-${color}` : 'text-gray-200'
)}
/>
)}
</div>
<QuickOutcomeView contract={contract} previewProb={previewProb} />
{/* Down bet triangle */}
<div>
<div
className="peer absolute bottom-0 left-0 right-0 h-[50%]"
onMouseEnter={() => setDownHover(true)}
onMouseLeave={() => setDownHover(false)}
onClick={() => placeQuickBet('DOWN')}
></div>
{hasDownShares > 0 ? (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5',
downHover ? `text-${color}` : 'text-gray-400'
)}
/>
) : (
<TriangleDownFillIcon
className={clsx(
'mx-auto h-5 w-5',
downHover ? `text-${color}` : 'text-gray-200'
)}
/>
)}
<div className="mb-2 text-center text-xs text-transparent peer-hover:text-gray-400">
{formatMoney(10)}
</div>
</div>
</Col>
)
}
export function ProbBar(props: { contract: Contract; previewProb?: number }) {
const { contract, previewProb } = props
const color = getColor(contract, previewProb)
const prob = previewProb ?? getProb(contract)
return (
<>
<div
className={clsx(
'absolute right-0 top-0 w-2 rounded-tr-md transition-all',
'bg-gray-200'
)}
style={{ height: `${100 * (1 - prob)}%` }}
/>
<div
className={clsx(
'absolute right-0 bottom-0 w-2 rounded-br-md transition-all',
`bg-${color}`,
// If we're showing the full bar, also round the top
prob === 1 ? 'rounded-tr-md' : ''
)}
style={{ height: `${100 * prob}%` }}
/>
</>
)
}
function QuickOutcomeView(props: {
contract: Contract
previewProb?: number
caption?: 'chance' | 'expected'
}) {
const { contract, previewProb, caption } = props
const { outcomeType } = contract
// If there's a preview prob, display that instead of the current prob
const override =
previewProb === undefined ? undefined : formatPercent(previewProb)
const textColor = `text-${getColor(contract, previewProb)}`
let display: string | undefined
switch (outcomeType) {
case 'BINARY':
display = getBinaryProbPercent(contract)
break
case 'NUMERIC':
display = formatLargeNumber(getExpectedValue(contract as NumericContract))
break
case 'FREE_RESPONSE': {
const topAnswer = getTopAnswer(contract as FreeResponseContract)
display =
topAnswer &&
formatPercent(getOutcomeProbability(contract, topAnswer.id))
break
}
}
return (
<Col className={clsx('items-center text-3xl', textColor)}>
{override ?? display}
{caption && <div className="text-base">{caption}</div>}
<ProbBar contract={contract} previewProb={previewProb} />
</Col>
)
}
// Return a number from 0 to 1 for this contract
// Resolved contracts are set to 1, for coloring purposes (even if NO)
function getProb(contract: Contract) {
const { outcomeType, resolution } = contract
return resolution
? 1
: outcomeType === 'BINARY'
? getBinaryProb(contract)
: outcomeType === 'FREE_RESPONSE'
? getOutcomeProbability(contract, getTopAnswer(contract)?.id || '')
: outcomeType === 'NUMERIC'
? getNumericScale(contract as NumericContract)
: 1 // Should not happen
}
function getNumericScale(contract: NumericContract) {
const { min, max } = contract
const ev = getExpectedValue(contract)
return (ev - min) / (max - min)
}
export function getColor(contract: Contract, previewProb?: number) {
// TODO: Not sure why eg green-400 doesn't work here; try upgrading Tailwind
// TODO: Try injecting a gradient here
// return 'primary'
const { resolution } = contract
if (resolution) {
return (
OUTCOME_TO_COLOR[resolution as 'YES' | 'NO' | 'CANCEL' | 'MKT'] ??
// If resolved to a FR answer, use 'primary'
'primary'
)
}
if (contract.outcomeType === 'NUMERIC') {
return 'blue-400'
}
const marketClosed = (contract.closeTime || Infinity) < Date.now()
const prob = previewProb ?? getProb(contract)
return marketClosed ? 'gray-400' : prob >= 0.5 ? 'primary' : 'red-400'
}

View File

@ -1,3 +1,4 @@
import React from 'react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'

View File

@ -1,4 +1,4 @@
import _ from 'lodash' import { sample } from 'lodash'
import { SparklesIcon, XIcon } from '@heroicons/react/solid' import { SparklesIcon, XIcon } from '@heroicons/react/solid'
import { Avatar } from './avatar' import { Avatar } from './avatar'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
@ -30,7 +30,7 @@ export function FeedPromo(props: { hotContracts: Contract[] }) {
<div className="font-semibold sm:mb-2"> <div className="font-semibold sm:mb-2">
Bet on{' '} Bet on{' '}
<span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent"> <span className="bg-gradient-to-r from-teal-400 to-green-400 bg-clip-text font-bold text-transparent">
any question! anything!
</span> </span>
</div> </div>
</h1> </h1>
@ -86,9 +86,7 @@ export default function FeedCreate(props: {
// Take care not to produce a different placeholder on the server and client // Take care not to produce a different placeholder on the server and client
const [defaultPlaceholder, setDefaultPlaceholder] = useState('') const [defaultPlaceholder, setDefaultPlaceholder] = useState('')
useEffect(() => { useEffect(() => {
setDefaultPlaceholder( setDefaultPlaceholder(`e.g. ${sample(ENV_CONFIG.newQuestionPlaceholders)}`)
`e.g. ${_.sample(ENV_CONFIG.newQuestionPlaceholders)}`
)
}, []) }, [])
const placeholder = props.placeholder ?? defaultPlaceholder const placeholder = props.placeholder ?? defaultPlaceholder

View File

@ -1,4 +1,4 @@
import _, { Dictionary } from 'lodash' import { last, findLastIndex, uniq, sortBy } from 'lodash'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
@ -28,7 +28,7 @@ type BaseActivityItem = {
export type CommentInputItem = BaseActivityItem & { export type CommentInputItem = BaseActivityItem & {
type: 'commentInput' type: 'commentInput'
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
comments: Comment[] commentsByCurrentUser: Comment[]
answerOutcome?: string answerOutcome?: string
} }
@ -54,6 +54,7 @@ export type CommentItem = BaseActivityItem & {
type: 'comment' type: 'comment'
comment: Comment comment: Comment
betsBySameUser: Bet[] betsBySameUser: Bet[]
probAtCreatedTime?: number
truncate?: boolean truncate?: boolean
smallAvatar?: boolean smallAvatar?: boolean
} }
@ -62,7 +63,7 @@ export type CommentThreadItem = BaseActivityItem & {
type: 'commentThread' type: 'commentThread'
parentComment: Comment parentComment: Comment
comments: Comment[] comments: Comment[]
betsByUserId: Dictionary<[Bet, ...Bet[]]> bets: Bet[]
} }
export type BetGroupItem = BaseActivityItem & { export type BetGroupItem = BaseActivityItem & {
@ -76,7 +77,7 @@ export type AnswerGroupItem = BaseActivityItem & {
answer: Answer answer: Answer
items: ActivityItem[] items: ActivityItem[]
betsByCurrentUser?: Bet[] betsByCurrentUser?: Bet[]
comments?: Comment[] commentsByCurrentUser?: Comment[]
} }
export type CloseItem = BaseActivityItem & { export type CloseItem = BaseActivityItem & {
@ -87,7 +88,6 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve' type: 'resolve'
} }
export const GENERAL_COMMENTS_OUTCOME_ID = 'General Comments'
const DAY_IN_MS = 24 * 60 * 60 * 1000 const DAY_IN_MS = 24 * 60 * 60 * 1000
const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3 const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3
@ -200,17 +200,17 @@ function getAnswerGroups(
) { ) {
const { sortByProb, abbreviated, reversed } = options const { sortByProb, abbreviated, reversed } = options
let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter( let outcomes = uniq(bets.map((bet) => bet.outcome)).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001 (outcome) => getOutcomeProbability(contract, outcome) > 0.0001
) )
if (abbreviated) { if (abbreviated) {
const lastComment = _.last(comments) const lastComment = last(comments)
const lastCommentOutcome = bets.find( const lastCommentOutcome = bets.find(
(bet) => bet.id === lastComment?.betId (bet) => bet.id === lastComment?.betId
)?.outcome )?.outcome
const lastBetOutcome = _.last(bets)?.outcome const lastBetOutcome = last(bets)?.outcome
if (lastCommentOutcome && lastBetOutcome) { if (lastCommentOutcome && lastBetOutcome) {
outcomes = _.uniq([ outcomes = uniq([
...outcomes.filter( ...outcomes.filter(
(outcome) => (outcome) =>
outcome !== lastCommentOutcome && outcome !== lastBetOutcome outcome !== lastCommentOutcome && outcome !== lastBetOutcome
@ -222,13 +222,13 @@ function getAnswerGroups(
outcomes = outcomes.slice(-2) outcomes = outcomes.slice(-2)
} }
if (sortByProb) { if (sortByProb) {
outcomes = _.sortBy(outcomes, (outcome) => outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome) getOutcomeProbability(contract, outcome)
) )
} else { } else {
// Sort by recent bet. // Sort by recent bet.
outcomes = _.sortBy(outcomes, (outcome) => outcomes = sortBy(outcomes, (outcome) =>
_.findLastIndex(bets, (bet) => bet.outcome === outcome) findLastIndex(bets, (bet) => bet.outcome === outcome)
) )
} }
@ -274,12 +274,13 @@ function getAnswerAndCommentInputGroups(
comments: Comment[], comments: Comment[],
user: User | undefined | null user: User | undefined | null
) { ) {
let outcomes = _.uniq(bets.map((bet) => bet.outcome)).filter( let outcomes = uniq(bets.map((bet) => bet.outcome)).filter(
(outcome) => getOutcomeProbability(contract, outcome) > 0.0001 (outcome) => getOutcomeProbability(contract, outcome) > 0.0001
) )
outcomes = _.sortBy(outcomes, (outcome) => outcomes = sortBy(outcomes, (outcome) =>
getOutcomeProbability(contract, outcome) getOutcomeProbability(contract, outcome)
) )
const betsByCurrentUser = bets.filter((bet) => bet.userId === user?.id)
const answerGroups = outcomes const answerGroups = outcomes
.map((outcome) => { .map((outcome) => {
@ -293,9 +294,7 @@ function getAnswerAndCommentInputGroups(
comment.answerOutcome === outcome || comment.answerOutcome === outcome ||
answerBets.some((bet) => bet.id === comment.betId) answerBets.some((bet) => bet.id === comment.betId)
) )
const items = getCommentThreads(answerBets, answerComments, contract) const items = getCommentThreads(bets, answerComments, contract)
if (outcome === GENERAL_COMMENTS_OUTCOME_ID) items.reverse()
return { return {
id: outcome, id: outcome,
@ -304,8 +303,10 @@ function getAnswerAndCommentInputGroups(
answer, answer,
items, items,
user, user,
betsByCurrentUser: answerBets.filter((bet) => bet.userId === user?.id), betsByCurrentUser,
comments: answerComments, commentsByCurrentUser: answerComments.filter(
(comment) => comment.userId === user?.id
),
} }
}) })
.filter((group) => group.answer) as ActivityItem[] .filter((group) => group.answer) as ActivityItem[]
@ -325,6 +326,7 @@ function groupBetsAndComments(
} }
) { ) {
const { smallAvatar, abbreviated, reversed } = options const { smallAvatar, abbreviated, reversed } = options
// Comments in feed don't show user's position?
const commentsWithoutBets = comments const commentsWithoutBets = comments
.filter((comment) => !comment.betId) .filter((comment) => !comment.betId)
.map((comment) => ({ .map((comment) => ({
@ -341,7 +343,7 @@ function groupBetsAndComments(
// iterate through the bets and comment activity items and add them to the items in order of comment creation time: // iterate through the bets and comment activity items and add them to the items in order of comment creation time:
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets] const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
let sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => { const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => {
if (item.type === 'comment') { if (item.type === 'comment') {
return item.comment.createdTime return item.comment.createdTime
} else if (item.type === 'bet') { } else if (item.type === 'bet') {
@ -364,7 +366,6 @@ function getCommentThreads(
comments: Comment[], comments: Comment[],
contract: Contract contract: Contract
) { ) {
const betsByUserId = _.groupBy(bets, (bet) => bet.userId)
const parentComments = comments.filter((comment) => !comment.replyToCommentId) const parentComments = comments.filter((comment) => !comment.replyToCommentId)
const items = parentComments.map((comment) => ({ const items = parentComments.map((comment) => ({
@ -373,7 +374,7 @@ function getCommentThreads(
contract: contract, contract: contract,
comments: comments, comments: comments,
parentComment: comment, parentComment: comment,
betsByUserId: betsByUserId, bets: bets,
})) }))
return items return items
@ -433,7 +434,7 @@ export function getAllContractActivityItems(
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: [], betsByCurrentUser: [],
comments: [], commentsByCurrentUser: [],
}) })
} else { } else {
items.push( items.push(
@ -459,7 +460,7 @@ export function getAllContractActivityItems(
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: [], betsByCurrentUser: [],
comments: [], commentsByCurrentUser: [],
}) })
} }
@ -520,6 +521,15 @@ export function getRecentContractActivityItems(
return [questionItem, ...items] return [questionItem, ...items]
} }
function commentIsGeneralComment(comment: Comment, contract: Contract) {
return (
comment.answerOutcome === undefined &&
(contract.outcomeType === 'FREE_RESPONSE'
? comment.betId === undefined
: true)
)
}
export function getSpecificContractActivityItems( export function getSpecificContractActivityItems(
contract: Contract, contract: Contract,
bets: Bet[], bets: Bet[],
@ -530,7 +540,7 @@ export function getSpecificContractActivityItems(
} }
) { ) {
const { mode } = options const { mode } = options
let items = [] as ActivityItem[] const items = [] as ActivityItem[]
switch (mode) { switch (mode) {
case 'bets': case 'bets':
@ -549,9 +559,9 @@ export function getSpecificContractActivityItems(
) )
break break
case 'comments': case 'comments': {
const nonFreeResponseComments = comments.filter( const nonFreeResponseComments = comments.filter((comment) =>
(comment) => comment.answerOutcome === undefined commentIsGeneralComment(comment, contract)
) )
const nonFreeResponseBets = const nonFreeResponseBets =
contract.outcomeType === 'FREE_RESPONSE' ? [] : bets contract.outcomeType === 'FREE_RESPONSE' ? [] : bets
@ -567,12 +577,15 @@ export function getSpecificContractActivityItems(
type: 'commentInput', type: 'commentInput',
id: 'commentInput', id: 'commentInput',
contract, contract,
betsByCurrentUser: user betsByCurrentUser: nonFreeResponseBets.filter(
? nonFreeResponseBets.filter((bet) => bet.userId === user.id) (bet) => bet.userId === user?.id
: [], ),
comments: nonFreeResponseComments, commentsByCurrentUser: nonFreeResponseComments.filter(
(comment) => comment.userId === user?.id
),
}) })
break break
}
case 'free-response-comment-answer-groups': case 'free-response-comment-answer-groups':
items.push( items.push(
...getAnswerAndCommentInputGroups( ...getAnswerAndCommentInputGroups(

View File

@ -21,14 +21,11 @@ export function CopyLinkDateTimeComponent(props: {
event: React.MouseEvent<HTMLAnchorElement, MouseEvent> event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) { ) {
event.preventDefault() event.preventDefault()
const elementLocation = `https://${ENV_CONFIG.domain}${contractPath(
contract
)}#${elementId}`
let currentLocation = window.location.href.includes('/home') copyToClipboard(elementLocation)
? `https://${ENV_CONFIG.domain}${contractPath(contract)}#${elementId}`
: window.location.href
if (currentLocation.includes('#')) {
currentLocation = currentLocation.split('#')[0]
}
copyToClipboard(`${currentLocation}#${elementId}`)
setShowToast(true) setShowToast(true)
setTimeout(() => setShowToast(false), 2000) setTimeout(() => setShowToast(false), 2000)
} }

View File

@ -17,8 +17,11 @@ import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { BuyButton } from 'web/components/yes-no-selector' import { BuyButton } from 'web/components/yes-no-selector'
import { CommentInput, FeedItem } from 'web/components/feed/feed-items' import { FeedItem } from 'web/components/feed/feed-items'
import { getMostRecentCommentableBet } from 'web/components/feed/feed-comments' import {
CommentInput,
getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
@ -28,15 +31,16 @@ export function FeedAnswerCommentGroup(props: {
items: ActivityItem[] items: ActivityItem[]
type: string type: string
betsByCurrentUser?: Bet[] betsByCurrentUser?: Bet[]
comments?: Comment[] commentsByCurrentUser?: Comment[]
}) { }) {
const { answer, items, contract, betsByCurrentUser, comments } = props const { answer, items, contract, betsByCurrentUser, commentsByCurrentUser } =
props
const { username, avatarUrl, name, text } = answer const { username, avatarUrl, name, text } = answer
const answerElementId = `answer-${answer.id}` const answerElementId = `answer-${answer.id}`
const user = useUser() const user = useUser()
const mostRecentCommentableBet = getMostRecentCommentableBet( const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser ?? [], betsByCurrentUser ?? [],
comments ?? [], commentsByCurrentUser ?? [],
user, user,
answer.number + '' answer.number + ''
) )
@ -44,7 +48,7 @@ export function FeedAnswerCommentGroup(props: {
const probPercent = formatPercent(prob) const probPercent = formatPercent(prob)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [showReply, setShowReply] = useState(false) const [showReply, setShowReply] = useState(false)
const isFreeResponseContractPage = comments const isFreeResponseContractPage = !!commentsByCurrentUser
if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true) if (mostRecentCommentableBet && !showReply) setShowReplyAndFocus(true)
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null) const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
@ -64,7 +68,7 @@ export function FeedAnswerCommentGroup(props: {
if (router.asPath.endsWith(`#${answerElementId}`)) { if (router.asPath.endsWith(`#${answerElementId}`)) {
setHighlighted(true) setHighlighted(true)
} }
}, [router.asPath]) }, [answerElementId, router.asPath])
return ( return (
<Col className={'flex-1 gap-2'}> <Col className={'flex-1 gap-2'}>
@ -174,7 +178,7 @@ export function FeedAnswerCommentGroup(props: {
<CommentInput <CommentInput
contract={contract} contract={contract}
betsByCurrentUser={betsByCurrentUser ?? []} betsByCurrentUser={betsByCurrentUser ?? []}
comments={comments ?? []} commentsByCurrentUser={commentsByCurrentUser ?? []}
answerOutcome={answer.number + ''} answerOutcome={answer.number + ''}
replyToUsername={answer.username} replyToUsername={answer.username}
setRef={setInputRef} setRef={setInputRef}

Some files were not shown because too many files have changed in this diff Show More