diff --git a/.github/workflows/merge-main-into-main2.yml b/.github/workflows/merge-main-into-main2.yml new file mode 100644 index 00000000..0a8de56f --- /dev/null +++ b/.github/workflows/merge-main-into-main2.yml @@ -0,0 +1,17 @@ +name: Merge main into main2 on every commit +on: + push: + branches: + - 'main' +jobs: + merge-branch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + + - name: Merge main -> main2 + uses: devmasx/merge-branch@master + with: + type: now + target_branch: main2 + github_token: ${{ github.token }} diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index b5153355..346fca79 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -147,7 +147,8 @@ function calculateAmountToBuyShares( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { // Search for amount between bounds (0, shares). // Min share price is M$0, and max is M$1 each. @@ -157,7 +158,8 @@ function calculateAmountToBuyShares( amount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) const totalShares = sumBy(takers, (taker) => taker.shares) @@ -169,7 +171,8 @@ export function calculateCpmmSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { if (Math.round(shares) < 0) { throw new Error('Cannot sell non-positive shares') @@ -180,15 +183,17 @@ export function calculateCpmmSale( state, shares, oppositeOutcome, - unfilledBets + unfilledBets, + balanceByUserId ) - const { cpmmState, makers, takers, totalFees } = computeFills( + const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills( oppositeOutcome, buyAmount, state, undefined, - unfilledBets + unfilledBets, + balanceByUserId ) // Transform buys of opposite outcome into sells. @@ -211,6 +216,7 @@ export function calculateCpmmSale( fees: totalFees, makers, takers: saleTakers, + ordersToCancel, } } @@ -218,9 +224,16 @@ export function getCpmmProbabilityAfterSale( state: CpmmState, shares: number, outcome: 'YES' | 'NO', - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { - const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets) + const { cpmmState } = calculateCpmmSale( + state, + shares, + outcome, + unfilledBets, + balanceByUserId + ) return getCpmmProbability(cpmmState.pool, cpmmState.p) } diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index 7c2153c1..9ad44522 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -1,9 +1,11 @@ -import { last, sortBy, sum, sumBy } from 'lodash' +import { last, sortBy, sum, sumBy, uniq } from 'lodash' import { calculatePayout } from './calculate' -import { Bet } from './bet' -import { Contract } from './contract' +import { Bet, LimitBet } from './bet' +import { Contract, CPMMContract, DPMContract } from './contract' import { PortfolioMetrics, User } from './user' import { DAY_MS } from './util/time' +import { getBinaryCpmmBetInfo, getNewMultiBetInfo } from './new-bet' +import { getCpmmProbability } from './calculate-cpmm' const computeInvestmentValue = ( bets: Bet[], @@ -40,6 +42,75 @@ export const computeInvestmentValueCustomProb = ( }) } +export const computeElasticity = ( + bets: Bet[], + contract: Contract, + betAmount = 50 +) => { + const { mechanism, outcomeType } = contract + return mechanism === 'cpmm-1' && + (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') + ? computeBinaryCpmmElasticity(bets, contract, betAmount) + : computeDpmElasticity(contract, betAmount) +} + +export const computeBinaryCpmmElasticity = ( + bets: Bet[], + contract: CPMMContract, + betAmount: number +) => { + const limitBets = bets + .filter( + (b) => + !b.isFilled && + !b.isSold && + !b.isRedemption && + !b.sale && + !b.isCancelled && + b.limitProb !== undefined + ) + .sort((a, b) => a.createdTime - b.createdTime) as LimitBet[] + + const userIds = uniq(limitBets.map((b) => b.userId)) + // Assume all limit orders are good. + const userBalances = Object.fromEntries( + userIds.map((id) => [id, Number.MAX_SAFE_INTEGER]) + ) + + const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo( + 'YES', + betAmount, + contract, + undefined, + limitBets, + userBalances + ) + const resultYes = getCpmmProbability(poolY, pY) + + const { newPool: poolN, newP: pN } = getBinaryCpmmBetInfo( + 'NO', + betAmount, + contract, + undefined, + limitBets, + userBalances + ) + const resultNo = getCpmmProbability(poolN, pN) + + // handle AMM overflow + const safeYes = Number.isFinite(resultYes) ? resultYes : 1 + const safeNo = Number.isFinite(resultNo) ? resultNo : 0 + + return safeYes - safeNo +} + +export const computeDpmElasticity = ( + contract: DPMContract, + betAmount: number +) => { + return getNewMultiBetInfo('', 2 * betAmount, contract).newBet.probAfter +} + const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const periodFilteredContracts = userContracts.filter( (contract) => contract.createdTime >= startTime diff --git a/common/calculate.ts b/common/calculate.ts index 6481734f..44dc9113 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -78,7 +78,8 @@ export function calculateShares( export function calculateSaleAmount( contract: Contract, bet: Bet, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' && (contract.outcomeType === 'BINARY' || @@ -87,7 +88,8 @@ export function calculateSaleAmount( contract, Math.abs(bet.shares), bet.outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ).saleValue : calculateDpmSaleAmount(contract, bet) } @@ -102,14 +104,16 @@ export function getProbabilityAfterSale( contract: Contract, outcome: string, shares: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) { return contract.mechanism === 'cpmm-1' ? getCpmmProbabilityAfterSale( contract, shares, outcome as 'YES' | 'NO', - unfilledBets + unfilledBets, + balanceByUserId ) : getDpmProbabilityAfterSale(contract.totalShares, outcome, shares) } diff --git a/common/contract.ts b/common/contract.ts index 1255874d..2656b5d5 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -49,6 +49,7 @@ export type Contract = { volume: number volume24Hours: number volume7Days: number + elasticity: number collectedFees: Fees diff --git a/common/fees.ts b/common/fees.ts index f944933c..7421ef54 100644 --- a/common/fees.ts +++ b/common/fees.ts @@ -1,3 +1,5 @@ +export const FLAT_TRADE_FEE = 0.1 // M$0.1 + export const PLATFORM_FEE = 0 export const CREATOR_FEE = 0 export const LIQUIDITY_FEE = 0 diff --git a/common/new-bet.ts b/common/new-bet.ts index 91faf640..8057cd5b 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -17,8 +17,7 @@ import { import { CPMMBinaryContract, DPMBinaryContract, - FreeResponseContract, - MultipleChoiceContract, + DPMContract, NumericContract, PseudoNumericContract, } from './contract' @@ -144,7 +143,8 @@ export const computeFills = ( betAmount: number, state: CpmmState, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { if (isNaN(betAmount)) { throw new Error('Invalid bet amount: ${betAmount}') @@ -166,10 +166,12 @@ export const computeFills = ( shares: number timestamp: number }[] = [] + const ordersToCancel: LimitBet[] = [] let amount = betAmount let cpmmState = { pool: state.pool, p: state.p } let totalFees = noFees + const currentBalanceByUserId = { ...balanceByUserId } let i = 0 while (true) { @@ -186,9 +188,20 @@ export const computeFills = ( takers.push(taker) } else { // Matched against bet. + i++ + const { userId } = maker.bet + const makerBalance = currentBalanceByUserId[userId] + + if (floatingGreaterEqual(makerBalance, maker.amount)) { + currentBalanceByUserId[userId] = makerBalance - maker.amount + } else { + // Insufficient balance. Cancel maker bet. + ordersToCancel.push(maker.bet) + continue + } + takers.push(taker) makers.push(maker) - i++ } amount -= taker.amount @@ -196,7 +209,7 @@ export const computeFills = ( if (floatingEqual(amount, 0)) break } - return { takers, makers, totalFees, cpmmState } + return { takers, makers, totalFees, cpmmState, ordersToCancel } } export const getBinaryCpmmBetInfo = ( @@ -204,15 +217,17 @@ export const getBinaryCpmmBetInfo = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number | undefined, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { pool, p } = contract - const { takers, makers, cpmmState, totalFees } = computeFills( + const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills( outcome, betAmount, { pool, p }, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) const probBefore = getCpmmProbability(contract.pool, contract.p) const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p) @@ -247,6 +262,7 @@ export const getBinaryCpmmBetInfo = ( newP: cpmmState.p, newTotalLiquidity, makers, + ordersToCancel, } } @@ -255,14 +271,16 @@ export const getBinaryBetStats = ( betAmount: number, contract: CPMMBinaryContract | PseudoNumericContract, limitProb: number, - unfilledBets: LimitBet[] + unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number } ) => { const { newBet } = getBinaryCpmmBetInfo( outcome, betAmount ?? 0, contract, limitProb, - unfilledBets as LimitBet[] + unfilledBets, + balanceByUserId ) const remainingMatched = ((newBet.orderAmount ?? 0) - newBet.amount) / @@ -325,7 +343,7 @@ export const getNewBinaryDpmBetInfo = ( export const getNewMultiBetInfo = ( outcome: string, amount: number, - contract: FreeResponseContract | MultipleChoiceContract + contract: DPMContract ) => { const { pool, totalShares, totalBets } = contract diff --git a/common/new-contract.ts b/common/new-contract.ts index 3580b164..9a73e2ea 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -70,6 +70,7 @@ export function getNewContract( volume: 0, volume24Hours: 0, volume7Days: 0, + elasticity: propsByOutcomeType.mechanism === 'cpmm-1' ? 0.38 : 0.75, collectedFees: { creatorFee: 0, diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 96636ca0..1b56c819 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = ( outcome: 'YES' | 'NO', contract: CPMMContract, unfilledBets: LimitBet[], + balanceByUserId: { [userId: string]: number }, loanPaid: number ) => { const { pool, p } = contract - const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale( + const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale( contract, shares, outcome, - unfilledBets + unfilledBets, + balanceByUserId, ) const probBefore = getCpmmProbability(pool, p) @@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = ( fees, makers, takers, + ordersToCancel } } diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index a9460fdc..6b5a448d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -179,31 +179,44 @@ export const getNotificationDestinationsForUser = ( reason: notification_reason_types | notification_preference ) => { const notificationSettings = privateUser.notificationPreferences - let destinations - let subscriptionType: notification_preference | undefined - if (Object.keys(notificationSettings).includes(reason)) { - subscriptionType = reason as notification_preference - destinations = notificationSettings[subscriptionType] - } else { - const key = reason as notification_reason_types - subscriptionType = notificationReasonToSubscriptionType[key] - destinations = subscriptionType - ? notificationSettings[subscriptionType] - : [] - } - const optOutOfAllSettings = notificationSettings['opt_out_all'] - // Your market closure notifications are high priority, opt-out doesn't affect their delivery - const optedOutOfEmail = - optOutOfAllSettings.includes('email') && - subscriptionType !== 'your_contract_closed' - const optedOutOfBrowser = - optOutOfAllSettings.includes('browser') && - subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - return { - sendToEmail: destinations.includes('email') && !optedOutOfEmail, - sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, - unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, - urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + try { + let destinations + let subscriptionType: notification_preference | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as notification_preference + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' + return { + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, + unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } + } catch (e) { + // Fail safely + console.log( + `couldn't get notification destinations for type ${reason} for user ${privateUser.id}` + ) + return { + sendToEmail: false, + sendToBrowser: false, + unsubscribeUrl: '', + urlToManageThisNotification: '', + } } } diff --git a/common/util/parse.ts b/common/util/parse.ts index 6cca448a..7e3774c6 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -73,6 +73,7 @@ export const exhibitExts = [ Image, Link, Mention, + Mention.extend({ name: 'contract-mention' }), Iframe, TiptapTweet, TiptapSpoiler, diff --git a/docs/docs/bounties.md b/docs/docs/bounties.md index ba4e865b..48b04dc1 100644 --- a/docs/docs/bounties.md +++ b/docs/docs/bounties.md @@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar ## Awarded bounties +💥 *Awarded on 2022-10-07* + +**[Pepe](https://manifold.markets/Pepe): M$10,000** +**[Jack](https://manifold.markets/jack): M$2,000** +**[Martin](https://manifold.markets/MartinRandall): M$2,000** +**[Yev](https://manifold.markets/Yev): M$2,000** +**[Michael](https://manifold.markets/MichaelWheatley): M$2,000** + +- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately. + +**[Matt](https://manifold.markets/MattP): M$5,000** +**[Adrian](https://manifold.markets/ahalekelly): M$5,000** +**[Yev](https://manifold.markets/Yev): M$5,000** + +- For discovering an AMM liquidity exploit and informing the Manifold team of it privately. + 🎈 *Awarded on 2022-06-14* **[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** diff --git a/functions/src/api.ts b/functions/src/api.ts index 7134c8d8..24677567 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { }, } as EndpointDefinition } + +export const newEndpointNoAuth = ( + endpointOpts: EndpointOptions, + fn: (req: Request) => Promise +) => { + const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts) + return { + opts, + handler: async (req: Request, res: Response) => { + log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`) + try { + if (opts.method !== req.method) { + throw new APIError(405, `This endpoint supports only ${opts.method}.`) + } + res.status(200).json(await fn(req)) + } catch (e) { + writeResponseError(e, res) + } + }, + } as EndpointDefinition +} diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 38852184..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -15,7 +15,7 @@ import { import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' -import { isProd } from './utils' +import { chargeUser, getContract, isProd } from './utils' import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' @@ -36,7 +36,7 @@ import { getPseudoProbability } from '../../common/pseudo-numeric' import { JSONContent } from '@tiptap/core' import { uniq, zip } from 'lodash' import { Bet } from '../../common/bet' -import { FieldValue, Transaction } from 'firebase-admin/firestore' +import { FieldValue } from 'firebase-admin/firestore' const descScehma: z.ZodType = z.lazy(() => z.intersection( @@ -107,242 +107,229 @@ export async function createMarketHelper(body: any, auth: AuthedUser) { visibility = 'public', } = validate(bodySchema, body) - return await firestore.runTransaction(async (trans) => { - let min, max, initialProb, isLogScale, answers + let min, max, initialProb, isLogScale, answers - if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { - let initialValue - ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) - if (max - min <= 0.01 || initialValue <= min || initialValue >= max) - throw new APIError(400, 'Invalid range.') + if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { + let initialValue + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) + if (max - min <= 0.01 || initialValue <= min || initialValue >= max) + throw new APIError(400, 'Invalid range.') - initialProb = - getPseudoProbability(initialValue, min, max, isLogScale) * 100 + initialProb = getPseudoProbability(initialValue, min, max, isLogScale) * 100 - if (initialProb < 1 || initialProb > 99) - if (outcomeType === 'PSEUDO_NUMERIC') - throw new APIError( - 400, - `Initial value is too ${initialProb < 1 ? 'low' : 'high'}` - ) - else throw new APIError(400, 'Invalid initial probability.') - } - - if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, body)) - } - - if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, body)) - } - - const userDoc = await trans.get(firestore.collection('users').doc(auth.uid)) - if (!userDoc.exists) { - throw new APIError(400, 'No user exists with the authenticated user ID.') - } - const user = userDoc.data() as User - - const ante = FIXED_ANTE - const deservesFreeMarket = - (user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX - // TODO: this is broken because it's not in a transaction - if (ante > user.balance && !deservesFreeMarket) - throw new APIError(400, `Balance must be at least ${ante}.`) - - let group: Group | null = null - if (groupId) { - const groupDocRef = firestore.collection('groups').doc(groupId) - const groupDoc = await trans.get(groupDocRef) - if (!groupDoc.exists) { - throw new APIError(400, 'No group exists with the given group ID.') - } - - group = groupDoc.data() as Group - const groupMembersSnap = await trans.get( - firestore.collection(`groups/${groupId}/groupMembers`) - ) - const groupMemberDocs = groupMembersSnap.docs.map( - (doc) => doc.data() as { userId: string; createdTime: number } - ) - if ( - !groupMemberDocs.map((m) => m.userId).includes(user.id) && - !group.anyoneCanJoin && - group.creatorId !== user.id - ) { + if (initialProb < 1 || initialProb > 99) + if (outcomeType === 'PSEUDO_NUMERIC') throw new APIError( 400, - 'User must be a member/creator of the group or group must be open to add markets to it.' + `Initial value is too ${initialProb < 1 ? 'low' : 'high'}` ) - } - } - const slug = await getSlug(trans, question) - const contractRef = firestore.collection('contracts').doc() + else throw new APIError(400, 'Invalid initial probability.') + } - console.log( - 'creating contract for', - user.username, - 'on', - question, - 'ante:', - ante || 0 - ) + if (outcomeType === 'BINARY') { + ;({ initialProb } = validate(binarySchema, body)) + } - // convert string descriptions into JSONContent - const newDescription = - !description || typeof description === 'string' - ? { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [{ type: 'text', text: description || ' ' }], - }, - ], - } - : description + if (outcomeType === 'MULTIPLE_CHOICE') { + ;({ answers } = validate(multipleChoiceSchema, body)) + } - const contract = getNewContract( - contractRef.id, - slug, - user, - question, - outcomeType, - newDescription, - initialProb ?? 0, - ante, - closeTime.getTime(), - tags ?? [], - NUMERIC_BUCKET_COUNT, - min ?? 0, - max ?? 0, - isLogScale ?? false, - answers ?? [], - visibility - ) + const userDoc = await firestore.collection('users').doc(auth.uid).get() + if (!userDoc.exists) { + throw new APIError(400, 'No user exists with the authenticated user ID.') + } + const user = userDoc.data() as User - const providerId = deservesFreeMarket - ? isProd() - ? HOUSE_LIQUIDITY_PROVIDER_ID - : DEV_HOUSE_LIQUIDITY_PROVIDER_ID - : user.id + const ante = FIXED_ANTE + const deservesFreeMarket = + (user?.freeMarketsCreated ?? 0) < FREE_MARKETS_PER_USER_MAX + // TODO: this is broken because it's not in a transaction + if (ante > user.balance && !deservesFreeMarket) + throw new APIError(400, `Balance must be at least ${ante}.`) - if (ante) { - const delta = FieldValue.increment(-ante) - const providerDoc = firestore.collection('users').doc(providerId) - await trans.update(providerDoc, { balance: delta, totalDeposits: delta }) + let group: Group | null = null + if (groupId) { + const groupDocRef = firestore.collection('groups').doc(groupId) + const groupDoc = await groupDocRef.get() + if (!groupDoc.exists) { + throw new APIError(400, 'No group exists with the given group ID.') } - if (deservesFreeMarket) { - await trans.update(firestore.collection('users').doc(user.id), { - freeMarketsCreated: FieldValue.increment(1), + group = groupDoc.data() as Group + const groupMembersSnap = await firestore + .collection(`groups/${groupId}/groupMembers`) + .get() + const groupMemberDocs = groupMembersSnap.docs.map( + (doc) => doc.data() as { userId: string; createdTime: number } + ) + if ( + !groupMemberDocs.map((m) => m.userId).includes(user.id) && + !group.anyoneCanJoin && + group.creatorId !== user.id + ) { + throw new APIError( + 400, + 'User must be a member/creator of the group or group must be open to add markets to it.' + ) + } + } + const slug = await getSlug(question) + const contractRef = firestore.collection('contracts').doc() + + console.log( + 'creating contract for', + user.username, + 'on', + question, + 'ante:', + ante || 0 + ) + + // convert string descriptions into JSONContent + const newDescription = + !description || typeof description === 'string' + ? { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: description || ' ' }], + }, + ], + } + : description + + const contract = getNewContract( + contractRef.id, + slug, + user, + question, + outcomeType, + newDescription, + initialProb ?? 0, + ante, + closeTime.getTime(), + tags ?? [], + NUMERIC_BUCKET_COUNT, + min ?? 0, + max ?? 0, + isLogScale ?? false, + answers ?? [], + visibility + ) + + const providerId = deservesFreeMarket + ? isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + : user.id + + if (ante) await chargeUser(providerId, ante, true) + if (deservesFreeMarket) + await firestore + .collection('users') + .doc(user.id) + .update({ freeMarketsCreated: FieldValue.increment(1) }) + + await contractRef.create(contract) + + if (group != null) { + const groupContractsSnap = await firestore + .collection(`groups/${groupId}/groupContracts`) + .get() + const groupContracts = groupContractsSnap.docs.map( + (doc) => doc.data() as { contractId: string; createdTime: number } + ) + if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { + await createGroupLinks(group, [contractRef.id], auth.uid) + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } + } - await contractRef.create(contract) + if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { + const liquidityDoc = firestore + .collection(`contracts/${contract.id}/liquidity`) + .doc() - if (group != null) { - const groupContractsSnap = await trans.get( - firestore.collection(`groups/${groupId}/groupContracts`) - ) - const groupContracts = groupContractsSnap.docs.map( - (doc) => doc.data() as { contractId: string; createdTime: number } + const lp = getCpmmInitialLiquidity( + providerId, + contract as CPMMBinaryContract, + liquidityDoc.id, + ante + ) + + await liquidityDoc.set(lp) + } else if (outcomeType === 'MULTIPLE_CHOICE') { + const betCol = firestore.collection(`contracts/${contract.id}/bets`) + const betDocs = (answers ?? []).map(() => betCol.doc()) + + const answerCol = firestore.collection(`contracts/${contract.id}/answers`) + const answerDocs = (answers ?? []).map((_, i) => + answerCol.doc(i.toString()) + ) + + const { bets, answerObjects } = getMultipleChoiceAntes( + user, + contract as MultipleChoiceContract, + answers ?? [], + betDocs.map((bd) => bd.id) + ) + + await Promise.all( + zip(bets, betDocs).map(([bet, doc]) => doc?.create(bet as Bet)) + ) + await Promise.all( + zip(answerObjects, answerDocs).map(([answer, doc]) => + doc?.create(answer as Answer) ) + ) + await contractRef.update({ answers: answerObjects }) + } else if (outcomeType === 'FREE_RESPONSE') { + const noneAnswerDoc = firestore + .collection(`contracts/${contract.id}/answers`) + .doc('0') - if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { - await createGroupLinks(trans, group, [contractRef.id], auth.uid) + const noneAnswer = getNoneAnswer(contract.id, user) + await noneAnswerDoc.set(noneAnswer) - const groupContractRef = firestore - .collection(`groups/${groupId}/groupContracts`) - .doc(contract.id) + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - await trans.set(groupContractRef, { - contractId: contract.id, - createdTime: Date.now(), - }) - } - } + const anteBet = getFreeAnswerAnte( + providerId, + contract as FreeResponseContract, + anteBetDoc.id + ) + await anteBetDoc.set(anteBet) + } else if (outcomeType === 'NUMERIC') { + const anteBetDoc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() - if (outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') { - const liquidityDoc = firestore - .collection(`contracts/${contract.id}/liquidity`) - .doc() + const anteBet = getNumericAnte( + providerId, + contract as NumericContract, + ante, + anteBetDoc.id + ) - const lp = getCpmmInitialLiquidity( - providerId, - contract as CPMMBinaryContract, - liquidityDoc.id, - ante - ) + await anteBetDoc.set(anteBet) + } - await trans.set(liquidityDoc, lp) - } else if (outcomeType === 'MULTIPLE_CHOICE') { - const betCol = firestore.collection(`contracts/${contract.id}/bets`) - const betDocs = (answers ?? []).map(() => betCol.doc()) - - const answerCol = firestore.collection(`contracts/${contract.id}/answers`) - const answerDocs = (answers ?? []).map((_, i) => - answerCol.doc(i.toString()) - ) - - const { bets, answerObjects } = getMultipleChoiceAntes( - user, - contract as MultipleChoiceContract, - answers ?? [], - betDocs.map((bd) => bd.id) - ) - - await Promise.all( - zip(bets, betDocs).map(([bet, doc]) => - doc ? trans.create(doc, bet as Bet) : undefined - ) - ) - await Promise.all( - zip(answerObjects, answerDocs).map(([answer, doc]) => - doc ? trans.create(doc, answer as Answer) : undefined - ) - ) - await trans.update(contractRef, { answers: answerObjects }) - } else if (outcomeType === 'FREE_RESPONSE') { - const noneAnswerDoc = firestore - .collection(`contracts/${contract.id}/answers`) - .doc('0') - - const noneAnswer = getNoneAnswer(contract.id, user) - await trans.set(noneAnswerDoc, noneAnswer) - - const anteBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const anteBet = getFreeAnswerAnte( - providerId, - contract as FreeResponseContract, - anteBetDoc.id - ) - await trans.set(anteBetDoc, anteBet) - } else if (outcomeType === 'NUMERIC') { - const anteBetDoc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - - const anteBet = getNumericAnte( - providerId, - contract as NumericContract, - ante, - anteBetDoc.id - ) - - await trans.set(anteBetDoc, anteBet) - } - - return contract - }) + return contract } -const getSlug = async (trans: Transaction, question: string) => { +const getSlug = async (question: string) => { const proposedSlug = slugify(question) - const preexistingContract = await getContractFromSlug(trans, proposedSlug) + const preexistingContract = await getContractFromSlug(proposedSlug) return preexistingContract ? proposedSlug + '-' + randomString() @@ -351,42 +338,46 @@ const getSlug = async (trans: Transaction, question: string) => { const firestore = admin.firestore() -async function getContractFromSlug(trans: Transaction, slug: string) { - const snap = await trans.get( - firestore.collection('contracts').where('slug', '==', slug) - ) +export async function getContractFromSlug(slug: string) { + const snap = await firestore + .collection('contracts') + .where('slug', '==', slug) + .get() return snap.empty ? undefined : (snap.docs[0].data() as Contract) } async function createGroupLinks( - trans: Transaction, group: Group, contractIds: string[], userId: string ) { for (const contractId of contractIds) { - const contractRef = firestore.collection('contracts').doc(contractId) - const contract = (await trans.get(contractRef)).data() as Contract - + const contract = await getContract(contractId) if (!contract?.groupSlugs?.includes(group.slug)) { - await trans.update(contractRef, { - groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), - }) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupSlugs: uniq([group.slug, ...(contract?.groupSlugs ?? [])]), + }) } if (!contract?.groupLinks?.map((gl) => gl.groupId).includes(group.id)) { - await trans.update(contractRef, { - groupLinks: [ - { - groupId: group.id, - name: group.name, - slug: group.slug, - userId, - createdTime: Date.now(), - } as GroupLink, - ...(contract?.groupLinks ?? []), - ], - }) + await firestore + .collection('contracts') + .doc(contractId) + .update({ + groupLinks: [ + { + groupId: group.id, + name: group.name, + slug: group.slug, + userId, + createdTime: Date.now(), + } as GroupLink, + ...(contract?.groupLinks ?? []), + ], + }) } } } diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 96e3c66a..d1864ac2 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -71,19 +71,23 @@ export const createpost = newEndpoint({}, async (req, auth) => { if (question) { const closeTime = Date.now() + DAY_MS * 30 * 3 - const result = await createMarketHelper( - { - question, - closeTime, - outcomeType: 'BINARY', - visibility: 'unlisted', - initialProb: 50, - // Dating group! - groupId: 'j3ZE8fkeqiKmRGumy3O1', - }, - auth - ) - contractSlug = result.slug + try { + const result = await createMarketHelper( + { + question, + closeTime, + outcomeType: 'BINARY', + visibility: 'unlisted', + initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', + }, + auth + ) + contractSlug = result.slug + } catch (e) { + console.error(e) + } } const post: Post = removeUndefinedProps({ diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 993fac81..31129b71 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,7 +12,7 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { contractUrl, getUser } from './utils' +import { contractUrl, getUser, log } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' @@ -212,20 +212,16 @@ export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, @@ -247,19 +243,15 @@ export const sendCreatorGuideEmail = async ( privateUser: PrivateUser, sendTime: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.onboarding_flow.includes('email') - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'onboarding_flow' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -279,22 +271,16 @@ export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.thank_you_for_purchases.includes( - 'email' - ) - ) - return + if (!privateUser || !privateUser.email) return const { name } = user const firstName = name.split(' ')[0] - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'thank_you_for_purchases' ) + if (!sendToEmail) return return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', @@ -466,17 +452,13 @@ export const sendInterestingMarketsEmail = async ( contractsToSend: Contract[], deliveryTime?: string ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.trending_markets.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'trending_markets' ) + if (!sendToEmail) return const { name } = user const firstName = name.split(' ')[0] @@ -620,18 +602,15 @@ export const sendWeeklyPortfolioUpdateEmail = async ( investments: PerContractInvestmentsData[], overallPerformance: OverallPerformanceData ) => { - if ( - !privateUser || - !privateUser.email || - !privateUser.notificationPreferences.profit_loss_updates.includes('email') - ) - return + if (!privateUser || !privateUser.email) return - const { unsubscribeUrl } = getNotificationDestinationsForUser( + const { unsubscribeUrl, sendToEmail } = getNotificationDestinationsForUser( privateUser, 'profit_loss_updates' ) + if (!sendToEmail) return + const { name } = user const firstName = name.split(' ')[0] const templateData: Record = { @@ -656,4 +635,5 @@ export const sendWeeklyPortfolioUpdateEmail = async ( : 'portfolio-update', templateData ) + log('Sent portfolio update email to', privateUser.email) } diff --git a/functions/src/index.ts b/functions/src/index.ts index f5c45004..a6d120c8 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -9,7 +9,7 @@ export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' -export * from './update-metrics' +export { scheduleUpdateMetrics } from './update-metrics' export * from './update-stats' export * from './update-loans' export * from './backup-db' @@ -77,6 +77,7 @@ import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' +import { updatemetrics } from './update-metrics' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -106,6 +107,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) const createPostFunction = toCloudFunction(createpost) const saveTwitchCredentials = toCloudFunction(savetwitchcredentials) +const updateMetricsFunction = toCloudFunction(updatemetrics) export { healthFunction as health, @@ -133,4 +135,5 @@ export { saveTwitchCredentials as savetwitchcredentials, addCommentBounty as addcommentbounty, awardCommentBounty as awardcommentbounty, + updateMetricsFunction as updatemetrics, } diff --git a/functions/src/on-update-user.ts b/functions/src/on-update-user.ts index b45809d0..66a6884c 100644 --- a/functions/src/on-update-user.ts +++ b/functions/src/on-update-user.ts @@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes' import { createReferralNotification } from './create-notification' import { ReferralTxn } from '../../common/txn' import { Contract } from '../../common/contract' -import { LimitBet } from '../../common/bet' -import { QuerySnapshot } from 'firebase-admin/firestore' import { Group } from '../../common/group' import { REFERRAL_AMOUNT } from '../../common/economy' const firestore = admin.firestore() @@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore if (prevUser.referredByUserId !== user.referredByUserId) { await handleUserUpdatedReferral(user, eventId) } - - if (user.balance <= 0) { - await cancelLimitOrders(user.id) - } }) async function handleUserUpdatedReferral(user: User, eventId: string) { @@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) { ) }) } - -async function cancelLimitOrders(userId: string) { - const snapshot = (await firestore - .collectionGroup('bets') - .where('userId', '==', userId) - .where('isFilled', '==', false) - .get()) as QuerySnapshot - - await Promise.all( - snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true })) - ) -} diff --git a/functions/src/place-bet.ts b/functions/src/place-bet.ts index 74df7dc3..e785565b 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' +import { FLAT_TRADE_FEE } from '../../common/fees' import { BetInfo, getBinaryCpmmBetInfo, @@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' import { addUserToContractFollowers } from './follow-market' +import { filterDefined } from '../../common/util/array' const bodySchema = z.object({ contractId: z.string(), @@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => { newTotalLiquidity, newP, makers, + ordersToCancel, } = await (async (): Promise< BetInfo & { makers?: maker[] + ordersToCancel?: LimitBet[] } > => { if ( @@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => { limitProb = Math.round(limitProb * 100) / 100 } - const unfilledBetsSnap = await trans.get( - getUnfilledBetsQuery(contractDoc) - ) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + const { unfilledBets, balanceByUserId } = + await getUnfilledBetsAndUserBalances(trans, contractDoc) return getBinaryCpmmBetInfo( outcome, amount, contract, limitProb, - unfilledBets + unfilledBets, + balanceByUserId ) } else if ( (outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') && @@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => { if (makers) { updateMakers(makers, betDoc.id, contractDoc, trans) } + if (ordersToCancel) { + for (const bet of ordersToCancel) { + trans.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + } + + const balanceChange = + newBet.amount !== 0 + ? // quick bet + newBet.amount + FLAT_TRADE_FEE + : // limit order + FLAT_TRADE_FEE + + trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) }) + log('Updated user balance.') if (newBet.amount !== 0) { - trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) }) - log('Updated user balance.') - trans.update( contractDoc, removeUndefinedProps({ @@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => { const firestore = admin.firestore() -export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { +const getUnfilledBetsQuery = (contractDoc: DocumentReference) => { return contractDoc .collection('bets') .where('isFilled', '==', false) .where('isCancelled', '==', false) as Query } +export const getUnfilledBetsAndUserBalances = async ( + trans: Transaction, + contractDoc: DocumentReference +) => { + const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc)) + const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) + + // Get balance of all users with open limit orders. + const userIds = uniq(unfilledBets.map((bet) => bet.userId)) + const userDocs = + userIds.length === 0 + ? [] + : await trans.getAll( + ...userIds.map((userId) => firestore.doc(`users/${userId}`)) + ) + const users = filterDefined(userDocs.map((doc) => doc.data() as User)) + const balanceByUserId = Object.fromEntries( + users.map((user) => [user.id, user.balance]) + ) + + return { unfilledBets, balanceByUserId } +} + type maker = { bet: LimitBet amount: number diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index 4237c8f2..bbdb10d6 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -1,13 +1,17 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -import { getAllPrivateUsers } from 'functions/src/utils' +import { filterDefined } from 'common/lib/util/array' +import { getPrivateUser } from '../utils' initAdmin() const firestore = admin.firestore() async function main() { - const privateUsers = await getAllPrivateUsers() + // const privateUsers = await getAllPrivateUsers() + const privateUsers = filterDefined([ + await getPrivateUser('ddSo9ALC15N9FAZdKdA2qE3iIvH3'), + ]) await Promise.all( privateUsers.map((privateUser) => { if (!privateUser.id) return Promise.resolve() @@ -20,6 +24,19 @@ async function main() { badges_awarded: ['browser'], }, }) + if (privateUser.notificationPreferences.opt_out_all === undefined) { + console.log('updating opt out all', privateUser.id) + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + opt_out_all: [], + }, + }) + } + return }) ) } diff --git a/functions/src/scripts/denormalize.ts b/functions/src/scripts/denormalize.ts index d4feb425..3362e940 100644 --- a/functions/src/scripts/denormalize.ts +++ b/functions/src/scripts/denormalize.ts @@ -3,7 +3,6 @@ import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' import { isEqual, zip } from 'lodash' -import { UpdateSpec } from '../utils' export type DocumentValue = { doc: DocumentSnapshot @@ -54,7 +53,7 @@ export function getDiffUpdate(diff: DocumentDiff) { return { doc: diff.dest.doc.ref, fields: Object.fromEntries(zip(diff.dest.fields, diff.src.vals)), - } as UpdateSpec + } } export function applyDiff(transaction: Transaction, diff: DocumentDiff) { diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index f2f475cb..0c49bb24 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,6 +1,7 @@ import { mapValues, groupBy, sumBy, uniq } from 'lodash' import * as admin from 'firebase-admin' import { z } from 'zod' +import { FieldValue } from 'firebase-admin/firestore' import { APIError, newEndpoint, validate } from './api' import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' @@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object' import { log } from './utils' import { Bet } from '../../common/bet' import { floatingEqual, floatingLesserEqual } from '../../common/util/math' -import { getUnfilledBetsQuery, updateMakers } from './place-bet' -import { FieldValue } from 'firebase-admin/firestore' +import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet' import { redeemShares } from './redeem-shares' import { removeUserFromContractFollowers } from './follow-market' @@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const contractDoc = firestore.doc(`contracts/${contractId}`) const userDoc = firestore.doc(`users/${auth.uid}`) const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid) - const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] = - await Promise.all([ - transaction.getAll(contractDoc, userDoc), - transaction.get(betsQ), - transaction.get(getUnfilledBetsQuery(contractDoc)), - ]) + const [ + [contractSnap, userSnap], + userBetsSnap, + { unfilledBets, balanceByUserId }, + ] = await Promise.all([ + transaction.getAll(contractDoc, userDoc), + transaction.get(betsQ), + getUnfilledBetsAndUserBalances(transaction, contractDoc), + ]) if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') if (!userSnap.exists) throw new APIError(400, 'User not found.') const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet) - const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data()) const contract = contractSnap.data() as Contract const user = userSnap.data() as User @@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => { let loanPaid = saleFrac * loanAmount if (!isFinite(loanPaid)) loanPaid = 0 - const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo( - soldShares, - chosenOutcome, - contract, - unfilledBets, - loanPaid - ) + const { newBet, newPool, newP, fees, makers, ordersToCancel } = + getCpmmSellBetInfo( + soldShares, + chosenOutcome, + contract, + unfilledBets, + balanceByUserId, + loanPaid + ) if ( !newP || @@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) + for (const bet of ordersToCancel) { + transaction.update(contractDoc.collection('bets').doc(bet.id), { + isCancelled: true, + }) + } + return { newBet, makers, maxShares, soldShares } }) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 70c7c742..e77ab71f 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -1,10 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' +import fetch from 'node-fetch' + import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' - import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' @@ -14,18 +15,41 @@ import { calculateNewPortfolioMetrics, calculateNewProfit, calculateProbChanges, + computeElasticity, computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' import { Group } from '../../common/group' import { batchedWaitAll } from '../../common/util/promise' +import { newEndpointNoAuth } from './api' +import { getFunctionUrl } from '../../common/api' const firestore = admin.firestore() -export const updateMetrics = functions - .runWith({ memory: '8GB', timeoutSeconds: 540 }) - .pubsub.schedule('every 15 minutes') - .onRun(updateMetricsCore) +export const scheduleUpdateMetrics = functions.pubsub + .schedule('every 15 minutes') + .onRun(async () => { + const response = await fetch(getFunctionUrl('updatemetrics'), { + headers: { + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({}), + }) + + const json = await response.json() + + if (response.ok) console.log(json) + else console.error(json) + }) + +export const updatemetrics = newEndpointNoAuth( + { timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 }, + async (_req) => { + await updateMetricsCore() + return { success: true } + } +) export async function updateMetricsCore() { console.log('Loading users') @@ -103,6 +127,7 @@ export async function updateMetricsCore() { fields: { volume24Hours: computeVolume(contractBets, now - DAY_MS), volume7Days: computeVolume(contractBets, now - DAY_MS * 7), + elasticity: computeElasticity(contractBets, contract), ...cpmmFields, }, } @@ -144,7 +169,7 @@ export async function updateMetricsCore() { return 0 } const contractRatio = - contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) + contract.flaggedByUsernames.length / (contract.uniqueBettorCount || 1) return contractRatio }) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index efc22e53..91f4b293 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -47,7 +47,7 @@ export const writeAsync = async ( const batch = db.batch() for (const { doc, fields } of chunks[i]) { if (operationType === 'update') { - batch.update(doc, fields) + batch.update(doc, fields as any) } else { batch.set(doc, fields) } diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index bcf6da17..215694eb 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -112,13 +112,12 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { ) ) ) - log('Found', contractsUsersBetOn.length, 'contracts') - let count = 0 await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) // Don't send to a user unless they're over 5 days old - if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) + return await setEmailFlagAsSent(privateUser.id) const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -219,13 +218,6 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { (differences) => Math.abs(differences.profit) ).reverse() - log( - 'Found', - investmentValueDifferences.length, - 'investment differences for user', - privateUser.id - ) - const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 @@ -245,29 +237,28 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { usersToContractsCreated[privateUser.id].length === 0 ) { log( - 'No bets in last week, no market movers, no markets created. Not sending an email.' + `No bets in last week, no market movers, no markets created. Not sending an email to ${privateUser.email} .` ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - return + return await setEmailFlagAsSent(privateUser.id) } + // Set the flag beforehand just to be safe + await setEmailFlagAsSent(privateUser.id) await sendWeeklyPortfolioUpdateEmail( user, privateUser, topInvestments.concat(worstInvestments) as PerContractInvestmentsData[], performanceData ) - await firestore.collection('private-users').doc(privateUser.id).update({ - weeklyPortfolioUpdateEmailSent: true, - }) - log('Sent weekly portfolio update email to', privateUser.email) - count++ - log('sent out emails to users:', count) }) ) } +async function setEmailFlagAsSent(privateUserId: string) { + await firestore.collection('private-users').doc(privateUserId).update({ + weeklyPortfolioUpdateEmailSent: true, + }) +} + export type PerContractInvestmentsData = { questionTitle: string questionUrl: string diff --git a/web/components/NoSEO.tsx b/web/components/NoSEO.tsx new file mode 100644 index 00000000..53437b10 --- /dev/null +++ b/web/components/NoSEO.tsx @@ -0,0 +1,10 @@ +import Head from 'next/head' + +/** Exclude page from search results */ +export function NoSEO() { + return ( + + + + ) +} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 65a79c20..8cd43369 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -6,6 +6,7 @@ import { Col } from './layout/col' import { ENV_CONFIG } from 'common/envs/constants' import { Row } from './layout/row' import { AddFundsModal } from './add-funds-modal' +import { Input } from './input' export function AmountInput(props: { amount: number | undefined @@ -44,9 +45,9 @@ export function AmountInput(props: { {label} - {!wasResolvedTo && (showChoice === 'checkbox' ? ( - No answers yet... )} - {outcomeType === 'FREE_RESPONSE' && - tradingAllowed(contract) && - (!resolveOption || resolveOption === 'CANCEL') && ( - - )} + {outcomeType === 'FREE_RESPONSE' && tradingAllowed(contract) && ( + + )} {(user?.id === creatorId || (isAdmin && needsAdminToResolve(contract))) && !resolution && ( diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 58f55327..4012e587 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,6 +1,5 @@ import clsx from 'clsx' import React, { useState } from 'react' -import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' @@ -26,6 +25,7 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' import { withTracking } from 'web/lib/service/analytics' import { lowerCase } from 'lodash' import { Button } from '../button' +import { ExpandingInput } from '../expanding-input' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -122,10 +122,10 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
Add your answer
-