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/.gitignore b/.gitignore index 10f5d982..b59de328 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .vercel node_modules yarn-error.log + +firebase-debug.log \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json index dd2fdceb..0aaf34ac 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,7 +6,8 @@ "recommendations": [ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "toba.vsfire" + "toba.vsfire", + "bradlc.vscode-tailwindcss" ], // List of extensions recommended by VS Code that should not be recommended for users of this workspace. "unwantedRecommendations": [] 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..b3dd990b 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -10,14 +10,12 @@ import { import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' +import { ENV_CONFIG } from './envs/constants' -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, diff --git a/common/api.ts b/common/api.ts new file mode 100644 index 00000000..b9376be5 --- /dev/null +++ b/common/api.ts @@ -0,0 +1,22 @@ +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_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 993a2fac..d5072c0f 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,9 +26,7 @@ export type Bet = { isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean - - createdTime: number -} +} & Partial export type NumericBet = Bet & { value: number @@ -35,4 +34,29 @@ export type NumericBet = Bet & { 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 +} + export const MAX_LOAN_PER_CONTRACT = 20 diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index e7d56ba3..493b5fa9 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,111 @@ 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 (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 +264,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 +290,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..e1f3e239 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,25 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, +} 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 +74,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 +100,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 +125,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 +174,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 { diff --git a/common/categories.ts b/common/categories.ts index 2bd6d25a..232aa526 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -1,5 +1,6 @@ import { difference } from 'lodash' +export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' export const CATEGORIES = { politics: 'Politics', technology: 'Technology', @@ -24,9 +25,15 @@ 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', + 'culture', + 'gaming', + 'crypto', + 'world', +] -export const DEFAULT_CATEGORIES = difference( - CATEGORY_LIST, - EXCLUDED_CATEGORIES -) +export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) diff --git a/common/charity.ts b/common/charity.ts index 249bcc51..f1223b04 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -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,22 @@ 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.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/contract.ts b/common/contract.ts index dc91a20e..5ddcf0b8 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,10 +1,12 @@ import { Answer } from './answer' import { Fees } from './fees' +import { JSONContent } from '@tiptap/core' export type AnyMechanism = DPM | CPMM -export type AnyOutcomeType = Binary | FreeResponse | Numeric +export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric export type AnyContractType = | (CPMM & Binary) + | (CPMM & PseudoNumeric) | (DPM & Binary) | (DPM & FreeResponse) | (DPM & Numeric) @@ -19,7 +21,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 +35,7 @@ export type Contract = { isResolved: boolean resolutionTime?: number // When the contract creator resolved the market resolution?: string - resolutionProbability?: number, + resolutionProbability?: number closeEmailsSent?: number @@ -42,9 +44,14 @@ export type Contract = { volume7Days: number collectedFees: Fees + + groupSlugs?: string[] + uniqueBettorIds?: string[] + uniqueBettorCount?: number } & T export type BinaryContract = Contract & Binary +export type PseudoNumericContract = Contract & PseudoNumeric export type NumericContract = Contract & Numeric export type FreeResponseContract = Contract & FreeResponse export type DPMContract = Contract & DPM @@ -75,6 +82,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'. @@ -94,7 +113,12 @@ 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', + 'FREE_RESPONSE', + 'PSEUDO_NUMERIC', + 'NUMERIC', +] as const export const MAX_QUESTION_LENGTH = 480 export const MAX_DESCRIPTION_LENGTH = 10000 diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 9362494f..0c98f05e 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -36,5 +36,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 e20c6e9e..3c062472 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -12,12 +12,7 @@ export const DEV_CONFIG: EnvConfig = { appId: '1:134303100058:web:27f9ea8b83347251f80323', measurementId: 'G-YJC9E37P37', }, - functionEndpoints: { - placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app', - sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app', - sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app', - createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', - creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app', - }, + cloudRunId: 'w3txbmd3ba', + cloudRunRegion: 'uc', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', } diff --git a/common/envs/hanania.ts b/common/envs/hanania.ts index 5aa1578a..32bbacde 100644 --- a/common/envs/hanania.ts +++ b/common/envs/hanania.ts @@ -11,16 +11,11 @@ export const HANANIA_CONFIG: EnvConfig = { appId: '1:319008991675:web:d2dc5e72b95cdcec96fc9e', measurementId: 'G-VCXVKYGKTC', }, - // TODO replace - functionEndpoints: { - placebet: 'https://placebet-45jazbrfja-uc.a.run.app', - sellshares: 'https://sellshares-45jazbrfja-uc.a.run.app', - sellbet: 'https://sellbet-45jazbrfja-uc.a.run.app', - createmarket: 'https://createmarket-45jazbrfja-uc.a.run.app', - creategroup: 'https://creategroup-45jazbrfja-uc.a.run.app', - }, + cloudRunId: '45jazbrfja', // TODO: fill in real ID for T1 + cloudRunRegion: 'uc', adminEmails: [...PROD_CONFIG.adminEmails], whitelistEmail: '', moneyMoniker: 'H$', visibility: 'PRIVATE', + newQuestionPlaceholders: [], } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 7bec6756..f8aaf4cc 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -1,16 +1,13 @@ -export type V2CloudFunction = - | 'placebet' - | 'sellbet' - | 'sellshares' - | 'createmarket' - | 'creategroup' - export type EnvConfig = { domain: string firebaseConfig: FirebaseConfig - functionEndpoints: Record amplitudeApiKey?: string + // IDs for v2 cloud functions -- find these by deploying a cloud function and + // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app + cloudRunId: string + cloudRunRegion: string + // Access controls adminEmails: string[] whitelistEmail?: string // e.g. '@theoremone.co'. If not provided, all emails are whitelisted @@ -20,14 +17,18 @@ export type EnvConfig = { moneyMoniker: string // e.g. 'M$' faviconPath?: string // Should be a file in /public navbarLogoPath?: string - newQuestionPlaceholders?: string[] // TODO remove + newQuestionPlaceholders: string[] + + // Currency controls + fixedAnte?: number + startingBalance?: number } type FirebaseConfig = { apiKey: string authDomain: string projectId: string - region?: string // TODO remove + region?: string storageBucket: string messagingSenderId: string appId: string @@ -48,13 +49,8 @@ export const PROD_CONFIG: EnvConfig = { appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', measurementId: 'G-SSFK1Q138D', }, - functionEndpoints: { - placebet: 'https://placebet-nggbo3neva-uc.a.run.app', - sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', - sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', - createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', - creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app', - }, + cloudRunId: 'nggbo3neva', + cloudRunRegion: 'uc', adminEmails: [ 'akrolsmir@gmail.com', // Austin 'jahooma@gmail.com', // James diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts index afc2b8ba..3b7f595b 100644 --- a/common/envs/theoremone.ts +++ b/common/envs/theoremone.ts @@ -12,14 +12,8 @@ export const THEOREMONE_CONFIG: EnvConfig = { appId: '1:698012149198:web:b342af75662831aa84b79f', measurementId: 'G-Y3EZ1WNT6E', }, - // TODO: fill in real endpoints for T1 - functionEndpoints: { - placebet: 'https://placebet-nggbo3neva-uc.a.run.app', - sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', - sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', - createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', - creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app', - }, + cloudRunId: 'nggbo3neva', // TODO: fill in real ID for T1 + cloudRunRegion: 'uc', adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], whitelistEmail: '@theoremone.co', moneyMoniker: 'T$', diff --git a/common/group.ts b/common/group.ts index f06fdd15..15348d5a 100644 --- a/common/group.ts +++ b/common/group.ts @@ -9,7 +9,10 @@ export type Group = { memberIds: string[] // User ids anyoneCanJoin: boolean contractIds: string[] + + chatDisabled?: boolean } 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'] diff --git a/common/manalink.ts b/common/manalink.ts new file mode 100644 index 00000000..7dc3b8dc --- /dev/null +++ b/common/manalink.ts @@ -0,0 +1,35 @@ +export type Manalink = { + // The link to send: https://manifold.markets/send/{slug} + // Also functions as the unique id for the link. + slug: string + + // Note: we assume both fromId and toId are of SourceType 'USER' + fromId: string + + // Displayed to people claiming the link + message: string + + // How much to send with the link + amount: number + token: 'M$' // TODO: could send eg YES shares too?? + + createdTime: number + // If null, the link is valid forever + expiresTime: number | null + // If null, the link can be used infinitely + maxUses: number | null + + // Used for simpler caching + claimedUserIds: string[] + // Successful redemptions of the link + claims: Claim[] +} + +export type Claim = { + toId: string + + // The ID of the successful txn that tracks the money moved + txnId: string + + claimedTime: number +} diff --git a/common/new-bet.ts b/common/new-bet.ts index ba799624..f484b9f7 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,6 +1,6 @@ -import { sumBy } from 'lodash' +import { sortBy, sumBy } from 'lodash' -import { Bet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' +import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { calculateDpmShares, getDpmProbability, @@ -8,20 +8,31 @@ import { getNumericBets, calculateNumericDpmShares, } from './calculate-dpm' -import { calculateCpmmPurchase, getCpmmProbability } from './calculate-cpmm' +import { + calculateCpmmAmountToProb, + calculateCpmmPurchase, + CpmmState, + getCpmmProbability, +} from './calculate-cpmm' import { CPMMBinaryContract, DPMBinaryContract, FreeResponseContract, 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 } @@ -29,38 +40,203 @@ export type BetInfo = { newP?: number } -export const getNewBinaryCpmmBetInfo = ( - outcome: 'YES' | 'NO', +const computeFill = ( amount: number, - contract: CPMMBinaryContract, - loanAmount: number + 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, - 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[] +) => { + 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 getNewBinaryDpmBetInfo = ( @@ -95,7 +271,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: CandidateBet = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -132,7 +308,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, diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..abfafaf8 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -7,10 +7,12 @@ import { FreeResponse, 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 +20,7 @@ export function getNewContract( creator: User, question: string, outcomeType: outcomeType, - description: string, + description: JSONContent, initialProb: number, ante: number, closeTime: number, @@ -27,16 +29,23 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean ) { 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) : getFreeAnswerProps(ante) @@ -52,7 +61,7 @@ export function getNewContract( creatorAvatarUrl: creator.avatarUrl, question: question.trim(), - description: description.trim(), + description, tags, lowercaseTags, visibility: 'public', @@ -111,6 +120,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', diff --git a/common/notification.ts b/common/notification.ts index 919cf917..63a44a52 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,8 @@ export type notification_source_types = | 'tip' | 'admin_message' | 'group' + | 'user' + | 'bonus' export type notification_source_update_types = | 'created' @@ -53,3 +57,9 @@ 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' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index ef364b74..46885668 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 = 5 diff --git a/common/package.json b/common/package.json index 1bd67851..6f0f5b29 100644 --- a/common/package.json +++ b/common/package.json @@ -3,10 +3,14 @@ "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/extension-image": "2.0.0-beta.30", + "@tiptap/extension-link": "2.0.0-beta.43", + "@tiptap/starter-kit": "2.0.0-beta.190", "lodash": "4.17.21" }, "devDependencies": { 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..1469cf4e 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,11 +109,11 @@ 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) @@ -115,8 +124,8 @@ export const getDpmPayouts = ( return getDpmStandardPayouts(outcome, contract, openBets) case 'MKT': - return contract.outcomeType === 'FREE_RESPONSE' - ? getPayoutsMultiOutcome(resolutions, contract, openBets) + return contract.outcomeType === 'FREE_RESPONSE' // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? getPayoutsMultiOutcome(resolutions!, contract, openBets) : getDpmMktPayouts(contract, openBets, resolutionProbability) case 'CANCEL': case undefined: diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts new file mode 100644 index 00000000..c99e670f --- /dev/null +++ b/common/pseudo-numeric.ts @@ -0,0 +1,45 @@ +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) + return 10 ** logValue + min + } + + 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 (isLogScale) { + return Math.log10(value - min) / Math.log10(max - min) + } + + 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/stats.ts b/common/stats.ts new file mode 100644 index 00000000..152a6eae --- /dev/null +++ b/common/stats.ts @@ -0,0 +1,23 @@ +export type Stats = { + startDate: number + dailyActiveUsers: number[] + weeklyActiveUsers: number[] + monthlyActiveUsers: number[] + dailyBetCounts: number[] + dailyContractCounts: number[] + dailyCommentCounts: number[] + dailySignups: number[] + weekOnWeekRetention: number[] + monthlyRetention: number[] + weeklyActivationRate: number[] + topTenthActions: { + daily: number[] + weekly: number[] + monthly: number[] + } + manaBet: { + daily: number[] + weekly: number[] + monthly: number[] + } +} 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 c64eddbb..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 +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,6 +16,8 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -34,10 +36,31 @@ type Tip = { toType: 'USER' category: 'TIP' data: { - contractId: string commentId: string + contractId?: string + groupId?: string } } +type Manalink = { + fromType: 'USER' + toType: 'USER' + 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..477139fd 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,11 +35,15 @@ export type User = { followerCountCached: number followedCategories?: string[] + + referredByUserId?: string + referredByContractId?: string } -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 = 500 export type PrivateUser = { id: string // same as User.id username: string // denormalized from User @@ -51,6 +57,7 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types + lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' 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/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..94b5ab7f 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,25 @@ 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' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -27,3 +48,31 @@ export function parseWordsAsTags(text: string) { .join(' ') return parseTags(taggedText) } + +// 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, +] +// export const exhibitExts = [StarterKit as unknown as Extension, Image] + +export function richTextToString(text?: JSONContent) { + return !text ? '' : generateText(text, exhibitExts) +} diff --git a/docs/docs/api.md b/docs/docs/api.md index e7e6a341..1cea6027 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -106,7 +106,7 @@ Requires no authorization. // A list of tags on each market. Any user can add tags to any market. // This list also includes the predefined categories shown as filters on the home page. tags: string[] - + // Note: This url always points to https://manifold.markets, regardless of what instance the api is running on. // This url includes the creator's username, but this doesn't need to be correct when constructing valid URLs. // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market @@ -115,10 +115,10 @@ Requires no authorization. outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC mechanism: string // dpm-2 or cpmm-1 - pool: number // sum of YES and NO shares in liquidity pool for CPMM, null for DPM probability: number - p?: number // probability constant in y^p * n^(1-p) = k - totalLiquidity?: number + pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. + p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k + totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool volume: number volume7Days: number @@ -127,7 +127,7 @@ Requires no authorization. isResolved: boolean resolutionTime?: number resolution?: string - resolutionProbability?: number // Used for BINARY markets resolved to MKT + resolutionProbability?: number // Used for BINARY markets resolved to MKT } ``` @@ -147,202 +147,204 @@ Requires no authorization. ```json { - "id":"lEoqtnDgJzft6apSKzYK", - "creatorUsername":"Angela", - "creatorName":"Angela", - "createdTime":1655258914863, - "creatorAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "closeTime":1655265001448, - "question":"What is good?", - "description":"Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.", - "tags":[], - "url":"https://manifold.markets/Angela/what-is-good", - "pool":null, - "outcomeType":"FREE_RESPONSE", - "mechanism":"dpm-2", - "volume":112, - "volume7Days":212, - "volume24Hours":0, - "isResolved":true, - "resolution":"MKT", - "resolutionTime":1655265001448, - "answers":[ + "id": "lEoqtnDgJzft6apSKzYK", + "creatorUsername": "Angela", + "creatorName": "Angela", + "createdTime": 1655258914863, + "creatorAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "closeTime": 1655265001448, + "question": "What is good?", + "description": "Resolves proportionally to the answer(s) which I find most compelling. (Obviously I’ll refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.", + "tags": [], + "url": "https://manifold.markets/Angela/what-is-good", + "pool": null, + "outcomeType": "FREE_RESPONSE", + "mechanism": "dpm-2", + "volume": 112, + "volume7Days": 212, + "volume24Hours": 0, + "isResolved": true, + "resolution": "MKT", + "resolutionTime": 1655265001448, + "answers": [ { - "createdTime":1655258941573, - "avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "id":"1", - "username":"Angela", - "number":1, - "name":"Angela", - "contractId":"lEoqtnDgJzft6apSKzYK", - "text":"ANTE", - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "probability":0.66749733001068 + "createdTime": 1655258941573, + "avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "id": "1", + "username": "Angela", + "number": 1, + "name": "Angela", + "contractId": "lEoqtnDgJzft6apSKzYK", + "text": "ANTE", + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "probability": 0.66749733001068 }, { - "name":"Isaac King", - "username":"IsaacKing", - "text":"This answer", - "userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2", - "id":"2", - "number":2, - "avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c", - "contractId":"lEoqtnDgJzft6apSKzYK", - "createdTime":1655261198074, - "probability":0.008922214311142757 + "name": "Isaac King", + "username": "IsaacKing", + "text": "This answer", + "userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2", + "id": "2", + "number": 2, + "avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c", + "contractId": "lEoqtnDgJzft6apSKzYK", + "createdTime": 1655261198074, + "probability": 0.008922214311142757 }, { - "createdTime":1655263226587, - "userId":"jbgplxty4kUKIa1MmgZk22byJq03", - "id":"3", - "avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a", - "text":"Toyota Camry", - "contractId":"lEoqtnDgJzft6apSKzYK", - "name":"Undox", - "username":"Undox", - "number":3, - "probability":0.008966714133143469 + "createdTime": 1655263226587, + "userId": "jbgplxty4kUKIa1MmgZk22byJq03", + "id": "3", + "avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a", + "text": "Toyota Camry", + "contractId": "lEoqtnDgJzft6apSKzYK", + "name": "Undox", + "username": "Undox", + "number": 3, + "probability": 0.008966714133143469 }, { - "number":4, - "name":"James Grugett", - "userId":"5LZ4LgYuySdL1huCWe7bti02ghx2", - "text":"Utility (Defined by your personal utility function.)", - "createdTime":1655264793224, - "contractId":"lEoqtnDgJzft6apSKzYK", - "username":"JamesGrugett", - "id":"4", - "avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c", - "probability":0.09211463154147384 + "number": 4, + "name": "James Grugett", + "userId": "5LZ4LgYuySdL1huCWe7bti02ghx2", + "text": "Utility (Defined by your personal utility function.)", + "createdTime": 1655264793224, + "contractId": "lEoqtnDgJzft6apSKzYK", + "username": "JamesGrugett", + "id": "4", + "avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c", + "probability": 0.09211463154147384 } ], - "comments":[ + "comments": [ { - "id":"ZdHIyfQazHyl8nI0ENS7", - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "createdTime":1655265807433, - "text":"ok what\ni did not resolve this intentionally", - "contractId":"lEoqtnDgJzft6apSKzYK", - "userName":"Angela", - "userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "userUsername":"Angela" + "id": "ZdHIyfQazHyl8nI0ENS7", + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "createdTime": 1655265807433, + "text": "ok what\ni did not resolve this intentionally", + "contractId": "lEoqtnDgJzft6apSKzYK", + "userName": "Angela", + "userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "userUsername": "Angela" }, { - "userName":"James Grugett", - "userUsername":"JamesGrugett", - "id":"F7fvHGhTiFal8uTsUc9P", - "userAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c","replyToCommentId":"ZdHIyfQazHyl8nI0ENS7", - "text":"@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.", - "createdTime":1655266286514, - "userId":"5LZ4LgYuySdL1huCWe7bti02ghx2", - "contractId":"lEoqtnDgJzft6apSKzYK" + "userName": "James Grugett", + "userUsername": "JamesGrugett", + "id": "F7fvHGhTiFal8uTsUc9P", + "userAvatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c", + "replyToCommentId": "ZdHIyfQazHyl8nI0ENS7", + "text": "@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.", + "createdTime": 1655266286514, + "userId": "5LZ4LgYuySdL1huCWe7bti02ghx2", + "contractId": "lEoqtnDgJzft6apSKzYK" }, { - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "contractId":"lEoqtnDgJzft6apSKzYK", - "id":"PIHhXy5hLHSgW8uoUD0Q", - "userName":"Angela", - "text":"lmk if anyone lost manna from this situation and i'll try to fix it", - "userUsername":"Angela", - "createdTime":1655277581308, - "userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476" - },{ - "userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", - "userName":"Angela", - "text":"from my end it looks like no one did", - "replyToCommentId":"PIHhXy5hLHSgW8uoUD0Q", - "createdTime":1655287149528, - "userUsername":"Angela", - "id":"5slnWEQWwm6dHjDi6oiH", - "contractId":"lEoqtnDgJzft6apSKzYK", - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2" + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "contractId": "lEoqtnDgJzft6apSKzYK", + "id": "PIHhXy5hLHSgW8uoUD0Q", + "userName": "Angela", + "text": "lmk if anyone lost manna from this situation and i'll try to fix it", + "userUsername": "Angela", + "createdTime": 1655277581308, + "userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476" + }, + { + "userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476", + "userName": "Angela", + "text": "from my end it looks like no one did", + "replyToCommentId": "PIHhXy5hLHSgW8uoUD0Q", + "createdTime": 1655287149528, + "userUsername": "Angela", + "id": "5slnWEQWwm6dHjDi6oiH", + "contractId": "lEoqtnDgJzft6apSKzYK", + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2" } ], - "bets":[ + "bets": [ { - "outcome":"0", - "contractId":"lEoqtnDgJzft6apSKzYK", - "fees":{ - "liquidityFee":0, - "creatorFee":0, - "platformFee":0 + "outcome": "0", + "contractId": "lEoqtnDgJzft6apSKzYK", + "fees": { + "liquidityFee": 0, + "creatorFee": 0, + "platformFee": 0 }, - "isAnte":true, - "shares":100, - "probAfter":1, - "amount":100, - "userId":"IPTOzEqrpkWmEzh6hwvAyY9PqFb2", - "createdTime":1655258914863, - "probBefore":0, - "id":"2jNZqnwoEQL7WDTTAWDP" + "isAnte": true, + "shares": 100, + "probAfter": 1, + "amount": 100, + "userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2", + "createdTime": 1655258914863, + "probBefore": 0, + "id": "2jNZqnwoEQL7WDTTAWDP" }, { - "shares":173.20508075688772, - "fees":{ - "platformFee":0, - "liquidityFee":0, - "creatorFee":0 + "shares": 173.20508075688772, + "fees": { + "platformFee": 0, + "liquidityFee": 0, + "creatorFee": 0 }, - "contractId":"lEoqtnDgJzft6apSKzYK", - "probBefore":0, - "createdTime":1655258941573, - "loanAmount":0, - "userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2", - "amount":100, - "outcome":"1", - "probAfter":0.75, - "id":"xuc3JoiNkE8lXPh15mUb" + "contractId": "lEoqtnDgJzft6apSKzYK", + "probBefore": 0, + "createdTime": 1655258941573, + "loanAmount": 0, + "userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2", + "amount": 100, + "outcome": "1", + "probAfter": 0.75, + "id": "xuc3JoiNkE8lXPh15mUb" }, { - "userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2", - "contractId":"lEoqtnDgJzft6apSKzYK", - "loanAmount":0, - "probAfter":0.009925496893641248, - "id":"8TBlzPtOdO0q5BgSyRbi", - "createdTime":1655261198074, - "shares":20.024984394500787, - "amount":1, - "outcome":"2", - "probBefore":0, - "fees":{ - "liquidityFee":0, - "creatorFee":0, - "platformFee":0 + "userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2", + "contractId": "lEoqtnDgJzft6apSKzYK", + "loanAmount": 0, + "probAfter": 0.009925496893641248, + "id": "8TBlzPtOdO0q5BgSyRbi", + "createdTime": 1655261198074, + "shares": 20.024984394500787, + "amount": 1, + "outcome": "2", + "probBefore": 0, + "fees": { + "liquidityFee": 0, + "creatorFee": 0, + "platformFee": 0 } }, { - "probAfter":0.00987648269777473, - "outcome":"3", - "id":"9vdwes6s9QxbYZUBhHs4", - "createdTime":1655263226587, - "shares":20.074859899884732, - "amount":1, - "loanAmount":0, - "fees":{ - "liquidityFee":0, - "platformFee":0, - "creatorFee":0 + "probAfter": 0.00987648269777473, + "outcome": "3", + "id": "9vdwes6s9QxbYZUBhHs4", + "createdTime": 1655263226587, + "shares": 20.074859899884732, + "amount": 1, + "loanAmount": 0, + "fees": { + "liquidityFee": 0, + "platformFee": 0, + "creatorFee": 0 }, - "userId":"jbgplxty4kUKIa1MmgZk22byJq03", - "contractId":"lEoqtnDgJzft6apSKzYK", - "probBefore":0 + "userId": "jbgplxty4kUKIa1MmgZk22byJq03", + "contractId": "lEoqtnDgJzft6apSKzYK", + "probBefore": 0 }, { - "createdTime":1655264793224, - "fees":{ - "creatorFee":0, - "liquidityFee":0, - "platformFee":0 + "createdTime": 1655264793224, + "fees": { + "creatorFee": 0, + "liquidityFee": 0, + "platformFee": 0 }, - "probAfter":0.09211463154147384, - "amount":10, - "id":"BehiSGgk1wAkIWz1a8L4", - "userId":"5LZ4LgYuySdL1huCWe7bti02ghx2", - "contractId":"lEoqtnDgJzft6apSKzYK", - "loanAmount":0, - "probBefore":0, - "outcome":"4", - "shares":64.34283176858165 + "probAfter": 0.09211463154147384, + "amount": 10, + "id": "BehiSGgk1wAkIWz1a8L4", + "userId": "5LZ4LgYuySdL1huCWe7bti02ghx2", + "contractId": "lEoqtnDgJzft6apSKzYK", + "loanAmount": 0, + "probBefore": 0, + "outcome": "4", + "shares": 64.34283176858165 } ] } @@ -396,6 +398,64 @@ Requires no authorization. ``` - Response type: A `FullMarket` ; same as above. +### `GET /v0/users` + +Lists all users. + +Requires no authorization. + +- Example request + ``` + https://manifold.markets/api/v0/users + ``` +- Example response + ```json + [ + { + "id":"igi2zGXsfxYPgB0DJTXVJVmwCOr2", + "createdTime":1639011767273, + "name":"Austin", + "username":"Austin", + "url":"https://manifold.markets/Austin", + "avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c", + "bio":"I build Manifold! Always happy to chat; reach out on Discord or find a time on https://calendly.com/austinchen/manifold!", + "bannerUrl":"https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1531&q=80", + "website":"https://blog.austn.io", + "twitterHandle":"akrolsmir", + "discordHandle":"akrolsmir#4125", + "balance":9122.607163564959, + "totalDeposits":10339.004780544328, + "totalPnLCached":9376.601262721899, + "creatorVolumeCached":76078.46984199001 + } + ``` +- Response type: Array of `LiteUser` + + ```tsx + // Basic information about a user + type LiteUser = { + id: string // user's unique id + createdTime: number + + name: string // display name, may contain spaces + username: string // username, used in urls + url: string // link to user's profile + avatarUrl?: string + + bio?: string + bannerUrl?: string + website?: string + twitterHandle?: string + discordHandle?: string + + // Note: the following are here for convenience only and may be removed in the future. + balance: number + totalDeposits: number + totalPnLCached: number + creatorVolumeCached: number + } + ``` + ### `POST /v0/bet` Places a new bet on behalf of the authorized user. @@ -453,6 +513,118 @@ $ 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} \ + ]}' +``` + +### `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-california-abolish-daylight-sa + ``` +- Response type: A `Bet[]`. + +-
Example response

+ + ```json + [ + { + "probAfter": 0.44418877319153904, + "shares": -645.8346334931828, + "outcome": "YES", + "contractId": "tgB1XmvFXZNhjr3xMNLp", + "sale": { + "betId": "RcOtarI3d1DUUTjiE0rx", + "amount": 474.9999999999998 + }, + "createdTime": 1644602886293, + "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", + "probBefore": 0.7229189477449224, + "id": "x9eNmCaqQeXW8AgJ8Zmp", + "amount": -499.9999999999998 + }, + { + "probAfter": 0.9901970375647697, + "contractId": "zdeaYVAfHlo9jKzWh57J", + "outcome": "YES", + "amount": 1, + "id": "8PqxKYwXCcLYoXy2m2Nm", + "shares": 1.0049875638533763, + "userId": "94YYTk1AFWfbWMpfYcvnnwI1veP2", + "probBefore": 0.9900000000000001, + "createdTime": 1644705818872 + } + ] + ``` + +

+
+ ## Changelog - 2022-06-08: Add paging to markets endpoint 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/firebase.json b/firebase.json index 8da7d8f3..25f9b61f 100644 --- a/firebase.json +++ b/firebase.json @@ -1,8 +1,8 @@ { "functions": { - "predeploy": "npm --prefix \"$RESOURCE_DIR\" run build", - "runtime": "nodejs12", - "source": "functions" + "predeploy": "cd functions && yarn build", + "runtime": "nodejs16", + "source": "functions/dist" }, "firestore": { "rules": "firestore.rules", diff --git a/firestore.indexes.json b/firestore.indexes.json index 5dd029df..12e88033 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -306,6 +306,62 @@ } ] }, + { + "collectionGroup": "manalinks", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "fromId", + "order": "ASCENDING" + }, + { + "fieldPath": "createdTime", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "notifications", + "queryScope": "COLLECTION", + "fields": [ + { + "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", @@ -396,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 2be7f21a..918448d6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,13 +12,26 @@ service cloud.firestore { || request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold } + match /stats/stats { + allow read; + } + match /users/{userId} { allow read; allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(resource.data.id == 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; } @@ -58,7 +71,7 @@ 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']); allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['description', 'closeTime']) && resource.data.creatorId == request.auth.uid; @@ -104,6 +117,16 @@ service cloud.firestore { allow read; } + // Note: `resource` = existing doc, `request.resource` = incoming doc + match /manalinks/{slug} { + // Anyone can view any manalink + allow get; + // Only you can create a manalink with your fromId + allow create: if request.auth.uid == request.resource.data.fromId; + // Only you can list and change your own manalinks + allow list, update: if request.auth.uid == resource.data.fromId; + } + match /users/{userId}/notifications/{notificationId} { allow read; allow update: if resource.data.userId == request.auth.uid 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 7fd312c3..8013fb20 100644 --- a/functions/README.md +++ b/functions/README.md @@ -23,8 +23,10 @@ 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 @@ -52,7 +54,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started ## Deploying 0. `$ firebase use prod` to switch to prod -1. `$ yarn deploy` to push your changes live! +1. `$ firebase deploy --only functions` to push your changes live! (Future TODO: auto-deploy functions on Git push) ## Secrets management diff --git a/functions/package.json b/functions/package.json index 7b5c30b0..f8657516 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,23 +5,29 @@ "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", + "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/starter-kit": "2.0.0-beta.190", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", 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 abbf952b..8c01ea05 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -3,12 +3,14 @@ import { logger } from 'firebase-functions/v2' import { HttpsOptions, onRequest, Request } 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 +22,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( @@ -108,17 +99,26 @@ export const validate = (schema: T, val: unknown) => { } } -const DEFAULT_OPTS: HttpsOptions = { - minInstances: 1, - cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], +interface EndpointOptions extends HttpsOptions { + methods?: string[] } -export const newEndpoint = (methods: [string], fn: Handler) => - onRequest(DEFAULT_OPTS, async (req, res) => { +const DEFAULT_OPTS = { + methods: ['POST'], + minInstances: 1, + concurrency: 100, + memory: '2GiB', + cpu: 1, + cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_VERCEL, CORS_ORIGIN_LOCALHOST], +} + +export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { + const opts = Object.assign(endpointOpts, DEFAULT_OPTS) + return onRequest(opts, async (req, res) => { log('Request processing started.') try { - if (!methods.includes(req.method)) { - const allowed = methods.join(', ') + if (!opts.methods.includes(req.method)) { + const allowed = opts.methods.join(', ') throw new APIError(405, `This endpoint supports only ${allowed}.`) } const authedUser = await lookupUser(await parseCredentials(req)) @@ -126,7 +126,7 @@ export const newEndpoint = (methods: [string], fn: Handler) => res.status(200).json(await fn(req, authedUser)) } catch (e) { if (e instanceof APIError) { - const output: { [k: string]: unknown } = { message: e.msg } + const output: { [k: string]: unknown } = { message: e.message } if (e.details != null) { output.details = e.details } @@ -137,3 +137,4 @@ export const newEndpoint = (methods: [string], fn: Handler) => } } }) +} 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..d29a6cee --- /dev/null +++ b/functions/src/cancel-bet.ts @@ -0,0 +1,35 @@ +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) + + const result = 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 } + }) + + return result +}) + +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 new file mode 100644 index 00000000..3822bbf7 --- /dev/null +++ b/functions/src/claim-manalink.ts @@ -0,0 +1,104 @@ +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' + +const bodySchema = z.object({ + slug: z.string(), +}) + +export const claimmanalink = newEndpoint({}, async (req, auth) => { + const { slug } = validate(bodySchema, req.body) + + // 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 + + const { amount, fromId, claimedUserIds } = manalink + + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + throw new APIError(500, 'Invalid amount') + + 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 + + // Only permit one redemption per user per link + if (claimedUserIds.includes(auth.uid)) { + throw new APIError(400, `You already redeemed manalink ${slug}`) + } + + // 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}` + ) + } + + if (fromUser.balance < amount) { + throw new APIError( + 400, + `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} ` + ) + } + + // 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 cf3867b0..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,122 +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 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 { 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 ec81ba6e..c8cfc7c4 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -5,7 +5,6 @@ import { CPMMBinaryContract, Contract, FreeResponseContract, - MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, MAX_TAG_LENGTH, NumericContract, @@ -22,17 +21,41 @@ import { getCpmmInitialLiquidity, getFreeAnswerAnte, getNumericAnte, - HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' import { 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 { getPseudoProbability } from '../../common/pseudo-numeric' +import { JSONContent } from '@tiptap/core' + +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(), @@ -46,49 +69,50 @@ 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) => { +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 + + 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) + throw new APIError(400, 'Invalid initial value.') } if (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) } - // Uses utc time on server: - const today = new Date() - let freeMarketResetTime = new Date().setUTCHours(16, 0, 0, 0) - if (today.getTime() < freeMarketResetTime) { - freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000 - } - 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.') } const user = userDoc.data() as User - const userContractsCreatedTodaySnapshot = await firestore - .collection(`contracts`) - .where('creatorId', '==', auth.uid) - .where('createdTime', '>=', freeMarketResetTime) - .get() - console.log('free market reset time: ', freeMarketResetTime) - const isFree = userContractsCreatedTodaySnapshot.size === 0 - const ante = FIXED_ANTE // TODO: this is broken because it's not in a transaction - if (ante > user.balance && !isFree) + if (ante > user.balance) throw new APIError(400, `Balance must be at least ${ante}.`) const slug = await getSlug(question) @@ -130,23 +154,24 @@ 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 ) - if (!isFree && ante) await chargeUser(user.id, ante, true) + if (ante) await chargeUser(user.id, ante, true) await contractRef.create(contract) - const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id + const providerId = user.id - if (outcomeType === 'BINARY') { + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { const liquidityDoc = firestore .collection(`contracts/${contract.id}/liquidity`) .doc() 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..1fb6c3af 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -10,14 +10,16 @@ import { Contract } from '../../common/contract' import { getUserByUsername, 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 } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types } + [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } } export const createNotification = async ( @@ -66,11 +68,11 @@ 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, + isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -252,44 +254,91 @@ export const createNotification = async ( } } + const notifyUserReceivedReferralBonus = async ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string + ) => { + if (shouldGetNotification(relatedUserId, userToReasonTexts)) + userToReasonTexts[relatedUserId] = { + // If the referrer is the market creator, just tell them they joined to bet on their market + reason: + sourceContract?.creatorId === relatedUserId + ? 'user_joined_to_bet_on_your_market' + : 'you_referred_user', + } + } + + const notifyContractCreatorOfUniqueBettorsBonus = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + userToReasonTexts[userId] = { + reason: 'unique_bettors_on_your_contract', + } + } + + const notifyOtherGroupMembersOfComment = async ( + userToReasons: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasons)) + userToReasons[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + 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) - } - } else if (sourceType === 'follow' && relatedUserId) { + if (sourceType === 'follow' && relatedUserId) { await notifyFollowedUser(userToReasonTexts, relatedUserId) } else if (sourceType === 'group' && relatedUserId) { if (sourceUpdateType === 'created') await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) + } else if (sourceType === 'user' && relatedUserId) { + await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { + await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) + } + + // 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 (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) + } 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 +346,74 @@ 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)) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 189976ed..332c1872 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,13 +1,12 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' - +import { z } from 'zod' import { 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 +14,91 @@ 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 { uniq } from 'lodash' +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 ipCount = req.ip ? await numberUsersWithIp(req.ip) : 0 - const balance = - deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE + const balance = + deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE - 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, - } + 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, + } - await firestore.collection('users').doc(userId).create(user) - console.log('created user', username, 'firebase id:', userId) + await firestore.collection('users').doc(auth.uid).create(user) + console.log('created user', username, 'firebase id:', auth.uid) - const privateUser: PrivateUser = { - id: userId, - username, - email, - initialIpAddress: ipAddress, - initialDeviceToken: deviceToken, - } + const privateUser: PrivateUser = { + id: auth.uid, + username, + email, + initialIpAddress: req.ip, + initialDeviceToken: deviceToken, + } - await firestore.collection('private-users').doc(userId).create(privateUser) + await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await sendWelcomeEmail(user, privateUser) + await sendWelcomeEmail(user, privateUser) + await addUserToDefaultGroups(user) + await track(auth.uid, 'create user', { username }, { ip: req.ip }) - await track(userId, 'create user', { username }, { ip: ipAddress }) - - return { status: 'success', user } - }) + return user +}) const firestore = admin.firestore() @@ -115,3 +119,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.name} (@${user.username})!`, + createdTime: Date.now(), + userName: 'Manifold Markets', + userUsername: 'ManifoldMarkets', + userAvatarUrl: 'https://manifold.markets/logo-bg-white.png', + }) + } + } +} 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, ?`" ) - 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({ + return require(keyPath) +} + +export const initAdmin = (env?: string) => { + const serviceAccount = getServiceAccountCredentials(env) + console.log(`Initializing connection to ${serviceAccount.project_id}...`) + return admin.initializeApp({ + projectId: serviceAccount.project_id, credential: admin.credential.cert(serviceAccount), }) } 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/scripts/update-stats.ts b/functions/src/scripts/update-stats.ts new file mode 100644 index 00000000..105230ba --- /dev/null +++ b/functions/src/scripts/update-stats.ts @@ -0,0 +1,15 @@ +import { initAdmin } from './script-init' +initAdmin() + +import { log, logMemory } from '../utils' +import { updateStatsCore } from '../update-stats' + +async function updateStats() { + logMemory() + log('Updating stats...') + await updateStatsCore() +} + +if (require.main === module) { + updateStats().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..3407760b 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -9,6 +9,9 @@ import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { getValues } from './utils' import { Bet } from '../../common/bet' +import { floatingLesserEqual } from '../../common/util/math' +import { getUnfilledBetsQuery, updateMakers } from './place-bet' +import { FieldValue } from 'firebase-admin/firestore' const bodySchema = z.object({ contractId: z.string(), @@ -16,7 +19,7 @@ const bodySchema = z.object({ outcome: z.enum(['YES', 'NO']), }) -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. @@ -24,9 +27,8 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { 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.') @@ -47,14 +49,22 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const maxShares = sumBy(outcomeBets, (bet) => bet.shares) - if (shares > maxShares + 0.000000000001) + if (!floatingLesserEqual(shares, maxShares)) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) - const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( - shares, + const soldShares = Math.min(shares, maxShares) + + const unfilledBetsSnap = await transaction.get( + getUnfilledBetsQuery(contractDoc) + ) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( + soldShares, outcome, contract, - prevLoanAmount + prevLoanAmount, + unfilledBets ) if ( @@ -66,11 +76,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({ diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index 67309aa8..450bbe35 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -1,4 +1,4 @@ -import * as functions from 'firebase-functions' +import { onRequest } from 'firebase-functions/v2/https' import * as admin from 'firebase-admin' import Stripe from 'stripe' @@ -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 = onRequest( + { minInstances: 1, secrets: ['STRIPE_APIKEY'] }, + async (req, res) => { const userId = req.query.userId?.toString() const manticDollarQuantity = req.query.manticDollarQuantity?.toString() @@ -86,14 +86,15 @@ export const createCheckoutSession = functions }) res.redirect(303, session.url || '') - }) + } +) -export const stripeWebhook = functions - .runWith({ +export const stripewebhook = onRequest( + { minInstances: 1, secrets: ['MAILGUN_KEY', 'STRIPE_APIKEY', 'STRIPE_WEBHOOKSECRET'], - }) - .https.onRequest(async (req, res) => { + }, + async (req, res) => { const stripe = initStripe() let event @@ -115,7 +116,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 397632ea..113afc0b 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -1,108 +1,84 @@ -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 const transact = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (data: Omit, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +export type TxnData = Omit - const { - amount, - fromType, - fromId, - toId, - toType, - category, - token, - data: innerData, - description, - } = data +// TODO: We totally fail to validate most of the input to this function, +// so anyone can spam our database with malformed transactions. - if (fromType !== 'USER') - return { - status: 'error', - message: "From type is only implemented for type 'user'.", - } +export const transact = newEndpoint({}, async (req, auth) => { + const data = req.body + const { amount, fromType, fromId } = data - if (fromId !== userId) - return { - status: 'error', - message: 'Must be authenticated with userId equal to specified fromId.', - } + if (fromType !== 'USER') + throw new APIError(400, "From type is only implemented for type 'user'.") - if (isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (fromId !== auth.uid) + throw new APIError( + 403, + 'Must be authenticated with userId equal to specified fromId.' + ) - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - const fromDoc = firestore.doc(`users/${userId}`) - const fromSnap = await transaction.get(fromDoc) - if (!fromSnap.exists) { - return { status: 'error', message: 'User not found' } - } - const fromUser = fromSnap.data() as User + if (isNaN(amount) || !isFinite(amount)) + throw new APIError(400, 'Invalid amount') - if (amount > 0 && fromUser.balance < amount) { - return { - status: 'error', - message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, - } - } + // 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 + }) +}) - if (toType === 'USER') { - const toDoc = firestore.doc(`users/${toId}`) - const toSnap = await transaction.get(toDoc) - if (!toSnap.exists) { - return { status: 'error', message: 'User not found' } - } - const toUser = toSnap.data() as User - if (amount < 0 && toUser.balance < -amount) { - return { - status: 'error', - message: `Insufficient balance: ${ - toUser.username - } needed ${-amount} but only had ${toUser.balance} `, - } - } +export async function runTxn( + fbTransaction: admin.firestore.Transaction, + data: TxnData +) { + const { amount, fromId, toId, toType } = data - transaction.update(toDoc, { - balance: toUser.balance + amount, - totalDeposits: toUser.totalDeposits + amount, - }) - } + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await fbTransaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as User - const newTxnDoc = firestore.collection(`txns/`).doc() + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } - const txn = removeUndefinedProps({ - id: newTxnDoc.id, - createdTime: Date.now(), - - fromId, - fromType, - toId, - toType, - - amount, - category, - data: innerData, - token, - - description, - }) - - transaction.create(newTxnDoc, txn) - transaction.update(fromDoc, { - balance: fromUser.balance - amount, - totalDeposits: fromUser.totalDeposits - amount, - }) - - return { status: 'success', txn } + // TODO: Track payments received by charities, bank, contracts too. + if (toType === 'USER') { + const toDoc = firestore.doc(`users/${toId}`) + const toSnap = await fbTransaction.get(toDoc) + if (!toSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const toUser = toSnap.data() as User + fbTransaction.update(toDoc, { + balance: toUser.balance + amount, + totalDeposits: toUser.totalDeposits + amount, }) + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data } + fbTransaction.create(newTxnDoc, removeUndefinedProps(txn)) + fbTransaction.update(fromDoc, { + balance: fromUser.balance - amount, + totalDeposits: fromUser.totalDeposits - amount, }) + return { status: 'success', txn } +} + const firestore = admin.firestore() diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index a41a7155..48dd29c0 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -1,71 +1,66 @@ -import * as functions from 'firebase-functions' +import { onRequest } from 'firebase-functions/v2/https' import * as admin from 'firebase-admin' import { getUser } from './utils' import { PrivateUser } from '../../common/user' -export const unsubscribe = functions - .runWith({ minInstances: 1 }) - .https.onRequest(async (req, res) => { - const id = req.query.id as string - let type = req.query.type as string - if (!id || !type) { - res.status(400).send('Empty id or type parameter.') - return - } +export const unsubscribe = onRequest({ minInstances: 1 }, async (req, res) => { + const id = req.query.id as string + let type = req.query.type as string + if (!id || !type) { + res.status(400).send('Empty id or type parameter.') + return + } - if (type === 'market-resolved') type = 'market-resolve' + if (type === 'market-resolved') type = 'market-resolve' - if ( - ![ - 'market-resolve', - 'market-comment', - 'market-answer', - 'generic', - ].includes(type) - ) { - res.status(400).send('Invalid type parameter.') - return - } + if ( + !['market-resolve', 'market-comment', 'market-answer', 'generic'].includes( + type + ) + ) { + res.status(400).send('Invalid type parameter.') + return + } - const user = await getUser(id) + const user = await getUser(id) - if (!user) { - res.send('This user is not currently subscribed or does not exist.') - return - } + if (!user) { + res.send('This user is not currently subscribed or does not exist.') + return + } - const { name } = user + const { name } = user - const update: Partial = { - ...(type === 'market-resolve' && { - unsubscribedFromResolutionEmails: true, - }), - ...(type === 'market-comment' && { - unsubscribedFromCommentEmails: true, - }), - ...(type === 'market-answer' && { - unsubscribedFromAnswerEmails: true, - }), - ...(type === 'generic' && { - unsubscribedFromGenericEmails: true, - }), - } + const update: Partial = { + ...(type === 'market-resolve' && { + unsubscribedFromResolutionEmails: true, + }), + ...(type === 'market-comment' && { + unsubscribedFromCommentEmails: true, + }), + ...(type === 'market-answer' && { + unsubscribedFromAnswerEmails: true, + }), + ...(type === 'generic' && { + unsubscribedFromGenericEmails: true, + }), + } - await firestore.collection('private-users').doc(id).update(update) + await firestore.collection('private-users').doc(id).update(update) - if (type === 'market-resolve') - res.send( - `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` - ) - else if (type === 'market-comment') - res.send( - `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` - ) - else if (type === 'market-answer') - res.send( - `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` - ) - else res.send(`${name}, you have been unsubscribed.`) - }) + if (type === 'market-resolve') + res.send( + `${name}, you have been unsubscribed from market resolution emails on Manifold Markets.` + ) + else if (type === 'market-comment') + res.send( + `${name}, you have been unsubscribed from market comment emails on Manifold Markets.` + ) + else if (type === 'market-answer') + res.send( + `${name}, you have been unsubscribed from market answer emails on Manifold Markets.` + ) + else res.send(`${name}, you have been unsubscribed.`) +}) const firestore = admin.firestore() diff --git a/functions/src/update-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-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/update-stats.ts b/functions/src/update-stats.ts new file mode 100644 index 00000000..f99458ef --- /dev/null +++ b/functions/src/update-stats.ts @@ -0,0 +1,316 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash' +import { getValues, log, logMemory } from './utils' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' +import { Comment } from '../../common/comment' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { average } from '../../common/util/math' + +const firestore = admin.firestore() + +const numberOfDays = 90 + +const getBetsQuery = (startTime: number, endTime: number) => + firestore + .collectionGroup('bets') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyBets(startTime: number, numberOfDays: number) { + const query = getBetsQuery(startTime, startTime + DAY_MS * numberOfDays) + const bets = await getValues(query) + + const betsByDay = range(0, numberOfDays).map(() => [] as Bet[]) + for (const bet of bets) { + const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_MS) + betsByDay[dayIndex].push(bet) + } + + return betsByDay +} + +const getCommentsQuery = (startTime: number, endTime: number) => + firestore + .collectionGroup('comments') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyComments( + startTime: number, + numberOfDays: number +) { + const query = getCommentsQuery(startTime, startTime + DAY_MS * numberOfDays) + const comments = await getValues(query) + + const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[]) + for (const comment of comments) { + const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_MS) + commentsByDay[dayIndex].push(comment) + } + + return commentsByDay +} + +const getContractsQuery = (startTime: number, endTime: number) => + firestore + .collection('contracts') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyContracts( + startTime: number, + numberOfDays: number +) { + const query = getContractsQuery(startTime, startTime + DAY_MS * numberOfDays) + const contracts = await getValues(query) + + const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[]) + for (const contract of contracts) { + const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_MS) + contractsByDay[dayIndex].push(contract) + } + + return contractsByDay +} + +const getUsersQuery = (startTime: number, endTime: number) => + firestore + .collection('users') + .where('createdTime', '>=', startTime) + .where('createdTime', '<', endTime) + .orderBy('createdTime', 'asc') + +export async function getDailyNewUsers( + startTime: number, + numberOfDays: number +) { + const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays) + const users = await getValues(query) + + const usersByDay = range(0, numberOfDays).map(() => [] as User[]) + for (const user of users) { + const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS) + usersByDay[dayIndex].push(user) + } + + return usersByDay +} + +export const updateStatsCore = async () => { + const today = Date.now() + const startDate = today - numberOfDays * DAY_MS + + log('Fetching data for stats update...') + const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] = + await Promise.all([ + getDailyBets(startDate.valueOf(), numberOfDays), + getDailyContracts(startDate.valueOf(), numberOfDays), + getDailyComments(startDate.valueOf(), numberOfDays), + getDailyNewUsers(startDate.valueOf(), numberOfDays), + ]) + logMemory() + + const dailyBetCounts = dailyBets.map((bets) => bets.length) + const dailyContractCounts = dailyContracts.map( + (contracts) => contracts.length + ) + const dailyCommentCounts = dailyComments.map((comments) => comments.length) + + const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map( + ([contracts, bets, comments]) => { + const creatorIds = (contracts ?? []).map((c) => c.creatorId) + const betUserIds = (bets ?? []).map((bet) => bet.userId) + const commentUserIds = (comments ?? []).map((comment) => comment.userId) + return uniq([...creatorIds, ...betUserIds, ...commentUserIds]) + } + ) + log( + `Fetched ${sum(dailyBetCounts)} bets, ${sum( + dailyContractCounts + )} contracts, ${sum(dailyComments)} comments, from ${sum( + dailyNewUsers + )} unique users.` + ) + + const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length) + + const weeklyActiveUsers = dailyUserIds.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + const uniques = new Set() + for (let j = start; j <= end; j++) + dailyUserIds[j].forEach((userId) => uniques.add(userId)) + return uniques.size + }) + + const monthlyActiveUsers = dailyUserIds.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + const uniques = new Set() + for (let j = start; j <= end; j++) + dailyUserIds[j].forEach((userId) => uniques.add(userId)) + return uniques.size + }) + + const weekOnWeekRetention = dailyUserIds.map((_userId, i) => { + const twoWeeksAgo = { + start: Math.max(0, i - 13), + end: Math.max(0, i - 7), + } + const lastWeek = { + start: Math.max(0, i - 6), + end: i, + } + + const activeTwoWeeksAgo = new Set() + for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) { + dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId)) + } + const activeLastWeek = new Set() + for (let j = lastWeek.start; j <= lastWeek.end; j++) { + dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId)) + } + const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) => + activeLastWeek.has(userId) ? 1 : 0 + ) + const retainedFrac = retainedCount / activeTwoWeeksAgo.size + return Math.round(retainedFrac * 100 * 100) / 100 + }) + + const monthlyRetention = dailyUserIds.map((_userId, i) => { + const twoMonthsAgo = { + start: Math.max(0, i - 60), + end: Math.max(0, i - 30), + } + const lastMonth = { + start: Math.max(0, i - 30), + end: i, + } + + const activeTwoMonthsAgo = new Set() + for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) { + dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId)) + } + const activeLastMonth = new Set() + for (let j = lastMonth.start; j <= lastMonth.end; j++) { + dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId)) + } + const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) => + activeLastMonth.has(userId) ? 1 : 0 + ) + const retainedFrac = retainedCount / activeTwoMonthsAgo.size + return Math.round(retainedFrac * 100 * 100) / 100 + }) + + const firstBetDict: { [userId: string]: number } = {} + for (let i = 0; i < dailyBets.length; i++) { + const bets = dailyBets[i] + for (const bet of bets) { + if (bet.userId in firstBetDict) continue + firstBetDict[bet.userId] = i + } + } + const weeklyActivationRate = dailyNewUsers.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + let activatedCount = 0 + let newUsers = 0 + for (let j = start; j <= end; j++) { + const userIds = dailyNewUsers[j].map((user) => user.id) + newUsers += userIds.length + for (const userId of userIds) { + const dayIndex = firstBetDict[userId] + if (dayIndex !== undefined && dayIndex <= end) { + activatedCount++ + } + } + } + const frac = activatedCount / (newUsers || 1) + return Math.round(frac * 100 * 100) / 100 + }) + const dailySignups = dailyNewUsers.map((users) => users.length) + + const dailyTopTenthActions = zip( + dailyContracts, + dailyBets, + dailyComments + ).map(([contracts, bets, comments]) => { + const userIds = concat( + contracts?.map((c) => c.creatorId) ?? [], + bets?.map((b) => b.userId) ?? [], + comments?.map((c) => c.userId) ?? [] + ) + const counts = Object.values(countBy(userIds)) + const sortedCounts = sortBy(counts, (count) => count).reverse() + if (sortedCounts.length === 0) return 0 + const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)] + return tenthPercentile + }) + const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + return average(dailyTopTenthActions.slice(start, end)) + }) + const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + return average(dailyTopTenthActions.slice(start, end)) + }) + + // Total mana divided by 100. + const dailyManaBet = dailyBets.map((bets) => { + return Math.round(sumBy(bets, (bet) => bet.amount) / 100) + }) + const weeklyManaBet = dailyManaBet.map((_, i) => { + const start = Math.max(0, i - 6) + const end = i + const total = sum(dailyManaBet.slice(start, end)) + if (end - start < 7) return (total * 7) / (end - start) + return total + }) + const monthlyManaBet = dailyManaBet.map((_, i) => { + const start = Math.max(0, i - 29) + const end = i + const total = sum(dailyManaBet.slice(start, end)) + const range = end - start + 1 + if (range < 30) return (total * 30) / range + return total + }) + + const statsData = { + startDate: startDate.valueOf(), + dailyActiveUsers, + weeklyActiveUsers, + monthlyActiveUsers, + dailyBetCounts, + dailyContractCounts, + dailyCommentCounts, + dailySignups, + weekOnWeekRetention, + weeklyActivationRate, + monthlyRetention, + topTenthActions: { + daily: dailyTopTenthActions, + weekly: weeklyTopTenthActions, + monthly: monthlyTopTenthActions, + }, + manaBet: { + daily: dailyManaBet, + weekly: weeklyManaBet, + monthly: monthlyManaBet, + }, + } + log('Computed stats: ', statsData) + await firestore.doc('stats/stats').set(statsData) +} + +export const updateStats = functions + .runWith({ memory: '1GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 60 minutes') + .onRun(updateStatsCore) 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/package.json b/package.json index a5c1e29e..e4aee3fd 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "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": { 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/.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/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 705433b1..8c1d0430 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -6,7 +6,7 @@ import { Answer } from 'common/answer' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { APIError, placeBet } from 'web/lib/firebase/api-call' +import { APIError, placeBet } from 'web/lib/firebase/api' import { Row } from '../layout/row' import { Spacer } from '../layout/spacer' import { @@ -25,6 +25,7 @@ import { import { Bet } from 'common/bet' import { track } from 'web/lib/service/analytics' import { SignUpPrompt } from '../sign-up-prompt' +import { isIOS } from 'web/lib/util/device' export function AnswerBetPanel(props: { answer: Answer @@ -44,6 +45,7 @@ export function AnswerBetPanel(props: { const inputRef = useRef(null) useEffect(() => { + if (isIOS()) window.scrollTo(0, window.scrollY + 200) inputRef.current && inputRef.current.focus() }, []) diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 81b94550..5b59f050 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx' -import { sum, mapValues } from 'lodash' +import { sum } from 'lodash' import { useState } from 'react' import { Contract, FreeResponse } 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' @@ -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/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 2a089f50..ce266778 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,11 +1,12 @@ import clsx from 'clsx' import { useState } from 'react' import Textarea from 'react-expanding-textarea' +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, @@ -23,6 +24,7 @@ import { firebaseLogin } from 'web/lib/firebase/users' import { Bet } from 'common/bet' import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' +import { lowerCase } from 'lodash' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -30,27 +32,67 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const [text, setText] = useState('') const [betAmount, setBetAmount] = useState(10) const [amountError, setAmountError] = useState() + const [answerError, setAnswerError] = useState() + const [possibleDuplicateAnswer, setPossibleDuplicateAnswer] = useState< + string | undefined + >() const [isSubmitting, setIsSubmitting] = useState(false) + const { answers } = contract - const canSubmit = text && betAmount && !amountError && !isSubmitting + const canSubmit = + text && betAmount && !amountError && !isSubmitting && !answerError const submitAnswer = async () => { 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) + } + } + + const changeAnswer = (text: string) => { + setText(text) + const existingAnswer = answers.find( + (a) => lowerCase(a.text) === lowerCase(text) + ) + + if (existingAnswer) { + setAnswerError( + existingAnswer + ? `"${existingAnswer.text}" already exists as an answer` + : '' + ) + return + } else { + setAnswerError('') + } + + if (answers.length && text) { + const matches = findBestMatch( + lowerCase(text), + answers.map((a) => lowerCase(a.text)) + ) + setPossibleDuplicateAnswer( + matches.bestMatch.rating > 0.8 + ? answers[matches.bestMatchIndex].text + : '' + ) } } @@ -79,12 +121,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
Add your answer
- - - - - -
- - -