diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dcf81c44..e441edcf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -52,4 +52,4 @@ jobs: - name: Run Typescript checker on cloud functions if: ${{ success() || failure() }} working-directory: functions - run: tsc --pretty --project tsconfig.json --noEmit + run: tsc -b -v --pretty diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..2aa95e44 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,43 @@ +name: Reformat main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + +env: + FORCE_COLOR: 3 + NEXT_TELEMETRY_DISABLED: 1 + +# mqp - i generated a personal token to use for these writes -- it's unclear +# why, but the default token didn't work, even when i gave it max permissions + +jobs: + prettify: + name: Auto-prettify + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + token: ${{ secrets.FORMATTER_ACCESS_TOKEN }} + - 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: yarn format + - name: Commit any Prettier changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Auto-prettification + branch: ${{ github.head_ref }} diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 3d6cfa82..c6f9703e 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,6 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], + ignorePatterns: ['lib'], env: { browser: true, node: true, @@ -31,6 +32,7 @@ module.exports = { rules: { 'no-extra-semi': 'off', 'no-constant-condition': ['error', { checkLoops: false }], + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/common/.gitignore b/common/.gitignore index e0ba0181..11320851 100644 --- a/common/.gitignore +++ b/common/.gitignore @@ -1,6 +1,5 @@ # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ @@ -10,4 +9,4 @@ node_modules/ package-lock.json ui-debug.log -firebase-debug.log \ No newline at end of file +firebase-debug.log diff --git a/common/.yarnrc b/common/.yarnrc new file mode 100644 index 00000000..fdd705c6 --- /dev/null +++ b/common/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/common/antes.ts b/common/antes.ts index becc9b7e..b9914451 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -5,19 +5,19 @@ import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, + MultipleChoiceContract, NumericContract, } from './contract' import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' +import { ENV_CONFIG } from './envs/constants' +import { Answer } from './answer' -export const FIXED_ANTE = 100 - -// deprecated -export const PHANTOM_ANTE = 0.001 -export const MINIMUM_ANTE = 50 +export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id +export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id export function getCpmmInitialLiquidity( providerId: string, @@ -113,6 +113,50 @@ export function getFreeAnswerAnte( return anteBet } +export function getMultipleChoiceAntes( + creator: User, + contract: MultipleChoiceContract, + answers: string[], + betDocIds: string[] +) { + const { totalBets, totalShares } = contract + const amount = totalBets['0'] + const shares = totalShares['0'] + const p = 1 / answers.length + + const { createdTime } = contract + + const bets: Bet[] = answers.map((answer, i) => ({ + id: betDocIds[i], + userId: creator.id, + contractId: contract.id, + amount, + shares, + outcome: i.toString(), + probBefore: p, + probAfter: p, + createdTime, + isAnte: true, + fees: noFees, + })) + + const { username, name, avatarUrl } = creator + + const answerObjects: Answer[] = answers.map((answer, i) => ({ + id: i.toString(), + number: i, + contractId: contract.id, + createdTime, + userId: creator.id, + username, + name, + avatarUrl, + text: answer, + })) + + return { bets, answerObjects } +} + export function getNumericAnte( anteBettorId: string, contract: NumericContract, diff --git a/common/api.ts b/common/api.ts new file mode 100644 index 00000000..1ae9a5fd --- /dev/null +++ b/common/api.ts @@ -0,0 +1,24 @@ +import { ENV_CONFIG } from './envs/constants' + +export class APIError extends Error { + code: number + details?: unknown + constructor(code: number, message: string, details?: unknown) { + super(message) + this.code = code + this.name = 'APIError' + this.details = details + } +} + +export function getFunctionUrl(name: string) { + if (process.env.NEXT_PUBLIC_FUNCTIONS_URL) { + return `${process.env.NEXT_PUBLIC_FUNCTIONS_URL}/${name}` + } else if (process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { + const { projectId, region } = ENV_CONFIG.firebaseConfig + return `http://localhost:5001/${projectId}/${region}/${name}` + } else { + const { cloudRunId, cloudRunRegion } = ENV_CONFIG + return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app` + } +} diff --git a/common/bet.ts b/common/bet.ts index 75aab89d..fbfc0387 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -4,6 +4,7 @@ export type Bet = { id: string userId: string contractId: string + createdTime: number amount: number // bet size; negative if SELL bet loanAmount?: number @@ -25,12 +26,36 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean - - createdTime: number -} + challengeSlug?: string +} & Partial export type NumericBet = Bet & { value: number allOutcomeShares: { [outcome: string]: number } allBetAmounts: { [outcome: string]: number } } + +// Binary market limit order. +export type LimitBet = Bet & LimitProps + +type LimitProps = { + orderAmount: number // Amount of limit order. + limitProb: number // [0, 1]. Bet to this probability. + isFilled: boolean // Whether all of the bet amount has been filled. + isCancelled: boolean // Whether to prevent any further fills. + // A record of each transaction that partially (or fully) fills the orderAmount. + // I.e. A limit order could be filled by partially matching with several bets. + // Non-limit orders can also be filled by matching with multiple limit orders. + fills: fill[] +} + +export type fill = { + // The id the bet matched against, or null if the bet was matched by the pool. + matchedBetId: string | null + amount: number + shares: number + timestamp: number + // If the fill is a sale, it means the matching bet has shares of the same outcome. + // I.e. -fill.shares === matchedBet.shares + isSale?: boolean +} diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index e7d56ba3..b5153355 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,10 +1,17 @@ -import { sum, groupBy, mapValues, sumBy, partition } from 'lodash' +import { sum, groupBy, mapValues, sumBy } from 'lodash' +import { LimitBet } from './bet' -import { CPMMContract } from './contract' -import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees' +import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { LiquidityProvision } from './liquidity-provision' +import { computeFills } from './new-bet' +import { binarySearch } from './util/algos' import { addObjects } from './util/object' +export type CpmmState = { + pool: { [outcome: string]: number } + p: number +} + export function getCpmmProbability( pool: { [outcome: string]: number }, p: number @@ -14,11 +21,11 @@ export function getCpmmProbability( } export function getCpmmProbabilityAfterBetBeforeFees( - contract: CPMMContract, + state: CpmmState, outcome: string, bet: number ) { - const { pool, p } = contract + const { pool, p } = state const shares = calculateCpmmShares(pool, p, bet, outcome) const { YES: y, NO: n } = pool @@ -31,12 +38,12 @@ export function getCpmmProbabilityAfterBetBeforeFees( } export function getCpmmOutcomeProbabilityAfterBet( - contract: CPMMContract, + state: CpmmState, outcome: string, bet: number ) { - const { newPool } = calculateCpmmPurchase(contract, bet, outcome) - const p = getCpmmProbability(newPool, contract.p) + const { newPool } = calculateCpmmPurchase(state, bet, outcome) + const p = getCpmmProbability(newPool, state.p) return outcome === 'NO' ? 1 - p : p } @@ -58,12 +65,8 @@ function calculateCpmmShares( : n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p)) } -export function getCpmmLiquidityFee( - contract: CPMMContract, - bet: number, - outcome: string -) { - const prob = getCpmmProbabilityAfterBetBeforeFees(contract, outcome, bet) +export function getCpmmFees(state: CpmmState, bet: number, outcome: string) { + const prob = getCpmmProbabilityAfterBetBeforeFees(state, outcome, bet) const betP = outcome === 'YES' ? 1 - prob : prob const liquidityFee = LIQUIDITY_FEE * betP * bet @@ -78,25 +81,23 @@ export function getCpmmLiquidityFee( } export function calculateCpmmSharesAfterFee( - contract: CPMMContract, + state: CpmmState, bet: number, outcome: string ) { - const { pool, p } = contract - const { remainingBet } = getCpmmLiquidityFee(contract, bet, outcome) + const { pool, p } = state + const { remainingBet } = getCpmmFees(state, bet, outcome) return calculateCpmmShares(pool, p, remainingBet, outcome) } export function calculateCpmmPurchase( - contract: CPMMContract, + state: CpmmState, bet: number, outcome: string ) { - const { pool, p } = contract - const { remainingBet, fees } = getCpmmLiquidityFee(contract, bet, outcome) - // const remainingBet = bet - // const fees = noFees + const { pool, p } = state + const { remainingBet, fees } = getCpmmFees(state, bet, outcome) const shares = calculateCpmmShares(pool, p, remainingBet, outcome) const { YES: y, NO: n } = pool @@ -115,119 +116,112 @@ export function calculateCpmmPurchase( return { shares, newPool, newP, fees } } -function computeK(y: number, n: number, p: number) { - return y ** p * n ** (1 - p) -} - -function sellSharesK( - y: number, - n: number, - p: number, - s: number, - outcome: 'YES' | 'NO', - b: number -) { - return outcome === 'YES' - ? computeK(y - b + s, n - b, p) - : computeK(y - b, n - b + s, p) -} - -function calculateCpmmShareValue( - contract: CPMMContract, - shares: number, +// Note: there might be a closed form solution for this. +// If so, feel free to switch out this implementation. +export function calculateCpmmAmountToProb( + state: CpmmState, + prob: number, outcome: 'YES' | 'NO' ) { - const { pool, p } = contract + if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity + if (outcome === 'NO') prob = 1 - prob - // Find bet amount that preserves k after selling shares. - const k = computeK(pool.YES, pool.NO, p) - const otherPool = outcome === 'YES' ? pool.NO : pool.YES + // First, find an upper bound that leads to a more extreme probability than prob. + let maxGuess = 10 + let newProb = 0 + do { + maxGuess *= 10 + newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, maxGuess) + } while (newProb < prob) - // Constrain the max sale value to the lessor of 1. shares and 2. the other pool. - // This is because 1. the max value per share is M$ 1, - // and 2. The other pool cannot go negative and the sale value is subtracted from it. - // (Without this, there are multiple solutions for the same k.) - let highAmount = Math.min(shares, otherPool) - let lowAmount = 0 - let mid = 0 - let kGuess = 0 - while (true) { - mid = lowAmount + (highAmount - lowAmount) / 2 + // Then, binary search for the amount that gets closest to prob. + const amount = binarySearch(0, maxGuess, (amount) => { + const newProb = getCpmmOutcomeProbabilityAfterBet(state, outcome, amount) + return newProb - prob + }) - // Break once we've reached max precision. - if (mid === lowAmount || mid === highAmount) break + return amount +} - kGuess = sellSharesK(pool.YES, pool.NO, p, shares, outcome, mid) - if (kGuess < k) { - highAmount = mid - } else { - lowAmount = mid - } - } - return mid +function calculateAmountToBuyShares( + state: CpmmState, + shares: number, + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] +) { + // Search for amount between bounds (0, shares). + // Min share price is M$0, and max is M$1 each. + return binarySearch(0, shares, (amount) => { + const { takers } = computeFills( + outcome, + amount, + state, + undefined, + unfilledBets + ) + + const totalShares = sumBy(takers, (taker) => taker.shares) + return totalShares - shares + }) } export function calculateCpmmSale( - contract: CPMMContract, + state: CpmmState, shares: number, - outcome: string + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] ) { if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') } - const saleValue = calculateCpmmShareValue( - contract, + const oppositeOutcome = outcome === 'YES' ? 'NO' : 'YES' + const buyAmount = calculateAmountToBuyShares( + state, shares, - outcome as 'YES' | 'NO' + oppositeOutcome, + unfilledBets ) - const fees = noFees + const { cpmmState, makers, takers, totalFees } = computeFills( + oppositeOutcome, + buyAmount, + state, + undefined, + unfilledBets + ) - // const { fees, remainingBet: saleValue } = getCpmmLiquidityFee( - // contract, - // rawSaleValue, - // outcome === 'YES' ? 'NO' : 'YES' - // ) + // Transform buys of opposite outcome into sells. + const saleTakers = takers.map((taker) => ({ + ...taker, + // You bought opposite shares, which combine with existing shares, removing them. + shares: -taker.shares, + // Opposite shares combine with shares you are selling for M$ of shares. + // You paid taker.amount for the opposite shares. + // Take the negative because this is money you gain. + amount: -(taker.shares - taker.amount), + isSale: true, + })) - const { pool } = contract - const { YES: y, NO: n } = pool + const saleValue = -sumBy(saleTakers, (taker) => taker.amount) - const { liquidityFee: fee } = fees - - const [newY, newN] = - outcome === 'YES' - ? [y + shares - saleValue + fee, n - saleValue + fee] - : [y - saleValue + fee, n + shares - saleValue + fee] - - if (newY < 0 || newN < 0) { - console.log('calculateCpmmSale', { - newY, - newN, - y, - n, - shares, - saleValue, - fee, - outcome, - }) - throw new Error('Cannot sell more than in pool') + return { + saleValue, + cpmmState, + fees: totalFees, + makers, + takers: saleTakers, } - - const postBetPool = { YES: newY, NO: newN } - - const { newPool, newP } = addCpmmLiquidity(postBetPool, contract.p, fee) - - return { saleValue, newPool, newP, fees } } export function getCpmmProbabilityAfterSale( - contract: CPMMContract, + state: CpmmState, shares: number, - outcome: 'YES' | 'NO' + outcome: 'YES' | 'NO', + unfilledBets: LimitBet[] ) { - const { newPool } = calculateCpmmSale(contract, shares, outcome) - return getCpmmProbability(newPool, contract.p) + const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + return getCpmmProbability(cpmmState.pool, cpmmState.p) } export function getCpmmLiquidity( @@ -271,22 +265,24 @@ const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { } export function getCpmmLiquidityPoolWeights( - contract: CPMMContract, - liquidities: LiquidityProvision[] + state: CpmmState, + liquidities: LiquidityProvision[], + excludeAntes: boolean ) { - const [antes, nonAntes] = partition(liquidities, (l) => !!l.isAnte) + const calcLiqudity = calculateLiquidityDelta(state.p) + const liquidityShares = liquidities.map(calcLiqudity) + const shareSum = sum(liquidityShares) - const calcLiqudity = calculateLiquidityDelta(contract.p) - const liquidityShares = nonAntes.map(calcLiqudity) - - const shareSum = sum(liquidityShares) + sum(antes.map(calcLiqudity)) - - const weights = liquidityShares.map((s, i) => ({ - weight: s / shareSum, - providerId: nonAntes[i].userId, + const weights = liquidityShares.map((shares, i) => ({ + weight: shares / shareSum, + providerId: liquidities[i].userId, })) - const userWeights = groupBy(weights, (w) => w.providerId) + const includedWeights = excludeAntes + ? weights.filter((_, i) => !liquidities[i].isAnte) + : weights + + const userWeights = groupBy(includedWeights, (w) => w.providerId) const totalUserWeights = mapValues(userWeights, (userWeight) => sumBy(userWeight, (w) => w.weight) ) @@ -295,11 +291,12 @@ export function getCpmmLiquidityPoolWeights( export function getUserLiquidityShares( userId: string, - contract: CPMMContract, - liquidities: LiquidityProvision[] + state: CpmmState, + liquidities: LiquidityProvision[], + excludeAntes: boolean ) { - const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes) const userWeight = weights[userId] ?? 0 - return mapValues(contract.pool, (shares) => userWeight * shares) + return mapValues(state.pool, (shares) => userWeight * shares) } diff --git a/common/calculate-dpm.ts b/common/calculate-dpm.ts index 497f1155..d38a7b67 100644 --- a/common/calculate-dpm.ts +++ b/common/calculate-dpm.ts @@ -2,7 +2,7 @@ import { cloneDeep, range, sum, sumBy, sortBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { DPMContract, DPMBinaryContract, NumericContract } from './contract' import { DPM_FEES } from './fees' -import { normpdf } from '../common/util/math' +import { normpdf } from './util/math' import { addObjects } from './util/object' export function getDpmProbability(totalShares: { [outcome: string]: number }) { diff --git a/common/calculate.ts b/common/calculate.ts index a0574c10..d25fd313 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,5 +1,5 @@ import { maxBy } from 'lodash' -import { Bet } from './bet' +import { Bet, LimitBet } from './bet' import { calculateCpmmSale, getCpmmProbability, @@ -18,15 +18,26 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, + MultipleChoiceContract, +} from './contract' +import { floatingEqual } from './util/math' -export function getProbability(contract: BinaryContract) { +export function getProbability( + contract: BinaryContract | PseudoNumericContract +) { return contract.mechanism === 'cpmm-1' ? getCpmmProbability(contract.pool, contract.p) : getDpmProbability(contract.totalShares) } -export function getInitialProbability(contract: BinaryContract) { +export function getInitialProbability( + contract: BinaryContract | PseudoNumericContract +) { if (contract.initialProbability) return contract.initialProbability if (contract.mechanism === 'dpm-2' || (contract as any).totalShares) @@ -64,9 +75,20 @@ export function calculateShares( : calculateDpmShares(contract.totalShares, bet, betChoice) } -export function calculateSaleAmount(contract: Contract, bet: Bet) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' - ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue +export function calculateSaleAmount( + contract: Contract, + bet: Bet, + unfilledBets: LimitBet[] +) { + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') + ? calculateCpmmSale( + contract, + Math.abs(bet.shares), + bet.outcome as 'YES' | 'NO', + unfilledBets + ).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -79,15 +101,23 @@ export function calculatePayoutAfterCorrectBet(contract: Contract, bet: Bet) { export function getProbabilityAfterSale( contract: Contract, outcome: string, - shares: number + shares: number, + unfilledBets: LimitBet[] ) { return contract.mechanism === 'cpmm-1' - ? getCpmmProbabilityAfterSale(contract, shares, outcome as 'YES' | 'NO') + ? getCpmmProbabilityAfterSale( + contract, + shares, + outcome as 'YES' | 'NO', + unfilledBets + ) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) } export function calculatePayout(contract: Contract, bet: Bet, outcome: string) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -96,7 +126,9 @@ export function resolvedPayout(contract: Contract, bet: Bet) { const outcome = contract.resolution if (!outcome) throw new Error('Contract not resolved') - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateFixedPayout(contract, bet, outcome) : calculateDpmPayout(contract, bet, outcome) } @@ -143,7 +175,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profitPercent = (profit / totalInvested) * 100 const hasShares = Object.values(totalShares).some( - (shares) => shares > 0 + (shares) => !floatingEqual(shares, 0) ) return { @@ -169,7 +201,9 @@ export function getContractBetNullMetrics() { } } -export function getTopAnswer(contract: FreeResponseContract) { +export function getTopAnswer( + contract: FreeResponseContract | MultipleChoiceContract +) { const { answers } = contract const top = maxBy( answers?.map((answer) => ({ diff --git a/common/categories.ts b/common/categories.ts index 2bd6d25a..f302e3f2 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -1,5 +1,7 @@ import { difference } from 'lodash' +export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' + export const CATEGORIES = { politics: 'Politics', technology: 'Technology', @@ -24,9 +26,18 @@ export const TO_CATEGORY = Object.fromEntries( export const CATEGORY_LIST = Object.keys(CATEGORIES) -export const EXCLUDED_CATEGORIES: category[] = ['fun', 'manifold', 'personal'] +export const EXCLUDED_CATEGORIES: category[] = [ + 'fun', + 'manifold', + 'personal', + 'covid', + 'gaming', + 'crypto', +] -export const DEFAULT_CATEGORIES = difference( - CATEGORY_LIST, - EXCLUDED_CATEGORIES -) +export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) + +export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({ + slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX, + name: CATEGORIES[c as category], +})) diff --git a/common/challenge.ts b/common/challenge.ts new file mode 100644 index 00000000..9bac8c08 --- /dev/null +++ b/common/challenge.ts @@ -0,0 +1,65 @@ +import { IS_PRIVATE_MANIFOLD } from './envs/constants' + +export type Challenge = { + // The link to send: https://manifold.markets/challenges/username/market-slug/{slug} + // Also functions as the unique id for the link. + slug: string + + // The user that created the challenge. + creatorId: string + creatorUsername: string + creatorName: string + creatorAvatarUrl?: string + + // Displayed to people claiming the challenge + message: string + + // How much to put up + creatorAmount: number + + // YES or NO for now + creatorOutcome: string + + // Different than the creator + acceptorOutcome: string + acceptorAmount: number + + // The probability the challenger thinks + creatorOutcomeProb: number + + contractId: string + contractSlug: string + contractQuestion: string + contractCreatorUsername: string + + createdTime: number + // If null, the link is valid forever + expiresTime: number | null + + // How many times the challenge can be used + maxUses: number + + // Used for simpler caching + acceptedByUserIds: string[] + // Successful redemptions of the link + acceptances: Acceptance[] + + // TODO: will have to fill this on resolve contract + isResolved: boolean + resolutionOutcome?: string +} + +export type Acceptance = { + // User that accepted the challenge + userId: string + userUsername: string + userName: string + userAvatarUrl: string + + // The ID of the successful bet that tracks the money moved + betId: string + + createdTime: number +} + +export const CHALLENGES_ENABLED = !IS_PRIVATE_MANIFOLD diff --git a/common/charity.ts b/common/charity.ts index 249bcc51..c18c6ba1 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -169,7 +169,7 @@ export const charities: Charity[] = [ { name: "Founder's Pledge Climate Change Fund", website: 'https://founderspledge.com/funds/climate-change-fund', - photo: 'https://i.imgur.com/ZAhzHu4.png', + photo: 'https://i.imgur.com/9turaJW.png', preview: 'The Climate Change Fund aims to sustainably reach net-zero emissions globally, while still allowing growth to free millions from energy poverty.', description: `The Climate Change Fund aims to sustainably reach net-zero emissions globally. @@ -183,7 +183,7 @@ export const charities: Charity[] = [ { name: "Founder's Pledge Patient Philanthropy Fund", website: 'https://founderspledge.com/funds/patient-philanthropy-fund', - photo: 'https://i.imgur.com/ZAhzHu4.png', + photo: 'https://i.imgur.com/LLR6CI6.png', preview: 'The Patient Philanthropy Project aims to safeguard and benefit the long-term future of humanity', description: `The Patient Philanthropy Project focuses on how we can collectively grow our resources to support the long-term flourishing of humanity. It addresses a crucial gap: as a society, we spend much too little on safeguarding and benefiting future generations. In fact, we spend more money on ice cream each year than we do on preventing our own extinction. However, people in the future - who do not have a voice in their future survival or environment - matter. Lots of them may yet come into existence and we have the ability to positively affect their lives now, if only by making sure we avoid major catastrophes that could destroy our common future. @@ -300,10 +300,29 @@ Future plans: We expect to focus on similar theoretical problems in alignment un name: 'Wild Animal Initiative', website: 'https://www.wildanimalinitiative.org/', ein: '82-2281466', + tags: ['Featured'] as CharityTag[], photo: 'https://i.imgur.com/bOVUnDm.png', - preview: 'We want to make life better for wild animals.', - description: - 'Wild Animal Initiative (WAI) currently operates in the U.S., where they work to strengthen the animal advocacy movement through creating an academic field dedicated to wild animal welfare. They compile literature reviews, write theoretical and opinion articles, and publish research results on their website and/or in peer-reviewed journals. WAI focuses on identifying and sharing possible research avenues and connecting with more established fields. They also work with researchers from various academic and non-academic institutions to identify potential collaborators, and they recently launched a grant assistance program.', + preview: + 'Our mission is to understand and improve the lives of wild animals.', + description: `Although the natural world is a source of great beauty and happiness, vast numbers of animals routinely face serious challenges such as disease, hunger, or natural disasters. There is no “one-size-fits-all” solution to these threats. However, even as we recognize that improving the welfare of free-ranging wild animals is difficult, we believe that humans have a responsibility to help whenever we can. + +Our staff explores how humans can beneficially coexist with animals through the lens of wild animal welfare. + +We respect wild animals as individuals with their own needs and preferences, rather than seeing them as mere parts of ecosystems. But this approach demands a richer understanding of wild animals’ lives. + +We want to take a proactive approach to managing the welfare benefits, threats, and uncertainties that are inherent to complex natural and urban environments. Yet, to take action safely, we must conduct research to understand the impacts of our actions. The transdisciplinary perspective of wild animal welfare draws upon ethics, ecology, and animal welfare science to gather the knowledge we need, facilitating evidence-based improvements to wild animals’ quality of life. + +Without sufficient public interest or research activity, solutions to the problems wild animals face will go undiscovered. + +Wild Animal Initiative currently focuses on helping scientists, grantors, and decision-makers investigate important and understudied questions about wild animal welfare. Our work catalyzes research and applied projects that will open the door to a clearer picture of wild animals’ needs and how to enhance their well-being. Ultimately, we envision a world in which people actively choose to help wild animals — and have the knowledge they need to do so responsibly.`, + }, + { + name: 'FYXX Foundation', + website: 'https://www.fyxxfoundation.org/', + photo: 'https://i.imgur.com/ROmWO7m.png', + preview: + 'FYXX Foundation: wildlife population management, without killing.', + description: `The future of our planet depends on the innovations of today, and the health of our wildlife are the first indication of our successful stewardship, which we believe can be improved by safe population management utilizing fertility control instead of poison and culling.`, }, { name: 'New Incentives', @@ -516,6 +535,36 @@ The American Civil Liberties Union is our nation's guardian of liberty, working The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`, }, + { + name: 'The Center for Election Science', + website: 'https://electionscience.org/', + photo: 'https://i.imgur.com/WvdHHZa.png', + preview: + 'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.', + description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform. + +Our Mission — To empower people with voting methods that strengthen democracy. + +Our Vision — A world where democracies thrive because voters’ voices are heard. + +With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research. + +The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`, + }, + { + name: 'Founders Pledge Global Health and Development Fund', + website: 'https://founderspledge.com/funds/global-health-and-development', + photo: 'https://i.imgur.com/EXbxH7T.png', + preview: + 'Tackling the vast global inequalities in health, wealth and opportunity', + description: `Nearly half the world lives on less than $2.50 a day, yet giving by the world’s richest often overlooks the world’s poorest and most vulnerable. Despite the average American household being richer than 90% of the rest of the world, only 6% of US charitable giving goes to charities which work internationally. + +This Fund is focused on helping those who need it most, wherever that help can make the biggest difference. By building a mixed portfolio of direct and indirect interventions, such as policy work, we aim to: + +Improve the lives of the world's most vulnerable people. +Reduce the number of easily preventable deaths worldwide. +Work towards sustainable, systemic change.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/comment.ts b/common/comment.ts index 0d0c4daf..a217b292 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,3 +1,5 @@ +import type { JSONContent } from '@tiptap/core' + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. export type Comment = { @@ -9,7 +11,9 @@ export type Comment = { replyToCommentId?: string userId: string - text: string + /** @deprecated - content now stored as JSON in content*/ + text?: string + content: JSONContent createdTime: number // Denormalized, for rendering comments diff --git a/common/contract.ts b/common/contract.ts index dc91a20e..c414a332 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,13 +1,22 @@ import { Answer } from './answer' import { Fees } from './fees' +import { JSONContent } from '@tiptap/core' +import { GroupLink } from 'common/group' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | FreeResponse | Numeric +export type AnyOutcomeType = + | Binary + | MultipleChoice + | PseudoNumeric + | FreeResponse + | Numeric export type AnyContractType = | (CPMM & Binary) + | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) + | (DPM & MultipleChoice) export type Contract = { id: string @@ -19,7 +28,7 @@ export type Contract = { creatorAvatarUrl?: string question: string - description: string // More info about what the contract is about + description: string | JSONContent // More info about what the contract is about tags: string[] lowercaseTags: string[] visibility: 'public' | 'unlisted' @@ -33,7 +42,7 @@ export type Contract = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market resolution?: string - resolutionProbability?: number, + resolutionProbability?: number closeEmailsSent?: number @@ -42,11 +51,19 @@ export type Contract = { volume7Days: number collectedFees: Fees + + groupSlugs?: string[] + groupLinks?: GroupLink[] + uniqueBettorIds?: string[] + uniqueBettorCount?: number + popularityScore?: number } & T export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse +export type MultipleChoiceContract = Contract & MultipleChoice export type DPMContract = Contract & DPM export type CPMMContract = Contract & CPMM export type DPMBinaryContract = BinaryContract & DPM @@ -75,6 +92,18 @@ export type Binary = { resolution?: resolution } +export type PseudoNumeric = { + outcomeType: 'PSEUDO_NUMERIC' + min: number + max: number + isLogScale: boolean + resolutionValue?: number + + // same as binary market; map everything to probability + initialProbability: number + resolutionProbability?: number +} + export type FreeResponse = { outcomeType: 'FREE_RESPONSE' answers: Answer[] // Used for outcomeType 'FREE_RESPONSE'. @@ -82,6 +111,13 @@ export type FreeResponse = { resolutions?: { [outcome: string]: number } // Used for MKT resolution. } +export type MultipleChoice = { + outcomeType: 'MULTIPLE_CHOICE' + answers: Answer[] + resolution?: string | 'MKT' | 'CANCEL' + resolutions?: { [outcome: string]: number } // Used for MKT resolution. +} + export type Numeric = { outcomeType: 'NUMERIC' bucketCount: number @@ -94,10 +130,16 @@ export type Numeric = { export type outcomeType = AnyOutcomeType['outcomeType'] export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const -export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'NUMERIC'] as const +export const OUTCOME_TYPES = [ + 'BINARY', + 'MULTIPLE_CHOICE', + 'FREE_RESPONSE', + 'PSEUDO_NUMERIC', + 'NUMERIC', +] as const export const MAX_QUESTION_LENGTH = 480 -export const MAX_DESCRIPTION_LENGTH = 10000 +export const MAX_DESCRIPTION_LENGTH = 16000 export const MAX_TAG_LENGTH = 60 export const CPMM_MIN_POOL_QTY = 0.01 diff --git a/common/envs/constants.ts b/common/envs/constants.ts index c03c44bc..48f9bf63 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -25,6 +25,10 @@ export function isAdmin(email: string) { return ENV_CONFIG.adminEmails.includes(email) } +export function isManifoldId(userId: string) { + return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' +} + export const DOMAIN = ENV_CONFIG.domain export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId @@ -34,5 +38,9 @@ export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' ) +// Vercel deployments, used for testing. +export const CORS_ORIGIN_VERCEL = new RegExp( + '^https?://[a-zA-Z0-9\\-]+' + escapeRegExp('mantic.vercel.app') + '$' +) // Any localhost server on any port export const CORS_ORIGIN_LOCALHOST = /^http:\/\/localhost:\d+$/ diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 3c062472..719de36e 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod' export const DEV_CONFIG: EnvConfig = { ...PROD_CONFIG, + domain: 'dev.manifold.markets', firebaseConfig: { apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', authDomain: 'dev-mantic-markets.firebaseapp.com', diff --git a/common/envs/prod.ts b/common/envs/prod.ts index f5a0e55e..5bd12095 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -18,13 +18,18 @@ export type EnvConfig = { faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] + + // Currency controls + fixedAnte?: number + startingBalance?: number + referralBonus?: number } type FirebaseConfig = { apiKey: string authDomain: string projectId: string - region: string + region?: string storageBucket: string messagingSenderId: string appId: string diff --git a/common/group.ts b/common/group.ts index f06fdd15..7d3215ae 100644 --- a/common/group.ts +++ b/common/group.ts @@ -9,7 +9,21 @@ export type Group = { memberIds: string[] // User ids anyoneCanJoin: boolean contractIds: string[] + + chatDisabled?: boolean + mostRecentChatActivityTime?: number + mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 +export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] +export const GROUP_CHAT_SLUG = 'chat' + +export type GroupLink = { + slug: string + name: string + groupId: string + createdTime: number + userId?: string +} diff --git a/common/new-bet.ts b/common/new-bet.ts index e62c316f..7085a4fe 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,4 +1,6 @@ -import { Bet, NumericBet } from './bet' +import { sortBy, sum, sumBy } from 'lodash' + +import { Bet, fill, LimitBet, NumericBet } from './bet' import { calculateDpmShares, getDpmProbability, @@ -6,20 +8,32 @@ import { getNumericBets, calculateNumericDpmShares, } from './calculate-dpm' -import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' +import { + calculateCpmmAmountToProb, + calculateCpmmPurchase, + CpmmState, + getCpmmProbability, +} from './calculate-cpmm' import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, + MultipleChoiceContract, NumericContract, + PseudoNumericContract, } from './contract' import { noFees } from './fees' -import { addObjects } from './util/object' +import { addObjects, removeUndefinedProps } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' +import { + floatingEqual, + floatingGreaterEqual, + floatingLesserEqual, +} from './util/math' -export type CandidateBet = Omit +export type CandidateBet = Omit export type BetInfo = { - newBet: CandidateBet + newBet: CandidateBet newPool?: { [outcome: string]: number } newTotalShares?: { [outcome: string]: number } newTotalBets?: { [outcome: string]: number } @@ -27,37 +41,236 @@ export type BetInfo = { newP?: number } -export const getNewBinaryCpmmBetInfo = ( - outcome: 'YES' | 'NO', +const computeFill = ( amount: number, - contract: CPMMBinaryContract + outcome: 'YES' | 'NO', + limitProb: number | undefined, + cpmmState: CpmmState, + matchedBet: LimitBet | undefined ) => { - const { shares, newPool, newP, fees } = calculateCpmmPurchase( - contract, - amount, - outcome - ) + const prob = getCpmmProbability(cpmmState.pool, cpmmState.p) - const { pool, p, totalLiquidity } = contract - const probBefore = getCpmmProbability(pool, p) - const probAfter = getCpmmProbability(newPool, newP) - - const newBet: CandidateBet = { - contractId: contract.id, - amount, - shares, - outcome, - fees, - loanAmount: 0, - probBefore, - probAfter, - createdTime: Date.now(), + if ( + limitProb !== undefined && + (outcome === 'YES' + ? floatingGreaterEqual(prob, limitProb) && + (matchedBet?.limitProb ?? 1) > limitProb + : floatingLesserEqual(prob, limitProb) && + (matchedBet?.limitProb ?? 0) < limitProb) + ) { + // No fill. + return undefined } - const { liquidityFee } = fees - const newTotalLiquidity = (totalLiquidity ?? 0) + liquidityFee + const timestamp = Date.now() - return { newBet, newPool, newP, newTotalLiquidity } + if ( + !matchedBet || + (outcome === 'YES' + ? !floatingGreaterEqual(prob, matchedBet.limitProb) + : !floatingLesserEqual(prob, matchedBet.limitProb)) + ) { + // Fill from pool. + const limit = !matchedBet + ? limitProb + : outcome === 'YES' + ? Math.min(matchedBet.limitProb, limitProb ?? 1) + : Math.max(matchedBet.limitProb, limitProb ?? 0) + + const buyAmount = + limit === undefined + ? amount + : Math.min(amount, calculateCpmmAmountToProb(cpmmState, limit, outcome)) + + const { shares, newPool, newP, fees } = calculateCpmmPurchase( + cpmmState, + buyAmount, + outcome + ) + const newState = { pool: newPool, p: newP } + + return { + maker: { + matchedBetId: null, + shares, + amount: buyAmount, + state: newState, + fees, + timestamp, + }, + taker: { + matchedBetId: null, + shares, + amount: buyAmount, + timestamp, + }, + } + } + + // Fill from matchedBet. + const matchRemaining = matchedBet.orderAmount - matchedBet.amount + const shares = Math.min( + amount / + (outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb), + matchRemaining / + (outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb) + ) + + const maker = { + bet: matchedBet, + matchedBetId: 'taker', + amount: + shares * + (outcome === 'YES' ? 1 - matchedBet.limitProb : matchedBet.limitProb), + shares, + timestamp, + } + const taker = { + matchedBetId: matchedBet.id, + amount: + shares * + (outcome === 'YES' ? matchedBet.limitProb : 1 - matchedBet.limitProb), + shares, + timestamp, + } + return { maker, taker } +} + +export const computeFills = ( + outcome: 'YES' | 'NO', + betAmount: number, + state: CpmmState, + limitProb: number | undefined, + unfilledBets: LimitBet[] +) => { + if (isNaN(betAmount)) { + throw new Error('Invalid bet amount: ${betAmount}') + } + if (isNaN(limitProb ?? 0)) { + throw new Error('Invalid limitProb: ${limitProb}') + } + + const sortedBets = sortBy( + unfilledBets.filter((bet) => bet.outcome !== outcome), + (bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb), + (bet) => bet.createdTime + ) + + const takers: fill[] = [] + const makers: { + bet: LimitBet + amount: number + shares: number + timestamp: number + }[] = [] + + let amount = betAmount + let cpmmState = { pool: state.pool, p: state.p } + let totalFees = noFees + + let i = 0 + while (true) { + const matchedBet: LimitBet | undefined = sortedBets[i] + const fill = computeFill(amount, outcome, limitProb, cpmmState, matchedBet) + if (!fill) break + + const { taker, maker } = fill + + if (maker.matchedBetId === null) { + // Matched against pool. + cpmmState = maker.state + totalFees = addObjects(totalFees, maker.fees) + takers.push(taker) + } else { + // Matched against bet. + takers.push(taker) + makers.push(maker) + i++ + } + + amount -= taker.amount + + if (floatingEqual(amount, 0)) break + } + + return { takers, makers, totalFees, cpmmState } +} + +export const getBinaryCpmmBetInfo = ( + outcome: 'YES' | 'NO', + betAmount: number, + contract: CPMMBinaryContract | PseudoNumericContract, + limitProb: number | undefined, + unfilledBets: LimitBet[] +) => { + const { pool, p } = contract + const { takers, makers, cpmmState, totalFees } = computeFills( + outcome, + betAmount, + { pool, p }, + limitProb, + unfilledBets + ) + const probBefore = getCpmmProbability(contract.pool, contract.p) + const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) + + const takerAmount = sumBy(takers, 'amount') + const takerShares = sumBy(takers, 'shares') + const isFilled = floatingEqual(betAmount, takerAmount) + + const newBet: CandidateBet = removeUndefinedProps({ + orderAmount: betAmount, + amount: takerAmount, + shares: takerShares, + limitProb, + isFilled, + isCancelled: false, + fills: takers, + contractId: contract.id, + outcome, + probBefore, + probAfter, + loanAmount: 0, + createdTime: Date.now(), + fees: totalFees, + }) + + const { liquidityFee } = totalFees + const newTotalLiquidity = (contract.totalLiquidity ?? 0) + liquidityFee + + return { + newBet, + newPool: cpmmState.pool, + newP: cpmmState.p, + newTotalLiquidity, + makers, + } +} + +export const getBinaryBetStats = ( + outcome: 'YES' | 'NO', + betAmount: number, + contract: CPMMBinaryContract | PseudoNumericContract, + limitProb: number, + unfilledBets: LimitBet[] +) => { + const { newBet } = getBinaryCpmmBetInfo( + outcome, + betAmount ?? 0, + contract, + limitProb, + unfilledBets as LimitBet[] + ) + const remainingMatched = + ((newBet.orderAmount ?? 0) - newBet.amount) / + (outcome === 'YES' ? limitProb : 1 - limitProb) + const currentPayout = newBet.shares + remainingMatched + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + + const totalFees = sum(Object.values(newBet.fees)) + + return { currentPayout, currentReturn, totalFees, newBet } } export const getNewBinaryDpmBetInfo = ( @@ -91,7 +304,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: CandidateBet = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount: 0, @@ -109,7 +322,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract, ) => { const { pool, totalShares, totalBets } = contract @@ -127,7 +340,7 @@ export const getNewMultiBetInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: CandidateBet = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount: 0, diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..ad7dc5a2 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -5,12 +5,15 @@ import { CPMM, DPM, FreeResponse, + MultipleChoice, Numeric, outcomeType, + PseudoNumeric, } from './contract' import { User } from './user' -import { parseTags } from './util/parse' +import { parseTags, richTextToString } from './util/parse' import { removeUndefinedProps } from './util/object' +import { JSONContent } from '@tiptap/core' export function getNewContract( id: string, @@ -18,7 +21,7 @@ export function getNewContract( creator: User, question: string, outcomeType: outcomeType, - description: string, + description: JSONContent, initialProb: number, ante: number, closeTime: number, @@ -27,18 +30,30 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean, + + // for multiple choice + answers: string[] ) { const tags = parseTags( - `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` + [ + question, + richTextToString(description), + ...extraTags.map((tag) => `#${tag}`), + ].join(' ') ) const lowercaseTags = tags.map((tag) => tag.toLowerCase()) const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) + : outcomeType === 'PSEUDO_NUMERIC' + ? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) : outcomeType === 'NUMERIC' ? getNumericProps(ante, bucketCount, min, max) + : outcomeType === 'MULTIPLE_CHOICE' + ? getMultipleChoiceProps(ante, answers) : getFreeAnswerProps(ante) const contract: Contract = removeUndefinedProps({ @@ -52,7 +67,7 @@ export function getNewContract( creatorAvatarUrl: creator.avatarUrl, question: question.trim(), - description: description.trim(), + description, tags, lowercaseTags, visibility: 'public', @@ -111,6 +126,24 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { return system } +const getPseudoNumericCpmmProps = ( + initialProb: number, + ante: number, + min: number, + max: number, + isLogScale: boolean +) => { + const system: CPMM & PseudoNumeric = { + ...getBinaryCpmmProps(initialProb, ante), + outcomeType: 'PSEUDO_NUMERIC', + min, + max, + isLogScale, + } + + return system +} + const getFreeAnswerProps = (ante: number) => { const system: DPM & FreeResponse = { mechanism: 'dpm-2', @@ -124,6 +157,26 @@ const getFreeAnswerProps = (ante: number) => { return system } +const getMultipleChoiceProps = (ante: number, answers: string[]) => { + const numAnswers = answers.length + const betAnte = ante / numAnswers + const betShares = Math.sqrt(ante ** 2 / numAnswers) + + const defaultValues = (x: any) => + Object.fromEntries(range(0, numAnswers).map((k) => [k, x])) + + const system: DPM & MultipleChoice = { + mechanism: 'dpm-2', + outcomeType: 'MULTIPLE_CHOICE', + pool: defaultValues(betAnte), + totalShares: defaultValues(betShares), + totalBets: defaultValues(betAnte), + answers: [], + } + + return system +} + const getNumericProps = ( ante: number, bucketCount: number, diff --git a/common/notification.ts b/common/notification.ts index 919cf917..fa4cd90a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -22,6 +22,8 @@ export type Notification = { sourceSlug?: string sourceTitle?: string + + isSeenOnHref?: string } export type notification_source_types = | 'contract' @@ -33,6 +35,9 @@ export type notification_source_types = | 'tip' | 'admin_message' | 'group' + | 'user' + | 'bonus' + | 'challenge' export type notification_source_update_types = | 'created' @@ -53,3 +58,11 @@ export type notification_reason_types = | 'on_new_follow' | 'you_follow_user' | 'added_you_to_group' + | 'you_referred_user' + | 'user_joined_to_bet_on_your_market' + | 'unique_bettors_on_your_contract' + | 'on_group_you_are_member_of' + | 'tip_received' + | 'bet_fill' + | 'user_joined_from_your_group_invite' + | 'challenge_accepted' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index ef364b74..f399aa5a 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' +export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 diff --git a/common/package.json b/common/package.json index 1bd67851..955e9662 100644 --- a/common/package.json +++ b/common/package.json @@ -3,10 +3,16 @@ "version": "1.0.0", "private": true, "scripts": { - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx eslint . --max-warnings 0" }, "sideEffects": false, "dependencies": { + "@tiptap/core": "2.0.0-beta.181", + "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/extension-link": "2.0.0-beta.43", + "@tiptap/extension-mention": "2.0.0-beta.102", + "@tiptap/starter-kit": "2.0.0-beta.190", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index 6cecddff..7d4a0185 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -2,7 +2,11 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' import { deductDpmFees, getDpmProbability } from './calculate-dpm' -import { DPMContract, FreeResponseContract } from './contract' +import { + DPMContract, + FreeResponseContract, + MultipleChoiceContract, +} from './contract' import { DPM_CREATOR_FEE, DPM_FEES, DPM_PLATFORM_FEE } from './fees' import { addObjects } from './util/object' @@ -180,7 +184,7 @@ export const getDpmMktPayouts = ( export const getPayoutsMultiOutcome = ( resolutions: { [outcome: string]: number }, - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { const poolTotal = sum(Object.values(contract.pool)) diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts index 4e06042b..4b8de85a 100644 --- a/common/payouts-fixed.ts +++ b/common/payouts-fixed.ts @@ -72,7 +72,7 @@ export const getLiquidityPoolPayouts = ( const { pool } = contract const finalPool = pool[outcome] - const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) return Object.entries(weights).map(([providerId, weight]) => ({ userId: providerId, @@ -123,7 +123,7 @@ export const getLiquidityPoolProbPayouts = ( const { pool } = contract const finalPool = p * pool.YES + (1 - p) * pool.NO - const weights = getCpmmLiquidityPoolWeights(contract, liquidities) + const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) return Object.entries(weights).map(([providerId, weight]) => ({ userId: providerId, diff --git a/common/payouts.ts b/common/payouts.ts index a3f105cf..cc6c338d 100644 --- a/common/payouts.ts +++ b/common/payouts.ts @@ -1,7 +1,12 @@ import { sumBy, groupBy, mapValues } from 'lodash' import { Bet, NumericBet } from './bet' -import { Contract, CPMMBinaryContract, DPMContract } from './contract' +import { + Contract, + CPMMBinaryContract, + DPMContract, + PseudoNumericContract, +} from './contract' import { Fees } from './fees' import { LiquidityProvision } from './liquidity-provision' import { @@ -48,15 +53,19 @@ export type PayoutInfo = { export const getPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: Contract, bets: Bet[], liquidities: LiquidityProvision[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { - if (contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY') { + if ( + contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') + ) { return getFixedPayouts( outcome, contract, @@ -67,16 +76,16 @@ export const getPayouts = ( } return getDpmPayouts( outcome, - resolutions, contract, bets, + resolutions, resolutionProbability ) } export const getFixedPayouts = ( outcome: string | undefined, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, bets: Bet[], liquidities: LiquidityProvision[], resolutionProbability?: number @@ -100,14 +109,15 @@ export const getFixedPayouts = ( export const getDpmPayouts = ( outcome: string | undefined, - resolutions: { - [outcome: string]: number - }, contract: DPMContract, bets: Bet[], + resolutions?: { + [outcome: string]: number + }, resolutionProbability?: number ): PayoutInfo => { const openBets = bets.filter((b) => !b.isSold && !b.sale) + const { outcomeType } = contract switch (outcome) { case 'YES': @@ -115,15 +125,16 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' - ? getPayoutsMultiOutcome(resolutions, contract, openBets) + return outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': case undefined: return getDpmCancelPayouts(contract, openBets) default: - if (contract.outcomeType === 'NUMERIC') + if (outcomeType === 'NUMERIC') return getNumericDpmPayouts(outcome, contract, openBets as NumericBet[]) // Outcome is a free response answer id. diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts new file mode 100644 index 00000000..ca62a80e --- /dev/null +++ b/common/pseudo-numeric.ts @@ -0,0 +1,48 @@ +import { BinaryContract, PseudoNumericContract } from './contract' +import { formatLargeNumber, formatPercent } from './util/format' + +export function formatNumericProbability( + p: number, + contract: PseudoNumericContract +) { + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) +} + +export const getMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return p + + const { min, max, isLogScale } = contract + + if (isLogScale) { + const logValue = p * Math.log10(max - min + 1) + return 10 ** logValue + min - 1 + } + + return p * (max - min) + min + } + +export const getFormattedMappedValue = + (contract: PseudoNumericContract | BinaryContract) => (p: number) => { + if (contract.outcomeType === 'BINARY') return formatPercent(p) + + const value = getMappedValue(contract)(p) + return formatLargeNumber(value) + } + +export const getPseudoProbability = ( + value: number, + min: number, + max: number, + isLogScale = false +) => { + if (value < min) return 0 + if (value > max) return 1 + + if (isLogScale) { + return Math.log10(value - min + 1) / Math.log10(max - min + 1) + } + + return (value - min) / (max - min) +} diff --git a/common/redeem.ts b/common/redeem.ts new file mode 100644 index 00000000..4a4080f6 --- /dev/null +++ b/common/redeem.ts @@ -0,0 +1,54 @@ +import { partition, sumBy } from 'lodash' + +import { Bet } from './bet' +import { getProbability } from './calculate' +import { CPMMContract } from './contract' +import { noFees } from './fees' +import { CandidateBet } from './new-bet' + +type RedeemableBet = Pick + +export const getRedeemableAmount = (bets: RedeemableBet[]) => { + const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') + const yesShares = sumBy(yesBets, (b) => b.shares) + const noShares = sumBy(noBets, (b) => b.shares) + const shares = Math.max(Math.min(yesShares, noShares), 0) + const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const loanPayment = Math.min(loanAmount, shares) + const netAmount = shares - loanPayment + return { shares, loanPayment, netAmount } +} + +export const getRedemptionBets = ( + shares: number, + loanPayment: number, + contract: CPMMContract +) => { + const p = getProbability(contract) + const createdTime = Date.now() + const yesBet: CandidateBet = { + contractId: contract.id, + amount: p * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'YES', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + const noBet: CandidateBet = { + contractId: contract.id, + amount: (1 - p) * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'NO', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + return [yesBet, noBet] +} diff --git a/common/scoring.ts b/common/scoring.ts index d4e40267..39a342fd 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -42,10 +42,10 @@ export function scoreUsersByContract(contract: Contract, bets: Bet[]) { ) const { payouts: resolvePayouts } = getPayouts( resolution as string, - {}, contract, openBets, [], + {}, resolutionProb ) diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 6d487ff2..e1fd9c5d 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -1,4 +1,4 @@ -import { Bet } from './bet' +import { Bet, LimitBet } from './bet' import { calculateDpmShareValue, deductDpmFees, @@ -7,6 +7,7 @@ import { import { calculateCpmmSale, getCpmmProbability } from './calculate-cpmm' import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' +import { sumBy } from 'lodash' export type CandidateBet = Omit @@ -78,19 +79,24 @@ export const getCpmmSellBetInfo = ( shares: number, outcome: 'YES' | 'NO', contract: CPMMContract, - prevLoanAmount: number + prevLoanAmount: number, + unfilledBets: LimitBet[] ) => { const { pool, p } = contract - const { saleValue, newPool, newP, fees } = calculateCpmmSale( + const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( contract, shares, - outcome + outcome, + unfilledBets ) const loanPaid = Math.min(prevLoanAmount, saleValue) const probBefore = getCpmmProbability(pool, p) - const probAfter = getCpmmProbability(newPool, p) + const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) + + const takerAmount = sumBy(takers, 'amount') + const takerShares = sumBy(takers, 'shares') console.log( 'SELL M$', @@ -104,20 +110,26 @@ export const getCpmmSellBetInfo = ( const newBet: CandidateBet = { contractId: contract.id, - amount: -saleValue, - shares: -shares, + amount: takerAmount, + shares: takerShares, outcome, probBefore, probAfter, createdTime: Date.now(), loanAmount: -loanPaid, fees, + fills: takers, + isFilled: true, + isCancelled: false, + orderAmount: takerAmount, } return { newBet, - newPool, - newP, + newPool: cpmmState.pool, + newP: cpmmState.p, fees, + makers, + takers, } } diff --git a/common/tsconfig.json b/common/tsconfig.json index 158a5218..62a5c745 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, + "module": "commonjs", "moduleResolution": "node", "noImplicitReturns": true, "outDir": "lib", diff --git a/common/txn.ts b/common/txn.ts index 25d4a1c3..701b67fe 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,7 +16,8 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -35,8 +36,9 @@ type Tip = { toType: 'USER' category: 'TIP' data: { - contractId: string commentId: string + contractId?: string + groupId?: string } } @@ -46,6 +48,19 @@ type Manalink = { category: 'MANALINK' } +type Referral = { + fromType: 'BANK' + toType: 'USER' + category: 'REFERRAL' +} + +type Bonus = { + fromType: 'BANK' + toType: 'USER' + category: 'UNIQUE_BETTOR_BONUS' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink +export type ReferralTxn = Txn & Referral diff --git a/common/user.ts b/common/user.ts index 298fee56..2aeb7122 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { ENV_CONFIG } from './envs/constants' + export type User = { id: string createdTime: number @@ -33,10 +35,18 @@ export type User = { followerCountCached: number followedCategories?: string[] + + referredByUserId?: string + referredByContractId?: string + referredByGroupId?: string + lastPingTime?: number + shouldShowWelcome?: boolean } -export const STARTING_BALANCE = 1000 -export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person +export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 +// for sus users, i.e. multiple sign ups for same person +export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 +export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 export type PrivateUser = { id: string // same as User.id @@ -47,6 +57,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string apiKey?: string @@ -62,3 +73,6 @@ export type PortfolioMetrics = { timestamp: number userId: string } + +export const MANIFOLD_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' diff --git a/common/util/algos.ts b/common/util/algos.ts new file mode 100644 index 00000000..dd450075 --- /dev/null +++ b/common/util/algos.ts @@ -0,0 +1,22 @@ +export function binarySearch( + min: number, + max: number, + comparator: (x: number) => number +) { + let mid = 0 + while (true) { + mid = min + (max - min) / 2 + + // Break once we've reached max precision. + if (mid === min || mid === max) break + + const comparison = comparator(mid) + if (comparison === 0) break + else if (comparison > 0) { + max = mid + } else { + min = mid + } + } + return mid +} diff --git a/common/util/array.ts b/common/util/array.ts index d81edba1..8a429262 100644 --- a/common/util/array.ts +++ b/common/util/array.ts @@ -1,3 +1,38 @@ export function filterDefined(array: (T | null | undefined)[]) { return array.filter((item) => item !== null && item !== undefined) as T[] } + +export function buildArray( + ...params: (T | T[] | false | undefined | null)[] +) { + const array: T[] = [] + + for (const el of params) { + if (Array.isArray(el)) { + array.push(...el) + } else if (el) { + array.push(el) + } + } + + return array +} + +export function groupConsecutive(xs: T[], key: (x: T) => U) { + if (!xs.length) { + return [] + } + const result = [] + let curr = { key: key(xs[0]), items: [xs[0]] } + for (const x of xs.slice(1)) { + const k = key(x) + if (k !== curr.key) { + result.push(curr) + curr = { key: k, items: [x] } + } else { + curr.items.push(x) + } + } + result.push(curr) + return result +} diff --git a/common/util/format.ts b/common/util/format.ts index decdd55d..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -33,18 +33,24 @@ export function formatPercent(zeroToOne: number) { return (zeroToOne * 100).toFixed(decimalPlaces) + '%' } +const showPrecision = (x: number, sigfigs: number) => + // convert back to number for weird formatting reason + `${Number(x.toPrecision(sigfigs))}` + // 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)) - } + if (absNum < 1) return showPrecision(num, sigfigs) + + if (absNum < 100) return showPrecision(num, 2) + if (absNum < 1000) return showPrecision(num, 3) + if (absNum < 10000) return showPrecision(num, 4) 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}` + const i = Math.floor(Math.log10(absNum) / 3) + + const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs) + return `${numStr}${suffix[i] ?? ''}` } export function toCamelCase(words: string) { diff --git a/common/util/math.ts b/common/util/math.ts index 66bcff1b..fb07afed 100644 --- a/common/util/math.ts +++ b/common/util/math.ts @@ -34,3 +34,17 @@ export function median(xs: number[]) { export function average(xs: number[]) { return sum(xs) / xs.length } + +const EPSILON = 0.00000001 + +export function floatingEqual(a: number, b: number, epsilon = EPSILON) { + return Math.abs(a - b) < epsilon +} + +export function floatingGreaterEqual(a: number, b: number, epsilon = EPSILON) { + return a + epsilon >= b +} + +export function floatingLesserEqual(a: number, b: number, epsilon = EPSILON) { + return a - epsilon <= b +} diff --git a/common/util/parse.ts b/common/util/parse.ts index b73bdfb3..4fac3225 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,29 @@ import { MAX_TAG_LENGTH } from '../contract' +import { generateText, JSONContent } from '@tiptap/core' +// Tiptap starter extensions +import { Blockquote } from '@tiptap/extension-blockquote' +import { Bold } from '@tiptap/extension-bold' +import { BulletList } from '@tiptap/extension-bullet-list' +import { Code } from '@tiptap/extension-code' +import { CodeBlock } from '@tiptap/extension-code-block' +import { Document } from '@tiptap/extension-document' +import { HardBreak } from '@tiptap/extension-hard-break' +import { Heading } from '@tiptap/extension-heading' +import { History } from '@tiptap/extension-history' +import { HorizontalRule } from '@tiptap/extension-horizontal-rule' +import { Italic } from '@tiptap/extension-italic' +import { ListItem } from '@tiptap/extension-list-item' +import { OrderedList } from '@tiptap/extension-ordered-list' +import { Paragraph } from '@tiptap/extension-paragraph' +import { Strike } from '@tiptap/extension-strike' +import { Text } from '@tiptap/extension-text' +// other tiptap extensions +import { Image } from '@tiptap/extension-image' +import { Link } from '@tiptap/extension-link' +import { Mention } from '@tiptap/extension-mention' +import Iframe from './tiptap-iframe' +import TiptapTweet from './tiptap-tweet-type' +import { uniq } from 'lodash' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -27,3 +52,52 @@ export function parseWordsAsTags(text: string) { .join(' ') return parseTags(taggedText) } + +// TODO: fuzzy matching +export const wordIn = (word: string, corpus: string) => + corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) + +const checkAgainstQuery = (query: string, corpus: string) => + query.split(' ').every((word) => wordIn(word, corpus)) + +export const searchInAny = (query: string, ...fields: string[]) => + fields.some((field) => checkAgainstQuery(query, field)) + +/** @return user ids of all \@mentions */ +export function parseMentions(data: JSONContent): string[] { + const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs + if (data.type === 'mention' && data.attrs) { + mentions.push(data.attrs.id as string) + } + return uniq(mentions) +} + +// can't just do [StarterKit, Image...] because it doesn't work with cjs imports +export const exhibitExts = [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlock, + Document, + HardBreak, + Heading, + History, + HorizontalRule, + Italic, + ListItem, + OrderedList, + Paragraph, + Strike, + Text, + + Image, + Link, + Mention, + Iframe, + TiptapTweet, +] + +export function richTextToString(text?: JSONContent) { + return !text ? '' : generateText(text, exhibitExts) +} diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts new file mode 100644 index 00000000..5af63d2f --- /dev/null +++ b/common/util/tiptap-iframe.ts @@ -0,0 +1,92 @@ +// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts + +import { Node } from '@tiptap/core' + +export interface IframeOptions { + allowFullscreen: boolean + HTMLAttributes: { + [key: string]: any + } +} + +declare module '@tiptap/core' { + interface Commands { + iframe: { + setIframe: (options: { src: string }) => ReturnType + } + } +} + +// These classes style the outer wrapper and the inner iframe; +// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue +const wrapperClasses = 'relative h-auto w-full overflow-hidden' +const iframeClasses = 'absolute top-0 left-0 h-full w-full' + +export default Node.create({ + name: 'iframe', + + group: 'block', + + atom: true, + + addOptions() { + return { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper' + ' ' + wrapperClasses, + // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: + style: 'padding-bottom: 20rem;', + }, + } + }, + + addAttributes() { + return { + src: { + default: null, + }, + frameborder: { + default: 0, + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => this.options.allowFullscreen, + }, + } + }, + + parseHTML() { + return [{ tag: 'iframe' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + this.options.HTMLAttributes, + [ + 'iframe', + { + ...HTMLAttributes, + class: HTMLAttributes.class + ' ' + iframeClasses, + }, + ], + ] + }, + + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr + const node = this.type.create(options) + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node) + } + + return true + }, + } + }, +}) diff --git a/common/util/tiptap-tweet-type.ts b/common/util/tiptap-tweet-type.ts new file mode 100644 index 00000000..0b9acffc --- /dev/null +++ b/common/util/tiptap-tweet-type.ts @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface TweetOptions { + tweetId: string +} + +// This is a version of the Tiptap Node config without addNodeView, +// since that would require bundling in tsx +export const TiptapTweetNode = { + name: 'tiptapTweet', + group: 'block', + atom: true, + + addAttributes() { + return { + tweetId: { + default: null, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'tiptap-tweet', + }, + ] + }, + + renderHTML(props: { HTMLAttributes: Record }) { + return ['tiptap-tweet', mergeAttributes(props.HTMLAttributes)] + }, +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export default Node.create(TiptapTweetNode) diff --git a/dev.sh b/dev.sh new file mode 100755 index 00000000..ca3246ac --- /dev/null +++ b/dev.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +ENV=${1:-dev} +case $ENV in + dev) + FIREBASE_PROJECT=dev + NEXT_ENV=DEV ;; + prod) + FIREBASE_PROJECT=prod + NEXT_ENV=PROD ;; + localdb) + FIREBASE_PROJECT=dev + NEXT_ENV=DEV + EMULATOR=true ;; + *) + echo "Invalid environment; must be dev, prod, or localdb." + exit 1 +esac + +firebase use $FIREBASE_PROJECT + +if [ ! -z $EMULATOR ] +then + npx concurrently \ + -n FIRESTORE,FUNCTIONS,NEXT,TS \ + -c green,white,magenta,cyan \ + "yarn --cwd=functions firestore" \ + "cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \ + "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ + NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \ + NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \ + yarn --cwd=web serve" \ + "cross-env yarn --cwd=web ts-watch" +else + npx concurrently \ + -n FUNCTIONS,NEXT,TS \ + -c white,magenta,cyan \ + "yarn --cwd=functions dev" \ + "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ + NEXT_PUBLIC_FIREBASE_ENV=${NEXT_ENV} \ + yarn --cwd=web serve" \ + "cross-env yarn --cwd=web ts-watch" +fi diff --git a/docs/docs/api.md b/docs/docs/api.md index ffdaa65f..e4936418 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -34,6 +34,40 @@ response was a 4xx or 5xx.) ## Endpoints +### `GET /v0/user/[username]` + +Gets a user by their username. Remember that usernames may change. + +Requires no authorization. + +### `GET /v0/user/by-id/[id]` + +Gets a user by their unique ID. Many other API endpoints return this as the `userId`. + +Requires no authorization. + +### GET /v0/me + +Returns the authenticated user. + +### `GET /v0/groups` + +Gets all groups, in no particular order. + +Requires no authorization. + +### `GET /v0/groups/[slug]` + +Gets a group by its slug. + +Requires no authorization. + +### `GET /v0/groups/by-id/[id]` + +Gets a group by its unique ID. + +Requires no authorization. + ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -456,7 +490,6 @@ Requires no authorization. } ``` - ### `POST /v0/bet` Places a new bet on behalf of the authorized user. @@ -470,6 +503,20 @@ Parameters: answer. For numeric markets, this is a string representing the target bucket, and an additional `value` parameter is required which is a number representing the target value. (Bet on numeric markets at your own peril.) +- `limitProb`: Optional. A number between `0.001` and `0.999` inclusive representing + the limit probability for your bet (i.e. 0.1% to 99.9% — multiply by 100 for the + probability percentage). + The bet will execute immediately in the direction of `outcome`, but not beyond this + specified limit. If not all the bet is filled, the bet will remain as an open offer + that can later be matched against an opposite direction bet. + - For example, if the current market probability is `50%`: + - A `M$10` bet on `YES` with `limitProb=0.4` would not be filled until the market + probability moves down to `40%` and someone bets `M$15` of `NO` to match your + bet odds. + - A `M$100` bet on `YES` with `limitProb=0.6` would fill partially or completely + depending on current unfilled limit bets and the AMM's liquidity. Any remaining + portion of the bet not filled would remain to be matched against in the future. + - An unfilled limit order bet can be cancelled using the cancel API. Example request: @@ -481,6 +528,10 @@ $ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application "contractId":"{...}"}' ``` +### `POST /v0/bet/cancel/[id]` + +Cancel the limit order of a bet with the specified id. If the bet was unfilled, it will be cancelled so that no other bets will match with it. This is action irreversable. + ### `POST /v0/market` Creates a new market on behalf of the authorized user. @@ -514,8 +565,170 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "initialProb":25}' ``` +### `POST /v0/market/[marketId]/resolve` + +Resolves a market on behalf of the authorized user. + +Parameters: + +For binary markets: + +- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. +- `probabilityInt`: Optional. The probability to use for `MKT` resolution. + +For free response markets: + +- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. +- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. + +For numeric markets: + +- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. +- `value`: The value that the market may resolves to. + +Example request: + +``` +# Resolve a binary market +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES"}' + +# Resolve a binary market with a specified probability +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "MKT", \ + "probabilityInt": 75}' + +# Resolve a free response market with a single answer chosen +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": 2}' + +# Resolve a free response market with multiple answers chosen +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "MKT", \ + "resolutions": [ \ + {"answer": 0, "pct": 50}, \ + {"answer": 2, "pct": 50} \ + ]}' +``` + +### `POST /v0/market/[marketId]/sell` + +Sells some quantity of shares in a binary market on behalf of the authorized user. + +Parameters: + +- `outcome`: Optional. One of `YES`, or `NO`. If you leave it off, and you only + own one kind of shares, you will sell that kind of shares. +- `shares`: Optional. The amount of shares to sell of the outcome given + above. If not provided, all the shares you own will be sold. + +Example request: + +``` +$ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES", "shares": 10}' +``` + +### `GET /v0/bets` + +Gets a list of bets, ordered by creation date descending. + +Parameters: + +- `username`: Optional. If set, the response will include only bets created by this user. +- `market`: Optional. The slug of a market. If set, the response will only include bets on this market. +- `limit`: Optional. How many bets to return. The maximum and the default is 1000. +- `before`: Optional. The ID of the bet before which the list will start. For + example, if you ask for the most recent 10 bets, and then perform a second + query for 10 more bets with `before=[the id of the 10th bet]`, you will + get bets 11 through 20. + +Requires no authorization. + +- Example request + ``` + https://manifold.markets/api/v0/bets?username=ManifoldMarkets&market=will-i-be-able-to-place-a-limit-ord + ``` +- Response type: A `Bet[]`. + +-
Example response

+ + ```json + [ + // Limit bet, partially filled. + { + "isFilled": false, + "amount": 15.596681605353808, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "contractId": "Tz5dA01GkK5QKiQfZeDL", + "probBefore": 0.5730753474948571, + "isCancelled": false, + "outcome": "YES", + "fees": { "creatorFee": 0, "liquidityFee": 0, "platformFee": 0 }, + "shares": 31.193363210707616, + "limitProb": 0.5, + "id": "yXB8lVbs86TKkhWA1FVi", + "loanAmount": 0, + "orderAmount": 100, + "probAfter": 0.5730753474948571, + "createdTime": 1659482775970, + "fills": [ + { + "timestamp": 1659483249648, + "matchedBetId": "MfrMd5HTiGASDXzqibr7", + "amount": 15.596681605353808, + "shares": 31.193363210707616 + } + ] + }, + // Normal bet (no limitProb specified). + { + "shares": 17.350459904608414, + "probBefore": 0.5304358279113885, + "isFilled": true, + "probAfter": 0.5730753474948571, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "amount": 10, + "contractId": "Tz5dA01GkK5QKiQfZeDL", + "id": "1LPJHNz5oAX4K6YtJlP1", + "fees": { + "platformFee": 0, + "liquidityFee": 0, + "creatorFee": 0.4251333951457593 + }, + "isCancelled": false, + "loanAmount": 0, + "orderAmount": 10, + "fills": [ + { + "amount": 10, + "matchedBetId": null, + "shares": 17.350459904608414, + "timestamp": 1659482757271 + } + ], + "createdTime": 1659482757271, + "outcome": "YES" + } + ] + ``` + +

+
+ ## Changelog +- 2022-07-15: Add user by username and user by ID APIs - 2022-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints - 2022-02-28: Add `resolutionTime` to markets, change `closeTime` definition diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index ade5caee..0871be52 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -10,13 +10,16 @@ A list of community-created projects built on, or related to, Manifold Markets. - [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government - [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold +- [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$. ## API / Dev - [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) +- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets ## Bots - [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon +- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets diff --git a/docs/docs/market-details.md b/docs/docs/market-details.md index f7eeb0f6..9836b850 100644 --- a/docs/docs/market-details.md +++ b/docs/docs/market-details.md @@ -19,7 +19,6 @@ for the pool to be sorted into. - Users can create a market on any question they want. - When a user creates a market, they must choose a close date, after which trading will halt. - They must also pay a M$100 market creation fee, which is used as liquidity to subsidize trading on the market. - - The creation fee for the first market created each day is provided by Manifold. - The market creator will earn a commission on all bets placed in the market. - The market creator is responsible for resolving each market in a timely manner. All fees earned as a commission will be paid out after resolution. - Creators can also resolve N/A to cancel all transactions and reverse all transactions made on the market - this includes profits from selling shares. diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 85129d87..0cf5a8f2 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -26,8 +26,7 @@ const config = { docs: { routeBasePath: '/', sidebarPath: require.resolve('./sidebars.js'), - // Please change this to your repo. - editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs', + editUrl: 'https://github.com/manifoldmarkets/manifold/tree/main/docs', remarkPlugins: [math], rehypePlugins: [katex], }, @@ -72,7 +71,7 @@ const config = { label: 'Docs', }, { - href: 'https://github.com/manifoldmarkets/docs', + href: 'https://github.com/manifoldmarkets/manifold/tree/main/docs/docs', label: 'GitHub', position: 'right', }, @@ -116,7 +115,7 @@ const config = { }, { label: 'GitHub', - href: 'https://github.com/manifoldmarkets/docs', + href: 'https://github.com/manifoldmarkets/manifold/', }, ], }, diff --git a/docs/package.json b/docs/package.json index 9e320306..38b69777 100644 --- a/docs/package.json +++ b/docs/package.json @@ -30,7 +30,8 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "2.0.0-beta.17", - "@tsconfig/docusaurus": "^1.0.4" + "@tsconfig/docusaurus": "^1.0.4", + "@types/react": "^17.0.2" }, "browserslist": { "production": [ diff --git a/firebase.json b/firebase.json index de1e19b7..25f9b61f 100644 --- a/firebase.json +++ b/firebase.json @@ -1,8 +1,8 @@ { "functions": { - "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", + "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions" + "source": "functions/dist" }, "firestore": { "rules": "firestore.rules", diff --git a/firestore.indexes.json b/firestore.indexes.json index 064f6f2f..12e88033 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -307,15 +307,11 @@ ] }, { - "collectionGroup": "txns", + "collectionGroup": "manalinks", "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "toId", - "order": "ASCENDING" - }, - { - "fieldPath": "toType", + "fieldPath": "fromId", "order": "ASCENDING" }, { @@ -325,11 +321,57 @@ ] }, { - "collectionGroup": "manalinks", + "collectionGroup": "notifications", "queryScope": "COLLECTION", "fields": [ { - "fieldPath": "fromId", + "fieldPath": "isSeen", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "txns", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "toId", + "order": "ASCENDING" + }, + { + "fieldPath": "toType", "order": "ASCENDING" }, { @@ -410,6 +452,28 @@ } ] }, + { + "collectionGroup": "bets", + "fieldPath": "id", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, { "collectionGroup": "bets", "fieldPath": "userId", diff --git a/firestore.rules b/firestore.rules index 176cc71e..81ab4eed 100644 --- a/firestore.rules +++ b/firestore.rules @@ -6,10 +6,12 @@ service cloud.firestore { match /databases/{database}/documents { function isAdmin() { - return request.auth.uid == 'igi2zGXsfxYPgB0DJTXVJVmwCOr2' // Austin - || request.auth.uid == '5LZ4LgYuySdL1huCWe7bti02ghx2' // James - || request.auth.uid == 'tlmGNz9kjXc2EteizMORes4qvWl2' // Stephen - || request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold + return request.auth.token.email in [ + 'akrolsmir@gmail.com', + 'jahooma@gmail.com', + 'taowell@gmail.com', + 'manticmarkets@gmail.com' + ] } match /stats/stats { @@ -18,15 +20,36 @@ service cloud.firestore { match /users/{userId} { allow read; - allow update: if resource.data.id == request.auth.uid + allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); + // User referral rules + allow update: if userId == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(userId == request.resource.data.referredByUserId); + // quid pro quos enabled (only once though so nbd) - bc I can't make this work: + // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { allow read; } + match /{somePath=**}/challenges/{challengeId}{ + allow read; + } + + match /contracts/{contractId}/challenges/{challengeId}{ + allow read; + allow create: if request.auth.uid == request.resource.data.creatorId; + // allow update if there have been no claims yet and if the challenge is still open + allow update: if request.auth.uid == resource.data.creatorId; + } + match /users/{userId}/follows/{followUserId} { allow read; allow write: if request.auth.uid == userId; @@ -37,8 +60,8 @@ service cloud.firestore { } match /private-users/{userId} { - allow read: if resource.data.id == request.auth.uid || isAdmin(); - allow update: if (resource.data.id == request.auth.uid || isAdmin()) + allow read: if userId == request.auth.uid || isAdmin(); + allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]); } @@ -62,9 +85,9 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime']) + .hasOnly(['description', 'closeTime', 'question']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { diff --git a/functions/.env b/functions/.env new file mode 100644 index 00000000..0c4303df --- /dev/null +++ b/functions/.env @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=PROD diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 7f571610..2c607231 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], - ignorePatterns: ['lib'], + ignorePatterns: ['dist', 'lib'], env: { node: true, }, @@ -30,6 +30,7 @@ module.exports = { }, ], rules: { + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/functions/.gitignore b/functions/.gitignore index 7aeaedd4..58f30dcb 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1,10 +1,11 @@ # Secrets -.env* .runtimeconfig.json +# GCP deployment artifact +dist/ + # Compiled JavaScript files -lib/**/*.js -lib/**/*.js.map +lib/ # TypeScript v1 declaration files typings/ diff --git a/functions/.yarnrc b/functions/.yarnrc new file mode 100644 index 00000000..fdd705c6 --- /dev/null +++ b/functions/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/functions/README.md b/functions/README.md index 031cc4fa..97a7a33b 100644 --- a/functions/README.md +++ b/functions/README.md @@ -23,8 +23,11 @@ Adapted from https://firebase.google.com/docs/functions/get-started ### For local development 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI -1. 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.`): 0. `$ brew install java` - 1. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` +1. 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. `$ brew install java` + 2. `$ sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jdk` + 2. `$ gcloud auth login` to authenticate the CLI tools to Google Cloud 3. `$ gcloud config set project ` to choose the project (`$ gcloud projects list` to see options) 4. `$ mkdir firestore_export` to create a folder to store the exported database @@ -51,7 +54,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Deploying -0. `$ firebase use prod` to switch to prod +0. After merging, you need to manually deploy to backend: +1. `git checkout main` +1. `git pull origin main` +1. `$ firebase use prod` to switch to prod 1. `$ firebase deploy --only functions` to push your changes live! (Future TODO: auto-deploy functions on Git push) diff --git a/functions/package.json b/functions/package.json index 7b5c30b0..5839b5eb 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,23 +5,35 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "tsc", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist", + "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", "start": "yarn shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", - "serve": "yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", + "dev": "nodemon src/serve.ts", + "firestore": "firebase emulators:start --only firestore --import=./firestore_export", + "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", - "verify": "(cd .. && yarn verify)" + "verify": "(cd .. && yarn verify)", + "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" }, - "main": "lib/functions/src/index.js", + "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", - "fetch": "1.1.0", + "@google-cloud/functions-framework": "3.1.2", + "@tiptap/core": "2.0.0-beta.181", + "@tiptap/extension-image": "2.0.0-beta.30", + "@tiptap/extension-link": "2.0.0-beta.43", + "@tiptap/extension-mention": "2.0.0-beta.102", + "@tiptap/starter-kit": "2.0.0-beta.190", + "cors": "2.8.5", + "dayjs": "1.11.4", + "express": "4.18.1", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts new file mode 100644 index 00000000..eae6ab55 --- /dev/null +++ b/functions/src/accept-challenge.ts @@ -0,0 +1,167 @@ +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' +import { log } from './utils' +import { Contract, CPMMBinaryContract } from '../../common/contract' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { FieldValue } from 'firebase-admin/firestore' +import { removeUndefinedProps } from '../../common/util/object' +import { Acceptance, Challenge } from '../../common/challenge' +import { CandidateBet } from '../../common/new-bet' +import { createChallengeAcceptedNotification } from './create-notification' +import { noFees } from '../../common/fees' +import { formatMoney, formatPercent } from '../../common/util/format' + +const bodySchema = z.object({ + contractId: z.string(), + challengeSlug: z.string(), + outcomeType: z.literal('BINARY'), + closeTime: z.number().gte(Date.now()), +}) +const firestore = admin.firestore() + +export const acceptchallenge = newEndpoint({}, async (req, auth) => { + const { challengeSlug, contractId } = validate(bodySchema, req.body) + + const result = await firestore.runTransaction(async (trans) => { + const contractDoc = firestore.doc(`contracts/${contractId}`) + const userDoc = firestore.doc(`users/${auth.uid}`) + const challengeDoc = firestore.doc( + `contracts/${contractId}/challenges/${challengeSlug}` + ) + const [contractSnap, userSnap, challengeSnap] = await trans.getAll( + contractDoc, + userDoc, + challengeDoc + ) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + if (!userSnap.exists) throw new APIError(400, 'User not found.') + if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.') + + const anyContract = contractSnap.data() as Contract + const user = userSnap.data() as User + const challenge = challengeSnap.data() as Challenge + + if (challenge.acceptances.length > 0) + throw new APIError(400, 'Challenge already accepted.') + + const creatorDoc = firestore.doc(`users/${challenge.creatorId}`) + const creatorSnap = await trans.get(creatorDoc) + if (!creatorSnap.exists) throw new APIError(400, 'Creator not found.') + const creator = creatorSnap.data() as User + + const { + creatorAmount, + acceptorOutcome, + creatorOutcome, + creatorOutcomeProb, + acceptorAmount, + } = challenge + + if (user.balance < acceptorAmount) + throw new APIError(400, 'Insufficient balance.') + + if (creator.balance < creatorAmount) + throw new APIError(400, 'Creator has insufficient balance.') + + const contract = anyContract as CPMMBinaryContract + const shares = (1 / creatorOutcomeProb) * creatorAmount + const createdTime = Date.now() + const probOfYes = + creatorOutcome === 'YES' ? creatorOutcomeProb : 1 - creatorOutcomeProb + + log( + 'Creating challenge bet for', + user.username, + shares, + acceptorOutcome, + 'shares', + 'at', + formatPercent(creatorOutcomeProb), + 'for', + formatMoney(acceptorAmount) + ) + + const yourNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: acceptorAmount, + amount: acceptorAmount, + shares, + isCancelled: false, + contractId: contract.id, + outcome: acceptorOutcome, + probBefore: probOfYes, + probAfter: probOfYes, + loanAmount: 0, + createdTime, + fees: noFees, + challengeSlug: challenge.slug, + }) + + const yourNewBetDoc = contractDoc.collection('bets').doc() + trans.create(yourNewBetDoc, { + id: yourNewBetDoc.id, + userId: user.id, + ...yourNewBet, + }) + + trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) }) + + const creatorNewBet: CandidateBet = removeUndefinedProps({ + orderAmount: creatorAmount, + amount: creatorAmount, + shares, + isCancelled: false, + contractId: contract.id, + outcome: creatorOutcome, + probBefore: probOfYes, + probAfter: probOfYes, + loanAmount: 0, + createdTime, + fees: noFees, + challengeSlug: challenge.slug, + }) + const creatorBetDoc = contractDoc.collection('bets').doc() + trans.create(creatorBetDoc, { + id: creatorBetDoc.id, + userId: creator.id, + ...creatorNewBet, + }) + + trans.update(creatorDoc, { + balance: FieldValue.increment(-creatorNewBet.amount), + }) + + const volume = contract.volume + yourNewBet.amount + creatorNewBet.amount + trans.update(contractDoc, { volume }) + + trans.update( + challengeDoc, + removeUndefinedProps({ + acceptedByUserIds: [user.id], + acceptances: [ + { + userId: user.id, + betId: yourNewBetDoc.id, + createdTime, + amount: acceptorAmount, + userUsername: user.username, + userName: user.name, + userAvatarUrl: user.avatarUrl, + } as Acceptance, + ], + }) + ) + + await createChallengeAcceptedNotification( + user, + creator, + challenge, + acceptorAmount, + contract + ) + log('Done, sent notification.') + return yourNewBetDoc + }) + + return { betId: result.id } +}) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index 34d3f7c6..6746486e 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -1,104 +1,90 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { removeUndefinedProps } from '../../common/util/object' -import { redeemShares } from './redeem-shares' import { getNewLiquidityProvision } from '../../common/add-liquidity' +import { APIError, newEndpoint, validate } from './api' -export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - amount: number - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) - const { amount, contractId } = data +export const addliquidity = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + 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}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - if ( - contract.mechanism !== 'cpmm-1' || - contract.outcomeType !== 'BINARY' - ) - return { status: 'error', message: 'Invalid contract' } + 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 + if ( + contract.mechanism !== 'cpmm-1' || + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') + ) + throw new APIError(400, 'Invalid contract') - const { closeTime } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const newLiquidityProvisionDoc = firestore - .collection(`contracts/${contractId}/liquidity`) - .doc() + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contractId}/liquidity`) + .doc() - const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = - getNewLiquidityProvision( - user, - amount, - contract, - newLiquidityProvisionDoc.id - ) + const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + getNewLiquidityProvision( + user, + amount, + contract, + newLiquidityProvisionDoc.id + ) - if (newP !== undefined && !isFinite(newP)) { - return { - status: 'error', - message: 'Liquidity injection rejected due to overflow error.', - } - } + if (newP !== undefined && !isFinite(newP)) { + return { + status: 'error', + message: 'Liquidity injection rejected due to overflow error.', + } + } - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalLiquidity: newTotalLiquidity, - }) - ) - - const newBalance = user.balance - amount - const newTotalDeposits = user.totalDeposits - amount - - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } - - transaction.update(userDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - }) - - transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) - - return { status: 'success', newLiquidityProvision } + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalLiquidity: newTotalLiquidity, }) - .then(async (result) => { - await redeemShares(userId, contractId) - return result - }) - } -) + ) + + const newBalance = user.balance - amount + const newTotalDeposits = user.totalDeposits - amount + + if (!isFinite(newBalance)) { + throw new APIError(500, 'Invalid user balance for ' + user.username) + } + + transaction.update(userDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + }) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + + return newLiquidityProvision + }) +}) const firestore = admin.firestore() diff --git a/functions/src/api.ts b/functions/src/api.ts index f7efab5a..e9a488c2 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -1,14 +1,17 @@ import * as admin from 'firebase-admin' -import { logger } from 'firebase-functions/v2' -import { HttpsOptions, onRequest, Request } from 'firebase-functions/v2/https' +import { Request, RequestHandler, Response } from 'express' +import { error } from 'firebase-functions/logger' +import { HttpsOptions } from 'firebase-functions/v2/https' import { log } from './utils' import { z } from 'zod' - +import { APIError } from '../../common/api' import { PrivateUser } from '../../common/user' import { CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST, + CORS_ORIGIN_VERCEL, } from '../../common/envs/constants' +export { APIError } from '../../common/api' type Output = Record type AuthedUser = { @@ -20,17 +23,6 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type KeyCredentials = { kind: 'key'; data: string } type Credentials = JwtCredentials | KeyCredentials -export class APIError { - code: number - msg: string - details: unknown - constructor(code: number, msg: string, details?: unknown) { - this.code = code - this.msg = msg - this.details = details - } -} - const auth = admin.auth() const firestore = admin.firestore() const privateUsers = firestore.collection( @@ -54,7 +46,7 @@ export const parseCredentials = async (req: Request): Promise => { return { kind: 'jwt', data: await auth.verifyIdToken(payload) } } catch (err) { // This is somewhat suspicious, so get it into the firebase console - logger.error('Error verifying Firebase JWT: ', err) + error('Error verifying Firebase JWT: ', err) throw new APIError(403, 'Error validating token.') } case 'Key': @@ -86,12 +78,30 @@ export const lookupUser = async (creds: Credentials): Promise => { } } +export const writeResponseError = (e: unknown, res: Response) => { + if (e instanceof APIError) { + const output: { [k: string]: unknown } = { message: e.message } + if (e.details != null) { + output.details = e.details + } + res.status(e.code).json(output) + } else { + error(e) + res.status(500).json({ message: 'An unknown error occurred.' }) + } +} + export const zTimestamp = () => { return z.preprocess((arg) => { return typeof arg == 'number' ? new Date(arg) : undefined }, z.date()) } +export type EndpointDefinition = { + opts: EndpointOptions & { method: string } + handler: RequestHandler +} + export const validate = (schema: T, val: unknown) => { const result = schema.safeParse(val) if (!result.success) { @@ -108,35 +118,34 @@ export const validate = (schema: T, val: unknown) => { } } -const DEFAULT_OPTS: HttpsOptions = { +export interface EndpointOptions extends HttpsOptions { + method?: string +} + +const DEFAULT_OPTS = { + method: 'POST', minInstances: 1, concurrency: 100, memory: '2GiB', cpu: 1, - cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], + cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_VERCEL, CORS_ORIGIN_LOCALHOST], } -export const newEndpoint = (methods: [string], fn: Handler) => - onRequest(DEFAULT_OPTS, async (req, res) => { - log('Request processing started.') - try { - if (!methods.includes(req.method)) { - const allowed = methods.join(', ') - throw new APIError(405, `This endpoint supports only ${allowed}.`) - } - const authedUser = await lookupUser(await parseCredentials(req)) - log('User credentials processed.') - res.status(200).json(await fn(req, authedUser)) - } catch (e) { - if (e instanceof APIError) { - const output: { [k: string]: unknown } = { message: e.msg } - if (e.details != null) { - output.details = e.details +export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { + const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts) + return { + opts, + handler: async (req: Request, res: Response) => { + log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`) + try { + if (opts.method !== req.method) { + throw new APIError(405, `This endpoint supports only ${opts.method}.`) } - res.status(e.code).json(output) - } else { - logger.error(e) - res.status(500).json({ message: 'An unknown error occurred.' }) + const authedUser = await lookupUser(await parseCredentials(req)) + res.status(200).json(await fn(req, authedUser)) + } catch (e) { + writeResponseError(e, res) } - } - }) + }, + } as EndpointDefinition +} diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index 5174f595..227c89e4 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -18,46 +18,63 @@ import * as functions from 'firebase-functions' import * as firestore from '@google-cloud/firestore' -const client = new firestore.v1.FirestoreAdminClient() +import { FirestoreAdminClient } from '@google-cloud/firestore/types/v1/firestore_admin_client' -const bucket = 'gs://manifold-firestore-backup' +export const backupDbCore = async ( + client: FirestoreAdminClient, + project: string, + bucket: string +) => { + const name = client.databasePath(project, '(default)') + const outputUriPrefix = `gs://${bucket}` + // Leave collectionIds empty to export all collections + // or set to a list of collection IDs to export, + // collectionIds: ['users', 'posts'] + // NOTE: Subcollections are not backed up by default + const collectionIds = [ + 'contracts', + 'groups', + 'private-users', + 'stripe-transactions', + 'transactions', + 'users', + 'bets', + 'comments', + 'follows', + 'followers', + 'answers', + 'txns', + 'manalinks', + 'liquidity', + 'stats', + 'cache', + 'latency', + 'views', + 'notifications', + 'portfolioHistory', + 'folds', + ] + return await client.exportDocuments({ name, outputUriPrefix, collectionIds }) +} export const backupDb = functions.pubsub .schedule('every 24 hours') - .onRun((_context) => { - const projectId = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT - if (projectId == null) { - throw new Error('No project ID environment variable set.') + .onRun(async (_context) => { + try { + const client = new firestore.v1.FirestoreAdminClient() + const project = process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT + if (project == null) { + throw new Error('No project ID environment variable set.') + } + const responses = await backupDbCore( + client, + project, + 'manifold-firestore-backup' + ) + const response = responses[0] + console.log(`Operation Name: ${response['name']}`) + } catch (err) { + console.error(err) + throw new Error('Export operation failed') } - const databaseName = client.databasePath(projectId, '(default)') - - return client - .exportDocuments({ - name: databaseName, - outputUriPrefix: bucket, - // Leave collectionIds empty to export all collections - // or set to a list of collection IDs to export, - // collectionIds: ['users', 'posts'] - // NOTE: Subcollections are not backed up by default - collectionIds: [ - 'contracts', - 'groups', - 'private-users', - 'stripe-transactions', - 'users', - 'bets', - 'comments', - 'followers', - 'answers', - 'txns', - ], - }) - .then((responses) => { - const response = responses[0] - console.log(`Operation Name: ${response['name']}`) - }) - .catch((err) => { - console.error(err) - throw new Error('Export operation failed') - }) }) diff --git a/functions/src/call-cloud-function.ts b/functions/src/call-cloud-function.ts deleted file mode 100644 index 35191343..00000000 --- a/functions/src/call-cloud-function.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as admin from 'firebase-admin' - -import fetch from './fetch' - -export const callCloudFunction = (functionName: string, data: unknown = {}) => { - const projectId = admin.instanceId().app.options.projectId - - const url = `https://us-central1-${projectId}.cloudfunctions.net/${functionName}` - - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ data }), - }).then((response) => response.json()) -} diff --git a/functions/src/cancel-bet.ts b/functions/src/cancel-bet.ts new file mode 100644 index 00000000..0b7a42aa --- /dev/null +++ b/functions/src/cancel-bet.ts @@ -0,0 +1,33 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' +import { APIError, newEndpoint, validate } from './api' +import { LimitBet } from '../../common/bet' + +const bodySchema = z.object({ + betId: z.string(), +}) + +export const cancelbet = newEndpoint({}, async (req, auth) => { + const { betId } = validate(bodySchema, req.body) + + return await firestore.runTransaction(async (trans) => { + const snap = await trans.get( + firestore.collectionGroup('bets').where('id', '==', betId) + ) + const betDoc = snap.docs[0] + if (!betDoc?.exists) throw new APIError(400, 'Bet not found.') + + const bet = betDoc.data() as LimitBet + if (bet.userId !== auth.uid) + throw new APIError(400, 'Not authorized to cancel bet.') + if (bet.limitProb === undefined) + throw new APIError(400, 'Not a limit order: Cannot cancel.') + if (bet.isCancelled) throw new APIError(400, 'Bet already cancelled.') + + trans.update(betDoc.ref, { isCancelled: true }) + + return { ...bet, isCancelled: true } + }) +}) + +const firestore = admin.firestore() diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index 118d5c67..aa041856 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { getUser } from './utils' import { Contract } from '../../common/contract' @@ -11,37 +11,23 @@ import { } from '../../common/util/clean-username' import { removeUndefinedProps } from '../../common/util/object' import { Answer } from '../../common/answer' +import { APIError, newEndpoint, validate } from './api' -export const changeUserInfo = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - username?: string - name?: string - avatarUrl?: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + username: z.string().optional(), + name: z.string().optional(), + avatarUrl: z.string().optional(), +}) - const user = await getUser(userId) - if (!user) return { status: 'error', message: 'User not found' } +export const changeuserinfo = newEndpoint({}, async (req, auth) => { + const { username, name, avatarUrl } = validate(bodySchema, req.body) - const { username, name, avatarUrl } = data + const user = await getUser(auth.uid) + if (!user) throw new APIError(400, 'User not found') - return await changeUser(user, { username, name, avatarUrl }) - .then(() => { - console.log('succesfully changed', user.username, 'to', data) - return { status: 'success' } - }) - .catch((e) => { - console.log('Error', e.message) - return { status: 'error', message: e.message } - }) - } - ) + await changeUser(user, { username, name, avatarUrl }) + return { message: 'Successfully changed user info.' } +}) export const changeUser = async ( user: User, @@ -55,14 +41,14 @@ export const changeUser = async ( if (update.username) { update.username = cleanUsername(update.username) if (!update.username) { - throw new Error('Invalid username') + throw new APIError(400, 'Invalid username') } const sameNameUser = await transaction.get( firestore.collection('users').where('username', '==', update.username) ) if (!sameNameUser.empty) { - throw new Error('Username already exists') + throw new APIError(400, 'Username already exists') } } @@ -104,17 +90,10 @@ export const changeUser = async ( ) const answerUpdate: Partial = removeUndefinedProps(update) - await transaction.update(userRef, userUpdate) - - await Promise.all( - commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate)) - ) - - await Promise.all( - answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate)) - ) - - await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate)) + transaction.update(userRef, userUpdate) + commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate)) + answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate)) + contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate)) }) } diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts index 4bcd8b16..b534f0a3 100644 --- a/functions/src/claim-manalink.ts +++ b/functions/src/claim-manalink.ts @@ -1,102 +1,107 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { User } from 'common/user' import { Manalink } from 'common/manalink' import { runTxn, TxnData } from './transact' +import { APIError, newEndpoint, validate } from './api' -export const claimManalink = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (slug: string, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + slug: z.string(), +}) - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - // Look up the manalink - const manalinkDoc = firestore.doc(`manalinks/${slug}`) - const manalinkSnap = await transaction.get(manalinkDoc) - if (!manalinkSnap.exists) { - return { status: 'error', message: 'Manalink not found' } - } - const manalink = manalinkSnap.data() as Manalink +export const claimmanalink = newEndpoint({}, async (req, auth) => { + const { slug } = validate(bodySchema, req.body) - const { amount, fromId, claimedUserIds } = manalink + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + // Look up the manalink + const manalinkDoc = firestore.doc(`manalinks/${slug}`) + const manalinkSnap = await transaction.get(manalinkDoc) + if (!manalinkSnap.exists) { + throw new APIError(400, 'Manalink not found') + } + const manalink = manalinkSnap.data() as Manalink - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + const { amount, fromId, claimedUserIds } = manalink - const fromDoc = firestore.doc(`users/${fromId}`) - const fromSnap = await transaction.get(fromDoc) - if (!fromSnap.exists) { - return { status: 'error', message: `User ${fromId} not found` } - } - const fromUser = fromSnap.data() as User + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + throw new APIError(500, 'Invalid amount') - // Only permit one redemption per user per link - if (claimedUserIds.includes(userId)) { - return { - status: 'error', - message: `${fromUser.name} already redeemed manalink ${slug}`, - } - } + if (auth.uid === fromId) + throw new APIError(400, `You can't claim your own manalink`) - // Disallow expired or maxed out links - if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { - return { - status: 'error', - message: `Manalink ${slug} expired on ${new Date( - manalink.expiresTime - ).toLocaleString()}`, - } - } - if ( - manalink.maxUses != null && - manalink.maxUses <= manalink.claims.length - ) { - return { - status: 'error', - message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`, - } - } + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + throw new APIError(500, `User ${fromId} not found`) + } + const fromUser = fromSnap.data() as User - if (fromUser.balance < amount) { - return { - status: 'error', - message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `, - } - } + // Only permit one redemption per user per link + if (claimedUserIds.includes(auth.uid)) { + throw new APIError(400, `You already redeemed manalink ${slug}`) + } - // Actually execute the txn - const data: TxnData = { - fromId, - fromType: 'USER', - toId: userId, - toType: 'USER', - amount, - token: 'M$', - category: 'MANALINK', - description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`, - } - const result = await runTxn(transaction, data) - const txnId = result.txn?.id - if (!txnId) { - return { status: 'error', message: result.message } - } + // Disallow expired or maxed out links + if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { + throw new APIError( + 400, + `Manalink ${slug} expired on ${new Date( + manalink.expiresTime + ).toLocaleString()}` + ) + } + if ( + manalink.maxUses != null && + manalink.maxUses <= manalink.claims.length + ) { + throw new APIError( + 400, + `Manalink ${slug} has reached its max uses of ${manalink.maxUses}` + ) + } - // Update the manalink object with this info - const claim = { - toId: userId, - txnId, - claimedTime: Date.now(), - } - transaction.update(manalinkDoc, { - claimedUserIds: [...claimedUserIds, userId], - claims: [...manalink.claims, claim], - }) + if (fromUser.balance < amount) { + throw new APIError( + 400, + `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} ` + ) + } - return { status: 'success', message: 'Manalink claimed' } + // Actually execute the txn + const data: TxnData = { + fromId, + fromType: 'USER', + toId: auth.uid, + toType: 'USER', + amount, + token: 'M$', + category: 'MANALINK', + description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${auth.uid}`, + } + const result = await runTxn(transaction, data) + const txnId = result.txn?.id + if (!txnId) { + throw new APIError( + 500, + result.message ?? 'An error occurred posting the transaction.' + ) + } + + // Update the manalink object with this info + const claim = { + toId: auth.uid, + txnId, + claimedTime: Date.now(), + } + transaction.update(manalinkDoc, { + claimedUserIds: [...claimedUserIds, auth.uid], + claims: [...manalink.claims, claim], }) + + return { message: 'Manalink claimed' } }) +}) const firestore = admin.firestore() diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 5cc8cf46..2abaf44d 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' @@ -7,120 +7,103 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' +import { APIError, newEndpoint, validate } from './api' -export const createAnswer = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - contractId: string - amount: number - text: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string().max(MAX_ANSWER_LENGTH), + amount: z.number().gt(0), + text: z.string(), +}) - const { contractId, amount, text } = data +const opts = { secrets: ['MAILGUN_KEY'] } - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } +export const createanswer = newEndpoint(opts, async (req, auth) => { + const { contractId, amount, text } = validate(bodySchema, req.body) - if (!text || typeof text !== 'string' || text.length > MAX_ANSWER_LENGTH) - return { status: 'error', message: 'Invalid text' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // Run as transaction to prevent race conditions. - const result = await firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // Run as transaction to prevent race conditions. + const answer = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract + const 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 - if (contract.outcomeType !== 'FREE_RESPONSE') - return { - status: 'error', - message: 'Requires a free response contract', - } + if (contract.outcomeType !== 'FREE_RESPONSE') + throw new APIError(400, 'Requires a free response contract') - const { closeTime, volume } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime, volume } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - const [lastAnswer] = await getValues( - firestore - .collection(`contracts/${contractId}/answers`) - .orderBy('number', 'desc') - .limit(1) - ) + const [lastAnswer] = await getValues( + firestore + .collection(`contracts/${contractId}/answers`) + .orderBy('number', 'desc') + .limit(1) + ) - if (!lastAnswer) - return { status: 'error', message: 'Could not fetch last answer' } + if (!lastAnswer) throw new APIError(500, 'Could not fetch last answer') - const number = lastAnswer.number + 1 - const id = `${number}` + const number = lastAnswer.number + 1 + const id = `${number}` - const newAnswerDoc = firestore - .collection(`contracts/${contractId}/answers`) - .doc(id) + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc(id) - const answerId = newAnswerDoc.id - const { username, name, avatarUrl } = user + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user - const answer: Answer = { - id, - number, - contractId, - createdTime: Date.now(), - userId: user.id, - username, - name, - avatarUrl, - text, - } - transaction.create(newAnswerDoc, answer) - - const { newBet, newPool, newTotalShares, newTotalBets } = - getNewMultiBetInfo(answerId, amount, contract) - - 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, - totalBets: newTotalBets, - answers: [...(contract.answers ?? []), answer], - volume: volume + amount, - }) - - return { status: 'success', answerId, betId: betDoc.id, answer } - }) - - const { answer } = result - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - - return result + const answer: Answer = { + id, + number, + contractId, + createdTime: Date.now(), + userId: user.id, + username, + name, + avatarUrl, + text, } - ) + transaction.create(newAnswerDoc, answer) + + const loanAmount = 0 + + const { newBet, newPool, newTotalShares, newTotalBets } = + getNewMultiBetInfo(answerId, amount, contract, loanAmount) + + 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, + totalBets: newTotalBets, + answers: [...(contract.answers ?? []), answer], + volume: volume + amount, + }) + + return answer + }) + + const contract = await getContract(contractId) + + if (answer && contract) await sendNewAnswerEmail(answer, contract) + + return answer +}) const firestore = admin.firestore() diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 71d778b3..44ced6a8 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -2,36 +2,64 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { - CPMMBinaryContract, Contract, + CPMMBinaryContract, FreeResponseContract, - MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, + MultipleChoiceContract, NumericContract, OUTCOME_TYPES, } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { chargeUser } from './utils' +import { chargeUser, getContract } from './utils' import { APIError, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, getCpmmInitialLiquidity, getFreeAnswerAnte, + getMultipleChoiceAntes, getNumericAnte, } from '../../common/antes' -import { getNoneAnswer } from '../../common/answer' +import { Answer, getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' -import { Group, MAX_ID_LENGTH } from '../../common/group' +import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group' +import { getPseudoProbability } from '../../common/pseudo-numeric' +import { JSONContent } from '@tiptap/core' +import { uniq, zip } from 'lodash' +import { Bet } from '../../common/bet' + +const descScehma: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(descScehma).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), - description: z.string().max(MAX_DESCRIPTION_LENGTH), + description: descScehma.optional(), tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(), closeTime: zTimestamp().refine( (date) => date.getTime() > new Date().getTime(), @@ -45,24 +73,54 @@ const binarySchema = z.object({ initialProb: z.number().min(1).max(99), }) +const finite = () => + z.number().gte(Number.MIN_SAFE_INTEGER).lte(Number.MAX_SAFE_INTEGER) + const numericSchema = z.object({ - min: z.number(), - max: z.number(), + min: finite(), + max: finite(), + initialValue: finite(), + isLogScale: z.boolean().optional(), }) -export const createmarket = newEndpoint(['POST'], async (req, auth) => { +const multipleChoiceSchema = z.object({ + answers: z.string().trim().min(1).array().min(2), +}) + +export const createmarket = newEndpoint({}, async (req, auth) => { const { question, description, tags, closeTime, outcomeType, groupId } = validate(bodySchema, req.body) - let min, max, initialProb - if (outcomeType === 'NUMERIC') { - ;({ min, max } = validate(numericSchema, req.body)) - if (max - min <= 0.01) throw new APIError(400, 'Invalid range.') + let min, max, initialProb, isLogScale, answers + + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate( + numericSchema, + req.body + )) + if (max - min <= 0.01 || initialValue <= min || initialValue >= max) + throw new APIError(400, 'Invalid range.') + + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 + + if (initialProb < 1 || initialProb > 99) + if (outcomeType === 'PSEUDO_NUMERIC') + throw new APIError( + 400, + `Initial value is too ${initialProb < 1 ? 'low' : 'high'}` + ) + else throw new APIError(400, 'Invalid initial probability.') } + if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) } + if (outcomeType === 'MULTIPLE_CHOICE') { + ;({ answers } = validate(multipleChoiceSchema, req.body)) + } + const userDoc = await firestore.collection('users').doc(auth.uid).get() if (!userDoc.exists) { throw new APIError(400, 'No user exists with the authenticated user ID.') @@ -78,27 +136,6 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { const slug = await getSlug(question) const contractRef = firestore.collection('contracts').doc() - let group = null - if (groupId) { - const groupDocRef = await firestore.collection('groups').doc(groupId) - const groupDoc = await groupDocRef.get() - if (!groupDoc.exists) { - throw new APIError(400, 'No group exists with the given group ID.') - } - - group = groupDoc.data() as Group - if (!group.memberIds.includes(user.id)) { - throw new APIError( - 400, - 'User must be a member of the group to add markets to it.' - ) - } - if (!group.contractIds.includes(contractRef.id)) - await groupDocRef.update({ - contractIds: [...group.contractIds, contractRef.id], - }) - } - console.log( 'creating contract for', user.username, @@ -114,23 +151,52 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { user, question, outcomeType, - description, + description ?? {}, initialProb ?? 0, ante, closeTime.getTime(), tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + isLogScale ?? false, + answers ?? [] ) if (ante) await chargeUser(user.id, ante, true) await contractRef.create(contract) + let group = null + if (groupId) { + const groupDocRef = firestore.collection('groups').doc(groupId) + const groupDoc = await groupDocRef.get() + if (!groupDoc.exists) { + throw new APIError(400, 'No group exists with the given group ID.') + } + + group = groupDoc.data() as Group + if ( + !group.memberIds.includes(user.id) && + !group.anyoneCanJoin && + group.creatorId !== user.id + ) { + throw new APIError( + 400, + 'User must be a member/creator of the group or group must be open to add markets to it.' + ) + } + if (!group.contractIds.includes(contractRef.id)) { + await createGroupLinks(group, [contractRef.id], auth.uid) + await groupDocRef.update({ + contractIds: uniq([...group.contractIds, contractRef.id]), + }) + } + } + const providerId = user.id - if (outcomeType === 'BINARY') { + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) .doc() @@ -143,6 +209,31 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { ) await liquidityDoc.set(lp) + } else if (outcomeType === 'MULTIPLE_CHOICE') { + const betCol = firestore.collection(`contracts/${contract.id}/bets`) + const betDocs = (answers ?? []).map(() => betCol.doc()) + + const answerCol = firestore.collection(`contracts/${contract.id}/answers`) + const answerDocs = (answers ?? []).map((_, i) => + answerCol.doc(i.toString()) + ) + + const { bets, answerObjects } = getMultipleChoiceAntes( + user, + contract as MultipleChoiceContract, + answers ?? [], + betDocs.map((bd) => bd.id) + ) + + await Promise.all( + zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet)) + ) + await Promise.all( + zip(answerObjects, answerDocs).map(([answer, doc]) => + doc?.create(answer as Answer) + ) + ) + await contractRef.update({ answers: answerObjects }) } else if (outcomeType === 'FREE_RESPONSE') { const noneAnswerDoc = firestore .collection(`contracts/${contract.id}/answers`) @@ -199,3 +290,38 @@ export async function getContractFromSlug(slug: string) { return snap.empty ? undefined : (snap.docs[0].data() as Contract) } + +async function createGroupLinks( + group: Group, + contractIds: string[], + userId: string +) { + for (const contractId of contractIds) { + const contract = await getContract(contractId) + if (!contract?.groupSlugs?.includes(group.slug)) { + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), + }) + } + if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupLinks: [ + { + groupId: group.id, + name: group.name, + slug: group.slug, + userId, + createdTime: Date.now(), + } as GroupLink, + ...(contract?.groupLinks ?? []), + ], + }) + } + } +} diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index e7ee0cf5..a9626916 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -20,7 +20,7 @@ const bodySchema = z.object({ about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), }) -export const creategroup = newEndpoint(['POST'], async (req, auth) => { +export const creategroup = newEndpoint({}, async (req, auth) => { const { name, about, memberIds, anyoneCanJoin } = validate( bodySchema, req.body diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index daf7e9d7..51b884ad 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,13 +7,17 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getUserByUsername, getValues } from './utils' +import { getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' -import { Bet } from '../../common/bet' +import { Bet, LimitBet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' +import { TipTxn } from '../../common/txn' +import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Challenge } from '../../common/challenge' +import { richTextToString } from '../../common/util/parse' const firestore = admin.firestore() type user_to_reason_texts = { @@ -27,12 +31,22 @@ export const createNotification = async ( sourceUser: User, idempotencyKey: string, sourceText: string, - sourceContract?: Contract, - relatedSourceType?: notification_source_types, - relatedUserId?: string, - sourceSlug?: string, - sourceTitle?: string + miscData?: { + contract?: Contract + relatedSourceType?: notification_source_types + recipients?: string[] + slug?: string + title?: string + } ) => { + const { + contract: sourceContract, + relatedSourceType, + recipients, + slug, + title, + } = miscData ?? {} + const shouldGetNotification = ( userId: string, userToReasonTexts: user_to_reason_texts @@ -66,11 +80,10 @@ export const createNotification = async ( sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, sourceContractCreatorUsername: sourceContract?.creatorUsername, - // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, - sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, + sourceSlug: slug ? slug : sourceContract?.slug, + sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -116,7 +129,7 @@ export const createNotification = async ( }) } - const notifyRepliedUsers = async ( + const notifyRepliedUser = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string, relatedSourceType: notification_source_types @@ -133,7 +146,7 @@ export const createNotification = async ( } } - const notifyFollowedUser = async ( + const notifyFollowedUser = ( userToReasonTexts: user_to_reason_texts, followedUserId: string ) => { @@ -143,21 +156,13 @@ export const createNotification = async ( } } - const notifyTaggedUsers = async ( + const notifyTaggedUsers = ( userToReasonTexts: user_to_reason_texts, - sourceText: string + userIds: (string | undefined)[] ) => { - const taggedUsers = sourceText.match(/@\w+/g) - if (!taggedUsers) return - // await all get tagged users: - const users = await Promise.all( - taggedUsers.map(async (username) => { - return await getUserByUsername(username.slice(1)) - }) - ) - users.forEach((taggedUser) => { - if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts)) - userToReasonTexts[taggedUser.id] = { + userIds.forEach((id) => { + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { reason: 'tagged_user', } }) @@ -242,7 +247,7 @@ export const createNotification = async ( }) } - const notifyUserAddedToGroup = async ( + const notifyUserAddedToGroup = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string ) => { @@ -252,44 +257,62 @@ export const createNotification = async ( } } + const notifyContractCreatorOfUniqueBettorsBonus = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + userToReasonTexts[userId] = { + reason: 'unique_bettors_on_your_contract', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. - if (sourceContract) { - if ( - sourceType === 'comment' || - sourceType === 'answer' || - (sourceType === 'contract' && - (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) - ) { - if (sourceType === 'comment') { - if (relatedUserId && relatedSourceType) - await notifyRepliedUsers( - userToReasonTexts, - relatedUserId, - relatedSourceType - ) - if (sourceText) await notifyTaggedUsers(userToReasonTexts, sourceText) - } - await notifyContractCreator(userToReasonTexts, sourceContract) - await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) - await notifyLiquidityProviders(userToReasonTexts, sourceContract) - await notifyBettorsOnContract(userToReasonTexts, sourceContract) - await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) - } else if (sourceType === 'contract' && sourceUpdateType === 'created') { - await notifyUsersFollowers(userToReasonTexts) - } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) - } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { - await notifyContractCreator(userToReasonTexts, sourceContract) + if (sourceType === 'follow' && recipients?.[0]) { + notifyFollowedUser(userToReasonTexts, recipients[0]) + } else if ( + sourceType === 'group' && + sourceUpdateType === 'created' && + recipients + ) { + recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) + } + + // The following functions need sourceContract to be defined. + if (!sourceContract) return userToReasonTexts + + if ( + sourceType === 'comment' || + sourceType === 'answer' || + (sourceType === 'contract' && + (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) + ) { + if (sourceType === 'comment') { + if (recipients?.[0] && relatedSourceType) + notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) + if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) } - } else if (sourceType === 'follow' && relatedUserId) { - await notifyFollowedUser(userToReasonTexts, relatedUserId) - } else if (sourceType === 'group' && relatedUserId) { - if (sourceUpdateType === 'created') - await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + await notifyContractCreator(userToReasonTexts, sourceContract) + await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) + await notifyLiquidityProviders(userToReasonTexts, sourceContract) + await notifyBettorsOnContract(userToReasonTexts, sourceContract) + await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) + } else if (sourceType === 'contract' && sourceUpdateType === 'created') { + await notifyUsersFollowers(userToReasonTexts) + notifyTaggedUsers(userToReasonTexts, recipients ?? []) + } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { + await notifyContractCreator(userToReasonTexts, sourceContract, { + force: true, + }) + } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { + await notifyContractCreator(userToReasonTexts, sourceContract) + } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) } return userToReasonTexts } @@ -297,3 +320,187 @@ export const createNotification = async ( const userToReasonTexts = await getUsersToNotify() await createUsersNotifications(userToReasonTexts) } + +export const createTipNotification = async ( + fromUser: User, + toUser: User, + tip: TipTxn, + idempotencyKey: string, + commentId: string, + contract?: Contract, + group?: Group +) => { + const slug = group ? group.slug + `#${commentId}` : commentId + + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: 'tip_received', + createdTime: Date.now(), + isSeen: false, + sourceId: tip.id, + sourceType: 'tip', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: tip.amount.toString(), + sourceContractCreatorUsername: contract?.creatorUsername, + sourceContractTitle: contract?.question, + sourceContractSlug: contract?.slug, + sourceSlug: slug, + sourceTitle: group?.name, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} + +export const createBetFillNotification = async ( + fromUser: User, + toUser: User, + bet: Bet, + userBet: LimitBet, + contract: Contract, + idempotencyKey: string +) => { + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) + const fillAmount = fill?.amount ?? 0 + + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: 'bet_fill', + createdTime: Date.now(), + isSeen: false, + sourceId: userBet.id, + sourceType: 'bet', + sourceUpdateType: 'updated', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: fillAmount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} + +export const createGroupCommentNotification = async ( + fromUser: User, + toUserId: string, + comment: Comment, + group: Group, + idempotencyKey: string +) => { + if (toUserId === fromUser.id) return + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'on_group_you_are_member_of', + createdTime: Date.now(), + isSeen: false, + sourceId: comment.id, + sourceType: 'comment', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: richTextToString(comment.content), + sourceSlug, + sourceTitle: `${group.name}`, + isSeenOnHref: sourceSlug, + } + await notificationRef.set(removeUndefinedProps(notification)) +} + +export const createReferralNotification = async ( + toUser: User, + referredUser: User, + idempotencyKey: string, + bonusAmount: string, + referredByContract?: Contract, + referredByGroup?: Group +) => { + const notificationRef = firestore + .collection(`/users/${toUser.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUser.id, + reason: referredByGroup + ? 'user_joined_from_your_group_invite' + : referredByContract?.creatorId === toUser.id + ? 'user_joined_to_bet_on_your_market' + : 'you_referred_user', + createdTime: Date.now(), + isSeen: false, + sourceId: referredUser.id, + sourceType: 'user', + sourceUpdateType: 'updated', + sourceContractId: referredByContract?.id, + sourceUserName: referredUser.name, + sourceUserUsername: referredUser.username, + sourceUserAvatarUrl: referredUser.avatarUrl, + sourceText: bonusAmount, + // Only pass the contract referral details if they weren't referred to a group + sourceContractCreatorUsername: !referredByGroup + ? referredByContract?.creatorUsername + : undefined, + sourceContractTitle: !referredByGroup + ? referredByContract?.question + : undefined, + sourceContractSlug: !referredByGroup ? referredByContract?.slug : undefined, + sourceSlug: referredByGroup + ? groupPath(referredByGroup.slug) + : referredByContract?.slug, + sourceTitle: referredByGroup + ? referredByGroup.name + : referredByContract?.question, + } + await notificationRef.set(removeUndefinedProps(notification)) +} + +const groupPath = (groupSlug: string) => `/group/${groupSlug}` + +export const createChallengeAcceptedNotification = async ( + challenger: User, + challengeCreator: User, + challenge: Challenge, + acceptedAmount: number, + contract: Contract +) => { + const notificationRef = firestore + .collection(`/users/${challengeCreator.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: challengeCreator.id, + reason: 'challenge_accepted', + createdTime: Date.now(), + isSeen: false, + sourceId: challenge.slug, + sourceType: 'challenge', + sourceUpdateType: 'updated', + sourceUserName: challenger.name, + sourceUserUsername: challenger.username, + sourceUserAvatarUrl: challenger.avatarUrl, + sourceText: acceptedAmount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 189976ed..bd65b14a 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,13 +1,16 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' +import { uniq } from 'lodash' import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USERNAME, PrivateUser, STARTING_BALANCE, SUS_STARTING_BALANCE, User, } from '../../common/user' -import { getUser, getUserByUsername } from './utils' +import { getUser, getUserByUsername, getValues, isProd } from './utils' import { randomString } from '../../common/util/random' import { cleanDisplayName, @@ -15,86 +18,88 @@ import { } from '../../common/util/clean-username' import { sendWelcomeEmail } from './emails' import { isWhitelisted } from '../../common/envs/constants' -import { DEFAULT_CATEGORIES } from '../../common/categories' +import { + CATEGORIES_GROUP_SLUG_POSTFIX, + DEFAULT_CATEGORIES, +} from '../../common/categories' import { track } from './analytics' +import { APIError, newEndpoint, validate } from './api' +import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' -export const createUser = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall(async (data: { deviceToken?: string }, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + deviceToken: z.string().optional(), +}) - const preexistingUser = await getUser(userId) - if (preexistingUser) - return { - status: 'error', - message: 'User already created', - user: preexistingUser, - } +const opts = { secrets: ['MAILGUN_KEY'] } - const fbUser = await admin.auth().getUser(userId) +export const createuser = newEndpoint(opts, async (req, auth) => { + const { deviceToken } = validate(bodySchema, req.body) + const preexistingUser = await getUser(auth.uid) + if (preexistingUser) + throw new APIError(400, 'User already exists', { user: preexistingUser }) - const email = fbUser.email - if (!isWhitelisted(email)) { - return { status: 'error', message: `${email} is not whitelisted` } - } - const emailName = email?.replace(/@.*$/, '') + const fbUser = await admin.auth().getUser(auth.uid) - const rawName = fbUser.displayName || emailName || 'User' + randomString(4) - const name = cleanDisplayName(rawName) - let username = cleanUsername(name) + const email = fbUser.email + if (!isWhitelisted(email)) { + throw new APIError(400, `${email} is not whitelisted`) + } + const emailName = email?.replace(/@.*$/, '') - const sameNameUser = await getUserByUsername(username) - if (sameNameUser) { - username += randomString(4) - } + const rawName = fbUser.displayName || emailName || 'User' + randomString(4) + const name = cleanDisplayName(rawName) + let username = cleanUsername(name) - const avatarUrl = fbUser.photoURL + const sameNameUser = await getUserByUsername(username) + if (sameNameUser) { + username += randomString(4) + } - const { deviceToken } = data - const deviceUsedBefore = - !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) + const avatarUrl = fbUser.photoURL + const deviceUsedBefore = + !deviceToken || (await isPrivateUserWithDeviceToken(deviceToken)) - const ipAddress = context.rawRequest.ip - const ipCount = ipAddress ? await numberUsersWithIp(ipAddress) : 0 + const balance = deviceUsedBefore ? SUS_STARTING_BALANCE : STARTING_BALANCE - const balance = - deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE + const user: User = { + id: auth.uid, + name, + username, + avatarUrl, + balance, + totalDeposits: balance, + createdTime: Date.now(), + profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, + creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, + followerCountCached: 0, + followedCategories: DEFAULT_CATEGORIES, + shouldShowWelcome: true, + } - const user: User = { - id: userId, - name, - username, - avatarUrl, - balance, - totalDeposits: balance, - createdTime: Date.now(), - profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, - creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 }, - followerCountCached: 0, - followedCategories: DEFAULT_CATEGORIES, - } + await firestore.collection('users').doc(auth.uid).create(user) + console.log('created user', username, 'firebase id:', auth.uid) - await firestore.collection('users').doc(userId).create(user) - console.log('created user', username, 'firebase id:', userId) + const privateUser: PrivateUser = { + id: auth.uid, + username, + email, + initialIpAddress: req.ip, + initialDeviceToken: deviceToken, + } - const privateUser: PrivateUser = { - id: userId, - username, - email, - initialIpAddress: ipAddress, - initialDeviceToken: deviceToken, - } + await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await firestore.collection('private-users').doc(userId).create(privateUser) + await addUserToDefaultGroups(user) + await sendWelcomeEmail(user, privateUser) + await track(auth.uid, 'create user', { username }, { ip: req.ip }) - await sendWelcomeEmail(user, privateUser) - - await track(userId, 'create user', { username }, { ip: ipAddress }) - - return { status: 'success', user } - }) + return { user, privateUser } +}) const firestore = admin.firestore() @@ -107,7 +112,7 @@ const isPrivateUserWithDeviceToken = async (deviceToken: string) => { return !snap.empty } -const numberUsersWithIp = async (ipAddress: string) => { +export const numberUsersWithIp = async (ipAddress: string) => { const snap = await firestore .collection('private-users') .where('initialIpAddress', '==', ipAddress) @@ -115,3 +120,50 @@ const numberUsersWithIp = async (ipAddress: string) => { return snap.docs.length } + +const addUserToDefaultGroups = async (user: User) => { + for (const category of Object.values(DEFAULT_CATEGORIES)) { + const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX + const groups = await getValues( + firestore.collection('groups').where('slug', '==', slug) + ) + await firestore + .collection('groups') + .doc(groups[0].id) + .update({ + memberIds: uniq(groups[0].memberIds.concat(user.id)), + }) + } + + for (const slug of NEW_USER_GROUP_SLUGS) { + const groups = await getValues( + firestore.collection('groups').where('slug', '==', slug) + ) + const group = groups[0] + await firestore + .collection('groups') + .doc(group.id) + .update({ + memberIds: uniq(group.memberIds.concat(user.id)), + }) + const manifoldAccount = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + + if (slug === 'welcome') { + const welcomeCommentDoc = firestore + .collection(`groups/${group.id}/comments`) + .doc() + await welcomeCommentDoc.create({ + id: welcomeCommentDoc.id, + groupId: group.id, + userId: manifoldAccount, + text: `Welcome, @${user.username} aka ${user.name}!`, + createdTime: Date.now(), + userName: 'Manifold Markets', + userUsername: MANIFOLD_USERNAME, + userAvatarUrl: MANIFOLD_AVATAR_URL, + }) + } + } +} diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html new file mode 100644 index 00000000..1ef9dbb7 --- /dev/null +++ b/functions/src/email-templates/500-mana.html @@ -0,0 +1,260 @@ + + + + + Manifold Markets 7th Day Anniversary Gift! + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

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

+
+
+

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

+ +

 

+

Cheers,

+

David from Manifold

+

 

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

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

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

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

+

+   +

+

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

+

+   +

+

+ What makes a good question? +

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

+   +

+

+ Examples of markets you should + emulate!  +

+ +

+   +

+

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

+

+ Thanks for reading! +

+

+ David from Manifold +

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

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

+
+
+
+
+ +
+
+ + + + diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 00e8b439..711f7ccb 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -613,7 +613,7 @@ >our Discord! Or, - - Welcome to Manifold Markets - - - - - - - + + + + + + - - - + + - + + + - - - -
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
- -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
-
-

- Hi {{name}}, thanks for joining Manifold - Markets!

We can't wait to see what questions you - will ask! -

-

- As a gift M$1000 has been credited to your - account - the equivalent of 10 USD. - -

-

- Click the buttons to see what you can do with - it! -

-
-
-
- -
- - - - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - -
- - - -
-
- - - - - - -
- - - -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
-
-

- If you have any questions or feedback we'd - love to hear from you in our Discord server! -

-

-

- Looking forward to seeing you, -

-

- David from Manifold -

-
-

-
-
-
- -
-
- -
- - - - - - -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-

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

-
-
-
-
- -
-
- + } + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

+ Hi {{name}},

+
+
+
+

+ Welcome! Manifold Markets is a play-money prediction market platform where you can bet on + anything, from elections to Elon Musk to scientific papers to the NBA.

+
+
+
+

+ +

+
+
+

+
+ + + + +
+ + + + +
+ + Explore markets + +
+
+
+
+

+ +

 

+

Cheers,

+

David from Manifold

+

 

+
+
+
+ +
- - + +
+ + + +
+ +
+ + + + + + + +
+
- + ) } diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index a31957cb..426a9371 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -41,7 +41,7 @@ export function AmountInput(props: { {label} void className?: string isModal?: boolean @@ -44,6 +46,7 @@ export function AnswerBetPanel(props: { const inputRef = useRef(null) useEffect(() => { + if (isIOS()) window.scrollTo(0, window.scrollY + 200) inputRef.current && inputRef.current.focus() }, []) @@ -111,6 +114,8 @@ export function AnswerBetPanel(props: { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) + const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) + return ( @@ -137,6 +142,22 @@ export function AnswerBetPanel(props: { disabled={isSubmitting} inputRef={inputRef} /> + + {(betAmount ?? 0) > 10 && + bankrollFraction >= 0.5 && + bankrollFraction <= 1 ? ( + + ) : ( + '' + )} +
Probability
diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index 87756a07..f1ab2f88 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { Answer } from 'common/answer' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { Row } from '../layout/row' import { Avatar } from '../avatar' @@ -13,7 +13,7 @@ import { Linkify } from '../linkify' export function AnswerItem(props: { answer: Answer - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract showChoice: 'radio' | 'checkbox' | undefined chosenProb: number | undefined totalChosenProb?: number diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 81b94550..0a4ac1e1 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -1,17 +1,17 @@ import clsx from 'clsx' -import { sum, mapValues } from 'lodash' +import { sum } from 'lodash' import { useState } from 'react' -import { Contract, FreeResponse } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' -import { resolveMarket } from 'web/lib/firebase/fn-call' +import { APIError, resolveMarket } from 'web/lib/firebase/api' import { Row } from '../layout/row' import { ChooseCancelSelector } from '../yes-no-selector' import { ResolveConfirmationButton } from '../confirmation-button' import { removeUndefinedProps } from 'common/util/object' export function AnswerResolvePanel(props: { - contract: Contract & FreeResponse + contract: FreeResponseContract | MultipleChoiceContract resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined setResolveOption: ( option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined @@ -31,30 +31,34 @@ export function AnswerResolvePanel(props: { setIsSubmitting(true) const totalProb = sum(Object.values(chosenAnswers)) - const normalizedProbs = mapValues( - chosenAnswers, - (prob) => (100 * prob) / totalProb - ) + const resolutions = Object.entries(chosenAnswers).map(([i, p]) => { + return { answer: parseInt(i), pct: (100 * p) / totalProb } + }) const resolutionProps = removeUndefinedProps({ outcome: resolveOption === 'CHOOSE' - ? answers[0] + ? parseInt(answers[0]) : resolveOption === 'CHOOSE_MULTIPLE' ? 'MKT' : 'CANCEL', resolutions: - resolveOption === 'CHOOSE_MULTIPLE' ? normalizedProbs : undefined, + resolveOption === 'CHOOSE_MULTIPLE' ? resolutions : undefined, contractId: contract.id, }) - const result = await resolveMarket(resolutionProps).then((r) => r.data) - - console.log('resolved', resolutionProps, 'result:', result) - - if (result?.status !== 'success') { - setError(result?.message || 'Error resolving market') + try { + const result = await resolveMarket(resolutionProps) + console.log('resolved', resolutionProps, 'result:', result) + } catch (e) { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error resolving market') + } } + setResolveOption(undefined) setIsSubmitting(false) } diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index 3e16a4c2..27152db9 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -5,14 +5,14 @@ import { groupBy, sortBy, sumBy } from 'lodash' import { memo } from 'react' import { Bet } from 'common/bet' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { getOutcomeProbability } from 'common/calculate' import { useWindowSize } from 'web/hooks/use-window-size' const NUM_LINES = 6 export const AnswersGraph = memo(function AnswersGraph(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract bets: Bet[] height?: number }) { @@ -178,15 +178,22 @@ function formatTime( return d.format(format) } -const computeProbsByOutcome = (bets: Bet[], contract: FreeResponseContract) => { - const { totalBets } = contract +const computeProbsByOutcome = ( + bets: Bet[], + contract: FreeResponseContract | MultipleChoiceContract +) => { + const { totalBets, outcomeType } = contract const betsByOutcome = groupBy(bets, (bet) => bet.outcome) const outcomes = Object.keys(betsByOutcome).filter((outcome) => { const maxProb = Math.max( ...betsByOutcome[outcome].map((bet) => bet.probAfter) ) - return outcome !== '0' && maxProb > 0.02 && totalBets[outcome] > 0.000000001 + return ( + (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + maxProb > 0.02 && + totalBets[outcome] > 0.000000001 + ) }) const trackedOutcomes = sortBy( diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index e7bf4da8..6e0bfef6 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,7 +1,7 @@ import { sortBy, partition, sum, uniq } from 'lodash' import { useEffect, useState } from 'react' -import { FreeResponseContract } from 'common/contract' +import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { Col } from '../layout/col' import { useUser } from 'web/hooks/use-user' import { getDpmOutcomeProbability } from 'common/calculate-dpm' @@ -25,14 +25,19 @@ import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' -export function AnswersPanel(props: { contract: FreeResponseContract }) { +export function AnswersPanel(props: { + contract: FreeResponseContract | MultipleChoiceContract +}) { const { contract } = props - const { creatorId, resolution, resolutions, totalBets } = contract + const { creatorId, resolution, resolutions, totalBets, outcomeType } = + contract const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( answers.filter( - (answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001 + (answer) => + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && + totalBets[answer.id] > 0.000000001 ), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) @@ -131,7 +136,8 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) {
No answers yet...
)} - {tradingAllowed(contract) && + {outcomeType === 'FREE_RESPONSE' && + tradingAllowed(contract) && (!resolveOption || resolveOption === 'CANCEL') && ( )} @@ -152,7 +158,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) { } function getAnswerItems( - contract: FreeResponseContract, + contract: FreeResponseContract | MultipleChoiceContract, answers: Answer[], user: User | undefined | null ) { @@ -178,7 +184,7 @@ function getAnswerItems( } function OpenAnswer(props: { - contract: FreeResponseContract + contract: FreeResponseContract | MultipleChoiceContract answer: Answer items: ActivityItem[] type: string diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 6eeadf97..ce266778 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -6,7 +6,7 @@ import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { createAnswer } from 'web/lib/firebase/fn-call' +import { APIError, createAnswer } from 'web/lib/firebase/api' import { Row } from '../layout/row' import { formatMoney, @@ -46,19 +46,23 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (canSubmit) { setIsSubmitting(true) - const result = await createAnswer({ - contractId: contract.id, - text, - amount: betAmount, - }).then((r) => r.data) - - setIsSubmitting(false) - - if (result.status === 'success') { + try { + await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }) setText('') setBetAmount(10) setAmountError(undefined) - } else setAmountError(result.message) + setPossibleDuplicateAnswer(undefined) + } catch (e) { + if (e instanceof APIError) { + setAmountError(e.toString()) + } + } + + setIsSubmitting(false) } } diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx new file mode 100644 index 00000000..c2857eb2 --- /dev/null +++ b/web/components/answers/multiple-choice-answers.tsx @@ -0,0 +1,67 @@ +import { MAX_ANSWER_LENGTH } from 'common/answer' +import Textarea from 'react-expanding-textarea' +import { XIcon } from '@heroicons/react/solid' +import { Col } from '../layout/col' +import { Row } from '../layout/row' + +export function MultipleChoiceAnswers(props: { + answers: string[] + setAnswers: (answers: string[]) => void +}) { + const { answers, setAnswers } = props + + const setAnswer = (i: number, answer: string) => { + const newAnswers = setElement(answers, i, answer) + setAnswers(newAnswers) + } + + const removeAnswer = (i: number) => { + const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1)) + setAnswers(newAnswers) + } + + const addAnswer = () => setAnswer(answers.length, '') + + return ( +
+ {answers.map((answer, i) => ( + + {i + 1}.{' '} + - - - - - -
- - -
+ + + +
+
+ + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

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

+
+
+
+
+
+
+
+ +
+ + + + + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1ba8ca96..a097393e 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,4 +1,4 @@ -import { DOMAIN, PROJECT_ID } from '../../common/envs/constants' +import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' @@ -6,11 +6,20 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatMoney, formatPercent } from '../../common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from '../../common/util/format' import { getValueFromBucket } from '../../common/calculate-dpm' +import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' +import { getFunctionUrl } from '../../common/api' +import { richTextToString } from '../../common/util/parse' + +const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') export const sendMarketResolutionEmail = async ( userId: string, @@ -48,6 +57,9 @@ export const sendMarketResolutionEmail = async ( ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' + const emailType = 'market-resolved' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const templateData: market_resolved_template = { userId: user.id, name: user.name, @@ -57,6 +69,7 @@ export const sendMarketResolutionEmail = async ( investment: `${Math.floor(investment)}`, payout: `${Math.floor(payout)}${creatorPayoutText}`, url: `https://${DOMAIN}/${creator.username}/${contract.slug}`, + unsubscribeUrl, } // Modify template here: @@ -80,6 +93,7 @@ type market_resolved_template = { investment: string payout: string url: string + unsubscribeUrl: string } const toDisplayResolution = ( @@ -101,6 +115,17 @@ const toDisplayResolution = ( return display || resolution } + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + const { resolutionValue } = contract + + return resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? getProbability(contract), + contract + ) + } + if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' @@ -125,7 +150,7 @@ export const sendWelcomeEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}` + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -141,7 +166,6 @@ export const sendWelcomeEmail = async ( ) } -// TODO: use manalinks to give out M$500 export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -157,16 +181,16 @@ export const sendOneWeekBonusEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}` + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, - 'Manifold one week anniversary gift', + 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, unsubscribeLink, - manalink: '', // TODO + manalink: 'https://manifold.markets/link/lj4JbBvE', }, { from: 'David from Manifold ', @@ -189,7 +213,7 @@ export const sendThankYouEmail = async ( const firstName = name.split(' ')[0] const emailType = 'generic' - const unsubscribeLink = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=${emailType}` + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -223,6 +247,8 @@ export const sendMarketCloseEmail = async ( const { question, slug, volume, mechanism, collectedFees } = contract const url = `https://${DOMAIN}/${username}/${slug}` + const emailType = 'market-resolve' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` await sendTemplateEmail( privateUser.email, @@ -231,6 +257,7 @@ export const sendMarketCloseEmail = async ( { question, url, + unsubscribeUrl, userId, name: firstName, volume: formatMoney(volume), @@ -261,11 +288,12 @@ export const sendNewCommentEmail = async ( const { question, creatorUsername, slug } = contract const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` - - const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-comment` + const emailType = 'market-comment' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { text } = comment + const { content } = comment + const text = richTextToString(content) let betDescription = '' if (bet) { @@ -275,7 +303,7 @@ export const sendNewCommentEmail = async ( )}` } - const subject = `Comment from ${commentorName} on ${question}` + const subject = `Comment on ${question}` const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { @@ -343,7 +371,8 @@ export const sendNewAnswerEmail = async ( const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const unsubscribeUrl = `https://us-central1-${PROJECT_ID}.cloudfunctions.net/unsubscribe?id=${userId}&type=market-answer` + const emailType = 'market-answer' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} ` diff --git a/functions/src/fetch.ts b/functions/src/fetch.ts deleted file mode 100644 index 1b54dc6c..00000000 --- a/functions/src/fetch.ts +++ /dev/null @@ -1,9 +0,0 @@ -let fetchRequest: typeof fetch - -try { - fetchRequest = fetch -} catch { - fetchRequest = require('node-fetch') -} - -export default fetchRequest diff --git a/functions/src/get-current-user.ts b/functions/src/get-current-user.ts new file mode 100644 index 00000000..409f897f --- /dev/null +++ b/functions/src/get-current-user.ts @@ -0,0 +1,18 @@ +import { User } from 'common/user' +import * as admin from 'firebase-admin' +import { newEndpoint, APIError } from './api' + +export const getcurrentuser = newEndpoint( + { method: 'GET' }, + async (_req, auth) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const [userSnap] = await firestore.getAll(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + + const user = userSnap.data() as User + + return user + } +) + +const firestore = admin.firestore() diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts new file mode 100644 index 00000000..4aaaac11 --- /dev/null +++ b/functions/src/get-custom-token.ts @@ -0,0 +1,33 @@ +import * as admin from 'firebase-admin' +import { + APIError, + EndpointDefinition, + lookupUser, + parseCredentials, + writeResponseError, +} from './api' + +const opts = { + method: 'GET', + minInstances: 1, + concurrency: 100, + memory: '2GiB', + cpu: 1, +} as const + +export const getcustomtoken: EndpointDefinition = { + opts, + handler: async (req, res) => { + try { + const credentials = await parseCredentials(req) + if (credentials.kind != 'jwt') { + throw new APIError(403, 'API keys cannot mint custom tokens.') + } + const user = await lookupUser(credentials) + const token = await admin.auth().createCustomToken(user.uid) + res.status(200).json({ token: token }) + } catch (e) { + writeResponseError(e, res) + } + }, +} diff --git a/functions/src/health.ts b/functions/src/health.ts index 6f4d73dc..4ce04e05 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,6 +1,6 @@ import { newEndpoint } from './api' -export const health = newEndpoint(['GET'], async (_req, auth) => { +export const health = newEndpoint({ method: 'GET' }, async (_req, auth) => { return { message: 'Server is working.', uid: auth.uid, diff --git a/functions/src/index.ts b/functions/src/index.ts index b5a23fba..4c8a6782 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,26 +1,18 @@ import * as admin from 'firebase-admin' +import { onRequest } from 'firebase-functions/v2/https' +import { EndpointDefinition } from './api' admin.initializeApp() // v1 -// export * from './keep-awake' -export * from './claim-manalink' -export * from './transact' -export * from './resolve-market' -export * from './stripe' -export * from './create-user' -export * from './create-answer' export * from './on-create-bet' -export * from './on-create-comment' +export * from './on-create-comment-on-contract' export * from './on-view' -export * from './unsubscribe' export * from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' -export * from './change-user-info' export * from './market-close-notifications' -export * from './add-liquidity' export * from './on-create-answer' export * from './on-update-contract' export * from './on-create-contract' @@ -29,12 +21,98 @@ export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' export * from './on-create-group' +export * from './on-update-user' +export * from './on-create-comment-on-group' +export * from './on-create-txn' +export * from './on-delete-group' +export * from './score-contracts' // v2 export * from './health' +export * from './transact' +export * from './change-user-info' +export * from './create-user' +export * from './create-answer' export * from './place-bet' +export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' +export * from './claim-manalink' export * from './create-contract' +export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' +export * from './resolve-market' +export * from './unsubscribe' +export * from './stripe' +export * from './mana-bonus-email' + +import { health } from './health' +import { transact } from './transact' +import { changeuserinfo } from './change-user-info' +import { createuser } from './create-user' +import { createanswer } from './create-answer' +import { placebet } from './place-bet' +import { cancelbet } from './cancel-bet' +import { sellbet } from './sell-bet' +import { sellshares } from './sell-shares' +import { claimmanalink } from './claim-manalink' +import { createmarket } from './create-contract' +import { addliquidity } from './add-liquidity' +import { withdrawliquidity } from './withdraw-liquidity' +import { creategroup } from './create-group' +import { resolvemarket } from './resolve-market' +import { unsubscribe } from './unsubscribe' +import { stripewebhook, createcheckoutsession } from './stripe' +import { getcurrentuser } from './get-current-user' +import { acceptchallenge } from './accept-challenge' +import { getcustomtoken } from './get-custom-token' + +const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { + return onRequest(opts, handler as any) +} +const healthFunction = toCloudFunction(health) +const transactFunction = toCloudFunction(transact) +const changeUserInfoFunction = toCloudFunction(changeuserinfo) +const createUserFunction = toCloudFunction(createuser) +const createAnswerFunction = toCloudFunction(createanswer) +const placeBetFunction = toCloudFunction(placebet) +const cancelBetFunction = toCloudFunction(cancelbet) +const sellBetFunction = toCloudFunction(sellbet) +const sellSharesFunction = toCloudFunction(sellshares) +const claimManalinkFunction = toCloudFunction(claimmanalink) +const createMarketFunction = toCloudFunction(createmarket) +const addLiquidityFunction = toCloudFunction(addliquidity) +const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) +const createGroupFunction = toCloudFunction(creategroup) +const resolveMarketFunction = toCloudFunction(resolvemarket) +const unsubscribeFunction = toCloudFunction(unsubscribe) +const stripeWebhookFunction = toCloudFunction(stripewebhook) +const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) +const getCurrentUserFunction = toCloudFunction(getcurrentuser) +const acceptChallenge = toCloudFunction(acceptchallenge) +const getCustomTokenFunction = toCloudFunction(getcustomtoken) + +export { + healthFunction as health, + transactFunction as transact, + changeUserInfoFunction as changeuserinfo, + createUserFunction as createuser, + createAnswerFunction as createanswer, + placeBetFunction as placebet, + cancelBetFunction as cancelbet, + sellBetFunction as sellbet, + sellSharesFunction as sellshares, + claimManalinkFunction as claimmanalink, + createMarketFunction as createmarket, + addLiquidityFunction as addliquidity, + withdrawLiquidityFunction as withdrawliquidity, + createGroupFunction as creategroup, + resolveMarketFunction as resolvemarket, + unsubscribeFunction as unsubscribe, + stripeWebhookFunction as stripewebhook, + createCheckoutSessionFunction as createcheckoutsession, + getCurrentUserFunction as getcurrentuser, + acceptChallenge as acceptchallenge, + getCustomTokenFunction as getcustomtoken, +} diff --git a/functions/src/keep-awake.ts b/functions/src/keep-awake.ts deleted file mode 100644 index 00799e65..00000000 --- a/functions/src/keep-awake.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as functions from 'firebase-functions' - -import { callCloudFunction } from './call-cloud-function' - -export const keepAwake = functions.pubsub - .schedule('every 1 minutes') - .onRun(async () => { - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - - await sleep(30) - - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - }) - -const sleep = (seconds: number) => { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)) -} diff --git a/functions/src/mana-bonus-email.ts b/functions/src/mana-bonus-email.ts new file mode 100644 index 00000000..29a7e6e0 --- /dev/null +++ b/functions/src/mana-bonus-email.ts @@ -0,0 +1,42 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import * as dayjs from 'dayjs' + +import { getPrivateUser } from './utils' +import { sendOneWeekBonusEmail } from './emails' +import { User } from 'common/user' + +export const manabonusemail = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('0 9 * * 1-7') + .onRun(async () => { + await sendOneWeekEmails() + }) + +const firestore = admin.firestore() + +async function sendOneWeekEmails() { + const oneWeekAgo = dayjs().subtract(1, 'week').valueOf() + const twoWeekAgo = dayjs().subtract(2, 'weeks').valueOf() + + const userDocs = await firestore + .collection('users') + .where('createdTime', '<=', oneWeekAgo) + .get() + + for (const user of userDocs.docs.map((d) => d.data() as User)) { + if (user.createdTime < twoWeekAgo) continue + + const privateUser = await getPrivateUser(user.id) + if (!privateUser || privateUser.manaBonusEmailSent) continue + + await firestore + .collection('private-users') + .doc(user.id) + .update({ manaBonusEmailSent: true }) + + console.log('sending m$ bonus email to', user.username) + await sendOneWeekBonusEmail(user, privateUser) + return + } +} diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index ee9952bf..f31674a1 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -64,7 +64,7 @@ async function sendMarketCloseEmails() { user, 'closed' + contract.id.slice(6, contract.id.length), contract.closeTime?.toString() ?? new Date().toString(), - contract + { contract } ) } } diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 78fd1399..6af5e699 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -10,14 +10,14 @@ export const onCreateAnswer = functions.firestore contractId: string } const { eventId } = context - const contract = await getContract(contractId) - if (!contract) - throw new Error('Could not find contract corresponding with answer') - const answer = change.data() as Answer // Ignore ante answer. if (answer.number === 0) return + const contract = await getContract(contractId) + if (!contract) + throw new Error('Could not find contract corresponding with answer') + const answerCreator = await getUser(answer.userId) if (!answerCreator) throw new Error('Could not find answer creator') @@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore answerCreator, eventId, answer.text, - contract + { contract } ) }) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 3e615e42..d33e71dd 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -1,9 +1,26 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { keyBy, uniq } from 'lodash' -import { Bet } from '../../common/bet' +import { Bet, LimitBet } from '../../common/bet' +import { getContract, getUser, getValues, isProd, log } from './utils' +import { + createBetFillNotification, + createNotification, +} from './create-notification' +import { filterDefined } from '../../common/util/array' +import { Contract } from '../../common/contract' +import { runTxn, TxnData } from './transact' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { APIError } from '../../common/api' +import { User } from '../../common/user' const firestore = admin.firestore() +const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() export const onCreateBet = functions.firestore .document('contracts/{contractId}/bets/{betId}') @@ -11,6 +28,8 @@ export const onCreateBet = functions.firestore const { contractId } = context.params as { contractId: string } + const { eventId } = context + const bet = change.data() as Bet const lastBetTime = bet.createdTime @@ -18,4 +37,146 @@ export const onCreateBet = functions.firestore .collection('contracts') .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) + + await notifyFills(bet, contractId, eventId) + await updateUniqueBettorsAndGiveCreatorBonus( + contractId, + eventId, + bet.userId + ) }) + +const updateUniqueBettorsAndGiveCreatorBonus = async ( + contractId: string, + eventId: string, + bettorId: string +) => { + const userContractSnap = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + const contract = userContractSnap.data() as Contract + if (!contract) { + log(`Could not find contract ${contractId}`) + return + } + let previousUniqueBettorIds = contract.uniqueBettorIds + + if (!previousUniqueBettorIds) { + const contractBets = ( + await firestore.collection(`contracts/${contractId}/bets`).get() + ).docs.map((doc) => doc.data() as Bet) + + if (contractBets.length === 0) { + log(`No bets for contract ${contractId}`) + return + } + + previousUniqueBettorIds = uniq( + contractBets + .filter((bet) => bet.createdTime < BONUS_START_DATE) + .map((bet) => bet.userId) + ) + } + + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) + + const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) + // Update contract unique bettors + if (!contract.uniqueBettorIds || isNewUniqueBettor) { + log(`Got ${previousUniqueBettorIds} unique bettors`) + isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) + await firestore.collection(`contracts`).doc(contractId).update({ + uniqueBettorIds: newUniqueBettorIds, + uniqueBettorCount: newUniqueBettorIds.length, + }) + } + + // No need to give a bonus for the creator's bet + if (!isNewUniqueBettor || bettorId == contract.creatorId) return + + // Create combined txn for all new unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettorIds: newUniqueBettorIds, + } + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: contract.creatorId, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + eventId + '-bonus', + result.txn.amount + '', + { + contract, + slug: contract.slug, + title: contract.question, + } + ) + } +} + +const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { + if (!bet.fills) return + + const user = await getUser(bet.userId) + if (!user) return + const contract = await getContract(contractId) + if (!contract) return + + const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) + const matchedBets = ( + await Promise.all( + matchedFills.map((fill) => + getValues( + firestore.collectionGroup('bets').where('id', '==', fill.matchedBetId) + ) + ) + ) + ).flat() + + const betUsers = await Promise.all( + matchedBets.map((bet) => getUser(bet.userId)) + ) + const betUsersById = keyBy(filterDefined(betUsers), 'id') + + await Promise.all( + matchedBets.map((matchedBet) => { + const matchedUser = betUsersById[matchedBet.userId] + if (!matchedUser) return + + return createBetFillNotification( + user, + matchedUser, + bet, + matchedBet, + contract, + eventId + ) + }) + ) +} diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment-on-contract.ts similarity index 87% rename from functions/src/on-create-comment.ts rename to functions/src/on-create-comment-on-contract.ts index 8d52fd46..d7aa0c5e 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,17 +1,17 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - +import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createNotification } from './create-notification' +import { parseMentions, richTextToString } from '../../common/util/parse' const firestore = admin.firestore() -export const onCreateComment = functions +export const onCreateCommentOnContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .firestore.document('contracts/{contractId}/comments/{commentId}') .onCreate(async (change, context) => { @@ -68,20 +68,22 @@ export const onCreateComment = functions ? 'answer' : undefined - const relatedUser = comment.replyToCommentId + const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const recipients = uniq( + compact([...parseMentions(comment.content), repliedUserId]) + ) + await createNotification( comment.id, 'comment', 'created', commentCreator, eventId, - comment.text, - contract, - relatedSourceType, - relatedUser + richTextToString(comment.content), + { contract, relatedSourceType, recipients } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts new file mode 100644 index 00000000..0064480f --- /dev/null +++ b/functions/src/on-create-comment-on-group.ts @@ -0,0 +1,46 @@ +import * as functions from 'firebase-functions' +import { Comment } from '../../common/comment' +import * as admin from 'firebase-admin' +import { Group } from '../../common/group' +import { User } from '../../common/user' +import { createGroupCommentNotification } from './create-notification' +const firestore = admin.firestore() + +export const onCreateCommentOnGroup = functions.firestore + .document('groups/{groupId}/comments/{commentId}') + .onCreate(async (change, context) => { + const { eventId } = context + const { groupId } = context.params as { + groupId: string + } + + const comment = change.data() as Comment + const creatorSnapshot = await firestore + .collection('users') + .doc(comment.userId) + .get() + if (!creatorSnapshot.exists) throw new Error('Could not find user') + + const groupSnapshot = await firestore + .collection('groups') + .doc(groupId) + .get() + if (!groupSnapshot.exists) throw new Error('Could not find group') + + const group = groupSnapshot.data() as Group + await firestore.collection('groups').doc(groupId).update({ + mostRecentChatActivityTime: comment.createdTime, + }) + + await Promise.all( + group.memberIds.map(async (memberId) => { + return await createGroupCommentNotification( + creatorSnapshot.data() as User, + memberId, + comment, + group, + eventId + ) + }) + ) + }) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 20c7ceba..6b57a9a0 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -2,6 +2,8 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' +import { parseMentions, richTextToString } from '../../common/util/parse' +import { JSONContent } from '@tiptap/core' export const onCreateContract = functions.firestore .document('contracts/{contractId}') @@ -12,13 +14,16 @@ export const onCreateContract = functions.firestore const contractCreator = await getUser(contract.creatorId) if (!contractCreator) throw new Error('Could not find contract creator') + const desc = contract.description as JSONContent + const mentioned = parseMentions(desc) + await createNotification( contract.id, 'contract', 'created', contractCreator, eventId, - contract.description, - contract + richTextToString(desc), + { contract, recipients: mentioned } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 1d041c04..5209788d 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -12,19 +12,17 @@ export const onCreateGroup = functions.firestore const groupCreator = await getUser(group.creatorId) if (!groupCreator) throw new Error('Could not find group creator') // create notifications for all members of the group - for (const memberId of group.memberIds) { - await createNotification( - group.id, - 'group', - 'created', - groupCreator, - eventId, - group.about, - undefined, - undefined, - memberId, - group.slug, - group.name - ) - } + await createNotification( + group.id, + 'group', + 'created', + groupCreator, + eventId, + group.about, + { + recipients: group.memberIds, + slug: group.slug, + title: group.name, + } + ) }) diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index d55b2be4..6ec092a5 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -8,14 +8,14 @@ export const onCreateLiquidityProvision = functions.firestore .onCreate(async (change, context) => { const liquidity = change.data() as LiquidityProvision const { eventId } = context - const contract = await getContract(liquidity.contractId) - - if (!contract) - throw new Error('Could not find contract corresponding with liquidity') // Ignore Manifold Markets liquidity for now - users see a notification for free market liquidity provision if (liquidity.userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2') return + const contract = await getContract(liquidity.contractId) + if (!contract) + throw new Error('Could not find contract corresponding with liquidity') + const liquidityProvider = await getUser(liquidity.userId) if (!liquidityProvider) throw new Error('Could not find liquidity provider') @@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore liquidityProvider, eventId, liquidity.amount.toString(), - contract + { contract } ) }) diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts new file mode 100644 index 00000000..b915cfa1 --- /dev/null +++ b/functions/src/on-create-txn.ts @@ -0,0 +1,81 @@ +import * as functions from 'firebase-functions' +import { TipTxn, Txn } from 'common/txn' +import { getContract, getGroup, getUser, log } from './utils' +import { createTipNotification } from './create-notification' +import * as admin from 'firebase-admin' +import { Comment } from 'common/comment' + +const firestore = admin.firestore() + +export const onCreateTxn = functions.firestore + .document('txns/{txnId}') + .onCreate(async (change, context) => { + const txn = change.data() as Txn + const { eventId } = context + + if (txn.category === 'TIP') { + await handleTipTxn(txn, eventId) + } + }) + +async function handleTipTxn(txn: TipTxn, eventId: string) { + // get user sending and receiving tip + const [sender, receiver] = await Promise.all([ + getUser(txn.fromId), + getUser(txn.toId), + ]) + if (!sender || !receiver) { + log('Could not find corresponding users') + return + } + + if (!txn.data?.commentId) { + log('No comment id in tip txn.data') + return + } + let contract = undefined + let group = undefined + let commentSnapshot = undefined + + if (txn.data.contractId) { + contract = await getContract(txn.data.contractId) + if (!contract) { + log('Could not find contract') + return + } + commentSnapshot = await firestore + .collection('contracts') + .doc(contract.id) + .collection('comments') + .doc(txn.data.commentId) + .get() + } else if (txn.data.groupId) { + group = await getGroup(txn.data.groupId) + if (!group) { + log('Could not find group') + return + } + commentSnapshot = await firestore + .collection('groups') + .doc(group.id) + .collection('comments') + .doc(txn.data.commentId) + .get() + } + + if (!commentSnapshot || !commentSnapshot.exists) { + log('Could not find comment') + return + } + const comment = commentSnapshot.data() as Comment + + await createTipNotification( + sender, + receiver, + txn, + eventId, + comment.id, + contract, + group + ) +} diff --git a/functions/src/on-delete-group.ts b/functions/src/on-delete-group.ts new file mode 100644 index 00000000..e5531d7b --- /dev/null +++ b/functions/src/on-delete-group.ts @@ -0,0 +1,36 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Group } from 'common/group' +import { Contract } from 'common/contract' + +const firestore = admin.firestore() + +export const onDeleteGroup = functions.firestore + .document('groups/{groupId}') + .onDelete(async (change) => { + const group = change.data() as Group + + // get all contracts with this group's slug + const contracts = await firestore + .collection('contracts') + .where('groupSlugs', 'array-contains', group.slug) + .get() + console.log("contracts with group's slug:", contracts) + + for (const doc of contracts.docs) { + const contract = doc.data() as Contract + const newGroupLinks = contract.groupLinks?.filter( + (link) => link.slug !== group.slug + ) + + // remove the group from the contract + await firestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug), + groupLinks: newGroupLinks ?? [], + }) + } + }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index ad85f4d3..52042345 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - undefined, - undefined, - follow.userId + { recipients: [follow.userId] } ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index f47c019c..2042f726 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -24,6 +24,9 @@ export const onUpdateContract = functions.firestore if (resolutionText === 'MKT' && contract.resolutionProbability) resolutionText = `${contract.resolutionProbability}%` else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && contract.resolutionValue) + resolutionText = `${contract.resolutionValue}` } await createNotification( @@ -33,7 +36,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, resolutionText, - contract + { contract } ) } else if ( previousValue.closeTime !== contract.closeTime || @@ -59,7 +62,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, sourceText, - contract + { contract } ) } }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index bc6f6ab4..7e6a5697 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -1,6 +1,8 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Group } from '../../common/group' +import { getContract } from './utils' +import { uniq } from 'lodash' const firestore = admin.firestore() export const onUpdateGroup = functions.firestore @@ -9,12 +11,41 @@ export const onUpdateGroup = functions.firestore const prevGroup = change.before.data() as Group const group = change.after.data() as Group - // ignore the update we just made + // Ignore the activity update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return + if (prevGroup.contractIds.length < group.contractIds.length) { + await firestore + .collection('groups') + .doc(group.id) + .update({ mostRecentContractAddedTime: Date.now() }) + //TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url + // but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two + } + await firestore .collection('groups') .doc(group.id) .update({ mostRecentActivityTime: Date.now() }) }) + +export async function removeGroupLinks(group: Group, contractIds: string[]) { + for (const contractId of contractIds) { + const contract = await getContract(contractId) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([ + ...(contract?.groupSlugs?.filter((slug) => slug !== group.slug) ?? + []), + ]), + groupLinks: [ + ...(contract?.groupLinks?.filter( + (link) => link.groupId !== group.id + ) ?? []), + ], + }) + } +} diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts new file mode 100644 index 00000000..a76132b5 --- /dev/null +++ b/functions/src/on-update-user.ts @@ -0,0 +1,136 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { REFERRAL_AMOUNT, User } from '../../common/user' +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { createReferralNotification } from './create-notification' +import { ReferralTxn } from '../../common/txn' +import { Contract } from '../../common/contract' +import { LimitBet } from 'common/bet' +import { QuerySnapshot } from 'firebase-admin/firestore' +import { Group } from 'common/group' +const firestore = admin.firestore() + +export const onUpdateUser = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + const prevUser = change.before.data() as User + const user = change.after.data() as User + const { eventId } = context + + if (prevUser.referredByUserId !== user.referredByUserId) { + await handleUserUpdatedReferral(user, eventId) + } + + if (user.balance <= 0) { + await cancelLimitOrders(user.id) + } + }) + +async function handleUserUpdatedReferral(user: User, eventId: string) { + // Only create a referral txn if the user has a referredByUserId + if (!user.referredByUserId) { + console.log(`Not set: referredByUserId ${user.referredByUserId}`) + return + } + const referredByUserId = user.referredByUserId + + await firestore.runTransaction(async (transaction) => { + // get user that referred this user + const referredByUserDoc = firestore.doc(`users/${referredByUserId}`) + const referredByUserSnap = await transaction.get(referredByUserDoc) + if (!referredByUserSnap.exists) { + console.log(`User ${referredByUserId} not found`) + return + } + const referredByUser = referredByUserSnap.data() as User + + let referredByContract: Contract | undefined = undefined + if (user.referredByContractId) { + const referredByContractDoc = firestore.doc( + `contracts/${user.referredByContractId}` + ) + referredByContract = await transaction + .get(referredByContractDoc) + .then((snap) => snap.data() as Contract) + } + console.log(`referredByContract: ${referredByContract}`) + + let referredByGroup: Group | undefined = undefined + if (user.referredByGroupId) { + const referredByGroupDoc = firestore.doc( + `groups/${user.referredByGroupId}` + ) + referredByGroup = await transaction + .get(referredByGroupDoc) + .then((snap) => snap.data() as Group) + } + console.log(`referredByGroup: ${referredByGroup}`) + + const txns = ( + await firestore + .collection('txns') + .where('toId', '==', referredByUserId) + .where('category', '==', 'REFERRAL') + .get() + ).docs.map((txn) => txn.ref) + if (txns.length > 0) { + const referralTxns = await transaction.getAll(...txns).catch((err) => { + console.error('error getting txns:', err) + throw err + }) + // If the referring user already has a referral txn due to referring this user, halt + if ( + referralTxns.map((txn) => txn.data()?.description).includes(user.id) + ) { + console.log('found referral txn with the same details, aborting') + return + } + } + console.log('creating referral txns') + const fromId = HOUSE_LIQUIDITY_PROVIDER_ID + + // if they're updating their referredId, create a txn for both + const txn: ReferralTxn = { + id: eventId, + createdTime: Date.now(), + fromId, + fromType: 'BANK', + toId: referredByUserId, + toType: 'USER', + amount: REFERRAL_AMOUNT, + token: 'M$', + category: 'REFERRAL', + description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`, + } + + const txnDoc = firestore.collection(`txns/`).doc(txn.id) + transaction.set(txnDoc, txn) + console.log('created referral with txn id:', txn.id) + // We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes. + transaction.update(referredByUserDoc, { + balance: referredByUser.balance + REFERRAL_AMOUNT, + totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, + }) + + await createReferralNotification( + referredByUser, + user, + eventId, + txn.amount.toString(), + referredByContract, + referredByGroup + ) + }) +} + +async function cancelLimitOrders(userId: string) { + const snapshot = (await firestore + .collectionGroup('bets') + .where('userId', '==', userId) + .where('isFilled', '==', false) + .get()) as QuerySnapshot + + await Promise.all( + snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true })) + ) +} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 2ffaf4d2..a9ada863 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -1,17 +1,25 @@ import * as admin from 'firebase-admin' import { z } from 'zod' +import { + DocumentReference, + FieldValue, + Query, + Transaction, +} from 'firebase-admin/firestore' +import { groupBy, mapValues, sumBy, uniq } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { BetInfo, - getNewBinaryCpmmBetInfo, - getNewBinaryDpmBetInfo, + getBinaryCpmmBetInfo, getNewMultiBetInfo, getNumericBetsInfo, } from '../../common/new-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' +import { LimitBet } from '../../common/bet' +import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' @@ -22,6 +30,15 @@ const bodySchema = z.object({ const binarySchema = z.object({ outcome: z.enum(['YES', 'NO']), + limitProb: z + .number() + .gte(0.001) + .lte(0.999) + .refine( + (p) => Math.round(p * 100) === p * 100, + 'limitProb must be in increments of 0.01 (i.e. whole percentage points)' + ) + .optional(), }) const freeResponseSchema = z.object({ @@ -33,7 +50,7 @@ const numericSchema = z.object({ value: z.number(), }) -export const placebet = newEndpoint(['POST'], async (req, auth) => { +export const placebet = newEndpoint({}, async (req, auth) => { log('Inside endpoint handler.') const { amount, contractId } = validate(bodySchema, req.body) @@ -41,10 +58,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => { log('Inside main transaction.') const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) - const [contractSnap, userSnap] = await Promise.all([ - trans.get(contractDoc), - trans.get(userDoc), - ]) + const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') log('Loaded user and contract snapshots.') @@ -65,14 +79,34 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => { newTotalBets, newTotalLiquidity, newP, - } = await (async (): Promise => { - if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { - const { outcome } = validate(binarySchema, req.body) - return getNewBinaryDpmBetInfo(outcome, amount, contract) - } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { - const { outcome } = validate(binarySchema, req.body) - return getNewBinaryCpmmBetInfo(outcome, amount, contract) - } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { + makers, + } = await (async (): Promise< + BetInfo & { + makers?: maker[] + } + > => { + if ( + (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && + mechanism == 'cpmm-1' + ) { + const { outcome, limitProb } = validate(binarySchema, req.body) + + const unfilledBetsSnap = await trans.get( + getUnfilledBetsQuery(contractDoc) + ) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + return getBinaryCpmmBetInfo( + outcome, + amount, + contract, + limitProb, + unfilledBets + ) + } else if ( + (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && + mechanism == 'dpm-2' + ) { const { outcome } = validate(freeResponseSchema, req.body) const answerDoc = contractDoc.collection('answers').doc(outcome) const answerSnap = await trans.get(answerDoc) @@ -96,33 +130,99 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => { throw new APIError(400, 'Bet too large for current liquidity pool.') } - const newBalance = user.balance - amount const betDoc = contractDoc.collection('bets').doc() trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet }) log('Created new bet document.') - trans.update(userDoc, { balance: newBalance }) - log('Updated user balance.') - trans.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalShares: newTotalShares, - totalBets: newTotalBets, - totalLiquidity: newTotalLiquidity, - collectedFees: addObjects(newBet.fees, collectedFees), - volume: volume + amount, - }) - ) - log('Updated contract properties.') - return { betId: betDoc.id } + if (makers) { + updateMakers(makers, betDoc.id, contractDoc, trans) + } + + if (newBet.amount !== 0) { + trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) + log('Updated user balance.') + + trans.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalShares: newTotalShares, + totalBets: newTotalBets, + totalLiquidity: newTotalLiquidity, + collectedFees: addObjects(newBet.fees, collectedFees), + volume: volume + newBet.amount, + }) + ) + log('Updated contract properties.') + } + + return { betId: betDoc.id, makers, newBet } }) log('Main transaction finished.') - await redeemShares(auth.uid, contractId) - log('Share redemption transaction finished.') - return result + + if (result.newBet.amount !== 0) { + const userIds = uniq([ + auth.uid, + ...(result.makers ?? []).map((maker) => maker.bet.userId), + ]) + await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) + log('Share redemption transaction finished.') + } + + return { betId: result.betId } }) const firestore = admin.firestore() + +export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { + return contractDoc + .collection('bets') + .where('isFilled', '==', false) + .where('isCancelled', '==', false) as Query +} + +type maker = { + bet: LimitBet + amount: number + shares: number + timestamp: number +} +export const updateMakers = ( + makers: maker[], + takerBetId: string, + contractDoc: DocumentReference, + trans: Transaction +) => { + const makersByBet = groupBy(makers, (maker) => maker.bet.id) + for (const makers of Object.values(makersByBet)) { + const bet = makers[0].bet + const newFills = makers.map((maker) => { + const { amount, shares, timestamp } = maker + return { amount, shares, matchedBetId: takerBetId, timestamp } + }) + const fills = [...bet.fills, ...newFills] + const totalShares = sumBy(fills, 'shares') + const totalAmount = sumBy(fills, 'amount') + const isFilled = floatingEqual(totalAmount, bet.orderAmount) + + log('Updated a matched limit order.') + trans.update(contractDoc.collection('bets').doc(bet.id), { + fills, + isFilled, + amount: totalAmount, + shares: totalShares, + }) + } + + // Deduct balance of makers. + const spentByUser = mapValues( + groupBy(makers, (maker) => maker.bet.userId), + (makers) => sumBy(makers, (maker) => maker.amount) + ) + for (const [userId, spent] of Object.entries(spentByUser)) { + const userDoc = firestore.collection('users').doc(userId) + trans.update(userDoc, { balance: FieldValue.increment(-spent) }) + } +} diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index bdd3ab94..0a69521f 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -1,92 +1,46 @@ import * as admin from 'firebase-admin' -import { partition, sumBy } from 'lodash' import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' +import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' -import { noFees } from '../../common/fees' import { User } from '../../common/user' export const redeemShares = async (userId: string, contractId: string) => { - return await firestore.runTransaction(async (transaction) => { + return await firestore.runTransaction(async (trans) => { const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) + const contractSnap = await trans.get(contractDoc) if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') - return { status: 'success' } + const { mechanism } = contract + if (mechanism !== 'cpmm-1') return { status: 'success' } - const betsSnap = await transaction.get( - firestore - .collection(`contracts/${contract.id}/bets`) - .where('userId', '==', userId) - ) + const betsColl = firestore.collection(`contracts/${contract.id}/bets`) + const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') - const yesShares = sumBy(yesBets, (b) => b.shares) - const noShares = sumBy(noBets, (b) => b.shares) - - const amount = Math.min(yesShares, noShares) - if (amount <= 0) return - - const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPaid = Math.min(prevLoanAmount, amount) - const netAmount = amount - loanPaid - - const p = getProbability(contract) - const createdTime = Date.now() - - const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const yesBet: Bet = { - id: yesDoc.id, - userId: userId, - contractId: contract.id, - amount: p * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'YES', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } - - const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const noBet: Bet = { - id: noDoc.id, - userId: userId, - contractId: contract.id, - amount: (1 - p) * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'NO', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, + const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + if (netAmount === 0) { + return { status: 'success' } } + const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) + const userSnap = await trans.get(userDoc) if (!userSnap.exists) return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User - const newBalance = user.balance + netAmount if (!isFinite(newBalance)) { throw new Error('Invalid user balance for ' + user.username) } - transaction.update(userDoc, { balance: newBalance }) - - transaction.create(yesDoc, yesBet) - transaction.create(noDoc, noBet) + const yesDoc = betsColl.doc() + const noDoc = betsColl.doc() + trans.update(userDoc, { balance: newBalance }) + trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet }) + trans.create(noDoc, { id: noDoc.id, userId, ...noBet }) return { status: 'success' } }) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 43cb4839..7277f40b 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,8 +1,13 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, resolution, RESOLUTIONS } from '../../common/contract' +import { + Contract, + FreeResponseContract, + MultipleChoiceContract, + RESOLUTIONS, +} from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -13,158 +18,163 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' +import { isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' +import { APIError, newEndpoint, validate } from './api' -export const resolveMarket = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - outcome: resolution - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), +}) - const { outcome, contractId, probabilityInt, resolutions, value } = data +const binarySchema = z.object({ + outcome: z.enum(RESOLUTIONS), + probabilityInt: z.number().gte(0).lte(100).optional(), +}) - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - const { creatorId, outcomeType, closeTime } = contract +const freeResponseSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + resolutions: z.array( + z.object({ + answer: z.number().int().nonnegative(), + pct: z.number().gte(0).lte(100), + }) + ), + }), + z.object({ + outcome: z.number().int().nonnegative(), + }), +]) - if (outcomeType === 'BINARY') { - if (!RESOLUTIONS.includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'FREE_RESPONSE') { - if ( - isNaN(+outcome) && - !(outcome === 'MKT' && resolutions) && - outcome !== 'CANCEL' - ) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'NUMERIC') { - if (isNaN(+outcome) && outcome !== 'CANCEL') - return { status: 'error', message: 'Invalid outcome' } - } else { - return { status: 'error', message: 'Invalid contract outcomeType' } - } +const numericSchema = z.object({ + outcome: z.union([z.literal('CANCEL'), z.string()]), + value: z.number().optional(), +}) - if (value !== undefined && !isFinite(value)) - return { status: 'error', message: 'Invalid value' } +const pseudoNumericSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), + }), +]) - if ( - outcomeType === 'BINARY' && - probabilityInt !== undefined && - (probabilityInt < 0 || - probabilityInt > 100 || - !isFinite(probabilityInt)) - ) - return { status: 'error', message: 'Invalid probability' } +const opts = { secrets: ['MAILGUN_KEY'] } - if (creatorId !== userId) - return { status: 'error', message: 'User not creator of contract' } +export const resolvemarket = newEndpoint(opts, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + throw new APIError(404, 'No contract exists with the provided ID') + const contract = contractSnap.data() as Contract + const { creatorId, closeTime } = contract - if (contract.resolution) - return { status: 'error', message: 'Contract already resolved' } - - const creator = await getUser(creatorId) - if (!creator) return { status: 'error', message: 'Creator not found' } - - const resolutionProbability = - probabilityInt !== undefined ? probabilityInt / 100 : undefined - - const resolutionTime = Date.now() - const newCloseTime = closeTime - ? Math.min(closeTime, resolutionTime) - : closeTime - - const betsSnap = await firestore - .collection(`contracts/${contractId}/bets`) - .get() - - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - - const liquiditiesSnap = await firestore - .collection(`contracts/${contractId}/liquidity`) - .get() - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const { payouts, creatorPayout, liquidityPayouts, collectedFees } = - getPayouts( - outcome, - resolutions ?? {}, - contract, - bets, - liquidities, - resolutionProbability - ) - - await contractDoc.update( - removeUndefinedProps({ - isResolved: true, - resolution: outcome, - resolutionValue: value, - resolutionTime, - closeTime: newCloseTime, - resolutionProbability, - resolutions, - collectedFees, - }) - ) - - console.log('contract ', contractId, 'resolved to:', outcome) - - const openBets = bets.filter((b) => !b.isSold && !b.sale) - const loanPayouts = getLoanPayouts(openBets) - - if (!isProd()) - console.log( - 'payouts:', - payouts, - 'creator payout:', - creatorPayout, - 'liquidity payout:' - ) - - if (creatorPayout) - await processPayouts( - [{ userId: creatorId, payout: creatorPayout }], - true - ) - - await processPayouts(liquidityPayouts, true) - - const result = await processPayouts([...payouts, ...loanPayouts]) - - const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - - await sendResolutionEmails( - openBets, - userPayoutsWithoutLoans, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - - return result - } + const { value, resolutions, probabilityInt, outcome } = getResolutionParams( + contract, + req.body ) + if (creatorId !== auth.uid && !isManifoldId(auth.uid)) + throw new APIError(403, 'User is not creator of contract') + + if (contract.resolution) throw new APIError(400, 'Contract already resolved') + + const creator = await getUser(creatorId) + if (!creator) throw new APIError(500, 'Creator not found') + + const resolutionProbability = + probabilityInt !== undefined ? probabilityInt / 100 : undefined + + const resolutionTime = Date.now() + const newCloseTime = closeTime + ? Math.min(closeTime, resolutionTime) + : closeTime + + const betsSnap = await firestore + .collection(`contracts/${contractId}/bets`) + .get() + + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + + const liquiditiesSnap = await firestore + .collection(`contracts/${contractId}/liquidity`) + .get() + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const { payouts, creatorPayout, liquidityPayouts, collectedFees } = + getPayouts( + outcome, + contract, + bets, + liquidities, + resolutions, + resolutionProbability + ) + + const updatedContract = { + ...contract, + ...removeUndefinedProps({ + isResolved: true, + resolution: outcome, + resolutionValue: value, + resolutionTime, + closeTime: newCloseTime, + resolutionProbability, + resolutions, + collectedFees, + }), + } + + await contractDoc.update(updatedContract) + + console.log('contract ', contractId, 'resolved to:', outcome) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const loanPayouts = getLoanPayouts(openBets) + + if (!isProd()) + console.log( + 'payouts:', + payouts, + 'creator payout:', + creatorPayout, + 'liquidity payout:' + ) + + if (creatorPayout) + await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) + + await processPayouts(liquidityPayouts, true) + + await processPayouts([...payouts, ...loanPayouts]) + + const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) + + await sendResolutionEmails( + openBets, + userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions + ) + + return updatedContract +}) + const processPayouts = async (payouts: Payout[], isDeposit = false) => { const userPayouts = groupPayoutsByUser(payouts) @@ -221,4 +231,78 @@ const sendResolutionEmails = async ( ) } +function getResolutionParams(contract: Contract, body: string) { + const { outcomeType } = contract + + if (outcomeType === 'NUMERIC') { + return { + ...validate(numericSchema, body), + resolutions: undefined, + probabilityInt: undefined, + } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } + } else if ( + outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE' + ) { + const freeResponseParams = validate(freeResponseSchema, body) + const { outcome } = freeResponseParams + switch (outcome) { + case 'CANCEL': + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + case 'MKT': { + const { resolutions } = freeResponseParams + resolutions.forEach(({ answer }) => validateAnswer(contract, answer)) + const pctSum = sumBy(resolutions, ({ pct }) => pct) + if (Math.abs(pctSum - 100) > 0.1) { + throw new APIError(400, 'Resolution percentages must sum to 100') + } + return { + outcome: outcome.toString(), + resolutions: Object.fromEntries( + resolutions.map((r) => [r.answer, r.pct]) + ), + value: undefined, + probabilityInt: undefined, + } + } + default: { + validateAnswer(contract, outcome) + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + } + } + } else if (outcomeType === 'BINARY') { + return { + ...validate(binarySchema, body), + value: undefined, + resolutions: undefined, + } + } + throw new APIError(500, `Invalid outcome type: ${outcomeType}`) +} + +function validateAnswer( + contract: FreeResponseContract | MultipleChoiceContract, + answer: number +) { + const validIds = contract.answers.map((a) => a.id) + if (!validIds.includes(answer.toString())) { + throw new APIError(400, `${answer} is not a valid answer ID`) + } +} + const firestore = admin.firestore() diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts new file mode 100644 index 00000000..57976ff2 --- /dev/null +++ b/functions/src/score-contracts.ts @@ -0,0 +1,54 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Bet } from 'common/bet' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' +import { log } from './utils' + +export const scoreContracts = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + await scoreContractsInternal() + }) +const firestore = admin.firestore() + +async function scoreContractsInternal() { + const now = Date.now() + const lastHour = now - 60 * 60 * 1000 + const last3Days = now - 1000 * 60 * 60 * 24 * 3 + const activeContractsSnap = await firestore + .collection('contracts') + .where('lastUpdatedTime', '>', lastHour) + .get() + const activeContracts = activeContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + // We have to downgrade previously active contracts to allow the new ones to bubble up + const previouslyActiveContractsSnap = await firestore + .collection('contracts') + .where('popularityScore', '>', 0) + .get() + const activeContractIds = activeContracts.map((c) => c.id) + const previouslyActiveContracts = previouslyActiveContractsSnap.docs + .map((doc) => doc.data() as Contract) + .filter((c) => !activeContractIds.includes(c.id)) + + const contracts = activeContracts.concat(previouslyActiveContracts) + log(`Found ${contracts.length} contracts to score`) + + for (const contract of contracts) { + const bets = await firestore + .collection(`contracts/${contract.id}/bets`) + .where('createdTime', '>', last3Days) + .get() + const bettors = bets.docs + .map((doc) => doc.data() as Bet) + .map((bet) => bet.userId) + const score = uniq(bettors).length + if (contract.popularityScore !== score) + await firestore + .collection('contracts') + .doc(contract.id) + .update({ popularityScore: score }) + } +} diff --git a/functions/src/scripts/backfill-comment-ids.ts b/functions/src/scripts/backfill-comment-ids.ts new file mode 100644 index 00000000..e6bb6902 --- /dev/null +++ b/functions/src/scripts/backfill-comment-ids.ts @@ -0,0 +1,55 @@ +// We have some old comments without IDs and user IDs. Let's fill them in. +// Luckily, this was back when all comments had associated bets, so it's possible +// to retrieve the user IDs through the bets. + +import * as admin from 'firebase-admin' +import { QueryDocumentSnapshot } from 'firebase-admin/firestore' +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' + +initAdmin() +const firestore = admin.firestore() + +const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => { + const bets = await firestore.collectionGroup('bets').get() + log(`Loaded ${bets.size} bets.`) + const betsById = Object.fromEntries( + bets.docs.map((b) => [b.id, b.data() as Bet]) + ) + return Object.fromEntries( + comments.map((c) => [c.id, betsById[c.data().betId].userId]) + ) +} + +if (require.main === module) { + const commentsQuery = firestore.collectionGroup('comments') + commentsQuery.get().then(async (commentSnaps) => { + log(`Loaded ${commentSnaps.size} comments.`) + const needsFilling = commentSnaps.docs.filter((ct) => { + return !('id' in ct.data()) || !('userId' in ct.data()) + }) + log(`${needsFilling.length} comments need IDs.`) + const userIdNeedsFilling = needsFilling.filter((ct) => { + return !('userId' in ct.data()) + }) + log(`${userIdNeedsFilling.length} comments need user IDs.`) + const userIdsByCommentId = + userIdNeedsFilling.length > 0 + ? await getUserIdsByCommentId(userIdNeedsFilling) + : {} + const updates = needsFilling.map((ct) => { + const fields: { [k: string]: unknown } = {} + if (!ct.data().id) { + fields.id = ct.id + } + if (!ct.data().userId && userIdsByCommentId[ct.id]) { + fields.userId = userIdsByCommentId[ct.id] + } + return { doc: ct.ref, fields } + }) + log(`Updating ${updates.length} comments.`) + await writeAsync(firestore, updates) + log(`Updated all comments.`) + }) +} diff --git a/functions/src/scripts/backfill-group-ids.ts b/functions/src/scripts/backfill-group-ids.ts new file mode 100644 index 00000000..ddce5d99 --- /dev/null +++ b/functions/src/scripts/backfill-group-ids.ts @@ -0,0 +1,25 @@ +// We have some groups without IDs. Let's fill them in. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' + +initAdmin() +const firestore = admin.firestore() + +if (require.main === module) { + const groupsQuery = firestore.collection('groups') + groupsQuery.get().then(async (groupSnaps) => { + log(`Loaded ${groupSnaps.size} groups.`) + const needsFilling = groupSnaps.docs.filter((ct) => { + return !('id' in ct.data()) + }) + log(`${needsFilling.length} groups need IDs.`) + const updates = needsFilling.map((group) => { + return { doc: group.ref, fields: { id: group.id } } + }) + log(`Updating ${updates.length} groups.`) + await writeAsync(firestore, updates) + log(`Updated all groups.`) + }) +} diff --git a/functions/src/scripts/backup-db.ts b/functions/src/scripts/backup-db.ts new file mode 100644 index 00000000..04c66438 --- /dev/null +++ b/functions/src/scripts/backup-db.ts @@ -0,0 +1,16 @@ +import * as firestore from '@google-cloud/firestore' +import { getServiceAccountCredentials } from './script-init' +import { backupDbCore } from '../backup-db' + +async function backupDb() { + const credentials = getServiceAccountCredentials() + const projectId = credentials.project_id + const client = new firestore.v1.FirestoreAdminClient({ credentials }) + const bucket = 'manifold-firestore-backup' + const resp = await backupDbCore(client, projectId, bucket) + console.log(`Operation: ${resp[0]['name']}`) +} + +if (require.main === module) { + backupDb().then(() => process.exit()) +} diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts new file mode 100644 index 00000000..3436bcbc --- /dev/null +++ b/functions/src/scripts/convert-categories.ts @@ -0,0 +1,108 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getValues, isProd } from '../utils' +import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' +import { Group, GroupLink } from 'common/group' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' +import { User } from 'common/user' +import { filterDefined } from 'common/util/array' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/antes' + +initAdmin() + +const adminFirestore = admin.firestore() + +const convertCategoriesToGroupsInternal = async (categories: string[]) => { + for (const category of categories) { + const markets = await getValues( + adminFirestore + .collection('contracts') + .where('lowercaseTags', 'array-contains', category.toLowerCase()) + ) + const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX + const oldGroup = await getValues( + adminFirestore.collection('groups').where('slug', '==', slug) + ) + if (oldGroup.length > 0) { + console.log(`Found old group for ${category}`) + await adminFirestore.collection('groups').doc(oldGroup[0].id).delete() + } + + const allUsers = await getValues(adminFirestore.collection('users')) + const groupUsers = filterDefined( + allUsers.map((user: User) => { + if (!user.followedCategories || user.followedCategories.length === 0) + return user.id + if (!user.followedCategories.includes(category.toLowerCase())) + return null + return user.id + }) + ) + + const manifoldAccount = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const newGroupRef = await adminFirestore.collection('groups').doc() + const newGroup: Group = { + id: newGroupRef.id, + name: category, + slug, + creatorId: manifoldAccount, + createdTime: Date.now(), + anyoneCanJoin: true, + memberIds: [manifoldAccount], + about: 'Default group for all things related to ' + category, + mostRecentActivityTime: Date.now(), + contractIds: markets.map((market) => market.id), + chatDisabled: true, + } + + await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup) + // Update group with new memberIds to avoid notifying everyone + await adminFirestore + .collection('groups') + .doc(newGroupRef.id) + .update({ + memberIds: uniq(groupUsers), + }) + + for (const market of markets) { + if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) + continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: newGroup.id, + createdTime: Date.now(), + slug: newGroup.slug, + name: newGroup.name, + } as GroupLink, + ] + await adminFirestore + .collection('contracts') + .doc(market.id) + .update({ + groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), + groupLinks: newGroupLinks, + }) + } + } +} + +async function convertCategoriesToGroups() { + // const defaultCategories = Object.values(DEFAULT_CATEGORIES) + const moreCategories = ['world', 'culture'] + await convertCategoriesToGroupsInternal(moreCategories) +} + +if (require.main === module) { + convertCategoriesToGroups() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/functions/src/scripts/link-contracts-to-groups.ts b/functions/src/scripts/link-contracts-to-groups.ts new file mode 100644 index 00000000..e3296160 --- /dev/null +++ b/functions/src/scripts/link-contracts-to-groups.ts @@ -0,0 +1,53 @@ +import { getValues } from 'functions/src/utils' +import { Group } from 'common/group' +import { Contract } from 'common/contract' +import { initAdmin } from 'functions/src/scripts/script-init' +import * as admin from 'firebase-admin' +import { filterDefined } from 'common/util/array' +import { uniq } from 'lodash' + +initAdmin() + +const adminFirestore = admin.firestore() + +const addGroupIdToContracts = async () => { + const groups = await getValues(adminFirestore.collection('groups')) + + for (const group of groups) { + const groupContracts = await getValues( + adminFirestore + .collection('contracts') + .where('groupSlugs', 'array-contains', group.slug) + ) + + for (const contract of groupContracts) { + const oldGroupLinks = contract.groupLinks?.filter( + (l) => l.slug != group.slug + ) + const newGroupLinks = filterDefined([ + ...(oldGroupLinks ?? []), + group.id + ? { + slug: group.slug, + name: group.name, + groupId: group.id, + createdTime: Date.now(), + } + : undefined, + ]) + await adminFirestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + } +} + +if (require.main === module) { + addGroupIdToContracts() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts index 1686ebd9..a121889f 100644 --- a/functions/src/scripts/pay-out-contract-again.ts +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) { const { payouts } = getPayouts( resolution, - resolutions, contract, openBets, [], + resolutions, resolutionProbability ) diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 8f65e4be..5f7dc410 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -47,26 +47,37 @@ const getFirebaseActiveProject = (cwd: string) => { } } -export const initAdmin = (env?: string) => { +export const getServiceAccountCredentials = (env?: string) => { env = env || getFirebaseActiveProject(process.cwd()) if (env == null) { - console.error( + throw new Error( "Couldn't find active Firebase project; did you do `firebase use ?`" ) - return } const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const keyPath = process.env[envVar] if (keyPath == null) { - console.error( + throw new Error( `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` ) - return } - console.log(`Initializing connection to ${env} Firebase...`) /* eslint-disable-next-line @typescript-eslint/no-var-requires */ - const serviceAccount = require(keyPath) - admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), - }) + return require(keyPath) +} + +export const initAdmin = (env?: string) => { + try { + const serviceAccount = getServiceAccountCredentials(env) + console.log( + `Initializing connection to ${serviceAccount.project_id} Firebase...` + ) + return admin.initializeApp({ + projectId: serviceAccount.project_id, + credential: admin.credential.cert(serviceAccount), + }) + } catch (err) { + console.error(err) + console.log(`Initializing connection to default Firebase...`) + return admin.initializeApp() + } } diff --git a/functions/src/scripts/set-avatar-cache-headers.ts b/functions/src/scripts/set-avatar-cache-headers.ts new file mode 100644 index 00000000..676ec62d --- /dev/null +++ b/functions/src/scripts/set-avatar-cache-headers.ts @@ -0,0 +1,27 @@ +import { initAdmin } from './script-init' +import { log } from '../utils' + +const app = initAdmin() +const ONE_YEAR_SECS = 60 * 60 * 24 * 365 +const AVATAR_EXTENSION_RE = /\.(gif|tiff|jpe?g|png|webp)$/i + +const processAvatars = async () => { + const storage = app.storage() + const bucket = storage.bucket(`${app.options.projectId}.appspot.com`) + const [files] = await bucket.getFiles({ prefix: 'user-images' }) + log(`${files.length} avatar images to process.`) + for (const file of files) { + if (AVATAR_EXTENSION_RE.test(file.name)) { + log(`Updating metadata for ${file.name}.`) + await file.setMetadata({ + cacheControl: `public, max-age=${ONE_YEAR_SECS}`, + }) + } else { + log(`Skipping ${file.name} because it probably isn't an avatar.`) + } + } +} + +if (require.main === module) { + processAvatars().catch((e) => console.error(e)) +} diff --git a/functions/src/scripts/update-feed.ts b/functions/src/scripts/update-feed.ts deleted file mode 100644 index c5cba142..00000000 --- a/functions/src/scripts/update-feed.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as admin from 'firebase-admin' - -import { initAdmin } from './script-init' -initAdmin() - -import { getValues } from '../utils' -import { User } from '../../../common/user' -import { batchedWaitAll } from '../../../common/util/promise' -import { Contract } from '../../../common/contract' -import { updateWordScores } from '../update-recommendations' -import { computeFeed } from '../update-feed' -import { getFeedContracts, getTaggedContracts } from '../get-feed-data' -import { CATEGORY_LIST } from '../../../common/categories' - -const firestore = admin.firestore() - -async function updateFeed() { - console.log('Updating feed') - - const contracts = await getValues(firestore.collection('contracts')) - const feedContracts = await getFeedContracts() - const users = await getValues( - firestore.collection('users').where('username', '==', 'JamesGrugett') - ) - - await batchedWaitAll( - users.map((user) => async () => { - console.log('Updating recs for', user.username) - await updateWordScores(user, contracts) - console.log('Updating feed for', user.username) - await computeFeed(user, feedContracts) - }) - ) - - console.log('Updating feed categories!') - - await batchedWaitAll( - users.map((user) => async () => { - for (const category of CATEGORY_LIST) { - const contracts = await getTaggedContracts(category) - const feed = await computeFeed(user, contracts) - await firestore - .collection(`private-users/${user.id}/cache`) - .doc(`feed-${category}`) - .set({ feed }) - } - }) - ) -} - -if (require.main === module) { - updateFeed().then(() => process.exit()) -} diff --git a/functions/src/sell-bet.ts b/functions/src/sell-bet.ts index 419206c0..18df4536 100644 --- a/functions/src/sell-bet.ts +++ b/functions/src/sell-bet.ts @@ -13,7 +13,7 @@ const bodySchema = z.object({ betId: z.string(), }) -export const sellbet = newEndpoint(['POST'], async (req, auth) => { +export const sellbet = newEndpoint({}, async (req, auth) => { const { contractId, betId } = validate(bodySchema, req.body) // run as transaction to prevent race conditions @@ -21,11 +21,11 @@ export const sellbet = newEndpoint(['POST'], async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betDoc = firestore.doc(`contracts/${contractId}/bets/${betId}`) - const [contractSnap, userSnap, betSnap] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), - transaction.get(betDoc), - ]) + const [contractSnap, userSnap, betSnap] = await transaction.getAll( + contractDoc, + userDoc, + betDoc + ) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!betSnap.exists) throw new APIError(400, 'Bet not found.') diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index dd4e2ec5..ec08ab86 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,4 +1,4 @@ -import { sumBy } from 'lodash' +import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' @@ -7,26 +7,29 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' -import { getValues } from './utils' +import { getValues, log } from './utils' import { Bet } from '../../common/bet' +import { floatingEqual, floatingLesserEqual } from '../../common/util/math' +import { getUnfilledBetsQuery, updateMakers } from './place-bet' +import { FieldValue } from 'firebase-admin/firestore' +import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), - shares: z.number(), - outcome: z.enum(['YES', 'NO']), + shares: z.number().optional(), // leave it out to sell all shares + outcome: z.enum(['YES', 'NO']).optional(), // leave it out to sell whichever you have }) -export const sellshares = newEndpoint(['POST'], async (req, auth) => { +export const sellshares = newEndpoint({}, async (req, auth) => { const { contractId, shares, outcome } = validate(bodySchema, req.body) // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { + const result = await firestore.runTransaction(async (transaction) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [contractSnap, userSnap, userBets] = await Promise.all([ - transaction.get(contractDoc), - transaction.get(userDoc), + const [[contractSnap, userSnap], userBets] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), getValues(betsQ), // TODO: why is this not in the transaction?? ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') @@ -43,18 +46,49 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { throw new APIError(400, 'Trading is closed.') const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) + const betsByOutcome = groupBy(userBets, (bet) => bet.outcome) + const sharesByOutcome = mapValues(betsByOutcome, (bets) => + sumBy(bets, (b) => b.shares) + ) - const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) - const maxShares = sumBy(outcomeBets, (bet) => bet.shares) + let chosenOutcome: 'YES' | 'NO' + if (outcome != null) { + chosenOutcome = outcome + } else { + const nonzeroShares = Object.entries(sharesByOutcome).filter( + ([_k, v]) => !floatingEqual(0, v) + ) + if (nonzeroShares.length == 0) { + throw new APIError(400, "You don't own any shares in this market.") + } + if (nonzeroShares.length > 1) { + throw new APIError( + 400, + `You own multiple kinds of shares, but did not specify which to sell.` + ) + } + chosenOutcome = nonzeroShares[0][0] as 'YES' | 'NO' + } - if (shares > maxShares + 0.000000000001) + const maxShares = sharesByOutcome[chosenOutcome] + const sharesToSell = shares ?? maxShares + + if (!floatingLesserEqual(sharesToSell, maxShares)) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) - const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( - shares, - outcome, + const soldShares = Math.min(sharesToSell, maxShares) + + const unfilledBetsSnap = await transaction.get( + getUnfilledBetsQuery(contractDoc) + ) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( + soldShares, + chosenOutcome, contract, - prevLoanAmount + prevLoanAmount, + unfilledBets ) if ( @@ -66,11 +100,17 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { } const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() - const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0) - const userId = user.id - transaction.update(userDoc, { balance: newBalance }) - transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet }) + updateMakers(makers, newBetDoc.id, contractDoc, transaction) + + transaction.update(userDoc, { + balance: FieldValue.increment(-newBet.amount), + }) + transaction.create(newBetDoc, { + id: newBetDoc.id, + userId: user.id, + ...newBet, + }) transaction.update( contractDoc, removeUndefinedProps({ @@ -81,8 +121,14 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { }) ) - return { status: 'success' } + return { newBet, makers } }) + + const userIds = uniq(result.makers.map((maker) => maker.bet.userId)) + await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) + log('Share redemption transaction finished.') + + return { status: 'success' } }) const firestore = admin.firestore() diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index f97234f6..7ff4c047 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -26,16 +26,20 @@ export const sendTemplateEmail = ( subject: string, templateId: string, templateData: Record, - options?: { from: string } + options?: Partial ) => { - const data = { + const data: mailgun.messages.SendTemplateData = { + ...options, from: options?.from ?? 'Manifold Markets ', to, subject, template: templateId, 'h:X-Mailgun-Variables': JSON.stringify(templateData), + 'o:tag': templateId, + 'o:tracking': true, } const mg = initMailgun() + return mg.messages().send(data, (error) => { if (error) console.log('Error sending email', error) else console.log('Sent template email', templateId, to, subject) diff --git a/functions/src/serve.ts b/functions/src/serve.ts new file mode 100644 index 00000000..bf96db20 --- /dev/null +++ b/functions/src/serve.ts @@ -0,0 +1,72 @@ +import * as cors from 'cors' +import * as express from 'express' +import { Express, Request, Response, NextFunction } from 'express' +import { EndpointDefinition } from './api' + +const PORT = 8088 + +import { initAdmin } from './scripts/script-init' +initAdmin() + +import { health } from './health' +import { transact } from './transact' +import { changeuserinfo } from './change-user-info' +import { createuser } from './create-user' +import { createanswer } from './create-answer' +import { placebet } from './place-bet' +import { cancelbet } from './cancel-bet' +import { sellbet } from './sell-bet' +import { sellshares } from './sell-shares' +import { claimmanalink } from './claim-manalink' +import { createmarket } from './create-contract' +import { addliquidity } from './add-liquidity' +import { withdrawliquidity } from './withdraw-liquidity' +import { creategroup } from './create-group' +import { resolvemarket } from './resolve-market' +import { unsubscribe } from './unsubscribe' +import { stripewebhook, createcheckoutsession } from './stripe' +import { getcurrentuser } from './get-current-user' +import { getcustomtoken } from './get-custom-token' + +type Middleware = (req: Request, res: Response, next: NextFunction) => void +const app = express() + +const addEndpointRoute = ( + path: string, + endpoint: EndpointDefinition, + ...middlewares: Middleware[] +) => { + const method = endpoint.opts.method.toLowerCase() as keyof Express + const corsMiddleware = cors({ origin: endpoint.opts.cors }) + const allMiddleware = [...middlewares, corsMiddleware] + app.options(path, corsMiddleware) // preflight requests + app[method](path, ...allMiddleware, endpoint.handler) +} + +const addJsonEndpointRoute = (name: string, endpoint: EndpointDefinition) => { + addEndpointRoute(name, endpoint, express.json()) +} + +addEndpointRoute('/health', health) +addJsonEndpointRoute('/transact', transact) +addJsonEndpointRoute('/changeuserinfo', changeuserinfo) +addJsonEndpointRoute('/createuser', createuser) +addJsonEndpointRoute('/createanswer', createanswer) +addJsonEndpointRoute('/placebet', placebet) +addJsonEndpointRoute('/cancelbet', cancelbet) +addJsonEndpointRoute('/sellbet', sellbet) +addJsonEndpointRoute('/sellshares', sellshares) +addJsonEndpointRoute('/claimmanalink', claimmanalink) +addJsonEndpointRoute('/createmarket', createmarket) +addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) +addJsonEndpointRoute('/creategroup', creategroup) +addJsonEndpointRoute('/resolvemarket', resolvemarket) +addJsonEndpointRoute('/unsubscribe', unsubscribe) +addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) +addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addEndpointRoute('/getcustomtoken', getcustomtoken) +addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) + +app.listen(PORT) +console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index 67309aa8..79f0ad53 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -1,7 +1,7 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import Stripe from 'stripe' +import { EndpointDefinition } from './api' import { getPrivateUser, getUser, isProd, payUser } from './utils' import { sendThankYouEmail } from './emails' import { track } from './analytics' @@ -42,9 +42,9 @@ const manticDollarStripePrice = isProd() 10000: 'price_1K8bEiGdoFKoCJW7Us4UkRHE', } -export const createCheckoutSession = functions - .runWith({ minInstances: 1, secrets: ['STRIPE_APIKEY'] }) - .https.onRequest(async (req, res) => { +export const createcheckoutsession: EndpointDefinition = { + opts: { method: 'POST', minInstances: 1, secrets: ['STRIPE_APIKEY'] }, + handler: async (req, res) => { const userId = req.query.userId?.toString() const manticDollarQuantity = req.query.manticDollarQuantity?.toString() @@ -86,20 +86,24 @@ export const createCheckoutSession = functions }) res.redirect(303, session.url || '') - }) + }, +} -export const stripeWebhook = functions - .runWith({ +export const stripewebhook: EndpointDefinition = { + opts: { + method: 'POST', minInstances: 1, secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], - }) - .https.onRequest(async (req, res) => { + }, + handler: async (req, res) => { const stripe = initStripe() let event try { + // Cloud Functions jam the raw body into a special `rawBody` property + const rawBody = (req as any).rawBody ?? req.body event = stripe.webhooks.constructEvent( - req.rawBody, + rawBody, req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOKSECRET as string ) @@ -115,7 +119,8 @@ export const stripeWebhook = functions } res.status(200).send('success') - }) + }, +} const issueMoneys = async (session: StripeSession) => { const { id: sessionId } = session diff --git a/functions/src/transact.ts b/functions/src/transact.ts index cd091b83..113afc0b 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -1,40 +1,40 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { User } from '../../common/user' import { Txn } from '../../common/txn' import { removeUndefinedProps } from '../../common/util/object' +import { APIError, newEndpoint } from './api' export type TxnData = Omit -export const transact = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (data: TxnData, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +// TODO: We totally fail to validate most of the input to this function, +// so anyone can spam our database with malformed transactions. - const { amount, fromType, fromId } = data +export const transact = newEndpoint({}, async (req, auth) => { + const data = req.body + const { amount, fromType, fromId } = data - if (fromType !== 'USER') - return { - status: 'error', - message: "From type is only implemented for type 'user'.", - } + if (fromType !== 'USER') + throw new APIError(400, "From type is only implemented for type 'user'.") - if (fromId !== userId) - return { - status: 'error', - message: 'Must be authenticated with userId equal to specified fromId.', - } + if (fromId !== auth.uid) + throw new APIError( + 403, + 'Must be authenticated with userId equal to specified fromId.' + ) - if (isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (isNaN(amount) || !isFinite(amount)) + throw new APIError(400, 'Invalid amount') - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - await runTxn(transaction, data) - }) + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + const result = await runTxn(transaction, data) + if (result.status == 'error') { + throw new APIError(500, result.message ?? 'An unknown error occurred.') + } + return result }) +}) export async function runTxn( fbTransaction: admin.firestore.Transaction, diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index a41a7155..fda20e16 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,11 +1,11 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { EndpointDefinition } from './api' import { getUser } from './utils' import { PrivateUser } from '../../common/user' -export const unsubscribe = functions - .runWith({ minInstances: 1 }) - .https.onRequest(async (req, res) => { +export const unsubscribe: EndpointDefinition = { + opts: { method: 'GET', minInstances: 1 }, + handler: async (req, res) => { const id = req.query.id as string let type = req.query.type as string if (!id || !type) { @@ -66,6 +66,7 @@ export const unsubscribe = functions `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` ) else res.send(`${name}, you have been unsubscribed.`) - }) + }, +} const firestore = admin.firestore() diff --git a/functions/src/update-feed.ts b/functions/src/update-feed.ts deleted file mode 100644 index f19fda92..00000000 --- a/functions/src/update-feed.ts +++ /dev/null @@ -1,220 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { flatten, shuffle, sortBy, uniq, zip, zipObject } from 'lodash' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { logInterpolation } from '../../common/util/math' -import { DAY_MS } from '../../common/util/time' -import { - getProbability, - getOutcomeProbability, - getTopAnswer, -} from '../../common/calculate' -import { User } from '../../common/user' -import { - getContractScore, - MAX_FEED_CONTRACTS, -} from '../../common/recommended-contracts' -import { callCloudFunction } from './call-cloud-function' -import { - getFeedContracts, - getRecentBetsAndComments, - getTaggedContracts, -} from './get-feed-data' -import { CATEGORY_LIST } from '../../common/categories' - -const firestore = admin.firestore() - -const BATCH_SIZE = 30 -const MAX_BATCHES = 50 - -const getUserBatches = async () => { - const users = shuffle(await getValues(firestore.collection('users'))) - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += BATCH_SIZE) { - userBatches.push(users.slice(i, i + BATCH_SIZE)) - } - - console.log('updating feed batches', MAX_BATCHES, 'of', userBatches.length) - - return userBatches.slice(0, MAX_BATCHES) -} - -export const updateFeed = functions.pubsub - .schedule('every 60 minutes') - .onRun(async () => { - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map((users) => - callCloudFunction('updateFeedBatch', { users }) - ) - ) - - console.log('updating category feed') - - await Promise.all( - CATEGORY_LIST.map((category) => - callCloudFunction('updateCategoryFeed', { - category, - }) - ) - ) - }) - -export const updateFeedBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - const contracts = await getFeedContracts() - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc('feed').set({ feed }) - ) - ) - } -) - -export const updateCategoryFeed = functions.https.onCall( - async (data: { category: string }) => { - const { category } = data - const userBatches = await getUserBatches() - - await Promise.all( - userBatches.map(async (users) => { - await callCloudFunction('updateCategoryFeedBatch', { - users, - category, - }) - }) - ) - } -) - -export const updateCategoryFeedBatch = functions.https.onCall( - async (data: { users: User[]; category: string }) => { - const { users, category } = data - const contracts = await getTaggedContracts(category) - const feeds = await getNewFeeds(users, contracts) - await Promise.all( - zip(users, feeds).map(([user, feed]) => - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - getUserCacheCollection(user!).doc(`feed-${category}`).set({ feed }) - ) - ) - } -) - -const getNewFeeds = async (users: User[], contracts: Contract[]) => { - const feeds = await Promise.all(users.map((u) => computeFeed(u, contracts))) - const contractIds = uniq(flatten(feeds).map((c) => c.id)) - const data = await Promise.all(contractIds.map(getRecentBetsAndComments)) - const dataByContractId = zipObject(contractIds, data) - return feeds.map((feed) => - feed.map((contract) => { - return { contract, ...dataByContractId[contract.id] } - }) - ) -} - -const getUserCacheCollection = (user: User) => - firestore.collection(`private-users/${user.id}/cache`) - -export const computeFeed = async (user: User, contracts: Contract[]) => { - const userCacheCollection = getUserCacheCollection(user) - - const [wordScores, lastViewedTime] = await Promise.all([ - getValue<{ [word: string]: number }>(userCacheCollection.doc('wordScores')), - getValue<{ [contractId: string]: number }>( - userCacheCollection.doc('lastViewTime') - ), - ]).then((dicts) => dicts.map((dict) => dict ?? {})) - - const scoredContracts = contracts.map((contract) => { - const score = scoreContract( - contract, - wordScores, - lastViewedTime[contract.id] - ) - return [contract, score] as [Contract, number] - }) - - const sortedContracts = sortBy( - scoredContracts, - ([_, score]) => score - ).reverse() - - // console.log(sortedContracts.map(([c, score]) => c.question + ': ' + score)) - - return sortedContracts.slice(0, MAX_FEED_CONTRACTS).map(([c]) => c) -} - -function scoreContract( - contract: Contract, - wordScores: { [word: string]: number }, - viewTime: number | undefined -) { - const recommendationScore = getContractScore(contract, wordScores) - const activityScore = getActivityScore(contract, viewTime) - // const lastViewedScore = getLastViewedScore(viewTime) - return recommendationScore * activityScore -} - -function getActivityScore(contract: Contract, viewTime: number | undefined) { - const { createdTime, lastBetTime, lastCommentTime, outcomeType } = contract - const hasNewComments = - lastCommentTime && (!viewTime || lastCommentTime > viewTime) - const newCommentScore = hasNewComments ? 1 : 0.5 - - const timeSinceLastComment = Date.now() - (lastCommentTime ?? createdTime) - const commentDaysAgo = timeSinceLastComment / DAY_MS - const commentTimeScore = - 0.25 + 0.75 * (1 - logInterpolation(0, 3, commentDaysAgo)) - - const timeSinceLastBet = Date.now() - (lastBetTime ?? createdTime) - const betDaysAgo = timeSinceLastBet / DAY_MS - const betTimeScore = 0.5 + 0.5 * (1 - logInterpolation(0, 3, betDaysAgo)) - - let prob = 0.5 - if (outcomeType === 'BINARY') { - prob = getProbability(contract) - } else if (outcomeType === 'FREE_RESPONSE') { - const topAnswer = getTopAnswer(contract) - if (topAnswer) - prob = Math.max(0.5, getOutcomeProbability(contract, topAnswer.id)) - } - const frac = 1 - Math.abs(prob - 0.5) ** 2 / 0.25 - const probScore = 0.5 + frac * 0.5 - - const { volume24Hours, volume7Days } = contract - const combinedVolume = Math.log(volume24Hours + 1) + Math.log(volume7Days + 1) - const volumeScore = 0.5 + 0.5 * logInterpolation(4, 20, combinedVolume) - - const score = - newCommentScore * commentTimeScore * betTimeScore * probScore * volumeScore - - // Map score to [0.5, 1] since no recent activty is not a deal breaker. - const mappedScore = 0.5 + 0.5 * score - const newMappedScore = 0.7 + 0.3 * score - - const isNew = Date.now() < contract.createdTime + DAY_MS - return isNew ? newMappedScore : mappedScore -} - -// function getLastViewedScore(viewTime: number | undefined) { -// if (viewTime === undefined) { -// return 1 -// } - -// const daysAgo = (Date.now() - viewTime) / DAY_MS - -// if (daysAgo < 0.5) { -// const frac = logInterpolation(0, 0.5, daysAgo) -// return 0.5 + 0.25 * frac -// } - -// const frac = logInterpolation(0.5, 14, daysAgo) -// return 0.75 + 0.25 * frac -// } diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 76570f54..cc9f8ebe 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -11,8 +11,6 @@ import { last } from 'lodash' const firestore = admin.firestore() -const oneDay = 1000 * 60 * 60 * 24 - const computeInvestmentValue = ( bets: Bet[], contractsDict: { [k: string]: Contract } @@ -59,8 +57,8 @@ export const updateMetricsCore = async () => { return { doc: firestore.collection('contracts').doc(contract.id), fields: { - volume24Hours: computeVolume(contractBets, now - oneDay), - volume7Days: computeVolume(contractBets, now - oneDay * 7), + volume24Hours: computeVolume(contractBets, now - DAY_MS), + volume7Days: computeVolume(contractBets, now - DAY_MS * 7), }, } }) diff --git a/functions/src/update-recommendations.ts b/functions/src/update-recommendations.ts deleted file mode 100644 index bc82291c..00000000 --- a/functions/src/update-recommendations.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { getValue, getValues } from './utils' -import { Contract } from '../../common/contract' -import { Bet } from '../../common/bet' -import { User } from '../../common/user' -import { ClickEvent } from '../../common/tracking' -import { getWordScores } from '../../common/recommended-contracts' -import { batchedWaitAll } from '../../common/util/promise' -import { callCloudFunction } from './call-cloud-function' - -const firestore = admin.firestore() - -export const updateRecommendations = functions.pubsub - .schedule('every 24 hours') - .onRun(async () => { - const users = await getValues(firestore.collection('users')) - - const batchSize = 100 - const userBatches: User[][] = [] - for (let i = 0; i < users.length; i += batchSize) { - userBatches.push(users.slice(i, i + batchSize)) - } - - await Promise.all( - userBatches.map((batch) => - callCloudFunction('updateRecommendationsBatch', { users: batch }) - ) - ) - }) - -export const updateRecommendationsBatch = functions.https.onCall( - async (data: { users: User[] }) => { - const { users } = data - - const contracts = await getValues( - firestore.collection('contracts') - ) - - await batchedWaitAll( - users.map((user) => () => updateWordScores(user, contracts)) - ) - } -) - -export const updateWordScores = async (user: User, contracts: Contract[]) => { - const [bets, viewCounts, clicks] = await Promise.all([ - getValues( - firestore.collectionGroup('bets').where('userId', '==', user.id) - ), - - getValue<{ [contractId: string]: number }>( - firestore.doc(`private-users/${user.id}/cache/viewCounts`) - ), - - getValues( - firestore - .collection(`private-users/${user.id}/events`) - .where('type', '==', 'click') - ), - ]) - - const wordScores = getWordScores(contracts, viewCounts ?? {}, clicks, bets) - - const cachedCollection = firestore.collection( - `private-users/${user.id}/cache` - ) - await cachedCollection.doc('wordScores').set(wordScores) -} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 29f0db00..0414b01e 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' +import { Group } from '../../common/group' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -66,6 +67,10 @@ export const getContract = (contractId: string) => { return getDoc('contracts', contractId) } +export const getGroup = (groupId: string) => { + return getDoc('groups', groupId) +} + export const getUser = (userId: string) => { return getDoc('users', userId) } diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index 4c48ce49..53974f7d 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,138 +1,121 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { CPMMContract } from '../../common/contract' -import { User } from '../../common/user' -import { subtractObjects } from '../../common/util/object' -import { LiquidityProvision } from '../../common/liquidity-provision' -import { getUserLiquidityShares } from '../../common/calculate-cpmm' -import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' -import { noFees } from '../../common/fees' - -import { APIError } from './api' -import { redeemShares } from './redeem-shares' - -export const withdrawLiquidity = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } - - const { contractId } = data - if (!contractId) - return { status: 'error', message: 'Missing contract id' } - - return await firestore - .runTransaction(async (trans) => { - const lpDoc = firestore.doc(`users/${userId}`) - const lpSnap = await trans.get(lpDoc) - if (!lpSnap.exists) throw new APIError(400, 'User not found.') - const lp = lpSnap.data() as User - - 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 CPMMContract - - const liquidityCollection = firestore.collection( - `contracts/${contractId}/liquidity` - ) - - const liquiditiesSnap = await trans.get(liquidityCollection) - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const userShares = getUserLiquidityShares( - userId, - contract, - liquidities - ) - - // zero all added amounts for now - // can add support for partial withdrawals in the future - liquiditiesSnap.docs - .filter( - (_, i) => - !liquidities[i].isAnte && liquidities[i].userId === userId - ) - .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} - - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial) - - const newPool = subtractObjects(contract.pool, userShares) - - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout - - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) - - const prob = getProbability(contract) - - // surplus shares become user's bets - const bets = Object.entries(userShares) - .map(([outcome, shares]) => - shares - payout < 1 // don't create bet if less than 1 share - ? undefined - : ({ - userId: userId, - contractId: contract.id, - amount: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit) - ) - .filter((x) => x !== undefined) - - for (const bet of bets) { - const doc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - trans.create(doc, { id: doc.id, ...bet }) - } - - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(userId, contractId) - - console.log('userid', userId, 'withdraws', result) - return { status: 'success', userShares: result } - }) - .catch((e) => { - return { status: 'error', message: e.message } - }) - } - ) - -const firestore = admin.firestore() +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { CPMMContract } from '../../common/contract' +import { User } from '../../common/user' +import { subtractObjects } from '../../common/util/object' +import { LiquidityProvision } from '../../common/liquidity-provision' +import { getUserLiquidityShares } from '../../common/calculate-cpmm' +import { Bet } from '../../common/bet' +import { getProbability } from '../../common/calculate' +import { noFees } from '../../common/fees' + +import { APIError, newEndpoint, validate } from './api' +import { redeemShares } from './redeem-shares' + +const bodySchema = z.object({ + contractId: z.string(), +}) + +export const withdrawliquidity = newEndpoint({}, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) + + return await firestore + .runTransaction(async (trans) => { + const lpDoc = firestore.doc(`users/${auth.uid}`) + const lpSnap = await trans.get(lpDoc) + if (!lpSnap.exists) throw new APIError(400, 'User not found.') + const lp = lpSnap.data() as User + + 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 CPMMContract + + const liquidityCollection = firestore.collection( + `contracts/${contractId}/liquidity` + ) + + const liquiditiesSnap = await trans.get(liquidityCollection) + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const userShares = getUserLiquidityShares( + auth.uid, + contract, + liquidities, + true + ) + + // zero all added amounts for now + // can add support for partial withdrawals in the future + liquiditiesSnap.docs + .filter( + (_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid + ) + .forEach((doc) => trans.update(doc.ref, { amount: 0 })) + + const payout = Math.min(...Object.values(userShares)) + if (payout <= 0) return {} + + const newBalance = lp.balance + payout + const newTotalDeposits = lp.totalDeposits + payout + trans.update(lpDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + } as Partial) + + const newPool = subtractObjects(contract.pool, userShares) + + const minPoolShares = Math.min(...Object.values(newPool)) + const adjustedTotal = contract.totalLiquidity - payout + + // total liquidity is a bogus number; use minPoolShares to prevent from going negative + const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) + + trans.update(contractDoc, { + pool: newPool, + totalLiquidity: newTotalLiquidity, + }) + + const prob = getProbability(contract) + + // surplus shares become user's bets + const bets = Object.entries(userShares) + .map(([outcome, shares]) => + shares - payout < 1 // don't create bet if less than 1 share + ? undefined + : ({ + userId: auth.uid, + contractId: contract.id, + amount: + (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), + shares: shares - payout, + outcome, + probBefore: prob, + probAfter: prob, + createdTime: Date.now(), + isLiquidityProvision: true, + fees: noFees, + } as Omit) + ) + .filter((x) => x !== undefined) + + for (const bet of bets) { + const doc = firestore.collection(`contracts/${contract.id}/bets`).doc() + trans.create(doc, { id: doc.id, ...bet }) + } + + return userShares + }) + .then(async (result) => { + // redeem surplus bet with pre-existing bets + await redeemShares(auth.uid, contractId) + console.log('userid', auth.uid, 'withdraws', result) + return result + }) +}) + +const firestore = admin.firestore() diff --git a/functions/tsconfig.json b/functions/tsconfig.json index e183bb44..9496b9cb 100644 --- a/functions/tsconfig.json +++ b/functions/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": "../", + "composite": true, "module": "commonjs", "noImplicitReturns": true, "outDir": "lib", @@ -8,6 +9,11 @@ "strict": true, "target": "es2017" }, + "references": [ + { + "path": "../common" + } + ], "compileOnSave": true, - "include": ["src", "../common/**/*.ts"] + "include": ["src"] } diff --git a/og-image/README.md b/og-image/README.md index 7d0d2f92..6ecc4e82 100644 --- a/og-image/README.md +++ b/og-image/README.md @@ -1,32 +1,35 @@ +# Installing +1. `yarn install` +2. `yarn start` +3. `Y` to `Set up and develop “~path/to/the/repo/manifold”? [Y/n]` +4. `Manifold Markets` to `Which scope should contain your project? [Y/n] ` +5. `Y` to `Link to existing project? [Y/n] ` +6. `opengraph-image` to `What’s the name of your existing project?` + # Quickstart -1. To get started: `yarn install` -2. To test locally: `yarn start` +1. To test locally: `yarn start` The local image preview is broken for some reason; but the service works. E.g. try `http://localhost:3000/manifold.png` -3. To deploy: push to Github - -For more info, see Contributing.md - -- note2: You may have to configure Vercel the first time: - - ``` - $ yarn start - yarn run v1.22.10 - $ cd .. && vercel dev - Vercel CLI 23.1.2 dev (beta) — https://vercel.com/feedback - ? Set up and develop “~/Code/mantic”? [Y/n] y - ? Which scope should contain your project? Mantic Markets - ? Found project “mantic/mantic”. Link to it? [Y/n] n - ? Link to different existing project? [Y/n] y - ? What’s the name of your existing project? manifold-og-image - ``` - -- note2: (Not `dev` because that's reserved for Vercel) -- note3: (Or `cd .. && vercel --prod`, I think) +2. To deploy: push to Github +- note: (Not `dev` because that's reserved for Vercel) +- note2: (Or `cd .. && vercel --prod`, I think) +For more info, see Contributing.md (Everything below is from the original repo) +# Development +- Code of interest is contained in the `api/_lib` directory, i.e. `template.ts` is the page that renders the UI. +- Edit `parseRequest(req: IncomingMessage)` in `parser.ts` to add/edit query parameters. +- Note: When testing a remote branch on vercel, the og-image previews that apps load will point to +`https://manifold-og-image.vercel.app/m.png?question=etc.`, (see relevant code in `SEO.tsx`) and not your remote branch. +You have to find your opengraph-image branch's url and replace the part before `m.png` with it. + - You can also preview the image locally, e.g. `http://localhost:3000/m.png?question=etc.` + - Every time you change the template code you'll have to change the query parameter slightly as the image will likely be cached. +- You can find your remote branch's opengraph-image url by click `Visit Preview` on Github: +![](../../../../../Desktop/Screen Shot 2022-08-01 at 2.56.42 PM.png) + + # [Open Graph Image as a Service](https://og-image.vercel.app) diff --git a/og-image/api/_lib/challenge-template.ts b/og-image/api/_lib/challenge-template.ts new file mode 100644 index 00000000..6dc43ac1 --- /dev/null +++ b/og-image/api/_lib/challenge-template.ts @@ -0,0 +1,203 @@ +import { sanitizeHtml } from './sanitizer' +import { ParsedRequest } from './types' + +function getCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} + +export function getChallengeHtml(parsedReq: ParsedRequest) { + const { + theme, + fontSize, + question, + creatorName, + creatorAvatarUrl, + challengerAmount, + challengerOutcome, + creatorAmount, + creatorOutcome, + acceptedName, + acceptedAvatarUrl, + } = parsedReq + const MAX_QUESTION_CHARS = 78 + const truncatedQuestion = + question.length > MAX_QUESTION_CHARS + ? question.slice(0, MAX_QUESTION_CHARS) + '...' + : question + const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + const hideAcceptedAvatar = acceptedAvatarUrl ? '' : 'hidden' + const accepted = acceptedName !== '' + return ` + + + + Generated Image + + + + + +
+ + +
+
+ ${truncatedQuestion} +
+
+
+ + +
+

${creatorName}

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

You

+ +
+ +
+

${acceptedName}

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

Internal Error

Sorry, there was a problem

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

Internal Error

Sorry, there was a problem

') + console.error(e) } } diff --git a/package.json b/package.json index a5c1e29e..05924ef0 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,20 @@ "web" ], "scripts": { - "verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)" + "verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)" }, "dependencies": {}, "devDependencies": { "@typescript-eslint/eslint-plugin": "5.25.0", "@typescript-eslint/parser": "5.25.0", + "@types/node": "16.11.11", + "concurrently": "6.5.1", "eslint": "8.15.0", "eslint-plugin-lodash": "^7.4.0", "prettier": "2.5.0", - "typescript": "4.6.4" + "typescript": "4.6.4", + "ts-node": "10.9.1", + "nodemon": "2.0.19" }, "resolutions": { "@types/react": "17.0.43" diff --git a/web/.eslintrc.js b/web/.eslintrc.js index b55b3277..fec650f9 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], '@next/next/no-img-element': 'off', '@next/next/no-typos': 'off', + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, env: { diff --git a/web/.prettierignore b/web/.prettierignore index b79c5513..6cc1e5c7 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,4 @@ # Ignore Next artifacts .next/ -out/ \ No newline at end of file +out/ +public/**/*.json \ No newline at end of file diff --git a/web/.yarnrc b/web/.yarnrc new file mode 100644 index 00000000..fdd705c6 --- /dev/null +++ b/web/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx new file mode 100644 index 00000000..7a839a7a --- /dev/null +++ b/web/components/NotificationSettings.tsx @@ -0,0 +1,210 @@ +import { useUser } from 'web/hooks/use-user' +import React, { useEffect, useState } from 'react' +import { notification_subscribe_types, PrivateUser } from 'common/user' +import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' +import toast from 'react-hot-toast' +import { track } from '@amplitude/analytics-browser' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' + +export function NotificationSettings() { + const user = useUser() + const [notificationSettings, setNotificationSettings] = + useState('all') + const [emailNotificationSettings, setEmailNotificationSettings] = + useState('all') + const [privateUser, setPrivateUser] = useState(null) + + useEffect(() => { + if (user) listenForPrivateUser(user.id, setPrivateUser) + }, [user]) + + useEffect(() => { + if (!privateUser) return + if (privateUser.notificationPreferences) { + setNotificationSettings(privateUser.notificationPreferences) + } + if ( + privateUser.unsubscribedFromResolutionEmails && + privateUser.unsubscribedFromCommentEmails && + privateUser.unsubscribedFromAnswerEmails + ) { + setEmailNotificationSettings('none') + } else if ( + !privateUser.unsubscribedFromResolutionEmails && + !privateUser.unsubscribedFromCommentEmails && + !privateUser.unsubscribedFromAnswerEmails + ) { + setEmailNotificationSettings('all') + } else { + setEmailNotificationSettings('less') + } + }, [privateUser]) + + const loading = 'Changing Notifications Settings' + const success = 'Notification Settings Changed!' + function changeEmailNotifications(newValue: notification_subscribe_types) { + if (!privateUser) return + if (newValue === 'all') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: false, + unsubscribedFromCommentEmails: false, + unsubscribedFromAnswerEmails: false, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } else if (newValue === 'less') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: false, + unsubscribedFromCommentEmails: true, + unsubscribedFromAnswerEmails: true, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } else if (newValue === 'none') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: true, + unsubscribedFromCommentEmails: true, + unsubscribedFromAnswerEmails: true, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } + } + + function changeInAppNotificationSettings( + newValue: notification_subscribe_types + ) { + if (!privateUser) return + track('In-App Notification Preferences Changed', { + newPreference: newValue, + oldPreference: privateUser.notificationPreferences, + }) + toast.promise( + updatePrivateUser(privateUser.id, { + notificationPreferences: newValue, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } + + useEffect(() => { + if (privateUser && privateUser.notificationPreferences) + setNotificationSettings(privateUser.notificationPreferences) + else setNotificationSettings('all') + }, [privateUser]) + + if (!privateUser) { + return + } + + function NotificationSettingLine(props: { + label: string + highlight: boolean + }) { + const { label, highlight } = props + return ( + + {highlight ? : } + {label} + + ) + } + + return ( +
+
In App Notifications
+ + changeInAppNotificationSettings( + choice as notification_subscribe_types + ) + } + className={'col-span-4 p-2'} + toggleClassName={'w-24'} + /> +
+
+
+ You will receive notifications for: + + + + + +
+
+
+
Email Notifications
+ + changeEmailNotifications(choice as notification_subscribe_types) + } + className={'col-span-4 p-2'} + toggleClassName={'w-24'} + /> +
+
+ You will receive emails for: + + + + +
+
+
+ ) +} diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 11e24c99..08dee31e 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,5 +1,6 @@ import { ReactNode } from 'react' import Head from 'next/head' +import { Challenge } from 'common/challenge' export type OgCardProps = { question: string @@ -8,27 +9,51 @@ export type OgCardProps = { creatorName: string creatorUsername: string creatorAvatarUrl?: string + numericValue?: string } -function buildCardUrl(props: OgCardProps) { +function buildCardUrl(props: OgCardProps, challenge?: Challenge) { + const { + creatorAmount, + acceptances, + acceptorAmount, + creatorOutcome, + acceptorOutcome, + } = challenge || {} + const { userName, userAvatarUrl } = acceptances?.[0] ?? {} + const probabilityParam = props.probability === undefined ? '' : `&probability=${encodeURIComponent(props.probability ?? '')}` + + const numericValueParam = + props.numericValue === undefined + ? '' + : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + const creatorAvatarUrlParam = props.creatorAvatarUrl === undefined ? '' : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + const challengeUrlParams = challenge + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + + `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + + `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` + : '' + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + `?question=${encodeURIComponent(props.question)}` + probabilityParam + + numericValueParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + + challengeUrlParams ) } @@ -38,8 +63,9 @@ export function SEO(props: { url?: string children?: ReactNode ogCardProps?: OgCardProps + challenge?: Challenge }) { - const { title, description, url, children, ogCardProps } = props + const { title, description, url, children, ogCardProps, challenge } = props return ( @@ -71,13 +97,13 @@ export function SEO(props: { <> diff --git a/web/components/alert-box.tsx b/web/components/alert-box.tsx index a8306583..b908b180 100644 --- a/web/components/alert-box.tsx +++ b/web/components/alert-box.tsx @@ -1,24 +1,26 @@ import { ExclamationIcon } from '@heroicons/react/solid' +import { Col } from './layout/col' +import { Row } from './layout/row' import { Linkify } from './linkify' export function AlertBox(props: { title: string; text: string }) { const { title, text } = props return ( -
-
-
-
+