Compare commits

..

3 Commits

Author SHA1 Message Date
Marshall Polaris
99978118a5 Write migration script to fix up old contract/post/comment text 2022-09-23 14:09:17 -07:00
Marshall Polaris
5cda037f6b Store all text content as JSON-serialized ProseMirror docs 2022-09-23 14:09:17 -07:00
Marshall Polaris
d309a4f31b Extract utility method 2022-09-23 14:09:17 -07:00
331 changed files with 6470 additions and 17715 deletions

View File

@ -1,17 +0,0 @@
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 }}

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'warn', 'unused-imports/no-unused-imports': 'error',
}, },
}, },
], ],

View File

@ -1,4 +1,4 @@
import { getCpmmLiquidity } from './calculate-cpmm' import { addCpmmLiquidity, getCpmmLiquidity } from './calculate-cpmm'
import { CPMMContract } from './contract' import { CPMMContract } from './contract'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
@ -8,23 +8,25 @@ export const getNewLiquidityProvision = (
contract: CPMMContract, contract: CPMMContract,
newLiquidityProvisionId: string newLiquidityProvisionId: string
) => { ) => {
const { pool, p, totalLiquidity, subsidyPool } = contract const { pool, p, totalLiquidity } = contract
const liquidity = getCpmmLiquidity(pool, p) const { newPool, newP } = addCpmmLiquidity(pool, p, amount)
const liquidity =
getCpmmLiquidity(newPool, newP) - getCpmmLiquidity(pool, newP)
const newLiquidityProvision: LiquidityProvision = { const newLiquidityProvision: LiquidityProvision = {
id: newLiquidityProvisionId, id: newLiquidityProvisionId,
userId: userId, userId: userId,
contractId: contract.id, contractId: contract.id,
amount, amount,
pool, pool: newPool,
p, p: newP,
liquidity, liquidity,
createdTime: Date.now(), createdTime: Date.now(),
} }
const newTotalLiquidity = (totalLiquidity ?? 0) + amount const newTotalLiquidity = (totalLiquidity ?? 0) + amount
const newSubsidyPool = (subsidyPool ?? 0) + amount
return { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } return { newLiquidityProvision, newPool, newP, newTotalLiquidity }
} }

View File

@ -1,123 +0,0 @@
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
}

View File

@ -1,10 +1,11 @@
import { groupBy, mapValues, sumBy } from 'lodash' import { sum, groupBy, mapValues, sumBy } from 'lodash'
import { LimitBet } from './bet' import { LimitBet } from './bet'
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees' import { CREATOR_FEE, Fees, LIQUIDITY_FEE, PLATFORM_FEE } from './fees'
import { LiquidityProvision } from './liquidity-provision' import { LiquidityProvision } from './liquidity-provision'
import { computeFills } from './new-bet' import { computeFills } from './new-bet'
import { binarySearch } from './util/algos' import { binarySearch } from './util/algos'
import { addObjects } from './util/object'
export type CpmmState = { export type CpmmState = {
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
@ -146,8 +147,7 @@ function calculateAmountToBuyShares(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) { ) {
// Search for amount between bounds (0, shares). // Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each. // Min share price is M$0, and max is M$1 each.
@ -157,8 +157,7 @@ function calculateAmountToBuyShares(
amount, amount,
state, state,
undefined, undefined,
unfilledBets, unfilledBets
balanceByUserId
) )
const totalShares = sumBy(takers, (taker) => taker.shares) const totalShares = sumBy(takers, (taker) => taker.shares)
@ -170,8 +169,7 @@ export function calculateCpmmSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) { ) {
if (Math.round(shares) < 0) { if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares') throw new Error('Cannot sell non-positive shares')
@ -182,17 +180,15 @@ export function calculateCpmmSale(
state, state,
shares, shares,
oppositeOutcome, oppositeOutcome,
unfilledBets, unfilledBets
balanceByUserId
) )
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills( const { cpmmState, makers, takers, totalFees } = computeFills(
oppositeOutcome, oppositeOutcome,
buyAmount, buyAmount,
state, state,
undefined, undefined,
unfilledBets, unfilledBets
balanceByUserId
) )
// Transform buys of opposite outcome into sells. // Transform buys of opposite outcome into sells.
@ -215,7 +211,6 @@ export function calculateCpmmSale(
fees: totalFees, fees: totalFees,
makers, makers,
takers: saleTakers, takers: saleTakers,
ordersToCancel,
} }
} }
@ -223,16 +218,9 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState, state: CpmmState,
shares: number, shares: number,
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) { ) {
const { cpmmState } = calculateCpmmSale( const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
state,
shares,
outcome,
unfilledBets,
balanceByUserId
)
return getCpmmProbability(cpmmState.pool, cpmmState.p) return getCpmmProbability(cpmmState.pool, cpmmState.p)
} }
@ -266,22 +254,48 @@ export function addCpmmLiquidity(
return { newPool, liquidity, newP } return { newPool, liquidity, newP }
} }
export function getCpmmLiquidityPoolWeights(liquidities: LiquidityProvision[]) { const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
const userAmounts = groupBy(liquidities, (w) => w.userId) const oldLiquidity = getCpmmLiquidity(l.pool, p)
const totalAmount = sumBy(liquidities, (w) => w.amount)
return mapValues( const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
userAmounts, const newLiquidity = getCpmmLiquidity(newPool, p)
(amounts) => sumBy(amounts, (w) => w.amount) / totalAmount
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 totalUserWeights
} }
export function getUserLiquidityShares( export function getUserLiquidityShares(
userId: string, userId: string,
state: CpmmState, state: CpmmState,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[],
excludeAntes: boolean
) { ) {
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(state, liquidities, excludeAntes)
const userWeight = weights[userId] ?? 0 const userWeight = weights[userId] ?? 0
return mapValues(state.pool, (shares) => userWeight * shares) return mapValues(state.pool, (shares) => userWeight * shares)

View File

@ -1,17 +1,9 @@
import { Dictionary, groupBy, last, partition, sum, sumBy, uniq } from 'lodash' import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout, getContractBetMetrics } from './calculate' import { calculatePayout } from './calculate'
import { Bet, LimitBet } from './bet' import { Bet } from './bet'
import { import { Contract } from './contract'
Contract,
CPMMBinaryContract,
CPMMContract,
DPMContract,
} from './contract'
import { PortfolioMetrics, User } from './user' import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time' import { DAY_MS } from './util/time'
import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet'
import { getCpmmProbability } from './calculate-cpmm'
import { removeUndefinedProps } from './util/object'
const computeInvestmentValue = ( const computeInvestmentValue = (
bets: Bet[], bets: Bet[],
@ -29,93 +21,6 @@ const computeInvestmentValue = (
}) })
} }
export const computeInvestmentValueCustomProb = (
bets: Bet[],
contract: Contract,
p: number
) => {
return sumBy(bets, (bet) => {
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const { outcome, shares } = bet
const betP = outcome === 'YES' ? p : 1 - p
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 computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter( const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime (contract) => contract.createdTime >= startTime
@ -199,9 +104,14 @@ export const calculateNewPortfolioMetrics = (
} }
const calculateProfitForPeriod = ( const calculateProfitForPeriod = (
startingPortfolio: PortfolioMetrics | undefined, startTime: number,
descendingPortfolio: PortfolioMetrics[],
currentProfit: number currentProfit: number
) => { ) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) { if (startingPortfolio === undefined) {
return currentProfit return currentProfit
} }
@ -216,100 +126,33 @@ export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
} }
export const calculateNewProfit = ( export const calculateNewProfit = (
portfolioHistory: Record< portfolioHistory: PortfolioMetrics[],
'current' | 'day' | 'week' | 'month',
PortfolioMetrics | undefined
>,
newPortfolio: PortfolioMetrics newPortfolio: PortfolioMetrics
) => { ) => {
const allTimeProfit = calculatePortfolioProfit(newPortfolio) const allTimeProfit = calculatePortfolioProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = { const newProfit = {
daily: calculateProfitForPeriod(portfolioHistory.day, allTimeProfit), daily: calculateProfitForPeriod(
weekly: calculateProfitForPeriod(portfolioHistory.week, allTimeProfit), Date.now() - 1 * DAY_MS,
monthly: calculateProfitForPeriod(portfolioHistory.month, allTimeProfit), descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
allTime: allTimeProfit, allTime: allTimeProfit,
} }
return newProfit return newProfit
} }
export const calculateMetricsByContract = (
bets: Bet[],
contractsById: Dictionary<Contract>
) => {
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<
typeof calculateMetricsByContract
>[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, recentBets] = partition(
bets,
(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: recentProfit, invested: recentInvested } =
getContractBetMetrics(contract, recentBets)
const profit = currentBetsValue - previousBetsValue + recentProfit
const invested = previousBetsValue + recentInvested
const profitPercent = invested === 0 ? 0 : 100 * (profit / invested)
return {
profit,
profitPercent,
invested,
prevValue: previousBetsValue,
value: currentBetsValue,
}
}

View File

@ -78,8 +78,7 @@ export function calculateShares(
export function calculateSaleAmount( export function calculateSaleAmount(
contract: Contract, contract: Contract,
bet: Bet, bet: Bet,
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' && return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' || (contract.outcomeType === 'BINARY' ||
@ -88,8 +87,7 @@ export function calculateSaleAmount(
contract, contract,
Math.abs(bet.shares), Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO', bet.outcome as 'YES' | 'NO',
unfilledBets, unfilledBets
balanceByUserId
).saleValue ).saleValue
: calculateDpmSaleAmount(contract, bet) : calculateDpmSaleAmount(contract, bet)
} }
@ -104,16 +102,14 @@ export function getProbabilityAfterSale(
contract: Contract, contract: Contract,
outcome: string, outcome: string,
shares: number, shares: number,
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) { ) {
return contract.mechanism === 'cpmm-1' return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale( ? getCpmmProbabilityAfterSale(
contract, contract,
shares, shares,
outcome as 'YES' | 'NO', outcome as 'YES' | 'NO',
unfilledBets, unfilledBets
balanceByUserId
) )
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
} }
@ -178,8 +174,6 @@ function getDpmInvested(yourBets: Bet[]) {
}) })
} }
export type ContractBetMetrics = ReturnType<typeof getContractBetMetrics>
export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
const { resolution } = contract const { resolution } = contract
const isCpmm = contract.mechanism === 'cpmm-1' const isCpmm = contract.mechanism === 'cpmm-1'
@ -216,8 +210,9 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
} }
} }
const netPayout = payout - loan
const profit = payout + saleValue + redeemed - totalInvested const profit = payout + saleValue + redeemed - totalInvested
const profitPercent = totalInvested === 0 ? 0 : (profit / totalInvested) * 100 const profitPercent = (profit / totalInvested) * 100
const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets)
const hasShares = Object.values(totalShares).some( const hasShares = Object.values(totalShares).some(
@ -226,8 +221,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
return { return {
invested, invested,
loan,
payout, payout,
netPayout,
profit, profit,
profitPercent, profitPercent,
totalShares, totalShares,
@ -238,8 +233,8 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) {
export function getContractBetNullMetrics() { export function getContractBetNullMetrics() {
return { return {
invested: 0, invested: 0,
loan: 0,
payout: 0, payout: 0,
netPayout: 0,
profit: 0, profit: 0,
profitPercent: 0, profitPercent: 0,
totalShares: {} as { [outcome: string]: number }, totalShares: {} as { [outcome: string]: number },

View File

@ -589,15 +589,6 @@ 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.`, 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) => { ].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-') const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return { return {

View File

@ -1,5 +1,3 @@
import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup | OnPost export type AnyCommentType = OnContract | OnGroup | OnPost
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
@ -8,17 +6,13 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
id: string id: string
replyToCommentId?: string replyToCommentId?: string
userId: string userId: string
content: string
/** @deprecated - content now stored as JSON in content*/
text?: string
content: JSONContent
createdTime: number createdTime: number
// Denormalized, for rendering comments // Denormalized, for rendering comments
userName: string userName: string
userUsername: string userUsername: string
userAvatarUrl?: string userAvatarUrl?: string
bountiesAwarded?: number
} & T } & T
export type OnContract = { export type OnContract = {

View File

@ -30,7 +30,7 @@ export function contractTextDetails(contract: Contract) {
const { closeTime, groupLinks } = contract const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const groupHashtags = groupLinks?.map((g) => `#${g.name.replace(/ /g, '')}`) const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
return ( return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +

View File

@ -1,6 +1,5 @@
import { Answer } from './answer' import { Answer } from './answer'
import { Fees } from './fees' import { Fees } from './fees'
import { JSONContent } from '@tiptap/core'
import { GroupLink } from 'common/group' import { GroupLink } from 'common/group'
export type AnyMechanism = DPM | CPMM export type AnyMechanism = DPM | CPMM
@ -10,7 +9,6 @@ export type AnyOutcomeType =
| PseudoNumeric | PseudoNumeric
| FreeResponse | FreeResponse
| Numeric | Numeric
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)
@ -29,7 +27,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
creatorAvatarUrl?: string creatorAvatarUrl?: string
question: string question: string
description: string | JSONContent // More info about what the contract is about description: string // More info about what the contract is about
tags: string[] tags: string[]
lowercaseTags: string[] lowercaseTags: string[]
visibility: visibility visibility: visibility
@ -50,7 +48,6 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume: number volume: number
volume24Hours: number volume24Hours: number
volume7Days: number volume7Days: number
elasticity: number
collectedFees: Fees collectedFees: Fees
@ -64,9 +61,6 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
featuredOnHomeRank?: number featuredOnHomeRank?: number
likedByUserIds?: string[] likedByUserIds?: string[]
likedByUserCount?: number likedByUserCount?: number
flaggedByUsernames?: string[]
openCommentBounties?: number
unlistedById?: string
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary
@ -92,8 +86,7 @@ export type CPMM = {
mechanism: 'cpmm-1' mechanism: 'cpmm-1'
pool: { [outcome: string]: number } pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // for historical reasons, this the total subsidy amount added in M$ totalLiquidity: number // in M$
subsidyPool: number // current value of subsidy pool in M$
prob: number prob: number
probChanges: { probChanges: {
day: number day: number

View File

@ -11,10 +11,7 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT = export const BETTING_STREAK_BONUS_AMOUNT =
econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5 econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 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 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

View File

@ -16,6 +16,6 @@ export const DEV_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba', cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets', // this is Phil's deployment
sprigEnvironmentId: 'Tu7kRZPm7daP', twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app',
} }

View File

@ -3,7 +3,6 @@ export type EnvConfig = {
firebaseConfig: FirebaseConfig firebaseConfig: FirebaseConfig
amplitudeApiKey?: string amplitudeApiKey?: string
twitchBotEndpoint?: string twitchBotEndpoint?: string
sprigEnvironmentId?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and // IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
@ -41,7 +40,6 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number FREE_MARKETS_PER_USER_MAX?: number
COMMENT_BOUNTY_AMOUNT?: number
} }
type FirebaseConfig = { type FirebaseConfig = {
@ -58,7 +56,6 @@ type FirebaseConfig = {
export const PROD_CONFIG: EnvConfig = { export const PROD_CONFIG: EnvConfig = {
domain: 'manifold.markets', domain: 'manifold.markets',
amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15',
sprigEnvironmentId: 'sQcrq9TDqkib',
firebaseConfig: { firebaseConfig: {
apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw',
@ -70,7 +67,7 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7', appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D', measurementId: 'G-SSFK1Q138D',
}, },
twitchBotEndpoint: 'https://twitch-bot.manifold.markets', twitchBotEndpoint: 'https://twitch-bot-nggbo3neva-uc.a.run.app',
cloudRunId: 'nggbo3neva', cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc', cloudRunRegion: 'uc',
adminEmails: [ adminEmails: [

View File

@ -1,5 +1,3 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0 export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0 export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0 export const LIQUIDITY_FEE = 0

View File

@ -1,3 +0,0 @@
export type GlobalConfig = {
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}

View File

@ -23,7 +23,6 @@ export type Group = {
score: number score: number
}[] }[]
} }
pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75
@ -39,4 +38,3 @@ export type GroupLink = {
createdTime: number createdTime: number
userId?: string userId?: string
} }
export type GroupContractDoc = { contractId: string; createdTime: number }

View File

@ -1,9 +1,8 @@
export type Like = { export type Like = {
id: string // will be id of the object liked, i.e. contract.id id: string // will be id of the object liked, i.e. contract.id
userId: string userId: string
type: 'contract' | 'post' type: 'contract'
createdTime: number createdTime: number
tipTxnId?: string // only holds most recent tip txn id tipTxnId?: string // only holds most recent tip txn id
} }
export const LIKE_TIP_AMOUNT = 10 export const LIKE_TIP_AMOUNT = 5
export const TIP_UNDO_DURATION = 2000

View File

@ -17,7 +17,8 @@ import {
import { import {
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
DPMContract, FreeResponseContract,
MultipleChoiceContract,
NumericContract, NumericContract,
PseudoNumericContract, PseudoNumericContract,
} from './contract' } from './contract'
@ -143,8 +144,7 @@ export const computeFills = (
betAmount: number, betAmount: number,
state: CpmmState, state: CpmmState,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) => { ) => {
if (isNaN(betAmount)) { if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}') throw new Error('Invalid bet amount: ${betAmount}')
@ -166,12 +166,10 @@ export const computeFills = (
shares: number shares: number
timestamp: number timestamp: number
}[] = [] }[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p } let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0 let i = 0
while (true) { while (true) {
@ -188,20 +186,9 @@ export const computeFills = (
takers.push(taker) takers.push(taker)
} else { } else {
// Matched against bet. // 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) takers.push(taker)
makers.push(maker) makers.push(maker)
i++
} }
amount -= taker.amount amount -= taker.amount
@ -209,7 +196,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break if (floatingEqual(amount, 0)) break
} }
return { takers, makers, totalFees, cpmmState, ordersToCancel } return { takers, makers, totalFees, cpmmState }
} }
export const getBinaryCpmmBetInfo = ( export const getBinaryCpmmBetInfo = (
@ -217,17 +204,15 @@ export const getBinaryCpmmBetInfo = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined, limitProb: number | undefined,
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills( const { takers, makers, cpmmState, totalFees } = computeFills(
outcome, outcome,
betAmount, betAmount,
{ pool, p }, { pool, p },
limitProb, limitProb,
unfilledBets, unfilledBets
balanceByUserId
) )
const probBefore = getCpmmProbability(contract.pool, contract.p) const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -262,7 +247,6 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p, newP: cpmmState.p,
newTotalLiquidity, newTotalLiquidity,
makers, makers,
ordersToCancel,
} }
} }
@ -271,16 +255,14 @@ export const getBinaryBetStats = (
betAmount: number, betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract, contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number, limitProb: number,
unfilledBets: LimitBet[], unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
) => { ) => {
const { newBet } = getBinaryCpmmBetInfo( const { newBet } = getBinaryCpmmBetInfo(
outcome, outcome,
betAmount ?? 0, betAmount ?? 0,
contract, contract,
limitProb, limitProb,
unfilledBets, unfilledBets as LimitBet[]
balanceByUserId
) )
const remainingMatched = const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) / ((newBet.orderAmount ?? 0) - newBet.amount) /
@ -343,7 +325,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: DPMContract contract: FreeResponseContract | MultipleChoiceContract
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -12,6 +12,7 @@ import {
visibility, visibility,
} from './contract' } from './contract'
import { User } from './user' import { User } from './user'
import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object' import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
@ -37,6 +38,15 @@ export function getNewContract(
answers: string[], answers: string[],
visibility: visibility visibility: visibility
) { ) {
const tags = parseTags(
[
question,
richTextToString(description),
...extraTags.map((tag) => `#${tag}`),
].join(' ')
)
const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType = const propsByOutcomeType =
outcomeType === 'BINARY' outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
@ -59,11 +69,10 @@ export function getNewContract(
creatorAvatarUrl: creator.avatarUrl, creatorAvatarUrl: creator.avatarUrl,
question: question.trim(), question: question.trim(),
description, description: JSON.stringify(description),
tags: [], tags,
lowercaseTags: [], lowercaseTags,
visibility, visibility,
unlistedById: visibility === 'unlisted' ? creator.id : undefined,
isResolved: false, isResolved: false,
createdTime: Date.now(), createdTime: Date.now(),
closeTime, closeTime,
@ -71,7 +80,6 @@ export function getNewContract(
volume: 0, volume: 0,
volume24Hours: 0, volume24Hours: 0,
volume7Days: 0, volume7Days: 0,
elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75,
collectedFees: { collectedFees: {
creatorFee: 0, creatorFee: 0,
@ -112,7 +120,6 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
mechanism: 'cpmm-1', mechanism: 'cpmm-1',
outcomeType: 'BINARY', outcomeType: 'BINARY',
totalLiquidity: ante, totalLiquidity: ante,
subsidyPool: 0,
initialProbability: p, initialProbability: p,
p, p,
pool: pool, pool: pool,

View File

@ -4,7 +4,7 @@ export type Notification = {
id: string id: string
userId: string userId: string
reasonText?: string reasonText?: string
reason?: notification_reason_types | notification_preference reason?: notification_reason_types
createdTime: number createdTime: number
viewTime?: number viewTime?: number
isSeen: boolean isSeen: boolean
@ -46,7 +46,6 @@ export type notification_source_types =
| 'loan' | 'loan'
| 'like' | 'like'
| 'tip_and_like' | 'tip_and_like'
| 'badge'
export type notification_source_update_types = export type notification_source_update_types =
| 'created' | 'created'
@ -97,7 +96,6 @@ type notification_descriptions = {
[key in notification_preference]: { [key in notification_preference]: {
simple: string simple: string
detailed: string detailed: string
necessary?: boolean
} }
} }
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@ -118,8 +116,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: "Only answers by market creator on markets you're watching", detailed: "Only answers by market creator on markets you're watching",
}, },
betting_streaks: { betting_streaks: {
simple: `For prediction streaks`, simple: 'For predictions made over consecutive days',
detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`, detailed: 'Bonuses for predictions made over consecutive days',
}, },
comments_by_followed_users_on_watched_markets: { comments_by_followed_users_on_watched_markets: {
simple: 'Only comments by users you follow', simple: 'Only comments by users you follow',
@ -161,8 +159,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Large changes in probability on markets that you watch', detailed: 'Large changes in probability on markets that you watch',
}, },
profit_loss_updates: { profit_loss_updates: {
simple: 'Weekly portfolio updates', simple: 'Weekly profit and loss updates',
detailed: 'Weekly portfolio updates', detailed: 'Weekly profit and loss updates',
}, },
referral_bonuses: { referral_bonuses: {
simple: 'For referring new users', simple: 'For referring new users',
@ -210,9 +208,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets', detailed: 'Bonuses for unique predictors on your markets',
}, },
your_contract_closed: { your_contract_closed: {
simple: 'Your market has closed and you need to resolve it (necessary)', simple: 'Your market has closed and you need to resolve it',
detailed: 'Your market has closed and you need to resolve it (necessary)', detailed: 'Your market has closed and you need to resolve it',
necessary: true,
}, },
all_comments_on_watched_markets: { all_comments_on_watched_markets: {
simple: 'All new comments', simple: 'All new comments',
@ -238,15 +235,6 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`, simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that 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 = { export type BettingStreakData = {

View File

@ -8,13 +8,11 @@
}, },
"sideEffects": false, "sideEffects": false,
"dependencies": { "dependencies": {
"@tiptap/core": "2.0.0-beta.199", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.199", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.199", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.199", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/html": "2.0.0-beta.199", "@tiptap/starter-kit": "2.0.0-beta.191",
"@tiptap/starter-kit": "2.0.0-beta.199",
"@tiptap/suggestion": "2.0.0-beta.199",
"lodash": "4.17.21" "lodash": "4.17.21"
}, },
"devDependencies": { "devDependencies": {

View File

@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = (
const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal
const profit = winnings - amount const profit = winnings - amount
const payout = amount + (1 - DPM_FEES) * profit const payout = amount + (1 - DPM_FEES) * Math.max(0, profit)
return { userId, profit, payout } return { userId, profit, payout }
}) })

View File

@ -1,3 +1,4 @@
import { Bet } from './bet' import { Bet } from './bet'
import { getProbability } from './calculate' import { getProbability } from './calculate'
import { getCpmmLiquidityPoolWeights } from './calculate-cpmm' import { getCpmmLiquidityPoolWeights } from './calculate-cpmm'
@ -55,11 +56,10 @@ export const getLiquidityPoolPayouts = (
outcome: string, outcome: string,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool, subsidyPool } = contract const { pool } = contract
const finalPool = pool[outcome] + (subsidyPool ?? 0) const finalPool = pool[outcome]
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
return Object.entries(weights).map(([providerId, weight]) => ({ return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId, userId: providerId,
@ -95,11 +95,10 @@ export const getLiquidityPoolProbPayouts = (
p: number, p: number,
liquidities: LiquidityProvision[] liquidities: LiquidityProvision[]
) => { ) => {
const { pool, subsidyPool } = contract const { pool } = contract
const finalPool = p * pool.YES + (1 - p) * pool.NO + (subsidyPool ?? 0) const finalPool = p * pool.YES + (1 - p) * pool.NO
if (finalPool < 1e-3) return []
const weights = getCpmmLiquidityPoolWeights(liquidities) const weights = getCpmmLiquidityPoolWeights(contract, liquidities, false)
return Object.entries(weights).map(([providerId, weight]) => ({ return Object.entries(weights).map(([providerId, weight]) => ({
userId: providerId, userId: providerId,

View File

@ -1,29 +1,10 @@
import { JSONContent } from '@tiptap/core'
export type Post = { export type Post = {
id: string id: string
title: string title: string
subtitle: string content: string
content: JSONContent
creatorId: string // User id creatorId: string // User id
createdTime: number createdTime: number
slug: string slug: string
// denormalized user fields
creatorName: string
creatorUsername: string
creatorAvatarUrl?: string
likedByUserIds?: string[]
likedByUserCount?: number
}
export type DateDoc = Post & {
bounty: number
birthday: number
type: 'date-doc'
contractSlug: string
} }
export const MAX_POST_TITLE_LENGTH = 480 export const MAX_POST_TITLE_LENGTH = 480
export const MAX_POST_SUBTITLE_LENGTH = 480

View File

@ -1,9 +1,8 @@
import { groupBy, sumBy, mapValues, keyBy, sortBy } from 'lodash' import { groupBy, sumBy, mapValues } from 'lodash'
import { Bet } from './bet' import { Bet } from './bet'
import { getContractBetMetrics, resolvedPayout } from './calculate' import { getContractBetMetrics } from './calculate'
import { Contract } from './contract' import { Contract } from './contract'
import { ContractComment } from './comment'
export function scoreCreators(contracts: Contract[]) { export function scoreCreators(contracts: Contract[]) {
const creatorScore = mapValues( const creatorScore = mapValues(
@ -31,11 +30,8 @@ export function scoreTraders(contracts: Contract[], bets: Bet[][]) {
} }
export function scoreUsersByContract(contract: Contract, bets: Bet[]) { export function scoreUsersByContract(contract: Contract, bets: Bet[]) {
const betsByUser = groupBy(bets, (bet) => bet.userId) const betsByUser = groupBy(bets, bet => bet.userId)
return mapValues( return mapValues(betsByUser, bets => getContractBetMetrics(contract, bets).profit)
betsByUser,
(bets) => getContractBetMetrics(contract, bets).profit
)
} }
export function addUserScores( export function addUserScores(
@ -47,47 +43,3 @@ export function addUserScores(
dest[userId] += score 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<string, number> = {}
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,
}
}

View File

@ -84,17 +84,15 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO', outcome: 'YES' | 'NO',
contract: CPMMContract, contract: CPMMContract,
unfilledBets: LimitBet[], unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number loanPaid: number
) => { ) => {
const { pool, p } = contract const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale( const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
contract, contract,
shares, shares,
outcome, outcome,
unfilledBets, unfilledBets
balanceByUserId,
) )
const probBefore = getCpmmProbability(pool, p) const probBefore = getCpmmProbability(pool, p)
@ -136,6 +134,5 @@ export const getCpmmSellBetInfo = (
fees, fees,
makers, makers,
takers, takers,
ordersToCancel
} }
} }

View File

@ -8,7 +8,6 @@ type AnyTxnType =
| UniqueBettorBonus | UniqueBettorBonus
| BettingStreakBonus | BettingStreakBonus
| CancelUniqueBettorBonus | CancelUniqueBettorBonus
| CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = { export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -32,8 +31,6 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
| 'UNIQUE_BETTOR_BONUS' | 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS' | 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS'
| 'COMMENT_BOUNTY'
| 'REFUND_COMMENT_BOUNTY'
// Any extra data // Any extra data
data?: { [key: string]: any } data?: { [key: string]: any }
@ -101,34 +98,6 @@ type CancelUniqueBettorBonus = {
} }
} }
type CommentBountyDeposit = {
fromType: 'USER'
toType: 'BANK'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
}
}
type CommentBountyWithdrawal = {
fromType: 'BANK'
toType: 'USER'
category: 'COMMENT_BOUNTY'
data: {
contractId: string
commentId: string
}
}
type CommentBountyRefund = {
fromType: 'BANK'
toType: 'USER'
category: 'REFUND_COMMENT_BOUNTY'
data: {
contractId: string
}
}
export type DonationTxn = Txn & Donation export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink export type ManalinkTxn = Txn & Manalink
@ -136,5 +105,3 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal

View File

@ -53,9 +53,6 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[] profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[] onboarding_flow: notification_destination_types[]
thank_you_for_purchases: 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 = ( export const getDefaultNotificationPreferences = (
@ -68,7 +65,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[] return filterDefined([browser, email]) as notification_destination_types[]
} }
const defaults: notification_preferences = { return {
// Watched Markets // Watched Markets
all_comments_on_watched_markets: constructPref(true, false), all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false),
@ -110,7 +107,7 @@ export const getDefaultNotificationPreferences = (
loan_income: constructPref(true, false), loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false), betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true), referral_bonuses: constructPref(true, true),
unique_bettors_on_your_contract: constructPref(true, true), unique_bettors_on_your_contract: constructPref(true, false),
tipped_comments_on_watched_markets: constructPref(true, true), tipped_comments_on_watched_markets: constructPref(true, true),
tips_on_your_markets: constructPref(true, true), tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false), limit_order_fills: constructPref(true, false),
@ -124,11 +121,7 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false), probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false), thank_you_for_purchases: constructPref(false, false),
onboarding_flow: 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 // Adding a new key:value here is optional, you can just use a key of notification_subscription_types
@ -179,8 +172,6 @@ export const getNotificationDestinationsForUser = (
reason: notification_reason_types | notification_preference reason: notification_reason_types | notification_preference
) => { ) => {
const notificationSettings = privateUser.notificationPreferences const notificationSettings = privateUser.notificationPreferences
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
try {
let destinations let destinations
let subscriptionType: notification_preference | undefined let subscriptionType: notification_preference | undefined
if (Object.keys(notificationSettings).includes(reason)) { if (Object.keys(notificationSettings).includes(reason)) {
@ -193,30 +184,11 @@ export const getNotificationDestinationsForUser = (
? notificationSettings[subscriptionType] ? notificationSettings[subscriptionType]
: [] : []
} }
const optOutOfAllSettings = notificationSettings['opt_out_all'] const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
// 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 { return {
sendToEmail: destinations.includes('email') && !optedOutOfEmail, sendToEmail: destinations.includes('email'),
sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, sendToBrowser: destinations.includes('browser'),
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings&section=${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: '',
}
}
} }

View File

@ -1,6 +1,5 @@
import { notification_preferences } from './user-notification-preferences' import { notification_preferences } from './user-notification-preferences'
import { ENV_CONFIG } from './envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { MarketCreatorBadge, ProvenCorrectBadge, StreakerBadge } from './badge'
export type User = { export type User = {
id: string id: string
@ -12,6 +11,7 @@ export type User = {
// For their user page // For their user page
bio?: string bio?: string
bannerUrl?: string
website?: string website?: string
twitterHandle?: string twitterHandle?: string
discordHandle?: string discordHandle?: string
@ -33,8 +33,6 @@ export type User = {
allTime: number allTime: number
} }
fractionResolvedCorrectly: number
nextLoanCached: number nextLoanCached: number
followerCountCached: number followerCountCached: number
@ -51,18 +49,6 @@ export type User = {
hasSeenContractFollowModal?: boolean hasSeenContractFollowModal?: boolean
freeMarketsCreated?: number freeMarketsCreated?: number
isBannedFromPosting?: boolean isBannedFromPosting?: boolean
achievements: {
provenCorrect?: {
badges: ProvenCorrectBadge[]
}
marketCreator?: {
badges: MarketCreatorBadge[]
}
streaker?: {
badges: StreakerBadge[]
}
}
} }
export type PrivateUser = { export type PrivateUser = {
@ -71,7 +57,6 @@ export type PrivateUser = {
email?: string email?: string
weeklyTrendingEmailSent?: boolean weeklyTrendingEmailSent?: boolean
weeklyPortfolioUpdateEmailSent?: boolean
manaBonusEmailSent?: boolean manaBonusEmailSent?: boolean
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string
@ -93,8 +78,7 @@ export type PortfolioMetrics = {
userId: string userId: string
} }
export const MANIFOLD_USER_USERNAME = 'ManifoldMarkets' export const MANIFOLD_USERNAME = 'ManifoldMarkets'
export const MANIFOLD_USER_NAME = 'ManifoldMarkets'
export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png'
// TODO: remove. Hardcoding the strings would be better. // TODO: remove. Hardcoding the strings would be better.

View File

@ -1,24 +0,0 @@
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]
}

View File

@ -8,14 +8,7 @@ const formatter = new Intl.NumberFormat('en-US', {
}) })
export function formatMoney(amount: number) { export function formatMoney(amount: number) {
const newAmount = const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case
// handle -0 case
Math.round(amount) === 0
? 0
: // Handle 499.9999999999999 case
(amount > 0 ? Math.floor : Math.ceil)(
amount + 0.00000000001 * Math.sign(amount)
)
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
} }
@ -60,16 +53,6 @@ export function formatLargeNumber(num: number, sigfigs = 2): string {
return `${numStr}${suffix[i] ?? ''}` 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) { export function toCamelCase(words: string) {
const camelCase = words const camelCase = words
.split(' ') .split(' ')

View File

@ -1,5 +1,5 @@
import { generateText, JSONContent, Node } from '@tiptap/core' import { MAX_TAG_LENGTH } from '../contract'
import { generateJSON } from '@tiptap/html' import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions // Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote' import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold' import { Bold } from '@tiptap/extension-bold'
@ -25,7 +25,6 @@ import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) { export function getUrl(text: string) {
@ -33,6 +32,34 @@ export function getUrl(text: string) {
return results.length ? results[0].href : null 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 // TODO: fuzzy matching
export const wordIn = (word: string, corpus: string) => export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
@ -52,28 +79,20 @@ export function parseMentions(data: JSONContent): string[] {
return uniq(mentions) return uniq(mentions)
} }
// TODO: this is a hack to get around the fact that tiptap doesn't have a export const plainTextToProseMirror = (text: string): JSONContent => {
// way to add a node view without bundling in tsx return {
function skippableComponent(name: string): Node<any, any> { type: 'doc',
return Node.create({ content: [
name,
group: 'block',
content: 'inline*',
parseHTML() {
return [
{ {
tag: 'grid-cards-component', type: 'paragraph',
content: [{ type: 'text', text }],
}, },
] ],
}, }
})
} }
const stringParseExts = [ // can't just do [StarterKit, Image...] because it doesn't work with cjs imports
// StarterKit extensions export const exhibitExts = [
Blockquote, Blockquote,
Bold, Bold,
BulletList, BulletList,
@ -90,26 +109,14 @@ const stringParseExts = [
Paragraph, Paragraph,
Strike, Strike,
Text, Text,
// other extensions
Image,
Link, Link,
Image.extend({ renderText: () => '[image]' }), Mention,
Mention, // user @mention Iframe,
Mention.extend({ name: 'contract-mention' }), // market %mention TiptapTweet,
Iframe.extend({
renderText: ({ node }) =>
'[embed]' + node.attrs.src ? `(${node.attrs.src})` : '',
}),
skippableComponent('gridCardsComponent'),
skippableComponent('staticReactEmbedComponent'),
TiptapTweet.extend({ renderText: () => '[tweet]' }),
TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }),
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {
if (!text) return '' return !text ? '' : generateText(text, exhibitExts)
return generateText(text, stringParseExts)
}
export function htmlToRichText(html: string) {
return generateJSON(html, stringParseExts)
} }

View File

@ -1,116 +0,0 @@
// adapted from @n8body/tiptap-spoiler
import {
Mark,
markInputRule,
markPasteRule,
mergeAttributes,
} from '@tiptap/core'
import type { ElementType } from 'react'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
spoilerEditor: {
setSpoiler: () => ReturnType
toggleSpoiler: () => ReturnType
unsetSpoiler: () => ReturnType
}
}
}
export type SpoilerOptions = {
HTMLAttributes: Record<string, any>
spoilerOpenClass: string
spoilerCloseClass?: string
inputRegex: RegExp
pasteRegex: RegExp
as: ElementType
}
const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
export const TiptapSpoiler = Mark.create<SpoilerOptions>({
name: 'spoiler',
inline: true,
group: 'inline',
inclusive: false,
exitable: true,
content: 'inline*',
priority: 1001, // higher priority than other formatting so they go inside
addOptions() {
return {
HTMLAttributes: { 'aria-label': 'spoiler' },
spoilerOpenClass: '',
spoilerCloseClass: undefined,
inputRegex: spoilerInputRegex,
pasteRegex: spoilerPasteRegex,
as: 'span',
editing: false,
}
},
addCommands() {
return {
setSpoiler:
() =>
({ commands }) =>
commands.setMark(this.name),
toggleSpoiler:
() =>
({ commands }) =>
commands.toggleMark(this.name),
unsetSpoiler:
() =>
({ commands }) =>
commands.unsetMark(this.name),
}
},
addInputRules() {
return [
markInputRule({
find: this.options.inputRegex,
type: this.type,
}),
]
},
addPasteRules() {
return [
markPasteRule({
find: this.options.pasteRegex,
type: this.type,
}),
]
},
parseHTML() {
return [
{
tag: 'span',
getAttrs: (node) =>
(node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null,
},
]
},
renderHTML({ HTMLAttributes }) {
const elem = document.createElement(this.options.as as string)
Object.entries(
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass,
})
).forEach(([attr, val]) => elem.setAttribute(attr, val))
elem.addEventListener('click', () => {
elem.setAttribute('class', this.options.spoilerOpenClass)
})
return elem
},
})

View File

@ -55,7 +55,6 @@ Returns the authenticated user.
Gets all groups, in no particular order. Gets all groups, in no particular order.
Parameters: Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can - `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned. join and groups they've already joined will be returned.
@ -82,6 +81,7 @@ Gets a group's markets by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL. Note: group is singular in the URL.
### `GET /v0/markets` ### `GET /v0/markets`
Lists all markets, ordered by creation date descending. Lists all markets, ordered by creation date descending.
@ -158,16 +158,13 @@ Requires no authorization.
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
url: string url: string
outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
mechanism: string // dpm-2 or cpmm-1 mechanism: string // dpm-2 or cpmm-1
probability: number probability: number
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
volume: number volume: number
volume7Days: number volume7Days: number
@ -411,7 +408,7 @@ Requires no authorization.
type FullMarket = LiteMarket & { type FullMarket = LiteMarket & {
bets: Bet[] bets: Bet[]
comments: Comment[] comments: Comment[]
answers?: Answer[] // dpm-2 markets only answers?: Answer[]
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds textDescription: string // string description without formatting, images, or embeds
} }
@ -557,7 +554,7 @@ Creates a new market on behalf of the authorized user.
Parameters: Parameters:
- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`. - `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
- `question`: Required. The headline question for the market. - `question`: Required. The headline question for the market.
- `description`: Required. A long description describing the rules for the market. - `description`: Required. A long description describing the rules for the market.
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json). - Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
@ -572,12 +569,6 @@ For numeric markets, you must also provide:
- `min`: The minimum value that the market may resolve to. - `min`: The minimum value that the market may resolve to.
- `max`: The maximum value that the market may resolve to. - `max`: The maximum value that the market may resolve to.
- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
- `initialValue`: An initial value for the market, between min and max, exclusive.
For multiple choice markets, you must also provide:
- `answers`: An array of strings, each of which will be a valid answer for the market.
Example request: Example request:
@ -591,18 +582,13 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat
"initialProb":25}' "initialProb":25}'
``` ```
### `POST /v0/market/[marketId]/add-liquidity`
Adds a specified amount of liquidity into the market.
- `amount`: Required. The amount of liquidity to add, in M$.
### `POST /v0/market/[marketId]/close` ### `POST /v0/market/[marketId]/close`
Closes a market on behalf of the authorized user. Closes a market on behalf of the authorized user.
- `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past. - `closeTime`: Optional. Milliseconds since the epoch to close the market at. If not provided, the market will be closed immediately. Cannot provide close time in past.
### `POST /v0/market/[marketId]/resolve` ### `POST /v0/market/[marketId]/resolve`
Resolves a market on behalf of the authorized user. Resolves a market on behalf of the authorized user.
@ -614,18 +600,15 @@ For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution. - `probabilityInt`: Optional. The probability to use for `MKT` resolution.
For free response or multiple choice markets: For free response markets:
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. - `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100. - `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
For numeric markets: For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to. - `value`: The value that the market may resolves to.
- `probabilityInt`: Required if `value` is present. Should be equal to
- If log scale: `log10(value - min + 1) / log10(max - min + 1)`
- Otherwise: `(value - min) / (max - min)`
Example request: Example request:
@ -680,17 +663,6 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \
--data-raw '{"outcome": "YES", "shares": 10}' --data-raw '{"outcome": "YES", "shares": 10}'
``` ```
### `POST /v0/comment`
Creates a comment in the specified market. Only supports top-level comments for now.
Parameters:
- `contractId`: Required. The ID of the market to comment on.
- `content`: The comment to post, formatted as [TipTap json](https://tiptap.dev/guide/output#option-1-json), OR
- `html`: The comment to post, formatted as an HTML string, OR
- `markdown`: The comment to post, formatted as a markdown string.
### `GET /v0/bets` ### `GET /v0/bets`
Gets a list of bets, ordered by creation date descending. Gets a list of bets, ordered by creation date descending.
@ -780,7 +752,6 @@ Requires no authorization.
## Changelog ## Changelog
- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
- 2022-07-15: Add user by username and user by ID APIs - 2022-07-15: Add user by username and user by ID APIs
- 2022-06-08: Add paging to markets endpoint - 2022-06-08: Add paging to markets endpoint
- 2022-06-05: Add new authorized write endpoints - 2022-06-05: Add new authorized write endpoints

View File

@ -15,22 +15,6 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties ## 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* 🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -23,17 +23,11 @@ service cloud.firestore {
allow read; allow read;
} }
match /globalConfig/globalConfig {
allow read;
allow update: if isAdmin()
allow create: if isAdmin()
}
match /users/{userId} { match /users/{userId} {
allow read; allow read;
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']); .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal', 'homeSections']);
// User referral rules // User referral rules
allow update: if userId == request.auth.uid allow update: if userId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys() && request.resource.data.diff(resource.data).affectedKeys()
@ -50,10 +44,6 @@ service cloud.firestore {
allow read; allow read;
} }
match /{somePath=**}/contract-metrics/{contractId} {
allow read;
}
match /{somePath=**}/challenges/{challengeId}{ match /{somePath=**}/challenges/{challengeId}{
allow read; allow read;
} }
@ -110,9 +100,9 @@ service cloud.firestore {
match /contracts/{contractId} { match /contracts/{contractId} {
allow read; allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']); .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys() allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) .hasOnly(['description', 'closeTime', 'question'])
&& resource.data.creatorId == request.auth.uid; && resource.data.creatorId == request.auth.uid;
allow update: if isAdmin(); allow update: if isAdmin();
match /comments/{commentId} { match /comments/{commentId} {
@ -186,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]); .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} { match /groupContracts/{contractId} {

View File

@ -1,3 +0,0 @@
# This sets which EnvConfig is deployed to Firebase Cloud Functions
NEXT_PUBLIC_FIREBASE_ENV=DEV

View File

@ -26,7 +26,7 @@ module.exports = {
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_',
}, },
], ],
'unused-imports/no-unused-imports': 'warn', 'unused-imports/no-unused-imports': 'error',
}, },
}, },
], ],

View File

@ -20,7 +20,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
3. `$ firebase login` to authenticate the CLI tools to Firebase 3. `$ firebase login` to authenticate the CLI tools to Firebase
4. `$ firebase use dev` to choose the dev project 4. `$ firebase use dev` to choose the dev project
#### (Installing) For local development ### For local development
0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI 0. [Install](https://cloud.google.com/sdk/docs/install) gcloud CLI
1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`): 1. If you don't have java (or see the error `Error: Process java -version has exited with code 1. Please make sure Java is installed and on your system PATH.`):
@ -35,10 +35,10 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Developing locally ## Developing locally
0. `$ ./dev.sh localdb` to start the local emulator and front end 0. `$ firebase use dev` if you haven't already
1. If you change db trigger code, you have to start (doesn't have to complete) the deploy of it to dev to cause a hard emulator code refresh `$ firebase deploy --only functions:dbTriggerNameHere` 1. `$ yarn serve` to spin up the emulators 0. The Emulator UI is at http://localhost:4000; the functions are hosted on :5001.
- There's surely a better way to cause/react to a db trigger update but just adding this here for now as it works Note: You have to kill and restart emulators when you change code; no hot reload =(
2. If you want to test a scheduled function replace your function in `test-scheduled-function.ts` and send a GET to `http://localhost:8088/testscheduledfunction` (Best user experience is via [Postman](https://www.postman.com/downloads/)!) 2. `$ yarn dev:emulate` in `/web` to connect to emulators with the frontend 0. Note: emulated database is cleared after every shutdown
## Firestore Commands ## Firestore Commands

View File

@ -5,7 +5,7 @@
"firestore": "dev-mantic-markets.appspot.com" "firestore": "dev-mantic-markets.appspot.com"
}, },
"scripts": { "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.prod dist && cp .env.dev dist", "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist",
"compile": "tsc -b", "compile": "tsc -b",
"watch": "tsc -w", "watch": "tsc -w",
"shell": "yarn build && firebase functions:shell", "shell": "yarn build && firebase functions:shell",
@ -15,9 +15,9 @@
"dev": "nodemon src/serve.ts", "dev": "nodemon src/serve.ts",
"localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil -m mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)", "verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
@ -26,13 +26,11 @@
"dependencies": { "dependencies": {
"@amplitude/node": "1.10.0", "@amplitude/node": "1.10.0",
"@google-cloud/functions-framework": "3.1.2", "@google-cloud/functions-framework": "3.1.2",
"@tiptap/core": "2.0.0-beta.199", "@tiptap/core": "2.0.0-beta.182",
"@tiptap/extension-image": "2.0.0-beta.199", "@tiptap/extension-image": "2.0.0-beta.30",
"@tiptap/extension-link": "2.0.0-beta.199", "@tiptap/extension-link": "2.0.0-beta.43",
"@tiptap/extension-mention": "2.0.0-beta.199", "@tiptap/extension-mention": "2.0.0-beta.102",
"@tiptap/html": "2.0.0-beta.199", "@tiptap/starter-kit": "2.0.0-beta.191",
"@tiptap/starter-kit": "2.0.0-beta.199",
"@tiptap/suggestion": "2.0.0-beta.199",
"cors": "2.8.5", "cors": "2.8.5",
"dayjs": "1.11.4", "dayjs": "1.11.4",
"express": "4.18.1", "express": "4.18.1",
@ -40,19 +38,17 @@
"firebase-functions": "3.21.2", "firebase-functions": "3.21.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"mailgun-js": "0.22.0", "mailgun-js": "0.22.0",
"marked": "4.1.1",
"module-alias": "2.2.2", "module-alias": "2.2.2",
"node-fetch": "2", "node-fetch": "2",
"react-masonry-css": "1.0.16",
"stripe": "8.194.0", "stripe": "8.194.0",
"zod": "3.17.2" "zod": "3.17.2"
}, },
"devDependencies": { "devDependencies": {
"@types/mailgun-js": "0.22.12", "@types/mailgun-js": "0.22.12",
"@types/marked": "4.0.7",
"@types/module-alias": "2.0.1", "@types/module-alias": "2.0.1",
"@types/node-fetch": "2.6.2", "@types/node-fetch": "2.6.2",
"firebase-functions-test": "0.3.3", "firebase-functions-test": "0.3.3"
"puppeteer": "18.0.5"
}, },
"private": true "private": true
} }

View File

@ -3,18 +3,24 @@ import { z } from 'zod'
import { Contract, CPMMContract } from '../../common/contract' import { Contract, CPMMContract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { getNewLiquidityProvision } from '../../common/add-liquidity' import { getNewLiquidityProvision } from '../../common/add-liquidity'
import { APIError, newEndpoint, validate } from './api' 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({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
amount: z.number().gt(0), amount: z.number().gt(0),
}) })
export const addsubsidy = newEndpoint({}, async (req, auth) => { export const addliquidity = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body) const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount) || amount < 1) throw new APIError(400, 'Invalid amount') if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions // run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
@ -44,7 +50,7 @@ export const addsubsidy = newEndpoint({}, async (req, auth) => {
.collection(`contracts/${contractId}/liquidity`) .collection(`contracts/${contractId}/liquidity`)
.doc() .doc()
const { newLiquidityProvision, newTotalLiquidity, newSubsidyPool } = const { newLiquidityProvision, newPool, newP, newTotalLiquidity } =
getNewLiquidityProvision( getNewLiquidityProvision(
user.id, user.id,
amount, amount,
@ -52,10 +58,21 @@ export const addsubsidy = newEndpoint({}, async (req, auth) => {
newLiquidityProvisionDoc.id newLiquidityProvisionDoc.id
) )
transaction.update(contractDoc, { if (newP !== undefined && !isFinite(newP)) {
subsidyPool: newSubsidyPool, return {
status: 'error',
message: 'Liquidity injection rejected due to overflow error.',
}
}
transaction.update(
contractDoc,
removeUndefinedProps({
pool: newPool,
p: newP,
totalLiquidity: newTotalLiquidity, totalLiquidity: newTotalLiquidity,
} as Partial<CPMMContract>) })
)
const newBalance = user.balance - amount const newBalance = user.balance - amount
const newTotalDeposits = user.totalDeposits - amount const newTotalDeposits = user.totalDeposits - amount
@ -76,3 +93,41 @@ export const addsubsidy = newEndpoint({}, async (req, auth) => {
}) })
const firestore = admin.firestore() 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)
})
}

View File

@ -14,7 +14,7 @@ import {
export { APIError } from '../../common/api' export { APIError } from '../../common/api'
type Output = Record<string, unknown> type Output = Record<string, unknown>
export type AuthedUser = { type AuthedUser = {
uid: string uid: string
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
} }
@ -146,24 +146,3 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
}, },
} as EndpointDefinition } as EndpointDefinition
} }
export const newEndpointNoAuth = (
endpointOpts: EndpointOptions,
fn: (req: Request) => Promise<Output>
) => {
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
}

View File

@ -7,7 +7,6 @@ import { getNewMultiBetInfo } from '../../common/new-bet'
import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer'
import { getValues } from './utils' import { getValues } from './utils'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { addUserToContractFollowers } from './follow-market'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string().max(MAX_ANSWER_LENGTH), contractId: z.string().max(MAX_ANSWER_LENGTH),
@ -97,8 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
return answer return answer
}) })
await addUserToContractFollowers(contractId, auth.uid)
return answer return answer
}) })

View File

@ -1,105 +0,0 @@
import * as admin from 'firebase-admin'
import { getContract, getUser, log } from './utils'
import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core'
import { z } from 'zod'
import { removeUndefinedProps } from '../../common/util/object'
import { htmlToRichText } from '../../common/util/parse'
import { marked } from 'marked'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection(
z.record(z.any()),
z.object({
type: z.string().optional(),
attrs: z.record(z.any()).optional(),
content: z.array(contentSchema).optional(),
marks: z
.array(
z.intersection(
z.record(z.any()),
z.object({
type: z.string(),
attrs: z.record(z.any()).optional(),
})
)
)
.optional(),
text: z.string().optional(),
})
)
)
const postSchema = z.object({
contractId: z.string(),
content: contentSchema.optional(),
html: z.string().optional(),
markdown: z.string().optional(),
})
const MAX_COMMENT_JSON_LENGTH = 20000
// For now, only supports creating a new top-level comment on a contract.
// Replies, posts, chats are not supported yet.
export const createcomment = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
const { contractId, content, html, markdown } = validate(postSchema, req.body)
const creator = await getUser(auth.uid)
const contract = await getContract(contractId)
if (!creator) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
if (!contract) {
throw new APIError(400, 'No contract exists with the given ID.')
}
let contentJson = null
if (content) {
contentJson = content
} else if (html) {
console.log('html', html)
contentJson = htmlToRichText(html)
} else if (markdown) {
const markedParse = marked.parse(markdown)
log('parsed', markedParse)
contentJson = htmlToRichText(markedParse)
log('json', contentJson)
}
if (!contentJson) {
throw new APIError(400, 'No comment content provided.')
}
if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) {
throw new APIError(
400,
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
)
}
const ref = firestore.collection(`contracts/${contractId}/comments`).doc()
const comment = removeUndefinedProps({
id: ref.id,
content: contentJson,
createdTime: Date.now(),
userId: creator.id,
userName: creator.name,
userUsername: creator.username,
userAvatarUrl: creator.avatarUrl,
// OnContract fields
commentType: 'contract',
contractId: contractId,
contractSlug: contract.slug,
contractQuestion: contract.question,
})
await ref.set(comment)
return { status: 'success', comment }
})

View File

@ -62,7 +62,6 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
totalContracts: 0, totalContracts: 0,
totalMembers: memberIds.length, totalMembers: memberIds.length,
postIds: [], postIds: [],
pinnedItems: [],
} }
await groupRef.create(group) await groupRef.create(group)

View File

@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { chargeUser, getContract, isProd } from './utils' import { chargeUser, getContract, isProd } from './utils'
import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { APIError, newEndpoint, validate, zTimestamp } from './api'
import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy'
import { import {
@ -29,6 +29,7 @@ import {
} from '../../common/antes' } from '../../common/antes'
import { Answer, getNoneAnswer } from '../../common/answer' import { Answer, getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract' import { getNewContract } from '../../common/new-contract'
import { plainTextToProseMirror } from '../../common/util/parse'
import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group' import { Group, GroupLink, MAX_ID_LENGTH } from '../../common/group'
@ -92,11 +93,7 @@ const multipleChoiceSchema = z.object({
answers: z.string().trim().min(1).array().min(2), answers: z.string().trim().min(1).array().min(2),
}) })
export const createmarket = newEndpoint({}, (req, auth) => { export const createmarket = newEndpoint({}, async (req, auth) => {
return createMarketHelper(req.body, auth)
})
export async function createMarketHelper(body: any, auth: AuthedUser) {
const { const {
question, question,
description, description,
@ -105,13 +102,16 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
outcomeType, outcomeType,
groupId, groupId,
visibility = 'public', visibility = 'public',
} = validate(bodySchema, body) } = validate(bodySchema, req.body)
let min, max, initialProb, isLogScale, answers let min, max, initialProb, isLogScale, answers
if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') {
let initialValue let initialValue
;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) ;({ min, max, initialValue, isLogScale } = validate(
numericSchema,
req.body
))
if (max - min <= 0.01 || initialValue <= min || initialValue >= max) if (max - min <= 0.01 || initialValue <= min || initialValue >= max)
throw new APIError(400, 'Invalid range.') throw new APIError(400, 'Invalid range.')
@ -127,11 +127,11 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
} }
if (outcomeType === 'BINARY') { if (outcomeType === 'BINARY') {
;({ initialProb } = validate(binarySchema, body)) ;({ initialProb } = validate(binarySchema, req.body))
} }
if (outcomeType === 'MULTIPLE_CHOICE') { if (outcomeType === 'MULTIPLE_CHOICE') {
;({ answers } = validate(multipleChoiceSchema, body)) ;({ answers } = validate(multipleChoiceSchema, req.body))
} }
const userDoc = await firestore.collection('users').doc(auth.uid).get() const userDoc = await firestore.collection('users').doc(auth.uid).get()
@ -187,17 +187,9 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
// convert string descriptions into JSONContent // convert string descriptions into JSONContent
const newDescription = const newDescription =
!description || typeof description === 'string' typeof description === 'string'
? { ? plainTextToProseMirror(description)
type: 'doc', : description ?? {}
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: description || ' ' }],
},
],
}
: description
const contract = getNewContract( const contract = getNewContract(
contractRef.id, contractRef.id,
@ -324,7 +316,7 @@ export async function createMarketHelper(body: any, auth: AuthedUser) {
} }
return contract return contract
} })
const getSlug = async (question: string) => { const getSlug = async (question: string) => {
const proposedSlug = slugify(question) const proposedSlug = slugify(question)

View File

@ -6,13 +6,7 @@ import {
Notification, Notification,
notification_reason_types, notification_reason_types,
} from '../../common/notification' } from '../../common/notification'
import { import { User } from '../../common/user'
MANIFOLD_AVATAR_URL,
MANIFOLD_USER_NAME,
MANIFOLD_USER_USERNAME,
PrivateUser,
User,
} from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getValues } from './utils' import { getPrivateUser, getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
@ -36,26 +30,27 @@ import {
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { ContractFollow } from '../../common/follow' import { ContractFollow } from '../../common/follow'
import { Badge } from 'common/badge'
const firestore = admin.firestore() const firestore = admin.firestore()
type recipients_to_reason_texts = { type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { reason: notification_reason_types }
} }
export const createFollowOrMarketSubsidizedNotification = async ( export const createNotification = async (
sourceId: string, sourceId: string,
sourceType: 'liquidity' | 'follow', sourceType: 'contract' | 'liquidity' | 'follow',
sourceUpdateType: 'created', sourceUpdateType: 'closed' | 'created',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
recipients?: string[] recipients?: string[]
slug?: string
title?: string
} }
) => { ) => {
const { contract: sourceContract, recipients } = miscData ?? {} const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
const shouldReceiveNotification = ( const shouldReceiveNotification = (
userId: string, userId: string,
@ -99,15 +94,23 @@ export const createFollowOrMarketSubsidizedNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question, sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceContract?.slug, sourceSlug: slug ? slug : sourceContract?.slug,
sourceTitle: sourceContract?.question, sourceTitle: title ? title : sourceContract?.question,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
if (!sendToEmail) continue if (!sendToEmail) continue
if (reason === 'subsidized_your_market') { 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') {
// TODO: send email to creator of market that was subsidized // TODO: send email to creator of market that was subsidized
} else if (reason === 'on_new_follow') { } else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed // TODO: send email to user who was followed
@ -124,7 +127,20 @@ export const createFollowOrMarketSubsidizedNotification = async (
reason: 'on_new_follow', reason: 'on_new_follow',
} }
return await sendNotificationsIfSettingsPermit(userToReasonTexts) return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (sourceType === 'liquidity' && sourceContract) { } 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
) {
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = { userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market', reason: 'subsidized_your_market',
@ -197,7 +213,6 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
const needNotFollowContractReasons = ['tagged_user']
const stillFollowingContract = (userId: string) => { const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId) return contractFollowersIds.includes(userId)
} }
@ -206,12 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
userId: string, userId: string,
reason: notification_reason_types reason: notification_reason_types
) => { ) => {
if ( if (!stillFollowingContract(userId) || sourceUser.id == userId) return
(!stillFollowingContract(userId) &&
!needNotFollowContractReasons.includes(reason)) ||
sourceUser.id == userId
)
return
const privateUser = await getPrivateUser(userId) const privateUser = await getPrivateUser(userId)
if (!privateUser) return if (!privateUser) return
const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser(
@ -1036,122 +1046,3 @@ export const createContractResolvedNotifications = async (
) )
) )
} }
export const createBountyNotification = async (
fromUser: User,
toUserId: string,
amount: number,
idempotencyKey: string,
contract: Contract,
commentId?: string
) => {
const privateUser = await getPrivateUser(toUserId)
if (!privateUser) return
const { sendToBrowser } = getNotificationDestinationsForUser(
privateUser,
'tip_received'
)
if (!sendToBrowser) return
const slug = commentId
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'tip_received',
createdTime: Date.now(),
isSeen: false,
sourceId: commentId ? commentId : contract.id,
sourceType: 'tip',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: amount.toString(),
sourceContractCreatorUsername: contract.creatorUsername,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: slug,
sourceTitle: contract.question,
}
return await notificationRef.set(removeUndefinedProps(notification))
}
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
)
}

View File

@ -3,17 +3,10 @@ import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { slugify } from '../../common/util/slugify' import { slugify } from '../../common/util/slugify'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
import { import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
Post,
MAX_POST_TITLE_LENGTH,
MAX_POST_SUBTITLE_LENGTH,
} from '../../common/post'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { z } from 'zod' import { z } from 'zod'
import { removeUndefinedProps } from '../../common/util/object'
import { createMarketHelper } from './create-market'
import { DAY_MS } from '../../common/util/time'
const contentSchema: z.ZodType<JSONContent> = z.lazy(() => const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
z.intersection( z.intersection(
@ -40,21 +33,13 @@ const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
const postSchema = z.object({ const postSchema = z.object({
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
subtitle: z.string().min(1).max(MAX_POST_SUBTITLE_LENGTH),
content: contentSchema, content: contentSchema,
groupId: z.string().optional(), groupId: z.string().optional(),
// Date doc fields:
bounty: z.number().optional(),
birthday: z.number().optional(),
type: z.string().optional(),
question: z.string().optional(),
}) })
export const createpost = newEndpoint({}, async (req, auth) => { export const createpost = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const { title, subtitle, content, groupId, question, ...otherProps } = const { title, content, groupId } = validate(postSchema, req.body)
validate(postSchema, req.body)
const creator = await getUser(auth.uid) const creator = await getUser(auth.uid)
if (!creator) if (!creator)
@ -66,45 +51,14 @@ export const createpost = newEndpoint({}, async (req, auth) => {
const postRef = firestore.collection('posts').doc() const postRef = firestore.collection('posts').doc()
// If this is a date doc, create a market for it. const post: Post = {
let contractSlug
if (question) {
const closeTime = Date.now() + DAY_MS * 30 * 3
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({
...otherProps,
id: postRef.id, id: postRef.id,
creatorId: creator.id, creatorId: creator.id,
slug, slug,
title, title,
subtitle,
createdTime: Date.now(), createdTime: Date.now(),
content: content, content: JSON.stringify(content),
contractSlug, }
creatorName: creator.name,
creatorUsername: creator.username,
creatorAvatarUrl: creator.avatarUrl,
itemType: 'post',
})
await postRef.create(post) await postRef.create(post)
if (groupId) { if (groupId) {

View File

@ -69,8 +69,6 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0, followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES, followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true, shouldShowWelcome: true,
fractionResolvedCorrectly: 1,
achievements: {},
} }
await firestore.collection('users').doc(auth.uid).create(user) await firestore.collection('users').doc(auth.uid).create(user)

View File

@ -1,69 +0,0 @@
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()
})
}

View File

@ -483,7 +483,11 @@
color: #999; color: #999;
text-decoration: underline; text-decoration: underline;
margin: 0; margin: 0;
">our Discord</a>! ">our Discord</a>! Or,
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</td> </td>
</tr> </tr>
</table> </table>

View File

@ -1,411 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Weekly Portfolio Update on Manifold</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family:"Readex Pro", Helvetica, sans-serif;
}
table { margin: 0 auto; }
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
th {color:#000000; font-size:17px;}
th, td {padding: 10px; }
td{ font-size: 17px}
th, td { vertical-align: center; text-align: left }
a { vertical-align: center; text-align: left}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
p.change{
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
}
p.prob{
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
}
a.question{
font-size: 18px;display: inline; vertical-align: middle;
}
td.question{
vertical-align: middle; padding-bottom: 15px; text-align: left;
}
td.probs{
text-align: right; padding-left: 10px; min-width: 115px
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto"
src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align: top; margin-bottom: 30px" width="100%">
<tbody>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
We ran the numbers and here's how you did this past week!
</span>
</p>
</div>
</td>
</tr>
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
<tr>
<tr>
<th style='font-size: 22px; text-align: center'>
Profit
</th>
</tr>
<tr>
<td style='padding-bottom: 30px; text-align: center'>
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
{{profit}}
</p>
</td>
</tr>
<td align="center"
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px; ">
<tbody>
<tr>
<th style='width: 170px'>
🔥 Prediction streak
</th>
<td>
{{prediction_streak}}
</td>
</tr>
<tr>
<th>
💸 Tips received
</th>
<td>
{{tips_received}}
</td>
</tr>
<tr>
<th>
📈 Markets traded
</th>
<td>
{{markets_traded}}
</td>
</tr>
<tr>
<th>
❓ Markets created
</th>
<td>
{{markets_created}}
</td>
</tr>
<tr>
<th style='width: 55px'>
🥳 Traders attracted
</th>
<td>
{{unique_bettors}}
</td>
</tr>
</tbody>
</table>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
">
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table border="0" cellpadding="0" cellspacing="0"
role="presentation" width="100%">
<tbody>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
">
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -1,510 +0,0 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Weekly Portfolio Update on Manifold</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family:"Readex Pro", Helvetica, sans-serif;
}
table { margin: 0 auto; }
table,
td {
border-collapse: collapse;
mso-table-lspace: 0;
mso-table-rspace: 0;
}
th {color:#000000; font-size:17px;}
th, td {padding: 10px; }
td{ font-size: 17px}
th, td { vertical-align: center; text-align: left }
a { vertical-align: center; text-align: left}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
p.change{
margin: 0; vertical-align: middle;font-size:16px;display: inline; padding: 2px; border-radius: 5px; width: 20px; text-align: right;
}
p.prob{
font-size: 22px;display: inline; vertical-align: middle; font-weight: bold; width: 50px;
}
a.question{
font-size: 18px;display: inline; vertical-align: middle;
}
td.question{
vertical-align: middle; padding-bottom: 15px; text-align: left;
}
td.probs{
text-align: right; padding-left: 10px; min-width: 115px
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing: normal; background-color: #f4f4f4">
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto"
src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="
background: #ffffff;
background-color: #ffffff;
margin: 0px auto;
max-width: 600px;
">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background: #ffffff; background-color: #ffffff; width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 0px 0px;
padding-bottom: 0px;
padding-left: 0px;
padding-right: 0px;
padding-top: 20px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align: top" width="100%">
<tbody>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y"><span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
</span>Hi {{name}},</p>
</div>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 0px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
We ran the numbers and here's how you did this past week!
</span>
</p>
</div>
</td>
</tr>
<!--/ show 5 columns with headers titled: Investment value, 7-day change, current balance, tips received, and markets made/-->
<tr>
<tr>
<th style='font-size: 22px; text-align: center'>
Profit
</th>
</tr>
<tr>
<td style='padding-bottom: 30px; text-align: center'>
<p class='change' style='font-size: 24px; padding:4px; {{profit_style}}'>
{{profit}}
</p>
</td>
</tr>
<td align="center"
style="font-size:0px;padding:10px 20px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px; ">
<tbody>
<tr>
<th style='width: 170px'>
🔥 Prediction streak
</th>
<td>
{{prediction_streak}}
</td>
</tr>
<tr>
<th>
💸 Tips received
</th>
<td>
{{tips_received}}
</td>
</tr>
<tr>
<th>
📈 Markets traded
</th>
<td>
{{markets_traded}}
</td>
</tr>
<tr>
<th>
❓ Markets created
</th>
<td>
{{markets_created}}
</td>
</tr>
<tr>
<th style='width: 55px'>
🥳 Traders attracted
</th>
<td>
{{unique_bettors}}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
data-testid="4XoHRGw1Y">
<span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
And here's some recent changes in your investments:
</span>
</p>
</div>
</td>
<tr>
<td
style="font-size:0; padding-left:10px;padding-top:10px;padding-bottom:0;word-break:break-word;">
<table role="presentation">
<tbody>
<tr>
<td class='question'>
<a class='question' href='{{question1Url}}'>
{{question1Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question1Prob}}
<!-- 9.9%-->
<p class='change' style='{{question1ChangeStyle}}'>
{{question1Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<td class='question'>
<a class='question' href='{{question2Url}}'>
{{question2Title}}
<!-- Will the US economy recover from the pandemic? blah blah blah-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question2Prob}}
<!-- 99.9%-->
<p class='change' style='{{question2ChangeStyle}}'>
{{question2Change}}
<!-- +7%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td class='question'>
<a class='question' href='{{question3Url}}'>
{{question3Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question3Prob}}
<!-- 99.9%-->
<p class='change' style='{{question3ChangeStyle}}'>
{{question3Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr><tr>
<!-- <td style="{{investment_value_style}}">-->
<td class='question'>
<a class='question' href='{{question4Url}}'>
{{question4Title}}
<!-- Will the US economy recover from the pandemic?-->
</a>
</td>
<td class='probs'>
<p class='prob'>
{{question4Prob}}
<!-- 99.9%-->
<p class='change' style='{{question4ChangeStyle}}'>
{{question4Change}}
<!-- +17%-->
</p>
</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 0 0 20px 0;
text-align: center;
">
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width: 100%">
<tbody>
<tr>
<td style="
direction: ltr;
font-size: 0px;
padding: 20px 0px 20px 0px;
text-align: center;
">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
width="100%">
<tbody>
<tr>
<td style="vertical-align: top; padding: 0">
<table border="0" cellpadding="0" cellspacing="0"
role="presentation" width="100%">
<tbody>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
">
<div style="
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 11px;
line-height: 22px;
text-align: center;
color: #000000;
">
<p style="margin: 10px 0">
This e-mail has been sent to
{{name}},
<a href="{{unsubscribeUrl}}" style="
color: inherit;
text-decoration: none;
" target="_blank">click here to unsubscribe from this type of notification</a>.
</p>
</div>
</td>
</tr>
<tr>
<td align="center" style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -12,15 +12,14 @@ import { getValueFromBucket } from '../../common/calculate-dpm'
import { formatNumericProbability } from '../../common/pseudo-numeric' import { formatNumericProbability } from '../../common/pseudo-numeric'
import { sendTemplateEmail, sendTextEmail } from './send-email' import { sendTemplateEmail, sendTextEmail } from './send-email'
import { contractUrl, getUser, log } from './utils' import { getUser } from './utils'
import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details'
import { notification_reason_types } from '../../common/notification' import { notification_reason_types } from '../../common/notification'
import { Dictionary } from 'lodash' import { Dictionary } from 'lodash'
import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences'
import { import {
PerContractInvestmentsData, getNotificationDestinationsForUser,
OverallPerformanceData, notification_preference,
} from './weekly-portfolio-emails' } from '../../common/user-notification-preferences'
export const sendMarketResolutionEmail = async ( export const sendMarketResolutionEmail = async (
reason: notification_reason_types, reason: notification_reason_types,
@ -153,10 +152,9 @@ export const sendWelcomeEmail = async (
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl } = getNotificationDestinationsForUser( const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
privateUser, 'onboarding_flow' as notification_preference
'onboarding_flow' }`
)
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
@ -212,17 +210,19 @@ export const sendOneWeekBonusEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if (!privateUser || !privateUser.email) return if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
privateUser, 'onboarding_flow' as notification_preference
'onboarding_flow' }`
)
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Manifold Markets one week anniversary gift', 'Manifold Markets one week anniversary gift',
@ -243,15 +243,19 @@ export const sendCreatorGuideEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
sendTime: string sendTime: string
) => { ) => {
if (!privateUser || !privateUser.email) return if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.onboarding_flow.includes('email')
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser, const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'onboarding_flow' 'onboarding_flow' as notification_preference
) }`
if (!sendToEmail) return
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Create your own prediction market', 'Create your own prediction market',
@ -271,16 +275,22 @@ export const sendThankYouEmail = async (
user: User, user: User,
privateUser: PrivateUser privateUser: PrivateUser
) => { ) => {
if (!privateUser || !privateUser.email) return if (
!privateUser ||
!privateUser.email ||
!privateUser.notificationPreferences.thank_you_for_purchases.includes(
'email'
)
)
return
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'thank_you_for_purchases'
)
if (!sendToEmail) return const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'thank_you_for_purchases' as notification_preference
}`
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Thanks for your Manifold purchase', 'Thanks for your Manifold purchase',
@ -301,7 +311,12 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
contract: Contract contract: Contract
) => { ) => {
if (!privateUser.email) return const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -310,7 +325,6 @@ export const sendMarketCloseEmail = async (
const url = `https://${DOMAIN}/${username}/${slug}` 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( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Your market has closed', 'Your market has closed',
@ -318,7 +332,7 @@ export const sendMarketCloseEmail = async (
{ {
question, question,
url, url,
unsubscribeUrl: '', unsubscribeUrl,
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
@ -448,13 +462,16 @@ export const sendInterestingMarketsEmail = async (
contractsToSend: Contract[], contractsToSend: Contract[],
deliveryTime?: string deliveryTime?: string
) => { ) => {
if (!privateUser || !privateUser.email) return if (
!privateUser ||
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( !privateUser.email ||
privateUser, !privateUser.notificationPreferences.trending_markets.includes('email')
'trending_markets'
) )
if (!sendToEmail) return return
const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings&section=${
'trending_markets' as notification_preference
}`
const { name } = user const { name } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -490,6 +507,10 @@ export const sendInterestingMarketsEmail = async (
) )
} }
function contractUrl(contract: Contract) {
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
}
function imageSourceUrl(contract: Contract) { function imageSourceUrl(contract: Contract) {
return buildCardUrl(getOpenGraphProps(contract)) return buildCardUrl(getOpenGraphProps(contract))
} }
@ -591,45 +612,3 @@ export const sendNewUniqueBettorsEmail = async (
} }
) )
} }
export const sendWeeklyPortfolioUpdateEmail = async (
user: User,
privateUser: PrivateUser,
investments: PerContractInvestmentsData[],
overallPerformance: OverallPerformanceData
) => {
if (!privateUser || !privateUser.email) return
const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser(
privateUser,
'profit_loss_updates'
)
if (!sendToEmail) return
const { name } = user
const firstName = name.split(' ')[0]
const templateData: Record<string, string> = {
name: firstName,
unsubscribeUrl,
...overallPerformance,
}
investments.forEach((investment, i) => {
templateData[`question${i + 1}Title`] = investment.questionTitle
templateData[`question${i + 1}Url`] = investment.questionUrl
templateData[`question${i + 1}Prob`] = investment.questionProb
templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
})
await sendTemplateEmail(
privateUser.email,
// 'iansphilips@gmail.com',
`Here's your weekly portfolio update!`,
investments.length === 0
? 'portfolio-update-no-movers'
: 'portfolio-update',
templateData
)
log('Sent portfolio update email to', privateUser.email)
}

View File

@ -1,42 +0,0 @@
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<CPMMContract>)
transaction.create(newLiquidityProvisionDoc, newLiquidityProvision)
})
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet' export * from './on-create-bet'
export * from './on-create-comment-on-contract' export * from './on-create-comment-on-contract'
export * from './on-view' export * from './on-view'
export { scheduleUpdateMetrics } from './update-metrics' export * from './update-metrics'
export * from './update-stats' export * from './update-stats'
export * from './update-loans' export * from './update-loans'
export * from './backup-db' export * from './backup-db'
@ -27,11 +27,9 @@ export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
export * from './weekly-markets-emails' export * from './weekly-markets-emails'
export * from './reset-betting-streaks' export * from './reset-betting-streaks'
export * from './reset-weekly-emails-flags' export * from './reset-weekly-emails-flag'
export * from './on-update-contract-follow' export * from './on-update-contract-follow'
export * from './on-update-like' export * from './on-update-like'
export * from './weekly-portfolio-emails'
export * from './drizzle-liquidity'
// v2 // v2
export * from './health' export * from './health'
@ -45,14 +43,14 @@ export * from './sell-bet'
export * from './sell-shares' export * from './sell-shares'
export * from './claim-manalink' export * from './claim-manalink'
export * from './create-market' export * from './create-market'
export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group' export * from './create-group'
export * from './resolve-market' export * from './resolve-market'
export * from './unsubscribe' export * from './unsubscribe'
export * from './stripe' export * from './stripe'
export * from './mana-bonus-email' export * from './mana-bonus-email'
export * from './close-market' export * from './close-market'
export * from './update-comment-bounty'
export * from './add-subsidy'
import { health } from './health' import { health } from './health'
import { transact } from './transact' import { transact } from './transact'
@ -65,8 +63,8 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { createcomment } from './create-comment' import { addliquidity } from './add-liquidity'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { closemarket } from './close-market' import { closemarket } from './close-market'
@ -76,8 +74,6 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
import { addsubsidy } from './add-subsidy'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -93,10 +89,8 @@ const sellBetFunction = toCloudFunction(sellbet)
const sellSharesFunction = toCloudFunction(sellshares) const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink) const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket) const createMarketFunction = toCloudFunction(createmarket)
const addSubsidyFunction = toCloudFunction(addsubsidy) const addLiquidityFunction = toCloudFunction(addliquidity)
const addCommentBounty = toCloudFunction(addcommentbounty) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createCommentFunction = toCloudFunction(createcomment)
const awardCommentBounty = toCloudFunction(awardcommentbounty)
const createGroupFunction = toCloudFunction(creategroup) const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket) const resolveMarketFunction = toCloudFunction(resolvemarket)
const closeMarketFunction = toCloudFunction(closemarket) const closeMarketFunction = toCloudFunction(closemarket)
@ -107,7 +101,6 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost) const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export { export {
healthFunction as health, healthFunction as health,
@ -121,7 +114,8 @@ export {
sellSharesFunction as sellshares, sellSharesFunction as sellshares,
claimManalinkFunction as claimmanalink, claimManalinkFunction as claimmanalink,
createMarketFunction as createmarket, createMarketFunction as createmarket,
addSubsidyFunction as addsubsidy, addLiquidityFunction as addliquidity,
withdrawLiquidityFunction as withdrawliquidity,
createGroupFunction as creategroup, createGroupFunction as creategroup,
resolveMarketFunction as resolvemarket, resolveMarketFunction as resolvemarket,
closeMarketFunction as closemarket, closeMarketFunction as closemarket,
@ -132,8 +126,4 @@ export {
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
createPostFunction as createpost, createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials, saveTwitchCredentials as savetwitchcredentials,
createCommentFunction as createcomment,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
} }

View File

@ -3,10 +3,8 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils' import { getPrivateUser, getUserByUsername } from './utils'
import { createMarketClosedNotification } from './create-notification' import { createNotification } from './create-notification'
import { DAY_MS } from '../../common/util/time'
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
export const marketCloseNotifications = functions export const marketCloseNotifications = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('every 1 hours') .pubsub.schedule('every 1 hours')
@ -16,31 +14,31 @@ export const marketCloseNotifications = functions
const firestore = admin.firestore() const firestore = admin.firestore()
export async function sendMarketCloseEmails() { async function sendMarketCloseEmails() {
const contracts = await firestore.runTransaction(async (transaction) => { const contracts = await firestore.runTransaction(async (transaction) => {
const snap = await transaction.get( const snap = await transaction.get(
firestore.collection('contracts').where('isResolved', '!=', true) 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)
)
await Promise.all( return snap.docs
closeContracts.map(async (contract) => { .map((doc) => {
await transaction.update( const contract = doc.data() as Contract
firestore.collection('contracts').doc(contract.id),
{ if (
closeEmailsSent: admin.firestore.FieldValue.increment(1), contract.resolution ||
} (contract.closeEmailsSent ?? 0) >= 1 ||
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
) )
return undefined
transaction.update(doc.ref, {
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1,
}) })
)
return closeContracts return contract
})
.filter((x) => !!x) as Contract[]
}) })
for (const contract of contracts) { for (const contract of contracts) {
@ -57,40 +55,14 @@ export async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id) const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue if (!privateUser) continue
await createMarketClosedNotification( await createNotification(
contract, contract.id,
'contract',
'closed',
user, user,
privateUser, 'closed' + contract.id.slice(6, contract.id.length),
contract.id + '-closed-at-' + contract.closeTime contract.closeTime?.toString() ?? new Date().toString(),
{ contract }
) )
} }
} }
// 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
),
}
}

View File

@ -12,7 +12,6 @@ import {
revalidateStaticProps, revalidateStaticProps,
} from './utils' } from './utils'
import { import {
createBadgeAwardedNotification,
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
createUniqueBettorBonusNotification, createUniqueBettorBonusNotification,
@ -25,7 +24,6 @@ import {
BETTING_STREAK_BONUS_MAX, BETTING_STREAK_BONUS_MAX,
BETTING_STREAK_RESET_HOUR, BETTING_STREAK_RESET_HOUR,
UNIQUE_BETTOR_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT,
UNIQUE_BETTOR_LIQUIDITY,
} from '../../common/economy' } from '../../common/economy'
import { import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
@ -35,11 +33,6 @@ import { APIError } from '../../common/api'
import { User } from '../../common/user' import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn' import { BettingStreakBonusTxn, UniqueBettorBonusTxn } from '../../common/txn'
import { addHouseSubsidy } from './helpers/add-house-subsidy'
import {
StreakerBadge,
streakerBadgeRarityThresholds,
} from '../../common/badge'
const firestore = admin.firestore() const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -110,7 +103,7 @@ const updateBettingStreak = async (
const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1 const newBettingStreak = (bettor?.currentBettingStreak ?? 0) + 1
// Otherwise, add 1 to their betting streak // Otherwise, add 1 to their betting streak
trans.update(userDoc, { await trans.update(userDoc, {
currentBettingStreak: newBettingStreak, currentBettingStreak: newBettingStreak,
lastBetTime: bet.createdTime, lastBetTime: bet.createdTime,
}) })
@ -150,7 +143,7 @@ const updateBettingStreak = async (
log('message:', result.message) log('message:', result.message)
return return
} }
if (result.txn) { if (result.txn)
await createBettingStreakBonusNotification( await createBettingStreakBonusNotification(
user, user,
result.txn.id, result.txn.id,
@ -160,8 +153,6 @@ const updateBettingStreak = async (
newBettingStreak, newBettingStreak,
eventId eventId
) )
await handleBettingStreakBadgeAward(user, newBettingStreak)
}
} }
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
@ -200,7 +191,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
trans.update(contractDoc, { await trans.update(contractDoc, {
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
}) })
@ -213,13 +204,8 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
return { newUniqueBettorIds } return { newUniqueBettorIds }
} }
) )
if (!newUniqueBettorIds) return if (!newUniqueBettorIds) return
if (oldContract.mechanism === 'cpmm-1') {
await addHouseSubsidy(oldContract.id, UNIQUE_BETTOR_LIQUIDITY)
}
const bonusTxnDetails = { const bonusTxnDetails = {
contractId: oldContract.id, contractId: oldContract.id,
uniqueNewBettorId: bettor.id, uniqueNewBettorId: bettor.id,
@ -229,9 +215,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID : DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get() const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.') if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => { const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = { const bonusTxn: TxnData = {
fromId: fromUser.id, fromId: fromUser.id,
@ -244,9 +228,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
description: JSON.stringify(bonusTxnDetails), description: JSON.stringify(bonusTxnDetails),
data: bonusTxnDetails, data: bonusTxnDetails,
} as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'> } as Omit<UniqueBettorBonusTxn, 'id' | 'createdTime'>
const { status, message, txn } = await runTxn(trans, bonusTxn) const { status, message, txn } = await runTxn(trans, bonusTxn)
return { status, newUniqueBettorIds, message, txn } return { status, newUniqueBettorIds, message, txn }
}) })
@ -314,39 +296,3 @@ const notifyFills = async (
const currentDateBettingStreakResetTime = () => { const currentDateBettingStreakResetTime = () => {
return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) 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)
}
}

View File

@ -174,7 +174,8 @@ export const onCreateCommentOnContract = functions
? comments.find((c) => c.id === comment.replyToCommentId)?.userId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId : answer?.userId
const mentionedUsers = compact(parseMentions(comment.content)) const parsedContent = JSON.parse(comment.content)
const mentionedUsers = compact(parseMentions(parsedContent))
const repliedUsers: replied_users_info = {} const repliedUsers: replied_users_info = {}
// The parent of the reply chain could be a comment or an answer // The parent of the reply chain could be a comment or an answer
@ -210,7 +211,7 @@ export const onCreateCommentOnContract = functions
'created', 'created',
commentCreator, commentCreator,
eventId, eventId,
richTextToString(comment.content), richTextToString(parsedContent),
contract, contract,
{ {
repliedUsersInfo: repliedUsers, repliedUsersInfo: repliedUsers,

View File

@ -1,20 +1,11 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser, getValues } from './utils' import { getUser } from './utils'
import { import { createNewContractNotification } from './create-notification'
createBadgeAwardedNotification,
createNewContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market' 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 export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -26,7 +17,7 @@ export const onCreateContract = functions
const contractCreator = await getUser(contract.creatorId) const contractCreator = await getUser(contract.creatorId)
if (!contractCreator) throw new Error('Could not find contract creator') if (!contractCreator) throw new Error('Could not find contract creator')
const desc = contract.description as JSONContent const desc = JSON.parse(contract.description) as JSONContent
const mentioned = parseMentions(desc) const mentioned = parseMentions(desc)
await addUserToContractFollowers(contract.id, contractCreator.id) await addUserToContractFollowers(contract.id, contractCreator.id)
@ -37,43 +28,4 @@ export const onCreateContract = functions
richTextToString(desc), richTextToString(desc),
mentioned 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<Contract>(
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)
}
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser, log } from './utils' import { getContract, getUser, log } from './utils'
import { createFollowOrMarketSubsidizedNotification } from './create-notification' import { createNotification } from './create-notification'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy' 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') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id) await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createFollowOrMarketSubsidizedNotification( await createNotification(
contract.id, contract.id,
'liquidity', 'liquidity',
'created', 'created',

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { createFollowOrMarketSubsidizedNotification } from './create-notification' import { createNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore export const onFollowUser = functions.firestore
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
followerCountCached: FieldValue.increment(1), followerCountCached: FieldValue.increment(1),
}) })
await createFollowOrMarketSubsidizedNotification( await createNotification(
followingUser.id, followingUser.id,
'follow', 'follow',
'created', 'created',

View File

@ -1,110 +1,33 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser, getValues } from './utils' import { getUser } from './utils'
import { import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
createBadgeAwardedNotification,
createCommentOrAnswerOrUpdatedContractNotification,
} from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
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 export const onUpdateContract = functions.firestore
.document('contracts/{contractId}') .document('contracts/{contractId}')
.onUpdate(async (change, context) => { .onUpdate(async (change, context) => {
const contract = change.after.data() as Contract const contract = change.after.data() as Contract
const previousContract = change.before.data() as Contract
const { eventId } = context const { eventId } = context
const { closeTime, question } = contract
if (!previousContract.isResolved && contract.isResolved) {
// No need to notify users of resolution, that's handled in resolve-market
return await handleResolvedContract(contract)
} else if (previousContract.groupSlugs !== contract.groupSlugs) {
await handleContractGroupUpdated(previousContract, contract)
} else if (
previousContract.closeTime !== closeTime ||
previousContract.question !== question
) {
await handleUpdatedCloseTime(previousContract, contract, eventId)
}
})
async function handleResolvedContract(contract: Contract) {
if (
(contract.uniqueBettorCount ?? 0) <
MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE ||
contract.resolution === 'CANCEL'
)
return
// get all bets on this contract
const bets = await getValues<Bet>(
firestore.collection(`contracts/${contract.id}/bets`)
)
// get comments on this contract
const comments = await getValues<ContractComment>(
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,
eventId: string
) {
const contractUpdater = await getUser(contract.creatorId) const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater') if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
// Resolution is handled in resolve-market.ts
if (!previousValue.isResolved && contract.isResolved) return
if (
previousValue.closeTime !== contract.closeTime ||
previousValue.question !== contract.question
) {
let sourceText = '' let sourceText = ''
if (previousContract.closeTime !== contract.closeTime && contract.closeTime) { if (
previousValue.closeTime !== contract.closeTime &&
contract.closeTime
) {
sourceText = contract.closeTime.toString() sourceText = contract.closeTime.toString()
} else if (previousContract.question !== contract.question) { } else if (previousValue.question !== contract.question) {
sourceText = contract.question sourceText = contract.question
} }
@ -117,44 +40,5 @@ async function handleUpdatedCloseTime(
sourceText, sourceText,
contract contract
) )
}
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')
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')
await firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
.delete()
}
}
const firestore = admin.firestore()

View File

@ -5,6 +5,8 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification' import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn' import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy' import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -19,6 +21,10 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) { if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId) await handleUserUpdatedReferral(user, eventId)
} }
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
}) })
async function handleUserUpdatedReferral(user: User, eventId: string) { async function handleUserUpdatedReferral(user: User, eventId: string) {
@ -117,3 +123,15 @@ 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<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -11,7 +11,6 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import { import {
BetInfo, BetInfo,
getBinaryCpmmBetInfo, getBinaryCpmmBetInfo,
@ -24,7 +23,6 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { log } from './utils' import { log } from './utils'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -75,11 +73,9 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity, newTotalLiquidity,
newP, newP,
makers, makers,
ordersToCancel,
} = await (async (): Promise< } = await (async (): Promise<
BetInfo & { BetInfo & {
makers?: maker[] makers?: maker[]
ordersToCancel?: LimitBet[]
} }
> => { > => {
if ( if (
@ -103,16 +99,17 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100 limitProb = Math.round(limitProb * 100) / 100
} }
const { unfilledBets, balanceByUserId } = const unfilledBetsSnap = await trans.get(
await getUnfilledBetsAndUserBalances(trans, contractDoc) getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
return getBinaryCpmmBetInfo( return getBinaryCpmmBetInfo(
outcome, outcome,
amount, amount,
contract, contract,
limitProb, limitProb,
unfilledBets, unfilledBets
balanceByUserId
) )
} else if ( } else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -155,25 +152,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) { if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans) 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) { if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update( trans.update(
contractDoc, contractDoc,
removeUndefinedProps({ removeUndefinedProps({
@ -210,36 +193,13 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc return contractDoc
.collection('bets') .collection('bets')
.where('isFilled', '==', false) .where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet> .where('isCancelled', '==', false) as Query<LimitBet>
} }
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 = { type maker = {
bet: LimitBet bet: LimitBet
amount: number amount: number

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getAllPrivateUsers } from './utils' import { getAllPrivateUsers } from './utils'
export const resetWeeklyEmailsFlags = functions export const resetWeeklyEmailsFlag = functions
.runWith({ .runWith({
timeoutSeconds: 300, timeoutSeconds: 300,
memory: '4GB', memory: '4GB',
@ -17,7 +17,6 @@ export const resetWeeklyEmailsFlags = functions
privateUsers.map(async (user) => { privateUsers.map(async (user) => {
return firestore.collection('private-users').doc(user.id).update({ return firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: false, weeklyTrendingEmailSent: false,
weeklyPortfolioUpdateEmailSent: false,
}) })
}) })
) )

View File

@ -1,6 +1,6 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { mapValues, groupBy, sumBy, uniqBy } from 'lodash' import { mapValues, groupBy, sumBy } from 'lodash'
import { import {
Contract, Contract,
@ -9,20 +9,12 @@ import {
RESOLUTIONS, RESOLUTIONS,
} from '../../common/contract' } from '../../common/contract'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { import { getContractPath, getUser, getValues, isProd, log, payUser, revalidateStaticProps } from './utils'
getContractPath,
getUser,
getValues,
isProd,
log,
payUsers,
payUsersMultipleTransactions,
revalidateStaticProps,
} from './utils'
import { import {
getLoanPayouts, getLoanPayouts,
getPayouts, getPayouts,
groupPayoutsByUser, groupPayoutsByUser,
Payout,
} from '../../common/payouts' } from '../../common/payouts'
import { isAdmin, isManifoldId } from '../../common/envs/constants' import { isAdmin, isManifoldId } from '../../common/envs/constants'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
@ -36,7 +28,6 @@ import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID, DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes' } from '../../common/antes'
import { User } from 'common/user'
const bodySchema = z.object({ const bodySchema = z.object({
contractId: z.string(), contractId: z.string(),
@ -90,10 +81,13 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
if (!contractSnap.exists) if (!contractSnap.exists)
throw new APIError(404, 'No contract exists with the provided ID') throw new APIError(404, 'No contract exists with the provided ID')
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
const { creatorId } = contract const { creatorId, closeTime } = contract
const firebaseUser = await admin.auth().getUser(auth.uid) const firebaseUser = await admin.auth().getUser(auth.uid)
const resolutionParams = getResolutionParams(contract, req.body) const { value, resolutions, probabilityInt, outcome } = getResolutionParams(
contract,
req.body
)
if ( if (
creatorId !== auth.uid && creatorId !== auth.uid &&
@ -107,16 +101,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
const creator = await getUser(creatorId) const creator = await getUser(creatorId)
if (!creator) throw new APIError(500, 'Creator not found') if (!creator) throw new APIError(500, 'Creator not found')
return await resolveMarket(contract, creator, resolutionParams)
})
export const resolveMarket = async (
contract: Contract,
creator: User,
{ value, resolutions, probabilityInt, outcome }: ResolutionParams
) => {
const { creatorId, closeTime, id: contractId } = contract
const resolutionProbability = const resolutionProbability =
probabilityInt !== undefined ? probabilityInt / 100 : undefined probabilityInt !== undefined ? probabilityInt / 100 : undefined
@ -139,12 +123,8 @@ export const resolveMarket = async (
(doc) => doc.data() as LiquidityProvision (doc) => doc.data() as LiquidityProvision
) )
const { const { payouts, creatorPayout, liquidityPayouts, collectedFees } =
payouts: traderPayouts, getPayouts(
creatorPayout,
liquidityPayouts,
collectedFees,
} = getPayouts(
outcome, outcome,
contract, contract,
bets, bets,
@ -165,50 +145,35 @@ export const resolveMarket = async (
resolutions, resolutions,
collectedFees, collectedFees,
}), }),
subsidyPool: 0,
} }
await contractDoc.update(updatedContract)
console.log('contract ', contractId, 'resolved to:', outcome)
const openBets = bets.filter((b) => !b.isSold && !b.sale) const openBets = bets.filter((b) => !b.isSold && !b.sale)
const loanPayouts = getLoanPayouts(openBets) const loanPayouts = getLoanPayouts(openBets)
const payoutsWithoutLoans = [
{ userId: creatorId, payout: creatorPayout, deposit: creatorPayout },
...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })),
...traderPayouts,
]
const payouts = [...payoutsWithoutLoans, ...loanPayouts]
if (!isProd()) if (!isProd())
console.log( console.log(
'trader payouts:', 'payouts:',
traderPayouts, payouts,
'creator payout:', 'creator payout:',
creatorPayout, creatorPayout,
'liquidity payout:', 'liquidity payout:'
liquidityPayouts,
'loan payouts:',
loanPayouts
) )
const userCount = uniqBy(payouts, 'userId').length if (creatorPayout)
const contractDoc = firestore.doc(`contracts/${contractId}`) await processPayouts([{ userId: creatorId, payout: creatorPayout }], true)
if (userCount <= 499) { await processPayouts(liquidityPayouts, true)
await firestore.runTransaction(async (transaction) => {
payUsers(transaction, payouts)
transaction.update(contractDoc, updatedContract)
})
} else {
await payUsersMultipleTransactions(payouts)
await contractDoc.update(updatedContract)
}
console.log('contract ', contractId, 'resolved to:', outcome)
await processPayouts([...payouts, ...loanPayouts])
await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) await undoUniqueBettorRewardsIfCancelResolution(contract, outcome)
await revalidateStaticProps(getContractPath(contract)) await revalidateStaticProps(getContractPath(contract))
const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
const userInvestments = mapValues( const userInvestments = mapValues(
groupBy(bets, (bet) => bet.userId), groupBy(bets, (bet) => bet.userId),
@ -235,6 +200,18 @@ export const resolveMarket = async (
) )
return updatedContract return updatedContract
})
const processPayouts = async (payouts: Payout[], isDeposit = false) => {
const userPayouts = groupPayoutsByUser(payouts)
const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) =>
payUser(userId, payout, isDeposit)
)
return await Promise.all(payoutPromises)
.catch((e) => ({ status: 'error', message: e }))
.then(() => ({ status: 'success' }))
} }
function getResolutionParams(contract: Contract, body: string) { function getResolutionParams(contract: Contract, body: string) {
@ -301,8 +278,6 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`) throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
} }
type ResolutionParams = ReturnType<typeof getResolutionParams>
function validateAnswer( function validateAnswer(
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
answer: number answer: number

View File

@ -5,7 +5,6 @@ import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { log } from './utils' import { log } from './utils'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { DAY_MS, HOUR_MS } from '../../common/util/time'
export const scoreContracts = functions export const scoreContracts = functions
.runWith({ memory: '4GB', timeoutSeconds: 540 }) .runWith({ memory: '4GB', timeoutSeconds: 540 })
@ -17,12 +16,11 @@ const firestore = admin.firestore()
async function scoreContractsInternal() { async function scoreContractsInternal() {
const now = Date.now() const now = Date.now()
const hourAgo = now - HOUR_MS const lastHour = now - 60 * 60 * 1000
const dayAgo = now - DAY_MS const last3Days = now - 1000 * 60 * 60 * 24 * 3
const threeDaysAgo = now - DAY_MS * 3
const activeContractsSnap = await firestore const activeContractsSnap = await firestore
.collection('contracts') .collection('contracts')
.where('lastUpdatedTime', '>', hourAgo) .where('lastUpdatedTime', '>', lastHour)
.get() .get()
const activeContracts = activeContractsSnap.docs.map( const activeContracts = activeContractsSnap.docs.map(
(doc) => doc.data() as Contract (doc) => doc.data() as Contract
@ -43,21 +41,15 @@ async function scoreContractsInternal() {
for (const contract of contracts) { for (const contract of contracts) {
const bets = await firestore const bets = await firestore
.collection(`contracts/${contract.id}/bets`) .collection(`contracts/${contract.id}/bets`)
.where('createdTime', '>', threeDaysAgo) .where('createdTime', '>', last3Days)
.get() .get()
const bettors = bets.docs const bettors = bets.docs
.map((doc) => doc.data() as Bet) .map((doc) => doc.data() as Bet)
.map((bet) => bet.userId) .map((bet) => bet.userId)
const popularityScore = uniq(bettors).length const popularityScore = uniq(bettors).length
const wasCreatedToday = contract.createdTime > dayAgo
let dailyScore: number | undefined let dailyScore: number | undefined
if ( if (contract.outcomeType === 'BINARY' && contract.mechanism === 'cpmm-1') {
contract.outcomeType === 'BINARY' &&
contract.mechanism === 'cpmm-1' &&
!wasCreatedToday
) {
const percentChange = Math.abs(contract.probChanges.day) const percentChange = Math.abs(contract.probChanges.day)
dailyScore = popularityScore * percentChange dailyScore = popularityScore * percentChange
} }

View File

@ -1,30 +0,0 @@
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())

View File

@ -1,136 +0,0 @@
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
} else {
// Make corrections to existing achievements
await awardMarketCreatorBadges(user)
}
})
)
}
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<Contract>(
firestore.collection(`contracts`).where('creatorId', '==', user.id)
)
).filter((c) => !c.resolution || c.resolution != 'CANCEL')
const achievements = {
...user.achievements,
marketCreator: {
badges: [...(user.achievements.marketCreator?.badges ?? [])],
},
}
for (const threshold of marketCreatorBadgeRarityThresholds) {
const alreadyHasBadge = user.achievements.marketCreator?.badges.some(
(b) => b.data.totalContractsCreated === threshold
)
if (alreadyHasBadge) continue
if (contracts.length >= threshold) {
console.log(`User ${user.id} has at least ${threshold} contracts`)
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
}

View File

@ -1,24 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
const firestore = admin.firestore()
if (require.main === module) {
const contractsRef = firestore.collection('contracts')
contractsRef.get().then(async (contractsSnaps) => {
console.log(`Loaded ${contractsSnaps.size} contracts.`)
const needsFilling = contractsSnaps.docs.filter((ct) => {
return !('subsidyPool' in ct.data())
})
console.log(`Found ${needsFilling.length} contracts to update.`)
await Promise.all(
needsFilling.map((ct) => ct.ref.update({ subsidyPool: 0 }))
)
console.log(`Updated all contracts.`)
})
}

View File

@ -1,54 +0,0 @@
// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
const DOMAIN = 'http://localhost:3000'
// Dev API key for Cause Exploration Prizes (@CEP)
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
// DEV API key for Criticism and Red Teaming (@CARTBot)
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
// Warning: Checking these in can be dangerous!
// Prod API key for @CEPBot
// Can just curl /v0/group/{slug} to get a group
async function getGroupBySlug(slug: string) {
const resp = await fetch(`${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(`${DOMAIN}/api/v0/group/by-id/${id}/markets`)
return await resp.json()
}
async function addLiquidityById(id: string, amount: number) {
const resp = await fetch(`${DOMAIN}/api/v0/market/${id}/add-liquidity`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
amount: amount,
}),
})
return await resp.json()
}
async function main() {
const group = await getGroupBySlug('cart-contest')
const markets = await getMarketsByGroupId(group.id)
// Count up some metrics
console.log('Number of markets', markets.length)
// Resolve each market to NO
for (const market of markets.slice(0, 3)) {
console.log(market.slug, market.totalLiquidity)
const resp = await addLiquidityById(market.id, 200)
console.log(resp)
}
}
main()
export {}

View File

@ -1,115 +0,0 @@
// Run with `npx ts-node src/scripts/contest/create-markets.ts`
import { data } from './criticism-and-red-teaming'
// Dev API key for Cause Exploration Prizes (@CEP)
// const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
// DEV API key for Criticism and Red Teaming (@CARTBot)
const API_KEY = '6ff1f78a-32fe-43b2-b31b-9e3c78c5f18c'
type CEPSubmission = {
title: string
author?: string
link: string
}
// Use the API to create a new market for this Cause Exploration Prize submission
async function postMarket(submission: CEPSubmission) {
const { title, author } = submission
const response = await fetch('https://dev.manifold.markets/api/v0/market', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Key ${API_KEY}`,
},
body: JSON.stringify({
outcomeType: 'BINARY',
question: `"${title}" by ${author ?? 'anonymous'}`,
description: makeDescription(submission),
closeTime: Date.parse('2022-09-30').valueOf(),
initialProb: 10,
// Super secret options:
// groupId: 'y2hcaGybXT1UfobK3XTx', // [DEV] CEP Tournament
// groupId: 'cMcpBQ2p452jEcJD2SFw', // [PROD] Predict CEP
groupId: 'h3MhjYbSSG6HbxY8ZTwE', // [DEV] CART
// groupId: 'K86LmEmidMKdyCHdHNv4', // [PROD] CART
visibility: 'unlisted',
// TODO: Increase liquidity?
}),
})
const data = await response.json()
console.log('Created market:', data.slug)
}
async function postAll() {
for (const submission of data.slice(0, 3)) {
await postMarket(submission)
}
}
postAll()
/* Example curl request:
$ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Key {...}'
--data-raw '{"outcomeType":"BINARY", \
"question":"Is there life on Mars?", \
"description":"I'm not going to type some long ass example description.", \
"closeTime":1700000000000, \
"initialProb":25}'
*/
function makeDescription(submission: CEPSubmission) {
const { title, author, link } = submission
return {
content: [
{
content: [
{ text: `Will ${author ?? 'anonymous'}'s post "`, type: 'text' },
{
marks: [
{
attrs: {
target: '_blank',
href: link,
class:
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
},
type: 'link',
},
],
type: 'text',
text: title,
},
{ text: '" win any prize in the ', type: 'text' },
{
text: 'EA Criticism and Red Teaming Contest',
type: 'text',
marks: [
{
attrs: {
target: '_blank',
class:
'no-underline !text-indigo-700 z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
href: 'https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming',
},
type: 'link',
},
],
},
{ text: '?', type: 'text' },
],
type: 'paragraph',
},
{ type: 'paragraph' },
{
type: 'iframe',
attrs: {
allowfullscreen: true,
src: link,
frameborder: 0,
},
},
],
type: 'doc',
}
}

View File

@ -1,65 +0,0 @@
// 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 {}

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +0,0 @@
// Run with `npx ts-node src/scripts/contest/scrape-ea.ts`
import * as fs from 'fs'
import * as puppeteer from 'puppeteer'
export function scrapeEA(contestLink: string, fileName: string) {
;(async () => {
const browser = await puppeteer.launch({ headless: true })
const page = await browser.newPage()
await page.goto(contestLink)
let loadMoreButton = await page.$('.LoadMore-root')
while (loadMoreButton) {
await loadMoreButton.click()
await page.waitForNetworkIdle()
loadMoreButton = await page.$('.LoadMore-root')
}
/* Run javascript inside the page */
const data = await page.evaluate(() => {
const list = []
const items = document.querySelectorAll('.PostsItem2-root')
for (const item of items) {
const link =
'https://forum.effectivealtruism.org' +
item?.querySelector('a')?.getAttribute('href')
// Replace '&amp;' with '&'
const clean = (str: string | undefined) => str?.replace(/&amp;/g, '&')
list.push({
title: clean(item?.querySelector('a>span>span')?.innerHTML),
author: item?.querySelector('a.UsersNameDisplay-userName')?.innerHTML,
link: link,
})
}
return list
})
fs.writeFileSync(
`./src/scripts/contest/${fileName}.ts`,
`export const data = ${JSON.stringify(data, null, 2)}`
)
console.log(data)
await browser.close()
})()
}
scrapeEA(
'https://forum.effectivealtruism.org/topics/criticism-and-red-teaming-contest',
'criticism-and-red-teaming'
)

View File

@ -42,7 +42,6 @@ const createGroup = async (
totalContracts: contracts.length, totalContracts: contracts.length,
totalMembers: 1, totalMembers: 1,
postIds: [], postIds: [],
pinnedItems: [],
} }
await groupRef.create(group) await groupRef.create(group)
// create a GroupMemberDoc for the creator // create a GroupMemberDoc for the creator

View File

@ -3,6 +3,7 @@
import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore'
import { isEqual, zip } from 'lodash' import { isEqual, zip } from 'lodash'
import { UpdateSpec } from '../utils'
export type DocumentValue = { export type DocumentValue = {
doc: DocumentSnapshot doc: DocumentSnapshot
@ -53,7 +54,7 @@ export function getDiffUpdate(diff: DocumentDiff) {
return { return {
doc: diff.dest.doc.ref, doc: diff.dest.doc.ref,
fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)),
} } as UpdateSpec
} }
export function applyDiff(transaction: Transaction, diff: DocumentDiff) { export function applyDiff(transaction: Transaction, diff: DocumentDiff) {

View File

@ -1,8 +0,0 @@
import { initAdmin } from './script-init'
initAdmin()
import { drizzleLiquidity } from '../drizzle-liquidity'
if (require.main === module) {
drizzleLiquidity().then(() => process.exit())
}

View File

@ -0,0 +1,113 @@
// We have three kinds of rich text:
// - Contract descriptions.
// - Comment text.
// - Post contents.
// These are stored in two different ways:
// - As plaintext strings.
// - As structured ProseMirror JSON in Firestore.
// We want to make all of these into:
// - Strings containing serialized ProseMirror JSON.
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
import { initAdmin } from './script-init'
import { log, writeAsync } from '../utils'
import { filterDefined } from '../../../common/util/array'
import { plainTextToProseMirror } from '../../../common/util/parse'
initAdmin()
const firestore = admin.firestore()
const isValidJSON = (s: string) => {
try {
JSON.parse(s)
return true
} catch {
return false
}
}
const migrateContractDescriptions = async () => {
const contractQ = await firestore.collection('contracts').get()
console.log(`Loaded ${contractQ.size} contracts.`)
const updates = filterDefined(
contractQ.docs.map((doc) => {
const fields: { [k: string]: unknown } = {}
const oldDescription = doc.get('description')
if (typeof oldDescription === 'string') {
if (isValidJSON(oldDescription)) {
// this one is already good
return null
} else {
fields.description = JSON.stringify(
plainTextToProseMirror(oldDescription)
)
}
} else if (oldDescription != null) {
// already JSON, just need to serialize into string
fields.description = JSON.stringify(oldDescription)
} else {
throw new Error('Content had null description for some reason.')
}
return { doc: doc.ref, fields }
})
)
log(`Found ${updates.length} contracts with old format descriptions.`)
await writeAsync(firestore, updates)
}
const migrateCommentContents = async () => {
const commentQ = await firestore.collectionGroup('comments').get()
console.log(`Loaded ${commentQ.size} comments.`)
const updates = filterDefined(
commentQ.docs.map((doc) => {
const fields: { [k: string]: unknown } = {}
const oldText = doc.get('text')
const oldContent = doc.get('content')
if (typeof oldContent === 'string') {
// this one is already good
return null
} else if (oldContent != null) {
// already JSON, just need to serialize into string
fields.content = JSON.stringify(oldContent)
} else if (oldText != null) {
fields.text = FieldValue.delete()
fields.content = JSON.stringify(plainTextToProseMirror(oldText))
} else {
throw new Error('Comment mysteriously had neither text nor content.')
}
return { doc: doc.ref, fields }
})
)
log(`Found ${updates.length} comments with old format content.`)
await writeAsync(firestore, updates)
}
const migratePostContents = async () => {
const postQ = await firestore.collection('posts').get()
console.log(`Loaded ${postQ.size} posts.`)
const updates = filterDefined(
postQ.docs.map((doc) => {
const fields: { [k: string]: unknown } = {}
const oldContent = doc.get('content')
if (typeof oldContent === 'string') {
// this one is already good
return null
} else if (oldContent != null) {
// already JSON, just need to serialize into string
fields.content = JSON.stringify(oldContent)
} else {
throw new Error('Post had null content for some reason.')
}
return { doc: doc.ref, fields }
})
)
log(`Found ${updates.length} posts with old format content.`)
await writeAsync(firestore, updates)
}
if (require.main === module) {
migrateContractDescriptions()
migrateCommentContents()
migratePostContents()
}

View File

@ -1,59 +0,0 @@
import { initAdmin } from './script-init'
initAdmin()
import { zip } from 'lodash'
import { filterDefined } from 'common/util/array'
import { resolveMarket } from '../resolve-market'
import { getContract, getUser } from '../utils'
if (require.main === module) {
const contractIds = process.argv.slice(2)
if (contractIds.length === 0) {
throw new Error('No contract ids provided')
}
resolveMarketsAgain(contractIds).then(() => process.exit(0))
}
async function resolveMarketsAgain(contractIds: string[]) {
const maybeContracts = await Promise.all(contractIds.map(getContract))
if (maybeContracts.some((c) => !c)) {
throw new Error('Invalid contract id')
}
const contracts = filterDefined(maybeContracts)
const maybeCreators = await Promise.all(
contracts.map((c) => getUser(c.creatorId))
)
if (maybeCreators.some((c) => !c)) {
throw new Error('No creator found')
}
const creators = filterDefined(maybeCreators)
if (
!contracts.every((c) => c.resolution === 'YES' || c.resolution === 'NO')
) {
throw new Error('Only YES or NO resolutions supported')
}
const resolutionParams = contracts.map((c) => ({
outcome: c.resolution as string,
value: undefined,
probabilityInt: undefined,
resolutions: undefined,
}))
const params = zip(contracts, creators, resolutionParams)
for (const [contract, creator, resolutionParams] of params) {
if (contract && creator && resolutionParams) {
console.log('Resolving', contract.question)
try {
await resolveMarket(contract, creator, resolutionParams)
} catch (e) {
console.log(e)
}
}
}
console.log(`Resolved all contracts.`)
}

View File

@ -0,0 +1,46 @@
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<Contract>(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<Contract>)
}
}
if (require.main === module) {
updateContractTags().then(() => process.exit())
}

View File

@ -89,20 +89,17 @@ const getGroups = async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() { async function updateTotalContractsAndMembers() {
const groups = await getGroups() const groups = await getGroups()
await Promise.all( for (const group of groups) {
groups.map(async (group) => {
log('updating group total contracts and members', group.slug) log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id) const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()) const totalMembers = (await groupRef.collection('groupMembers').get()).size
.size
const totalContracts = (await groupRef.collection('groupContracts').get()) const totalContracts = (await groupRef.collection('groupContracts').get())
.size .size
await groupRef.update({ await groupRef.update({
totalMembers, totalMembers,
totalContracts, totalContracts,
}) })
}) }
)
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() { async function removeUnusedMemberAndContractFields() {
@ -120,6 +117,6 @@ async function removeUnusedMemberAndContractFields() {
if (require.main === module) { if (require.main === module) {
initAdmin() initAdmin()
// convertGroupFieldsToGroupDocuments() // convertGroupFieldsToGroupDocuments()
updateTotalContractsAndMembers() // updateTotalContractsAndMembers()
// removeUnusedMemberAndContractFields() removeUnusedMemberAndContractFields()
} }

View File

@ -1,7 +1,6 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash' import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api' import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -11,7 +10,8 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils' import { log } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet' import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { redeemShares } from './redeem-shares' import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market' import { removeUserFromContractFollowers } from './follow-market'
@ -29,18 +29,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`) const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`) const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [ const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
[contractSnap, userSnap], await Promise.all([
userBetsSnap,
{ unfilledBets, balanceByUserId },
] = await Promise.all([
transaction.getAll(contractDoc, userDoc), transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ), transaction.get(betsQ),
getUnfilledBetsAndUserBalances(transaction, contractDoc), transaction.get(getUnfilledBetsQuery(contractDoc)),
]) ])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) 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 contract = contractSnap.data() as Contract
const user = userSnap.data() as User const user = userSnap.data() as User
@ -88,13 +86,11 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0 if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers, ordersToCancel } = const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
getCpmmSellBetInfo(
soldShares, soldShares,
chosenOutcome, chosenOutcome,
contract, contract,
unfilledBets, unfilledBets,
balanceByUserId,
loanPaid loanPaid
) )
@ -131,12 +127,6 @@ 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 } return { newBet, makers, maxShares, soldShares }
}) })

View File

@ -19,7 +19,8 @@ import { sellbet } from './sell-bet'
import { sellshares } from './sell-shares' import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink' import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market' import { createmarket } from './create-market'
import { createcomment } from './create-comment' import { addliquidity } from './add-liquidity'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group' import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market' import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe' import { unsubscribe } from './unsubscribe'
@ -27,8 +28,6 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post' import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials' import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -54,15 +53,14 @@ addJsonEndpointRoute('/transact', transact)
addJsonEndpointRoute('/changeuserinfo', changeuserinfo) addJsonEndpointRoute('/changeuserinfo', changeuserinfo)
addJsonEndpointRoute('/createuser', createuser) addJsonEndpointRoute('/createuser', createuser)
addJsonEndpointRoute('/createanswer', createanswer) addJsonEndpointRoute('/createanswer', createanswer)
addJsonEndpointRoute('/createcomment', createcomment)
addJsonEndpointRoute('/placebet', placebet) addJsonEndpointRoute('/placebet', placebet)
addJsonEndpointRoute('/cancelbet', cancelbet) addJsonEndpointRoute('/cancelbet', cancelbet)
addJsonEndpointRoute('/sellbet', sellbet) addJsonEndpointRoute('/sellbet', sellbet)
addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addCommentBounty', addcommentbounty) addJsonEndpointRoute('/addliquidity', addliquidity)
addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)
@ -71,7 +69,6 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost) addEndpointRoute('/createpost', createpost)
addEndpointRoute('/testscheduledfunction', testscheduledfunction)
app.listen(PORT) app.listen(PORT)
console.log(`Serving functions on port ${PORT}.`) console.log(`Serving functions on port ${PORT}.`)

View File

@ -1,17 +0,0 @@
import { APIError, newEndpoint } from './api'
import { isProd } from './utils'
import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
// Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint(
{ method: 'GET', memory: '4GiB' },
async (_req) => {
if (isProd())
throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here
await sendMarketCloseEmails()
return { success: true }
}
)

View File

@ -4,7 +4,6 @@ import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user' import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences' import { notification_preference } from '../../common/user-notification-preferences'
import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = { export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 }, opts: { method: 'GET', minInstances: 1 },
@ -21,8 +20,6 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.') res.status(400).send('Invalid subscription type parameter.')
return return
} }
const optOutAllType: notification_preference = 'opt_out_all'
const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id) const user = await getPrivateUser(id)
@ -34,181 +31,20 @@ export const unsubscribe: EndpointDefinition = {
const previousDestinations = const previousDestinations =
user.notificationPreferences[notificationSubscriptionType] user.notificationPreferences[notificationSubscriptionType]
let newDestinations = previousDestinations
if (wantsToOptOutAll) newDestinations.push('email')
else
newDestinations = previousDestinations.filter(
(destination) => destination !== 'email'
)
console.log(previousDestinations) console.log(previousDestinations)
const { email } = user const { email } = user
const update: Partial<PrivateUser> = { const update: Partial<PrivateUser> = {
notificationPreferences: { notificationPreferences: {
...user.notificationPreferences, ...user.notificationPreferences,
[notificationSubscriptionType]: newDestinations, [notificationSubscriptionType]: previousDestinations.filter(
(destination) => destination !== 'email'
),
}, },
} }
await firestore.collection('private-users').doc(id).update(update) await firestore.collection('private-users').doc(id).update(update)
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
if (wantsToOptOutAll) {
res.send(
`
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>Unsubscribe from Manifold Markets emails</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
[owa] .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#F4F4F4;">
<div style="background-color:#F4F4F4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
width="100%">
<tbody>
<tr>
<td style="width:550px;">
<a href="https://manifold.markets" target="_blank">
<img alt="banner logo" height="auto" src="https://manifold.markets/logo-banner.png"
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
title="" width="550">
</a>
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
<p class="text-build-content"
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
data-testid="4XoHRGw1Y">
<span
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
${email} has opted out of receiving unnecessary email notifications
</span>
</div>
</td>
</tr>
<tr>
<td>
<p></p>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>`
)
} else {
res.send( res.send(
` `
<!DOCTYPE html> <!DOCTYPE html>
@ -216,7 +52,7 @@ export const unsubscribe: EndpointDefinition = {
xmlns:o="urn:schemas-microsoft-com:office:office"> xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<title>Unsubscribe from Manifold Markets emails</title> <title>Manifold Markets 7th Day Anniversary Gift!</title>
<!--[if !mso]><!--> <!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]--> <!--<![endif]-->
@ -361,12 +197,6 @@ export const unsubscribe: EndpointDefinition = {
<br/> <br/>
<br/> <br/>
<span>Click <span>Click
<a href=${optOutAllUrl}>here</a>
to unsubscribe from all unnecessary emails.
</span>
<br/>
<br/>
<span>Click
<a href='https://manifold.markets/notifications?tab=settings'>here</a> <a href='https://manifold.markets/notifications?tab=settings'>here</a>
to manage the rest of your notification settings. to manage the rest of your notification settings.
</span> </span>
@ -392,7 +222,6 @@ export const unsubscribe: EndpointDefinition = {
</html> </html>
` `
) )
}
}, },
} }

View File

@ -1,162 +0,0 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { removeUndefinedProps } from '../../common/util/object'
import { APIError, newEndpoint, validate } from './api'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { isProd } from './utils'
import {
CommentBountyDepositTxn,
CommentBountyWithdrawalTxn,
} from '../../common/txn'
import { runTxn } from './transact'
import { Comment } from '../../common/comment'
import { createBountyNotification } from './create-notification'
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
const awardBodySchema = z.object({
contractId: z.string(),
commentId: z.string(),
amount: z.number().gt(0),
})
export const addcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
return await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.balance < amount)
throw new APIError(400, 'Insufficient user balance')
const newCommentBountyTxn = {
fromId: user.id,
fromType: 'USER',
toId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
toType: 'BANK',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
},
description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`,
} as CommentBountyDepositTxn
const result = await runTxn(transaction, newCommentBountyTxn)
transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: (contract.openCommentBounties ?? 0) + amount,
})
)
return result
})
})
export const awardcommentbounty = newEndpoint({}, async (req, auth) => {
const { amount, commentId, contractId } = validate(awardBodySchema, req.body)
if (!isFinite(amount)) throw new APIError(400, 'Invalid amount')
// run as transaction to prevent race conditions
const res = await firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${auth.uid}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) throw new APIError(400, 'User not found')
const user = userSnap.data() as User
const contractDoc = firestore.doc(`contracts/${contractId}`)
const contractSnap = await transaction.get(contractDoc)
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract
if (user.id !== contract.creatorId)
throw new APIError(
400,
'Only contract creator can award comment bounties'
)
const commentDoc = firestore.doc(
`contracts/${contractId}/comments/${commentId}`
)
const commentSnap = await transaction.get(commentDoc)
if (!commentSnap.exists) throw new APIError(400, 'Invalid comment')
const comment = commentSnap.data() as Comment
const amountAvailable = contract.openCommentBounties ?? 0
if (amountAvailable < amount)
throw new APIError(400, 'Insufficient open bounty balance')
const newCommentBountyTxn = {
fromId: isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
fromType: 'BANK',
toId: comment.userId,
toType: 'USER',
amount,
token: 'M$',
category: 'COMMENT_BOUNTY',
data: {
contractId,
commentId,
},
description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`,
} as CommentBountyWithdrawalTxn
const result = await runTxn(transaction, newCommentBountyTxn)
await transaction.update(
contractDoc,
removeUndefinedProps({
openCommentBounties: amountAvailable - amount,
})
)
await transaction.update(
commentDoc,
removeUndefinedProps({
bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount,
})
)
return { ...result, comment, contract, user }
})
if (res.txn?.id) {
const { comment, contract, user } = res
await createBountyNotification(
user,
comment.userId,
amount,
res.txn.id,
contract,
comment.id
)
}
return res
})
const firestore = admin.firestore()

View File

@ -1,11 +1,10 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { groupBy, keyBy, sortBy } from 'lodash' import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils' import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans' import { getLoanUpdates } from '../../common/loans'
@ -15,82 +14,41 @@ import {
calculateNewPortfolioMetrics, calculateNewPortfolioMetrics,
calculateNewProfit, calculateNewProfit,
calculateProbChanges, calculateProbChanges,
calculateMetricsByContract,
computeElasticity,
computeVolume, computeVolume,
} from '../../common/calculate-metrics' } from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Group } from '../../common/group' 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() 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({}),
})
const json = await response.json() export const updateMetrics = functions
.runWith({ memory: '8GB', timeoutSeconds: 540 })
if (response.ok) console.log(json) .pubsub.schedule('every 15 minutes')
else console.error(json) .onRun(updateMetricsCore)
})
export const updatemetrics = newEndpointNoAuth(
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
async (_req) => {
await updateMetricsCore()
return { success: true }
}
)
export async function updateMetricsCore() { export async function updateMetricsCore() {
console.log('Loading users') const [users, contracts, bets, allPortfolioHistories, groups] =
const users = await getValues<User>(firestore.collection('users')) await Promise.all([
getValues<User>(firestore.collection('users')),
console.log('Loading contracts') getValues<Contract>(firestore.collection('contracts')),
const contracts = await getValues<Contract>(firestore.collection('contracts')) getValues<Bet>(firestore.collectionGroup('bets')),
getValues<PortfolioMetrics>(
console.log('Loading portfolio history') firestore
const userPortfolioHistory = await loadPortfolioHistory(users) .collectionGroup('portfolioHistory')
.where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago
console.log('Loading groups')
const groups = await getValues<Group>(firestore.collection('groups'))
console.log('Loading bets')
const contractBets = await batchedWaitAll(
contracts
.filter((c) => c.id)
.map(
(c) => () =>
getValues<Bet>(
firestore.collection('contracts').doc(c.id).collection('bets')
)
), ),
100 getValues<Group>(firestore.collection('groups')),
) ])
const bets = contractBets.flat()
console.log('Loading group contracts')
const contractsByGroup = await Promise.all( const contractsByGroup = await Promise.all(
groups.map((group) => groups.map((group) => {
getValues( return getValues(
firestore firestore
.collection('groups') .collection('groups')
.doc(group.id) .doc(group.id)
.collection('groupContracts') .collection('groupContracts')
) )
) })
) )
log( log(
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
@ -126,7 +84,6 @@ export async function updateMetricsCore() {
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
elasticity: computeElasticity(contractBets, contract),
...cpmmFields, ...cpmmFields,
}, },
} }
@ -139,10 +96,11 @@ export async function updateMetricsCore() {
) )
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
const betsByUser = groupBy(bets, (bet) => bet.userId) const betsByUser = groupBy(bets, (bet) => bet.userId)
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
const userMetrics = users.map((user) => { const userMetrics = users.map((user) => {
const currentBets = betsByUser[user.id] ?? [] const currentBets = betsByUser[user.id] ?? []
const portfolioHistory = userPortfolioHistory[user.id] ?? [] const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
const userContracts = contractsByUser[user.id] ?? [] const userContracts = contractsByUser[user.id] ?? []
const newCreatorVolume = calculateCreatorVolume(userContracts) const newCreatorVolume = calculateCreatorVolume(userContracts)
const newPortfolio = calculateNewPortfolioMetrics( const newPortfolio = calculateNewPortfolioMetrics(
@ -150,51 +108,21 @@ export async function updateMetricsCore() {
contractsById, contractsById,
currentBets currentBets
) )
const currPortfolio = portfolioHistory.current const lastPortfolio = last(portfolioHistory)
const didPortfolioChange = const didPortfolioChange =
currPortfolio === undefined || lastPortfolio === undefined ||
currPortfolio.balance !== newPortfolio.balance || lastPortfolio.balance !== newPortfolio.balance ||
currPortfolio.totalDeposits !== newPortfolio.totalDeposits || lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
currPortfolio.investmentValue !== newPortfolio.investmentValue lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) 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 { return {
user, user,
newCreatorVolume, newCreatorVolume,
newPortfolio, newPortfolio,
newProfit, newProfit,
didPortfolioChange, didPortfolioChange,
newFractionResolvedCorrectly,
metricsByContract,
} }
}) })
@ -210,61 +138,61 @@ export async function updateMetricsCore() {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map( const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newProfit, newFractionResolvedCorrectly }) => { ({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id), doc: firestore.collection('users').doc(user.id),
fields: { fields: {
creatorVolumeCached: newCreatorVolume, creatorVolumeCached: newCreatorVolume,
profitCached: newProfit, profitCached: newProfit,
nextLoanCached, nextLoanCached,
fractionResolvedCorrectly: newFractionResolvedCorrectly,
}, },
} },
}
)
await writeAsync(firestore, userUpdates)
const portfolioHistoryUpdates = filterDefined( subcollectionUpdates: {
userMetrics.map(({ user, newPortfolio, didPortfolioChange }) => {
return didPortfolioChange
? {
doc: firestore doc: firestore
.collection('users') .collection('users')
.doc(user.id) .doc(user.id)
.collection('portfolioHistory') .collection('portfolioHistory')
.doc(), .doc(),
fields: newPortfolio, fields: didPortfolioChange ? newPortfolio : {},
},
} }
: null
})
)
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(
await writeAsync(firestore, contractMetricsUpdates, 'set') firestore,
userUpdates.map((u) => u.fieldUpdates)
)
await writeAsync(
firestore,
userUpdates
.filter((u) => !isEmpty(u.subcollectionUpdates.fields))
.map((u) => u.subcollectionUpdates),
'set'
)
log(`Updated metrics for ${users.length} users.`) log(`Updated metrics for ${users.length} users.`)
try { try {
const groupUpdates = groups.map((group, index) => { const groupUpdates = groups.map((group, index) => {
const groupContractIds = contractsByGroup[index] as GroupContractDoc[] const groupContractIds = contractsByGroup[index] as GroupContractDoc[]
const groupContracts = filterDefined( const groupContracts = groupContractIds
groupContractIds.map((e) => contractsById[e.contractId]) .map((e) => contractsById[e.contractId])
) .filter((e) => e !== undefined) as Contract[]
const bets = groupContracts.map((e) => betsByContract[e.id] ?? []) const bets = groupContracts.map((e) => {
if (e != null && e.id in betsByContract) {
return betsByContract[e.id] ?? []
} else {
return []
}
})
const creatorScores = scoreCreators(groupContracts) const creatorScores = scoreCreators(groupContracts)
const traderScores = scoreTraders(groupContracts, bets) const traderScores = scoreTraders(groupContracts, bets)
@ -296,46 +224,3 @@ const topUserScores = (scores: { [userId: string]: number }) => {
} }
type GroupContractDoc = { contractId: string; createdTime: 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<PortfolioMetrics>(query),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - DAY_MS)
),
getValues<PortfolioMetrics>(
query.where('timestamp', '<', now - 7 * DAY_MS)
),
getValues<PortfolioMetrics>(
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)
}

View File

@ -18,7 +18,7 @@ import { average } from '../../common/util/math'
const firestore = admin.firestore() const firestore = admin.firestore()
const numberOfDays = 180 const numberOfDays = 90
const getBetsQuery = (startTime: number, endTime: number) => const getBetsQuery = (startTime: number, endTime: number) =>
firestore firestore

View File

@ -1,8 +1,7 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import fetch from 'node-fetch' import fetch from 'node-fetch'
import { FieldValue, Transaction } from 'firebase-admin/firestore'
import { chunk, groupBy, mapValues, sumBy } from 'lodash'
import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group' import { Group } from '../../common/group'
@ -48,7 +47,7 @@ export const writeAsync = async (
const batch = db.batch() const batch = db.batch()
for (const { doc, fields } of chunks[i]) { for (const { doc, fields } of chunks[i]) {
if (operationType === 'update') { if (operationType === 'update') {
batch.update(doc, fields as any) batch.update(doc, fields)
} else { } else {
batch.set(doc, fields) batch.set(doc, fields)
} }
@ -113,12 +112,6 @@ export const getAllPrivateUsers = async () => {
return users.docs.map((doc) => doc.data() as PrivateUser) 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) => { export const getUserByUsername = async (username: string) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const snap = await firestore const snap = await firestore
@ -129,29 +122,38 @@ export const getUserByUsername = async (username: string) => {
return snap.empty ? undefined : (snap.docs[0].data() as User) return snap.empty ? undefined : (snap.docs[0].data() as User)
} }
const firestore = admin.firestore()
const updateUserBalance = ( const updateUserBalance = (
transaction: Transaction,
userId: string, userId: string,
balanceDelta: number, delta: number,
depositDelta: number isDeposit = false
) => { ) => {
const firestore = admin.firestore()
return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`) const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc)
if (!userSnap.exists) return
const user = userSnap.data() as User
// Note: Balance is allowed to go negative. const newUserBalance = user.balance + delta
transaction.update(userDoc, {
balance: FieldValue.increment(balanceDelta), // if (newUserBalance < 0)
totalDeposits: FieldValue.increment(depositDelta), // throw new Error(
// `User (${userId}) balance cannot be negative: ${newUserBalance}`
// )
if (isDeposit) {
const newTotalDeposits = (user.totalDeposits || 0) + delta
transaction.update(userDoc, { totalDeposits: newTotalDeposits })
}
transaction.update(userDoc, { balance: newUserBalance })
}) })
} }
export const payUser = (userId: string, payout: number, isDeposit = false) => { export const payUser = (userId: string, payout: number, isDeposit = false) => {
if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout)
return firestore.runTransaction(async (transaction) => { return updateUserBalance(userId, payout, isDeposit)
updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0)
})
} }
export const chargeUser = ( export const chargeUser = (
@ -162,73 +164,9 @@ export const chargeUser = (
if (!isFinite(charge) || charge <= 0) if (!isFinite(charge) || charge <= 0)
throw new Error('User charge is not positive: ' + charge) throw new Error('User charge is not positive: ' + charge)
return payUser(userId, -charge, isAnte) return updateUserBalance(userId, -charge, isAnte)
}
const checkAndMergePayouts = (
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
for (const { payout, deposit } of payouts) {
if (!isFinite(payout)) {
throw new Error('Payout is not finite: ' + payout)
}
if (deposit !== undefined && !isFinite(deposit)) {
throw new Error('Deposit is not finite: ' + deposit)
}
}
const groupedPayouts = groupBy(payouts, 'userId')
return Object.values(
mapValues(groupedPayouts, (payouts, userId) => ({
userId,
payout: sumBy(payouts, 'payout'),
deposit: sumBy(payouts, (p) => p.deposit ?? 0),
}))
)
}
// Max 500 users in one transaction.
export const payUsers = (
transaction: Transaction,
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
const mergedPayouts = checkAndMergePayouts(payouts)
for (const { userId, payout, deposit } of mergedPayouts) {
updateUserBalance(transaction, userId, payout, deposit)
}
}
export const payUsersMultipleTransactions = async (
payouts: {
userId: string
payout: number
deposit?: number
}[]
) => {
const mergedPayouts = checkAndMergePayouts(payouts)
const payoutChunks = chunk(mergedPayouts, 500)
for (const payoutChunk of payoutChunks) {
await firestore.runTransaction(async (transaction) => {
for (const { userId, payout, deposit } of payoutChunk) {
updateUserBalance(transaction, userId, payout, deposit)
}
})
}
} }
export const getContractPath = (contract: Contract) => { export const getContractPath = (contract: Contract) => {
return `/${contract.creatorUsername}/${contract.slug}` return `/${contract.creatorUsername}/${contract.slug}`
} }
export function contractUrl(contract: Contract) {
return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}`
}

View File

@ -4,24 +4,21 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { import {
getAllPrivateUsers, getAllPrivateUsers,
getGroup,
getPrivateUser, getPrivateUser,
getUser, getUser,
getValues, getValues,
isProd, isProd,
log, log,
} from './utils' } from './utils'
import { createRNG, shuffle } from '../../common/util/random'
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' import { sendInterestingMarketsEmail } from './emails'
import { createRNG, shuffle } from '../../common/util/random'
import { DAY_MS } from '../../common/util/time'
import { filterDefined } from '../../common/util/array'
export const weeklyMarketsEmails = functions export const weeklyMarketsEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Monday for 2 hours starting at 12pm PT (UTC -07:00) // every minute on Monday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19-20 * * 1') .pubsub.schedule('* 19 * * 1')
.timeZone('Etc/UTC') .timeZone('Etc/UTC')
.onRun(async () => { .onRun(async () => {
await sendTrendingMarketsEmailsToAllUsers() await sendTrendingMarketsEmailsToAllUsers()
@ -43,30 +40,18 @@ export async function getTrendingContracts() {
) )
} }
export async function sendTrendingMarketsEmailsToAllUsers() { async function sendTrendingMarketsEmailsToAllUsers() {
const numContractsToSend = 6 const numContractsToSend = 6
const privateUsers = isProd() const privateUsers = isProd()
? await getAllPrivateUsers() ? await getAllPrivateUsers()
: filterDefined([ : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2'), // dev Ian // get all users that haven't unsubscribed from weekly emails
]) const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
const privateUsersToSendEmailsTo = privateUsers return (
// Get all users that haven't unsubscribed from weekly emails
.filter(
(user) =>
user.notificationPreferences.trending_markets.includes('email') && user.notificationPreferences.trending_markets.includes('email') &&
!user.weeklyTrendingEmailSent !user.weeklyTrendingEmailSent
) )
.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( log(
'Sending weekly trending emails to', 'Sending weekly trending emails to',
privateUsersToSendEmailsTo.length, privateUsersToSendEmailsTo.length,
@ -83,358 +68,38 @@ export async function sendTrendingMarketsEmailsToAllUsers() {
!contract.groupSlugs?.includes('manifold-features') && !contract.groupSlugs?.includes('manifold-features') &&
!contract.groupSlugs?.includes('manifold-6748e065087e') !contract.groupSlugs?.includes('manifold-6748e065087e')
) )
.slice(0, 50) .slice(0, 20)
log(
`Found ${trendingContracts.length} trending contracts:\n`,
trendingContracts.map((c) => c.question).join('\n ')
)
const uniqueTrendingContracts = removeSimilarQuestions( for (const privateUser of privateUsersToSendEmailsTo) {
trendingContracts,
trendingContracts,
true
).slice(0, 20)
await Promise.all(
privateUsersToSendEmailsTo.map(async (privateUser) => {
if (!privateUser.email) { if (!privateUser.email) {
log(`No email for ${privateUser.username}`) log(`No email for ${privateUser.username}`)
return continue
} }
const contractsAvailableToSend = trendingContracts.filter((contract) => {
const unbetOnFollowedMarkets = await getUserUnBetOnFollowsMarkets( return !contract.uniqueBettorIds?.includes(privateUser.id)
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 if (contractsAvailableToSend.length < numContractsToSend) {
log('not enough new, unbet-on contracts to send to user', privateUser.id)
continue
} }
// choose random subset of contracts to send to user // choose random subset of contracts to send to user
const contractsToSend = chooseRandomSubset( const contractsToSend = chooseRandomSubset(
marketsAvailableToSend, contractsAvailableToSend,
numContractsToSend numContractsToSend
) )
const user = await getUser(privateUser.id) const user = await getUser(privateUser.id)
if (!user) return if (!user) continue
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 sendInterestingMarketsEmail(user, privateUser, contractsToSend)
await firestore.collection('private-users').doc(user.id).update({ await firestore.collection('private-users').doc(user.id).update({
weeklyTrendingEmailSent: true, weeklyTrendingEmailSent: true,
}) })
})
)
}
const MINIMUM_POPULARITY_SCORE = 10
const getUserUnBetOnFollowsMarkets = async (userId: string) => {
const follows = await getValues<Follow>(
firestore.collection('users').doc(userId).collection('follows')
)
const unBetOnContractsFromFollows = await Promise.all(
follows.map(async (follow) => {
const unresolvedContracts = await getValues<Contract>(
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<Contract>(
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<Contract>(
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<Contract>(
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 const fiveMinutes = 5 * 60 * 1000
@ -445,40 +110,3 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
shuffle(contracts, rng) shuffle(contracts, rng)
return contracts.slice(0, count) 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',
]

View File

@ -1,280 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { Contract, CPMMContract } from '../../common/contract'
import {
getAllPrivateUsers,
getPrivateUser,
getUser,
getValue,
getValues,
isProd,
log,
} from './utils'
import { filterDefined } from '../../common/util/array'
import { DAY_MS } from '../../common/util/time'
import { partition, sortBy, sum, uniq } from 'lodash'
import { Bet } from '../../common/bet'
import { computeInvestmentValueCustomProb } from '../../common/calculate-metrics'
import { sendWeeklyPortfolioUpdateEmail } from './emails'
import { contractUrl } from './utils'
import { Txn } from '../../common/txn'
import { formatMoney } from '../../common/util/format'
import { getContractBetMetrics } from '../../common/calculate'
export const weeklyPortfolioUpdateEmails = functions
.runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' })
// every minute on Friday for an hour at 12pm PT (UTC -07:00)
.pubsub.schedule('* 19 * * 5')
.timeZone('Etc/UTC')
.onRun(async () => {
await sendPortfolioUpdateEmailsToAllUsers()
})
const firestore = admin.firestore()
export async function sendPortfolioUpdateEmailsToAllUsers() {
const privateUsers = isProd()
? // ian & stephen's ids
// filterDefined([
// await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'),
// await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'),
// ])
await getAllPrivateUsers()
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
// get all users that haven't unsubscribed from weekly emails
const privateUsersToSendEmailsTo = privateUsers
.filter((user) => {
return isProd()
? user.notificationPreferences.profit_loss_updates.includes('email') &&
!user.weeklyPortfolioUpdateEmailSent
: user.notificationPreferences.profit_loss_updates.includes('email')
})
// Send emails in batches
.slice(0, 200)
log(
'Sending weekly portfolio emails to',
privateUsersToSendEmailsTo.length,
'users'
)
const usersBets: { [userId: string]: Bet[] } = {}
// get all bets made by each user
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return getValues<Bet>(
firestore.collectionGroup('bets').where('userId', '==', user.id)
).then((bets) => {
usersBets[user.id] = bets
})
})
)
const usersToContractsCreated: { [userId: string]: Contract[] } = {}
// Get all contracts created by each user
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return getValues<Contract>(
firestore
.collection('contracts')
.where('creatorId', '==', user.id)
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
).then((contracts) => {
usersToContractsCreated[user.id] = contracts
})
})
)
// Get all txns the users received over the past week
const usersToTxnsReceived: { [userId: string]: Txn[] } = {}
await Promise.all(
privateUsersToSendEmailsTo.map(async (user) => {
return getValues<Txn>(
firestore
.collection(`txns`)
.where('toId', '==', user.id)
.where('createdTime', '>', Date.now() - 7 * DAY_MS)
).then((txn) => {
usersToTxnsReceived[user.id] = txn
})
})
)
// Get a flat map of all the bets that users made to get the contracts they bet on
const contractsUsersBetOn = filterDefined(
await Promise.all(
uniq(
Object.values(usersBets).flatMap((bets) =>
bets.map((bet) => bet.contractId)
)
).map((contractId) =>
getValue<Contract>(firestore.collection('contracts').doc(contractId))
)
)
)
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 await setEmailFlagAsSent(privateUser.id)
const userBets = usersBets[privateUser.id] as Bet[]
const contractsUserBetOn = contractsUsersBetOn.filter((contract) =>
userBets.some((bet) => bet.contractId === contract.id)
)
const contractsBetOnInLastWeek = uniq(
userBets
.filter((bet) => bet.createdTime > Date.now() - 7 * DAY_MS)
.map((bet) => bet.contractId)
)
const totalTips = sum(
usersToTxnsReceived[privateUser.id]
.filter((txn) => txn.category === 'TIP')
.map((txn) => txn.amount)
)
const greenBg = 'rgba(0,160,0,0.2)'
const redBg = 'rgba(160,0,0,0.2)'
const clearBg = 'rgba(255,255,255,0)'
const roundedProfit =
Math.round(user.profitCached.weekly) === 0
? 0
: Math.floor(user.profitCached.weekly)
const performanceData = {
profit: formatMoney(user.profitCached.weekly),
profit_style: `background-color: ${
roundedProfit > 0 ? greenBg : roundedProfit === 0 ? clearBg : redBg
}`,
markets_created:
usersToContractsCreated[privateUser.id].length.toString(),
tips_received: formatMoney(totalTips),
unique_bettors: usersToTxnsReceived[privateUser.id]
.filter((txn) => txn.category === 'UNIQUE_BETTOR_BONUS')
.length.toString(),
markets_traded: contractsBetOnInLastWeek.length.toString(),
prediction_streak:
(user.currentBettingStreak?.toString() ?? '0') + ' days',
// More options: bonuses, tips given,
} as OverallPerformanceData
const investmentValueDifferences = sortBy(
filterDefined(
contractsUserBetOn.map((contract) => {
const cpmmContract = contract as CPMMContract
if (cpmmContract === undefined || cpmmContract.prob === undefined)
return
const bets = userBets.filter(
(bet) => bet.contractId === contract.id
)
const previousBets = bets.filter(
(b) => b.createdTime < Date.now() - 7 * DAY_MS
)
const betsInLastWeek = bets.filter(
(b) => b.createdTime >= Date.now() - 7 * DAY_MS
)
const marketProbabilityAWeekAgo =
cpmmContract.prob - cpmmContract.probChanges.week
const currentMarketProbability = cpmmContract.resolutionProbability
? cpmmContract.resolutionProbability
: cpmmContract.prob
// TODO: returns 0 for resolved markets - doesn't include them
const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb(
previousBets,
contract,
marketProbabilityAWeekAgo
)
const currentBetsMadeAWeekAgoValue =
computeInvestmentValueCustomProb(
previousBets,
contract,
currentMarketProbability
)
const betsMadeInLastWeekProfit = getContractBetMetrics(
contract,
betsInLastWeek
).profit
const profit =
betsMadeInLastWeekProfit +
(currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue)
return {
currentValue: currentBetsMadeAWeekAgoValue,
pastValue: betsMadeAWeekAgoValue,
profit,
contractSlug: contract.slug,
marketProbAWeekAgo: marketProbabilityAWeekAgo,
questionTitle: contract.question,
questionUrl: contractUrl(contract),
questionProb: cpmmContract.resolution
? cpmmContract.resolution
: Math.round(cpmmContract.prob * 100) + '%',
profitStyle: `color: ${
profit > 0 ? 'rgba(0,160,0,1)' : '#a80000'
};`,
} as PerContractInvestmentsData
})
),
(differences) => Math.abs(differences.profit)
).reverse()
const [winningInvestments, losingInvestments] = partition(
investmentValueDifferences.filter(
(diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1
),
(investmentsData: PerContractInvestmentsData) => {
return investmentsData.profit > 0
}
)
// pick 3 winning investments and 3 losing investments
const topInvestments = winningInvestments.slice(0, 2)
const worstInvestments = losingInvestments.slice(0, 2)
// if no bets in the last week ANd no market movers AND no markets created, don't send email
if (
contractsBetOnInLastWeek.length === 0 &&
topInvestments.length === 0 &&
worstInvestments.length === 0 &&
usersToContractsCreated[privateUser.id].length === 0
) {
log(
`No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .`
)
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
)
})
)
}
async function setEmailFlagAsSent(privateUserId: string) {
await firestore.collection('private-users').doc(privateUserId).update({
weeklyPortfolioUpdateEmailSent: true,
})
}
export type PerContractInvestmentsData = {
questionTitle: string
questionUrl: string
questionProb: string
profitStyle: string
currentValue: number
pastValue: number
profit: number
}
export type OverallPerformanceData = {
profit: string
prediction_streak: string
markets_traded: string
profit_style: string
tips_received: string
markets_created: string
unique_bettors: string
}

View File

@ -0,0 +1,121 @@
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<User>)
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<Bet, 'id'>)
)
.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()

View File

@ -14,6 +14,11 @@ export function getHtml(parsedReq: ParsedRequest) {
numericValue, numericValue,
resolution, resolution,
} = parsedReq } = parsedReq
const MAX_QUESTION_CHARS = 100
const truncatedQuestion =
question.length > MAX_QUESTION_CHARS
? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden' const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary' let resolutionColor = 'text-primary'
@ -64,7 +69,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<meta charset="utf-8"> <meta charset="utf-8">
<title>Generated Image</title> <title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com?plugins=line-clamp"></script> <script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<style> <style>
${getTemplateCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
@ -104,8 +109,8 @@ export function getHtml(parsedReq: ParsedRequest) {
</div> </div>
<div class="flex flex-row justify-between gap-12 pt-36"> <div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-tight line-clamp-4"> <div class="text-indigo-700 text-6xl leading-tight">
${question} ${truncatedQuestion}
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
${ ${
@ -122,7 +127,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<!-- Metadata --> <!-- Metadata -->
<div class="absolute bottom-16"> <div class="absolute bottom-16">
<div class="text-gray-500 text-3xl max-w-[80vw] line-clamp-2"> <div class="text-gray-500 text-3xl max-w-[80vw]">
${metadata} ${metadata}
</div> </div>
</div> </div>

View File

@ -24,5 +24,8 @@
"prettier": "2.7.1", "prettier": "2.7.1",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "4.8.2" "typescript": "4.8.2"
},
"resolutions": {
"@types/react": "17.0.43"
} }
} }

View File

@ -22,9 +22,8 @@ module.exports = {
'@next/next/no-typos': 'off', '@next/next/no-typos': 'off',
'linebreak-style': ['error', 'unix'], 'linebreak-style': ['error', 'unix'],
'lodash/import-scope': [2, 'member'], 'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'warn', 'unused-imports/no-unused-imports': 'error',
}, },
ignorePatterns: ['/public/mtg/*'],
env: { env: {
browser: true, browser: true,
node: true, node: true,

1
web/.gitignore vendored
View File

@ -3,4 +3,3 @@
node_modules node_modules
out out
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.env*

Some files were not shown because too many files have changed in this diff Show More