diff --git a/.github/workflows/merge-main-into-main2.yml b/.github/workflows/merge-main-into-main2.yml new file mode 100644 index 00000000..0a8de56f --- /dev/null +++ b/.github/workflows/merge-main-into-main2.yml @@ -0,0 +1,17 @@ +name: Merge main into main2 on every commit +on: + push: + branches: + - 'main' +jobs: + merge-branch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + + - name: Merge main -> main2 + uses: devmasx/merge-branch@master + with: + type: now + target_branch: main2 + github_token: ${{ github.token }} diff --git a/common/add-liquidity.ts b/common/add-liquidity.ts index 9271bbbf..47b3c1e9 100644 --- a/common/add-liquidity.ts +++ b/common/add-liquidity.ts @@ -1,4 +1,4 @@ -import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm' +import { getCpmmLiquidity } from './calculate-cpmm' import { CPMMContract } from './contract' import { LiquidityProvision } from './liquidity-provision' @@ -8,25 +8,23 @@ export const getNewLiquidityProvision = ( contract: CPMMContract, newLiquidityProvisionId: string ) => { - const { pool, p, totalLiquidity } = contract + const { pool, p, totalLiquidity, subsidyPool } = contract - const { newPool, newP } = addCpmmLiquidity(pool, p, amount) - - const liquidity = - getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP) + const liquidity = getCpmmLiquidity(pool, p) const newLiquidityProvision: LiquidityProvision = { id: newLiquidityProvisionId, userId: userId, contractId: contract.id, amount, - pool: newPool, - p: newP, + pool, + p, liquidity, createdTime: Date.now(), } const newTotalLiquidity = (totalLiquidity ?? 0) + amount + const newSubsidyPool = (subsidyPool ?? 0) + amount - return { newLiquidityProvision, newPool, newP, newTotalLiquidity } + return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } } diff --git a/common/badge.ts b/common/badge.ts new file mode 100644 index 00000000..c20b1f03 --- /dev/null +++ b/common/badge.ts @@ -0,0 +1,123 @@ +import { User } from './user' + +export type Badge = { + type: BadgeTypes + createdTime: number + data: { [key: string]: any } + name: 'Proven Correct' | 'Streaker' | 'Market Creator' +} + +export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR' + +export type ProvenCorrectBadgeData = { + type: 'PROVEN_CORRECT' + data: { + contractSlug: string + contractCreatorUsername: string + contractTitle: string + commentId: string + betAmount: number + } +} + +export type MarketCreatorBadgeData = { + type: 'MARKET_CREATOR' + data: { + totalContractsCreated: number + } +} + +export type StreakerBadgeData = { + type: 'STREAKER' + data: { + totalBettingStreak: number + } +} + +export type ProvenCorrectBadge = Badge & ProvenCorrectBadgeData +export type StreakerBadge = Badge & StreakerBadgeData +export type MarketCreatorBadge = Badge & MarketCreatorBadgeData + +export const MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE = 5 +export const provenCorrectRarityThresholds = [1, 1000, 10000] +const calculateProvenCorrectBadgeRarity = (badge: ProvenCorrectBadge) => { + const { betAmount } = badge.data + const thresholdArray = provenCorrectRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (betAmount >= thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const streakerBadgeRarityThresholds = [1, 50, 250] +const calculateStreakerBadgeRarity = (badge: StreakerBadge) => { + const { totalBettingStreak } = badge.data + const thresholdArray = streakerBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalBettingStreak == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export const marketCreatorBadgeRarityThresholds = [1, 75, 300] +const calculateMarketCreatorBadgeRarity = (badge: MarketCreatorBadge) => { + const { totalContractsCreated } = badge.data + const thresholdArray = marketCreatorBadgeRarityThresholds + let i = thresholdArray.length - 1 + while (i >= 0) { + if (totalContractsCreated == thresholdArray[i]) { + return i + 1 + } + i-- + } + return 1 +} + +export type rarities = 'bronze' | 'silver' | 'gold' + +const rarityRanks: { [key: number]: rarities } = { + 1: 'bronze', + 2: 'silver', + 3: 'gold', +} + +export const calculateBadgeRarity = (badge: Badge) => { + switch (badge.type) { + case 'PROVEN_CORRECT': + return rarityRanks[ + calculateProvenCorrectBadgeRarity(badge as ProvenCorrectBadge) + ] + case 'MARKET_CREATOR': + return rarityRanks[ + calculateMarketCreatorBadgeRarity(badge as MarketCreatorBadge) + ] + case 'STREAKER': + return rarityRanks[calculateStreakerBadgeRarity(badge as StreakerBadge)] + default: + return rarityRanks[0] + } +} + +export const getBadgesByRarity = (user: User | null | undefined) => { + const rarities: { [key in rarities]: number } = { + bronze: 0, + silver: 0, + gold: 0, + } + if (!user) return rarities + Object.values(user.achievements).map((value) => { + value.badges.map((badge) => { + rarities[calculateBadgeRarity(badge)] = + (rarities[calculateBadgeRarity(badge)] ?? 0) + 1 + }) + }) + return rarities +} diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index b5153355..ab8aabbe 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -1,11 +1,10 @@ -import { sum, groupBy, mapValues, sumBy } from 'lodash' +import { groupBy, mapValues, sumBy } from 'lodash' import { LimitBet } from './bet' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { LiquidityProvision } from './liquidity-provision' import { computeFills } from './new-bet' import { binarySearch } from './util/algos' -import { addObjects } from './util/object' export type CpmmState = { pool: { [outcome: string]: number } @@ -147,7 +146,8 @@ function calculateAmountToBuyShares( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { // Search for amount between bounds (0, shares). // Min share price is M$0, and max is M$1 each. @@ -157,7 +157,8 @@ function calculateAmountToBuyShares( amount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) const totalShares = sumBy(takers, (taker) => taker.shares) @@ -169,7 +170,8 @@ export function calculateCpmmSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') @@ -180,15 +182,17 @@ export function calculateCpmmSale( state, shares, oppositeOutcome, - unfilledBets + unfilledBets, + balanceByUserId ) - const { cpmmState, makers, takers, totalFees } = computeFills( + const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills( oppositeOutcome, buyAmount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) // Transform buys of opposite outcome into sells. @@ -211,6 +215,7 @@ export function calculateCpmmSale( fees: totalFees, makers, takers: saleTakers, + ordersToCancel, } } @@ -218,9 +223,16 @@ export function getCpmmProbabilityAfterSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { - const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + const { cpmmState } = calculateCpmmSale( + state, + shares, + outcome, + unfilledBets, + balanceByUserId + ) return getCpmmProbability(cpmmState.pool, cpmmState.p) } @@ -254,48 +266,22 @@ export function addCpmmLiquidity( return { newPool, liquidity, newP } } -const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => { - const oldLiquidity = getCpmmLiquidity(l.pool, p) +export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) { + const userAmounts = groupBy(liquidities, (w) => w.userId) + const totalAmount = sumBy(liquidities, (w) => w.amount) - const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount }) - const newLiquidity = getCpmmLiquidity(newPool, p) - - const liquidity = newLiquidity - oldLiquidity - return liquidity -} - -export function getCpmmLiquidityPoolWeights( - state: CpmmState, - liquidities: LiquidityProvision[], - excludeAntes: boolean -) { - const calcLiqudity = calculateLiquidityDelta(state.p) - const liquidityShares = liquidities.map(calcLiqudity) - const shareSum = sum(liquidityShares) - - const weights = liquidityShares.map((shares, i) => ({ - weight: shares / shareSum, - providerId: liquidities[i].userId, - })) - - const includedWeights = excludeAntes - ? weights.filter((_, i) => !liquidities[i].isAnte) - : weights - - const userWeights = groupBy(includedWeights, (w) => w.providerId) - const totalUserWeights = mapValues(userWeights, (userWeight) => - sumBy(userWeight, (w) => w.weight) + return mapValues( + userAmounts, + (amounts) => sumBy(amounts, (w) => w.amount) / totalAmount ) - return totalUserWeights } export function getUserLiquidityShares( userId: string, state: CpmmState, - liquidities: LiquidityProvision[], - excludeAntes: boolean + liquidities: LiquidityProvision[] ) { - const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes) + const weights = getCpmmLiquidityPoolWeights(liquidities) const userWeight = weights[userId] ?? 0 return mapValues(state.pool, (shares) => userWeight * shares) diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 7c2153c1..2c544217 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,9 +1,17 @@ -import { last, sortBy, sum, sumBy } from 'lodash' -import { calculatePayout } from './calculate' -import { Bet } from './bet' -import { Contract } from './contract' +import { Dictionary, groupBy, last, sum, sumBy, uniq } from 'lodash' +import { calculatePayout, getContractBetMetrics } from './calculate' +import { Bet, LimitBet } from './bet' +import { + Contract, + CPMMBinaryContract, + CPMMContract, + DPMContract, +} from './contract' import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' +import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' +import { getCpmmProbability } from './calculate-cpmm' +import { removeUndefinedProps } from './util/object' const computeInvestmentValue = ( bets: Bet[], @@ -33,13 +41,81 @@ export const computeInvestmentValueCustomProb = ( const betP = outcome === 'YES' ? p : 1 - p - const payout = betP * shares - const value = payout - (bet.loanAmount ?? 0) + const value = betP * shares if (isNaN(value)) return 0 return value }) } +export const computeElasticity = ( + bets: Bet[], + contract: Contract, + betAmount = 50 +) => { + const { mechanism, outcomeType } = contract + return mechanism === 'cpmm-1' && + (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') + ? computeBinaryCpmmElasticity(bets, contract, betAmount) + : computeDpmElasticity(contract, betAmount) +} + +export const computeBinaryCpmmElasticity = ( + bets: Bet[], + contract: CPMMContract, + betAmount: number +) => { + const limitBets = bets + .filter( + (b) => + !b.isFilled && + !b.isSold && + !b.isRedemption && + !b.sale && + !b.isCancelled && + b.limitProb !== undefined + ) + .sort((a, b) => a.createdTime - b.createdTime) as LimitBet[] + + const userIds = uniq(limitBets.map((b) => b.userId)) + // Assume all limit orders are good. + const userBalances = Object.fromEntries( + userIds.map((id) => [id, Number.MAX_SAFE_INTEGER]) + ) + + const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo( + 'YES', + betAmount, + contract, + undefined, + limitBets, + userBalances + ) + const resultYes = getCpmmProbability(poolY, pY) + + const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo( + 'NO', + betAmount, + contract, + undefined, + limitBets, + userBalances + ) + const resultNo = getCpmmProbability(poolN, pN) + + // handle AMM overflow + const safeYes = Number.isFinite(resultYes) ? resultYes : 1 + const safeNo = Number.isFinite(resultNo) ? resultNo : 0 + + return safeYes - safeNo +} + +export const computeDpmElasticity = ( + contract: DPMContract, + betAmount: number +) => { + return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter +} + const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const periodFilteredContracts = userContracts.filter( (contract) => contract.createdTime >= startTime @@ -123,14 +199,9 @@ export const calculateNewPortfolioMetrics = ( } const calculateProfitForPeriod = ( - startTime: number, - descendingPortfolio: PortfolioMetrics[], + startingPortfolio: PortfolioMetrics | undefined, currentProfit: number ) => { - const startingPortfolio = descendingPortfolio.find( - (p) => p.timestamp < startTime - ) - if (startingPortfolio === undefined) { return currentProfit } @@ -145,33 +216,90 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { } export const calculateNewProfit = ( - portfolioHistory: PortfolioMetrics[], + portfolioHistory: Record< + 'current' | 'day' | 'week' | 'month', + PortfolioMetrics | undefined + >, newPortfolio: PortfolioMetrics ) => { const allTimeProfit = calculatePortfolioProfit(newPortfolio) - const descendingPortfolio = sortBy( - portfolioHistory, - (p) => p.timestamp - ).reverse() const newProfit = { - daily: calculateProfitForPeriod( - Date.now() - 1 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - weekly: calculateProfitForPeriod( - Date.now() - 7 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), - monthly: calculateProfitForPeriod( - Date.now() - 30 * DAY_MS, - descendingPortfolio, - allTimeProfit - ), + daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit), + weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit), + monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit), allTime: allTimeProfit, } return newProfit } + +export const calculateMetricsByContract = ( + bets: Bet[], + contractsById: Dictionary +) => { + const betsByContract = groupBy(bets, (bet) => bet.contractId) + const unresolvedContracts = Object.keys(betsByContract) + .map((cid) => contractsById[cid]) + .filter((c) => c && !c.isResolved) + + return unresolvedContracts.map((c) => { + const bets = betsByContract[c.id] ?? [] + const current = getContractBetMetrics(c, bets) + + let periodMetrics + if (c.mechanism === 'cpmm-1' && c.outcomeType === 'BINARY') { + const periods = ['day', 'week', 'month'] as const + periodMetrics = Object.fromEntries( + periods.map((period) => [ + period, + calculatePeriodProfit(c, bets, period), + ]) + ) + } + + return removeUndefinedProps({ + contractId: c.id, + ...current, + from: periodMetrics, + }) + }) +} + +export type ContractMetrics = ReturnType[number] + +const calculatePeriodProfit = ( + contract: CPMMBinaryContract, + bets: Bet[], + period: 'day' | 'week' | 'month' +) => { + const days = period === 'day' ? 1 : period === 'week' ? 7 : 30 + const fromTime = Date.now() - days * DAY_MS + const previousBets = bets.filter((b) => b.createdTime < fromTime) + + const prevProb = contract.prob - contract.probChanges[period] + const prob = contract.resolutionProbability + ? contract.resolutionProbability + : contract.prob + + const previousBetsValue = computeInvestmentValueCustomProb( + previousBets, + contract, + prevProb + ) + const currentBetsValue = computeInvestmentValueCustomProb( + previousBets, + contract, + prob + ) + const profit = currentBetsValue - previousBetsValue + const profitPercent = + previousBetsValue === 0 ? 0 : 100 * (profit / previousBetsValue) + + return { + profit, + profitPercent, + prevValue: previousBetsValue, + value: currentBetsValue, + } +} diff --git a/common/calculate.ts b/common/calculate.ts index 5edf1211..c3461cb6 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -78,7 +78,8 @@ export function calculateShares( export function calculateSaleAmount( contract: Contract, bet: Bet, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' && (contract.outcomeType === 'BINARY' || @@ -87,7 +88,8 @@ export function calculateSaleAmount( contract, Math.abs(bet.shares), bet.outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -102,14 +104,16 @@ export function getProbabilityAfterSale( contract: Contract, outcome: string, shares: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' ? getCpmmProbabilityAfterSale( contract, shares, outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) } @@ -174,6 +178,8 @@ function getDpmInvested(yourBets: Bet[]) { }) } +export type ContractBetMetrics = ReturnType + export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const { resolution } = contract const isCpmm = contract.mechanism === 'cpmm-1' @@ -210,9 +216,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { } } - const netPayout = payout - loan const profit = payout + saleValue + redeemed - totalInvested - const profitPercent = (profit / totalInvested) * 100 + const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100 const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const hasShares = Object.values(totalShares).some( @@ -221,8 +226,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { return { invested, + loan, payout, - netPayout, profit, profitPercent, totalShares, @@ -233,8 +238,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { export function getContractBetNullMetrics() { return { invested: 0, + loan: 0, payout: 0, - netPayout: 0, profit: 0, profitPercent: 0, totalShares: {} as { [outcome: string]: number }, diff --git a/common/charity.ts b/common/charity.ts index fd5abc36..0ebeeec1 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -576,7 +576,7 @@ Work towards sustainable, systemic change.`, If you would like to support our work, you can do so by getting involved or by donating.`, }, - { + { name: 'CaRLA', website: 'https://carlaef.org/', photo: 'https://i.imgur.com/IsNVTOY.png', @@ -589,6 +589,14 @@ CaRLA uses legal advocacy and education to ensure all cities comply with their o In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`, }, + { + name: 'Mriya', + website: 'https://mriya-ua.org/', + photo: + 'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637', + preview: 'Donate supplies to soldiers in Ukraine', + description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.', + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/contract-details.ts b/common/contract-details.ts index c231b1e4..53a3ed97 100644 --- a/common/contract-details.ts +++ b/common/contract-details.ts @@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) { const { closeTime, groupLinks } = contract const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`) + const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`) return ( `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + diff --git a/common/contract.ts b/common/contract.ts index 2e9d94c4..dd2aa70a 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -49,6 +49,7 @@ export type Contract = { volume: number volume24Hours: number volume7Days: number + elasticity: number collectedFees: Fees @@ -62,7 +63,9 @@ export type Contract = { featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + flaggedByUsernames?: string[] openCommentBounties?: number + unlistedById?: string } & T export type BinaryContract = Contract & Binary @@ -88,7 +91,8 @@ export type CPMM = { mechanism: 'cpmm-1' pool: { [outcome: string]: number } p: number // probability constant in y^p * n^(1-p) = k - totalLiquidity: number // in M$ + totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$ + subsidyPool: number // current value of subsidy pool in M$ prob: number probChanges: { day: number diff --git a/common/economy.ts b/common/economy.ts index d25a0c71..c828b0d3 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -11,8 +11,10 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_AMOUNT = - econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 -export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 + econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5 +export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 + +export const UNIQUE_BETTOR_LIQUIDITY = 20 diff --git a/common/fees.ts b/common/fees.ts index f944933c..7421ef54 100644 --- a/common/fees.ts +++ b/common/fees.ts @@ -1,3 +1,5 @@ +export const FLAT_TRADE_FEE = 0.1 // M$0.1 + export const PLATFORM_FEE = 0 export const CREATOR_FEE = 0 export const LIQUIDITY_FEE = 0 diff --git a/common/group.ts b/common/group.ts index 8f5728d3..cb6660e8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -39,3 +39,4 @@ export type GroupLink = { createdTime: number userId?: string } +export type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/common/new-bet.ts b/common/new-bet.ts index 91faf640..8057cd5b 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -17,8 +17,7 @@ import { import { CPMMBinaryContract, DPMBinaryContract, - FreeResponseContract, - MultipleChoiceContract, + DPMContract, NumericContract, PseudoNumericContract, } from './contract' @@ -144,7 +143,8 @@ export const computeFills = ( betAmount: number, state: CpmmState, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { if (isNaN(betAmount)) { throw new Error('Invalid bet amount: ${betAmount}') @@ -166,10 +166,12 @@ export const computeFills = ( shares: number timestamp: number }[] = [] + const ordersToCancel: LimitBet[] = [] let amount = betAmount let cpmmState = { pool: state.pool, p: state.p } let totalFees = noFees + const currentBalanceByUserId = { ...balanceByUserId } let i = 0 while (true) { @@ -186,9 +188,20 @@ export const computeFills = ( takers.push(taker) } else { // Matched against bet. + i++ + const { userId } = maker.bet + const makerBalance = currentBalanceByUserId[userId] + + if (floatingGreaterEqual(makerBalance, maker.amount)) { + currentBalanceByUserId[userId] = makerBalance - maker.amount + } else { + // Insufficient balance. Cancel maker bet. + ordersToCancel.push(maker.bet) + continue + } + takers.push(taker) makers.push(maker) - i++ } amount -= taker.amount @@ -196,7 +209,7 @@ export const computeFills = ( if (floatingEqual(amount, 0)) break } - return { takers, makers, totalFees, cpmmState } + return { takers, makers, totalFees, cpmmState, ordersToCancel } } export const getBinaryCpmmBetInfo = ( @@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { pool, p } = contract - const { takers, makers, cpmmState, totalFees } = computeFills( + const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills( outcome, betAmount, { pool, p }, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) const probBefore = getCpmmProbability(contract.pool, contract.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) @@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = ( newP: cpmmState.p, newTotalLiquidity, makers, + ordersToCancel, } } @@ -255,14 +271,16 @@ export const getBinaryBetStats = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { newBet } = getBinaryCpmmBetInfo( outcome, betAmount ?? 0, contract, limitProb, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const remainingMatched = ((newBet.orderAmount ?? 0) - newBet.amount) / @@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract | MultipleChoiceContract + contract: DPMContract ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/new-contract.ts b/common/new-contract.ts index 431f435e..6a7f57da 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -12,7 +12,6 @@ import { visibility, } from './contract' import { User } from './user' -import { parseTags, richTextToString } from './util/parse' import { removeUndefinedProps } from './util/object' import { JSONContent } from '@tiptap/core' @@ -38,15 +37,6 @@ export function getNewContract( answers: string[], visibility: visibility ) { - const tags = parseTags( - [ - question, - richTextToString(description), - ...extraTags.map((tag) => `#${tag}`), - ].join(' ') - ) - const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) @@ -70,9 +60,10 @@ export function getNewContract( question: question.trim(), description, - tags, - lowercaseTags, + tags: [], + lowercaseTags: [], visibility, + unlistedById: visibility === 'unlisted' ? creator.id : undefined, isResolved: false, createdTime: Date.now(), closeTime, @@ -80,6 +71,7 @@ export function getNewContract( volume: 0, volume24Hours: 0, volume7Days: 0, + elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75, collectedFees: { creatorFee: 0, @@ -120,6 +112,7 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { mechanism: 'cpmm-1', outcomeType: 'BINARY', totalLiquidity: ante, + subsidyPool: 0, initialProbability: p, p, pool: pool, diff --git a/common/notification.ts b/common/notification.ts index d91dc300..436393a5 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -4,7 +4,7 @@ export type Notification = { id: string userId: string reasonText?: string - reason?: notification_reason_types + reason?: notification_reason_types | notification_preference createdTime: number viewTime?: number isSeen: boolean @@ -46,6 +46,7 @@ export type notification_source_types = | 'loan' | 'like' | 'tip_and_like' + | 'badge' export type notification_source_update_types = | 'created' @@ -96,6 +97,7 @@ type notification_descriptions = { [key in notification_preference]: { simple: string detailed: string + necessary?: boolean } } export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { @@ -208,8 +210,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Bonuses for unique predictors on your markets', }, your_contract_closed: { - simple: 'Your market has closed and you need to resolve it', - detailed: 'Your market has closed and you need to resolve it', + simple: 'Your market has closed and you need to resolve it (necessary)', + detailed: 'Your market has closed and you need to resolve it (necessary)', + necessary: true, }, all_comments_on_watched_markets: { simple: 'All new comments', @@ -235,6 +238,15 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { simple: `Only on markets you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`, }, + badges_awarded: { + simple: 'New badges awarded', + detailed: 'New badges you have earned', + }, + opt_out_all: { + simple: 'Opt out of all notifications (excludes when your markets close)', + detailed: + 'Opt out of all notifications excluding your own market closure notifications', + }, } export type BettingStreakData = { diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts index 99e03fac..74e9fe16 100644 --- a/common/payouts-fixed.ts +++ b/common/payouts-fixed.ts @@ -1,4 +1,3 @@ - import { Bet } from './bet' import { getProbability } from './calculate' import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' @@ -56,10 +55,10 @@ export const getLiquidityPoolPayouts = ( outcome: string, liquidities: LiquidityProvision[] ) => { - const { pool } = contract - const finalPool = pool[outcome] + const { pool, subsidyPool } = contract + const finalPool = pool[outcome] + subsidyPool - const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) + const weights = getCpmmLiquidityPoolWeights(liquidities) return Object.entries(weights).map(([providerId, weight]) => ({ userId: providerId, @@ -95,10 +94,10 @@ export const getLiquidityPoolProbPayouts = ( p: number, liquidities: LiquidityProvision[] ) => { - const { pool } = contract - const finalPool = p * pool.YES + (1 - p) * pool.NO + const { pool, subsidyPool } = contract + const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool - const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false) + const weights = getCpmmLiquidityPoolWeights(liquidities) return Object.entries(weights).map(([providerId, weight]) => ({ userId: providerId, diff --git a/common/post.ts b/common/post.ts index 45503b22..77130a2c 100644 --- a/common/post.ts +++ b/common/post.ts @@ -3,6 +3,7 @@ import { JSONContent } from '@tiptap/core' export type Post = { id: string title: string + subtitle: string content: JSONContent creatorId: string // User id createdTime: number @@ -17,3 +18,4 @@ export type DateDoc = Post & { } export const MAX_POST_TITLE_LENGTH = 480 +export const MAX_POST_SUBTITLE_LENGTH = 480 diff --git a/common/scoring.ts b/common/scoring.ts index 4ef46534..a8f62631 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -1,8 +1,9 @@ -import { groupBy, sumBy, mapValues } from 'lodash' +import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash' import { Bet } from './bet' -import { getContractBetMetrics } from './calculate' +import { getContractBetMetrics, resolvedPayout } from './calculate' import { Contract } from './contract' +import { ContractComment } from './comment' export function scoreCreators(contracts: Contract[]) { const creatorScore = mapValues( @@ -30,8 +31,11 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) { } export function scoreUsersByContract(contract: Contract, bets: Bet[]) { - const betsByUser = groupBy(bets, bet => bet.userId) - return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit) + const betsByUser = groupBy(bets, (bet) => bet.userId) + return mapValues( + betsByUser, + (bets) => getContractBetMetrics(contract, bets).profit + ) } export function addUserScores( @@ -43,3 +47,47 @@ export function addUserScores( dest[userId] += score } } + +export function scoreCommentorsAndBettors( + contract: Contract, + bets: Bet[], + comments: ContractComment[] +) { + const commentsById = keyBy(comments, 'id') + const betsById = keyBy(bets, 'id') + + // If 'id2' is the sale of 'id1', both are logged with (id2 - id1) of profit + // Otherwise, we record the profit at resolution time + const profitById: Record = {} + for (const bet of bets) { + if (bet.sale) { + const originalBet = betsById[bet.sale.betId] + const profit = bet.sale.amount - originalBet.amount + profitById[bet.id] = profit + profitById[originalBet.id] = profit + } else { + profitById[bet.id] = resolvedPayout(contract, bet) - bet.amount + } + } + + // Now find the betId with the highest profit + const topBetId = sortBy(bets, (b) => -profitById[b.id])[0]?.id + const topBettor = betsById[topBetId]?.userName + + // And also the commentId of the comment with the highest profit + const topCommentId = sortBy( + comments, + (c) => c.betId && -profitById[c.betId] + )[0]?.id + const topCommentBetId = commentsById[topCommentId]?.betId + + return { + topCommentId, + topBetId, + topBettor, + profitById, + commentsById, + betsById, + topCommentBetId, + } +} diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 96636ca0..1b56c819 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = ( outcome: 'YES' | 'NO', contract: CPMMContract, unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number }, loanPaid: number ) => { const { pool, p } = contract - const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( + const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale( contract, shares, outcome, - unfilledBets + unfilledBets, + balanceByUserId, ) const probBefore = getCpmmProbability(pool, p) @@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = ( fees, makers, takers, + ordersToCancel } } diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index 3fc0fb2f..6b5a448d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -53,6 +53,9 @@ export type notification_preferences = { profit_loss_updates: notification_destination_types[] onboarding_flow: notification_destination_types[] thank_you_for_purchases: notification_destination_types[] + badges_awarded: notification_destination_types[] + opt_out_all: notification_destination_types[] + // When adding a new notification preference, use add-new-notification-preference.ts to existing users } export const getDefaultNotificationPreferences = ( @@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = ( const email = noEmails ? undefined : emailIf ? 'email' : undefined return filterDefined([browser, email]) as notification_destination_types[] } - return { + const defaults: notification_preferences = { // Watched Markets all_comments_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false), @@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = ( loan_income: constructPref(true, false), betting_streaks: constructPref(true, false), referral_bonuses: constructPref(true, true), - unique_bettors_on_your_contract: constructPref(true, false), + unique_bettors_on_your_contract: constructPref(true, true), tipped_comments_on_watched_markets: constructPref(true, true), tips_on_your_markets: constructPref(true, true), limit_order_fills: constructPref(true, false), @@ -121,7 +124,11 @@ export const getDefaultNotificationPreferences = ( probability_updates_on_watched_markets: constructPref(true, false), thank_you_for_purchases: constructPref(false, false), onboarding_flow: constructPref(false, false), - } as notification_preferences + + opt_out_all: [], + badges_awarded: constructPref(true, false), + } + return defaults } // Adding a new key:value here is optional, you can just use a key of notification_subscription_types @@ -172,23 +179,44 @@ export const getNotificationDestinationsForUser = ( reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences - let destinations - let subscriptionType: notification_preference | undefined - if (Object.keys(notificationSettings).includes(reason)) { - subscriptionType = reason as notification_preference - destinations = notificationSettings[subscriptionType] - } else { - const key = reason as notification_reason_types - subscriptionType = notificationReasonToSubscriptionType[key] - destinations = subscriptionType - ? notificationSettings[subscriptionType] - : [] - } const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - return { - sendToEmail: destinations.includes('email'), - sendToBrowser: destinations.includes('browser'), - unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, - urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + try { + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' + return { + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } + } catch (e) { + // Fail safely + console.log( + `couldn't get notification destinations for type ${reason} for user ${privateUser.id}` + ) + return { + sendToEmail: false, + sendToBrowser: false, + unsubscribeUrl: '', + urlToManageThisNotification: '', + } } } diff --git a/common/user.ts b/common/user.ts index b1365929..f89223d2 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,5 +1,6 @@ import { notification_preferences } from './user-notification-preferences' -import { ENV_CONFIG } from 'common/envs/constants' +import { ENV_CONFIG } from './envs/constants' +import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge' export type User = { id: string @@ -11,7 +12,6 @@ export type User = { // For their user page bio?: string - bannerUrl?: string website?: string twitterHandle?: string discordHandle?: string @@ -33,6 +33,8 @@ export type User = { allTime: number } + fractionResolvedCorrectly: number + nextLoanCached: number followerCountCached: number @@ -49,6 +51,18 @@ export type User = { hasSeenContractFollowModal?: boolean freeMarketsCreated?: number isBannedFromPosting?: boolean + + achievements: { + provenCorrect?: { + badges: ProvenCorrectBadge[] + } + marketCreator?: { + badges: MarketCreatorBadge[] + } + streaker?: { + badges: StreakerBadge[] + } + } } export type PrivateUser = { @@ -79,7 +93,8 @@ export type PortfolioMetrics = { userId: string } -export const MANIFOLD_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets' +export const MANIFOLD_USER_NAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' // TODO: remove. Hardcoding the strings would be better. diff --git a/common/util/color.ts b/common/util/color.ts new file mode 100644 index 00000000..fb15cc6a --- /dev/null +++ b/common/util/color.ts @@ -0,0 +1,24 @@ +export const interpolateColor = (color1: string, color2: string, p: number) => { + const rgb1 = parseInt(color1.replace('#', ''), 16) + const rgb2 = parseInt(color2.replace('#', ''), 16) + + const [r1, g1, b1] = toArray(rgb1) + const [r2, g2, b2] = toArray(rgb2) + + const q = 1 - p + const rr = Math.round(r1 * q + r2 * p) + const rg = Math.round(g1 * q + g2 * p) + const rb = Math.round(b1 * q + b2 * p) + + const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16) + const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}` + return hex +} + +function toArray(rgb: number) { + const r = rgb >> 16 + const g = (rgb >> 8) % 256 + const b = rgb % 256 + + return [r, g, b] +} diff --git a/common/util/format.ts b/common/util/format.ts index ee59d3e7..ce7849aa 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -13,7 +13,9 @@ export function formatMoney(amount: number) { Math.round(amount) === 0 ? 0 : // Handle 499.9999999999999 case - Math.floor(amount + 0.00000000001 * Math.sign(amount)) + (amount > 0 ? Math.floor : Math.ceil)( + amount + 0.00000000001 * Math.sign(amount) + ) return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') } @@ -58,6 +60,16 @@ export function formatLargeNumber(num: number, sigfigs = 2): string { return `${numStr}${suffix[i] ?? ''}` } +export function shortFormatNumber(num: number): string { + if (num < 1000) return showPrecision(num, 3) + + const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] + const i = Math.floor(Math.log10(num) / 3) + + const numStr = showPrecision(num / Math.pow(10, 3 * i), 2) + return `${numStr}${suffix[i] ?? ''}` +} + export function toCamelCase(words: string) { const camelCase = words .split(' ') diff --git a/common/util/parse.ts b/common/util/parse.ts index 72ceaf15..53874c9e 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,3 @@ -import { MAX_TAG_LENGTH } from '../contract' import { generateText, JSONContent } from '@tiptap/core' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' @@ -24,7 +23,7 @@ import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' -import { uniq } from 'lodash' +import { cloneDeep, uniq } from 'lodash' import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ @@ -33,34 +32,6 @@ export function getUrl(text: string) { return results.length ? results[0].href : null } -export function parseTags(text: string) { - const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi - const matches = (text.match(regex) || []).map((match) => - match.trim().substring(1).substring(0, MAX_TAG_LENGTH) - ) - const tagSet = new Set() - const uniqueTags: string[] = [] - // Keep casing of last tag. - matches.reverse() - for (const tag of matches) { - const lowercase = tag.toLowerCase() - if (!tagSet.has(lowercase)) { - tagSet.add(lowercase) - uniqueTags.push(tag) - } - } - uniqueTags.reverse() - return uniqueTags -} - -export function parseWordsAsTags(text: string) { - const taggedText = text - .split(/\s+/) - .map((tag) => (tag.startsWith('#') ? tag : `#${tag}`)) - .join(' ') - return parseTags(taggedText) -} - // TODO: fuzzy matching export const wordIn = (word: string, corpus: string) => corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) @@ -81,7 +52,7 @@ export function parseMentions(data: JSONContent): string[] { } // can't just do [StarterKit, Image...] because it doesn't work with cjs imports -export const exhibitExts = [ +const stringParseExts = [ Blockquote, Bold, BulletList, @@ -101,12 +72,35 @@ export const exhibitExts = [ Image, Link, - Mention, + Mention, // user @mention + Mention.extend({ name: 'contract-mention' }), // market %mention Iframe, TiptapTweet, TiptapSpoiler, ] export function richTextToString(text?: JSONContent) { - return !text ? '' : generateText(text, exhibitExts) + if (!text) return '' + // remove spoiler tags. + const newText = cloneDeep(text) + dfs(newText, (current) => { + if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) { + current.text = '[spoiler]' + } else if (current.type === 'image') { + current.text = '[Image]' + // This is a hack, I've no idea how to change a tiptap extenstion's schema + current.type = 'text' + } else if (current.type === 'iframe') { + const src = current.attrs?.['src'] ? current.attrs['src'] : '' + current.text = '[Iframe]' + (src ? ` url:${src}` : '') + // This is a hack, I've no idea how to change a tiptap extenstion's schema + current.type = 'text' + } + }) + return generateText(newText, stringParseExts) +} + +const dfs = (data: JSONContent, f: (current: JSONContent) => any) => { + data.content?.forEach((d) => dfs(d, f)) + f(data) } diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts index 5502da58..c8944a46 100644 --- a/common/util/tiptap-spoiler.ts +++ b/common/util/tiptap-spoiler.ts @@ -39,7 +39,7 @@ export const TiptapSpoiler = Mark.create({ exitable: true, content: 'inline*', - priority: 200, // higher priority than other formatting so they go inside + priority: 1001, // higher priority than other formatting so they go inside addOptions() { return { diff --git a/docs/docs/bounties.md b/docs/docs/bounties.md index ba4e865b..48b04dc1 100644 --- a/docs/docs/bounties.md +++ b/docs/docs/bounties.md @@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar ## Awarded bounties +💥 *Awarded on 2022-10-07* + +**[Pepe](https://manifold.markets/Pepe): M$10,000** +**[Jack](https://manifold.markets/jack): M$2,000** +**[Martin](https://manifold.markets/MartinRandall): M$2,000** +**[Yev](https://manifold.markets/Yev): M$2,000** +**[Michael](https://manifold.markets/MichaelWheatley): M$2,000** + +- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately. + +**[Matt](https://manifold.markets/MattP): M$5,000** +**[Adrian](https://manifold.markets/ahalekelly): M$5,000** +**[Yev](https://manifold.markets/Yev): M$5,000** + +- For discovering an AMM liquidity exploit and informing the Manifold team of it privately. + 🎈 *Awarded on 2022-06-14* **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** diff --git a/firestore.rules b/firestore.rules index bf0375e6..993791b2 100644 --- a/firestore.rules +++ b/firestore.rules @@ -27,7 +27,7 @@ service cloud.firestore { allow read; allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); + .hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); // User referral rules allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() @@ -44,6 +44,10 @@ service cloud.firestore { allow read; } + match /{somePath=**}/contract-metrics/{contractId} { + allow read; + } + match /{somePath=**}/challenges/{challengeId}{ allow read; } @@ -102,7 +106,7 @@ service cloud.firestore { allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime', 'question']) + .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { diff --git a/functions/.env.dev b/functions/.env.dev new file mode 100644 index 00000000..b5aae225 --- /dev/null +++ b/functions/.env.dev @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=DEV diff --git a/functions/.env b/functions/.env.prod similarity index 100% rename from functions/.env rename to functions/.env.prod diff --git a/functions/package.json b/functions/package.json index 0397c5db..cd2a9ec5 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "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 && cp .env 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.prod dist && cp .env.dev dist", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", diff --git a/functions/src/add-liquidity.ts b/functions/src/add-subsidy.ts similarity index 55% rename from functions/src/add-liquidity.ts rename to functions/src/add-subsidy.ts index e6090111..b3ed1895 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-subsidy.ts @@ -3,24 +3,18 @@ import { z } from 'zod' import { Contract, CPMMContract } from '../../common/contract' import { User } from '../../common/user' -import { removeUndefinedProps } from '../../common/util/object' import { getNewLiquidityProvision } from '../../common/add-liquidity' import { APIError, newEndpoint, validate } from './api' -import { - DEV_HOUSE_LIQUIDITY_PROVIDER_ID, - HOUSE_LIQUIDITY_PROVIDER_ID, -} from '../../common/antes' -import { isProd } from './utils' const bodySchema = z.object({ contractId: z.string(), amount: z.number().gt(0), }) -export const addliquidity = newEndpoint({}, async (req, auth) => { +export const addsubsidy = newEndpoint({}, async (req, auth) => { const { amount, contractId } = validate(bodySchema, req.body) - if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount') // run as transaction to prevent race conditions return await firestore.runTransaction(async (transaction) => { @@ -50,7 +44,7 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { .collection(`contracts/${contractId}/liquidity`) .doc() - const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } = getNewLiquidityProvision( user.id, amount, @@ -58,21 +52,10 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { newLiquidityProvisionDoc.id ) - if (newP !== undefined && !isFinite(newP)) { - return { - status: 'error', - message: 'Liquidity injection rejected due to overflow error.', - } - } - - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalLiquidity: newTotalLiquidity, - }) - ) + transaction.update(contractDoc, { + subsidyPool: newSubsidyPool, + totalLiquidity: newTotalLiquidity, + } as Partial) const newBalance = user.balance - amount const newTotalDeposits = user.totalDeposits - amount @@ -93,41 +76,3 @@ export const addliquidity = newEndpoint({}, async (req, auth) => { }) const firestore = admin.firestore() - -export const addHouseLiquidity = (contract: CPMMContract, amount: number) => { - return firestore.runTransaction(async (transaction) => { - const newLiquidityProvisionDoc = firestore - .collection(`contracts/${contract.id}/liquidity`) - .doc() - - const providerId = isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - - const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = - getNewLiquidityProvision( - providerId, - amount, - contract, - newLiquidityProvisionDoc.id - ) - - if (newP !== undefined && !isFinite(newP)) { - throw new APIError( - 500, - 'Liquidity injection rejected due to overflow error.' - ) - } - - transaction.update( - firestore.doc(`contracts/${contract.id}`), - removeUndefinedProps({ - pool: newPool, - p: newP, - totalLiquidity: newTotalLiquidity, - }) - ) - - transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) - }) -} diff --git a/functions/src/api.ts b/functions/src/api.ts index 7134c8d8..24677567 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { }, } as EndpointDefinition } + +export const newEndpointNoAuth = ( + endpointOpts: EndpointOptions, + fn: (req: Request) => Promise +) => { + const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts) + return { + opts, + handler: async (req: Request, res: Response) => { + log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`) + try { + if (opts.method !== req.method) { + throw new APIError(405, `This endpoint supports only ${opts.method}.`) + } + res.status(200).json(await fn(req)) + } catch (e) { + writeResponseError(e, res) + } + }, + } as EndpointDefinition +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 9bd73d05..204105ac 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -6,7 +6,13 @@ import { Notification, notification_reason_types, } from '../../common/notification' -import { User } from '../../common/user' +import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USER_NAME, + MANIFOLD_USER_USERNAME, + PrivateUser, + User, +} from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' @@ -30,27 +36,26 @@ import { import { filterDefined } from '../../common/util/array' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { ContractFollow } from '../../common/follow' +import { Badge } from 'common/badge' const firestore = admin.firestore() type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } -export const createNotification = async ( +export const createFollowOrMarketSubsidizedNotification = async ( sourceId: string, - sourceType: 'contract' | 'liquidity' | 'follow', - sourceUpdateType: 'closed' | 'created', + sourceType: 'liquidity' | 'follow', + sourceUpdateType: 'created', sourceUser: User, idempotencyKey: string, sourceText: string, miscData?: { contract?: Contract recipients?: string[] - slug?: string - title?: string } ) => { - const { contract: sourceContract, recipients, slug, title } = miscData ?? {} + const { contract: sourceContract, recipients } = miscData ?? {} const shouldReceiveNotification = ( userId: string, @@ -94,23 +99,15 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceSlug: slug ? slug : sourceContract?.slug, - sourceTitle: title ? title : sourceContract?.question, + sourceSlug: sourceContract?.slug, + sourceTitle: sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) } if (!sendToEmail) continue - if (reason === 'your_contract_closed' && privateUser && sourceContract) { - // TODO: include number and names of bettors waiting for creator to resolve their market - await sendMarketCloseEmail( - reason, - sourceUser, - privateUser, - sourceContract - ) - } else if (reason === 'subsidized_your_market') { + if (reason === 'subsidized_your_market') { // TODO: send email to creator of market that was subsidized } else if (reason === 'on_new_follow') { // TODO: send email to user who was followed @@ -127,20 +124,7 @@ export const createNotification = async ( reason: 'on_new_follow', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'closed' && - sourceContract - ) { - userToReasonTexts[sourceContract.creatorId] = { - reason: 'your_contract_closed', - } - return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'liquidity' && - sourceUpdateType === 'created' && - sourceContract - ) { + } else if (sourceType === 'liquidity' && sourceContract) { if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) userToReasonTexts[sourceContract.creatorId] = { reason: 'subsidized_your_market', @@ -1087,6 +1071,81 @@ export const createBountyNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) - - // maybe TODO: send email notification to comment creator +} + +export const createBadgeAwardedNotification = async ( + user: User, + badge: Badge +) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'badges_awarded' + ) + if (!sendToBrowser) return + + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: user.id, + reason: 'badges_awarded', + createdTime: Date.now(), + isSeen: false, + sourceId: badge.type, + sourceType: 'badge', + sourceUpdateType: 'created', + sourceUserName: MANIFOLD_USER_NAME, + sourceUserUsername: MANIFOLD_USER_USERNAME, + sourceUserAvatarUrl: MANIFOLD_AVATAR_URL, + sourceText: `You earned a new ${badge.name} badge!`, + sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`, + sourceTitle: badge.name, + data: { + badge, + }, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification +} + +export const createMarketClosedNotification = async ( + contract: Contract, + creator: User, + privateUser: PrivateUser, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${creator.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: creator.id, + reason: 'your_contract_closed', + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'closed', + sourceContractId: contract?.id, + sourceUserName: creator.name, + sourceUserUsername: creator.username, + sourceUserAvatarUrl: creator.avatarUrl, + sourceText: contract.closeTime?.toString() ?? new Date().toString(), + sourceContractCreatorUsername: creator.username, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: contract.slug, + sourceTitle: contract.question, + } + await notificationRef.set(removeUndefinedProps(notification)) + await sendMarketCloseEmail( + 'your_contract_closed', + creator, + privateUser, + contract + ) } diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index e9d6ae8f..d1864ac2 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -3,7 +3,11 @@ import * as admin from 'firebase-admin' import { getUser } from './utils' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' +import { + Post, + MAX_POST_TITLE_LENGTH, + MAX_POST_SUBTITLE_LENGTH, +} from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' import { z } from 'zod' @@ -36,6 +40,7 @@ const contentSchema: z.ZodType = z.lazy(() => const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), + subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH), content: contentSchema, groupId: z.string().optional(), @@ -48,10 +53,8 @@ const postSchema = z.object({ export const createpost = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() - const { title, content, groupId, question, ...otherProps } = validate( - postSchema, - req.body - ) + const { title, subtitle, content, groupId, question, ...otherProps } = + validate(postSchema, req.body) const creator = await getUser(auth.uid) if (!creator) @@ -68,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => { if (question) { const closeTime = Date.now() + DAY_MS * 30 * 3 - const result = await createMarketHelper( - { - question, - closeTime, - outcomeType: 'BINARY', - visibility: 'unlisted', - initialProb: 50, - // Dating group! - groupId: 'j3ZE8fkeqiKmRGumy3O1', - }, - auth - ) - contractSlug = result.slug + try { + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + auth + ) + contractSlug = result.slug + } catch (e) { + console.error(e) + } } const post: Post = removeUndefinedProps({ @@ -89,6 +96,7 @@ export const createpost = newEndpoint({}, async (req, auth) => { creatorId: creator.id, slug, title, + subtitle, createdTime: Date.now(), content: content, contractSlug, diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab70b4e6..d22b8a2e 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -69,6 +69,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, + fractionResolvedCorrectly: 1, + achievements: {}, } await firestore.collection('users').doc(auth.uid).create(user) diff --git a/functions/src/drizzle-liquidity.ts b/functions/src/drizzle-liquidity.ts new file mode 100644 index 00000000..7757dee0 --- /dev/null +++ b/functions/src/drizzle-liquidity.ts @@ -0,0 +1,69 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { CPMMContract } from '../../common/contract' +import { batchedWaitAll } from '../../common/util/promise' +import { APIError } from '../../common/api' +import { addCpmmLiquidity } from '../../common/calculate-cpmm' +import { formatMoneyWithDecimals } from '../../common/util/format' + +const firestore = admin.firestore() + +export const drizzleLiquidity = async () => { + const snap = await firestore + .collection('contracts') + .where('subsidyPool', '>', 1e-7) + .get() + + const contractIds = snap.docs.map((doc) => doc.id) + console.log('found', contractIds.length, 'markets to drizzle') + console.log() + + await batchedWaitAll( + contractIds.map((cid) => () => drizzleMarket(cid)), + 10 + ) +} + +export const drizzleLiquidityScheduler = functions.pubsub + .schedule('* * * * *') // every minute + .onRun(drizzleLiquidity) + +const drizzleMarket = async (contractId: string) => { + await firestore.runTransaction(async (trans) => { + const snap = await trans.get(firestore.doc(`contracts/${contractId}`)) + const contract = snap.data() as CPMMContract + const { subsidyPool, pool, p, slug, popularityScore } = contract + if ((subsidyPool ?? 0) < 1e-7) return + + const r = Math.random() + const logPopularity = Math.log10((popularityScore ?? 0) + 1) + const v = Math.max(1, Math.min(5, logPopularity)) + const amount = subsidyPool <= 0.5 ? subsidyPool : r * v * 0.01 * subsidyPool + + const { newPool, newP } = addCpmmLiquidity(pool, p, amount) + + if (!isFinite(newP)) { + throw new APIError( + 500, + 'Liquidity injection rejected due to overflow error.' + ) + } + + await trans.update(firestore.doc(`contracts/${contract.id}`), { + pool: newPool, + p: newP, + subsidyPool: subsidyPool - amount, + }) + + console.log( + 'added subsidy', + formatMoneyWithDecimals(amount), + 'of', + formatMoneyWithDecimals(subsidyPool), + 'pool to', + slug + ) + console.log() + }) +} diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 4abd225e..b742c533 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -483,11 +483,7 @@ color: #999; text-decoration: underline; margin: 0; - ">our Discord! Or, - click here to unsubscribe from this type of notification. + ">our Discord! diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 993fac81..1b111a9b 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { contractUrl, getUser } from './utils' +import { contractUrl, getUser, log } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' @@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, @@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async ( privateUser: PrivateUser, sendTime: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -279,22 +271,16 @@ export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.thank_you_for_purchases.includes( - 'email' - ) - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'thank_you_for_purchases' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', @@ -315,12 +301,7 @@ export const sendMarketCloseEmail = async ( privateUser: PrivateUser, contract: Contract ) => { - const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( - privateUser, - reason - ) - - if (!privateUser.email || !sendToEmail) return + if (!privateUser.email) return const { username, name, id: userId } = user const firstName = name.split(' ')[0] @@ -329,6 +310,7 @@ export const sendMarketCloseEmail = async ( const url = `https://${DOMAIN}/${username}/${slug}` + // We ignore if they were able to unsubscribe from market close emails, this is a necessary email return await sendTemplateEmail( privateUser.email, 'Your market has closed', @@ -336,7 +318,7 @@ export const sendMarketCloseEmail = async ( { question, url, - unsubscribeUrl, + unsubscribeUrl: '', userId, name: firstName, volume: formatMoney(volume), @@ -466,17 +448,13 @@ export const sendInterestingMarketsEmail = async ( contractsToSend: Contract[], deliveryTime?: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.trending_markets.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'trending_markets' ) + if (!sendToEmail) return const { name } = user const firstName = name.split(' ')[0] @@ -620,18 +598,15 @@ export const sendWeeklyPortfolioUpdateEmail = async ( investments: PerContractInvestmentsData[], overallPerformance: OverallPerformanceData ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.profit_loss_updates.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'profit_loss_updates' ) + if (!sendToEmail) return + const { name } = user const firstName = name.split(' ')[0] const templateData: Record = { @@ -656,4 +631,5 @@ export const sendWeeklyPortfolioUpdateEmail = async ( : 'portfolio-update', templateData ) + log('Sent portfolio update email to', privateUser.email) } diff --git a/functions/src/helpers/add-house-subsidy.ts b/functions/src/helpers/add-house-subsidy.ts new file mode 100644 index 00000000..afba8bcf --- /dev/null +++ b/functions/src/helpers/add-house-subsidy.ts @@ -0,0 +1,42 @@ +import * as admin from 'firebase-admin' + +import { CPMMContract } from '../../../common/contract' +import { isProd } from '../utils' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../../common/antes' +import { getNewLiquidityProvision } from '../../../common/add-liquidity' + +const firestore = admin.firestore() + +export const addHouseSubsidy = (contractId: string, amount: number) => { + return firestore.runTransaction(async (transaction) => { + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contractId}/liquidity`) + .doc() + + const providerId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const snap = await contractDoc.get() + const contract = snap.data() as CPMMContract + + const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } = + getNewLiquidityProvision( + providerId, + amount, + contract, + newLiquidityProvisionDoc.id + ) + + transaction.update(contractDoc, { + subsidyPool: newSubsidyPool, + totalLiquidity: newTotalLiquidity, + } as Partial) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + }) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index f5c45004..b64155a3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export * from './update-metrics' +export { scheduleUpdateMetrics } from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' @@ -31,6 +31,7 @@ export * from './reset-weekly-emails-flags' export * from './on-update-contract-follow' export * from './on-update-like' export * from './weekly-portfolio-emails' +export * from './drizzle-liquidity' // v2 export * from './health' @@ -44,8 +45,6 @@ export * from './sell-bet' export * from './sell-shares' export * from './claim-manalink' export * from './create-market' -export * from './add-liquidity' -export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' export * from './unsubscribe' @@ -53,6 +52,7 @@ export * from './stripe' export * from './mana-bonus-email' export * from './close-market' export * from './update-comment-bounty' +export * from './add-subsidy' import { health } from './health' import { transact } from './transact' @@ -65,9 +65,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' -import { addliquidity } from './add-liquidity' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' -import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { closemarket } from './close-market' @@ -77,6 +75,8 @@ import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' +import { updatemetrics } from './update-metrics' +import { addsubsidy } from './add-subsidy' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -92,10 +92,9 @@ const sellBetFunction = toCloudFunction(sellbet) const sellSharesFunction = toCloudFunction(sellshares) const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) -const addLiquidityFunction = toCloudFunction(addliquidity) +const addSubsidyFunction = toCloudFunction(addsubsidy) const addCommentBounty = toCloudFunction(addcommentbounty) const awardCommentBounty = toCloudFunction(awardcommentbounty) -const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) const closeMarketFunction = toCloudFunction(closemarket) @@ -106,6 +105,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const createPostFunction = toCloudFunction(createpost) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) +const updateMetricsFunction = toCloudFunction(updatemetrics) export { healthFunction as health, @@ -119,8 +119,7 @@ export { sellSharesFunction as sellshares, claimManalinkFunction as claimmanalink, createMarketFunction as createmarket, - addLiquidityFunction as addliquidity, - withdrawLiquidityFunction as withdrawliquidity, + addSubsidyFunction as addsubsidy, createGroupFunction as creategroup, resolveMarketFunction as resolvemarket, closeMarketFunction as closemarket, @@ -133,4 +132,5 @@ export { saveTwitchCredentials as savetwitchcredentials, addCommentBounty as addcommentbounty, awardCommentBounty as awardcommentbounty, + updateMetricsFunction as updatemetrics, } diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index 21b52fbc..4647a837 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -3,8 +3,10 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { getPrivateUser, getUserByUsername } from './utils' -import { createNotification } from './create-notification' +import { createMarketClosedNotification } from './create-notification' +import { DAY_MS } from '../../common/util/time' +const SEND_NOTIFICATIONS_EVERY_DAYS = 5 export const marketCloseNotifications = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .pubsub.schedule('every 1 hours') @@ -14,31 +16,31 @@ export const marketCloseNotifications = functions const firestore = admin.firestore() -async function sendMarketCloseEmails() { +export async function sendMarketCloseEmails() { const contracts = await firestore.runTransaction(async (transaction) => { const snap = await transaction.get( firestore.collection('contracts').where('isResolved', '!=', true) ) + const contracts = snap.docs.map((doc) => doc.data() as Contract) + const now = Date.now() + const closeContracts = contracts.filter( + (contract) => + contract.closeTime && + contract.closeTime < now && + shouldSendFirstOrFollowUpCloseNotification(contract) + ) - return snap.docs - .map((doc) => { - const contract = doc.data() as Contract - - if ( - contract.resolution || - (contract.closeEmailsSent ?? 0) >= 1 || - contract.closeTime === undefined || - (contract.closeTime ?? 0) > Date.now() + await Promise.all( + closeContracts.map(async (contract) => { + await transaction.update( + firestore.collection('contracts').doc(contract.id), + { + closeEmailsSent: admin.firestore.FieldValue.increment(1), + } ) - return undefined - - transaction.update(doc.ref, { - closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1, - }) - - return contract }) - .filter((x) => !!x) as Contract[] + ) + return closeContracts }) for (const contract of contracts) { @@ -55,14 +57,40 @@ async function sendMarketCloseEmails() { const privateUser = await getPrivateUser(user.id) if (!privateUser) continue - await createNotification( - contract.id, - 'contract', - 'closed', + await createMarketClosedNotification( + contract, user, - contract.id + '-closed-at-' + contract.closeTime, - contract.closeTime?.toString() ?? new Date().toString(), - { contract } + privateUser, + contract.id + '-closed-at-' + contract.closeTime ) } } + +// The downside of this approach is if this function goes down for the entire +// day of a multiple of the time period after the market has closed, it won't +// keep sending them notifications bc when it comes back online the time period will have passed +function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) { + if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true + const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } = + marketClosedMultipleOfNDaysAgo(contract) + return ( + contract.closeEmailsSent > 0 && + closedMultipleOfNDaysAgo && + contract.closeEmailsSent === fullTimePeriodsSinceClose + ) +} + +function marketClosedMultipleOfNDaysAgo(contract: Contract) { + const now = Date.now() + const closeTime = contract.closeTime + if (!closeTime) + return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 } + const daysSinceClose = Math.floor((now - closeTime) / DAY_MS) + return { + closedMultipleOfNDaysAgo: + daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0, + fullTimePeriodsSinceClose: Math.floor( + daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS + ), + } +} diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index b2451c62..0881abe5 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -12,6 +12,7 @@ import { revalidateStaticProps, } from './utils' import { + createBadgeAwardedNotification, createBetFillNotification, createBettingStreakBonusNotification, createUniqueBettorBonusNotification, @@ -24,6 +25,7 @@ import { BETTING_STREAK_BONUS_MAX, BETTING_STREAK_RESET_HOUR, UNIQUE_BETTOR_BONUS_AMOUNT, + UNIQUE_BETTOR_LIQUIDITY, } from '../../common/economy' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, @@ -33,6 +35,11 @@ import { APIError } from '../../common/api' import { User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' +import { addHouseSubsidy } from './helpers/add-house-subsidy' +import { + StreakerBadge, + streakerBadgeRarityThresholds, +} from '../../common/badge' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -103,7 +110,7 @@ const updateBettingStreak = async ( const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1 // Otherwise, add 1 to their betting streak - await trans.update(userDoc, { + trans.update(userDoc, { currentBettingStreak: newBettingStreak, lastBetTime: bet.createdTime, }) @@ -143,7 +150,7 @@ const updateBettingStreak = async ( log('message:', result.message) return } - if (result.txn) + if (result.txn) { await createBettingStreakBonusNotification( user, result.txn.id, @@ -153,6 +160,8 @@ const updateBettingStreak = async ( newBettingStreak, eventId ) + await handleBettingStreakBadgeAward(user, newBettingStreak) + } } const updateUniqueBettorsAndGiveCreatorBonus = async ( @@ -191,7 +200,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) - await trans.update(contractDoc, { + trans.update(contractDoc, { uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, }) @@ -204,8 +213,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( return { newUniqueBettorIds } } ) + if (!newUniqueBettorIds) return + if (oldContract.mechanism === 'cpmm-1') { + await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY) + } + const bonusTxnDetails = { contractId: oldContract.id, uniqueNewBettorId: bettor.id, @@ -215,7 +229,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( : DEV_HOUSE_LIQUIDITY_PROVIDER_ID const fromSnap = await firestore.doc(`users/${fromUserId}`).get() if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + const result = await firestore.runTransaction(async (trans) => { const bonusTxn: TxnData = { fromId: fromUser.id, @@ -228,7 +244,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( description: JSON.stringify(bonusTxnDetails), data: bonusTxnDetails, } as Omit + const { status, message, txn } = await runTxn(trans, bonusTxn) + return { status, newUniqueBettorIds, message, txn } }) @@ -296,3 +314,39 @@ const notifyFills = async ( const currentDateBettingStreakResetTime = () => { return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) } + +async function handleBettingStreakBadgeAward( + user: User, + newBettingStreak: number +) { + const alreadyHasBadgeForFirstStreak = + user.achievements?.streaker?.badges.some( + (badge) => badge.data.totalBettingStreak === 1 + ) + // TODO: check if already awarded 50th streak as well + if (newBettingStreak === 1 && alreadyHasBadgeForFirstStreak) return + + if (streakerBadgeRarityThresholds.includes(newBettingStreak)) { + const badge = { + type: 'STREAKER', + name: 'Streaker', + data: { + totalBettingStreak: newBettingStreak, + }, + createdTime: Date.now(), + } as StreakerBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + streaker: { + badges: [...(user.achievements?.streaker?.badges ?? []), badge], + }, + }, + }) + await createBadgeAwardedNotification(user, badge) + } +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index b613142b..43a66719 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,11 +1,20 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' -import { createNewContractNotification } from './create-notification' +import { getUser, getValues } from './utils' +import { + createBadgeAwardedNotification, + createNewContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' import { addUserToContractFollowers } from './follow-market' +import { User } from '../../common/user' +import * as admin from 'firebase-admin' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, +} from '../../common/badge' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -28,4 +37,43 @@ export const onCreateContract = functions richTextToString(desc), mentioned ) + await handleMarketCreatorBadgeAward(contractCreator) }) + +const firestore = admin.firestore() + +async function handleMarketCreatorBadgeAward(contractCreator: User) { + // get all contracts by user and calculate size of array + const contracts = await getValues( + firestore + .collection(`contracts`) + .where('creatorId', '==', contractCreator.id) + .where('resolution', '!=', 'CANCEL') + ) + if (marketCreatorBadgeRarityThresholds.includes(contracts.length)) { + const badge = { + type: 'MARKET_CREATOR', + name: 'Market Creator', + data: { + totalContractsCreated: contracts.length, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + // update user + await firestore + .collection('users') + .doc(contractCreator.id) + .update({ + achievements: { + ...contractCreator.achievements, + marketCreator: { + badges: [ + ...(contractCreator.achievements?.marketCreator?.badges ?? []), + badge, + ], + }, + }, + }) + await createBadgeAwardedNotification(contractCreator, badge) + } +} diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 54da7fd9..53f61eaa 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import { getContract, getUser, log } from './utils' -import { createNotification } from './create-notification' +import { createFollowOrMarketSubsidizedNotification } from './create-notification' import { LiquidityProvision } from '../../common/liquidity-provision' import { addUserToContractFollowers } from './follow-market' import { FIXED_ANTE } from '../../common/economy' @@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore if (!liquidityProvider) throw new Error('Could not find liquidity provider') await addUserToContractFollowers(contract.id, liquidityProvider.id) - await createNotification( + await createFollowOrMarketSubsidizedNotification( contract.id, 'liquidity', 'created', diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index 52042345..2f601f6d 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createFollowOrMarketSubsidizedNotification } from './create-notification' import { FieldValue } from 'firebase-admin/firestore' export const onFollowUser = functions.firestore @@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore followerCountCached: FieldValue.increment(1), }) - await createNotification( + await createFollowOrMarketSubsidizedNotification( followingUser.id, 'follow', 'created', diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d667f0d2..f0aa0252 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,11 +1,19 @@ import * as functions from 'firebase-functions' -import { getUser, getValues, log } from './utils' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { getUser, getValues } from './utils' +import { + createBadgeAwardedNotification, + createCommentOrAnswerOrUpdatedContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' -import { Txn } from '../../common/txn' -import { partition, sortBy } from 'lodash' -import { runTxn, TxnData } from './transact' +import { Bet } from '../../common/bet' import * as admin from 'firebase-admin' +import { ContractComment } from '../../common/comment' +import { scoreCommentorsAndBettors } from '../../common/scoring' +import { + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE, + ProvenCorrectBadge, +} from '../../common/badge' +import { GroupContractDoc } from '../../common/group' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') @@ -13,18 +21,14 @@ export const onUpdateContract = functions.firestore const contract = change.after.data() as Contract const previousContract = change.before.data() as Contract const { eventId } = context - const { openCommentBounties, closeTime, question } = contract + const { closeTime, question } = contract - if ( - !previousContract.isResolved && - contract.isResolved && - (openCommentBounties ?? 0) > 0 - ) { - await handleUnusedCommentBountyRefunds(contract) + if (!previousContract.isResolved && contract.isResolved) { // No need to notify users of resolution, that's handled in resolve-market - return - } - if ( + return await handleResolvedContract(contract) + } else if (previousContract.groupSlugs !== contract.groupSlugs) { + await handleContractGroupUpdated(previousContract, contract) + } else if ( previousContract.closeTime !== closeTime || previousContract.question !== question ) { @@ -32,6 +36,63 @@ export const onUpdateContract = functions.firestore } }) +async function handleResolvedContract(contract: Contract) { + if ( + (contract.uniqueBettorCount ?? 0) < + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE + ) + return + + // get all bets on this contract + const bets = await getValues( + firestore.collection(`contracts/${contract.id}/bets`) + ) + + // get comments on this contract + const comments = await getValues( + firestore.collection(`contracts/${contract.id}/comments`) + ) + + const { topCommentId, profitById, commentsById, betsById, topCommentBetId } = + scoreCommentorsAndBettors(contract, bets, comments) + if (topCommentBetId && profitById[topCommentBetId] > 0) { + // award proven correct badge to user + const comment = commentsById[topCommentId] + const bet = betsById[topCommentBetId] + + const user = await getUser(comment.userId) + if (!user) return + const newProvenCorrectBadge = { + createdTime: Date.now(), + type: 'PROVEN_CORRECT', + name: 'Proven Correct', + data: { + contractSlug: contract.slug, + contractCreatorUsername: contract.creatorUsername, + commentId: comment.id, + betAmount: bet.amount, + contractTitle: contract.question, + }, + } as ProvenCorrectBadge + // update user + await firestore + .collection('users') + .doc(user.id) + .update({ + achievements: { + ...user.achievements, + provenCorrect: { + badges: [ + ...(user.achievements?.provenCorrect?.badges ?? []), + newProvenCorrectBadge, + ], + }, + }, + }) + await createBadgeAwardedNotification(user, newProvenCorrectBadge) + } +} + async function handleUpdatedCloseTime( previousContract: Contract, contract: Contract, @@ -57,62 +118,42 @@ async function handleUpdatedCloseTime( ) } -async function handleUnusedCommentBountyRefunds(contract: Contract) { - const outstandingCommentBounties = await getValues( - firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') - ) +async function handleContractGroupUpdated( + previousContract: Contract, + contract: Contract +) { + const prevLength = previousContract.groupSlugs?.length ?? 0 + const newLength = contract.groupSlugs?.length ?? 0 + if (prevLength < newLength) { + // Contract was added to a new group + const groupId = contract.groupLinks?.find( + (link) => + !previousContract.groupLinks + ?.map((l) => l.groupId) + .includes(link.groupId) + )?.groupId + if (!groupId) throw new Error('Could not find new group id') - const commentBountiesOnThisContract = sortBy( - outstandingCommentBounties.filter( - (bounty) => bounty.data?.contractId === contract.id - ), - (bounty) => bounty.createdTime - ) + await firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + .set({ + contractId: contract.id, + createdTime: Date.now(), + } as GroupContractDoc) + } + if (prevLength > newLength) { + // Contract was removed from a group + const groupId = previousContract.groupLinks?.find( + (link) => + !contract.groupLinks?.map((l) => l.groupId).includes(link.groupId) + )?.groupId + if (!groupId) throw new Error('Could not find old group id') - const [toBank, fromBank] = partition( - commentBountiesOnThisContract, - (bounty) => bounty.toType === 'BANK' - ) - if (toBank.length <= fromBank.length) return - - await firestore - .collection('contracts') - .doc(contract.id) - .update({ openCommentBounties: 0 }) - - const refunds = toBank.slice(fromBank.length) - await Promise.all( - refunds.map(async (extraBountyTxn) => { - const result = await firestore.runTransaction(async (trans) => { - const bonusTxn: TxnData = { - fromId: extraBountyTxn.toId, - fromType: 'BANK', - toId: extraBountyTxn.fromId, - toType: 'USER', - amount: extraBountyTxn.amount, - token: 'M$', - category: 'REFUND_COMMENT_BOUNTY', - data: { - contractId: contract.id, - }, - } - return await runTxn(trans, bonusTxn) - }) - - if (result.status != 'success' || !result.txn) { - log( - `Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`, - result.status - ) - log('message:', result.message) - } else { - log( - `Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`, - result.txn?.id - ) - } - }) - ) + await firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + .delete() + } } - const firestore = admin.firestore() diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index b45809d0..66a6884c 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' -import { LimitBet } from '../../common/bet' -import { QuerySnapshot } from 'firebase-admin/firestore' import { Group } from '../../common/group' import { REFERRAL_AMOUNT } from '../../common/economy' const firestore = admin.firestore() @@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore if (prevUser.referredByUserId !== user.referredByUserId) { await handleUserUpdatedReferral(user, eventId) } - - if (user.balance <= 0) { - await cancelLimitOrders(user.id) - } }) async function handleUserUpdatedReferral(user: User, eventId: string) { @@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { ) }) } - -async function cancelLimitOrders(userId: string) { - const snapshot = (await firestore - .collectionGroup('bets') - .where('userId', '==', userId) - .where('isFilled', '==', false) - .get()) as QuerySnapshot - - await Promise.all( - snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true })) - ) -} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 74df7dc3..e785565b 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' +import { FLAT_TRADE_FEE } from '../../common/fees' import { BetInfo, getBinaryCpmmBetInfo, @@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' import { addUserToContractFollowers } from './follow-market' +import { filterDefined } from '../../common/util/array' const bodySchema = z.object({ contractId: z.string(), @@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => { newTotalLiquidity, newP, makers, + ordersToCancel, } = await (async (): Promise< BetInfo & { makers?: maker[] + ordersToCancel?: LimitBet[] } > => { if ( @@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => { limitProb = Math.round(limitProb * 100) / 100 } - const unfilledBetsSnap = await trans.get( - getUnfilledBetsQuery(contractDoc) - ) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + const { unfilledBets, balanceByUserId } = + await getUnfilledBetsAndUserBalances(trans, contractDoc) return getBinaryCpmmBetInfo( outcome, amount, contract, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) } else if ( (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && @@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (makers) { updateMakers(makers, betDoc.id, contractDoc, trans) } + if (ordersToCancel) { + for (const bet of ordersToCancel) { + trans.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + } + + const balanceChange = + newBet.amount !== 0 + ? // quick bet + newBet.amount + FLAT_TRADE_FEE + : // limit order + FLAT_TRADE_FEE + + trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) }) + log('Updated user balance.') if (newBet.amount !== 0) { - trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) - log('Updated user balance.') - trans.update( contractDoc, removeUndefinedProps({ @@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() -export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { +const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { return contractDoc .collection('bets') .where('isFilled', '==', false) .where('isCancelled', '==', false) as Query } +export const getUnfilledBetsAndUserBalances = async ( + trans: Transaction, + contractDoc: DocumentReference +) => { + const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc)) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + // Get balance of all users with open limit orders. + const userIds = uniq(unfilledBets.map((bet) => bet.userId)) + const userDocs = + userIds.length === 0 + ? [] + : await trans.getAll( + ...userIds.map((userId) => firestore.doc(`users/${userId}`)) + ) + const users = filterDefined(userDocs.map((doc) => doc.data() as User)) + const balanceByUserId = Object.fromEntries( + users.map((user) => [user.id, user.balance]) + ) + + return { unfilledBets, balanceByUserId } +} + type maker = { bet: LimitBet amount: number diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index ca8f5fc0..4230f0ac 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -9,7 +9,15 @@ import { RESOLUTIONS, } from '../../common/contract' import { Bet } from '../../common/bet' -import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils' +import { + getContractPath, + getUser, + getValues, + isProd, + log, + payUser, + revalidateStaticProps, +} from './utils' import { getLoanPayouts, getPayouts, @@ -145,6 +153,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { resolutions, collectedFees, }), + subsidyPool: 0, } await contractDoc.update(updatedContract) diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts new file mode 100644 index 00000000..f72692f7 --- /dev/null +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -0,0 +1,30 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllPrivateUsers } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + if (privateUser.notificationPreferences.badges_awarded === undefined) { + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + badges_awarded: ['browser'], + }, + }) + } + return + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/scripts/backfill-badges.ts b/functions/src/scripts/backfill-badges.ts new file mode 100644 index 00000000..a3776eb0 --- /dev/null +++ b/functions/src/scripts/backfill-badges.ts @@ -0,0 +1,129 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllUsers, getValues } from '../utils' +import { Contract } from 'common/contract' +import { + MarketCreatorBadge, + marketCreatorBadgeRarityThresholds, + StreakerBadge, + streakerBadgeRarityThresholds, +} from 'common/badge' +import { User } from 'common/user' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const users = await getAllUsers() + // const users = filterDefined([await getUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // dev ian + // const users = filterDefined([await getUser('uglwf3YKOZNGjjEXKc5HampOFRE2')]) // prod David + // const users = filterDefined([await getUser('AJwLWoo3xue32XIiAVrL5SyR1WB2')]) // prod ian + await Promise.all( + users.map(async (user) => { + if (!user.id) return + // Only backfill users without achievements + if (user.achievements === undefined) { + await firestore.collection('users').doc(user.id).update({ + achievements: {}, + }) + user.achievements = {} + user.achievements = await awardMarketCreatorBadges(user) + user.achievements = await awardBettingStreakBadges(user) + console.log('Added achievements to user', user.id) + // going to ignore backfilling the proven correct badges for now + } + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function removeErrorBadges(user: User) { + if ( + user.achievements.streaker?.badges.some( + (b) => b.data.totalBettingStreak > 1 + ) + ) { + console.log( + `User ${ + user.id + } has a streaker badge with streaks ${user.achievements.streaker?.badges.map( + (b) => b.data.totalBettingStreak + )}` + ) + // delete non 1,50 streaks + user.achievements.streaker.badges = + user.achievements.streaker.badges.filter((b) => + streakerBadgeRarityThresholds.includes(b.data.totalBettingStreak) + ) + // update user + await firestore.collection('users').doc(user.id).update({ + achievements: user.achievements, + }) + } +} + +async function awardMarketCreatorBadges(user: User) { + // Award market maker badges + const contracts = await getValues( + firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('resolution', '!=', 'CANCEL') + ) + + const achievements = { + ...user.achievements, + marketCreator: { + badges: [...(user.achievements.marketCreator?.badges ?? [])], + }, + } + for (const threshold of marketCreatorBadgeRarityThresholds) { + if (contracts.length >= threshold) { + const badge = { + type: 'MARKET_CREATOR', + name: 'Market Creator', + data: { + totalContractsCreated: threshold, + }, + createdTime: Date.now(), + } as MarketCreatorBadge + achievements.marketCreator.badges.push(badge) + } + } + // update user + await firestore.collection('users').doc(user.id).update({ + achievements, + }) + return achievements +} + +async function awardBettingStreakBadges(user: User) { + const streak = user.currentBettingStreak ?? 0 + const achievements = { + ...user.achievements, + streaker: { + badges: [...(user.achievements?.streaker?.badges ?? [])], + }, + } + for (const threshold of streakerBadgeRarityThresholds) { + if (streak >= threshold) { + const badge = { + type: 'STREAKER', + name: 'Streaker', + data: { + totalBettingStreak: threshold, + }, + createdTime: Date.now(), + } as StreakerBadge + achievements.streaker.badges.push(badge) + } + } + // update user + await firestore.collection('users').doc(user.id).update({ + achievements, + }) + return achievements +} diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts index 99d5f12b..e29fb0a9 100644 --- a/functions/src/scripts/contest/bulk-add-liquidity.ts +++ b/functions/src/scripts/contest/bulk-add-liquidity.ts @@ -50,3 +50,5 @@ async function main() { } } main() + +export {} diff --git a/functions/src/scripts/contest/bulk-resolve-markets.ts b/functions/src/scripts/contest/bulk-resolve-markets.ts new file mode 100644 index 00000000..8008db8b --- /dev/null +++ b/functions/src/scripts/contest/bulk-resolve-markets.ts @@ -0,0 +1,65 @@ +// Run with `npx ts-node src/scripts/contest/resolve-markets.ts` + +const DOMAIN = 'dev.manifold.markets' +// Dev API key for Cause Exploration Prizes (@CEP) +const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf' +const GROUP_SLUG = 'cart-contest' + +// Can just curl /v0/group/{slug} to get a group +async function getGroupBySlug(slug: string) { + const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`) + return await resp.json() +} + +async function getMarketsByGroupId(id: string) { + // API structure: /v0/group/by-id/[id]/markets + const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`) + return await resp.json() +} + +/* Example curl 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"}' +*/ +async function resolveMarketById( + id: string, + outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL' +) { + const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + outcome, + }), + }) + return await resp.json() +} + +async function main() { + const group = await getGroupBySlug(GROUP_SLUG) + const markets = await getMarketsByGroupId(group.id) + + // Count up some metrics + console.log('Number of markets', markets.length) + console.log( + 'Number of resolved markets', + markets.filter((m: any) => m.isResolved).length + ) + + // Resolve each market to NO + for (const market of markets) { + if (!market.isResolved) { + console.log(`Resolving market ${market.url} to NO`) + await resolveMarketById(market.id, 'NO') + } + } +} +main() + +export {} diff --git a/functions/src/scripts/denormalize.ts b/functions/src/scripts/denormalize.ts index d4feb425..3362e940 100644 --- a/functions/src/scripts/denormalize.ts +++ b/functions/src/scripts/denormalize.ts @@ -3,7 +3,6 @@ import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { isEqual, zip } from 'lodash' -import { UpdateSpec } from '../utils' export type DocumentValue = { doc: DocumentSnapshot @@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) { return { doc: diff.dest.doc.ref, fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), - } as UpdateSpec + } } export function applyDiff(transaction: Transaction, diff: DocumentDiff) { diff --git a/functions/src/scripts/drizzle.ts b/functions/src/scripts/drizzle.ts new file mode 100644 index 00000000..c38b6659 --- /dev/null +++ b/functions/src/scripts/drizzle.ts @@ -0,0 +1,8 @@ +import { initAdmin } from './script-init' +initAdmin() + +import { drizzleLiquidity } from '../drizzle-liquidity' + +if (require.main === module) { + drizzleLiquidity().then(() => process.exit()) +} diff --git a/functions/src/scripts/update-contract-tags.ts b/functions/src/scripts/update-contract-tags.ts deleted file mode 100644 index 37a2b60a..00000000 --- a/functions/src/scripts/update-contract-tags.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - -import { initAdmin } from './script-init' -initAdmin() - -import { Contract } from '../../../common/contract' -import { parseTags } from '../../../common/util/parse' -import { getValues } from '../utils' - -async function updateContractTags() { - const firestore = admin.firestore() - console.log('Updating contracts tags') - - const contracts = await getValues(firestore.collection('contracts')) - - console.log('Loaded', contracts.length, 'contracts') - - for (const contract of contracts) { - const contractRef = firestore.doc(`contracts/${contract.id}`) - - const tags = uniq([ - ...parseTags(contract.question + contract.description), - ...(contract.tags ?? []), - ]) - const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - - console.log( - 'Updating tags', - contract.slug, - 'from', - contract.tags, - 'to', - tags - ) - - await contractRef.update({ - tags, - lowercaseTags, - } as Partial) - } -} - -if (require.main === module) { - updateContractTags().then(() => process.exit()) -} diff --git a/functions/src/scripts/update-groups.ts b/functions/src/scripts/update-groups.ts index fc402292..56a9f399 100644 --- a/functions/src/scripts/update-groups.ts +++ b/functions/src/scripts/update-groups.ts @@ -89,17 +89,20 @@ const getGroups = async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars async function updateTotalContractsAndMembers() { const groups = await getGroups() - for (const group of groups) { - log('updating group total contracts and members', group.slug) - const groupRef = admin.firestore().collection('groups').doc(group.id) - const totalMembers = (await groupRef.collection('groupMembers').get()).size - const totalContracts = (await groupRef.collection('groupContracts').get()) - .size - await groupRef.update({ - totalMembers, - totalContracts, + await Promise.all( + groups.map(async (group) => { + log('updating group total contracts and members', group.slug) + const groupRef = admin.firestore().collection('groups').doc(group.id) + const totalMembers = (await groupRef.collection('groupMembers').get()) + .size + const totalContracts = (await groupRef.collection('groupContracts').get()) + .size + await groupRef.update({ + totalMembers, + totalContracts, + }) }) - } + ) } // eslint-disable-next-line @typescript-eslint/no-unused-vars async function removeUnusedMemberAndContractFields() { @@ -117,6 +120,6 @@ async function removeUnusedMemberAndContractFields() { if (require.main === module) { initAdmin() // convertGroupFieldsToGroupDocuments() - // updateTotalContractsAndMembers() - removeUnusedMemberAndContractFields() + updateTotalContractsAndMembers() + // removeUnusedMemberAndContractFields() } diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index f2f475cb..0c49bb24 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,6 +1,7 @@ import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' +import { FieldValue } from 'firebase-admin/firestore' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' @@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object' import { log } from './utils' import { Bet } from '../../common/bet' import { floatingEqual, floatingLesserEqual } from '../../common/util/math' -import { getUnfilledBetsQuery, updateMakers } from './place-bet' -import { FieldValue } from 'firebase-admin/firestore' +import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet' import { redeemShares } from './redeem-shares' import { removeUserFromContractFollowers } from './follow-market' @@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, 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], userBetsSnap, unfilledBetsSnap] = - await Promise.all([ - transaction.getAll(contractDoc, userDoc), - transaction.get(betsQ), - transaction.get(getUnfilledBetsQuery(contractDoc)), - ]) + const [ + [contractSnap, userSnap], + userBetsSnap, + { unfilledBets, balanceByUserId }, + ] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), + transaction.get(betsQ), + getUnfilledBetsAndUserBalances(transaction, contractDoc), + ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) const contract = contractSnap.data() as Contract const user = userSnap.data() as User @@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => { let loanPaid = saleFrac * loanAmount if (!isFinite(loanPaid)) loanPaid = 0 - const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( - soldShares, - chosenOutcome, - contract, - unfilledBets, - loanPaid - ) + const { newBet, newPool, newP, fees, makers, ordersToCancel } = + getCpmmSellBetInfo( + soldShares, + chosenOutcome, + contract, + unfilledBets, + balanceByUserId, + loanPaid + ) if ( !newP || @@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) + for (const bet of ordersToCancel) { + transaction.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + return { newBet, makers, maxShares, soldShares } }) diff --git a/functions/src/serve.ts b/functions/src/serve.ts index d861dcbc..bc09029d 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -19,8 +19,6 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' -import { addliquidity } from './add-liquidity' -import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' @@ -61,10 +59,8 @@ addJsonEndpointRoute('/sellbet', sellbet) addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/createmarket', createmarket) -addJsonEndpointRoute('/addliquidity', addliquidity) addJsonEndpointRoute('/addCommentBounty', addcommentbounty) addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) -addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts index 41aa9fe9..ed51e5e9 100644 --- a/functions/src/test-scheduled-function.ts +++ b/functions/src/test-scheduled-function.ts @@ -1,6 +1,6 @@ import { APIError, newEndpoint } from './api' -import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails' import { isProd } from './utils' +import { sendMarketCloseEmails } from 'functions/src/market-close-notifications' // Function for testing scheduled functions locally export const testscheduledfunction = newEndpoint( @@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint( throw new APIError(400, 'This function is only available in dev mode') // Replace your function here - await sendPortfolioUpdateEmailsToAllUsers() + await sendMarketCloseEmails() return { success: true } } diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 418282c7..b8f452c6 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -4,6 +4,7 @@ import { getPrivateUser } from './utils' import { PrivateUser } from '../../common/user' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { notification_preference } from '../../common/user-notification-preferences' +import { getFunctionUrl } from '../../common/api' export const unsubscribe: EndpointDefinition = { opts: { method: 'GET', minInstances: 1 }, @@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = { res.status(400).send('Invalid subscription type parameter.') return } + const optOutAllType: notification_preference = 'opt_out_all' + const wantsToOptOutAll = notificationSubscriptionType === optOutAllType const user = await getPrivateUser(id) @@ -31,28 +34,36 @@ export const unsubscribe: EndpointDefinition = { const previousDestinations = user.notificationPreferences[notificationSubscriptionType] + let newDestinations = previousDestinations + if (wantsToOptOutAll) newDestinations.push('email') + else + newDestinations = previousDestinations.filter( + (destination) => destination !== 'email' + ) + console.log(previousDestinations) const { email } = user const update: Partial = { notificationPreferences: { ...user.notificationPreferences, - [notificationSubscriptionType]: previousDestinations.filter( - (destination) => destination !== 'email' - ), + [notificationSubscriptionType]: newDestinations, }, } await firestore.collection('private-users').doc(id).update(update) + const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - res.send( - ` - + const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}` + if (wantsToOptOutAll) { + res.send( + ` + - Manifold Markets 7th Day Anniversary Gift! + Unsubscribe from Manifold Markets emails @@ -163,19 +174,6 @@ export const unsubscribe: EndpointDefinition = { - - -
-

- Hello!

-
- - @@ -186,20 +184,9 @@ export const unsubscribe: EndpointDefinition = { data-testid="4XoHRGw1Y"> - ${email} has been unsubscribed from email notifications related to: + ${email} has opted out of receiving unnecessary email notifications -
-
- ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. -

-
-
-
- Click - here - to manage the rest of your notification settings. - @@ -219,9 +206,193 @@ export const unsubscribe: EndpointDefinition = { +` + ) + } else { + res.send( + ` + + + + + Unsubscribe from Manifold Markets emails + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${email} has been unsubscribed from email notifications related to: + +
+
+ + ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. +

+
+
+
+ Click + here + to unsubscribe from all unnecessary emails. + +
+
+ Click + here + to manage the rest of your notification settings. + +
+ +
+

+
+
+
+
+
+ ` - ) + ) + } }, } diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 12f41453..6a01318b 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,10 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' +import { groupBy, keyBy, sortBy } from 'lodash' +import fetch from 'node-fetch' + import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' - import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' @@ -14,18 +15,44 @@ import { calculateNewPortfolioMetrics, calculateNewProfit, calculateProbChanges, + calculateMetricsByContract, + computeElasticity, computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' import { Group } from '../../common/group' import { batchedWaitAll } from '../../common/util/promise' +import { newEndpointNoAuth } from './api' +import { getFunctionUrl } from '../../common/api' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() +export const scheduleUpdateMetrics = functions.pubsub + .schedule('every 15 minutes') + .onRun(async () => { + const url = getFunctionUrl('updatemetrics') + console.log('Scheduling update metrics', url) + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({}), + }) -export const updateMetrics = functions - .runWith({ memory: '8GB', timeoutSeconds: 540 }) - .pubsub.schedule('every 15 minutes') - .onRun(updateMetricsCore) + const json = await response.json() + + if (response.ok) console.log(json) + else console.error(json) + }) + +export const updatemetrics = newEndpointNoAuth( + { timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 }, + async (_req) => { + await updateMetricsCore() + return { success: true } + } +) export async function updateMetricsCore() { console.log('Loading users') @@ -35,11 +62,7 @@ export async function updateMetricsCore() { const contracts = await getValues(firestore.collection('contracts')) console.log('Loading portfolio history') - const allPortfolioHistories = await getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ) + const userPortfolioHistory = await loadPortfolioHistory(users) console.log('Loading groups') const groups = await getValues(firestore.collection('groups')) @@ -103,6 +126,7 @@ export async function updateMetricsCore() { fields: { volume24Hours: computeVolume(contractBets, now - DAY_MS), volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + elasticity: computeElasticity(contractBets, contract), ...cpmmFields, }, } @@ -115,11 +139,10 @@ export async function updateMetricsCore() { ) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const betsByUser = groupBy(bets, (bet) => bet.userId) - const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId) const userMetrics = users.map((user) => { const currentBets = betsByUser[user.id] ?? [] - const portfolioHistory = portfolioHistoryByUser[user.id] ?? [] + const portfolioHistory = userPortfolioHistory[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? [] const newCreatorVolume = calculateCreatorVolume(userContracts) const newPortfolio = calculateNewPortfolioMetrics( @@ -127,21 +150,51 @@ export async function updateMetricsCore() { contractsById, currentBets ) - const lastPortfolio = last(portfolioHistory) + const currPortfolio = portfolioHistory.current const didPortfolioChange = - lastPortfolio === undefined || - lastPortfolio.balance !== newPortfolio.balance || - lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || - lastPortfolio.investmentValue !== newPortfolio.investmentValue + currPortfolio === undefined || + currPortfolio.balance !== newPortfolio.balance || + currPortfolio.totalDeposits !== newPortfolio.totalDeposits || + currPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + const metricsByContract = calculateMetricsByContract( + currentBets, + contractsById + ) + + const contractRatios = userContracts + .map((contract) => { + if ( + !contract.flaggedByUsernames || + contract.flaggedByUsernames?.length === 0 + ) { + return 0 + } + const contractRatio = + contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1) + + return contractRatio + }) + .filter((ratio) => ratio > 0) + const badResolutions = contractRatios.filter( + (ratio) => ratio > BAD_RESOLUTION_THRESHOLD + ) + let newFractionResolvedCorrectly = 1 + if (userContracts.length > 0) { + newFractionResolvedCorrectly = + (userContracts.length - badResolutions.length) / userContracts.length + } + return { user, newCreatorVolume, newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, + metricsByContract, } }) @@ -157,61 +210,61 @@ export async function updateMetricsCore() { const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const userUpdates = userMetrics.map( - ({ - user, - newCreatorVolume, - newPortfolio, - newProfit, - didPortfolioChange, - }) => { + ({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { - fieldUpdates: { - doc: firestore.collection('users').doc(user.id), - fields: { - creatorVolumeCached: newCreatorVolume, - profitCached: newProfit, - nextLoanCached, - }, - }, - - subcollectionUpdates: { - doc: firestore - .collection('users') - .doc(user.id) - .collection('portfolioHistory') - .doc(), - fields: didPortfolioChange ? newPortfolio : {}, + doc: firestore.collection('users').doc(user.id), + fields: { + creatorVolumeCached: newCreatorVolume, + profitCached: newProfit, + nextLoanCached, + fractionResolvedCorrectly: newFractionResolvedCorrectly, }, } } ) - await writeAsync( - firestore, - userUpdates.map((u) => u.fieldUpdates) + await writeAsync(firestore, userUpdates) + + const portfolioHistoryUpdates = filterDefined( + userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => { + return didPortfolioChange + ? { + doc: firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .doc(), + fields: newPortfolio, + } + : null + }) ) - await writeAsync( - firestore, - userUpdates - .filter((u) => !isEmpty(u.subcollectionUpdates.fields)) - .map((u) => u.subcollectionUpdates), - 'set' + await writeAsync(firestore, portfolioHistoryUpdates, 'set') + + const contractMetricsUpdates = userMetrics.flatMap( + ({ user, metricsByContract }) => { + const collection = firestore + .collection('users') + .doc(user.id) + .collection('contract-metrics') + return metricsByContract.map((metrics) => ({ + doc: collection.doc(metrics.contractId), + fields: metrics, + })) + } ) + + await writeAsync(firestore, contractMetricsUpdates, 'set') + log(`Updated metrics for ${users.length} users.`) try { const groupUpdates = groups.map((group, index) => { const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds - .map((e) => contractsById[e.contractId]) - .filter((e) => e !== undefined) as Contract[] - const bets = groupContracts.map((e) => { - if (e != null && e.id in betsByContract) { - return betsByContract[e.id] ?? [] - } else { - return [] - } - }) + const groupContracts = filterDefined( + groupContractIds.map((e) => contractsById[e.contractId]) + ) + const bets = groupContracts.map((e) => betsByContract[e.id] ?? []) const creatorScores = scoreCreators(groupContracts) const traderScores = scoreTraders(groupContracts, bets) @@ -243,3 +296,46 @@ const topUserScores = (scores: { [userId: string]: number }) => { } type GroupContractDoc = { contractId: string; createdTime: number } + +const BAD_RESOLUTION_THRESHOLD = 0.1 + +const loadPortfolioHistory = async (users: User[]) => { + const now = Date.now() + const userPortfolioHistory = await batchedWaitAll( + users.map((user) => async () => { + const query = firestore + .collection('users') + .doc(user.id) + .collection('portfolioHistory') + .orderBy('timestamp', 'desc') + .limit(1) + + const portfolioMetrics = await Promise.all([ + getValues(query), + getValues( + query.where('timestamp', '<', now - DAY_MS) + ), + getValues( + query.where('timestamp', '<', now - 7 * DAY_MS) + ), + getValues( + query.where('timestamp', '<', now - 30 * DAY_MS) + ), + ]) + const [current, day, week, month] = portfolioMetrics.map( + (p) => p[0] as PortfolioMetrics | undefined + ) + + return { + userId: user.id, + current, + day, + week, + month, + } + }), + 100 + ) + + return keyBy(userPortfolioHistory, (p) => p.userId) +} diff --git a/functions/src/utils.ts b/functions/src/utils.ts index efc22e53..e0cd269a 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -47,7 +47,7 @@ export const writeAsync = async ( const batch = db.batch() for (const { doc, fields } of chunks[i]) { if (operationType === 'update') { - batch.update(doc, fields) + batch.update(doc, fields as any) } else { batch.set(doc, fields) } @@ -112,6 +112,12 @@ export const getAllPrivateUsers = async () => { return users.docs.map((doc) => doc.data() as PrivateUser) } +export const getAllUsers = async () => { + const firestore = admin.firestore() + const users = await firestore.collection('users').get() + return users.docs.map((doc) => doc.data() as User) +} + export const getUserByUsername = async (username: string) => { const firestore = admin.firestore() const snap = await firestore diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 7c6f21a4..3d5ab3ac 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -4,21 +4,24 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { getAllPrivateUsers, + getGroup, getPrivateUser, getUser, getValues, isProd, log, } from './utils' -import { sendInterestingMarketsEmail } from './emails' import { createRNG, shuffle } from '../../common/util/random' -import { DAY_MS } from '../../common/util/time' +import { DAY_MS, HOUR_MS } from '../../common/util/time' import { filterDefined } from '../../common/util/array' +import { Follow } from '../../common/follow' +import { countBy, uniq, uniqBy } from 'lodash' +import { sendInterestingMarketsEmail } from './emails' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) - // every minute on Monday for an hour at 12pm PT (UTC -07:00) - .pubsub.schedule('* 19 * * 1') + // every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00) + .pubsub.schedule('* 19-20 * * 1') .timeZone('Etc/UTC') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() @@ -40,20 +43,30 @@ export async function getTrendingContracts() { ) } -async function sendTrendingMarketsEmailsToAllUsers() { +export async function sendTrendingMarketsEmailsToAllUsers() { const numContractsToSend = 6 const privateUsers = isProd() ? await getAllPrivateUsers() - : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) - // get all users that haven't unsubscribed from weekly emails + : filterDefined([ + await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian + ]) const privateUsersToSendEmailsTo = privateUsers - .filter((user) => { - return ( + // Get all users that haven't unsubscribed from weekly emails + .filter( + (user) => user.notificationPreferences.trending_markets.includes('email') && !user.weeklyTrendingEmailSent - ) - }) - .slice(150) // Send the emails out in batches + ) + .slice(0, 90) // Send the emails out in batches + + // For testing different users on prod: (only send ian an email though) + // const privateUsersToSendEmailsTo = filterDefined([ + // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), // prod Ian + // // isProd() + // await getPrivateUser('FptiiMZZ6dQivihLI8MYFQ6ypSw1'), // prod Mik + // // : await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian + // ]) + log( 'Sending weekly trending emails to', privateUsersToSendEmailsTo.length, @@ -70,42 +83,358 @@ async function sendTrendingMarketsEmailsToAllUsers() { !contract.groupSlugs?.includes('manifold-features') && !contract.groupSlugs?.includes('manifold-6748e065087e') ) - .slice(0, 20) - log( - `Found ${trendingContracts.length} trending contracts:\n`, - trendingContracts.map((c) => c.question).join('\n ') - ) + .slice(0, 50) - // TODO: convert to Promise.all - for (const privateUser of privateUsersToSendEmailsTo) { - if (!privateUser.email) { - log(`No email for ${privateUser.username}`) - continue - } - const contractsAvailableToSend = trendingContracts.filter((contract) => { - return !contract.uniqueBettorIds?.includes(privateUser.id) - }) - if (contractsAvailableToSend.length < numContractsToSend) { - log('not enough new, unbet-on contracts to send to user', privateUser.id) - await firestore.collection('private-users').doc(privateUser.id).update({ + const uniqueTrendingContracts = removeSimilarQuestions( + trendingContracts, + trendingContracts, + true + ).slice(0, 20) + + await Promise.all( + privateUsersToSendEmailsTo.map(async (privateUser) => { + if (!privateUser.email) { + log(`No email for ${privateUser.username}`) + return + } + + const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets( + privateUser.id + ) + const unBetOnGroupMarkets = await getUserUnBetOnGroupsMarkets( + privateUser.id, + unbetOnFollowedMarkets + ) + const similarBettorsMarkets = await getSimilarBettorsMarkets( + privateUser.id, + unBetOnGroupMarkets + ) + + const marketsAvailableToSend = uniqBy( + [ + ...chooseRandomSubset(unbetOnFollowedMarkets, 2), + // // Most people will belong to groups but may not follow other users, + // so choose more from the other subsets if the followed markets is sparse + ...chooseRandomSubset( + unBetOnGroupMarkets, + unbetOnFollowedMarkets.length < 2 ? 3 : 2 + ), + ...chooseRandomSubset( + similarBettorsMarkets, + unbetOnFollowedMarkets.length < 2 ? 3 : 2 + ), + ], + (contract) => contract.id + ) + // // at least send them trending contracts if nothing else + if (marketsAvailableToSend.length < numContractsToSend) { + const trendingMarketsToSend = + numContractsToSend - marketsAvailableToSend.length + log( + `not enough personalized markets, sending ${trendingMarketsToSend} trending` + ) + marketsAvailableToSend.push( + ...removeSimilarQuestions( + uniqueTrendingContracts, + marketsAvailableToSend, + false + ) + .filter( + (contract) => !contract.uniqueBettorIds?.includes(privateUser.id) + ) + .slice(0, trendingMarketsToSend) + ) + } + + if (marketsAvailableToSend.length < numContractsToSend) { + log( + 'not enough new, unbet-on contracts to send to user', + privateUser.id + ) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyTrendingEmailSent: true, + }) + return + } + // choose random subset of contracts to send to user + const contractsToSend = chooseRandomSubset( + marketsAvailableToSend, + numContractsToSend + ) + + const user = await getUser(privateUser.id) + if (!user) return + + log( + 'sending contracts:', + contractsToSend.map((c) => c.question + ' ' + c.popularityScore) + ) + // if they don't have enough markets, find user bets and get the other bettor ids who most overlap on those markets, then do the same thing as above for them + await sendInterestingMarketsEmail(user, privateUser, contractsToSend) + await firestore.collection('private-users').doc(user.id).update({ weeklyTrendingEmailSent: true, }) - continue - } - // choose random subset of contracts to send to user - const contractsToSend = chooseRandomSubset( - contractsAvailableToSend, - numContractsToSend - ) - - const user = await getUser(privateUser.id) - if (!user) continue - - await sendInterestingMarketsEmail(user, privateUser, contractsToSend) - await firestore.collection('private-users').doc(user.id).update({ - weeklyTrendingEmailSent: true, }) - } + ) +} + +const MINIMUM_POPULARITY_SCORE = 10 + +const getUserUnBetOnFollowsMarkets = async (userId: string) => { + const follows = await getValues( + firestore.collection('users').doc(userId).collection('follows') + ) + + const unBetOnContractsFromFollows = await Promise.all( + follows.map(async (follow) => { + const unresolvedContracts = await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('visibility', '==', 'public') + .where('creatorId', '==', follow.userId) + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately + .orderBy('popularityScore', 'desc') + .limit(50) + ) + // filter out contracts that have close times less than 6 hours from now + const openContracts = unresolvedContracts.filter( + (contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS + ) + + return openContracts.filter( + (contract) => !contract.uniqueBettorIds?.includes(userId) + ) + }) + ) + + const sortedMarkets = uniqBy( + unBetOnContractsFromFollows.flat(), + (contract) => contract.id + ) + .filter( + (contract) => + contract.popularityScore !== undefined && + contract.popularityScore > MINIMUM_POPULARITY_SCORE + ) + .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) + + const uniqueSortedMarkets = removeSimilarQuestions( + sortedMarkets, + sortedMarkets, + true + ) + + const topSortedMarkets = uniqueSortedMarkets.slice(0, 10) + // log( + // 'top 10 sorted markets by followed users', + // topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore) + // ) + return topSortedMarkets +} + +const getUserUnBetOnGroupsMarkets = async ( + userId: string, + differentThanTheseContracts: Contract[] +) => { + const snap = await firestore + .collectionGroup('groupMembers') + .where('userId', '==', userId) + .get() + + const groupIds = filterDefined( + snap.docs.map((doc) => doc.ref.parent.parent?.id) + ) + const groups = filterDefined( + await Promise.all(groupIds.map(async (groupId) => await getGroup(groupId))) + ) + if (groups.length === 0) return [] + + const unBetOnContractsFromGroups = await Promise.all( + groups.map(async (group) => { + const unresolvedContracts = await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('visibility', '==', 'public') + .where('groupSlugs', 'array-contains', group.slug) + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately + .orderBy('popularityScore', 'desc') + .limit(50) + ) + // filter out contracts that have close times less than 6 hours from now + const openContracts = unresolvedContracts.filter( + (contract) => (contract?.closeTime ?? 0) > Date.now() + 6 * HOUR_MS + ) + + return openContracts.filter( + (contract) => !contract.uniqueBettorIds?.includes(userId) + ) + }) + ) + + const sortedMarkets = uniqBy( + unBetOnContractsFromGroups.flat(), + (contract) => contract.id + ) + .filter( + (contract) => + contract.popularityScore !== undefined && + contract.popularityScore > MINIMUM_POPULARITY_SCORE + ) + .sort((a, b) => (b.popularityScore ?? 0) - (a.popularityScore ?? 0)) + + const uniqueSortedMarkets = removeSimilarQuestions( + sortedMarkets, + sortedMarkets, + true + ) + const topSortedMarkets = removeSimilarQuestions( + uniqueSortedMarkets, + differentThanTheseContracts, + false + ).slice(0, 10) + + // log( + // 'top 10 sorted group markets', + // topSortedMarkets.map((c) => c.question + ' ' + c.popularityScore) + // ) + return topSortedMarkets +} + +// Gets markets followed by similar bettors and bet on by similar bettors +const getSimilarBettorsMarkets = async ( + userId: string, + differentThanTheseContracts: Contract[] +) => { + // get contracts with unique bettor ids with this user + const contractsUserHasBetOn = await getValues( + firestore + .collection('contracts') + .where('uniqueBettorIds', 'array-contains', userId) + ) + if (contractsUserHasBetOn.length === 0) return [] + // count the number of times each unique bettor id appears on those contracts + const bettorIdsToCounts = countBy( + contractsUserHasBetOn.map((contract) => contract.uniqueBettorIds).flat(), + (bettorId) => bettorId + ) + + // sort by number of times they appear with at least 2 appearances + const sortedBettorIds = Object.entries(bettorIdsToCounts) + .sort((a, b) => b[1] - a[1]) + .filter((bettorId) => bettorId[1] > 2) + .map((entry) => entry[0]) + .filter((bettorId) => bettorId !== userId) + + // get the top 10 most similar bettors (excluding this user) + const similarBettorIds = sortedBettorIds.slice(0, 10) + if (similarBettorIds.length === 0) return [] + + // get contracts with unique bettor ids with this user + const contractsSimilarBettorsHaveBetOn = uniqBy( + ( + await getValues( + firestore + .collection('contracts') + .where( + 'uniqueBettorIds', + 'array-contains-any', + similarBettorIds.slice(0, 10) + ) + .orderBy('popularityScore', 'desc') + .limit(200) + ) + ).filter( + (contract) => + !contract.uniqueBettorIds?.includes(userId) && + (contract.popularityScore ?? 0) > MINIMUM_POPULARITY_SCORE + ), + (contract) => contract.id + ) + + // sort the contracts by how many times similar bettor ids are in their unique bettor ids array + const sortedContractsInSimilarBettorsBets = contractsSimilarBettorsHaveBetOn + .map((contract) => { + const appearances = contract.uniqueBettorIds?.filter((bettorId) => + similarBettorIds.includes(bettorId) + ).length + return [contract, appearances] as [Contract, number] + }) + .sort((a, b) => b[1] - a[1]) + .map((entry) => entry[0]) + + const uniqueSortedContractsInSimilarBettorsBets = removeSimilarQuestions( + sortedContractsInSimilarBettorsBets, + sortedContractsInSimilarBettorsBets, + true + ) + + const topMostSimilarContracts = removeSimilarQuestions( + uniqueSortedContractsInSimilarBettorsBets, + differentThanTheseContracts, + false + ).slice(0, 10) + + // log( + // 'top 10 sorted contracts other similar bettors have bet on', + // topMostSimilarContracts.map((c) => c.question) + // ) + + return topMostSimilarContracts +} + +// search contract array by question and remove contracts with 3 matching words in the question +const removeSimilarQuestions = ( + contractsToFilter: Contract[], + byContracts: Contract[], + allowExactSameContracts: boolean +) => { + // log( + // 'contracts to filter by', + // byContracts.map((c) => c.question + ' ' + c.popularityScore) + // ) + let contractsToRemove: Contract[] = [] + byContracts.length > 0 && + byContracts.forEach((contract) => { + const contractQuestion = stripNonAlphaChars( + contract.question.toLowerCase() + ) + const contractQuestionWords = uniq(contractQuestion.split(' ')).filter( + (w) => !IGNORE_WORDS.includes(w) + ) + contractsToRemove = contractsToRemove.concat( + contractsToFilter.filter( + // Remove contracts with more than 2 matching (uncommon) words and a lower popularity score + (c2) => { + const significantOverlap = + // TODO: we should probably use a library for comparing strings/sentiments + uniq( + stripNonAlphaChars(c2.question.toLowerCase()).split(' ') + ).filter((word) => contractQuestionWords.includes(word)).length > + 2 + const lessPopular = + (c2.popularityScore ?? 0) < (contract.popularityScore ?? 0) + return ( + (significantOverlap && lessPopular) || + (allowExactSameContracts ? false : c2.id === contract.id) + ) + } + ) + ) + }) + // log( + // 'contracts to filter out', + // contractsToRemove.map((c) => c.question) + // ) + + const returnContracts = contractsToFilter.filter( + (cf) => !contractsToRemove.map((c) => c.id).includes(cf.id) + ) + + return returnContracts } const fiveMinutes = 5 * 60 * 1000 @@ -116,3 +445,40 @@ function chooseRandomSubset(contracts: Contract[], count: number) { shuffle(contracts, rng) return contracts.slice(0, count) } + +function stripNonAlphaChars(str: string) { + return str.replace(/[^\w\s']|_/g, '').replace(/\s+/g, ' ') +} + +const IGNORE_WORDS = [ + 'the', + 'a', + 'an', + 'and', + 'or', + 'of', + 'to', + 'in', + 'on', + 'will', + 'be', + 'is', + 'are', + 'for', + 'by', + 'at', + 'from', + 'what', + 'when', + 'which', + 'that', + 'it', + 'as', + 'if', + 'then', + 'than', + 'but', + 'have', + 'has', + 'had', +] diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index bcf6da17..215694eb 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { ) ) ) - log('Found', contractsUsersBetOn.length, 'contracts') - let count = 0 await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) // Don't send to a user unless they're over 5 days old - if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) + return await setEmailFlagAsSent(privateUser.id) const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { (differences) => Math.abs(differences.profit) ).reverse() - log( - 'Found', - investmentValueDifferences.length, - 'investment differences for user', - privateUser.id - ) - const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 @@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { usersToContractsCreated[privateUser.id].length === 0 ) { log( - 'No bets in last week, no market movers, no markets created. Not sending an email.' + `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .` ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - return + return await setEmailFlagAsSent(privateUser.id) } + // Set the flag beforehand just to be safe + await setEmailFlagAsSent(privateUser.id) await sendWeeklyPortfolioUpdateEmail( user, privateUser, topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], performanceData ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - log('Sent weekly portfolio update email to', privateUser.email) - count++ - log('sent out emails to users:', count) }) ) } +async function setEmailFlagAsSent(privateUserId: string) { + await firestore.collection('private-users').doc(privateUserId).update({ + weeklyPortfolioUpdateEmailSent: true, + }) +} + export type PerContractInvestmentsData = { questionTitle: string questionUrl: string diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts deleted file mode 100644 index 53974f7d..00000000 --- a/functions/src/withdraw-liquidity.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as admin from 'firebase-admin' -import { z } from 'zod' - -import { CPMMContract } from '../../common/contract' -import { User } from '../../common/user' -import { subtractObjects } from '../../common/util/object' -import { LiquidityProvision } from '../../common/liquidity-provision' -import { getUserLiquidityShares } from '../../common/calculate-cpmm' -import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' -import { noFees } from '../../common/fees' - -import { APIError, newEndpoint, validate } from './api' -import { redeemShares } from './redeem-shares' - -const bodySchema = z.object({ - contractId: z.string(), -}) - -export const withdrawliquidity = newEndpoint({}, async (req, auth) => { - const { contractId } = validate(bodySchema, req.body) - - return await firestore - .runTransaction(async (trans) => { - const lpDoc = firestore.doc(`users/${auth.uid}`) - const lpSnap = await trans.get(lpDoc) - if (!lpSnap.exists) throw new APIError(400, 'User not found.') - const lp = lpSnap.data() as User - - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await trans.get(contractDoc) - if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') - const contract = contractSnap.data() as CPMMContract - - const liquidityCollection = firestore.collection( - `contracts/${contractId}/liquidity` - ) - - const liquiditiesSnap = await trans.get(liquidityCollection) - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const userShares = getUserLiquidityShares( - auth.uid, - contract, - liquidities, - true - ) - - // zero all added amounts for now - // can add support for partial withdrawals in the future - liquiditiesSnap.docs - .filter( - (_, i) => !liquidities[i].isAnte && liquidities[i].userId === auth.uid - ) - .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} - - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial) - - const newPool = subtractObjects(contract.pool, userShares) - - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout - - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) - - const prob = getProbability(contract) - - // surplus shares become user's bets - const bets = Object.entries(userShares) - .map(([outcome, shares]) => - shares - payout < 1 // don't create bet if less than 1 share - ? undefined - : ({ - userId: auth.uid, - contractId: contract.id, - amount: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit) - ) - .filter((x) => x !== undefined) - - for (const bet of bets) { - const doc = firestore.collection(`contracts/${contract.id}/bets`).doc() - trans.create(doc, { id: doc.id, ...bet }) - } - - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(auth.uid, contractId) - console.log('userid', auth.uid, 'withdraws', result) - return result - }) -}) - -const firestore = admin.firestore() diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index f8e235b7..caba2657 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -14,11 +14,6 @@ export function getHtml(parsedReq: ParsedRequest) { numericValue, resolution, } = parsedReq - const MAX_QUESTION_CHARS = 100 - const truncatedQuestion = - question.length > MAX_QUESTION_CHARS - ? question.slice(0, MAX_QUESTION_CHARS) + '...' - : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' let resolutionColor = 'text-primary' @@ -69,7 +64,7 @@ export function getHtml(parsedReq: ParsedRequest) { Generated Image - + - - - - diff --git a/web/tailwind.config.js b/web/tailwind.config.js index ef7220ec..0390038f 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -18,6 +18,7 @@ module.exports = { colors: { 'red-25': '#FDF7F6', 'greyscale-1': '#FBFBFF', + 'greyscale-1.5': '#F4F4FB', 'greyscale-2': '#E7E7F4', 'greyscale-3': '#D8D8EB', 'greyscale-4': '#B1B1C7', diff --git a/yarn.lock b/yarn.lock index d0ef33f1..4d57c590 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1310,7 +1310,14 @@ dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.18.9": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" + integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA== + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.9.2": version "7.18.9" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== @@ -2351,10 +2358,23 @@ resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.6.1.tgz#d822792e589aac005462491dd62f86095e0c3bef" integrity sha512-gMd6uIs1U4Oz718Z5gFoV0o/vD43/4zvbyiJN9Dt7PK9Ubxn+TmJwTmYwyNJc5KxxU1t0CmgTNgwZX9+4NjCnQ== -"@heroicons/react@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d" - integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg== +"@hello-pangea/dnd@16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@hello-pangea/dnd/-/dnd-16.0.0.tgz#b97791286395924ffbdb4cd0f27f06f2985766d5" + integrity sha512-FprEzwrGMvyclVf8pWTrPbUV7/ZFt6NmL76ePj1mMyZG195htDUkmvET6CBwKJTXmV+AE/GyK4Lv3wpCqrlY/g== + dependencies: + "@babel/runtime" "^7.18.9" + css-box-model "^1.2.1" + memoize-one "^6.0.0" + raf-schd "^4.0.3" + react-redux "^8.0.2" + redux "^4.2.0" + use-memo-one "^1.1.2" + +"@heroicons/react@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324" + integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ== "@humanwhocodes/config-array@^0.10.4": version "0.10.4" @@ -2553,103 +2573,6 @@ resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.3.1.tgz#27d71a95247a9eaee03d47adee7e3bd594514136" integrity sha512-W1ijvzzg+kPEX6LAc+50EYYSEo0FVu7dmTE+t+DM4iOLqgGHoW9uYSz9wCVdkXOEEMP9xhXfGpcSxsfDucyPkA== -"@nivo/annotations@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.80.0.tgz#127e4801fff7370dcfb9acfe1e335781dd65cfd5" - integrity sha512-bC9z0CLjU07LULTMWsqpjovRtHxP7n8oJjqBQBLmHOGB4IfiLbrryBfu9+aEZH3VN2jXHhdpWUz+HxeZzOzsLg== - dependencies: - "@nivo/colors" "0.80.0" - "@react-spring/web" "9.4.5" - lodash "^4.17.21" - -"@nivo/axes@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/axes/-/axes-0.80.0.tgz#22788855ddc45bb6a619dcd03d62d4bd8c0fc35f" - integrity sha512-AsUyaSHGwQVSEK8QXpsn8X+poZxvakLMYW7crKY1xTGPNw+SU4SSBohPVumm2jMH3fTSLNxLhAjWo71GBJXfdA== - dependencies: - "@nivo/scales" "0.80.0" - "@react-spring/web" "9.4.5" - d3-format "^1.4.4" - d3-time-format "^3.0.0" - -"@nivo/colors@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.80.0.tgz#5b70b4979df246d9d0d69fb638bba9764dd88b52" - integrity sha512-T695Zr411FU4RPo7WDINOAn8f79DPP10SFJmDdEqELE+cbzYVTpXqLGZ7JMv88ko7EOf9qxLQgcBqY69rp9tHQ== - dependencies: - d3-color "^2.0.0" - d3-scale "^3.2.3" - d3-scale-chromatic "^2.0.0" - lodash "^4.17.21" - -"@nivo/core@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.80.0.tgz#d180cb2622158eb7bc5f984131ff07984f12297e" - integrity sha512-6caih0RavXdWWSfde+rC2pk17WrX9YQlqK26BrxIdXzv3Ydzlh5SkrC7dR2TEvMGBhunzVeLOfiC2DWT1S8CFg== - dependencies: - "@nivo/recompose" "0.80.0" - "@react-spring/web" "9.4.5" - d3-color "^2.0.0" - d3-format "^1.4.4" - d3-interpolate "^2.0.1" - d3-scale "^3.2.3" - d3-scale-chromatic "^2.0.0" - d3-shape "^1.3.5" - d3-time-format "^3.0.0" - lodash "^4.17.21" - -"@nivo/legends@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.80.0.tgz#49edc54000075b4df055f86794a8c32810269d06" - integrity sha512-h0IUIPGygpbKIZZZWIxkkxOw4SO0rqPrqDrykjaoQz4CvL4HtLIUS3YRA4akKOVNZfS5agmImjzvIe0s3RvqlQ== - -"@nivo/line@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/line/-/line-0.80.0.tgz#ba541b0fcfd53b3a7ce865feb43c993b7cf4a7d4" - integrity sha512-6UAD/y74qq3DDRnVb+QUPvXYojxMtwXMipGSNvCGk8omv1QZNTaUrbV+eQacvn9yh//a0yZcWipnpq0tGJyJCA== - dependencies: - "@nivo/annotations" "0.80.0" - "@nivo/axes" "0.80.0" - "@nivo/colors" "0.80.0" - "@nivo/legends" "0.80.0" - "@nivo/scales" "0.80.0" - "@nivo/tooltip" "0.80.0" - "@nivo/voronoi" "0.80.0" - "@react-spring/web" "9.4.5" - d3-shape "^1.3.5" - -"@nivo/recompose@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.80.0.tgz#572048aed793321a0bada1fd176b72df5a25282e" - integrity sha512-iL3g7j3nJGD9+mRDbwNwt/IXDXH6E29mhShY1I7SP91xrfusZV9pSFf4EzyYgruNJk/2iqMuaqn+e+TVFra44A== - dependencies: - react-lifecycles-compat "^3.0.4" - -"@nivo/scales@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/scales/-/scales-0.80.0.tgz#39313fb97c8ae9633c2aa1e17adb57cb851e8a50" - integrity sha512-4y2pQdCg+f3n4TKXC2tYuq71veZM+xPRQbOTgGYJpuBvMc7pQsXF9T5z7ryeIG9hkpXkrlyjecU6XcAG7tLSNg== - dependencies: - d3-scale "^3.2.3" - d3-time "^1.0.11" - d3-time-format "^3.0.0" - lodash "^4.17.21" - -"@nivo/tooltip@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.80.0.tgz#07ebef47eb708a0612bd6297d5ad156bbec19d34" - integrity sha512-qGmrreRwnCsYjn/LAuwBtxBn/tvG8y+rwgd4gkANLBAoXd3bzJyvmkSe+QJPhUG64bq57ibDK+lO2pC48a3/fw== - dependencies: - "@react-spring/web" "9.4.5" - -"@nivo/voronoi@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/voronoi/-/voronoi-0.80.0.tgz#59cc7ed253dc1a5bbcf614a5ac37d2468d561599" - integrity sha512-zaJV3I3cRu1gHpsXCIEvp6GGlGY8P7D9CwAVCjYDGrz3W/+GKN0kA7qGyHTC97zVxJtfefxSPlP/GtOdxac+qw== - dependencies: - d3-delaunay "^5.3.0" - d3-scale "^3.2.3" - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2744,52 +2667,6 @@ resolved "https://registry.yarnpkg.com/@react-query-firebase/firestore/-/firestore-0.4.2.tgz#6ae52768715aa0a5c0d903dd4fd953ed417ba635" integrity sha512-7eYp905+sfBRcBTdj7W7BAc3bI3V0D0kKca4/juOTnN4gyoNyaCNOCjLPY467dTq325hGs7BX0ol7Pw3JENdHA== -"@react-spring/animated@~9.4.5": - version "9.4.5" - resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.4.5.tgz#dd9921c716a4f4a3ed29491e0c0c9f8ca0eb1a54" - integrity sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA== - dependencies: - "@react-spring/shared" "~9.4.5" - "@react-spring/types" "~9.4.5" - -"@react-spring/core@~9.4.5": - version "9.4.5" - resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-9.4.5.tgz#4616e1adc18dd10f5731f100ebdbe9518b89ba3c" - integrity sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ== - dependencies: - "@react-spring/animated" "~9.4.5" - "@react-spring/rafz" "~9.4.5" - "@react-spring/shared" "~9.4.5" - "@react-spring/types" "~9.4.5" - -"@react-spring/rafz@~9.4.5": - version "9.4.5" - resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.4.5.tgz#84f809f287f2a66bbfbc66195db340482f886bd7" - integrity sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ== - -"@react-spring/shared@~9.4.5": - version "9.4.5" - resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-9.4.5.tgz#4c3ad817bca547984fb1539204d752a412a6d829" - integrity sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA== - dependencies: - "@react-spring/rafz" "~9.4.5" - "@react-spring/types" "~9.4.5" - -"@react-spring/types@~9.4.5": - version "9.4.5" - resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.4.5.tgz#9c71e5ff866b5484a7ef3db822bf6c10e77bdd8c" - integrity sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg== - -"@react-spring/web@9.4.5": - version "9.4.5" - resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.4.5.tgz#b92f05b87cdc0963a59ee149e677dcaff09f680e" - integrity sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA== - dependencies: - "@react-spring/animated" "~9.4.5" - "@react-spring/core" "~9.4.5" - "@react-spring/shared" "~9.4.5" - "@react-spring/types" "~9.4.5" - "@rushstack/eslint-patch@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.1.3.tgz#6801033be7ff87a6b7cadaf5b337c9f366a3c4b0" @@ -3536,7 +3413,7 @@ resolved "https://registry.yarnpkg.com/@types/hogan.js/-/hogan.js-3.0.1.tgz#64c54407b30da359763e14877f5702b8ae85d61c" integrity sha512-D03i/2OY7kGyMq9wdQ7oD8roE49z/ZCZThe/nbahtvuqCNZY9T2MfedOWyeBdbEpY2W8Gnh/dyJLdFtUCOkYbg== -"@types/hoist-non-react-statics@^3.3.0": +"@types/hoist-non-react-statics@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== @@ -3664,30 +3541,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-beautiful-dnd@13.1.2": - version "13.1.2" - resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz#510405abb09f493afdfd898bf83995dc6385c130" - integrity sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg== +"@types/react-dom@18.0.6": + version "18.0.6" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" + integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA== dependencies: "@types/react" "*" -"@types/react-dom@17.0.2": - version "17.0.2" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.2.tgz#35654cf6c49ae162d5bc90843d5437dc38008d43" - integrity sha512-Icd9KEgdnFfJs39KyRyr0jQ7EKhq8U6CcHRMGAS45fp5qgUvxL3ujUCfWFttUK2UErqZNj97t9gsVPNAqcwoCg== - dependencies: - "@types/react" "*" - -"@types/react-redux@^7.1.20": - version "7.1.24" - resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0" - integrity sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ== - dependencies: - "@types/hoist-non-react-statics" "^3.3.0" - "@types/react" "*" - hoist-non-react-statics "^3.3.0" - redux "^4.0.0" - "@types/react-router-config@*": version "5.0.6" resolved "https://registry.yarnpkg.com/@types/react-router-config/-/react-router-config-5.0.6.tgz#87c5c57e72d241db900d9734512c50ccec062451" @@ -3714,7 +3574,7 @@ "@types/history" "^4.7.11" "@types/react" "*" -"@types/react@*", "@types/react@17.0.43", "@types/react@^17.0.2": +"@types/react@*", "@types/react@^17.0.2": version "17.0.43" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.43.tgz#4adc142887dd4a2601ce730bc56c3436fdb07a55" integrity sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A== @@ -3723,6 +3583,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@18.0.21": + version "18.0.21" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.21.tgz#b8209e9626bb00a34c76f55482697edd2b43cc67" + integrity sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -3772,6 +3641,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/ws@^8.5.1": version "8.5.3" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" @@ -5284,7 +5158,7 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css-box-model@^1.2.0: +css-box-model@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== @@ -5450,13 +5324,6 @@ csstype@^3.0.2, csstype@^3.1.0: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== -d3-array@2, d3-array@^2.3.0: - version "2.12.1" - resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" - integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== - dependencies: - internmap "^1.0.0" - "d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.0.tgz#15bf96cd9b7333e02eb8de8053d78962eafcff14" @@ -5480,23 +5347,11 @@ d3-brush@3.0.0: d3-selection "3" d3-transition "3" -"d3-color@1 - 2", d3-color@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" - integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== - "d3-color@1 - 3": version "3.1.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== -d3-delaunay@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d" - integrity sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w== - dependencies: - delaunator "4" - "d3-dispatch@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" @@ -5515,28 +5370,11 @@ d3-delaunay@^5.3.0: resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== -"d3-format@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" - integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== - "d3-format@1 - 3": version "3.1.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== -d3-format@^1.4.4: - version "1.4.5" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" - integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== - -"d3-interpolate@1 - 2", "d3-interpolate@1.2.0 - 2", d3-interpolate@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-2.0.1.tgz#98be499cfb8a3b94d4ff616900501a64abc91163" - integrity sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ== - dependencies: - d3-color "1 - 2" - "d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" @@ -5544,24 +5382,11 @@ d3-format@^1.4.4: dependencies: d3-color "1 - 3" -d3-path@1: - version "1.0.9" - resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" - integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== - "d3-path@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.0.1.tgz#f09dec0aaffd770b7995f1a399152bf93052321e" integrity sha512-gq6gZom9AFZby0YLduxT1qmrp4xpBA1YZr19OI717WIdKE2OM5ETq5qrHLb301IgxhLwcuxvGZVLeeWc/k1I6w== -d3-scale-chromatic@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab" - integrity sha512-LLqy7dJSL8yDy7NRmf6xSlsFZ6zYvJ4BcWFE4zBrOPnQERv9zj24ohnXKRbyi9YHnYV+HN1oEO3iFK971/gkzA== - dependencies: - d3-color "1 - 2" - d3-interpolate "1 - 2" - d3-scale@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" @@ -5573,17 +5398,6 @@ d3-scale@4.0.2: d3-time "2.1.1 - 3" d3-time-format "2 - 4" -d3-scale@^3.2.3: - version "3.3.0" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-3.3.0.tgz#28c600b29f47e5b9cd2df9749c206727966203f3" - integrity sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ== - dependencies: - d3-array "^2.3.0" - d3-format "1 - 2" - d3-interpolate "1.2.0 - 2" - d3-time "^2.1.1" - d3-time-format "2 - 3" - d3-selection@3, d3-selection@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" @@ -5596,20 +5410,6 @@ d3-shape@3.1.0: dependencies: d3-path "1 - 3" -d3-shape@^1.3.5: - version "1.3.7" - resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" - integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== - dependencies: - d3-path "1" - -"d3-time-format@2 - 3", d3-time-format@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6" - integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag== - dependencies: - d3-time "1 - 2" - "d3-time-format@2 - 4": version "4.1.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" @@ -5617,13 +5417,6 @@ d3-shape@^1.3.5: dependencies: d3-time "1 - 3" -"d3-time@1 - 2", d3-time@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" - integrity sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ== - dependencies: - d3-array "2" - "d3-time@1 - 3", "d3-time@2.1.1 - 3": version "3.0.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.0.0.tgz#65972cb98ae2d4954ef5c932e8704061335d4975" @@ -5631,11 +5424,6 @@ d3-shape@^1.3.5: dependencies: d3-array "2 - 3" -d3-time@^1.0.11: - version "1.1.0" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" - integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== - "d3-timer@1 - 3": version "3.0.1" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" @@ -5790,11 +5578,6 @@ del@^6.0.0: rimraf "^3.0.2" slash "^3.0.0" -delaunator@4: - version "4.0.1" - resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-4.0.1.tgz#3d779687f57919a7a418f8ab947d3bddb6846957" - integrity sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag== - delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -7855,11 +7638,6 @@ internal-slot@^1.0.3: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== -internmap@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" - integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== - interpret@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" @@ -8834,10 +8612,10 @@ memfs@^3.1.2, memfs@^3.4.3: dependencies: fs-monkey "1.0.3" -memoize-one@^5.1.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" - integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== merge-descriptors@1.0.1: version "1.0.1" @@ -10335,7 +10113,7 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -raf-schd@^4.0.2: +raf-schd@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== @@ -10387,19 +10165,6 @@ react-base16-styling@^0.6.0: lodash.flow "^3.3.0" pure-color "^1.2.0" -react-beautiful-dnd@13.1.1: - version "13.1.1" - resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2" - integrity sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ== - dependencies: - "@babel/runtime" "^7.9.2" - css-box-model "^1.2.0" - memoize-one "^5.1.1" - raf-schd "^4.0.2" - react-redux "^7.2.0" - redux "^4.0.4" - use-memo-one "^1.1.1" - react-confetti@6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/react-confetti/-/react-confetti-6.0.1.tgz#d4f57b5a021dd908a6243b8f63b6009b00818d10" @@ -10437,7 +10202,15 @@ react-dev-utils@^12.0.0: strip-ansi "^6.0.1" text-table "^0.2.0" -react-dom@17.0.2, react-dom@^17.0.1: +react-dom@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" + integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.0" + +react-dom@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== @@ -10451,14 +10224,14 @@ react-error-overlay@^6.0.11: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== -react-expanding-textarea@2.3.5: - version "2.3.5" - resolved "https://registry.yarnpkg.com/react-expanding-textarea/-/react-expanding-textarea-2.3.5.tgz#310c28ab242c724e042589ac3ea400dd68ad1488" - integrity sha512-mPdtg3CxSgZFcsRLf80jueBWy1Zlh9AKy76S7dGXUKinvo4EypMavZa2iC0hEnLxY0tcwWR+n/K8B21TkttpUw== +react-expanding-textarea@2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/react-expanding-textarea/-/react-expanding-textarea-2.3.6.tgz#daa50e5110dd71ca79e1df8e056b0b2eb0e8a84a" + integrity sha512-LjkyZv1LilMlt+6/yYn9F1FlcK8iQU96myeq6PwU/a7IaQMkSwndSB1SAhMKgxSrUR71VbqsqnAHQ741WbAU/Q== dependencies: fast-shallow-equal "^1.0.0" - react-with-forwarded-ref "^0.3.3" - tslib "^2.0.3" + react-with-forwarded-ref "^0.3.5" + tslib "^2.4.0" react-fast-compare@^3.2.0: version "3.2.0" @@ -10507,10 +10280,10 @@ react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-is@^18.0.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== react-json-view@^1.21.3: version "1.21.3" @@ -10548,17 +10321,17 @@ react-query@3.39.0: broadcast-channel "^3.4.1" match-sorter "^6.0.2" -react-redux@^7.2.0: - version "7.2.8" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.8.tgz#a894068315e65de5b1b68899f9c6ee0923dd28de" - integrity sha512-6+uDjhs3PSIclqoCk0kd6iX74gzrGc3W5zcAjbrFgEdIjRSQObdIwfx80unTkVUYvbQ95Y8Av3OvFHq1w5EOUw== +react-redux@^8.0.2: + version "8.0.4" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.4.tgz#80c31dffa8af9526967c4267022ae1525ff0e36a" + integrity sha512-yMfQ7mX6bWuicz2fids6cR1YT59VTuT8MKyyE310wJQlINKENCeT1UcPdEiX6znI5tF8zXyJ/VYvDgeGuaaNwQ== dependencies: - "@babel/runtime" "^7.15.4" - "@types/react-redux" "^7.1.20" + "@babel/runtime" "^7.12.1" + "@types/hoist-non-react-statics" "^3.3.1" + "@types/use-sync-external-store" "^0.0.3" hoist-non-react-statics "^3.3.2" - loose-envify "^1.4.0" - prop-types "^15.7.2" - react-is "^17.0.2" + react-is "^18.0.0" + use-sync-external-store "^1.0.0" react-router-config@^5.1.1: version "5.1.1" @@ -10612,14 +10385,21 @@ react-twitter-embed@4.0.4: dependencies: scriptjs "^2.5.9" -react-with-forwarded-ref@^0.3.3: - version "0.3.4" - resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5" - integrity sha512-SRq/uTdTh+02JDwYzEEhY2aNNWl/CP2EKP2nQtXzhJw06w6PgYnJt2ObrebvFJu6+wGzX3vDHU3H/ux9hxyZUQ== +react-with-forwarded-ref@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.5.tgz#7d0bae2a9996fc91493f40ab179b8c54d29cfab9" + integrity sha512-BJK4q0Nvqg4AFwc+LV+PZZb2nxS1ZqQlS9hY14TdIyg7Lapzirk/V/TtbYjPFSsm/fGm0wC4tsgI1IDhxSVVSQ== dependencies: tslib "^2.0.3" -react@17.0.2, react@^17.0.1: +react@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== + dependencies: + loose-envify "^1.1.0" + +react@^17.0.1: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== @@ -10711,7 +10491,7 @@ recursive-readdir@^2.2.2: dependencies: minimatch "3.0.4" -redux@^4.0.0, redux@^4.0.4: +redux@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13" integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA== @@ -11077,6 +10857,13 @@ scheduler@^0.20.2: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" + integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== + dependencies: + loose-envify "^1.1.0" + schema-utils@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" @@ -12279,12 +12066,12 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" -use-memo-one@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.2.tgz#0c8203a329f76e040047a35a1197defe342fab20" - integrity sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ== +use-memo-one@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" + integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== -use-sync-external-store@1.2.0: +use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==