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/antes.ts b/common/antes.ts index becc9b7e..d4cb2ff9 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -10,12 +10,9 @@ 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 diff --git a/common/calculate.ts b/common/calculate.ts index a0574c10..482a0ccf 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -18,15 +18,24 @@ import { getDpmProbabilityAfterSale, } from './calculate-dpm' import { calculateFixedPayout } from './calculate-fixed-payouts' -import { Contract, BinaryContract, FreeResponseContract } from './contract' +import { + Contract, + BinaryContract, + FreeResponseContract, + PseudoNumericContract, +} from './contract' -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) @@ -65,7 +74,9 @@ export function calculateShares( } export function calculateSaleAmount(contract: Contract, bet: Bet) { - return contract.mechanism === 'cpmm-1' && contract.outcomeType === 'BINARY' + return contract.mechanism === 'cpmm-1' && + (contract.outcomeType === 'BINARY' || + contract.outcomeType === 'PSEUDO_NUMERIC') ? calculateCpmmSale(contract, Math.abs(bet.shares), bet.outcome).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -87,7 +98,9 @@ export function getProbabilityAfterSale( } 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 +109,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) } @@ -142,9 +157,7 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 - const hasShares = Object.values(totalShares).some( - (shares) => shares > 0 - ) + const hasShares = Object.values(totalShares).some((shares) => shares > 0) return { invested: Math.max(0, currentInvested), diff --git a/common/contract.ts b/common/contract.ts index 79ecda31..52ca91d6 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -3,9 +3,10 @@ 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) @@ -45,7 +46,8 @@ export type Contract = { collectedFees: Fees } & T -export type BinaryContract = Contract & Binary +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 @@ -76,6 +78,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'. @@ -95,7 +109,7 @@ 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/prod.ts b/common/envs/prod.ts index f5a0e55e..f8aaf4cc 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -18,13 +18,17 @@ export type EnvConfig = { faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] + + // Currency controls + fixedAnte?: number + startingBalance?: number } type FirebaseConfig = { apiKey: string authDomain: string projectId: string - region: string + region?: string storageBucket: string messagingSenderId: string appId: string diff --git a/common/new-bet.ts b/common/new-bet.ts index ba799624..57739af3 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -14,14 +14,15 @@ import { DPMBinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from './contract' import { noFees } from './fees' import { addObjects } from './util/object' import { NUMERIC_FIXED_VAR } from './numeric-constants' -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 } @@ -32,7 +33,7 @@ export type BetInfo = { export const getNewBinaryCpmmBetInfo = ( outcome: 'YES' | 'NO', amount: number, - contract: CPMMBinaryContract, + contract: CPMMBinaryContract | PseudoNumericContract, loanAmount: number ) => { const { shares, newPool, newP, fees } = calculateCpmmPurchase( @@ -45,7 +46,7 @@ export const getNewBinaryCpmmBetInfo = ( const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, newP) - const newBet: CandidateBet = { + const newBet: CandidateBet = { contractId: contract.id, amount, shares, @@ -95,7 +96,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 +133,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 e408a743..abfafaf8 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -7,6 +7,7 @@ import { FreeResponse, Numeric, outcomeType, + PseudoNumeric, } from './contract' import { User } from './user' import { parseTags, richTextToString } from './util/parse' @@ -28,7 +29,8 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + isLogScale: boolean ) { const tags = parseTags( [ @@ -42,6 +44,8 @@ export function getNewContract( 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) @@ -116,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..16444c48 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,7 @@ 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' 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/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..9a322e35 --- /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/txn.ts b/common/txn.ts index 25d4a1c3..53b08501 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,7 +16,8 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -46,6 +47,19 @@ type Manalink = { category: 'MANALINK' } +type Referral = { + fromType: 'BANK' + toType: 'USER' + category: 'REFERRAL' +} + +type Bonus = { + fromType: 'BANK' + toType: 'USER' + category: 'UNIQUE_BETTOR_BONUS' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink +export type ReferralTxn = Txn & Referral diff --git a/common/user.ts b/common/user.ts index 298fee56..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/docs/docs/api.md b/docs/docs/api.md index ffdaa65f..a8ac18fe 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -456,7 +456,6 @@ Requires no authorization. } ``` - ### `POST /v0/bet` Places a new bet on behalf of the authorized user. @@ -514,6 +513,60 @@ $ 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} \ + ]}' +``` + ## 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/firestore.indexes.json b/firestore.indexes.json index 064f6f2f..e0cee632 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -337,6 +337,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/firestore.rules b/firestore.rules index 176cc71e..28ff4485 100644 --- a/firestore.rules +++ b/firestore.rules @@ -20,7 +20,16 @@ service cloud.firestore { 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} { 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 2aeae30c..58f30dcb 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1,5 +1,4 @@ # Secrets -.env* .runtimeconfig.json # GCP deployment artifact diff --git a/functions/README.md b/functions/README.md index 031cc4fa..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 diff --git a/functions/package.json b/functions/package.json index 57d0b37e..ee2184bb 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,14 +5,14 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "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", + "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)", @@ -23,9 +23,9 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", + "@google-cloud/functions-framework": "3.1.2", "@tiptap/core": "^2.0.0-beta.181", "@tiptap/starter-kit": "^2.0.0-beta.190", - "fetch": "1.1.0", "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..eca0a056 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -39,7 +39,8 @@ export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( const contract = contractSnap.data() as Contract if ( contract.mechanism !== 'cpmm-1' || - contract.outcomeType !== 'BINARY' + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') ) return { status: 'error', message: 'Invalid contract' } diff --git a/functions/src/api.ts b/functions/src/api.ts index f7efab5a..290ea3d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -108,7 +108,12 @@ export const validate = (schema: T, val: unknown) => { } } -const DEFAULT_OPTS: HttpsOptions = { +interface EndpointOptions extends HttpsOptions { + methods?: string[] +} + +const DEFAULT_OPTS = { + methods: ['POST'], minInstances: 1, concurrency: 100, memory: '2GiB', @@ -116,12 +121,13 @@ const DEFAULT_OPTS: HttpsOptions = { cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST], } -export const newEndpoint = (methods: [string], fn: Handler) => - onRequest(DEFAULT_OPTS, async (req, res) => { +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)) @@ -140,3 +146,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/create-contract.ts b/functions/src/create-contract.ts index b2250d7b..8132e762 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -27,6 +27,7 @@ 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(() => @@ -68,19 +69,31 @@ 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 (outcomeType === 'BINARY') { ;({ initialProb } = validate(binarySchema, req.body)) @@ -144,7 +157,8 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + isLogScale ?? false ) if (ante) await chargeUser(user.id, ante, true) @@ -153,7 +167,7 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { 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..45db1c4e 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' 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 ( @@ -68,9 +68,11 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, + // TODO: move away from sourceContractSlug to sourceSlug 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,90 @@ 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 ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasonTexts)) + userToReasonTexts[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 } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 1ba8ca96..40e8900c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -6,8 +6,13 @@ import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' import { DPM_CREATOR_FEE } from '../../common/fees' import { PrivateUser, User } from '../../common/user' -import { formatMoney, formatPercent } from '../../common/util/format' +import { + formatLargeNumber, + formatMoney, + formatPercent, +} from '../../common/util/format' import { getValueFromBucket } from '../../common/calculate-dpm' +import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail } from './send-email' import { getPrivateUser, getUser } from './utils' @@ -101,6 +106,17 @@ const toDisplayResolution = ( return display || resolution } + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + const { resolutionValue } = contract + + return resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? getProbability(contract), + contract + ) + } + if (resolution === 'MKT' && resolutions) return 'MULTI' if (resolution === 'CANCEL') return 'N/A' diff --git a/functions/src/fetch.ts b/functions/src/fetch.ts deleted file mode 100644 index 1b54dc6c..00000000 --- a/functions/src/fetch.ts +++ /dev/null @@ -1,9 +0,0 @@ -let fetchRequest: typeof fetch - -try { - fetchRequest = fetch -} catch { - fetchRequest = require('node-fetch') -} - -export default fetchRequest diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts new file mode 100644 index 00000000..c5c1a1b3 --- /dev/null +++ b/functions/src/get-daily-bonuses.ts @@ -0,0 +1,139 @@ +import { APIError, newEndpoint } from './api' +import { log } from './utils' +import * as admin from 'firebase-admin' +import { PrivateUser } from '../../common/lib/user' +import { uniq } from 'lodash' +import { Bet } from '../../common/lib/bet' +const firestore = admin.firestore() +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { runTxn, TxnData } from './transact' +import { createNotification } from './create-notification' +import { User } from '../../common/lib/user' +import { Contract } from '../../common/lib/contract' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' + +const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime() +const QUERY_LIMIT_SECONDS = 60 + +export const getdailybonuses = newEndpoint({}, async (req, auth) => { + const { user, lastTimeCheckedBonuses } = await firestore.runTransaction( + async (trans) => { + const userSnap = await trans.get( + firestore.doc(`private-users/${auth.uid}`) + ) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as PrivateUser + const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0 + if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000) + throw new APIError( + 400, + `Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.` + ) + await trans.update(userSnap.ref, { + lastTimeCheckedBonuses: Date.now(), + }) + return { + user, + lastTimeCheckedBonuses, + } + } + ) + // TODO: switch to prod id + // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account + const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + // Get all users contracts made since implementation time + const userContractsSnap = await firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('createdTime', '>=', BONUS_START_DATE) + .get() + const userContracts = userContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + const nullReturn = { status: 'no bets', txn: null } + for (const contract of userContracts) { + const result = await firestore.runTransaction(async (trans) => { + const contractId = contract.id + // Get all bets made on user's contracts + const bets = ( + await firestore + .collection(`contracts/${contractId}/bets`) + .where('userId', '!=', user.id) + .get() + ).docs.map((bet) => bet.ref) + if (bets.length === 0) { + return nullReturn + } + const contractBetsSnap = await trans.getAll(...bets) + const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet) + + const uniqueBettorIdsBeforeLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime < lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter users for ONLY those that have made bets since the last daily bonus received time + const uniqueBettorIdsWithBetsAfterLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime > lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter for users only present in the above list + const newUniqueBettorIds = + uniqueBettorIdsWithBetsAfterLastResetTime.filter( + (userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId) + ) + newUniqueBettorIds.length > 0 && + log( + `Got ${newUniqueBettorIds.length} new unique bettors since last bonus` + ) + if (newUniqueBettorIds.length === 0) { + return nullReturn + } + // Create combined txn for all unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettors: newUniqueBettorIds.length, + } + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + result.status != nullReturn.status && + log(`No bonus for user: ${user.id} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + result.txn.id, + result.txn.amount + '', + contract, + undefined, + // No need to set the user id, we'll use the contract creator id + undefined, + contract.slug, + contract.question + ) + } + } + + return { userId: user.id, message: 'success' } +}) diff --git a/functions/src/health.ts b/functions/src/health.ts index 6f4d73dc..938261db 100644 --- a/functions/src/health.ts +++ b/functions/src/health.ts @@ -1,6 +1,6 @@ import { newEndpoint } from './api' -export const health = newEndpoint(['GET'], async (_req, auth) => { +export const health = newEndpoint({ methods: ['GET'] }, async (_req, auth) => { return { message: 'Server is working.', uid: auth.uid, diff --git a/functions/src/index.ts b/functions/src/index.ts index dcd50e66..d9b7a255 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,12 +6,11 @@ admin.initializeApp() // export * from './keep-awake' export * from './claim-manalink' export * from './transact' -export * from './resolve-market' export * from './stripe' export * from './create-user' export * from './create-answer' export * from './on-create-bet' -export * from './on-create-comment' +export * from './on-create-comment-on-contract' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' @@ -28,6 +27,8 @@ export * from './on-unfollow-user' export * from './on-create-liquidity-provision' export * from './on-update-group' export * from './on-create-group' +export * from './on-update-user' +export * from './on-create-comment-on-group' // v2 export * from './health' @@ -37,3 +38,5 @@ export * from './sell-shares' export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' +export * from './resolve-market' +export * from './get-daily-bonuses' diff --git a/functions/src/keep-awake.ts b/functions/src/keep-awake.ts deleted file mode 100644 index 00799e65..00000000 --- a/functions/src/keep-awake.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as functions from 'firebase-functions' - -import { callCloudFunction } from './call-cloud-function' - -export const keepAwake = functions.pubsub - .schedule('every 1 minutes') - .onRun(async () => { - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - - await sleep(30) - - await Promise.all([ - callCloudFunction('placeBet'), - callCloudFunction('resolveMarket'), - callCloudFunction('sellBet'), - ]) - }) - -const sleep = (seconds: number) => { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)) -} diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment-on-contract.ts similarity index 98% rename from functions/src/on-create-comment.ts rename to functions/src/on-create-comment-on-contract.ts index 8d52fd46..f7839b44 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -11,7 +11,7 @@ import { createNotification } from './create-notification' const firestore = admin.firestore() -export const onCreateComment = functions +export const onCreateCommentOnContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .firestore.document('contracts/{contractId}/comments/{commentId}') .onCreate(async (change, context) => { diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts new file mode 100644 index 00000000..7217e602 --- /dev/null +++ b/functions/src/on-create-comment-on-group.ts @@ -0,0 +1,52 @@ +import * as functions from 'firebase-functions' +import { Comment } from '../../common/comment' +import * as admin from 'firebase-admin' +import { Group } from '../../common/group' +import { User } from '../../common/user' +import { createNotification } from './create-notification' +const firestore = admin.firestore() + +export const onCreateCommentOnGroup = functions.firestore + .document('groups/{groupId}/comments/{commentId}') + .onCreate(async (change, context) => { + const { eventId } = context + const { groupId } = context.params as { + groupId: string + } + + const comment = change.data() as Comment + const creatorSnapshot = await firestore + .collection('users') + .doc(comment.userId) + .get() + if (!creatorSnapshot.exists) throw new Error('Could not find user') + + const groupSnapshot = await firestore + .collection('groups') + .doc(groupId) + .get() + if (!groupSnapshot.exists) throw new Error('Could not find group') + + const group = groupSnapshot.data() as Group + await firestore.collection('groups').doc(groupId).update({ + mostRecentActivityTime: comment.createdTime, + }) + + await Promise.all( + group.memberIds.map(async (memberId) => { + return await createNotification( + comment.id, + 'comment', + 'created', + creatorSnapshot.data() as User, + eventId, + comment.text, + undefined, + undefined, + memberId, + `/group/${group.slug}`, + `${group.name}` + ) + }) + ) + }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index bc6f6ab4..feaa6443 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return + // TODO: create notification with isSeeOnHref set to the group's /group/questions url await firestore .collection('groups') diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts new file mode 100644 index 00000000..b6ba6e0b --- /dev/null +++ b/functions/src/on-update-user.ts @@ -0,0 +1,111 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { REFERRAL_AMOUNT, User } from '../../common/user' +import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' +import { createNotification } from './create-notification' +import { ReferralTxn } from '../../common/txn' +import { Contract } from '../../common/contract' +const firestore = admin.firestore() + +export const onUpdateUser = functions.firestore + .document('users/{userId}') + .onUpdate(async (change, context) => { + const prevUser = change.before.data() as User + const user = change.after.data() as User + const { eventId } = context + + if (prevUser.referredByUserId !== user.referredByUserId) { + await handleUserUpdatedReferral(user, eventId) + } + }) + +async function handleUserUpdatedReferral(user: User, eventId: string) { + // Only create a referral txn if the user has a referredByUserId + if (!user.referredByUserId) { + console.log(`Not set: referredByUserId ${user.referredByUserId}`) + return + } + const referredByUserId = user.referredByUserId + + await firestore.runTransaction(async (transaction) => { + // get user that referred this user + const referredByUserDoc = firestore.doc(`users/${referredByUserId}`) + const referredByUserSnap = await transaction.get(referredByUserDoc) + if (!referredByUserSnap.exists) { + console.log(`User ${referredByUserId} not found`) + return + } + const referredByUser = referredByUserSnap.data() as User + + let referredByContract: Contract | undefined = undefined + if (user.referredByContractId) { + const referredByContractDoc = firestore.doc( + `contracts/${user.referredByContractId}` + ) + referredByContract = await transaction + .get(referredByContractDoc) + .then((snap) => snap.data() as Contract) + } + console.log(`referredByContract: ${referredByContract}`) + + const txns = ( + await firestore + .collection('txns') + .where('toId', '==', referredByUserId) + .where('category', '==', 'REFERRAL') + .get() + ).docs.map((txn) => txn.ref) + if (txns.length > 0) { + const referralTxns = await transaction.getAll(...txns).catch((err) => { + console.error('error getting txns:', err) + throw err + }) + // If the referring user already has a referral txn due to referring this user, halt + if ( + referralTxns.map((txn) => txn.data()?.description).includes(user.id) + ) { + console.log('found referral txn with the same details, aborting') + return + } + } + console.log('creating referral txns') + const fromId = HOUSE_LIQUIDITY_PROVIDER_ID + + // if they're updating their referredId, create a txn for both + const txn: ReferralTxn = { + id: eventId, + createdTime: Date.now(), + fromId, + fromType: 'BANK', + toId: referredByUserId, + toType: 'USER', + amount: REFERRAL_AMOUNT, + token: 'M$', + category: 'REFERRAL', + description: `Referred new user id: ${user.id} for ${REFERRAL_AMOUNT}`, + } + + const txnDoc = await firestore.collection(`txns/`).doc(txn.id) + await transaction.set(txnDoc, txn) + console.log('created referral with txn id:', txn.id) + // We're currently not subtracting M$ from the house, not sure if we want to for accounting purposes. + transaction.update(referredByUserDoc, { + balance: referredByUser.balance + REFERRAL_AMOUNT, + totalDeposits: referredByUser.totalDeposits + REFERRAL_AMOUNT, + }) + + await createNotification( + user.id, + 'user', + 'updated', + user, + eventId, + txn.amount.toString(), + referredByContract, + 'user', + referredByUser.id, + referredByContract?.slug, + referredByContract?.question + ) + }) +} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 1b5dd8bc..43906f3c 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -33,7 +33,7 @@ const numericSchema = z.object({ value: z.number(), }) -export const placebet = newEndpoint(['POST'], async (req, auth) => { +export const placebet = newEndpoint({}, async (req, auth) => { log('Inside endpoint handler.') const { amount, contractId } = validate(bodySchema, req.body) @@ -41,10 +41,7 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => { log('Inside main transaction.') const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) - const [contractSnap, userSnap] = await Promise.all([ - trans.get(contractDoc), - trans.get(userDoc), - ]) + const [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') log('Loaded user and contract snapshots.') @@ -70,7 +67,10 @@ export const placebet = newEndpoint(['POST'], async (req, auth) => { if (outcomeType == 'BINARY' && mechanism == 'dpm-2') { const { outcome } = validate(binarySchema, req.body) return getNewBinaryDpmBetInfo(outcome, amount, contract, loanAmount) - } else if (outcomeType == 'BINARY' && mechanism == 'cpmm-1') { + } else if ( + (outcomeType == 'BINARY' || outcomeType == 'PSEUDO_NUMERIC') && + mechanism == 'cpmm-1' + ) { const { outcome } = validate(binarySchema, req.body) return getNewBinaryCpmmBetInfo(outcome, amount, contract, loanAmount) } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') { diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index bdd3ab94..0a69521f 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -1,92 +1,46 @@ import * as admin from 'firebase-admin' -import { partition, sumBy } from 'lodash' import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' +import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' -import { noFees } from '../../common/fees' import { User } from '../../common/user' export const redeemShares = async (userId: string, contractId: string) => { - return await firestore.runTransaction(async (transaction) => { + return await firestore.runTransaction(async (trans) => { const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) + const contractSnap = await trans.get(contractDoc) if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - if (contract.outcomeType !== 'BINARY' || contract.mechanism !== 'cpmm-1') - return { status: 'success' } + const { mechanism } = contract + if (mechanism !== 'cpmm-1') return { status: 'success' } - const betsSnap = await transaction.get( - firestore - .collection(`contracts/${contract.id}/bets`) - .where('userId', '==', userId) - ) + const betsColl = firestore.collection(`contracts/${contract.id}/bets`) + const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') - const yesShares = sumBy(yesBets, (b) => b.shares) - const noShares = sumBy(noBets, (b) => b.shares) - - const amount = Math.min(yesShares, noShares) - if (amount <= 0) return - - const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPaid = Math.min(prevLoanAmount, amount) - const netAmount = amount - loanPaid - - const p = getProbability(contract) - const createdTime = Date.now() - - const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const yesBet: Bet = { - id: yesDoc.id, - userId: userId, - contractId: contract.id, - amount: p * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'YES', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } - - const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const noBet: Bet = { - id: noDoc.id, - userId: userId, - contractId: contract.id, - amount: (1 - p) * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'NO', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, + const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + if (netAmount === 0) { + return { status: 'success' } } + const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) + const userSnap = await trans.get(userDoc) if (!userSnap.exists) return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User - const newBalance = user.balance + netAmount if (!isFinite(newBalance)) { throw new Error('Invalid user balance for ' + user.username) } - transaction.update(userDoc, { balance: newBalance }) - - transaction.create(yesDoc, yesBet) - transaction.create(noDoc, noBet) + const yesDoc = betsColl.doc() + const noDoc = betsColl.doc() + trans.update(userDoc, { balance: newBalance }) + trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet }) + trans.create(noDoc, { id: noDoc.id, userId, ...noBet }) return { status: 'success' } }) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 43cb4839..f8976cb3 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,8 +1,12 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' -import { Contract, resolution, RESOLUTIONS } from '../../common/contract' +import { + Contract, + FreeResponseContract, + RESOLUTIONS, +} from '../../common/contract' import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' @@ -15,156 +19,162 @@ import { } from '../../common/payouts' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' +import { APIError, newEndpoint, validate } from './api' -export const resolveMarket = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - outcome: resolution - value?: number - contractId: string - probabilityInt?: number - resolutions?: { [outcome: string]: number } - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), +}) - const { outcome, contractId, probabilityInt, resolutions, value } = data +const binarySchema = z.object({ + outcome: z.enum(RESOLUTIONS), + probabilityInt: z.number().gte(0).lte(100).optional(), +}) - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await contractDoc.get() - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - const { creatorId, outcomeType, closeTime } = contract +const freeResponseSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + resolutions: z.array( + z.object({ + answer: z.number().int().nonnegative(), + pct: z.number().gte(0).lte(100), + }) + ), + }), + z.object({ + outcome: z.number().int().nonnegative(), + }), +]) - if (outcomeType === 'BINARY') { - if (!RESOLUTIONS.includes(outcome)) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'FREE_RESPONSE') { - if ( - isNaN(+outcome) && - !(outcome === 'MKT' && resolutions) && - outcome !== 'CANCEL' - ) - return { status: 'error', message: 'Invalid outcome' } - } else if (outcomeType === 'NUMERIC') { - if (isNaN(+outcome) && outcome !== 'CANCEL') - return { status: 'error', message: 'Invalid outcome' } - } else { - return { status: 'error', message: 'Invalid contract outcomeType' } - } +const numericSchema = z.object({ + outcome: z.union([z.literal('CANCEL'), z.string()]), + value: z.number().optional(), +}) - if (value !== undefined && !isFinite(value)) - return { status: 'error', message: 'Invalid value' } +const pseudoNumericSchema = z.union([ + z.object({ + outcome: z.literal('CANCEL'), + }), + z.object({ + outcome: z.literal('MKT'), + value: z.number(), + probabilityInt: z.number().gte(0).lte(100), + }), +]) - if ( - outcomeType === 'BINARY' && - probabilityInt !== undefined && - (probabilityInt < 0 || - probabilityInt > 100 || - !isFinite(probabilityInt)) - ) - return { status: 'error', message: 'Invalid probability' } +const opts = { secrets: ['MAILGUN_KEY'] } - if (creatorId !== userId) - return { status: 'error', message: 'User not creator of contract' } +export const resolvemarket = newEndpoint(opts, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) + const userId = auth.uid - if (contract.resolution) - return { status: 'error', message: 'Contract already resolved' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + throw new APIError(404, 'No contract exists with the provided ID') + const contract = contractSnap.data() as Contract + const { creatorId, closeTime } = contract - const creator = await getUser(creatorId) - if (!creator) return { status: 'error', message: 'Creator not found' } - - const resolutionProbability = - probabilityInt !== undefined ? probabilityInt / 100 : undefined - - const resolutionTime = Date.now() - const newCloseTime = closeTime - ? Math.min(closeTime, resolutionTime) - : closeTime - - const betsSnap = await firestore - .collection(`contracts/${contractId}/bets`) - .get() - - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - - const liquiditiesSnap = await firestore - .collection(`contracts/${contractId}/liquidity`) - .get() - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const { payouts, creatorPayout, liquidityPayouts, collectedFees } = - getPayouts( - outcome, - resolutions ?? {}, - contract, - bets, - liquidities, - resolutionProbability - ) - - await contractDoc.update( - removeUndefinedProps({ - isResolved: true, - resolution: outcome, - resolutionValue: value, - resolutionTime, - closeTime: newCloseTime, - resolutionProbability, - resolutions, - collectedFees, - }) - ) - - console.log('contract ', contractId, 'resolved to:', outcome) - - const openBets = bets.filter((b) => !b.isSold && !b.sale) - const loanPayouts = getLoanPayouts(openBets) - - if (!isProd()) - console.log( - 'payouts:', - payouts, - 'creator payout:', - creatorPayout, - 'liquidity payout:' - ) - - if (creatorPayout) - await processPayouts( - [{ userId: creatorId, payout: creatorPayout }], - true - ) - - await processPayouts(liquidityPayouts, true) - - const result = await processPayouts([...payouts, ...loanPayouts]) - - const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - - await sendResolutionEmails( - openBets, - userPayoutsWithoutLoans, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - - return result - } + const { value, resolutions, probabilityInt, outcome } = getResolutionParams( + contract, + req.body ) + if (creatorId !== userId) + throw new APIError(403, 'User is not creator of contract') + + if (contract.resolution) throw new APIError(400, 'Contract already resolved') + + const creator = await getUser(creatorId) + if (!creator) throw new APIError(500, 'Creator not found') + + const resolutionProbability = + probabilityInt !== undefined ? probabilityInt / 100 : undefined + + const resolutionTime = Date.now() + const newCloseTime = closeTime + ? Math.min(closeTime, resolutionTime) + : closeTime + + const betsSnap = await firestore + .collection(`contracts/${contractId}/bets`) + .get() + + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + + const liquiditiesSnap = await firestore + .collection(`contracts/${contractId}/liquidity`) + .get() + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const { payouts, creatorPayout, liquidityPayouts, collectedFees } = + getPayouts( + outcome, + contract, + bets, + liquidities, + resolutions, + resolutionProbability + ) + + const updatedContract = { + ...contract, + ...removeUndefinedProps({ + isResolved: true, + resolution: outcome, + resolutionValue: value, + resolutionTime, + closeTime: newCloseTime, + resolutionProbability, + resolutions, + collectedFees, + }), + } + + await contractDoc.update(updatedContract) + + console.log('contract ', contractId, 'resolved to:', outcome) + + const openBets = bets.filter((b) => !b.isSold && !b.sale) + const loanPayouts = getLoanPayouts(openBets) + + if (!isProd()) + console.log( + 'payouts:', + payouts, + 'creator payout:', + creatorPayout, + 'liquidity payout:' + ) + + if (creatorPayout) + await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) + + await processPayouts(liquidityPayouts, true) + + await processPayouts([...payouts, ...loanPayouts]) + + const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) + + await sendResolutionEmails( + openBets, + userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions + ) + + return updatedContract +}) + const processPayouts = async (payouts: Payout[], isDeposit = false) => { const userPayouts = groupPayoutsByUser(payouts) @@ -221,4 +231,72 @@ const sendResolutionEmails = async ( ) } +function getResolutionParams(contract: Contract, body: string) { + const { outcomeType } = contract + + if (outcomeType === 'NUMERIC') { + return { + ...validate(numericSchema, body), + resolutions: undefined, + probabilityInt: undefined, + } + } else if (outcomeType === 'PSEUDO_NUMERIC') { + return { + ...validate(pseudoNumericSchema, body), + resolutions: undefined, + } + } else if (outcomeType === 'FREE_RESPONSE') { + const freeResponseParams = validate(freeResponseSchema, body) + const { outcome } = freeResponseParams + switch (outcome) { + case 'CANCEL': + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + case 'MKT': { + const { resolutions } = freeResponseParams + resolutions.forEach(({ answer }) => validateAnswer(contract, answer)) + const pctSum = sumBy(resolutions, ({ pct }) => pct) + if (Math.abs(pctSum - 100) > 0.1) { + throw new APIError(400, 'Resolution percentages must sum to 100') + } + return { + outcome: outcome.toString(), + resolutions: Object.fromEntries( + resolutions.map((r) => [r.answer, r.pct]) + ), + value: undefined, + probabilityInt: undefined, + } + } + default: { + validateAnswer(contract, outcome) + return { + outcome: outcome.toString(), + resolutions: undefined, + value: undefined, + probabilityInt: undefined, + } + } + } + } else if (outcomeType === 'BINARY') { + return { + ...validate(binarySchema, body), + value: undefined, + resolutions: undefined, + } + } + throw new APIError(500, `Invalid outcome type: ${outcomeType}`) +} + +function validateAnswer(contract: FreeResponseContract, answer: number) { + const validIds = contract.answers.map((a) => a.id) + if (!validIds.includes(answer.toString())) { + throw new APIError(400, `${answer} is not a valid answer ID`) + } +} + const firestore = admin.firestore() diff --git a/functions/src/scripts/backup-db.ts b/functions/src/scripts/backup-db.ts new file mode 100644 index 00000000..04c66438 --- /dev/null +++ b/functions/src/scripts/backup-db.ts @@ -0,0 +1,16 @@ +import * as firestore from '@google-cloud/firestore' +import { getServiceAccountCredentials } from './script-init' +import { backupDbCore } from '../backup-db' + +async function backupDb() { + const credentials = getServiceAccountCredentials() + const projectId = credentials.project_id + const client = new firestore.v1.FirestoreAdminClient({ credentials }) + const bucket = 'manifold-firestore-backup' + const resp = await backupDbCore(client, projectId, bucket) + console.log(`Operation: ${resp[0]['name']}`) +} + +if (require.main === module) { + backupDb().then(() => process.exit()) +} diff --git a/functions/src/scripts/pay-out-contract-again.ts b/functions/src/scripts/pay-out-contract-again.ts index 1686ebd9..a121889f 100644 --- a/functions/src/scripts/pay-out-contract-again.ts +++ b/functions/src/scripts/pay-out-contract-again.ts @@ -27,10 +27,10 @@ async function checkIfPayOutAgain(contractRef: DocRef, contract: Contract) { const { payouts } = getPayouts( resolution, - resolutions, contract, openBets, [], + resolutions, resolutionProbability ) diff --git a/functions/src/scripts/script-init.ts b/functions/src/scripts/script-init.ts index 8f65e4be..cc17a620 100644 --- a/functions/src/scripts/script-init.ts +++ b/functions/src/scripts/script-init.ts @@ -47,26 +47,29 @@ const getFirebaseActiveProject = (cwd: string) => { } } -export const initAdmin = (env?: string) => { +export const getServiceAccountCredentials = (env?: string) => { env = env || getFirebaseActiveProject(process.cwd()) if (env == null) { - console.error( + throw new Error( "Couldn't find active Firebase project; did you do `firebase use ?`" ) - return } const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env.toUpperCase()}` const keyPath = process.env[envVar] if (keyPath == null) { - console.error( + throw new Error( `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` ) - return } - console.log(`Initializing connection to ${env} Firebase...`) /* eslint-disable-next-line @typescript-eslint/no-var-requires */ - const serviceAccount = require(keyPath) - admin.initializeApp({ + 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/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..62e43105 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -16,7 +16,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 +24,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,7 +46,7 @@ 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 (shares > maxShares) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( 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/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index 4c48ce49..cc8c84cf 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,138 +1,138 @@ -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 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() 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/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index 81b94550..6b8e2885 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-call' 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 6eeadf97..ed9012c9 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -58,6 +58,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { setText('') setBetAmount(10) setAmountError(undefined) + setPossibleDuplicateAnswer(undefined) } else setAmountError(result.message) } } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 73055872..f76117b9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -3,7 +3,11 @@ import React, { useEffect, useState } from 'react' import { partition, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' -import { BinaryContract, CPMMBinaryContract } from 'common/contract' +import { + BinaryContract, + CPMMBinaryContract, + PseudoNumericContract, +} from 'common/contract' import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' @@ -21,7 +25,7 @@ import { APIError, placeBet } from 'web/lib/firebase/api-call' import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { BinaryOutcomeLabel, PseudoNumericOutcomeLabel } from './outcome-label' import { calculatePayoutAfterCorrectBet, calculateShares, @@ -35,6 +39,7 @@ import { getCpmmProbability, getCpmmLiquidityFee, } from 'common/calculate-cpmm' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveShares } from './use-save-shares' import { SignUpPrompt } from './sign-up-prompt' @@ -42,7 +47,7 @@ import { isIOS } from 'web/lib/util/device' import { track } from 'web/lib/service/analytics' export function BetPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string }) { const { contract, className } = props @@ -81,7 +86,7 @@ export function BetPanel(props: { } export function BetPanelSwitcher(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string title?: string // Set if BetPanel is on a feed modal selected?: 'YES' | 'NO' @@ -89,7 +94,8 @@ export function BetPanelSwitcher(props: { }) { const { contract, className, title, selected, onBetSuccess } = props - const { mechanism } = contract + const { mechanism, outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) @@ -122,7 +128,12 @@ export function BetPanelSwitcher(props: {
You have {formatWithCommas(floorShares)}{' '} - shares + {isPseudoNumeric ? ( + + ) : ( + + )}{' '} + shares
{tradeType === 'BUY' && ( @@ -190,12 +201,13 @@ export function BetPanelSwitcher(props: { } function BuyPanel(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract user: User | null | undefined selected?: 'YES' | 'NO' onBuySuccess?: () => void }) { const { contract, user, selected, onBuySuccess } = props + const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC' const [betChoice, setBetChoice] = useState<'YES' | 'NO' | undefined>(selected) const [betAmount, setBetAmount] = useState(undefined) @@ -302,6 +314,9 @@ function BuyPanel(props: { : 0) )} ${betChoice ?? 'YES'} shares` : undefined + + const format = getFormattedMappedValue(contract) + return ( <> onBetChoice(choice)} + isPseudoNumeric={isPseudoNumeric} />
Amount
-
Probability
+
+ {isPseudoNumeric ? 'Estimated value' : 'Probability'} +
- {formatPercent(initialProb)} + {format(initialProb)} - {formatPercent(resultProb)} + {format(resultProb)}
@@ -340,6 +358,8 @@ function BuyPanel(props: {
payout if{' '} + ) : isPseudoNumeric ? ( + 'Max payout' ) : ( <> Payout if @@ -389,7 +409,7 @@ function BuyPanel(props: { } export function SellPanel(props: { - contract: CPMMBinaryContract + contract: CPMMBinaryContract | PseudoNumericContract userBets: Bet[] shares: number sharesOutcome: 'YES' | 'NO' @@ -488,6 +508,10 @@ export function SellPanel(props: { } } + const { outcomeType } = contract + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' + const format = getFormattedMappedValue(contract) + return ( <> {formatMoney(saleValue)}
-
Probability
+
+ {isPseudoNumeric ? 'Estimated value' : 'Probability'} +
- {formatPercent(initialProb)} + {format(initialProb)} - {formatPercent(resultProb)} + {format(resultProb)}
diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx index 9621f7a9..ae5e0b00 100644 --- a/web/components/bet-row.tsx +++ b/web/components/bet-row.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' import { BetPanelSwitcher } from './bet-panel' import { YesNoSelector } from './yes-no-selector' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { Modal } from './layout/modal' import { SellButton } from './sell-button' import { useUser } from 'web/hooks/use-user' @@ -12,7 +12,7 @@ import { useSaveShares } from './use-save-shares' // Inline version of a bet panel. Opens BetPanel in a new modal. export default function BetRow(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract className?: string btnClassName?: string betPanelClassName?: string @@ -32,6 +32,7 @@ export default function BetRow(props: { return ( <> { diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index f41f89b6..b8fb7d31 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -8,6 +8,7 @@ import { useUserBets } from 'web/hooks/use-user-bets' import { Bet } from 'web/lib/firebase/bets' import { User } from 'web/lib/firebase/users' import { + formatLargeNumber, formatMoney, formatPercent, formatWithCommas, @@ -40,6 +41,7 @@ import { import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render' import { trackLatency } from 'web/lib/firebase/tracking' import { NumericContract } from 'common/contract' +import { formatNumericProbability } from 'common/pseudo-numeric' import { useUser } from 'web/hooks/use-user' import { SellSharesModal } from './sell-modal' @@ -366,6 +368,7 @@ export function BetsSummary(props: { const { contract, isYourBets, className } = props const { resolution, closeTime, outcomeType, mechanism } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isCpmm = mechanism === 'cpmm-1' const isClosed = closeTime && Date.now() > closeTime @@ -427,6 +430,25 @@ export function BetsSummary(props: { + ) : isPseudoNumeric ? ( + <> + +
+ Payout if {'>='} {formatLargeNumber(contract.max)} +
+
+ {formatMoney(yesWinnings)} +
+ + +
+ Payout if {'<='} {formatLargeNumber(contract.min)} +
+
+ {formatMoney(noWinnings)} +
+ + ) : (
@@ -507,13 +529,15 @@ export function ContractBetsTable(props: { const { isResolved, mechanism, outcomeType } = contract const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return (
{amountRedeemed > 0 && ( <>
- {amountRedeemed} YES shares and {amountRedeemed} NO shares + {amountRedeemed} {isPseudoNumeric ? 'HIGHER' : 'YES'} shares and{' '} + {amountRedeemed} {isPseudoNumeric ? 'LOWER' : 'NO'} shares automatically redeemed for {formatMoney(amountRedeemed)}.
@@ -541,7 +565,7 @@ export function ContractBetsTable(props: { )} {!isCPMM && !isResolved && Payout if chosen} Shares - Probability + {!isPseudoNumeric && Probability} Date @@ -585,6 +609,7 @@ function BetRow(props: { const isCPMM = mechanism === 'cpmm-1' const isNumeric = outcomeType === 'NUMERIC' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const saleAmount = saleBet?.sale?.amount @@ -628,14 +653,18 @@ function BetRow(props: { truncate="short" /> )} + {isPseudoNumeric && + ' than ' + formatNumericProbability(bet.probAfter, contract)} {formatMoney(Math.abs(amount))} {!isCPMM && !isNumeric && {saleDisplay}} {!isCPMM && !isResolved && {payoutIfChosenDisplay}} {formatWithCommas(Math.abs(shares))} - - {formatPercent(probBefore)} → {formatPercent(probAfter)} - + {!isPseudoNumeric && ( + + {formatPercent(probBefore)} → {formatPercent(probAfter)} + + )} {dayjs(createdTime).format('MMM D, h:mma')} ) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index fac02d74..2c7f5b62 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -9,7 +9,7 @@ import { useSortBy, } from 'react-instantsearch-hooks-web' -import { Contract } from '../../common/contract' +import { Contract } from 'common/contract' import { Sort, useInitialQueryAndSort, @@ -58,15 +58,24 @@ export function ContractSearch(props: { additionalFilter?: { creatorId?: string tag?: string + excludeContractIds?: string[] } showCategorySelector: boolean onContractClick?: (contract: Contract) => void + showPlaceHolder?: boolean + hideOrderSelector?: boolean + overrideGridClassName?: string + hideQuickBet?: boolean }) { const { querySortOptions, additionalFilter, showCategorySelector, onContractClick, + overrideGridClassName, + hideOrderSelector, + showPlaceHolder, + hideQuickBet, } = props const user = useUser() @@ -122,7 +131,7 @@ export function ContractSearch(props: { const indexName = `${indexPrefix}contracts-${sort}` - if (IS_PRIVATE_MANIFOLD) { + if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { return ( Resolved - + {!hideOrderSelector && ( + + )} )} @@ -199,8 +214,17 @@ export function ContractSearchInner(props: { shouldLoadFromStorage?: boolean } onContractClick?: (contract: Contract) => void + overrideGridClassName?: string + hideQuickBet?: boolean + excludeContractIds?: string[] }) { - const { querySortOptions, onContractClick } = props + const { + querySortOptions, + onContractClick, + overrideGridClassName, + hideQuickBet, + excludeContractIds, + } = props const { initialQuery } = useInitialQueryAndSort(querySortOptions) const { query, setQuery, setSort } = useUpdateQueryAndSort({ @@ -239,7 +263,7 @@ export function ContractSearchInner(props: { }, []) const { showMore, hits, isLastPage } = useInfiniteHits() - const contracts = hits as any as Contract[] + let contracts = hits as any as Contract[] if (isInitialLoad && contracts.length === 0) return <> @@ -249,6 +273,9 @@ export function ContractSearchInner(props: { ? 'resolve-date' : undefined + if (excludeContractIds) + contracts = contracts.filter((c) => !excludeContractIds.includes(c.id)) + return ( ) } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 87239465..c6cda43c 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -9,6 +9,7 @@ import { BinaryContract, FreeResponseContract, NumericContract, + PseudoNumericContract, } from 'common/contract' import { AnswerLabel, @@ -16,7 +17,11 @@ import { CancelLabel, FreeResponseOutcomeLabel, } from '../outcome-label' -import { getOutcomeProbability, getTopAnswer } from 'common/calculate' +import { + getOutcomeProbability, + getProbability, + getTopAnswer, +} from 'common/calculate' import { AvatarDetails, MiscDetails, ShowTime } from './contract-details' import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' @@ -24,6 +29,7 @@ import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' import { track } from '@amplitude/analytics-browser' import { trackCallback } from 'web/lib/service/analytics' +import { formatNumericProbability } from 'common/pseudo-numeric' export function ContractCard(props: { contract: Contract @@ -131,6 +137,13 @@ export function ContractCard(props: { /> )} + {outcomeType === 'PSEUDO_NUMERIC' && ( + + )} + {outcomeType === 'NUMERIC' && ( ) : ( -
{resolutionValue}
+
+ {formatLargeNumber(resolutionValue)} +
)} ) : ( @@ -284,3 +299,42 @@ export function NumericResolutionOrExpectation(props: { ) } + +export function PseudoNumericResolutionOrExpectation(props: { + contract: PseudoNumericContract + className?: string +}) { + const { contract, className } = props + const { resolution, resolutionValue, resolutionProbability } = contract + const textColor = `text-blue-400` + + return ( + + {resolution ? ( + <> +
Resolved
+ + {resolution === 'CANCEL' ? ( + + ) : ( +
+ {resolutionValue + ? formatLargeNumber(resolutionValue) + : formatNumericProbability( + resolutionProbability ?? 0, + contract + )} +
+ )} + + ) : ( + <> +
+ {formatNumericProbability(getProbability(contract), contract)} +
+
expected
+ + )} + + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 03925a35..f908918e 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -29,6 +29,8 @@ import { groupPath } from 'web/lib/firebase/groups' import { SiteLink } from 'web/components/site-link' import { DAY_MS } from 'common/util/time' import { useGroupsWithContract } from 'web/hooks/use-group' +import { ShareIconButton } from 'web/components/share-icon-button' +import { useUser } from 'web/hooks/use-user' export type ShowTime = 'resolve-date' | 'close-date' @@ -128,8 +130,32 @@ export function ContractDetails(props: { const { contract, bets, isCreator, disabled } = props const { closeTime, creatorName, creatorUsername, creatorId } = contract const { volumeLabel, resolvedDate } = contractMetrics(contract) - // Find a group that this contract id is in - const groups = useGroupsWithContract(contract.id) + + const groups = (useGroupsWithContract(contract.id) ?? []).sort((g1, g2) => { + return g2.createdTime - g1.createdTime + }) + const user = useUser() + + const groupsUserIsMemberOf = groups + ? groups.filter((g) => g.memberIds.includes(contract.creatorId)) + : [] + const groupsUserIsCreatorOf = groups + ? groups.filter((g) => g.creatorId === contract.creatorId) + : [] + + // Priorities for which group the contract belongs to: + // In order of created most recently + // Group that the contract owner created + // Group the contract owner is a member of + // Any group the contract is in + const groupToDisplay = + groupsUserIsCreatorOf.length > 0 + ? groupsUserIsCreatorOf[0] + : groupsUserIsMemberOf.length > 0 + ? groupsUserIsMemberOf[0] + : groups + ? groups[0] + : undefined return ( @@ -150,14 +176,15 @@ export function ContractDetails(props: { )} {!disabled && } - {/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/} - {groups && groups.length > 0 && ( + {groupToDisplay ? ( - + - {groups[0].name} + {groupToDisplay.name} + ) : ( +
)} {(!!closeTime || !!resolvedDate) && ( @@ -192,6 +219,11 @@ export function ContractDetails(props: {
{volumeLabel}
+ {!disabled && } diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 7027d06a..3e51902b 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -13,7 +13,6 @@ import { getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' -import { CopyLinkButton } from '../copy-link-button' import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Row } from '../layout/row' @@ -22,6 +21,10 @@ import { Title } from '../title' import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' import { TagsInput } from 'web/components/tags-input' +import { DuplicateContractButton } from '../copy-contract-button' + +export const contractDetailsButtonClassName = + 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props @@ -48,13 +51,11 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { return ( <> @@ -66,15 +67,12 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
Share
- +
diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a68f37be..897bef04 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -11,6 +11,7 @@ import { FreeResponseResolutionOrChance, BinaryResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' import BetRow from '../bet-row' @@ -32,6 +33,7 @@ export const ContractOverview = (props: { const user = useUser() const isCreator = user?.id === creatorId const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return ( @@ -49,6 +51,13 @@ export const ContractOverview = (props: { /> )} + {isPseudoNumeric && ( + + )} + {outcomeType === 'NUMERIC' && ( + {tradingAllowed(contract) && } + + ) : isPseudoNumeric ? ( + + {tradingAllowed(contract) && } ) : ( @@ -86,7 +100,9 @@ export const ContractOverview = (props: { /> - {isBinary && }{' '} + {(isBinary || isPseudoNumeric) && ( + + )}{' '} {outcomeType === 'FREE_RESPONSE' && ( )} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index 7386d748..a9d26e2e 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -5,16 +5,20 @@ import dayjs from 'dayjs' import { memo } from 'react' import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' -import { BinaryContract } from 'common/contract' +import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' +import { getMappedValue } from 'common/pseudo-numeric' +import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { - contract: BinaryContract + contract: BinaryContract | PseudoNumericContract bets: Bet[] height?: number }) { const { contract, height } = props - const { resolutionTime, closeTime } = contract + const { resolutionTime, closeTime, outcomeType } = contract + const isBinary = outcomeType === 'BINARY' + const isLogScale = outcomeType === 'PSEUDO_NUMERIC' && contract.isLogScale const bets = props.bets.filter((bet) => !bet.isAnte && !bet.isRedemption) @@ -24,7 +28,10 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { contract.createdTime, ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const probs = [startProb, ...bets.map((bet) => bet.probAfter)] + + const f = getMappedValue(contract) + + const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) const isClosed = !!closeTime && Date.now() > closeTime const latestTime = dayjs( @@ -39,7 +46,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { times.push(latestTime.toDate()) probs.push(probs[probs.length - 1]) - const yTickValues = [0, 25, 50, 75, 100] + const quartiles = [0, 25, 50, 75, 100] + + const yTickValues = isBinary + ? quartiles + : quartiles.map((x) => x / 100).map(f) const { width } = useWindowSize() @@ -55,9 +66,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const totalPoints = width ? (width > 800 ? 300 : 50) : 1 const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints + const points: { x: Date; y: number }[] = [] + const s = isBinary ? 100 : 1 + const c = isLogScale && contract.min === 0 ? 1 : 0 + for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: probs[i] * 100 } + points[points.length] = { x: times[i], y: s * probs[i] + c } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -69,17 +84,23 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: probs[i] * 100, + y: s * probs[i] + c, } } } } - const data = [{ id: 'Yes', data: points, color: '#11b981' }] + const data = [ + { id: 'Yes', data: points, color: isBinary ? '#11b981' : '#5fa5f9' }, + ] const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const lessThanAWeek = dayjs(startDate).add(8, 'day').isAfter(latestTime) + const formatter = isBinary + ? formatPercent + : (x: DatumValue) => formatLargeNumber(+x.valueOf()) + return (
) : ( )} @@ -189,7 +171,7 @@ export function QuickBet(props: { contract: Contract; user: User }) { {/* Down bet triangle */} - {contract.outcomeType !== 'BINARY' ? ( + {outcomeType !== 'BINARY' && outcomeType !== 'PSEUDO_NUMERIC' ? (
+
@@ -82,15 +96,20 @@ export default function Create() { export function NewContract(props: { creator: User question: string - groupId?: string + params?: NewQuestionParams }) { - const { creator, question, groupId } = props - const [outcomeType, setOutcomeType] = useState('BINARY') + const { creator, question, params } = props + const { groupId, initValue } = params ?? {} + const [outcomeType, setOutcomeType] = useState( + (params?.outcomeType as outcomeType) ?? 'BINARY' + ) const [initialProb] = useState(50) - const [minString, setMinString] = useState('') - const [maxString, setMaxString] = useState('') - // const [tagText, setTagText] = useState(tag ?? '') - // const tags = parseWordsAsTags(tagText) + + const [minString, setMinString] = useState(params?.min ?? '') + const [maxString, setMaxString] = useState(params?.max ?? '') + const [isLogScale, setIsLogScale] = useState(!!params?.isLogScale) + const [initialValueString, setInitialValueString] = useState(initValue) + useEffect(() => { if (groupId && creator) getGroup(groupId).then((group) => { @@ -102,18 +121,17 @@ export function NewContract(props: { }, [creator, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) - // useEffect(() => { - // if (ante === null && creator) { - // const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100 - // setAnte(initialAnte) - // } - // }, [ante, creator]) - - // const [anteError, setAnteError] = useState() + // If params.closeTime is set, extract out the specified date and time // By default, close the market a week from today const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD') - const [closeDate, setCloseDate] = useState(weekFromToday) - const [closeHoursMinutes, setCloseHoursMinutes] = useState('23:59') + const timeInMs = Number(params?.closeTime ?? 0) + const initDate = timeInMs + ? dayjs(timeInMs).format('YYYY-MM-DD') + : weekFromToday + const initTime = timeInMs ? dayjs(timeInMs).format('HH:mm') : '23:59' + const [closeDate, setCloseDate] = useState(initDate) + const [closeHoursMinutes, setCloseHoursMinutes] = useState(initTime) + const [marketInfoText, setMarketInfoText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [selectedGroup, setSelectedGroup] = useState( @@ -130,6 +148,18 @@ export function NewContract(props: { const min = minString ? parseFloat(minString) : undefined const max = maxString ? parseFloat(maxString) : undefined + const initialValue = initialValueString + ? parseFloat(initialValueString) + : undefined + + const adjustIsLog = () => { + if (min === undefined || max === undefined) return + const lengthDiff = Math.log10(max - min) + if (lengthDiff > 2) { + setIsLogScale(true) + } + } + // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') @@ -140,18 +170,20 @@ export function NewContract(props: { question.length > 0 && ante !== undefined && ante !== null && - ante >= MINIMUM_ANTE && ante <= balance && // closeTime must be in the future closeTime && closeTime > Date.now() && - (outcomeType !== 'NUMERIC' || + (outcomeType !== 'PSEUDO_NUMERIC' || (min !== undefined && max !== undefined && + initialValue !== undefined && isFinite(min) && isFinite(max) && min < max && - max - min > 0.01)) + max - min > 0.01 && + min < initialValue && + initialValue < max)) function setCloseDateInDays(days: number) { const newCloseDate = dayjs().add(days, 'day').format('YYYY-MM-DD') @@ -177,6 +209,8 @@ export function NewContract(props: { closeTime, min, max, + initialValue, + isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, tags: category ? [category] : undefined, }) @@ -188,9 +222,7 @@ export function NewContract(props: { isFree: false, }) if (result && selectedGroup) { - await updateGroup(selectedGroup, { - contractIds: [...selectedGroup.contractIds, result.id], - }) + await addContractToGroup(selectedGroup, result.id) } await router.push(contractPath(result as Contract)) @@ -224,6 +256,7 @@ export function NewContract(props: { choicesMap={{ 'Yes / No': 'BINARY', 'Free response': 'FREE_RESPONSE', + Numeric: 'PSEUDO_NUMERIC', }} isSubmitting={isSubmitting} className={'col-span-4'} @@ -236,38 +269,89 @@ export function NewContract(props: { - {outcomeType === 'NUMERIC' && ( -
- + {outcomeType === 'PSEUDO_NUMERIC' && ( + <> +
+ - - e.stopPropagation()} - onChange={(e) => setMinString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={minString ?? ''} - /> - e.stopPropagation()} - onChange={(e) => setMaxString(e.target.value)} - min={Number.MIN_SAFE_INTEGER} - max={Number.MAX_SAFE_INTEGER} - disabled={isSubmitting} - value={maxString} - /> - -
+ + e.stopPropagation()} + onChange={(e) => setMinString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={minString ?? ''} + /> + e.stopPropagation()} + onChange={(e) => setMaxString(e.target.value)} + onBlur={adjustIsLog} + min={Number.MIN_SAFE_INTEGER} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={maxString} + /> + + + {!(min !== undefined && min < 0) && ( + + Log scale{' '} + setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> + + )} + + {min !== undefined && max !== undefined && min >= max && ( +
+ The maximum value must be greater than the minimum. +
+ )} +
+
+ + + + e.stopPropagation()} + onChange={(e) => setInitialValueString(e.target.value)} + max={Number.MAX_SAFE_INTEGER} + disabled={isSubmitting} + value={initialValueString ?? ''} + /> + + + {initialValue !== undefined && + min !== undefined && + max !== undefined && + min < max && + (initialValue <= min || initialValue >= max) && ( +
+ Initial value must be in between {min} and {max}.{' '} +
+ )} +
+ )}
diff --git a/web/pages/embed/[username]/[contractSlug].tsx b/web/pages/embed/[username]/[contractSlug].tsx index 98bf37b2..93439be7 100644 --- a/web/pages/embed/[username]/[contractSlug].tsx +++ b/web/pages/embed/[username]/[contractSlug].tsx @@ -7,6 +7,7 @@ import { BinaryResolutionOrChance, FreeResponseResolutionOrChance, NumericResolutionOrExpectation, + PseudoNumericResolutionOrExpectation, } from 'web/components/contract/contract-card' import { ContractDetails } from 'web/components/contract/contract-details' import { ContractProbGraph } from 'web/components/contract/contract-prob-graph' @@ -79,6 +80,7 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { const { question, outcomeType } = contract const isBinary = outcomeType === 'BINARY' + const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const href = `https://${DOMAIN}${contractPath(contract)}` @@ -110,13 +112,18 @@ function ContractEmbed(props: { contract: Contract; bets: Bet[] }) { {isBinary && ( - {/* this fails typechecking, but it doesn't explode because we will - never */} - + )} + {isPseudoNumeric && ( + + + + + )} + {outcomeType === 'FREE_RESPONSE' && (
- {isBinary && ( + {(isBinary || isPseudoNumeric) && ( { + const { referrer } = router.query as { + referrer?: string + } + if (!user && router.isReady) + writeReferralInfo(creator.username, undefined, referrer, group?.slug) + }, [user, creator, group, router]) + if (group === null || !groupSubpages.includes(page) || slugs[2]) { return } @@ -256,7 +274,13 @@ export default function GroupPage(props: { ) : (
- No questions yet. 🦗... Why not add one? + No questions yet. Why not{' '} + + add one? +
) ) : ( @@ -320,18 +344,17 @@ function GroupOverview(props: { return ( - - About {group.name} - {isCreator && } - - -
Created by
- + +
+
Created by
+ +
+ {isCreator && }
Membership @@ -351,6 +374,20 @@ function GroupOverview(props: { )} + {anyoneCanJoin && user && ( + + Sharing + + + Invite a friend and get M${REFERRAL_AMOUNT} if they sign up! + + + + )} ) @@ -473,74 +510,46 @@ function GroupLeaderboards(props: { } function AddContractButton(props: { group: Group; user: User }) { - const { group, user } = props + const { group } = props const [open, setOpen] = useState(false) - const [contracts, setContracts] = useState(undefined) - const [query, setQuery] = useState('') - useEffect(() => { - return listenForUserContracts(user.id, (contracts) => { - setContracts(contracts.filter((c) => !group.contractIds.includes(c.id))) - }) - }, [group.contractIds, user.id]) - - async function addContractToGroup(contract: Contract) { - await updateGroup(group, { - ...group, - contractIds: [...group.contractIds, contract.id], - }) + async function addContractToCurrentGroup(contract: Contract) { + await addContractToGroup(group, contract.id) setOpen(false) } - // TODO use find-active-contracts to sort by? - const matches = sortBy(contracts, [ - (contract) => -1 * contract.createdTime, - ]).filter( - (c) => - checkAgainstQuery(query, c.question) || - checkAgainstQuery(query, c.tags.flat().join(' ')) - ) - const debouncedQuery = debounce(setQuery, 50) return ( <> - - + +
Add a question to your group
- debouncedQuery(e.target.value)} - placeholder="Search your questions" - className="input input-bordered mb-4 w-full" - /> -
- {contracts ? ( - {}} - hasMore={false} - onContractClick={(contract) => { - addContractToGroup(contract) - }} - overrideGridClassName={'flex grid-cols-1 flex-col gap-3 p-1'} - hideQuickBet={true} - /> - ) : ( - - )} +
+
@@ -554,17 +563,11 @@ function JoinGroupButton(props: { const { group, user } = props function joinGroup() { if (user && !group.memberIds.includes(user.id)) { - toast.promise( - updateGroup(group, { - ...group, - memberIds: [...group.memberIds, user.id], - }), - { - loading: 'Joining group...', - success: 'Joined group!', - error: "Couldn't join group", - } - ) + toast.promise(addUserToGroup(group, user.id), { + loading: 'Joining group...', + success: 'Joined group!', + error: "Couldn't join group", + }) } } return ( diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index c8f08b25..22fe7661 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -15,6 +15,8 @@ import { getUser, User } from 'web/lib/firebase/users' import { Tabs } from 'web/components/layout/tabs' import { GroupMembersList } from 'web/pages/group/[...slugs]' import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params' +import { SiteLink } from 'web/components/site-link' +import clsx from 'clsx' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) @@ -105,7 +107,7 @@ export default function Groups(props: { 0 ? [ { title: 'My Groups', @@ -202,3 +204,16 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) { ) } + +export function GroupLink(props: { group: Group; className?: string }) { + const { group, className } = props + + return ( + + {group.name} + + ) +} diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 44c0a65b..f306493b 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -67,7 +67,9 @@ export default function Leaderboards(props: { {!isLoading ? ( <> - {period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available + {period === 'allTime' || + period == 'weekly' || + period === 'daily' ? ( //TODO: show other periods once they're available
- + <Title text={`Claim M$${manalink.amount} mana`} /> <ManalinkCard defaultMessage={fromUser?.name || 'Enjoy this mana!'} info={info} @@ -46,7 +46,7 @@ export default function ClaimPage() { if (result.data.status == 'error') { throw new Error(result.data.message) } - router.push('/account?claimed-mana=yes') + user && router.push(`/${user.username}?claimed-mana=yes`) } catch (e) { console.log(e) const message = diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 08c99460..12cde274 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -64,7 +64,7 @@ export default function LinkPage() { <Col className="w-full px-8"> <Title text="Manalinks" /> <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a3af0a9a..569f8ef8 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,12 +1,7 @@ import { Tabs } from 'web/components/layout/tabs' import { useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' -import { - Notification, - notification_reason_types, - notification_source_types, - notification_source_update_types, -} from 'common/notification' +import { Notification } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' @@ -14,9 +9,6 @@ import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' -import { Answer } from 'common/answer' -import { Comment } from 'web/lib/firebase/comments' -import { getValue } from 'web/lib/firebase/utils' import Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' @@ -34,48 +26,40 @@ import { ProbPercentLabel, } from 'web/components/outcome-label' import { - groupNotifications, NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' -import { getContractFromId } from 'web/lib/firebase/contracts' -import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' +import { groupBy } from 'lodash' + +export const NOTIFICATIONS_PER_PAGE = 30 +export const HIGHLIGHT_DURATION = 30 * 1000 export default function Notifications() { const user = useUser() - const [unseenNotificationGroups, setUnseenNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - const allNotificationGroups = usePreferredGroupedNotifications(user?.id, { + const [page, setPage] = useState(1) + + const groupedNotifications = usePreferredGroupedNotifications(user?.id, { unseenOnly: false, }) - + const [paginatedNotificationGroups, setPaginatedNotificationGroups] = + useState<NotificationGroup[]>([]) useEffect(() => { - if (!allNotificationGroups) return - // Don't re-add notifications that are visible right now or have been seen already. - const currentlyVisibleUnseenNotificationIds = Object.values( - unseenNotificationGroups ?? [] - ) - .map((n) => n.notifications.map((n) => n.id)) - .flat() - const unseenGroupedNotifications = groupNotifications( - allNotificationGroups - .map((notification: NotificationGroup) => notification.notifications) - .flat() - .filter( - (notification: Notification) => - !notification.isSeen || - currentlyVisibleUnseenNotificationIds.includes(notification.id) - ) - ) - setUnseenNotificationGroups(unseenGroupedNotifications) - - // We don't want unseenNotificationsGroup to be in the dependencies as we update it here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allNotificationGroups]) + if (!groupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = groupedNotifications.slice(start, end) + const remainingNotification = groupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedNotificationGroups(maxNotificationsToShow) + }, [groupedNotifications, page]) if (user === undefined) { return <LoadingIndicator /> @@ -84,63 +68,83 @@ export default function Notifications() { return <Custom404 /> } - // TODO: use infinite scroll return ( <Page> <div className={'p-2 sm:p-4'}> <Title text={'Notifications'} className={'hidden md:block'} /> <Tabs - className={'pb-2 pt-1 '} + labelClassName={'pb-2 pt-1 '} defaultIndex={0} tabs={[ { - title: 'New Notifications', - content: unseenNotificationGroups ? ( + title: 'Notifications', + content: groupedNotifications ? ( <div className={''}> - {unseenNotificationGroups.length === 0 && - "You don't have any new notifications."} - {unseenNotificationGroups.map((notification) => + {paginatedNotificationGroups.length === 0 && + "You don't have any notifications. Try changing your settings to see more."} + {paginatedNotificationGroups.map((notification) => notification.notifications.length === 1 ? ( <NotificationItem notification={notification.notifications[0]} key={notification.notifications[0].id} /> + ) : notification.type === 'income' ? ( + <IncomeNotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> ) : ( <NotificationGroupItem notificationGroup={notification} - key={ - notification.sourceContractId + - notification.timePeriod - } + key={notification.groupedById + notification.timePeriod} /> ) )} - </div> - ) : ( - <LoadingIndicator /> - ), - }, - { - title: 'All Notifications', - content: allNotificationGroups ? ( - <div className={''}> - {allNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {allNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : ( - <NotificationGroupItem - notificationGroup={notification} - key={ - notification.sourceContractId + - notification.timePeriod - } - /> - ) + {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + <nav + className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <div className="hidden sm:block"> + <p className="text-sm text-gray-700"> + Showing{' '} + <span className="font-medium"> + {page === 1 + ? page + : (page - 1) * NOTIFICATIONS_PER_PAGE} + </span>{' '} + to{' '} + <span className="font-medium"> + {page * NOTIFICATIONS_PER_PAGE} + </span>{' '} + of{' '} + <span className="font-medium"> + {groupedNotifications.length} + </span>{' '} + results + </p> + </div> + <div className="flex flex-1 justify-between sm:justify-end"> + <a + href="#" + className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 1 && setPage(page - 1)} + > + Previous + </a> + <a + href="#" + className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => + page < + groupedNotifications?.length / + NOTIFICATIONS_PER_PAGE && setPage(page + 1) + } + > + Next + </a> + </div> + </nav> )} </div> ) : ( @@ -162,13 +166,12 @@ export default function Notifications() { ) } -const setNotificationsAsSeen = (notifications: Notification[]) => { +export const setNotificationsAsSeen = (notifications: Notification[]) => { notifications.forEach((notification) => { if (!notification.isSeen) updateDoc( doc(db, `users/${notification.userId}/notifications/`, notification.id), { - ...notification, isSeen: true, viewTime: new Date(), } @@ -177,12 +180,158 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } +function IncomeNotificationGroupItem(props: { + notificationGroup: NotificationGroup + className?: string +}) { + const { notificationGroup, className } = props + const { notifications } = notificationGroup + const numSummaryLines = 3 + + const [expanded, setExpanded] = useState(false) + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + setNotificationsAsSeen(notifications) + }, [notifications]) + + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + + const totalIncome = notifications.reduce( + (acc, notification) => + acc + + (notification.sourceType && + notification.sourceText && + notification.sourceType === 'bonus' + ? parseInt(notification.sourceText) + : 0), + 0 + ) + // loop through the contracts and combine the notification items into one + function combineNotificationsByAddingSourceTextsAndReturningTheRest( + notifications: Notification[] + ) { + const newNotifications = [] + const groupedNotificationsByContractId = groupBy( + notifications, + (notification) => { + return notification.sourceContractId + } + ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + + const newNotification = + notificationsForContractId.length === 1 + ? notificationsForContractId[0] + : { + ...notificationsForContractId[0], + sourceText: sum.toString(), + } + newNotifications.push(newNotification) + } + return newNotifications + } + + const combinedNotifs = + combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications) + + return ( + <div + className={clsx( + 'relative cursor-pointer bg-white px-2 pt-6 text-sm', + className, + !expanded ? 'hover:bg-gray-100' : '', + highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' + )} + onClick={() => setExpanded(!expanded)} + > + {expanded && ( + <span + className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200" + aria-hidden="true" + /> + )} + <Row className={'items-center text-gray-500 sm:justify-start'}> + <TrendingUpIcon className={'text-primary h-7 w-7'} /> + <div className={'flex-1 overflow-hidden pl-2 sm:flex'}> + <div + onClick={() => setExpanded(!expanded)} + className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} + > + <span> + {'Daily Income Summary: '} + <span className={'text-primary'}>{formatMoney(totalIncome)}</span> + </span> + </div> + <RelativeTimestamp time={notifications[0].createdTime} /> + </div> + </Row> + <div> + <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}> + {' '} + <div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}> + {!expanded ? ( + <> + {combinedNotifs + .slice(0, numSummaryLines) + .map((notification) => { + return ( + <NotificationItem + notification={notification} + justSummary={true} + key={notification.id} + /> + ) + })} + <div className={'text-sm text-gray-500 hover:underline '}> + {combinedNotifs.length - numSummaryLines > 0 + ? 'And ' + + (combinedNotifs.length - numSummaryLines) + + ' more...' + : ''} + </div> + </> + ) : ( + <> + {combinedNotifs.map((notification) => ( + <NotificationItem + notification={notification} + key={notification.id} + justSummary={false} + /> + ))} + </> + )} + </div> + </div> + + <div className={'mt-6 border-b border-gray-300'} /> + </div> + </div> + ) +} + function NotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string }) { const { notificationGroup, className } = props - const { sourceContractId, notifications } = notificationGroup + const { notifications } = notificationGroup const { sourceContractTitle, sourceContractSlug, @@ -191,39 +340,28 @@ function NotificationGroupItem(props: { const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) - const [contract, setContract] = useState<Contract | undefined>(undefined) - + const [highlighted, setHighlighted] = useState(false) useEffect(() => { - if ( - sourceContractTitle && - sourceContractSlug && - sourceContractCreatorUsername - ) - return - if (sourceContractId) { - getContractFromId(sourceContractId) - .then((contract) => { - if (contract) setContract(contract) - }) - .catch((e) => console.log(e)) + if (notifications.some((n) => !n.isSeen)) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) } - }, [ - sourceContractCreatorUsername, - sourceContractId, - sourceContractSlug, - sourceContractTitle, - ]) - - useEffect(() => { setNotificationsAsSeen(notifications) }, [notifications]) + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + return ( <div className={clsx( 'relative cursor-pointer bg-white px-2 pt-6 text-sm', className, - !expanded ? 'hover:bg-gray-100' : '' + !expanded ? 'hover:bg-gray-100' : '', + highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : '' )} onClick={() => setExpanded(!expanded)} > @@ -240,20 +378,20 @@ function NotificationGroupItem(props: { onClick={() => setExpanded(!expanded)} className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} > - {sourceContractTitle || contract ? ( + {sourceContractTitle ? ( <span> {'Activity on '} <a href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : `/${contract?.creatorUsername}/${contract?.slug}` + : '' } className={ 'font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' } > - {sourceContractTitle || contract?.question} + {sourceContractTitle} </a> </span> ) : ( @@ -306,6 +444,7 @@ function NotificationGroupItem(props: { ) } +// TODO: where should we put referral bonus notifications? function NotificationSettings() { const user = useUser() const [notificationSettings, setNotificationSettings] = @@ -455,6 +594,10 @@ function NotificationSettings() { highlight={notificationSettings !== 'none'} label={"Activity on questions you're betting on"} /> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Income & referral bonuses you've received"} + /> <NotificationSettingLine label={"Activity on questions you've ever bet or commented on"} highlight={notificationSettings === 'all'} @@ -497,17 +640,6 @@ function NotificationSettings() { ) } -function isNotificationAboutContractResolution( - sourceType: notification_source_types | undefined, - sourceUpdateType: notification_source_update_types | undefined, - contract: Contract | null | undefined -) { - return ( - (sourceType === 'contract' && sourceUpdateType === 'resolved') || - (sourceType === 'contract' && !sourceUpdateType && contract?.resolution) - ) -} - function NotificationItem(props: { notification: Notification justSummary?: boolean @@ -515,7 +647,6 @@ function NotificationItem(props: { const { notification, justSummary } = props const { sourceType, - sourceContractId, sourceId, sourceUserName, sourceUserAvatarUrl, @@ -534,60 +665,25 @@ function NotificationItem(props: { const [defaultNotificationText, setDefaultNotificationText] = useState<string>('') - const [contract, setContract] = useState<Contract | null>(null) - - useEffect(() => { - if ( - !sourceContractId || - (sourceContractSlug && sourceContractCreatorUsername) - ) - return - getContractFromId(sourceContractId) - .then((contract) => { - if (contract) setContract(contract) - }) - .catch((e) => console.log(e)) - }, [ - sourceContractCreatorUsername, - sourceContractId, - sourceContractSlug, - sourceContractTitle, - ]) useEffect(() => { if (sourceText) { setDefaultNotificationText(sourceText) - } else if (!contract || !sourceContractId || !sourceId) return - else if ( - sourceType === 'answer' || - sourceType === 'comment' || - sourceType === 'contract' - ) { - try { - parseOldStyleNotificationText( - sourceId, - sourceContractId, - sourceType, - sourceUpdateType, - setDefaultNotificationText, - contract - ) - } catch (err) { - console.error(err) - } } else if (reasonText) { // Handle arbitrary notifications with reason text here. setDefaultNotificationText(reasonText) } - }, [ - contract, - reasonText, - sourceContractId, - sourceId, - sourceText, - sourceType, - sourceUpdateType, - ]) + }, [reasonText, sourceText]) + + const [highlighted, setHighlighted] = useState(false) + useEffect(() => { + if (!notification.isSeen) { + setHighlighted(true) + setTimeout(() => { + setHighlighted(false) + }, HIGHLIGHT_DURATION) + } + }, [notification.isSeen]) useEffect(() => { setNotificationsAsSeen([notification]) @@ -596,14 +692,16 @@ function NotificationItem(props: { function getSourceUrl() { if (sourceType === 'follow') return `/${sourceUserUsername}` if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` + if ( + sourceContractCreatorUsername && + sourceContractSlug && + sourceType === 'user' + ) + return `/${sourceContractCreatorUsername}/${sourceContractSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '' )}` - if (!contract) return '' - return `/${contract.creatorUsername}/${ - contract.slug - }#${getSourceIdForLinkComponent(sourceId ?? '')}` } function getSourceIdForLinkComponent(sourceId: string) { @@ -619,63 +717,30 @@ function NotificationItem(props: { } } - async function parseOldStyleNotificationText( - sourceId: string, - sourceContractId: string, - sourceType: 'answer' | 'comment' | 'contract', - sourceUpdateType: notification_source_update_types | undefined, - setText: (text: string) => void, - contract: Contract - ) { - if (sourceType === 'contract') { - if ( - isNotificationAboutContractResolution( - sourceType, - sourceUpdateType, - contract - ) && - contract.resolution - ) - setText(contract.resolution) - else setText(contract.question) - } else if (sourceType === 'answer') { - const answer = await getValue<Answer>( - doc(db, `contracts/${sourceContractId}/answers/`, sourceId) - ) - setText(answer?.text ?? '') - } else { - const comment = await getValue<Comment>( - doc(db, `contracts/${sourceContractId}/comments/`, sourceId) - ) - setText(comment?.text ?? '') - } - } - if (justSummary) { return ( <Row className={'items-center text-sm text-gray-500 sm:justify-start'}> <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}> <div className={'flex pl-1 sm:pl-0'}> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> + {sourceType != 'bonus' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + /> + )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <span className={'flex-shrink-0'}> {sourceType && reason && - getReasonForShowingNotification( - sourceType, - reason, - sourceUpdateType, - contract, - true - ).replace(' on', '')} + getReasonForShowingNotification(notification, true).replace( + ' on', + '' + )} </span> <div className={'ml-1 text-black'}> <NotificationTextLabel - contract={contract} + contract={null} defaultText={defaultNotificationText} className={'line-clamp-1'} notification={notification} @@ -690,48 +755,54 @@ function NotificationItem(props: { } return ( - <div className={'bg-white px-2 pt-6 text-sm sm:px-4'}> + <div + className={clsx( + 'bg-white px-2 pt-6 text-sm sm:px-4', + highlighted && 'bg-indigo-200 hover:bg-indigo-100' + )} + > <a href={getSourceUrl()}> <Row className={'items-center text-gray-500 sm:justify-start'}> - <Avatar - avatarUrl={sourceUserAvatarUrl} - size={'sm'} - className={'mr-2'} - username={sourceUserName} - /> + {sourceType != 'bonus' ? ( + <Avatar + avatarUrl={sourceUserAvatarUrl} + size={'sm'} + className={'mr-2'} + username={sourceUserName} + /> + ) : ( + <TrendingUpIcon className={'text-primary h-7 w-7'} /> + )} <div className={'flex-1 overflow-hidden sm:flex'}> <div className={ 'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' } > - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'mr-0 flex-shrink-0'} - /> + {sourceType != 'bonus' && sourceUpdateType != 'closed' && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'mr-0 flex-shrink-0'} + /> + )} <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> {sourceType && reason && ( <div className={'inline truncate'}> - {getReasonForShowingNotification( - sourceType, - reason, - sourceUpdateType, - contract - )} + {getReasonForShowingNotification(notification, false)} <a href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` : sourceType === 'group' && sourceSlug ? `${groupPath(sourceSlug)}` - : `/${contract?.creatorUsername}/${contract?.slug}` + : '' } className={ 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2' } > - {contract?.question || sourceContractTitle || sourceTitle} + {sourceContractTitle || sourceTitle} </a> </div> )} @@ -752,7 +823,7 @@ function NotificationItem(props: { </Row> <div className={'mt-1 ml-1 md:text-base'}> <NotificationTextLabel - contract={contract} + contract={null} defaultText={defaultNotificationText} notification={notification} /> @@ -779,13 +850,7 @@ function NotificationTextLabel(props: { return <span>{contract?.question || sourceContractTitle}</span> if (!sourceText) return <div /> // Resolved contracts - if ( - isNotificationAboutContractResolution( - sourceType, - sourceUpdateType, - contract - ) - ) { + if (sourceType === 'contract' && sourceUpdateType === 'resolved') { { if (sourceText === 'YES' || sourceText == 'NO') { return <BinaryOutcomeLabel outcome={sourceText as any} /> @@ -811,10 +876,26 @@ function NotificationTextLabel(props: { </span> ) } + } else if (sourceType === 'user' && sourceText) { + return ( + <span> + As a thank you, we sent you{' '} + <span className="text-primary"> + {formatMoney(parseInt(sourceText))} + </span> + ! + </span> + ) } else if (sourceType === 'liquidity' && sourceText) { return ( <span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span> ) + } else if (sourceType === 'bonus' && sourceText) { + return ( + <span className="text-primary"> + {'+' + formatMoney(parseInt(sourceText))} + </span> + ) } // return default text return ( @@ -825,14 +906,13 @@ function NotificationTextLabel(props: { } function getReasonForShowingNotification( - source: notification_source_types, - reason: notification_reason_types, - sourceUpdateType: notification_source_update_types | undefined, - contract: Contract | undefined | null, + notification: Notification, simple?: boolean ) { + const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } = + notification let reasonText: string - switch (source) { + switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') reasonText = !simple ? 'replied to your answer on' : 'replied' @@ -852,16 +932,9 @@ function getReasonForShowingNotification( break case 'contract': if (reason === 'you_follow_user') reasonText = 'created a new question' - else if ( - isNotificationAboutContractResolution( - source, - sourceUpdateType, - contract - ) - ) - reasonText = `resolved` + else if (sourceUpdateType === 'resolved') reasonText = `resolved` else if (sourceUpdateType === 'closed') - reasonText = `please resolve your question` + reasonText = `Please resolve your question` else reasonText = `updated` break case 'answer': @@ -883,6 +956,21 @@ function getReasonForShowingNotification( case 'group': reasonText = 'added you to the group' break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + case 'bonus': + if (reason === 'unique_bettors_on_your_contract' && sourceText) + reasonText = !simple + ? `You had ${ + parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT + } unique bettors on` + : 'You earned Mana for unique bettors:' + else reasonText = 'You earned your daily manna' + break default: reasonText = '' } diff --git a/yarn.lock b/yarn.lock index 10ad2fe5..d0070cbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2181,6 +2181,20 @@ google-gax "^2.24.1" protobufjs "^6.8.6" +"@google-cloud/functions-framework@3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@google-cloud/functions-framework/-/functions-framework-3.1.2.tgz#2cd92ce4307bf7f32555d028dca22e398473b410" + integrity sha512-pYvEH65/Rqh1JNPdcBmorcV7Xoom2/iOSmbtYza8msro7Inl+qOYxbyMiQfySD2gwAyn38WyWPRqsDRcf/BFLg== + dependencies: + "@types/express" "4.17.13" + body-parser "^1.18.3" + cloudevents "^6.0.0" + express "^4.16.4" + minimist "^1.2.5" + on-finished "^2.3.0" + read-pkg-up "^7.0.1" + semver "^7.3.5" + "@google-cloud/paginator@^3.0.7": version "3.0.7" resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" @@ -3113,7 +3127,7 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@*", "@types/express@^4.17.13": +"@types/express@*", "@types/express@4.17.13", "@types/express@^4.17.13": version "4.17.13" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== @@ -3236,6 +3250,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== +"@types/normalize-package-data@^2.4.0": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" + integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -3685,7 +3704,7 @@ ajv@^6.10.0, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.0.0, ajv@^8.8.0: +ajv@^8.0.0, ajv@^8.11.0, ajv@^8.8.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== @@ -3937,6 +3956,11 @@ autoprefixer@^10.3.7, autoprefixer@^10.4.2: picocolors "^1.0.0" postcss-value-parser "^4.2.0" +available-typed-arrays@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" + integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== + axe-core@^4.3.5: version "4.4.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.2.tgz#dcf7fb6dea866166c3eab33d68208afe4d5f670c" @@ -4062,19 +4086,12 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -biskviit@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/biskviit/-/biskviit-1.0.1.tgz#037a0cd4b71b9e331fd90a1122de17dc49e420a7" - integrity sha512-VGCXdHbdbpEkFgtjkeoBN8vRlbj1ZRX2/mxhE8asCCRalUx2nBzOomLJv8Aw/nRt5+ccDb+tPKidg4XxcfGW4w== - dependencies: - psl "^1.1.7" - bluebird@^3.7.1: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -body-parser@1.20.0: +body-parser@1.20.0, body-parser@^1.18.3: version "1.20.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== @@ -4430,6 +4447,16 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +cloudevents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/cloudevents/-/cloudevents-6.0.2.tgz#7b4990a92c6c30f6790eb4b59207b4d8949fca12" + integrity sha512-mn/4EZnAbhfb/TghubK2jPnxYM15JRjf8LnWJtXidiVKi5ZCkd+p9jyBZbL57w7nRm6oFAzJhjxRLsXd/DNaBQ== + dependencies: + ajv "^8.11.0" + ajv-formats "^2.1.1" + util "^0.12.4" + uuid "^8.3.2" + clsx@1.1.1, clsx@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" @@ -5424,13 +5451,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" - integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= - dependencies: - iconv-lite "~0.4.13" - end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -5478,7 +5498,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5: +es-abstract@^1.19.0, es-abstract@^1.19.1, es-abstract@^1.19.2, es-abstract@^1.19.5, es-abstract@^1.20.0: version "1.20.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.20.1.tgz#027292cd6ef44bd12b1913b828116f54787d1814" integrity sha512-WEm2oBhfoI2sImeM4OF2zE2V3BYdSF+KnSi9Sidz51fQHd7+JuF8Xgcj9/0o+OWeIeIS/MiuNnlruQrJf16GQA== @@ -5858,7 +5878,7 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -express@^4.17.1, express@^4.17.3: +express@^4.16.4, express@^4.17.1, express@^4.17.3: version "4.18.1" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== @@ -6004,14 +6024,6 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -fetch@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fetch/-/fetch-1.1.0.tgz#0a8279f06be37f9f0ebb567560a30a480da59a2e" - integrity sha1-CoJ58Gvjf58Ou1Z1YKMKSA2lmi4= - dependencies: - biskviit "1.0.1" - encoding "0.1.12" - file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -6080,7 +6092,7 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" -find-up@^4.0.0: +find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== @@ -6190,6 +6202,13 @@ follow-redirects@^1.0.0, follow-redirects@^1.14.7: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== +for-each@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" + integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== + dependencies: + is-callable "^1.1.3" + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.2" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340" @@ -6794,6 +6813,11 @@ hoist-non-react-statics@^3.1.0: dependencies: react-is "^16.7.0" +hosted-git-info@^2.1.4: + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -6969,7 +6993,7 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -iconv-lite@0.4.24, iconv-lite@~0.4.13: +iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -7154,6 +7178,14 @@ is-alphanumerical@^1.0.0: is-alphabetical "^1.0.0" is-decimal "^1.0.0" +is-arguments@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" + integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== + dependencies: + call-bind "^1.0.2" + has-tostringtag "^1.0.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -7186,7 +7218,7 @@ is-buffer@^2.0.0: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.4, is-callable@^1.2.4: +is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== @@ -7198,7 +7230,7 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" -is-core-module@^2.2.0, is-core-module@^2.8.1: +is-core-module@^2.2.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== @@ -7237,6 +7269,13 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -7370,6 +7409,17 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-typed-array@^1.1.3, is-typed-array@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.9.tgz#246d77d2871e7d9f5aeb1d54b9f52c71329ece67" + integrity sha512-kfrlnTTn8pZkfpJMUgYD7YZ3qzeJgWUn8XfVYBARc4wnmNOmLbmuuaAs3q5fvB0UJOn6yHAKaGTPM7d6ezoD/A== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -8335,6 +8385,16 @@ nopt@1.0.10: dependencies: abbrev "1" +normalize-package-data@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -8461,7 +8521,7 @@ obuf@^1.0.0, obuf@^1.1.2: resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== -on-finished@2.4.1: +on-finished@2.4.1, on-finished@^2.3.0: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -9428,11 +9488,6 @@ pseudomap@^1.0.1: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.7: - version "1.8.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" - integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -9767,6 +9822,25 @@ react@17.0.2, react@^17.0.1: loose-envify "^1.1.0" object-assign "^4.1.1" +read-pkg-up@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" + integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg== + dependencies: + find-up "^4.1.0" + read-pkg "^5.2.0" + type-fest "^0.8.1" + +read-pkg@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc" + integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg== + dependencies: + "@types/normalize-package-data" "^2.4.0" + normalize-package-data "^2.5.0" + parse-json "^5.0.0" + type-fest "^0.6.0" + readable-stream@1.1.x: version "1.1.14" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" @@ -10071,6 +10145,15 @@ resolve@^1.1.6, resolve@^1.14.2, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.3. path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.10.0: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^2.0.0-next.3: version "2.0.0-next.3" resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46" @@ -10157,7 +10240,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -10268,16 +10351,16 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + semver@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.4.1, semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -10532,6 +10615,32 @@ spawn-command@^0.0.2-1: resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A= +spdx-correct@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== + +spdx-expression-parse@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.11" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95" + integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g== + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" @@ -11022,6 +11131,16 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" + integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg== + +type-fest@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + type-fest@^2.5.0: version "2.13.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.13.0.tgz#d1ecee38af29eb2e863b22299a3d68ef30d2abfb" @@ -11290,6 +11409,18 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +util@^0.12.4: + version "0.12.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" + integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + safe-buffer "^5.1.2" + which-typed-array "^1.1.2" + utila@~0.4: version "0.4.0" resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" @@ -11315,6 +11446,14 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + value-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" @@ -11553,6 +11692,18 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-typed-array@^1.1.2: + version "1.1.8" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.8.tgz#0cfd53401a6f334d90ed1125754a42ed663eb01f" + integrity sha512-Jn4e5PItbcAHyLoRDwvPj1ypu27DJbtdYXUa5zsinrUx77Uvfb0cXwwnGMTn7cjUfhhqgVQnVJCwF+7cgU7tpw== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.20.0" + for-each "^0.3.3" + has-tostringtag "^1.0.0" + is-typed-array "^1.1.9" + which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"