diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 6e7b62cd..e03e5ef5 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -21,6 +21,8 @@ module.exports = { }, ], rules: { + 'no-extra-semi': 'off', + 'no-unused-vars': 'off', 'no-constant-condition': ['error', { checkLoops: false }], 'lodash/import-scope': [2, 'member'], }, diff --git a/common/contract.ts b/common/contract.ts index 756860a7..43c79cb6 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -34,8 +34,6 @@ export type FullContract< closeEmailsSent?: number - manaLimitPerUser?: number - volume: number volume24Hours: number volume7Days: number @@ -102,9 +100,10 @@ export type Numeric = { export type outcomeType = 'BINARY' | 'MULTI' | 'FREE_RESPONSE' | 'NUMERIC' export type resolutionType = 'MANUAL' | 'COMBINED' export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' -export const RESOLUTIONS = ['YES' , 'NO' , 'MKT' , 'CANCEL'] -export const OUTCOME_TYPES = ['BINARY', 'MULTI', 'FREE_RESPONSE', 'NUMERIC'] -export const RESOLUTION_TYPES = ['MANUAL', 'COMBINED'] + +export const RESOLUTIONS = [ 'YES', 'NO', 'MKT', 'CANCEL'] as const +export const OUTCOME_TYPES = [ 'BINARY', 'MULTI', 'FREE_RESPONSE', 'NUMERIC'] as const +export const RESOLUTION_TYPES = ['MANUAL', 'COMBINED'] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/new-bet.ts b/common/new-bet.ts index b640c0b5..1053ec58 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -18,18 +18,25 @@ import { Multi, NumericContract, } from './contract' -import { User } from './user' import { noFees } from './fees' import { addObjects } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' +export type CandidateBet = Omit +export type BetInfo = { + newBet: CandidateBet + newPool?: { [outcome: string]: number } + newTotalShares?: { [outcome: string]: number } + newTotalBets?: { [outcome: string]: number } + newTotalLiquidity?: number + newP?: number +} + export const getNewBinaryCpmmBetInfo = ( - user: User, outcome: 'YES' | 'NO', amount: number, contract: FullContract, - loanAmount: number, - newBetId: string + loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( contract, @@ -37,15 +44,11 @@ export const getNewBinaryCpmmBetInfo = ( outcome ) - const newBalance = user.balance - (amount - loanAmount) - const { pool, p, totalLiquidity } = contract const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, newP) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount, shares, @@ -60,16 +63,14 @@ export const getNewBinaryCpmmBetInfo = ( const { liquidityFee } = fees const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee - return { newBet, newPool, newP, newBalance, newTotalLiquidity, fees } + return { newBet, newPool, newP, newTotalLiquidity } } export const getNewBinaryDpmBetInfo = ( - user: User, outcome: 'YES' | 'NO', amount: number, contract: FullContract, - loanAmount: number, - newBetId: string + loanAmount: number ) => { const { YES: yesPool, NO: noPool } = contract.pool @@ -97,9 +98,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -111,18 +110,14 @@ export const getNewBinaryDpmBetInfo = ( fees: noFees, } - const newBalance = user.balance - (amount - loanAmount) - - return { newBet, newPool, newTotalShares, newTotalBets, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets } } export const getNewMultiBetInfo = ( - user: User, outcome: string, amount: number, contract: FullContract, - loanAmount: number, - newBetId: string + loanAmount: number ) => { const { pool, totalShares, totalBets } = contract @@ -140,9 +135,7 @@ export const getNewMultiBetInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -154,18 +147,14 @@ export const getNewMultiBetInfo = ( fees: noFees, } - const newBalance = user.balance - (amount - loanAmount) - - return { newBet, newPool, newTotalShares, newTotalBets, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets } } export const getNumericBetsInfo = ( - user: User, value: number, outcome: string, amount: number, - contract: NumericContract, - newBetId: string + contract: NumericContract ) => { const { pool, totalShares, totalBets } = contract @@ -187,9 +176,7 @@ export const getNumericBetsInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: NumericBet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, value, amount, @@ -203,9 +190,7 @@ export const getNumericBetsInfo = ( fees: noFees, } - const newBalance = user.balance - amount - - return { newBet, newPool, newTotalShares, newTotalBets, newBalance } + return { newBet, newPool, newTotalShares, newTotalBets } } export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => { diff --git a/common/new-contract.ts b/common/new-contract.ts index 17b958d8..829ab539 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -32,8 +32,7 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number, - manaLimitPerUser: number + max: number ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -78,7 +77,6 @@ export function getNewContract( liquidityFee: 0, platformFee: 0, }, - manaLimitPerUser, }) return contract as Contract diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index c138d302..300a906f 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -3,13 +3,7 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { deductDpmFees, getDpmProbability } from './calculate-dpm' import { DPM, FreeResponse, FullContract, Multi } from './contract' -import { - DPM_CREATOR_FEE, - DPM_FEES, - DPM_PLATFORM_FEE, - Fees, - noFees, -} from './fees' +import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees' import { addObjects } from './util/object' export const getDpmCancelPayouts = ( @@ -31,7 +25,7 @@ export const getDpmCancelPayouts = ( payouts, creatorPayout: 0, liquidityPayouts: [], - collectedFees: contract.collectedFees ?? noFees, + collectedFees: contract.collectedFees, } } @@ -57,17 +51,11 @@ export const getDpmStandardPayouts = ( 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 = { + const collectedFees = addObjects(contract.collectedFees, { creatorFee, platformFee, liquidityFee: 0, - } - - const collectedFees = addObjects( - finalFees, - contract.collectedFees ?? {} - ) + }) console.log( 'resolved', @@ -115,17 +103,11 @@ export const getNumericDpmPayouts = ( 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 = { + const collectedFees = addObjects(contract.collectedFees, { creatorFee, platformFee, liquidityFee: 0, - } - - const collectedFees = addObjects( - finalFees, - contract.collectedFees ?? {} - ) + }) console.log( 'resolved numeric bucket: ', @@ -174,17 +156,11 @@ export const getDpmMktPayouts = ( const creatorFee = DPM_CREATOR_FEE * profits const platformFee = DPM_PLATFORM_FEE * profits - - const finalFees: Fees = { + const collectedFees = addObjects(contract.collectedFees, { creatorFee, platformFee, liquidityFee: 0, - } - - const collectedFees = addObjects( - finalFees, - contract.collectedFees ?? {} - ) + }) console.log( 'resolved MKT', @@ -233,17 +209,11 @@ export const getPayoutsMultiOutcome = ( const creatorFee = DPM_CREATOR_FEE * profits const platformFee = DPM_PLATFORM_FEE * profits - - const finalFees: Fees = { + const collectedFees = addObjects(contract.collectedFees, { creatorFee, platformFee, liquidityFee: 0, - } - - const collectedFees = addObjects( - finalFees, - contract.collectedFees ?? noFees - ) + }) console.log( 'resolved', diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index c5b8e16f..5d66d2c6 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -17,6 +17,7 @@ module.exports = { }, ], rules: { + 'no-extra-semi': 'off', 'no-unused-vars': 'off', 'no-constant-condition': ['error', { checkLoops: false }], 'lodash/import-scope': [2, 'member'], diff --git a/functions/package.json b/functions/package.json index d3ee68e2..49352969 100644 --- a/functions/package.json +++ b/functions/package.json @@ -28,7 +28,8 @@ "mailgun-js": "0.22.0", "module-alias": "2.2.2", "react-query": "3.39.0", - "stripe": "8.194.0" + "stripe": "8.194.0", + "zod": "3.17.2" }, "devDependencies": { "@types/mailgun-js": "0.22.12", diff --git a/functions/src/api.ts b/functions/src/api.ts index fa3a9aa6..31f14257 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -1,6 +1,7 @@ import * as admin from 'firebase-admin' import * as functions from 'firebase-functions' import * as Cors from 'cors' +import { z } from 'zod' import { User, PrivateUser } from '../../common/user' import { @@ -8,10 +9,11 @@ import { CORS_ORIGIN_LOCALHOST, } from '../../common/envs/constants' +type Output = Record type Request = functions.https.Request type Response = functions.Response -type Handler = (req: Request, res: Response) => Promise type AuthedUser = [User, PrivateUser] +type Handler = (req: Request, user: AuthedUser) => Promise type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type KeyCredentials = { kind: 'key'; data: string } type Credentials = JwtCredentials | KeyCredentials @@ -19,10 +21,13 @@ type Credentials = JwtCredentials | KeyCredentials export class APIError { code: number msg: string - constructor(code: number, msg: string) { + details: unknown + constructor(code: number, msg: string, details?: unknown) { this.code = code this.msg = msg + this.details = details } + toJson() {} } export const parseCredentials = async (req: Request): Promise => { @@ -40,14 +45,11 @@ export const parseCredentials = async (req: Request): Promise => { case 'Bearer': try { 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 } } catch (err) { // This is somewhat suspicious, so get it into the firebase console 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': return { kind: 'key', data: payload } @@ -63,6 +65,9 @@ export const lookupUser = async (creds: Credentials): Promise => { switch (creds.kind) { case 'jwt': { 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([ users.doc(user_id).get(), privateUsers.doc(user_id).get(), @@ -109,6 +114,27 @@ export const applyCors = ( }) } +export const zTimestamp = () => { + return z.preprocess((arg) => { + return typeof arg == 'number' ? new Date(arg) : undefined + }, z.date()) +} + +export const validate = (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 + } +} + export const newEndpoint = (methods: [string], fn: Handler) => functions.runWith({ minInstances: 1 }).https.onRequest(async (req, res) => { await applyCors(req, res, { @@ -120,12 +146,17 @@ export const newEndpoint = (methods: [string], fn: Handler) => const allowed = methods.join(', ') throw new APIError(405, `This endpoint supports only ${allowed}.`) } - res.status(200).json(await fn(req, res)) + const authedUser = await lookupUser(await parseCredentials(req)) + res.status(200).json(await fn(req, authedUser)) } catch (e) { if (e instanceof APIError) { - // Emit a 200 anyway here for now, for backwards compatibility - res.status(e.code).json({ message: e.msg }) + const output: { [k: string]: unknown } = { message: e.msg } + if (e.details != null) { + output.details = e.details + } + res.status(e.code).json(output) } else { + functions.logger.error(e) res.status(500).json({ message: 'An unknown error occurred.' }) } } diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index bc74dc05..7fbe5416 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -12,7 +12,6 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' -import { Bet } from '../../common/bet' export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( async ( @@ -61,11 +60,6 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( if (closeTime && Date.now() > closeTime) 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 [lastAnswer] = await getValues( firestore .collection(`contracts/${contractId}/answers`) @@ -99,23 +93,20 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( } transaction.create(newAnswerDoc, answer) - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() + const loanAmount = 0 - const loanAmount = 0 // getLoanAmount(yourBets, amount) - - const { newBet, newPool, newTotalShares, newTotalBets, newBalance } = + const { newBet, newPool, newTotalShares, newTotalBets } = getNewMultiBetInfo( - user, answerId, amount, contract as FullContract, - loanAmount, - newBetDoc.id + loanAmount ) - 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, { pool: newPool, totalShares: newTotalShares, @@ -124,13 +115,7 @@ export const createAnswer = functions.runWith({ minInstances: 1 }).https.onCall( volume: volume + amount, }) - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } - - transaction.update(userDoc, { balance: newBalance }) - - return { status: 'success', answerId, betId: newBetDoc.id, answer } + return { status: 'success', answerId, betId: betDoc.id, answer } }) const { answer } = result diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index ee3a3d1f..b7104197 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -1,4 +1,5 @@ import * as admin from 'firebase-admin' +import { z } from 'zod' import { Binary, @@ -19,7 +20,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser } from './utils' -import { APIError, newEndpoint, parseCredentials, lookupUser } from './api' +import { APIError, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, @@ -28,94 +29,65 @@ import { getFreeAnswerAnte, getNumericAnte, HOUSE_LIQUIDITY_PROVIDER_ID, - MINIMUM_ANTE, } from '../../common/antes' 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 [creator, _privateUser] = await lookupUser(await parseCredentials(req)) - let { - question, - outcomeType, - description, - initialProb, - closeTime, - tags, - min, - max, - manaLimitPerUser, - resolutionType, - automaticResolution, - automaticResolutionTime - } = req.body || {} - - if (!question || typeof question != 'string') - throw new APIError(400, 'Missing or invalid question field') - - question = question.slice(0, MAX_QUESTION_LENGTH) - - if (typeof description !== 'string') - throw new APIError(400, 'Invalid description field') - - description = description.slice(0, MAX_DESCRIPTION_LENGTH) - - 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) +const bodySchema = z.object({ + question: z.string().min(1).max(MAX_QUESTION_LENGTH), + description: z.string().max(MAX_DESCRIPTION_LENGTH), + tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(), + closeTime: zTimestamp().refine( + (date) => date.getTime() > new Date().getTime(), + 'Close time must be in the future.' + ), + outcomeType: z.enum(OUTCOME_TYPES), + resolutionType: z.enum(RESOLUTION_TYPES), + automaticResolution: z.enum(RESOLUTIONS), + automaticResolutionTime: z.date() + }).refine( + (data) => data.automaticResolutionTime.getTime() > data.closeTime.getTime(), + 'Resolution time must be after close time.' + ).refine( + (data) => data.resolutionType === 'MANUAL' && data.automaticResolutionTime, + 'Time for automatic resolution specified even tho the resolution is \'MANUAL\'' ) - outcomeType = outcomeType ?? 'BINARY' +const binarySchema = z.object({ + initialProb: z.number().min(1).max(99), +}) - if (!OUTCOME_TYPES.includes(outcomeType)) - throw new APIError(400, 'Invalid outcomeType') +const numericSchema = z.object({ + min: z.number(), + max: z.number(), +}) - if ( - outcomeType === 'NUMERIC' && - !( - min !== undefined && - max !== undefined && - isFinite(min) && - isFinite(max) && - min < max && - max - min > 0.01 - ) +export const createContract = newEndpoint(['POST'], async (req, [user, _]) => { + const { question, description, tags, closeTime, outcomeType, resolutionType, automaticResolution, automaticResolutionTime } = validate( + bodySchema, + req.body ) - throw new APIError(400, 'Invalid range') - if ( - outcomeType === 'BINARY' && - (!initialProb || initialProb < 1 || initialProb > 99) - ) - throw new APIError(400, 'Invalid initial probability') - - resolutionType = resolutionType ?? 'MANUAL' - - if (!RESOLUTION_TYPES.includes(resolutionType)) - throw new APIError(400, 'Invalid resolutionType') - - automaticResolution = automaticResolution ?? 'MANUAL' - - if (!RESOLUTIONS.includes(automaticResolution)) - throw new APIError(400, 'Invalid automaticResolution') - - if (automaticResolution === 'MANUAL' && automaticResolutionTime) - throw new APIError(400, 'automaticResolutionTime specified even tho automaticResolution is \'MANUAL\'') - - if (automaticResolutionTime && automaticResolutionTime < closeTime) - throw new APIError(400, 'resolutionTime < closeTime') + let min, max, initialProb + if (outcomeType === 'NUMERIC') { + ;({ 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)) + } // Uses utc time on server: - const yesterday = new Date() - yesterday.setUTCDate(yesterday.getUTCDate() - 1) - const freeMarketResetTime = yesterday.setUTCHours(16, 0, 0, 0) + const today = new Date() + let freeMarketResetTime = today.setUTCHours(16, 0, 0, 0) + if (today.getTime() < freeMarketResetTime) { + freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000 + } const userContractsCreatedTodaySnapshot = await firestore .collection(`contracts`) - .where('creatorId', '==', creator.id) + .where('creatorId', '==', user.id) .where('createdTime', '>=', freeMarketResetTime) .get() console.log('free market reset time: ', freeMarketResetTime) @@ -123,18 +95,9 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { 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( 'creating contract for', - creator.username, + user.username, 'on', question, 'ante:', @@ -142,34 +105,31 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { ) const slug = await getSlug(question) - const contractRef = firestore.collection('contracts').doc() - const contract = getNewContract( contractRef.id, slug, - creator, + user, question, outcomeType, description, - initialProb, + initialProb ?? 0, ante, - closeTime, + closeTime.getTime(), tags ?? [], resolutionType, automaticResolution, - automaticResolutionTime, + automaticResolutionTime.getTime(), NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0, - manaLimitPerUser ?? 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) - const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : creator.id + const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id if (outcomeType === 'BINARY' && contract.mechanism === 'dpm-2') { const yesBetDoc = firestore @@ -179,7 +139,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { const noBetDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() const { yesBet, noBet } = getAnteBets( - creator, + user, contract as FullContract, yesBetDoc.id, noBetDoc.id @@ -205,7 +165,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { .collection(`contracts/${contract.id}/answers`) .doc('0') - const noneAnswer = getNoneAnswer(contract.id, creator) + const noneAnswer = getNoneAnswer(contract.id, user) await noneAnswerDoc.set(noneAnswer) const anteBetDoc = firestore @@ -224,7 +184,7 @@ export const createContract = newEndpoint(['POST'], async (req, _res) => { .doc() const anteBet = getNumericAnte( - creator, + user, contract as FullContract, ante, anteBetDoc.id diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index d97effe4..184ee2df 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -1,146 +1,112 @@ 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 { User } from '../../common/user' import { + BetInfo, getNewBinaryCpmmBetInfo, getNewBinaryDpmBetInfo, getNewMultiBetInfo, getNumericBetsInfo, } from '../../common/new-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -import { Bet } from '../../common/bet' import { redeemShares } from './redeem-shares' -import { Fees } from '../../common/fees' -export const placeBet = newEndpoint(['POST'], async (req, _res) => { - const [bettor, _privateUser] = await lookupUser(await parseCredentials(req)) - const { amount, outcome, contractId, value } = req.body || {} +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gte(1), +}) - if (amount < 1 || isNaN(amount) || !isFinite(amount)) - throw new APIError(400, 'Invalid amount') +const binarySchema = z.object({ + outcome: z.enum(['YES', 'NO']), +}) - if (outcome !== 'YES' && outcome !== 'NO' && isNaN(+outcome)) - throw new APIError(400, 'Invalid outcome') +const freeResponseSchema = z.object({ + outcome: z.string(), +}) - if (value !== undefined && !isFinite(value)) - throw new APIError(400, 'Invalid value') +const numericSchema = z.object({ + outcome: z.string(), + value: z.number(), +}) - // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - 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 +export const placeBet = newEndpoint(['POST'], async (req, [bettor, _]) => { + const { amount, contractId } = validate(bodySchema, req.body) - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') - const contract = contractSnap.data() as Contract + const result = await firestore.runTransaction(async (trans) => { + const userDoc = firestore.doc(`users/${bettor.id}`) + const userSnap = await trans.get(userDoc) + 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 { closeTime, outcomeType, mechanism, collectedFees, volume } = - contract - if (closeTime && Date.now() > closeTime) - throw new APIError(400, 'Trading is closed') + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await trans.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as Contract - const yourBetsSnap = await transaction.get( - contractDoc.collection('bets').where('userId', '==', bettor.id) - ) - const yourBets = yourBetsSnap.docs.map((doc) => doc.data() as Bet) + const loanAmount = 0 + const { closeTime, outcomeType, mechanism, collectedFees, volume } = + contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed.') - const loanAmount = 0 // getLoanAmount(yourBets, amount) - if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - - if (outcomeType === 'FREE_RESPONSE') { - const answerSnap = await transaction.get( - contractDoc.collection('answers').doc(outcome) - ) - if (!answerSnap.exists) throw new APIError(400, 'Invalid contract') + const { + newBet, + newPool, + newTotalShares, + newTotalBets, + newTotalLiquidity, + newP, + } = await (async (): Promise => { + if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { + const { outcome } = validate(binarySchema, req.body) + return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) + } 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 - .collection(`contracts/${contractId}/bets`) - .doc() + if (newP != null && !isFinite(newP)) { + throw new APIError(400, 'Trade rejected due to overflow error.') + } - const { - newBet, - newPool, - newTotalShares, - newTotalBets, - newBalance, - newTotalLiquidity, - fees, - newP, - } = - outcomeType === 'BINARY' - ? mechanism === 'dpm-2' - ? getNewBinaryDpmBetInfo( - user, - outcome as 'YES' | 'NO', - amount, - contract, - loanAmount, - newBetDoc.id - ) - : (getNewBinaryCpmmBetInfo( - user, - outcome as 'YES' | 'NO', - amount, - contract, - loanAmount, - newBetDoc.id - ) as any) - : outcomeType === 'NUMERIC' && mechanism === 'dpm-2' - ? getNumericBetsInfo( - user, - value, - outcome, - amount, - contract, - newBetDoc.id - ) - : getNewMultiBetInfo( - user, - outcome, - amount, - contract as any, - loanAmount, - newBetDoc.id - ) + const newBalance = user.balance - amount - loanAmount + const betDoc = contractDoc.collection('bets').doc() + trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) + trans.update(userDoc, { balance: newBalance }) + trans.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalShares: newTotalShares, + totalBets: newTotalBets, + totalLiquidity: newTotalLiquidity, + collectedFees: addObjects(newBet.fees, collectedFees), + volume: volume + amount, + }) + ) - if (newP !== undefined && !isFinite(newP)) { - throw new APIError(400, 'Trade rejected due to overflow error.') - } + return { betId: betDoc.id } + }) - transaction.create(newBetDoc, newBet) - - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalShares: newTotalShares, - totalBets: newTotalBets, - totalLiquidity: newTotalLiquidity, - collectedFees: addObjects(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 - }) + await redeemShares(bettor.id, contractId) + return result }) const firestore = admin.firestore() diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 868f0beb..cf8c018f 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, RESOLUTIONS } from '../../common/contract' +import { Contract, resolution, RESOLUTIONS } from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -21,7 +21,7 @@ export const resolveMarket = functions .https.onCall( async ( data: { - outcome: string + outcome: resolution value?: number contractId: string probabilityInt?: number diff --git a/functions/src/scripts/backfill-fees.ts b/functions/src/scripts/backfill-fees.ts new file mode 100644 index 00000000..66bbc0c3 --- /dev/null +++ b/functions/src/scripts/backfill-fees.ts @@ -0,0 +1,23 @@ +// 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(async (contractsSnaps) => { + console.log(`Loaded ${contractsSnaps.size} contracts.`) + const needsFilling = contractsSnaps.docs.filter((ct) => { + return !('collectedFees' in ct.data()) + }) + console.log(`Found ${needsFilling.length} contracts to update.`) + await Promise.all( + needsFilling.map((ct) => ct.ref.update({ collectedFees: noFees })) + ) + console.log(`Updated all contracts.`) + }) +} diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index fff88716..39ed8017 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -78,7 +78,7 @@ export const sellBet = functions.runWith({ minInstances: 1 }).https.onCall( pool: newPool, totalShares: newTotalShares, totalBets: newTotalBets, - collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), + collectedFees: addObjects(fees, collectedFees), volume: volume + Math.abs(newBet.amount), }) ) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 08e7d7c5..c4166b8b 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -101,7 +101,7 @@ export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall( removeUndefinedProps({ pool: newPool, p: newP, - collectedFees: addObjects(fees ?? {}, collectedFees ?? {}), + collectedFees: addObjects(fees, collectedFees), volume: volume + Math.abs(newBet.amount), }) ) diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index a52b0fae..d32010a6 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -5,11 +5,13 @@ import Stripe from 'stripe' import { getPrivateUser, getUser, isProd, payUser } from './utils' import { sendThankYouEmail } from './emails' +export type StripeSession = Stripe.Event.Data.Object & { id: any, metadata: any} + export type StripeTransaction = { userId: string manticDollarQuantity: number sessionId: string - session: any + session: StripeSession timestamp: number } @@ -96,14 +98,14 @@ export const stripeWebhook = functions } if (event.type === 'checkout.session.completed') { - const session = event.data.object as any + const session = event.data.object as StripeSession await issueMoneys(session) } res.status(200).send('success') }) -const issueMoneys = async (session: any) => { +const issueMoneys = async (session: StripeSession) => { const { id: sessionId } = session const query = await firestore diff --git a/web/.eslintrc.js b/web/.eslintrc.js index f864ffa2..6c34b521 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,10 +1,21 @@ module.exports = { parser: '@typescript-eslint/parser', plugins: ['lodash'], - extends: ['plugin:react-hooks/recommended', 'plugin:@next/next/recommended'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'plugin:@next/next/recommended', + ], rules: { + '@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-typos': 'off', 'lodash/import-scope': [2, 'member'], }, + env: { + browser: true, + node: true, + }, } diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 8987d671..11e24c99 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react' import Head from 'next/head' export type OgCardProps = { @@ -35,7 +36,7 @@ export function SEO(props: { title: string description: string url?: string - children?: any[] + children?: ReactNode ogCardProps?: OgCardProps }) { const { title, description, url, children, ogCardProps } = props diff --git a/web/components/advanced-panel.tsx b/web/components/advanced-panel.tsx index 1a8cf389..51caba67 100644 --- a/web/components/advanced-panel.tsx +++ b/web/components/advanced-panel.tsx @@ -1,7 +1,7 @@ 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 [collapsed, setCollapsed] = useState(true) diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 84a29b6d..a31957cb 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import React from 'react' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 706b61f3..8ae2a40d 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -1,5 +1,6 @@ import Router from 'next/router' import clsx from 'clsx' +import { MouseEvent } from 'react' import { UserCircleIcon } from '@heroicons/react/solid' export function Avatar(props: { @@ -15,7 +16,7 @@ export function Avatar(props: { const onClick = noLink && username ? undefined - : (e: any) => { + : (e: MouseEvent) => { e.stopPropagation() Router.push(`/${username}`) } diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 400a5f7f..c979899f 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -50,7 +50,7 @@ import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' -type BetFilter = 'open' | 'closed' | 'resolved' | 'all' +type BetFilter = 'open' | 'sold' | 'closed' | 'resolved' | 'all' export function BetsList(props: { user: User }) { const { user } = props @@ -107,6 +107,7 @@ export function BetsList(props: { user: User }) { !FILTERS.resolved(c) && (c.closeTime ?? Infinity) < Date.now(), open: (c) => !(FILTERS.closed(c) || FILTERS.resolved(c)), all: () => true, + sold: () => true, } const SORTS: Record number> = { profit: (c) => contractsMetrics[c.id].profit, @@ -122,10 +123,14 @@ export function BetsList(props: { user: User }) { .reverse() .filter(FILTERS[filter]) .filter((c) => { - if (sort === 'profit') return true + if (filter === 'all') return true - // Filter out contracts where you don't have shares anymore. const metrics = contractsMetrics[c.id] + + // Filter for contracts you sold out of. + if (filter === 'sold') return metrics.payout === 0 + + // Filter for contracts where you currently have shares. return metrics.payout > 0 }) @@ -181,6 +186,7 @@ export function BetsList(props: { user: User }) { onChange={(e) => setFilter(e.target.value as BetFilter)} > + diff --git a/web/components/client-render.tsx b/web/components/client-render.tsx index a58c90ff..d26d2301 100644 --- a/web/components/client-render.tsx +++ b/web/components/client-render.tsx @@ -1,5 +1,5 @@ // 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 }) { const { children } = props diff --git a/web/components/confirmation-button.tsx b/web/components/confirmation-button.tsx index c23ec87c..e07b6dab 100644 --- a/web/components/confirmation-button.tsx +++ b/web/components/confirmation-button.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useState } from 'react' +import { ReactNode, useState } from 'react' import { Col } from './layout/col' import { Modal } from './layout/modal' import { Row } from './layout/row' @@ -20,7 +20,7 @@ export function ConfirmationButton(props: { className?: string } onSubmit: () => void - children: any + children: ReactNode }) { const { id, openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 6f83ccb9..84e2db8f 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -20,6 +20,7 @@ import { import { AnswerLabel, BinaryContractOutcomeLabel, + CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' import { getOutcomeProbability, getTopAnswer } from 'common/calculate' @@ -240,7 +241,12 @@ export function NumericResolutionOrExpectation(props: { {resolution ? ( <>
Resolved
-
{resolutionValue}
+ + {resolution === 'CANCEL' ? ( + + ) : ( +
{resolutionValue}
+ )} ) : ( <> diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 8a238727..28cd91f4 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -95,7 +95,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { Creator earnings - {formatMoney(contract.collectedFees?.creatorFee ?? 0)} + {formatMoney(contract.collectedFees.creatorFee)} diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index c8489c7a..03de78ef 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -230,12 +230,13 @@ function QuickOutcomeView(props: { case 'NUMERIC': display = formatLargeNumber(getExpectedValue(contract as NumericContract)) break - case 'FREE_RESPONSE': + case 'FREE_RESPONSE': { const topAnswer = getTopAnswer(contract as FreeResponseContract) display = topAnswer && formatPercent(getOutcomeProbability(contract, topAnswer.id)) break + } } return ( diff --git a/web/components/datetime-tooltip.tsx b/web/components/datetime-tooltip.tsx index 69c4521e..7f7a9b45 100644 --- a/web/components/datetime-tooltip.tsx +++ b/web/components/datetime-tooltip.tsx @@ -1,3 +1,4 @@ +import React from 'react' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import timezone from 'dayjs/plugin/timezone' diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index c958a892..b8d99598 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -343,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: const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets] - let sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => { + const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => { if (item.type === 'comment') { return item.comment.createdTime } else if (item.type === 'bet') { @@ -540,7 +540,7 @@ export function getSpecificContractActivityItems( } ) { const { mode } = options - let items = [] as ActivityItem[] + const items = [] as ActivityItem[] switch (mode) { case 'bets': @@ -559,7 +559,7 @@ export function getSpecificContractActivityItems( ) break - case 'comments': + case 'comments': { const nonFreeResponseComments = comments.filter((comment) => commentIsGeneralComment(comment, contract) ) @@ -585,6 +585,7 @@ export function getSpecificContractActivityItems( ), }) break + } case 'free-response-comment-answer-groups': items.push( ...getAnswerAndCommentInputGroups( diff --git a/web/components/feed/copy-link-date-time.tsx b/web/components/feed/copy-link-date-time.tsx index 354996a4..60395801 100644 --- a/web/components/feed/copy-link-date-time.tsx +++ b/web/components/feed/copy-link-date-time.tsx @@ -21,7 +21,7 @@ export function CopyLinkDateTimeComponent(props: { event: React.MouseEvent ) { event.preventDefault() - let elementLocation = `https://${ENV_CONFIG.domain}${contractPath( + const elementLocation = `https://${ENV_CONFIG.domain}${contractPath( contract )}#${elementId}` diff --git a/web/components/join-spans.tsx b/web/components/join-spans.tsx index e6947ee8..2f071c82 100644 --- a/web/components/join-spans.tsx +++ b/web/components/join-spans.tsx @@ -1,12 +1,14 @@ +import { ReactNode } from 'react' + export const JoinSpans = (props: { - children: any[] - separator?: JSX.Element | string + children: ReactNode[] + separator?: ReactNode }) => { const { separator } = props const children = props.children.filter((x) => !!x) if (children.length === 0) return <> - if (children.length === 1) return children[0] + if (children.length === 1) return <>{children[0]} if (children.length === 2) return ( <> diff --git a/web/components/layout/col.tsx b/web/components/layout/col.tsx index 00e85532..74fad186 100644 --- a/web/components/layout/col.tsx +++ b/web/components/layout/col.tsx @@ -1,8 +1,8 @@ import clsx from 'clsx' -import { CSSProperties, Ref } from 'react' +import { CSSProperties, Ref, ReactNode } from 'react' export function Col(props: { - children?: any + children?: ReactNode className?: string style?: CSSProperties ref?: Ref diff --git a/web/components/layout/modal.tsx b/web/components/layout/modal.tsx index 6c6b1af4..d61a38dd 100644 --- a/web/components/layout/modal.tsx +++ b/web/components/layout/modal.tsx @@ -1,9 +1,9 @@ -import { Fragment } from 'react' +import { Fragment, ReactNode } from 'react' import { Dialog, Transition } from '@headlessui/react' // From https://tailwindui.com/components/application-ui/overlays/modals export function Modal(props: { - children: React.ReactNode + children: ReactNode open: boolean setOpen: (open: boolean) => void }) { diff --git a/web/components/layout/row.tsx b/web/components/layout/row.tsx index 5ccccd7b..1c69c252 100644 --- a/web/components/layout/row.tsx +++ b/web/components/layout/row.tsx @@ -1,7 +1,8 @@ import clsx from 'clsx' +import { ReactNode } from 'react' export function Row(props: { - children?: any + children?: ReactNode className?: string id?: string }) { diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx index fc9dd775..eeab17f9 100644 --- a/web/components/layout/tabs.tsx +++ b/web/components/layout/tabs.tsx @@ -1,12 +1,12 @@ import clsx from 'clsx' import Link from 'next/link' -import { useState } from 'react' +import { ReactNode, useState } from 'react' import { Row } from './row' type Tab = { title: string - tabIcon?: JSX.Element - content: JSX.Element + tabIcon?: ReactNode + content: ReactNode // If set, change the url to this href when the tab is selected href?: string } diff --git a/web/components/linkify.tsx b/web/components/linkify.tsx index 3a5f2a18..f33b2bf5 100644 --- a/web/components/linkify.tsx +++ b/web/components/linkify.tsx @@ -4,14 +4,14 @@ import { SiteLink } from './site-link' // Return a JSX span, linkifying @username, #hashtags, and https://... // TODO: Use a markdown parser instead of rolling our own here. export function Linkify(props: { text: string; gray?: boolean }) { - let { text, gray } = props + const { text, gray } = props // Replace "m1234" with "ϻ1234" // const mRegex = /(\W|^)m(\d+)/g // text = text.replace(mRegex, (_, pre, num) => `${pre}ϻ${num}`) // Find instances of @username, #hashtag, and https://... const regex = - /(?:^|\s)(?:[@#][a-z0-9_]+|https?:\/\/[-A-Za-z0-9+&@#\/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#\/%=~_|])/gi + /(?:^|\s)(?:[@#][a-z0-9_]+|https?:\/\/[-A-Za-z0-9+&@#/%?=~_()|!:,.;]*[-A-Za-z0-9+&@#/%=~_|])/gi const matches = text.match(regex) || [] const links = matches.map((match) => { // Matches are in the form: " @username" or "https://example.com" diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 0cb885f5..40a5aacd 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -17,7 +17,7 @@ import { Avatar } from '../avatar' import clsx from 'clsx' import { useRouter } from 'next/router' -function getNavigation(username: String) { +function getNavigation(username: string) { return [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Activity', href: '/activity', icon: ChatAltIcon }, diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 1948aae2..962593ed 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -25,7 +25,7 @@ import { useHasCreatedContractToday, } from 'web/hooks/use-has-created-contract-today' import { Row } from '../layout/row' -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' // Create an icon from the url of an image function IconFromUrl(url: string): React.ComponentType<{ className?: string }> { @@ -130,7 +130,7 @@ export default function Sidebar(props: { className?: string }) { const nextUtcResetTime = getUtcFreeMarketResetTime(false) const interval = setInterval(() => { const now = new Date().getTime() - let timeUntil = nextUtcResetTime - now + const timeUntil = nextUtcResetTime - now const hoursUntil = timeUntil / 1000 / 60 / 60 const minutesUntil = Math.floor((hoursUntil * 60) % 60) const secondsUntil = Math.floor((hoursUntil * 60 * 60) % 60) diff --git a/web/components/number-input.tsx b/web/components/number-input.tsx index a5adb3f8..d7159fab 100644 --- a/web/components/number-input.tsx +++ b/web/components/number-input.tsx @@ -1,5 +1,7 @@ import clsx from 'clsx' +import { ReactNode } from 'react' +import React from 'react' import { Col } from './layout/col' import { Spacer } from './layout/spacer' @@ -13,7 +15,7 @@ export function NumberInput(props: { inputClassName?: string // Needed to focus the amount input inputRef?: React.MutableRefObject - children?: any + children?: ReactNode }) { const { numberString, diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx index f249e3c3..ebb80dd0 100644 --- a/web/components/numeric-bet-panel.tsx +++ b/web/components/numeric-bet-panel.tsx @@ -102,12 +102,10 @@ function NumericBuyPanel(props: { const betDisabled = isSubmitting || !betAmount || !bucketChoice || error const { newBet, newPool, newTotalShares, newTotalBets } = getNumericBetsInfo( - { id: 'dummy', balance: 0 } as User, // a little hackish value ?? 0, bucketChoice ?? 'NaN', betAmount ?? 0, - contract, - 'dummy id' + contract ) const { probAfter: outcomeProb, shares } = newBet diff --git a/web/components/outcome-label.tsx b/web/components/outcome-label.tsx index 39f3be84..b0b5cd9a 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { ReactNode } from 'react' import { Answer } from 'common/answer' import { getProbability } from 'common/calculate' import { getValueFromBucket } from 'common/calculate-dpm' @@ -157,7 +158,7 @@ export function AnswerLabel(props: { function FreeResponseAnswerToolTip(props: { text: string - children?: React.ReactNode + children?: ReactNode }) { const { text } = props return ( diff --git a/web/components/page.tsx b/web/components/page.tsx index faefb718..421722a3 100644 --- a/web/components/page.tsx +++ b/web/components/page.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { ReactNode } from 'react' import { BottomNavBar } from './nav/nav-bar' import Sidebar from './nav/sidebar' import { Toaster } from 'react-hot-toast' @@ -6,9 +7,9 @@ import { Toaster } from 'react-hot-toast' export function Page(props: { margin?: boolean assertUser?: 'signed-in' | 'signed-out' - rightSidebar?: React.ReactNode + rightSidebar?: ReactNode suspend?: boolean - children?: any + children?: ReactNode }) { const { margin, assertUser, children, rightSidebar, suspend } = props diff --git a/web/components/site-link.tsx b/web/components/site-link.tsx index 86fc6c5c..8137eb08 100644 --- a/web/components/site-link.tsx +++ b/web/components/site-link.tsx @@ -1,9 +1,10 @@ import clsx from 'clsx' +import { ReactNode } from 'react' import Link from 'next/link' export const SiteLink = (props: { href: string - children?: any + children?: ReactNode onClick?: () => void className?: string }) => { @@ -30,7 +31,7 @@ export const SiteLink = (props: { ) } -function MaybeLink(props: { href: string; children: React.ReactNode }) { +function MaybeLink(props: { href: string; children: ReactNode }) { const { href, children } = props return href.startsWith('http') ? ( <>{children} diff --git a/web/components/tags-input.tsx b/web/components/tags-input.tsx index ff9029c9..6a0cdcda 100644 --- a/web/components/tags-input.tsx +++ b/web/components/tags-input.tsx @@ -5,6 +5,7 @@ import { Contract, updateContract } from 'web/lib/firebase/contracts' import { Col } from './layout/col' import { Row } from './layout/row' import { TagsList } from './tags-list' +import { MAX_TAG_LENGTH } from 'common/contract' export function TagsInput(props: { contract: Contract; className?: string }) { const { contract, className } = props @@ -36,6 +37,7 @@ export function TagsInput(props: { contract: Contract; className?: string }) { className="input input-sm input-bordered resize-none" disabled={isSubmitting} value={tagText} + maxLength={MAX_TAG_LENGTH} onChange={(e) => setTagText(e.target.value || '')} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { diff --git a/web/components/yes-no-selector.tsx b/web/components/yes-no-selector.tsx index ff4a18c5..1a5eabf5 100644 --- a/web/components/yes-no-selector.tsx +++ b/web/components/yes-no-selector.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import React from 'react' +import React, { ReactNode } from 'react' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { Row } from './layout/row' @@ -231,7 +231,7 @@ function Button(props: { className?: string onClick?: () => void color: 'green' | 'red' | 'blue' | 'yellow' | 'gray' - children?: any + children?: ReactNode }) { const { className, onClick, children, color } = props diff --git a/web/lib/api/proxy.ts b/web/lib/api/proxy.ts index 6fa66873..0a386730 100644 --- a/web/lib/api/proxy.ts +++ b/web/lib/api/proxy.ts @@ -6,10 +6,10 @@ import fetch, { Headers, Response } from 'node-fetch' function getProxiedRequestHeaders(req: NextApiRequest, whitelist: string[]) { const result = new Headers() - for (let name of whitelist) { + for (const name of whitelist) { const v = req.headers[name.toLowerCase()] if (Array.isArray(v)) { - for (let vv of v) { + for (const vv of v) { result.append(name, vv) } } else if (v != null) { @@ -23,7 +23,7 @@ function getProxiedRequestHeaders(req: NextApiRequest, whitelist: string[]) { function getProxiedResponseHeaders(res: Response, whitelist: string[]) { const result: { [k: string]: string } = {} - for (let name of whitelist) { + for (const name of whitelist) { const v = res.headers.get(name) if (v != null) { result[name] = v diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 379f7cb6..12f3d832 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -9,13 +9,15 @@ export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG) export const db = getFirestore() export const functions = getFunctions() -const EMULATORS_STARTED = 'EMULATORS_STARTED' +declare global { + /* eslint-disable-next-line no-var */ + var EMULATORS_STARTED: boolean +} + function startEmulators() { // I don't like this but this is the only way to reconnect to the emulators without error, see: https://stackoverflow.com/questions/65066963/firebase-firestore-emulator-error-host-has-been-set-in-both-settings-and-usee - // @ts-ignore - if (!global[EMULATORS_STARTED]) { - // @ts-ignore - global[EMULATORS_STARTED] = true + if (!global.EMULATORS_STARTED) { + global.EMULATORS_STARTED = true connectFirestoreEmulator(db, 'localhost', 8080) connectFunctionsEmulator(functions, 'localhost', 5001) } diff --git a/web/lib/util/copy.ts b/web/lib/util/copy.ts index 47dc4dab..66314612 100644 --- a/web/lib/util/copy.ts +++ b/web/lib/util/copy.ts @@ -21,7 +21,7 @@ export function copyToClipboard(text: string) { document.queryCommandSupported('copy') ) { console.log('copy 3') - var textarea = document.createElement('textarea') + const textarea = document.createElement('textarea') textarea.textContent = text textarea.style.position = 'fixed' // Prevent scrolling to bottom of page in Microsoft Edge. document.body.appendChild(textarea) diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 55618e4c..a833c933 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -13,12 +13,12 @@ function firstLine(msg: string) { function printBuildInfo() { // These are undefined if e.g. dev server if (process.env.NEXT_PUBLIC_VERCEL_ENV) { - let env = process.env.NEXT_PUBLIC_VERCEL_ENV - let msg = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_MESSAGE - let owner = process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER - let repo = process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG - let sha = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA - let url = `https://github.com/${owner}/${repo}/commit/${sha}` + const env = process.env.NEXT_PUBLIC_VERCEL_ENV + const msg = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_MESSAGE + const owner = process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_OWNER + const repo = process.env.NEXT_PUBLIC_VERCEL_GIT_REPO_SLUG + const sha = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA + const url = `https://github.com/${owner}/${repo}/commit/${sha}` console.info(`Build: ${env} / ${firstLine(msg || '???')} / ${url}`) } } diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index db230e74..e916eb6f 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -19,28 +19,22 @@ function avatarHtml(avatarUrl: string) { } function UsersTable() { - let users = useUsers() - let privateUsers = usePrivateUsers() + const users = useUsers() + const privateUsers = usePrivateUsers() // Map private users by user id const privateUsersById = mapKeys(privateUsers, 'id') console.log('private users by id', privateUsersById) // For each user, set their email from the PrivateUser - users = users.map((user) => { - // @ts-ignore - user.email = privateUsersById[user.id]?.email - return user - }) - - // Sort users by createdTime descending, by default - users = users.sort((a, b) => b.createdTime - a.createdTime) + const fullUsers = users + .map((user) => { + return { email: privateUsersById[user.id]?.email, ...user } + }) + .sort((a, b) => b.createdTime - a.createdTime) function exportCsv() { - const csv = users - // @ts-ignore - .map((u) => [u.email, u.name].join(', ')) - .join('\n') + const csv = fullUsers.map((u) => [u.email, u.name].join(', ')).join('\n') const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -108,27 +102,29 @@ function UsersTable() { } function ContractsTable() { - let contracts = useContracts() ?? [] + const contracts = useContracts() ?? [] + // Sort users by createdTime descending, by default - contracts.sort((a, b) => b.createdTime - a.createdTime) - // Render a clickable question. See https://gridjs.io/docs/examples/react-cells for docs - contracts.map((contract) => { - // @ts-ignore - contract.questionLink = r( - - ) - }) + const displayContracts = contracts + .sort((a, b) => b.createdTime - a.createdTime) + .map((contract) => { + // Render a clickable question. See https://gridjs.io/docs/examples/react-cells for docs + const questionLink = r( + + ) + return { questionLink, ...contract } + }) return ( setQuestion(e.target.value || '')} /> diff --git a/web/pages/folds.tsx b/web/pages/folds.tsx index 749da213..88d6643b 100644 --- a/web/pages/folds.tsx +++ b/web/pages/folds.tsx @@ -63,7 +63,7 @@ export default function Folds(props: { const [query, setQuery] = useState('') // Copied from contracts-list.tsx; extract if we copy this again const queryWords = query.toLowerCase().split(' ') - function check(corpus: String) { + function check(corpus: string) { return queryWords.every((word) => corpus.toLowerCase().includes(word)) } diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index ea206f99..ac06eaf2 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { RefreshIcon } from '@heroicons/react/outline' import Router from 'next/router' @@ -21,7 +21,7 @@ import Textarea from 'react-expanding-textarea' function EditUserField(props: { user: User - field: 'bio' | 'bannerUrl' | 'twitterHandle' | 'discordHandle' + field: 'bio' | 'website' | 'bannerUrl' | 'twitterHandle' | 'discordHandle' label: string }) { const { user, field, label } = props @@ -220,18 +220,15 @@ export default function ProfilePage() { }} /> - {[ - ['bio', 'Bio'], - ['website', 'Website URL'], - ['twitterHandle', 'Twitter'], - ['discordHandle', 'Discord'], - ].map(([field, label]) => ( - + {( + [ + ['bio', 'Bio'], + ['website', 'Website URL'], + ['twitterHandle', 'Twitter'], + ['discordHandle', 'Discord'], + ] as const + ).map(([field, label]) => ( + ))} )} diff --git a/web/pages/simulator.tsx b/web/pages/simulator.tsx index bbf7408e..dcf44478 100644 --- a/web/pages/simulator.tsx +++ b/web/pages/simulator.tsx @@ -112,7 +112,7 @@ function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) { function NewBidTable(props: { steps: number - bids: any[] + bids: Array<{ yesBid: number; noBid: number }> setSteps: (steps: number) => void setBids: (bids: any[]) => void }) { diff --git a/yarn.lock b/yarn.lock index 7eecfe4d..982cd49a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5410,3 +5410,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@3.17.2: + version "3.17.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.17.2.tgz#d20b32146a3b5068f8f71768b4f9a4bfe52cddb0" + integrity sha512-L8UPS2J/F3dIA8gsPTvGjd8wSRuwR1Td4AqR2Nw8r8BgcLIbZZ5/tCII7hbTLXTQDhxUnnsFdHwpETGajt5i3A==