diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts index b27ac977..7c2153c1 100644 --- a/common/calculate-metrics.ts +++ b/common/calculate-metrics.ts @@ -21,6 +21,25 @@ 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 payout = betP * shares + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + const computeTotalPool = (userContracts: Contract[], startTime = 0) => { const periodFilteredContracts = userContracts.filter( (contract) => contract.createdTime >= startTime diff --git a/common/comment.ts b/common/comment.ts index cdb62fd3..71c04af4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -18,6 +18,7 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: string + bountiesAwarded?: number } & T export type OnContract = { diff --git a/common/contract.ts b/common/contract.ts index 2f71bab7..2e9d94c4 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -57,10 +57,12 @@ export type Contract = { uniqueBettorIds?: string[] uniqueBettorCount?: number popularityScore?: number + dailyScore?: number followerCount?: number featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + openCommentBounties?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/economy.ts b/common/economy.ts index 7ec52b30..d25a0c71 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -15,3 +15,4 @@ export const BETTING_STREAK_BONUS_AMOUNT = 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 FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 +export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 96ec4dc2..ff3fd37d 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = { amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', // this is Phil's deployment twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', + sprigEnvironmentId: 'Tu7kRZPm7daP', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 3014f4e3..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -3,6 +3,7 @@ export type EnvConfig = { firebaseConfig: FirebaseConfig amplitudeApiKey?: string twitchBotEndpoint?: string + sprigEnvironmentId?: string // IDs for v2 cloud functions -- find these by deploying a cloud function and // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app @@ -40,6 +41,7 @@ export type Economy = { BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number FREE_MARKETS_PER_USER_MAX?: number + COMMENT_BOUNTY_AMOUNT?: number } type FirebaseConfig = { @@ -56,6 +58,7 @@ type FirebaseConfig = { export const PROD_CONFIG: EnvConfig = { domain: 'manifold.markets', amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', + sprigEnvironmentId: 'sQcrq9TDqkib', firebaseConfig: { apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', diff --git a/common/group.ts b/common/group.ts index 871bc821..8f5728d3 100644 --- a/common/group.ts +++ b/common/group.ts @@ -10,6 +10,7 @@ export type Group = { totalContracts: number totalMembers: number aboutPostId?: string + postIds: string[] chatDisabled?: boolean mostRecentContractAddedTime?: number cachedLeaderboard?: { @@ -22,6 +23,7 @@ export type Group = { score: number }[] } + pinnedItems: { itemId: string; type: 'post' | 'contract' }[] } export const MAX_GROUP_NAME_LENGTH = 75 diff --git a/common/like.ts b/common/like.ts index 38b25dad..7ec14726 100644 --- a/common/like.ts +++ b/common/like.ts @@ -5,4 +5,4 @@ export type Like = { createdTime: number tipTxnId?: string // only holds most recent tip txn id } -export const LIKE_TIP_AMOUNT = 5 +export const LIKE_TIP_AMOUNT = 10 diff --git a/common/notification.ts b/common/notification.ts index b42df541..d91dc300 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -116,8 +116,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: "Only answers by market creator on markets you're watching", }, betting_streaks: { - simple: 'For predictions made over consecutive days', - detailed: 'Bonuses for predictions made over consecutive days', + simple: `For prediction streaks`, + detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`, }, comments_by_followed_users_on_watched_markets: { simple: 'Only comments by users you follow', @@ -159,8 +159,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Large changes in probability on markets that you watch', }, profit_loss_updates: { - simple: 'Weekly profit and loss updates', - detailed: 'Weekly profit and loss updates', + simple: 'Weekly portfolio updates', + detailed: 'Weekly portfolio updates', }, referral_bonuses: { simple: 'For referring new users', diff --git a/common/payouts-dpm.ts b/common/payouts-dpm.ts index bf6f5ebc..48850dca 100644 --- a/common/payouts-dpm.ts +++ b/common/payouts-dpm.ts @@ -168,7 +168,7 @@ export const getPayoutsMultiOutcome = ( const winnings = (shares / sharesByOutcome[outcome]) * prob * poolTotal const profit = winnings - amount - const payout = amount + (1 - DPM_FEES) * Math.max(0, profit) + const payout = amount + (1 - DPM_FEES) * profit return { userId, profit, payout } }) diff --git a/common/post.ts b/common/post.ts index 05eab685..45503b22 100644 --- a/common/post.ts +++ b/common/post.ts @@ -9,4 +9,11 @@ export type Post = { slug: string } +export type DateDoc = Post & { + bounty: number + birthday: number + type: 'date-doc' + contractSlug: string +} + export const MAX_POST_TITLE_LENGTH = 480 diff --git a/common/txn.ts b/common/txn.ts index 2b7a32e8..c404059d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -8,6 +8,7 @@ type AnyTxnType = | UniqueBettorBonus | BettingStreakBonus | CancelUniqueBettorBonus + | CommentBountyRefund type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -31,6 +32,8 @@ export type Txn = { | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS' + | 'COMMENT_BOUNTY' + | 'REFUND_COMMENT_BOUNTY' // Any extra data data?: { [key: string]: any } @@ -98,6 +101,34 @@ 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 TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink @@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus +export type CommentBountyDepositTxn = Txn & CommentBountyDeposit +export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal diff --git a/common/user.ts b/common/user.ts index 0372d99b..b1365929 100644 --- a/common/user.ts +++ b/common/user.ts @@ -57,6 +57,7 @@ export type PrivateUser = { email?: string weeklyTrendingEmailSent?: boolean + weeklyPortfolioUpdateEmailSent?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/common/util/format.ts b/common/util/format.ts index 4f123535..ee59d3e7 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -8,7 +8,12 @@ const formatter = new Intl.NumberFormat('en-US', { }) export function formatMoney(amount: number) { - const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case + const newAmount = + // handle -0 case + Math.round(amount) === 0 + ? 0 + : // Handle 499.9999999999999 case + Math.floor(amount + 0.00000000001 * Math.sign(amount)) return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') } diff --git a/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..72ceaf15 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' import { uniq } from 'lodash' +import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ export function getUrl(text: string) { @@ -103,6 +104,7 @@ export const exhibitExts = [ Mention, Iframe, TiptapTweet, + TiptapSpoiler, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/time.ts b/common/util/time.ts index 9afb8db4..81dc3600 100644 --- a/common/util/time.ts +++ b/common/util/time.ts @@ -1,3 +1,6 @@ export const MINUTE_MS = 60 * 1000 export const HOUR_MS = 60 * MINUTE_MS export const DAY_MS = 24 * HOUR_MS + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts new file mode 100644 index 00000000..5502da58 --- /dev/null +++ b/common/util/tiptap-spoiler.ts @@ -0,0 +1,116 @@ +// adapted from @n8body/tiptap-spoiler + +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core' +import type { ElementType } from 'react' + +declare module '@tiptap/core' { + interface Commands { + spoilerEditor: { + setSpoiler: () => ReturnType + toggleSpoiler: () => ReturnType + unsetSpoiler: () => ReturnType + } + } +} + +export type SpoilerOptions = { + HTMLAttributes: Record + spoilerOpenClass: string + spoilerCloseClass?: string + inputRegex: RegExp + pasteRegex: RegExp + as: ElementType +} + +const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/ +const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g + +export const TiptapSpoiler = Mark.create({ + name: 'spoiler', + + inline: true, + group: 'inline', + inclusive: false, + exitable: true, + content: 'inline*', + + priority: 200, // 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 + }, +}) diff --git a/docs/docs/api.md b/docs/docs/api.md index 64e26de8..d25a18be 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -55,6 +55,7 @@ Returns the authenticated user. Gets all groups, in no particular order. Parameters: + - `availableToUserId`: Optional. if specified, only groups that the user can join and groups they've already joined will be returned. @@ -64,24 +65,23 @@ Requires no authorization. Gets a group by its slug. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/group/by-id/[id]/markets` Gets a group's markets by its unique ID. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. - ### `GET /v0/markets` Lists all markets, ordered by creation date descending. @@ -158,13 +158,16 @@ Requires no authorization. // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market url: string - outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC + outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC mechanism: string // dpm-2 or cpmm-1 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. 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 + 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 volume7Days: number @@ -408,7 +411,7 @@ Requires no authorization. type FullMarket = LiteMarket & { bets: Bet[] comments: Comment[] - answers?: Answer[] + answers?: Answer[] // dpm-2 markets only description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json textDescription: string // string description without formatting, images, or embeds } @@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user. Parameters: -- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. +- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`. - `question`: Required. The headline question 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). @@ -569,6 +572,12 @@ For numeric markets, you must also provide: - `min`: The minimum 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: @@ -582,6 +591,18 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "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` + +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. + ### `POST /v0/market/[marketId]/resolve` Resolves a market on behalf of the authorized user. @@ -593,15 +614,18 @@ For binary markets: - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `probabilityInt`: Optional. The probability to use for `MKT` resolution. -For free response markets: +For free response or multiple choice markets: - `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. -- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. +- `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. For numeric markets: - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `value`: The value that the market may resolves to. +- `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: @@ -745,6 +769,7 @@ Requires no authorization. ## 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-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 7a30fed6..ed01f75c 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -8,9 +8,8 @@ A list of community-created projects built on, or related to, Manifold Markets. ## Sites using Manifold -- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government -- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold - [WagerWith.me](https://www.wagerwith.me/) — Bet with your friends, with full Manifold integration to bet with M$. +- [Alignment Markets](https://alignmentmarkets.com/) - Bet on the progress of benchmarks in ML safety! ## API / Dev @@ -28,6 +27,7 @@ A list of community-created projects built on, or related to, Manifold Markets. - [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae) ## Writeups + - [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander - [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki - [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania @@ -36,5 +36,12 @@ A list of community-created projects built on, or related to, Manifold Markets. ## Art -- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png) -- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) +- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) ![](https://i.imgur.com/nVGY4pL.png) +- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg) + +## Alumni + +_These projects are no longer active, but were really really cool!_ + +- [Research.Bet](https://research.bet/) - Prediction market for scientific papers, using Manifold +- [CivicDashboard](https://civicdash.org/dashboard) - Uses Manifold to for tracked solutions for the SF city government diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 01c4dc36..5c369e39 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -4,11 +4,7 @@ ### Do I have to pay real money in order to participate? -Nope! Each account starts with a free M$1000. If you invest it wisely, you can increase your total without ever needing to put any real money into the site. - -### What is the name for the currency Manifold uses, represented by M$? - -Manifold Dollars, or mana for short. +Nope! Each account starts with a free 1000 mana (or M$1000 for short). If you invest it wisely, you can increase your total without ever needing to put any real money into the site. ### Can M$ be sold for real money? diff --git a/firestore.rules b/firestore.rules index 26649fa6..bf0375e6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -176,7 +176,7 @@ service cloud.firestore { allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]); allow delete: if request.auth.uid == resource.data.creatorId; match /groupContracts/{contractId} { diff --git a/functions/package.json b/functions/package.json index ba59f090..0397c5db 100644 --- a/functions/package.json +++ b/functions/package.json @@ -40,7 +40,6 @@ "mailgun-js": "0.22.0", "module-alias": "2.2.2", "node-fetch": "2", - "react-masonry-css": "1.0.16", "stripe": "8.194.0", "zod": "3.17.2" }, @@ -48,7 +47,8 @@ "@types/mailgun-js": "0.22.12", "@types/module-alias": "2.0.1", "@types/node-fetch": "2.6.2", - "firebase-functions-test": "0.3.3" + "firebase-functions-test": "0.3.3", + "puppeteer": "18.0.5" }, "private": true } diff --git a/functions/src/api.ts b/functions/src/api.ts index 7440f16a..7134c8d8 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -14,7 +14,7 @@ import { export { APIError } from '../../common/api' type Output = Record -type AuthedUser = { +export type AuthedUser = { uid: string creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser }) } diff --git a/functions/src/close-market.ts b/functions/src/close-market.ts new file mode 100644 index 00000000..b8b252a7 --- /dev/null +++ b/functions/src/close-market.ts @@ -0,0 +1,58 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { Contract } from '../../common/contract' +import { getUser } from './utils' + +import { isAdmin, isManifoldId } from '../../common/envs/constants' +import { APIError, newEndpoint, validate } from './api' + +const bodySchema = z.object({ + contractId: z.string(), + closeTime: z.number().int().nonnegative().optional(), +}) + +export const closemarket = newEndpoint({}, async (req, auth) => { + const { contractId, closeTime } = validate(bodySchema, req.body) + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await contractDoc.get() + if (!contractSnap.exists) + throw new APIError(404, 'No contract exists with the provided ID') + const contract = contractSnap.data() as Contract + const { creatorId } = contract + const firebaseUser = await admin.auth().getUser(auth.uid) + + if ( + creatorId !== auth.uid && + !isManifoldId(auth.uid) && + !isAdmin(firebaseUser.email) + ) + throw new APIError(403, 'User is not creator of contract') + + const now = Date.now() + if (!closeTime && contract.closeTime && contract.closeTime < now) + throw new APIError(400, 'Contract already closed') + + if (closeTime && closeTime < now) + throw new APIError( + 400, + 'Close time must be in the future. ' + + 'Alternatively, do not provide a close time to close immediately.' + ) + + const creator = await getUser(creatorId) + if (!creator) throw new APIError(500, 'Creator not found') + + const updatedContract = { + ...contract, + closeTime: closeTime ? closeTime : now, + } + + await contractDoc.update(updatedContract) + + console.log('contract ', contractId, 'closed') + + return updatedContract +}) + +const firestore = admin.firestore() diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index cc05d817..911f3b8c 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -7,6 +7,7 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' +import { addUserToContractFollowers } from './follow-market' const bodySchema = z.object({ contractId: z.string().max(MAX_ANSWER_LENGTH), @@ -96,6 +97,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) + await addUserToContractFollowers(contractId, auth.uid) + return answer }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 9d00bb0b..4b3f7446 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -61,6 +61,8 @@ export const creategroup = newEndpoint({}, async (req, auth) => { anyoneCanJoin, totalContracts: 0, totalMembers: memberIds.length, + postIds: [], + pinnedItems: [], } await groupRef.create(group) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 300d91f2..d1483ca4 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -16,7 +16,7 @@ import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' import { chargeUser, getContract, isProd } from './utils' -import { APIError, newEndpoint, validate, zTimestamp } from './api' +import { APIError, AuthedUser, newEndpoint, validate, zTimestamp } from './api' import { FIXED_ANTE, FREE_MARKETS_PER_USER_MAX } from '../../common/economy' import { @@ -92,7 +92,11 @@ const multipleChoiceSchema = z.object({ answers: z.string().trim().min(1).array().min(2), }) -export const createmarket = newEndpoint({}, async (req, auth) => { +export const createmarket = newEndpoint({}, (req, auth) => { + return createMarketHelper(req.body, auth) +}) + +export async function createMarketHelper(body: any, auth: AuthedUser) { const { question, description, @@ -101,16 +105,13 @@ export const createmarket = newEndpoint({}, async (req, auth) => { outcomeType, groupId, visibility = 'public', - } = validate(bodySchema, req.body) + } = validate(bodySchema, body) let min, max, initialProb, isLogScale, answers if (outcomeType === 'PSEUDO_NUMERIC' || outcomeType === 'NUMERIC') { let initialValue - ;({ min, max, initialValue, isLogScale } = validate( - numericSchema, - req.body - )) + ;({ min, max, initialValue, isLogScale } = validate(numericSchema, body)) if (max - min <= 0.01 || initialValue <= min || initialValue >= max) throw new APIError(400, 'Invalid range.') @@ -126,11 +127,11 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } if (outcomeType === 'BINARY') { - ;({ initialProb } = validate(binarySchema, req.body)) + ;({ initialProb } = validate(binarySchema, body)) } if (outcomeType === 'MULTIPLE_CHOICE') { - ;({ answers } = validate(multipleChoiceSchema, req.body)) + ;({ answers } = validate(multipleChoiceSchema, body)) } const userDoc = await firestore.collection('users').doc(auth.uid).get() @@ -186,17 +187,17 @@ export const createmarket = newEndpoint({}, async (req, auth) => { // convert string descriptions into JSONContent const newDescription = - typeof description === 'string' + !description || typeof description === 'string' ? { type: 'doc', content: [ { type: 'paragraph', - content: [{ type: 'text', text: description }], + content: [{ type: 'text', text: description || ' ' }], }, ], } - : description ?? {} + : description const contract = getNewContract( contractRef.id, @@ -323,7 +324,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } return contract -}) +} const getSlug = async (question: string) => { const proposedSlug = slugify(question) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 038e0142..9bd73d05 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1046,3 +1046,47 @@ 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)) + + // maybe TODO: send email notification to comment creator +} diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index 40d39bba..e9d6ae8f 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -7,6 +7,9 @@ import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post' import { APIError, newEndpoint, validate } from './api' import { JSONContent } from '@tiptap/core' 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 = z.lazy(() => z.intersection( @@ -34,11 +37,21 @@ const contentSchema: z.ZodType = z.lazy(() => const postSchema = z.object({ title: z.string().min(1).max(MAX_POST_TITLE_LENGTH), content: contentSchema, + 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) => { const firestore = admin.firestore() - const { title, content } = validate(postSchema, req.body) + const { title, content, groupId, question, ...otherProps } = validate( + postSchema, + req.body + ) const creator = await getUser(auth.uid) if (!creator) @@ -50,16 +63,50 @@ export const createpost = newEndpoint({}, async (req, auth) => { const postRef = firestore.collection('posts').doc() - const post: Post = { + // If this is a date doc, create a market for it. + let contractSlug + 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 + } + + const post: Post = removeUndefinedProps({ + ...otherProps, id: postRef.id, creatorId: creator.id, slug, title, createdTime: Date.now(), content: content, - } + contractSlug, + }) await postRef.create(post) + if (groupId) { + const groupRef = firestore.collection('groups').doc(groupId) + const group = await groupRef.get() + if (group.exists) { + const groupData = group.data() + if (groupData) { + const postIds = groupData.postIds ?? [] + postIds.push(postRef.id) + await groupRef.update({ postIds }) + } + } + } return { status: 'success', post } }) diff --git a/functions/src/email-templates/weekly-portfolio-update-no-movers.html b/functions/src/email-templates/weekly-portfolio-update-no-movers.html new file mode 100644 index 00000000..15303992 --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update-no-movers.html @@ -0,0 +1,411 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html new file mode 100644 index 00000000..921a58e5 --- /dev/null +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -0,0 +1,510 @@ + + + + + Weekly Portfolio Update on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ + We ran the numbers and here's how you did this past week! + +

+
+
+ Profit +
+

+ {{profit}} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ 🔥 Prediction streak + + {{prediction_streak}} +
+ 💸 Tips received + + {{tips_received}} +
+ 📈 Markets traded + + {{markets_traded}} +
+ ❓ Markets created + + {{markets_created}} +
+ 🥳 Traders attracted + + {{unique_bettors}} +
+ +
+
+

+ + And here's some recent changes in your investments: + +

+
+
+ + + + + + + + + + + + + + + + + + +
+ + {{question1Title}} + + + +

+ {{question1Prob}} + +

+ {{question1Change}} + +

+

+
+ + {{question2Title}} + + + +

+ {{question2Prob}} + +

+ {{question2Change}} + +

+

+
+ + {{question3Title}} + + + +

+ {{question3Prob}} + +

+ {{question3Change}} + +

+

+
+ + {{question4Title}} + + + +

+ {{question4Prob}} + +

+ {{question4Change}} + +

+

+
+ +
+
+
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe from this type of notification. +

+
+
+
+
+ +
+
+ +
+
diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 98309ebe..993fac81 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -12,14 +12,15 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getUser } from './utils' +import { contractUrl, getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' import { notification_reason_types } from '../../common/notification' import { Dictionary } from 'lodash' +import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { - getNotificationDestinationsForUser, - notification_preference, -} from '../../common/user-notification-preferences' + PerContractInvestmentsData, + OverallPerformanceData, +} from './weekly-portfolio-emails' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -152,9 +153,10 @@ export const sendWelcomeEmail = async ( const { name } = user const firstName = name.split(' ')[0] - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) return await sendTemplateEmail( privateUser.email, @@ -220,9 +222,11 @@ export const sendOneWeekBonusEmail = async ( const { name } = user const firstName = name.split(' ')[0] - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) + return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', @@ -252,10 +256,10 @@ export const sendCreatorGuideEmail = async ( const { name } = user const firstName = name.split(' ')[0] - - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'onboarding_flow' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'onboarding_flow' + ) return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', @@ -286,10 +290,10 @@ export const sendThankYouEmail = async ( const { name } = user const firstName = name.split(' ')[0] - - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'thank_you_for_purchases' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'thank_you_for_purchases' + ) return await sendTemplateEmail( privateUser.email, @@ -469,9 +473,10 @@ export const sendInterestingMarketsEmail = async ( ) return - const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ - 'trending_markets' as notification_preference - }` + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'trending_markets' + ) const { name } = user const firstName = name.split(' ')[0] @@ -507,10 +512,6 @@ export const sendInterestingMarketsEmail = async ( ) } -function contractUrl(contract: Contract) { - return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` -} - function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } @@ -612,3 +613,47 @@ export const sendNewUniqueBettorsEmail = async ( } ) } + +export const sendWeeklyPortfolioUpdateEmail = async ( + user: User, + privateUser: PrivateUser, + investments: PerContractInvestmentsData[], + overallPerformance: OverallPerformanceData +) => { + if ( + !privateUser || + !privateUser.email || + !privateUser.notificationPreferences.profit_loss_updates.includes('email') + ) + return + + const { unsubscribeUrl } = getNotificationDestinationsForUser( + privateUser, + 'profit_loss_updates' + ) + + const { name } = user + const firstName = name.split(' ')[0] + const templateData: Record = { + 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 + ) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index adfee75e..f5c45004 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,9 +27,10 @@ export * from './on-delete-group' export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' -export * from './reset-weekly-emails-flag' +export * from './reset-weekly-emails-flags' export * from './on-update-contract-follow' export * from './on-update-like' +export * from './weekly-portfolio-emails' // v2 export * from './health' @@ -50,6 +51,8 @@ export * from './resolve-market' export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' +export * from './close-market' +export * from './update-comment-bounty' import { health } from './health' import { transact } from './transact' @@ -63,9 +66,11 @@ import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' +import { closemarket } from './close-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' @@ -88,9 +93,12 @@ const sellSharesFunction = toCloudFunction(sellshares) const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addLiquidityFunction = toCloudFunction(addliquidity) +const addCommentBounty = toCloudFunction(addcommentbounty) +const awardCommentBounty = toCloudFunction(awardcommentbounty) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) +const closeMarketFunction = toCloudFunction(closemarket) const unsubscribeFunction = toCloudFunction(unsubscribe) const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) @@ -115,11 +123,14 @@ export { withdrawLiquidityFunction as withdrawliquidity, createGroupFunction as creategroup, resolveMarketFunction as resolvemarket, + closeMarketFunction as closemarket, unsubscribeFunction as unsubscribe, stripeWebhookFunction as stripewebhook, createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, createPostFunction as createpost, - saveTwitchCredentials as savetwitchcredentials + saveTwitchCredentials as savetwitchcredentials, + addCommentBounty as addcommentbounty, + awardCommentBounty as awardcommentbounty, } diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index 7878e410..21b52fbc 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -60,7 +60,7 @@ async function sendMarketCloseEmails() { 'contract', 'closed', user, - 'closed' + contract.id.slice(6, contract.id.length), + contract.id + '-closed-at-' + contract.closeTime, contract.closeTime?.toString() ?? new Date().toString(), { contract } ) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 5e2a94c0..d667f0d2 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,44 +1,118 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import { getUser, getValues, log } from './utils' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' +import { Txn } from '../../common/txn' +import { partition, sortBy } from 'lodash' +import { runTxn, TxnData } from './transact' +import * as admin from 'firebase-admin' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') .onUpdate(async (change, context) => { const contract = change.after.data() as Contract + const previousContract = change.before.data() as Contract const { eventId } = context - - const contractUpdater = await getUser(contract.creatorId) - 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 + const { openCommentBounties, closeTime, question } = contract if ( - previousValue.closeTime !== contract.closeTime || - previousValue.question !== contract.question + !previousContract.isResolved && + contract.isResolved && + (openCommentBounties ?? 0) > 0 ) { - let sourceText = '' - if ( - previousValue.closeTime !== contract.closeTime && - contract.closeTime - ) { - sourceText = contract.closeTime.toString() - } else if (previousValue.question !== contract.question) { - sourceText = contract.question - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'updated', - contractUpdater, - eventId, - sourceText, - contract - ) + await handleUnusedCommentBountyRefunds(contract) + // No need to notify users of resolution, that's handled in resolve-market + return + } + if ( + previousContract.closeTime !== closeTime || + previousContract.question !== question + ) { + await handleUpdatedCloseTime(previousContract, contract, eventId) } }) + +async function handleUpdatedCloseTime( + previousContract: Contract, + contract: Contract, + eventId: string +) { + const contractUpdater = await getUser(contract.creatorId) + if (!contractUpdater) throw new Error('Could not find contract updater') + let sourceText = '' + if (previousContract.closeTime !== contract.closeTime && contract.closeTime) { + sourceText = contract.closeTime.toString() + } else if (previousContract.question !== contract.question) { + sourceText = contract.question + } + + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'updated', + contractUpdater, + eventId, + sourceText, + contract + ) +} + +async function handleUnusedCommentBountyRefunds(contract: Contract) { + const outstandingCommentBounties = await getValues( + firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') + ) + + const commentBountiesOnThisContract = sortBy( + outstandingCommentBounties.filter( + (bounty) => bounty.data?.contractId === contract.id + ), + (bounty) => bounty.createdTime + ) + + const [toBank, fromBank] = partition( + commentBountiesOnThisContract, + (bounty) => bounty.toType === 'BANK' + ) + if (toBank.length <= fromBank.length) return + + await firestore + .collection('contracts') + .doc(contract.id) + .update({ openCommentBounties: 0 }) + + const refunds = toBank.slice(fromBank.length) + await Promise.all( + refunds.map(async (extraBountyTxn) => { + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: extraBountyTxn.toId, + fromType: 'BANK', + toId: extraBountyTxn.fromId, + toType: 'USER', + amount: extraBountyTxn.amount, + token: 'M$', + category: 'REFUND_COMMENT_BOUNTY', + data: { + contractId: contract.id, + }, + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + log( + `Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`, + result.status + ) + log('message:', result.message) + } else { + log( + `Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`, + result.txn?.id + ) + } + }) + ) +} + +const firestore = admin.firestore() diff --git a/functions/src/reset-weekly-emails-flag.ts b/functions/src/reset-weekly-emails-flags.ts similarity index 87% rename from functions/src/reset-weekly-emails-flag.ts rename to functions/src/reset-weekly-emails-flags.ts index 947e3e88..1b2109a1 100644 --- a/functions/src/reset-weekly-emails-flag.ts +++ b/functions/src/reset-weekly-emails-flags.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { getAllPrivateUsers } from './utils' -export const resetWeeklyEmailsFlag = functions +export const resetWeeklyEmailsFlags = functions .runWith({ timeoutSeconds: 300, memory: '4GB', @@ -17,6 +17,7 @@ export const resetWeeklyEmailsFlag = functions privateUsers.map(async (user) => { return firestore.collection('private-users').doc(user.id).update({ weeklyTrendingEmailSent: false, + weeklyPortfolioUpdateEmailSent: false, }) }) ) diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts index 57976ff2..497a4ba0 100644 --- a/functions/src/score-contracts.ts +++ b/functions/src/score-contracts.ts @@ -1,12 +1,15 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { Bet } from 'common/bet' import { uniq } from 'lodash' -import { Contract } from 'common/contract' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' import { log } from './utils' +import { removeUndefinedProps } from '../../common/util/object' +import { DAY_MS, HOUR_MS } from '../../common/util/time' -export const scoreContracts = functions.pubsub - .schedule('every 1 hours') +export const scoreContracts = functions + .runWith({ memory: '4GB', timeoutSeconds: 540 }) + .pubsub.schedule('every 1 hours') .onRun(async () => { await scoreContractsInternal() }) @@ -14,11 +17,12 @@ const firestore = admin.firestore() async function scoreContractsInternal() { const now = Date.now() - const lastHour = now - 60 * 60 * 1000 - const last3Days = now - 1000 * 60 * 60 * 24 * 3 + const hourAgo = now - HOUR_MS + const dayAgo = now - DAY_MS + const threeDaysAgo = now - DAY_MS * 3 const activeContractsSnap = await firestore .collection('contracts') - .where('lastUpdatedTime', '>', lastHour) + .where('lastUpdatedTime', '>', hourAgo) .get() const activeContracts = activeContractsSnap.docs.map( (doc) => doc.data() as Contract @@ -39,16 +43,33 @@ async function scoreContractsInternal() { for (const contract of contracts) { const bets = await firestore .collection(`contracts/${contract.id}/bets`) - .where('createdTime', '>', last3Days) + .where('createdTime', '>', threeDaysAgo) .get() const bettors = bets.docs .map((doc) => doc.data() as Bet) .map((bet) => bet.userId) - const score = uniq(bettors).length - if (contract.popularityScore !== score) + const popularityScore = uniq(bettors).length + + const wasCreatedToday = contract.createdTime > dayAgo + + let dailyScore: number | undefined + if ( + contract.outcomeType === 'BINARY' && + contract.mechanism === 'cpmm-1' && + !wasCreatedToday + ) { + const percentChange = Math.abs(contract.probChanges.day) + dailyScore = popularityScore * percentChange + } + + if ( + contract.popularityScore !== popularityScore || + contract.dailyScore !== dailyScore + ) { await firestore .collection('contracts') .doc(contract.id) - .update({ popularityScore: score }) + .update(removeUndefinedProps({ popularityScore, dailyScore })) + } } } diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts new file mode 100644 index 00000000..99d5f12b --- /dev/null +++ b/functions/src/scripts/contest/bulk-add-liquidity.ts @@ -0,0 +1,52 @@ +// 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() diff --git a/functions/src/scripts/contest/bulk-create-markets.ts b/functions/src/scripts/contest/bulk-create-markets.ts new file mode 100644 index 00000000..ba7245fe --- /dev/null +++ b/functions/src/scripts/contest/bulk-create-markets.ts @@ -0,0 +1,115 @@ +// 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', + } +} diff --git a/functions/src/scripts/contest/criticism-and-red-teaming.ts b/functions/src/scripts/contest/criticism-and-red-teaming.ts new file mode 100644 index 00000000..0f19705a --- /dev/null +++ b/functions/src/scripts/contest/criticism-and-red-teaming.ts @@ -0,0 +1,1219 @@ +export const data = [ + // { + // "title": "Announcing a contest: EA Criticism and Red Teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/8hvmvrgcxJJ2pYR4X/announcing-a-contest-ea-criticism-and-red-teaming" + // }, + // { + // "title": "Pre-announcing a contest for critiques and red teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/Fx8pWSLKGwuqsfuRQ/pre-announcing-a-contest-for-critiques-and-red-teaming" + // }, + // { + // "title": "Resource for criticisms and red teaming", + // "author": "Lizka", + // "link": "https://forum.effectivealtruism.org/posts/uuQDgiJJaswEyyzan/resource-for-criticisms-and-red-teaming" + // }, + { + title: + 'Deworming and decay: replicating GiveWell’s cost-effectiveness analysis ', + author: 'JoelMcGuire', + link: 'https://forum.effectivealtruism.org/posts/MKiqGvijAXfcBHCYJ/deworming-and-decay-replicating-givewell-s-cost', + }, + { + title: 'Critiques of EA that I want to read', + author: 'abrahamrowe', + link: 'https://forum.effectivealtruism.org/posts/n3WwTz4dbktYwNQ2j/critiques-of-ea-that-i-want-to-read', + }, + { + title: 'My take on What We Owe the Future', + author: 'elifland', + link: 'https://forum.effectivealtruism.org/posts/9Y6Y6qoAigRC7A8eX/my-take-on-what-we-owe-the-future', + }, + { + title: + 'A Critical Review of Open Philanthropy’s Bet On Criminal Justice Reform', + author: 'NunoSempere', + link: 'https://forum.effectivealtruism.org/posts/h2N9qEbvQ6RHABcae/a-critical-review-of-open-philanthropy-s-bet-on-criminal', + }, + { + title: 'Leaning into EA Disillusionment', + author: 'Helen', + link: 'https://forum.effectivealtruism.org/posts/MjTB4MvtedbLjgyja/leaning-into-ea-disillusionment', + }, + { + title: 'Red Teaming CEA’s Community Building Work', + author: 'AnonymousEAForumAccount', + link: 'https://forum.effectivealtruism.org/posts/hbejbRBpd6quqnTAB/red-teaming-cea-s-community-building-work-2', + }, + { + title: + 'A philosophical review of Open Philanthropy’s Cause Prioritisation Framework', + author: 'MichaelPlant', + link: 'https://forum.effectivealtruism.org/posts/bdiDW83SFAsoA4EeB/a-philosophical-review-of-open-philanthropy-s-cause', + }, + { + title: 'The Future Might Not Be So Great', + author: 'Jacy', + link: 'https://forum.effectivealtruism.org/posts/WebLP36BYDbMAKoa5/the-future-might-not-be-so-great', + }, + { + title: 'Potatoes: A Critical Review', + author: 'Pablo Villalobos', + link: 'https://forum.effectivealtruism.org/posts/iZrrWGvx2s2uPtica/potatoes-a-critical-review', + }, + { + title: 'Effective Altruism as Coordination & Field Incubation', + author: 'DavidNash', + link: 'https://forum.effectivealtruism.org/posts/Zm6iaaJhoZsoZ2uMD/effective-altruism-as-coordination-and-field-incubation', + }, + { + title: 'Enlightenment Values in a Vulnerable World', + author: 'Maxwell Tabarrok', + link: 'https://forum.effectivealtruism.org/posts/A4fMkKhBxio83NtBL/enlightenment-values-in-a-vulnerable-world', + }, + { + title: "21 criticisms of EA I'm thinking about", + author: 'Peter Wildeford', + link: 'https://forum.effectivealtruism.org/posts/X47rn28Xy5TRfGgSj/21-criticisms-of-ea-i-m-thinking-about', + }, + { + title: 'Longtermism and Computational Complexity', + author: 'David Kinney', + link: 'https://forum.effectivealtruism.org/posts/RRyHcupuDafFNXt6p/longtermism-and-computational-complexity', + }, + { + title: 'The Community Manifesto', + author: 'dianaqianmorgan', + link: 'https://forum.effectivealtruism.org/posts/cY3wBXoJoeHXJ7XYt/the-community-manifesto', + }, + { + title: 'Existential risk pessimism and the time of perils', + author: 'David Thorstad', + link: 'https://forum.effectivealtruism.org/posts/N6hcw8CxK7D3FCD5v/existential-risk-pessimism-and-the-time-of-perils-4', + }, + { + title: "A critical review of GiveWell's 2022 cost-effectiveness model", + author: 'Froolow', + link: 'https://forum.effectivealtruism.org/posts/6dtwkwBrHBGtc3xes/a-critical-review-of-givewell-s-2022-cost-effectiveness', + }, + { + title: 'How EA is perceived is crucial to its future trajectory', + author: 'GidonKadosh', + link: 'https://forum.effectivealtruism.org/posts/82ig8odF9ooccfJfa/how-ea-is-perceived-is-crucial-to-its-future-trajectory', + }, + { + title: + 'Before There Was Effective Altruism, There Was Effective Philanthropy', + author: 'ColdButtonIssues', + link: 'https://forum.effectivealtruism.org/posts/CdrKtaAX69iJuJD2r/before-there-was-effective-altruism-there-was-effective', + }, + { + title: + 'A concern about the “evolutionary anchor” of Ajeya Cotra’s report on AI timelines.', + author: 'NunoSempere', + link: 'https://forum.effectivealtruism.org/posts/FHTyixYNnGaQfEexH/a-concern-about-the-evolutionary-anchor-of-ajeya-cotra-s', + }, + { + title: + 'EA is becoming increasingly inaccessible, at the worst possible time', + author: 'Ann Garth', + link: 'https://forum.effectivealtruism.org/posts/duPDKhtXTJNAJBaSf/ea-is-becoming-increasingly-inaccessible-at-the-worst', + }, + { + title: 'Red-teaming contest: demographics and power structures in EA', + author: 'TheOtherHannah', + link: 'https://forum.effectivealtruism.org/posts/oD3zus6LhbhBj6z2F/red-teaming-contest-demographics-and-power-structures-in-ea', + }, + { + title: 'The Nietzschean Challenge to Effective Altruism', + author: 'Richard Y Chappell', + link: 'https://forum.effectivealtruism.org/posts/bedstSbqaP8aDBfDr/the-nietzschean-challenge-to-effective-altruism', + }, + { + title: + 'The Case for Funding New Long-Term Randomized Controlled Trials of Deworming', + author: 'MHR', + link: 'https://forum.effectivealtruism.org/posts/CyxZmwQ7gADwjBHG6/the-case-for-funding-new-long-term-randomized-controlled', + }, + { + title: 'Population Ethics Without Axiology: A Framework', + author: 'Lukas_Gloor', + link: 'https://forum.effectivealtruism.org/posts/dQvDxDMyueLyydHw4/population-ethics-without-axiology-a-framework', + }, + { + title: 'Questioning the Value of Extinction Risk Reduction', + author: 'Red Team 8', + link: 'https://forum.effectivealtruism.org/posts/eeDsHDoM9De4iGGLw/questioning-the-value-of-extinction-risk-reduction-1', + }, + { + title: 'Red teaming introductory EA courses', + author: 'Philip Hall Andersen', + link: 'https://forum.effectivealtruism.org/posts/JDEDsCaQd2CYm7QEi/red-teaming-introductory-ea-courses', + }, + { + title: 'Systemic Cascading Risks: Relevance in Longtermism & Value Lock-In', + author: 'Richard Ren', + link: 'https://forum.effectivealtruism.org/posts/mWGodAi9Mv2a2EbNj/systemic-cascading-risks-relevance-in-longtermism-and-value', + }, + { + title: + 'community building solely as a tool for impact creates toxic communities', + author: 'ruthgrace', + link: 'https://forum.effectivealtruism.org/posts/EpMQBmQv7e4yaDYYN/community-building-solely-as-a-tool-for-impact-creates-toxic', + }, + { + title: + 'Are you really in a race? The Cautionary Tales of Szilárd and Ellsberg', + author: 'HaydnBelfield', + link: 'https://forum.effectivealtruism.org/posts/cXBznkfoPJAjacFoT/are-you-really-in-a-race-the-cautionary-tales-of-szilard-and', + }, + { + title: + "Quantifying Uncertainty in GiveWell's GiveDirectly Cost-Effectiveness Analysis", + author: 'Hazelfire', + link: 'https://forum.effectivealtruism.org/posts/ycLhq4Bmep8ssr4wR/quantifying-uncertainty-in-givewell-s-givedirectly-cost', + }, + { + title: 'Changing the world through slack & hobbies', + author: 'Steven Byrnes', + link: 'https://forum.effectivealtruism.org/posts/ZkhABk4rRMqsNmwvf/changing-the-world-through-slack-and-hobbies', + }, + { + title: + 'Some concerns about policy work funding and the Long Term Future Fund', + author: 'weeatquince', + link: 'https://forum.effectivealtruism.org/posts/Xfon9oxyMFv47kFnc/some-concerns-about-policy-work-funding-and-the-long-term', + }, + { + title: + 'Why Effective Altruists Should Put a Higher Priority on Funding Academic Research', + author: 'Stuart Buck', + link: 'https://forum.effectivealtruism.org/posts/uTQKFNXrMXuwGe4vw/why-effective-altruists-should-put-a-higher-priority-on', + }, + { + title: 'Remuneration In Effective Altruism', + author: 'Stefan_Schubert', + link: 'https://forum.effectivealtruism.org/posts/wWnRtjDiyjRRgaFDb/remuneration-in-effective-altruism', + }, + { + title: "You Don't Need To Justify Everything", + author: 'ThomasW', + link: 'https://forum.effectivealtruism.org/posts/HX9ZDGwwSxAab46N9/you-don-t-need-to-justify-everything', + }, + { + title: 'EAs underestimate uncertainty in cause prioritisation', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/Ekd3oATEZkBbJ95uD/eas-underestimate-uncertainty-in-cause-prioritisation', + }, + { + title: '"Doing Good Best" isn\'t the EA ideal', + author: 'Davidmanheim', + link: 'https://forum.effectivealtruism.org/posts/f9NpDx65zY6Qk9ofe/doing-good-best-isn-t-the-ea-ideal', + }, + { + title: 'The discount rate is not zero', + author: 'Thomaaas', + link: 'https://forum.effectivealtruism.org/posts/zLZMsthcqfmv5J6Ev/the-discount-rate-is-not-zero', + }, + { + title: 'Questioning the Foundations of EA', + author: 'Wei_Dai', + link: 'https://forum.effectivealtruism.org/posts/zvNwSG2Xvy8x5Rtba/questioning-the-foundations-of-ea', + }, + { + title: + 'Notes on how prizes may fail and how to reduce the risk of them failing', + author: 'Peter Wildeford', + link: 'https://forum.effectivealtruism.org/posts/h2WcJf7pg5Qdfhsm3/notes-on-how-prizes-may-fail-and-how-to-reduce-the-risk-of', + }, + { + title: 'EA Culture and Causes: Less is More', + author: 'Allen Bell', + link: 'https://forum.effectivealtruism.org/posts/FWHDX32ecr9aF4xKw/ea-culture-and-causes-less-is-more', + }, + { + title: 'Things usually end slowly', + author: 'OllieBase', + link: 'https://forum.effectivealtruism.org/posts/qLwtCuh6nDCsrsrMK/things-usually-end-slowly', + }, + { + title: + 'Doing good is a privilege. This needs to change if we want to do good long-term. ', + author: 'SofiaBalderson', + link: 'https://forum.effectivealtruism.org/posts/gicYG5ymk4pPzrKAd/doing-good-is-a-privilege-this-needs-to-change-if-we-want-to', + }, + { + title: 'Animal Zoo', + author: 'bericlair', + link: 'https://forum.effectivealtruism.org/posts/YfmdnkmnoWBhmBaQL/animal-zoo', + }, + { + title: 'Summaries are underrated', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/nDawZHxDR3j53zdbf/summaries-are-underrated', + }, + { + title: 'Longtermism, risk, and extinction', + author: 'Richard Pettigrew', + link: 'https://forum.effectivealtruism.org/posts/xAoZotkzcY5mvmXFY/longtermism-risk-and-extinction', + }, + { + title: + 'Prioritisation should consider potential for ongoing evaluation alongside expected value and evidence quality', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/orfgdYRZXNKQtqzmh/prioritisation-should-consider-potential-for-ongoing', + }, + { + title: + '“Existential Risk” is badly named and leads to narrow focus on astronomical waste', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/qFdifovCmckujxEsq/existential-risk-is-badly-named-and-leads-to-narrow-focus-on', + }, + { + title: + 'The great energy descent (short version) - An important thing EA might have missed', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/wXzc75txE5hbHqYug/the-great-energy-descent-short-version-an-important-thing-ea', + }, + { + title: 'The Long Reflection as the Great Stagnation ', + author: 'Larks', + link: 'https://forum.effectivealtruism.org/posts/o5Q8dXfnHTozW9jkY/the-long-reflection-as-the-great-stagnation', + }, + { + title: 'Community posts: The Forum needs a way to work in public', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/NxWssGagWoQWErRer/community-posts-the-forum-needs-a-way-to-work-in-public', + }, + { + title: 'Improving Karma: $8mn of possible value (my estimate)', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/YajssmjwKndBTahQx/improving-karma-usd8mn-of-possible-value-my-estimate', + }, + { + title: 'Leveraging labor shortages as a pathway to career impact', + author: 'IanDavidMoss', + link: 'https://forum.effectivealtruism.org/posts/xdMn6FeQGjrXDPnQj/leveraging-labor-shortages-as-a-pathway-to-career-impact', + }, + { + title: 'How to dissolve moral cluelessness about donating mosquito nets', + author: 'ben.smith', + link: 'https://forum.effectivealtruism.org/posts/9XgLq4eQHMWybDsrv/how-to-dissolve-moral-cluelessness-about-donating-mosquito-1', + }, + { + title: '[TikTok] Comparability between suffering and happiness', + author: 'Ben_West', + link: 'https://forum.effectivealtruism.org/posts/ASmtarf3ADYb2Xmrt/tiktok-comparability-between-suffering-and-happiness', + }, + { + title: + 'Red teaming a model for estimating the value of longtermist interventions - A critique of Tarsney\'s "The Epistemic Challenge to Longtermism"', + author: 'Anjay F', + link: 'https://forum.effectivealtruism.org/posts/u9CvMCCmQRgjBD828/red-teaming-a-model-for-estimating-the-value-of-longtermist', + }, + { + title: + 'Criticism of EA Criticisms: Is the real disagreement about cause prio?', + author: 'Akash', + link: 'https://forum.effectivealtruism.org/posts/qgQaWub8iR2EERq7i/criticism-of-ea-criticisms-is-the-real-disagreement-about', + }, + { + title: 'Effective Altruism Should Seek Less Criticism', + author: 'The Chaostician', + link: 'https://forum.effectivealtruism.org/posts/oQA7Z6JKHAwvWz9wk/effective-altruism-should-seek-less-criticism', + }, + { + title: + 'The great energy descent - Part 1: Can renewables replace fossil fuels?', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/qG8k5pzhaDk6FhcYv/the-great-energy-descent-part-1-can-renewables-replace', + }, + { + title: 'We’re searching for meaning, not happiness (et al.)', + author: 'Joshua Clingo', + link: 'https://forum.effectivealtruism.org/posts/gmTYoTmggojK5bywA/we-re-searching-for-meaning-not-happiness-et-al', + }, + { + title: + 'Earning to give should have focused more on “entrepreneurship to give”', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/JXDi8tL6uoKPhg4uw/earning-to-give-should-have-focused-more-on-entrepreneurship', + }, + { + title: 'Longtermism neglects anti-ageing research', + author: 'freedomandutility', + link: 'https://forum.effectivealtruism.org/posts/pbPmhWhGxsGSzwpNE/longtermism-neglects-anti-ageing-research', + }, + { + title: + 'Popular EA Authors Should Give Libraries More Copies of Their EBooks', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/AAL2zPtg7T6bjWijT/popular-ea-authors-should-give-libraries-more-copies-of', + }, + { + title: 'this one weird trick creates infinite utility', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/BwuLaA97BAtLcbuuF/this-one-weird-trick-creates-infinite-utility', + }, + { + title: 'Think again: Should EA be a social movement?', + author: 'An A', + link: 'https://forum.effectivealtruism.org/posts/giznESHxt45SvuhZw/think-again-should-ea-be-a-social-movement', + }, + { + title: 'Rethinking longtermism and global development', + author: 'BrownHairedEevee', + link: 'https://forum.effectivealtruism.org/posts/GAEjbu2eHRXPHwTxF/rethinking-longtermism-and-global-development', + }, + { + title: 'Hiring: The Ignored Resource of Rejected EA Job Candidates', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/ekLyLdiCCcD6BqbJR/hiring-the-ignored-resource-of-rejected-ea-job-candidates-1', + }, + { + title: 'Suggestions for 80,000K ', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/MdvGwL4hTM2B96x4d/suggestions-for-80-000k', + }, + { + title: "We're funding task-adjusted survival (DALYs)", + author: 'brb243', + link: 'https://forum.effectivealtruism.org/posts/Gs8HS8QFBhtgAa8qo/we-re-funding-task-adjusted-survival-dalys', + }, + { + title: + 'A Need for Final Chapter Revision in Intro EA Fellowship Curricula & Other Ways to Fix Holes in The Funnel', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/AaXDbHZhJYgxLCjYZ/a-need-for-final-chapter-revision-in-intro-ea-fellowship', + }, + { + title: 'Moral Injury, Mental Health, and Obsession in EA', + author: 'ECJ', + link: 'https://forum.effectivealtruism.org/posts/jiiyBcoZXXXT7eFHm/moral-injury-mental-health-and-obsession-in-ea', + }, + { + title: 'Are we already past the precipice?', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/e6prpQSojPW3jC7YD/are-we-already-past-the-precipice', + }, + { + title: 'EA has a lying problem [Link Post]', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/8dWms5YxYwZW9xneL/ea-has-a-lying-problem-link-post', + }, + { + title: + "Senior EA 'ops' roles: if you want to undo the bottleneck, hire differently", + author: 'AnonymousThrowAway', + link: 'https://forum.effectivealtruism.org/posts/X8YMxbWNsF5FNaCFz/senior-ea-ops-roles-if-you-want-to-undo-the-bottleneck-hire', + }, + { + title: "On Deference and Yudkowsky's AI Risk Estimates", + author: 'Ben Garfinkel', + link: 'https://forum.effectivealtruism.org/posts/NBgpPaz5vYe3tH4ga/on-deference-and-yudkowsky-s-ai-risk-estimates', + }, + { + title: 'EA is too reliant on personal connections', + author: 'sawyer', + link: 'https://forum.effectivealtruism.org/posts/dvcpKuajunxdaZ6se/ea-is-too-reliant-on-personal-connections', + }, + { + title: 'Michael Nielsen\'s "Notes on effective altruism"', + author: 'Pablo', + link: 'https://forum.effectivealtruism.org/posts/JBAPssaYMMRfNqYt7/michael-nielsen-s-notes-on-effective-altruism', + }, + { + title: 'Effective altruism in the garden of ends', + author: 'tyleralterman', + link: 'https://forum.effectivealtruism.org/posts/AjxqsDmhGiW9g8ju6/effective-altruism-in-the-garden-of-ends', + }, + { + title: 'Critique of MacAskill’s “Is It Good to Make Happy People?”', + author: 'Magnus Vinding', + link: 'https://forum.effectivealtruism.org/posts/vZ4kB8gpvkfHLfz8d/critique-of-macaskill-s-is-it-good-to-make-happy-people', + }, + { + title: 'Effective altruism is no longer the right name for the movement', + author: 'ParthThaya', + link: 'https://forum.effectivealtruism.org/posts/2FB8tK9da89qksZ9E/effective-altruism-is-no-longer-the-right-name-for-the-1', + }, + { + title: 'Prioritizing x-risks may require caring about future people', + author: 'elifland', + link: 'https://forum.effectivealtruism.org/posts/rvvwCcixmEep4RSjg/prioritizing-x-risks-may-require-caring-about-future-people', + }, + { + title: 'Ways money can make things worse', + author: 'Jan_Kulveit', + link: 'https://forum.effectivealtruism.org/posts/YKEPXLQhYjm3nP7Td/ways-money-can-make-things-worse', + }, + { + title: "EA Shouldn't Try to Exercise Direct Political Power", + author: 'iamasockpuppet', + link: 'https://forum.effectivealtruism.org/posts/BgNnctp6deoGdKtbr/ea-shouldn-t-try-to-exercise-direct-political-power', + }, + { + title: 'EA on nuclear war and expertise', + author: 'bean', + link: 'https://forum.effectivealtruism.org/posts/bCB88GKeXTaxozr6y/ea-on-nuclear-war-and-expertise', + }, + { + title: 'The most important climate change uncertainty', + author: 'cwa', + link: 'https://forum.effectivealtruism.org/posts/nBN6NENeudd2uJBCQ/the-most-important-climate-change-uncertainty', + }, + { + title: "Critique of OpenPhil's macroeconomic policy advocacy", + author: 'Hauke Hillebrandt', + link: 'https://forum.effectivealtruism.org/posts/cDdcNzyizzdZD4hbR/critique-of-openphil-s-macroeconomic-policy-advocacy', + }, + { + title: + 'Methods for improving uncertainty analysis in EA cost-effectiveness models', + author: 'Froolow', + link: 'https://forum.effectivealtruism.org/posts/CuuCGzuzwD6cdu9mo/methods-for-improving-uncertainty-analysis-in-ea-cost', + }, + { + title: + 'Did OpenPhil ever publish their in-depth review of their three-year OpenAI grant?', + author: 'Markus Amalthea Magnuson', + link: 'https://forum.effectivealtruism.org/posts/sZhhW2AECqT5JikdE/did-openphil-ever-publish-their-in-depth-review-of-their', + }, + { + title: 'Go Republican, Young EA!', + author: 'ColdButtonIssues', + link: 'https://forum.effectivealtruism.org/posts/myympkZ6SuT59vuEQ/go-republican-young-ea', + }, + { + title: + 'Are too many young, highly-engaged longtermist EAs doing movement-building?', + author: 'Anonymous_EA', + link: 'https://forum.effectivealtruism.org/posts/Lfy89vKqHatQdJgDZ/are-too-many-young-highly-engaged-longtermist-eas-doing', + }, + { + title: "EA's Culture and Thinking are Severely Limiting its Impact", + author: 'Peter Elam', + link: 'https://forum.effectivealtruism.org/posts/jhCGX8Gwq44TmyPJv/ea-s-culture-and-thinking-are-severely-limiting-its-impact', + }, + { + title: 'Criticism of EA Criticism Contest', + author: 'Zvi ', + link: 'https://forum.effectivealtruism.org/posts/qjMPATBLM5p4ABcEB/criticism-of-ea-criticism-contest', + }, + { + title: + 'The EA community might be neglecting the value of influencing people', + author: 'JulianHazell', + link: 'https://forum.effectivealtruism.org/posts/3szWd8HwWccJb9z5L/the-ea-community-might-be-neglecting-the-value-of', + }, + { + title: 'Slowing down AI progress is an underexplored alignment strategy', + author: 'Michael Huang', + link: 'https://forum.effectivealtruism.org/posts/6LNvQYyNQpDQmnnux/slowing-down-ai-progress-is-an-underexplored-alignment', + }, + { + title: 'Some core assumptions of effective altruism, according to me', + author: 'peterhartree', + link: 'https://forum.effectivealtruism.org/posts/av7MiEhi983SjoXTe/some-core-assumptions-of-effective-altruism-according-to-me', + }, + { + title: 'Transcript of Twitter Discussion on EA from June 2022', + author: 'Zvi ', + link: 'https://forum.effectivealtruism.org/posts/MpJcvzHfQyFLxLZNh/transcript-of-twitter-discussion-on-ea-from-june-2022', + }, + { + title: 'EA culture is special; we should proceed with intentionality', + author: 'James Lin', + link: 'https://forum.effectivealtruism.org/posts/KuKzqhxLzaREL7KKi/ea-culture-is-special-we-should-proceed-with-intentionality', + }, + { + title: 'Four Concerns Regarding Longtermism', + author: 'Pat Andriola', + link: 'https://forum.effectivealtruism.org/posts/ESzGcWfkMtJgF2CCA/four-concerns-regarding-longtermism', + }, + { + title: 'Chesterton Fences and EA’s X-risks', + author: 'jehan', + link: 'https://forum.effectivealtruism.org/posts/j4RnXAQgyMCSLzBkW/chesterton-fences-and-ea-s-x-risks', + }, + { + title: 'Introduction to Pragmatic AI Safety [Pragmatic AI Safety #1]', + author: 'ThomasW', + link: 'https://forum.effectivealtruism.org/posts/MskKEsj8nWREoMjQK/introduction-to-pragmatic-ai-safety-pragmatic-ai-safety-1', + }, + { + title: 'EA needs to understand its “failures” better', + author: 'mariushobbhahn', + link: 'https://forum.effectivealtruism.org/posts/Nwut6L6eAGmrFSaT4/ea-needs-to-understand-its-failures-better', + }, + { + title: 'An Evaluation of Animal Charity Evaluators ', + author: 'eaanonymous1234', + link: 'https://forum.effectivealtruism.org/posts/pfSiMpkmskRB4WxYW/an-evaluation-of-animal-charity-evaluators', + }, + { + title: 'What is the overhead of grantmaking?', + author: 'MathiasKB', + link: 'https://forum.effectivealtruism.org/posts/RXm2mxvq3ReXmsHm4/what-is-the-overhead-of-grantmaking', + }, + { + title: + 'A Critique of The Precipice: Chapter 6 - The Risk Landscape [Red Team Challenge]', + author: 'Sarah Weiler', + link: 'https://forum.effectivealtruism.org/posts/faW24r7ocbcPisgCH/a-critique-of-the-precipice-chapter-6-the-risk-landscape-red', + }, + { + title: + 'Wheeling and dealing: An internal bargaining approach to moral uncertainty', + author: 'MichaelPlant', + link: 'https://forum.effectivealtruism.org/posts/kxEAkcEvyiwmjirjN/wheeling-and-dealing-an-internal-bargaining-approach-to', + }, + { + title: 'Let’s not glorify people for how they look.', + author: 'Florence', + link: 'https://forum.effectivealtruism.org/posts/8ii5SD7HBL4EdYw5K/let-s-not-glorify-people-for-how-they-look-2', + }, + { + title: 'The first AGI will be a buggy mess', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/pXjpZep49M6GGxFQF/the-first-agi-will-be-a-buggy-mess', + }, + { + title: '[Cause Exploration Prizes] The importance of Intercausal Impacts', + author: 'Sebastian Joy 樂百善', + link: 'https://forum.effectivealtruism.org/posts/MayveXrHbvXMBRo78/cause-exploration-prizes-the-importance-of-intercausal', + }, + { + title: 'The Windfall Clause has a remedies problem', + author: 'John Bridge', + link: 'https://forum.effectivealtruism.org/posts/wBzfLyfJFfocmdrwL/the-windfall-clause-has-a-remedies-problem', + }, + { + title: 'Future Paths for Effective Altruism', + author: 'James Broughel', + link: 'https://forum.effectivealtruism.org/posts/yzAoHcTzf3AjeGYsP/future-paths-for-effective-altruism', + }, + { + title: 'The Effective Altruism culture', + author: 'PabloAMC', + link: 'https://forum.effectivealtruism.org/posts/NkF9rAjZpkDajqDDt/the-effective-altruism-culture', + }, + { + title: + 'The Role of Individual Consumption Decisions in Animal Welfare and Climate are Analogous', + author: 'Gabriel Weil', + link: 'https://forum.effectivealtruism.org/posts/HWpwfTF5M84jo4iyo/the-role-of-individual-consumption-decisions-in-animal', + }, + { + title: 'Criticism of the main framework in AI alignment', + author: 'Michele Campolo', + link: 'https://forum.effectivealtruism.org/posts/Cs8qhNakLuLXY4GvE/criticism-of-the-main-framework-in-ai-alignment', + }, + { + title: 'Crowdsourced Criticisms: What does EA think about EA?', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/jK2Qends7GnyaRhm2/crowdsourced-criticisms-what-does-ea-think-about-ea', + }, + { + title: + 'EAs should recommend cost-effective interventions in more cause areas (not just the most pressing ones) \n\n', + author: 'Amber Dawn', + link: 'https://forum.effectivealtruism.org/posts/JiEyCNoGD3WwTgDkG/eas-should-recommend-cost-effective-interventions-in-more', + }, + { + title: + 'AGI Battle Royale: Why “slow takeover” scenarios devolve into a chaotic multi-AGI fight to the death', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/TxrzhfRr6EXiZHv4G/agi-battle-royale-why-slow-takeover-scenarios-devolve-into-a', + }, + { + title: 'Effective means to combat autocracies', + author: 'Junius Brutus', + link: 'https://forum.effectivealtruism.org/posts/kawE7rFmp3SkzLxpx/effective-means-to-combat-autocracies', + }, + { + title: 'Editing wild animals is underexplored in What We Owe the Future', + author: 'Michael Huang', + link: 'https://forum.effectivealtruism.org/posts/cWnQMagKFqJoaGA5M/editing-wild-animals-is-underexplored-in-what-we-owe-the', + }, + { + title: 'Reasons for my negative feelings towards the AI risk discussion', + author: 'fergusq', + link: 'https://forum.effectivealtruism.org/posts/hLbWWuDr3EbeQqrmg/reasons-for-my-negative-feelings-towards-the-ai-risk', + }, + { + title: + 'We need more discussion and clarity on how university groups create value', + author: 'Oscar Galvin', + link: 'https://forum.effectivealtruism.org/posts/HNHHNCDLEsDNjNwvm/we-need-more-discussion-and-clarity-on-how-university-groups', + }, + { + title: 'What 80000 Hours gets wrong about solar geoengineering', + author: 'Gideon Futerman', + link: 'https://forum.effectivealtruism.org/posts/6dbET4f9LbJZZTuDW/what-80000-hours-gets-wrong-about-solar-geoengineering', + }, + { + title: + 'Concerns/Thoughts over international aid, longtermism and philosophical notes on speaking with Larry Temkin.', + author: 'Ben Yeoh', + link: 'https://forum.effectivealtruism.org/posts/uhaKXdkAcuXJZHSci/concerns-thoughts-over-international-aid-longtermism-and', + }, + { + title: 'On longtermism, Bayesianism, and the doomsday argument', + author: 'iporphyry', + link: 'https://forum.effectivealtruism.org/posts/f2RzSd2ukFZyNB86L/on-longtermism-bayesianism-and-the-doomsday-argument', + }, + { + title: 'Friendship is Optimal: EAGs should be online', + author: 'Emrik', + link: 'https://forum.effectivealtruism.org/posts/35nRwEyzCKDfh3dCr/friendship-is-optimal-eags-should-be-online', + }, + { + title: 'A Critique of AI Takeover Scenarios', + author: 'Fods12', + link: 'https://forum.effectivealtruism.org/posts/j7X8nQ7YvvA7Pi4BX/a-critique-of-ai-takeover-scenarios', + }, + { + title: 'The dangers of high salaries within EA organisations', + author: 'James Ozden', + link: 'https://forum.effectivealtruism.org/posts/WXD3bRDBkcBhJ5Wcr/the-dangers-of-high-salaries-within-ea-organisations', + }, + { + title: 'Low-key Longtermism', + author: 'Jonathan Rystrom', + link: 'https://forum.effectivealtruism.org/posts/BaynHfmkjrfL8DXcK/low-key-longtermism', + }, + { + title: + 'The Credibility of Apocalyptic Claims: A Critique of Techno-Futurism within Existential Risk', + author: 'Ember', + link: 'https://forum.effectivealtruism.org/posts/a2XaDeadFe6eHfDwG/the-credibility-of-apocalyptic-claims-a-critique-of-techno', + }, + { + title: + 'The Role of "Economism" in the Belief-Formation Systems of Effective Altruism', + author: 'Thomas Aitken', + link: 'https://forum.effectivealtruism.org/posts/cR4pCrATD5SSN35Sm/the-role-of-economism-in-the-belief-formation-systems-of', + }, + { + title: + 'A slightly (I think?) different slant on why EA elitism bias/top-university focus/lack of diversity is a problem', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/LCfQCvtFyAEnxCnMf/a-slightly-i-think-different-slant-on-why-ea-elitism-bias', + }, + { + title: 'Chaining the evil genie: why "outer" AI safety is probably easy', + author: 'titotal', + link: 'https://forum.effectivealtruism.org/posts/AoPR8BFrAFgGGN9iZ/chaining-the-evil-genie-why-outer-ai-safety-is-probably-easy', + }, + { + title: 'Should EA shift away (a bit) from elite universities?', + author: 'Joseph Lemien', + link: 'https://forum.effectivealtruism.org/posts/Rts8vKvbxkngPbFh7/should-ea-shift-away-a-bit-from-elite-universities', + }, + { + title: 'Aesthetics as Epistemic Humility', + author: 'Étienne Fortier-Dubois', + link: 'https://forum.effectivealtruism.org/posts/bo6Jsvmq9oiykbDrM/aesthetics-as-epistemic-humility', + }, + { + title: + "What if states don't listen? A fundamental gap in x-risk reduction strategies ", + author: 'HTC', + link: 'https://forum.effectivealtruism.org/posts/sFxtu6ZKAScDSqLrK/what-if-states-don-t-listen-a-fundamental-gap-in-x-risk', + }, + { + title: 'Eliminate or Adjust Strong Upvotes to Improve the Forum', + author: 'Afternoon Coffee', + link: 'https://forum.effectivealtruism.org/posts/2XGFdBkxa5Hm5LWZq/eliminate-or-adjust-strong-upvotes-to-improve-the-forum', + }, + { + title: 'On the Philosophical Foundations of EA', + author: 'mm6', + link: 'https://forum.effectivealtruism.org/posts/gLWmeKTe68ZHnomwy/on-the-philosophical-foundations-of-ea', + }, + { + title: + "Why I Hope (Certain) Hedonic Utilitarians Don't Control the Long-term Future", + author: 'Jared_Riggs', + link: 'https://forum.effectivealtruism.org/posts/PJKecg5ugYnyWhezC/why-i-hope-certain-hedonic-utilitarians-don-t-control-the', + }, + { + title: 'Be More Succinct', + author: 'RedTeamPseudonym', + link: 'https://forum.effectivealtruism.org/posts/eNa8GpEi5HX94CZ2n/be-more-succinct', + }, + { + title: 'Against Anthropic Shadow', + author: 'tobycrisford', + link: 'https://forum.effectivealtruism.org/posts/A47EWTS6oBKLqxBpw/against-anthropic-shadow', + }, + { + title: + 'Ideological tensions between Effective Altruism and The UK Civil Service', + author: 'KZ X', + link: 'https://forum.effectivealtruism.org/posts/J7cAFqq9g9LzSe5E3/ideological-tensions-between-effective-altruism-and-the-uk', + }, + { + title: 'We’re really bad at guessing the future', + author: 'Benj Azose', + link: 'https://forum.effectivealtruism.org/posts/DkmNPpqTJKmudBHnp/we-re-really-bad-at-guessing-the-future', + }, + { + title: + 'Effective Altruism, the Principle of Explosion and Epistemic Fragility', + author: 'Eigengender', + link: 'https://forum.effectivealtruism.org/posts/zG4pnJBCMi5t49Eya/effective-altruism-the-principle-of-explosion-and-epistemic', + }, + { + title: + 'Should EA influence governments to enact more effective interventions?', + author: 'Markus Amalthea Magnuson', + link: 'https://forum.effectivealtruism.org/posts/pGPcfjxazPGFJyYHW/should-ea-influence-governments-to-enact-more-effective', + }, + { + title: 'Should large EA nonprofits consider splitting?', + author: 'Arepo', + link: 'https://forum.effectivealtruism.org/posts/J3pZ7fY6yvypvJrJE/should-large-ea-nonprofits-consider-splitting', + }, + { + title: + "Evaluating large-scale movement building: A better way to critique Open Philanthropy's criminal justice reform", + author: 'ruthgrace', + link: 'https://forum.effectivealtruism.org/posts/7ajePuRKiCo7fA92B/evaluating-large-scale-movement-building-a-better-way-to', + }, + { + title: 'Evaluation of Longtermist Institutional Reform', + author: 'Dwarkesh Patel', + link: 'https://forum.effectivealtruism.org/posts/v4Z6phNcDsdXtzj2K/evaluation-of-longtermist-institutional-reform', + }, + { + title: 'A Quick List of Some Problems in AI Alignment As A Field', + author: 'NicholasKross', + link: 'https://forum.effectivealtruism.org/posts/JFmhYuso5s9PgrQET/a-quick-list-of-some-problems-in-ai-alignment-as-a-field', + }, + { + title: 'Fixing bad incentives in EA', + author: 'IncentivesAccount', + link: 'https://forum.effectivealtruism.org/posts/3PrTiXhhNBdGtR9qf/fixing-bad-incentives-in-ea', + }, + { + title: 'The danger of good stories', + author: 'DuncanS', + link: 'https://forum.effectivealtruism.org/posts/eZK95zxwpzNySRebC/the-danger-of-good-stories', + }, + { + title: 'A dilemma for Maximize Expected Choiceworthiness (MEC)', + author: 'Calvin_Baker', + link: 'https://forum.effectivealtruism.org/posts/Gk7NhzFy2hHFdFTYr/a-dilemma-for-maximize-expected-choiceworthiness-mec', + }, + { + title: 'Should you still use the ITN framework? [Red Teaming Contest]', + author: 'frib', + link: 'https://forum.effectivealtruism.org/posts/hjH94Ji4CrpKadoCi/should-you-still-use-the-itn-framework-red-teaming-contest', + }, + { + title: 'Proposed tweak to the longtermism pitch', + author: 'TheOtherHannah', + link: 'https://forum.effectivealtruism.org/posts/nAvpSXELT2FZMD9aA/proposed-tweak-to-the-longtermism-pitch', + }, + { + title: 'EA vs. FIRE – reconciling these two movements', + author: 'Stewed_Walrus', + link: 'https://forum.effectivealtruism.org/posts/j2ccaxmHcjiwGDs9T/ea-vs-fire-reconciling-these-two-movements', + }, + { + title: 'Should young EAs really focus on career capital?', + author: 'Michael B.', + link: 'https://forum.effectivealtruism.org/posts/RYBFyDWAYZL4YCkW2/should-young-eas-really-focus-on-career-capital', + }, + { + title: 'Prediction Markets are Somewhat Overrated Within EA', + author: 'Francis', + link: 'https://forum.effectivealtruism.org/posts/4LsrNczpF6mfrHP4M/prediction-markets-are-somewhat-overrated-within-ea', + }, + { + title: 'Capitalism, power and epistemology: a critique of EA', + author: 'Matthew_Doran', + link: 'https://forum.effectivealtruism.org/posts/xWFhD6uQuZehrDKeY/capitalism-power-and-epistemology-a-critique-of-ea', + }, + { + title: 'EA Worries and Criticism ', + author: 'Connor Tabarrok', + link: 'https://forum.effectivealtruism.org/posts/A5tRZC2mduJfpMhud/ea-worries-and-criticism', + }, + { + title: 'EA criticism contest: Why I am not an effective altruist', + author: 'ErikHoel', + link: 'https://forum.effectivealtruism.org/posts/PZ6pEaNkzAg62ze69/ea-criticism-contest-why-i-am-not-an-effective-altruist', + }, + { + title: 'Nuclear Fine-Tuning: How Many Worlds Have Been Destroyed?', + author: 'Ember', + link: 'https://forum.effectivealtruism.org/posts/Gg2YsjGe3oahw2kxE/nuclear-fine-tuning-how-many-worlds-have-been-destroyed', + }, + { + title: 'An epistemic critique of longtermism', + author: 'Nathan_Barnard', + link: 'https://forum.effectivealtruism.org/posts/2455tgtiBsm5KXBfv/an-epistemic-critique-of-longtermism', + }, + { + title: 'Red Team: Write More.', + author: 'Weaver', + link: 'https://forum.effectivealtruism.org/posts/5A5cMh223b9s4uHwE/red-team-write-more', + }, + { + title: 'End-To-End Encryption For EA', + author: 'Talking Tree', + link: 'https://forum.effectivealtruism.org/posts/tekdQKdfFe3YJTwML/end-to-end-encryption-for-ea', + }, + { + title: 'Effective Altruism is Unkind', + author: 'Oliver Scott Curry', + link: 'https://forum.effectivealtruism.org/posts/cC6tGHctzrMmEAH8j/effective-altruism-is-unkind', + }, + { + title: 'Towards a more ecumenical EA movement ', + author: 'Locke', + link: 'https://forum.effectivealtruism.org/posts/NR2Y2B8Y4Wxn8pAS8/towards-a-more-ecumenical-ea-movement', + }, + { + title: + 'Effective altruism is similar to the AI alignment problem and suffers from the same difficulties [Criticism and Red Teaming Contest entry]', + author: 'turchin', + link: 'https://forum.effectivealtruism.org/posts/g8fn7oyvki4psJeYR/effective-altruism-is-similar-to-the-ai-alignment-problem', + }, + { + title: + 'The great energy descent - Part 2: Limits to growth and why we probably won’t reach the stars', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/8sW4h368DsoooHBNP/the-great-energy-descent-part-2-limits-to-growth-and-why-we', + }, + { + title: 'What a Large and Welcoming EA Could Accomplish', + author: 'Peter Elam', + link: 'https://forum.effectivealtruism.org/posts/K24widt85ZbGqzZKN/what-a-large-and-welcoming-ea-could-accomplish', + }, + { + title: 'Should we call ourselves effective altruists?', + author: 'Sam_Coggins', + link: 'https://forum.effectivealtruism.org/posts/YyDRSARnXz8r5dgca/should-we-call-ourselves-effective-altruists', + }, + { + title: 'Compounding assumptions and what it mean to be altruistic', + author: 'Badger', + link: 'https://forum.effectivealtruism.org/posts/4RGuqDxui2xWkXnda/compounding-assumptions-and-what-it-mean-to-be-altruistic', + }, + { + title: + 'The great energy descent - Post 3: What we can do, what we can’t do', + author: 'Corentin Biteau', + link: 'https://forum.effectivealtruism.org/posts/9zTLPy3zqJ7YfS7kn/the-great-energy-descent-post-3-what-we-can-do-what-we-can-t', + }, + { + title: 'Enantiodromia', + author: 'ChristianKleineidam', + link: 'https://forum.effectivealtruism.org/posts/b4ASDM434qh3rxLki/enantiodromia', + }, + { + title: 'Deontology, the Paralysis Argument and altruistic longtermism', + author: "William D'Alessandro", + link: 'https://forum.effectivealtruism.org/posts/DKe5eQhJoLNMWgaQv/deontology-the-paralysis-argument-and-altruistic-longtermism', + }, + { + title: 'Path dependence and its impact on long-term outcomes', + author: 'Archanaa', + link: 'https://forum.effectivealtruism.org/posts/jadS8deYknecGSebp/path-dependence-and-its-impact-on-long-term-outcomes', + }, + { + title: 'Histories of Value Lock-in and Ideology Critique', + author: 'clem', + link: 'https://forum.effectivealtruism.org/posts/poWd3CcGeQPas3Zbo/histories-of-value-lock-in-and-ideology-critique', + }, + { + title: 'A Case Against Strong Longtermism', + author: 'A. Wolff', + link: 'https://forum.effectivealtruism.org/posts/LADQ6dTGsQ2BBMrBv/a-case-against-strong-longtermism-1', + }, + { + title: 'The totalitarian implications of Effective Altruism', + author: 'Ed_Talks', + link: 'https://forum.effectivealtruism.org/posts/guyuidDdxNNxFegbJ/the-totalitarian-implications-of-effective-altruism-1', + }, + { + title: 'Forecasting Through Fiction', + author: 'Yitz', + link: 'https://forum.effectivealtruism.org/posts/DhJhtxMX6SdYAsWiY/forecasting-through-fiction', + }, + { + title: 'EA Undervalues Unseen Data', + author: 'tcelferact', + link: 'https://forum.effectivealtruism.org/posts/MpYPCq9dW8wovYpRY/ea-undervalues-unseen-data', + }, + { + title: 'The Happiness Maximizer:\nWhy EA is an x-risk', + author: 'Obasi Shaw', + link: 'https://forum.effectivealtruism.org/posts/ByHc6jdXF9skwevYf/the-happiness-maximizer-why-ea-is-an-x-risk', + }, + { + title: 'EA is a fight against Knightian uncertainty', + author: 'Rohit (Strange Loop)', + link: 'https://forum.effectivealtruism.org/posts/vic7EdWCGKd4fYtYd/ea-is-a-fight-against-knightian-uncertainty', + }, + { + title: + 'The Malthusian Gradient: Why some third-world interventions may be doing more harm than good', + author: 'JoePater', + link: 'https://forum.effectivealtruism.org/posts/juFzy7CWhu6ApQMAA/the-malthusian-gradient-why-some-third-world-interventions', + }, + { + title: 'The Hidden Impossibilities Of Being An Effective Altruist.', + author: 'Refined Insights ', + link: 'https://forum.effectivealtruism.org/posts/fsxEDLM2oPzSREM4G/the-hidden-impossibilities-of-being-an-effective-altruist', + }, + { + title: 'A critique of strong longtermism', + author: 'Pablo Rosado', + link: 'https://forum.effectivealtruism.org/posts/ryJys2fAz7J4vAwFC/a-critique-of-strong-longtermism', + }, + { + title: 'Making EA More Effective', + author: 'Peter Kelly', + link: 'https://forum.effectivealtruism.org/posts/Ag6bsmqxwqWTSjcHX/making-ea-more-effective', + }, + { + title: "A part of the system's apology", + author: 'Niv Cohen', + link: 'https://forum.effectivealtruism.org/posts/vNTD4mBAzfyZFJkfW/a-part-of-the-system-s-apology', + }, + { + title: 'The Wages of North-Atlantic Bias', + author: 'Sach Wry', + link: 'https://forum.effectivealtruism.org/posts/FA4tC72qAB5k37uFC/the-wages-of-north-atlantic-bias', + }, + { + title: 'Keeping it Real', + author: 'calumdavey', + link: 'https://forum.effectivealtruism.org/posts/ewEiyspZeqjZC7Yh7/keeping-it-real', + }, + { + title: 'Hobbit Manifesto', + author: 'Clay Cube', + link: 'https://forum.effectivealtruism.org/posts/3caZ7LhMsvsS7kRrz/hobbit-manifesto', + }, + { + title: + "How avoiding drastic career changes could support EA's epistemic health and long-term efficacy.", + author: 'nat goldthwaite', + link: 'https://forum.effectivealtruism.org/posts/3txJdk6ZcNmcRBjWP/how-avoiding-drastic-career-changes-could-support-ea-s', + }, + { + title: + "Present-day good intentions aren't sufficient to make the longterm future good in expectation", + author: 'trurl', + link: 'https://forum.effectivealtruism.org/posts/FBNk5ibcWwYcavkh4/present-day-good-intentions-aren-t-sufficient-to-make-the', + }, + { + title: + 'A podcast episode exploring critiques of effective altruism (with Michael Nielsen and Ajeya Cotra)', + author: 'spencerg', + link: 'https://forum.effectivealtruism.org/posts/2dHk3zBmmnNTefjWB/a-podcast-episode-exploring-critiques-of-effective-altruism', + }, + { + title: 'Follow-up: Crowdsourced Criticisms', + author: 'Hmash', + link: 'https://forum.effectivealtruism.org/posts/kXCsTDB5s7QRnWS8f/follow-up-crowdsourced-criticisms', + }, + { + title: 'Why the EA aversion to local altruistic action?', + author: 'Locke', + link: 'https://forum.effectivealtruism.org/posts/LnuuN7zuBSZvEo845/why-the-ea-aversion-to-local-altruistic-action', + }, + { + title: 'Effective Altruists and Religion: A Proposal for Experimentation', + author: 'Kbrown', + link: 'https://forum.effectivealtruism.org/posts/YA6fCNwB2c5cydrtG/effective-altruists-and-religion-a-proposal-for', + }, + { + title: + 'On the institutional critique of effective altruism: a response (mainly) to Brian Berkey ', + author: 'zzz1407', + link: 'https://forum.effectivealtruism.org/posts/GgNgnzjqceDghhozf/on-the-institutional-critique-of-effective-altruism-a-1', + }, + { + title: + 'We Can’t Do Long Term Utilitarian Calculations Until We Know if AIs Can Be Conscious or Not', + author: 'Mike20731', + link: 'https://forum.effectivealtruism.org/posts/Zsz3BYQTJjJdZd4DR/we-can-t-do-long-term-utilitarian-calculations-until-we-know', + }, + { + title: 'The ordinal utility argument against effective altruism ', + author: 'Barracuda', + link: 'https://forum.effectivealtruism.org/posts/rNYCcRLzkQtQEBnLa/the-ordinal-utility-argument-against-effective-altruism', + }, + { + title: + 'Reciprocity & the causes of diminishing returns: cause exploration submission', + link: 'https://forum.effectivealtruism.org/posts/x9towRLtvYidkXugk/reciprocity-and-the-causes-of-diminishing-returns-cause', + }, + { + title: + 'Altruism is systems change, so why isn’t EA? Constructive criticism.', + link: 'https://forum.effectivealtruism.org/posts/xZrvbwhSLmsGmHHSD/altruism-is-systems-change-so-why-isn-t-ea-constructive', + }, + { + title: 'The reasonableness of special concerns', + author: 'jwt', + link: 'https://forum.effectivealtruism.org/posts/CFGYLDgvsYQhsyZ42/the-reasonableness-of-special-concerns', + }, + { + title: 'The EA community should utilize the concept of beliefs more often', + author: 'Noah Scales', + link: 'https://forum.effectivealtruism.org/posts/9SKqeNSvAKozeMvGq/the-ea-community-should-utilize-the-concept-of-beliefs-more', + }, + { + title: 'Why bother doing the most good?', + author: 'Dov', + link: 'https://forum.effectivealtruism.org/posts/ZPcKeZbcC5SgLGLwg/why-bother-doing-the-most-good', + }, + { + title: + 'Framing EA as tending towards longtermism might be diminishing its potential impact', + author: 'Mm', + link: 'https://forum.effectivealtruism.org/posts/guGteQYvwcuDAECPA/framing-ea-as-tending-towards-longtermism-might-be', + }, + { + title: 'Bernard Williams: Ethics and the limits of impartiality', + author: 'peterhartree', + link: 'https://forum.effectivealtruism.org/posts/G6EWTrArPDf74sr3S/bernard-williams-ethics-and-the-limits-of-impartiality', + }, + { + title: 'Love and AI: Relational Brain/Mind Dynamics in AI Development', + author: 'JeffreyK', + link: 'https://forum.effectivealtruism.org/posts/MdfLn33GpNWGN7CSE/love-and-ai-relational-brain-mind-dynamics-in-ai-development', + }, + { + title: 'When 2/3rds of the world goes against you', + author: 'JeffreyK', + link: 'https://forum.effectivealtruism.org/posts/6va2EfHkQ3bTmdDyn/when-2-3rds-of-the-world-goes-against-you', + }, + { + title: 'My views on EA --> attempt to a constructive criticism', + author: 'Jin Jo', + link: 'https://forum.effectivealtruism.org/posts/LcDcqX6KWGHm3tSgr/my-views-on-ea-greater-than-attempt-to-a-constructive', + }, + { + title: 'Empirical critique of EA from another direction', + author: 'tonz', + link: 'https://forum.effectivealtruism.org/posts/nsqhmwmwZmWvFA2wb/empirical-critique-of-ea-from-another-direction', + }, + { + title: 'Critique: Cost-Benefit of Weirdness', + author: 'Mike Elias', + link: 'https://forum.effectivealtruism.org/posts/kw8ZmziAwcqPW2jt6/critique-cost-benefit-of-weirdness', + }, + { + title: 'Hits- or misses-based giving', + author: 'brb243', + link: 'https://forum.effectivealtruism.org/posts/XzawnaT4jyqpkEihz/hits-or-misses-based-giving', + }, + { + title: 'Mind your step', + author: 'Talsome', + link: 'https://forum.effectivealtruism.org/posts/rGNaz4GtWCzPbCWCB/mind-your-step', + }, + { + title: 'Against Impartial Altruism', + author: 'Sam K', + link: 'https://forum.effectivealtruism.org/posts/f5ZxK2k9gyZthHGND/against-impartial-altruism', + }, + { + title: 'Criticism of EA and longtermism', + author: 'St. Ignorant', + link: 'https://forum.effectivealtruism.org/posts/DuG8rBSAErSmSN7uE/criticism-of-ea-and-longtermism', + }, + { + title: + 'Against Longtermism: \nI welcome our robot overlords, and you should too!', + author: 'MattBall', + link: 'https://forum.effectivealtruism.org/posts/Cuu4Jjmp7QqL4a5Ls/against-longtermism-i-welcome-our-robot-overlords-and-you', + }, + { + title: 'Effective Altruism Criticisms', + author: 'Gavin Palmer (heroLFG.com)', + link: 'https://forum.effectivealtruism.org/posts/mMaAcvNLQPC3aTqB6/effective-altruism-criticisms', + }, + { + title: '"Of Human Bondage" and Morality', + author: 'Casaubon', + link: 'https://forum.effectivealtruism.org/posts/ZJnKCToBojYqqQphb/of-human-bondage-and-morality', + }, + { + title: + 'Portfolios, Locality, and Career - Three Critiques of Effective Altruism', + author: 'Philip Apps', + link: 'https://forum.effectivealtruism.org/posts/AoL2h2ZqTSNevdtRM/portfolios-locality-and-career-three-critiques-of-effective', + }, + { + title: 'Effective altruism in a non-ideal world', + author: 'Eric Kramer', + link: 'https://forum.effectivealtruism.org/posts/2nApcLJsZeABu38uW/effective-altruism-in-a-non-ideal-world', + }, + { + title: 'The future of humanity', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/nLyG65eQepKKeGbrg/the-future-of-humanity', + }, + { + title: + 'Investigating Ideology: want to earn money, help EA and/or me? Then check this out; it may be a mighty neglected cause ', + author: 'Dov', + link: 'https://forum.effectivealtruism.org/posts/twaKWNjAc4KEz3kMq/investigating-ideology-want-to-earn-money-help-ea-and-or-me', + }, + { + title: 'Accepting the Inevitability of Ambitious Egoism (”AE”)', + author: 'Dem0sthenes', + link: 'https://forum.effectivealtruism.org/posts/bQsxsaEcvxzEML9ZW/accepting-the-inevitability-of-ambitious-egoism-ae', + }, + { + title: 'Book Review: What We Owe The Future (Erik Hoel)', + author: 'ErikHoel', + link: 'https://forum.effectivealtruism.org/posts/AyPTZLTwm5hN2Kfcb/book-review-what-we-owe-the-future-erik-hoel', + }, + { + title: + 'Effective Altruism Goes Political: Normative Conflicts and Practical Judgment', + author: 'Michael Haiden', + link: 'https://forum.effectivealtruism.org/posts/aisE9yhZHuiM9Cdn7/effective-altruism-goes-political-normative-conflicts-and-1', + }, + { + title: 'Values lock-in is already happening (without AGI)', + link: 'https://forum.effectivealtruism.org/posts/ogwD28mzJy8dkwtmc/values-lock-in-is-already-happening-without-agi', + }, + { + title: 'EA Should Rename Itself', + author: 'Name Rectifier', + link: 'https://forum.effectivealtruism.org/posts/swkxLtjG9z7RY7i9x/ea-should-rename-itself', + }, + { + title: 'What we are for? Community, Correction and Scale [wip]', + author: 'Nathan Young', + link: 'https://forum.effectivealtruism.org/posts/QCv5GNcQFeH34iN2w/what-we-are-for-community-correction-and-scale-wip', + }, + { + title: '“One should love one’s neighbor more than oneself.”', + author: 'Barracuda', + link: 'https://forum.effectivealtruism.org/posts/bxbu8v83gw3MDzCBX/one-should-love-one-s-neighbor-more-than-oneself', + }, + { + title: 'Run For President', + author: 'Brian Moore', + link: 'https://forum.effectivealtruism.org/posts/ZniCnE8XhCMLeGHj8/run-for-president', + }, + { + title: 'Effective Altruism Risks Perpetuating a Harmful Worldview', + author: 'Theo Cox', + link: 'https://forum.effectivealtruism.org/posts/QRaf9iWvGbfKgWBvY/effective-altruism-risks-perpetuating-a-harmful-worldview', + }, +] diff --git a/functions/src/scripts/contest/scrape-ea.ts b/functions/src/scripts/contest/scrape-ea.ts new file mode 100644 index 00000000..c22f4ac7 --- /dev/null +++ b/functions/src/scripts/contest/scrape-ea.ts @@ -0,0 +1,55 @@ +// 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 '&' with '&' + const clean = (str: string | undefined) => str?.replace(/&/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' +) diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index 3240357e..e1330fe1 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -41,6 +41,8 @@ const createGroup = async ( anyoneCanJoin: true, totalContracts: contracts.length, totalMembers: 1, + postIds: [], + pinnedItems: [], } await groupRef.create(group) // create a GroupMemberDoc for the creator diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 6d062d40..d861dcbc 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -28,6 +28,8 @@ import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' 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 const app = express() @@ -60,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/addCommentBounty', addcommentbounty) +addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) @@ -69,6 +73,7 @@ addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/savetwitchcredentials', savetwitchcredentials) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/createpost', createpost) +addEndpointRoute('/testscheduledfunction', testscheduledfunction) app.listen(PORT) console.log(`Serving functions on port ${PORT}.`) diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts new file mode 100644 index 00000000..41aa9fe9 --- /dev/null +++ b/functions/src/test-scheduled-function.ts @@ -0,0 +1,17 @@ +import { APIError, newEndpoint } from './api' +import { sendPortfolioUpdateEmailsToAllUsers } from './weekly-portfolio-emails' +import { isProd } from './utils' + +// 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 sendPortfolioUpdateEmailsToAllUsers() + + return { success: true } + } +) diff --git a/functions/src/update-comment-bounty.ts b/functions/src/update-comment-bounty.ts new file mode 100644 index 00000000..af1d6c0a --- /dev/null +++ b/functions/src/update-comment-bounty.ts @@ -0,0 +1,162 @@ +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() diff --git a/functions/src/update-loans.ts b/functions/src/update-loans.ts index c35a0613..460398ef 100644 --- a/functions/src/update-loans.ts +++ b/functions/src/update-loans.ts @@ -12,7 +12,7 @@ import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() export const updateLoans = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .runWith({ memory: '8GB', timeoutSeconds: 540 }) // Run every day at midnight. .pubsub.schedule('0 0 * * *') .timeZone('America/Los_Angeles') diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 99f6ff1d..12f41453 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -17,38 +17,57 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' +import { Group } from '../../common/group' +import { batchedWaitAll } from '../../common/util/promise' const firestore = admin.firestore() export const updateMetrics = functions - .runWith({ memory: '4GB', timeoutSeconds: 540 }) + .runWith({ memory: '8GB', timeoutSeconds: 540 }) .pubsub.schedule('every 15 minutes') .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues(firestore.collection('groups')), - ]) + console.log('Loading users') + const users = await getValues(firestore.collection('users')) + console.log('Loading contracts') + const contracts = await getValues(firestore.collection('contracts')) + + console.log('Loading portfolio history') + const allPortfolioHistories = await getValues( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ) + + console.log('Loading groups') + const groups = await getValues(firestore.collection('groups')) + + console.log('Loading bets') + const contractBets = await batchedWaitAll( + contracts + .filter((c) => c.id) + .map( + (c) => () => + getValues( + firestore.collection('contracts').doc(c.id).collection('bets') + ) + ), + 100 + ) + const bets = contractBets.flat() + + console.log('Loading group contracts') const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( + groups.map((group) => + getValues( firestore .collection('groups') .doc(group.id) .collection('groupContracts') ) - }) + ) ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts index 1434c4f2..a01bc87e 100644 --- a/functions/src/update-stats.ts +++ b/functions/src/update-stats.ts @@ -18,7 +18,7 @@ import { average } from '../../common/util/math' const firestore = admin.firestore() -const numberOfDays = 90 +const numberOfDays = 180 const getBetsQuery = (startTime: number, endTime: number) => firestore @@ -343,6 +343,6 @@ export const updateStatsCore = async () => { } export const updateStats = functions - .runWith({ memory: '2GB', timeoutSeconds: 540 }) + .runWith({ memory: '4GB', timeoutSeconds: 540 }) .pubsub.schedule('every 60 minutes') .onRun(updateStatsCore) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 6bb8349a..efc22e53 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -170,3 +170,7 @@ export const chargeUser = ( export const getContractPath = (contract: Contract) => { return `/${contract.creatorUsername}/${contract.slug}` } + +export function contractUrl(contract: Contract) { + return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` +} diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index bec5949c..7c6f21a4 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -46,12 +46,14 @@ async function sendTrendingMarketsEmailsToAllUsers() { ? await getAllPrivateUsers() : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) // get all users that haven't unsubscribed from weekly emails - const privateUsersToSendEmailsTo = privateUsers.filter((user) => { - return ( - user.notificationPreferences.trending_markets.includes('email') && - !user.weeklyTrendingEmailSent - ) - }) + const privateUsersToSendEmailsTo = privateUsers + .filter((user) => { + return ( + user.notificationPreferences.trending_markets.includes('email') && + !user.weeklyTrendingEmailSent + ) + }) + .slice(150) // Send the emails out in batches log( 'Sending weekly trending emails to', privateUsersToSendEmailsTo.length, @@ -74,6 +76,7 @@ async function sendTrendingMarketsEmailsToAllUsers() { trendingContracts.map((c) => c.question).join('\n ') ) + // TODO: convert to Promise.all for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { log(`No email for ${privateUser.username}`) @@ -84,6 +87,9 @@ async function sendTrendingMarketsEmailsToAllUsers() { }) if (contractsAvailableToSend.length < numContractsToSend) { log('not enough new, unbet-on contracts to send to user', privateUser.id) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyTrendingEmailSent: true, + }) continue } // choose random subset of contracts to send to user diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts new file mode 100644 index 00000000..bcf6da17 --- /dev/null +++ b/functions/src/weekly-portfolio-emails.ts @@ -0,0 +1,289 @@ +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( + 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( + 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( + 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(firestore.collection('contracts').doc(contractId)) + ) + ) + ) + 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 + 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() + + 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 + ), + (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.' + ) + await firestore.collection('private-users').doc(privateUser.id).update({ + weeklyPortfolioUpdateEmailSent: true, + }) + return + } + 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) + }) + ) +} + +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 +} diff --git a/web/components/add-funds-button.tsx b/web/components/add-funds-button.tsx index 90b24b2c..b610bfee 100644 --- a/web/components/add-funds-button.tsx +++ b/web/components/add-funds-button.tsx @@ -30,10 +30,10 @@ export function AddFundsButton(props: { className?: string }) {
-
Get Manifold Dollars
+
Get Mana
- Use Manifold Dollars to trade in your favorite markets.
(Not + Buy mana (M$) to trade in your favorite markets.
(Not redeemable for cash.)
diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 2ad745a8..45c73c5e 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -5,7 +5,7 @@ import { formatMoney } from 'common/util/format' import { Col } from './layout/col' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' -import { useWindowSize } from 'web/hooks/use-window-size' +import { Row } from './layout/row' export function AmountInput(props: { amount: number | undefined @@ -34,46 +34,49 @@ export function AmountInput(props: { const isInvalid = !str || isNaN(amount) onChange(isInvalid ? undefined : amount) } - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 - return ( - - - {error && ( -
- {error === 'Insufficient balance' ? ( - <> - Not enough funds. - - Buy more? - - - ) : ( - error - )} -
- )} - + return ( + <> + + + + {error && ( +
+ {error === 'Insufficient balance' ? ( + <> + Not enough funds. + + Buy more? + + + ) : ( + error + )} +
+ )} + + ) } @@ -136,27 +139,29 @@ export function BuyAmountInput(props: { return ( <> - - {showSlider && ( - onAmountChange(parseRaw(parseInt(e.target.value)))} - className="range range-lg only-thumb z-40 mb-2 xl:hidden" - step="5" + + - )} + {showSlider && ( + onAmountChange(parseRaw(parseInt(e.target.value)))} + className="range range-lg only-thumb my-auto align-middle xl:hidden" + step="5" + /> + )} + ) } diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx deleted file mode 100644 index 131ce2a0..00000000 --- a/web/components/analytics/charts.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Point, ResponsiveLine } from '@nivo/line' -import clsx from 'clsx' -import { formatPercent } from 'common/util/format' -import dayjs from 'dayjs' -import { zip } from 'lodash' -import { useWindowSize } from 'web/hooks/use-window-size' -import { Col } from '../layout/col' - -export function DailyCountChart(props: { - startDate: number - dailyCounts: number[] - small?: boolean -}) { - const { dailyCounts, startDate, small } = props - const { width } = useWindowSize() - - const dates = dailyCounts.map((_, i) => - dayjs(startDate).add(i, 'day').toDate() - ) - - const points = zip(dates, dailyCounts).map(([date, betCount]) => ({ - x: date, - y: betCount, - })) - const data = [{ id: 'Count', data: points, color: '#11b981' }] - - const bottomAxisTicks = width && width < 600 ? 6 : undefined - - return ( -
- dayjs(date).format('MMM DD'), - }} - colors={{ datum: 'color' }} - pointSize={0} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 40 }} - sliceTooltip={({ slice }) => { - const point = slice.points[0] - return - }} - /> -
- ) -} - -export function DailyPercentChart(props: { - startDate: number - dailyPercent: number[] - small?: boolean - excludeFirstDays?: number -}) { - const { dailyPercent, startDate, small, excludeFirstDays } = props - const { width } = useWindowSize() - - const dates = dailyPercent.map((_, i) => - dayjs(startDate).add(i, 'day').toDate() - ) - - const points = zip(dates, dailyPercent) - .map(([date, percent]) => ({ - x: date, - y: percent, - })) - .slice(excludeFirstDays ?? 0) - const data = [{ id: 'Percent', data: points, color: '#11b981' }] - - const bottomAxisTicks = width && width < 600 ? 6 : undefined - - return ( -
- dayjs(date).format('MMM DD'), - }} - colors={{ datum: 'color' }} - pointSize={0} - pointBorderWidth={1} - pointBorderColor="#fff" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - margin={{ top: 20, right: 28, bottom: 22, left: 40 }} - sliceTooltip={({ slice }) => { - const point = slice.points[0] - return - }} - /> -
- ) -} - -function Tooltip(props: { point: Point; isPercent?: boolean }) { - const { point, isPercent } = props - return ( - -
- {point.serieId}{' '} - {isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)} -
-
{dayjs(point.data.x).format('MMM DD')}
- - ) -} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 3339ded5..9867abab 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -182,18 +182,16 @@ export function AnswerBetPanel(props: { - {user ? ( ) : ( diff --git a/web/components/answers/answer-resolve-panel.tsx b/web/components/answers/answer-resolve-panel.tsx index ddb7942c..57871cb8 100644 --- a/web/components/answers/answer-resolve-panel.tsx +++ b/web/components/answers/answer-resolve-panel.tsx @@ -85,17 +85,6 @@ export function AnswerResolvePanel(props: { setIsSubmitting(false) } - const resolutionButtonClass = - resolveOption === 'CANCEL' - ? 'bg-yellow-400 hover:bg-yellow-500' - : resolveOption === 'CHOOSE' && answers.length - ? 'btn-primary' - : resolveOption === 'CHOOSE_MULTIPLE' && - answers.length > 1 && - answers.every((answer) => chosenAnswers[answer] > 0) - ? 'bg-blue-400 hover:bg-blue-500' - : 'btn-disabled' - return ( @@ -129,11 +118,28 @@ export function AnswerResolvePanel(props: { Clear )} + 1 && + answers.every((answer) => chosenAnswers[answer] > 0) + ? 'blue' + : 'indigo' + } + disabled={ + !resolveOption || + (resolveOption === 'CHOOSE' && !answers.length) || + (resolveOption === 'CHOOSE_MULTIPLE' && + (!(answers.length > 1) || + !answers.every((answer) => chosenAnswers[answer] > 0))) + } onResolve={onResolve} isSubmitting={isSubmitting} - openModalButtonClass={resolutionButtonClass} - submitButtonClass={resolutionButtonClass} /> diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx deleted file mode 100644 index e4167d11..00000000 --- a/web/components/answers/answers-graph.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { DatumValue } from '@nivo/core' -import { ResponsiveLine } from '@nivo/line' -import dayjs from 'dayjs' -import { groupBy, sortBy, sumBy } from 'lodash' -import { memo } from 'react' - -import { Bet } from 'common/bet' -import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' -import { getOutcomeProbability } from 'common/calculate' -import { useWindowSize } from 'web/hooks/use-window-size' - -const NUM_LINES = 6 - -export const AnswersGraph = memo(function AnswersGraph(props: { - contract: FreeResponseContract | MultipleChoiceContract - bets: Bet[] - height?: number -}) { - const { contract, bets, height } = props - const { createdTime, resolutionTime, closeTime, answers } = contract - const now = Date.now() - - const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( - bets, - contract - ) - - const isClosed = !!closeTime && now > closeTime - const latestTime = dayjs( - resolutionTime && isClosed - ? Math.min(resolutionTime, closeTime) - : isClosed - ? closeTime - : resolutionTime ?? now - ) - - const { width } = useWindowSize() - - const isLargeWidth = !width || width > 800 - const labelLength = isLargeWidth ? 50 : 20 - - // Add a fake datapoint so the line continues to the right - const endTime = latestTime.valueOf() - - const times = sortBy([ - createdTime, - ...bets.map((bet) => bet.createdTime), - endTime, - ]) - const dateTimes = times.map((time) => new Date(time)) - - const data = sortedOutcomes.map((outcome) => { - const betProbs = probsByOutcome[outcome] - // Add extra point for contract start and end. - const probs = [0, ...betProbs, betProbs[betProbs.length - 1]] - - const points = probs.map((prob, i) => ({ - x: dateTimes[i], - y: Math.round(prob * 100), - })) - - const answer = - answers?.find((answer) => answer.id === outcome)?.text ?? 'None' - const answerText = - answer.slice(0, labelLength) + (answer.length > labelLength ? '...' : '') - - return { id: answerText, data: points } - }) - - data.reverse() - - const yTickValues = [0, 25, 50, 75, 100] - - const numXTickValues = isLargeWidth ? 5 : 2 - const startDate = dayjs(contract.createdTime) - const endDate = startDate.add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours') - : latestTime - const includeMinute = endDate.diff(startDate, 'hours') < 2 - - const multiYear = !startDate.isSame(latestTime, 'year') - const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) - - return ( -
- - formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) - } - axisBottom={{ - tickValues: numXTickValues, - format: (time) => - formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), - }} - colors={[ - '#fca5a5', // red-300 - '#a5b4fc', // indigo-300 - '#86efac', // green-300 - '#fef08a', // yellow-200 - '#fdba74', // orange-300 - '#c084fc', // purple-400 - ]} - pointSize={0} - curve="stepAfter" - enableSlices="x" - enableGridX={!!width && width >= 800} - enableArea - areaOpacity={1} - margin={{ top: 20, right: 20, bottom: 25, left: 40 }} - legends={[ - { - anchor: 'top-left', - direction: 'column', - justify: false, - translateX: isLargeWidth ? 5 : 2, - translateY: 0, - itemsSpacing: 0, - itemTextColor: 'black', - itemDirection: 'left-to-right', - itemWidth: isLargeWidth ? 288 : 138, - itemHeight: 20, - itemBackground: 'white', - itemOpacity: 0.9, - symbolSize: 12, - effects: [ - { - on: 'hover', - style: { - itemBackground: 'rgba(255, 255, 255, 1)', - itemOpacity: 1, - }, - }, - ], - }, - ]} - /> -
- ) -}) - -function formatPercent(y: DatumValue) { - return `${Math.round(+y.toString())}%` -} - -function formatTime( - now: number, - time: number, - includeYear: boolean, - includeHour: boolean, - includeMinute: boolean -) { - const d = dayjs(time) - if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) - return 'Now' - - let format: string - if (d.isSame(now, 'day')) { - format = '[Today]' - } else if (d.add(1, 'day').isSame(now, 'day')) { - format = '[Yesterday]' - } else { - format = 'MMM D' - } - - if (includeMinute) { - format += ', h:mma' - } else if (includeHour) { - format += ', ha' - } else if (includeYear) { - format += ', YYYY' - } - - return d.format(format) -} - -const computeProbsByOutcome = ( - bets: Bet[], - contract: FreeResponseContract | MultipleChoiceContract -) => { - const { totalBets, outcomeType } = contract - - const betsByOutcome = groupBy(bets, (bet) => bet.outcome) - const outcomes = Object.keys(betsByOutcome).filter((outcome) => { - const maxProb = Math.max( - ...betsByOutcome[outcome].map((bet) => bet.probAfter) - ) - return ( - (outcome !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - maxProb > 0.02 && - totalBets[outcome] > 0.000000001 - ) - }) - - const trackedOutcomes = sortBy( - outcomes, - (outcome) => -1 * getOutcomeProbability(contract, outcome) - ).slice(0, NUM_LINES) - - const probsByOutcome = Object.fromEntries( - trackedOutcomes.map((outcome) => [outcome, [] as number[]]) - ) - const sharesByOutcome = Object.fromEntries( - Object.keys(betsByOutcome).map((outcome) => [outcome, 0]) - ) - - for (const bet of bets) { - const { outcome, shares } = bet - sharesByOutcome[outcome] += shares - - const sharesSquared = sumBy( - Object.values(sharesByOutcome).map((shares) => shares ** 2) - ) - - for (const outcome of trackedOutcomes) { - probsByOutcome[outcome].push( - sharesByOutcome[outcome] ** 2 / sharesSquared - ) - } - } - - return { probsByOutcome, sortedOutcomes: trackedOutcomes } -} diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 41b7f0f9..a1cef4c3 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,4 +1,4 @@ -import { sortBy, partition, sum, uniq } from 'lodash' +import { sortBy, partition, sum } from 'lodash' import { useEffect, useState } from 'react' import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' @@ -11,7 +11,6 @@ import { AnswerItem } from './answer-item' import { CreateAnswerPanel } from './create-answer-panel' import { AnswerResolvePanel } from './answer-resolve-panel' import { Spacer } from '../layout/spacer' -import { User } from 'common/user' import { getOutcomeProbability } from 'common/calculate' import { Answer } from 'common/answer' import clsx from 'clsx' @@ -56,6 +55,11 @@ export function AnswersPanel(props: { ), ] + const answerItems = sortBy( + losingAnswers.length > 0 ? losingAnswers : sortedAnswers, + (answer) => -getOutcomeProbability(contract, answer.id) + ) + const user = useUser() const [resolveOption, setResolveOption] = useState< @@ -67,12 +71,6 @@ export function AnswersPanel(props: { const chosenTotal = sum(Object.values(chosenAnswers)) - const answerItems = getAnswerItems( - contract, - losingAnswers.length > 0 ? losingAnswers : sortedAnswers, - user - ) - const onChoose = (answerId: string, prob: number) => { if (resolveOption === 'CHOOSE') { setChosenAnswers({ [answerId]: prob }) @@ -123,28 +121,26 @@ export function AnswersPanel(props: { ))} {!resolveOption && ( -
-
- {answerItems.map((item) => ( -
-
- -
-
- ))} - - {hasZeroBetAnswers && !showAllAnswers && ( - - )} - -
-
+ + {answerItems.map((item) => ( + + ))} + {hasZeroBetAnswers && !showAllAnswers && ( + + )} + )} {answers.length <= 1 && ( @@ -175,35 +171,9 @@ export function AnswersPanel(props: { ) } -function getAnswerItems( - contract: FreeResponseContract | MultipleChoiceContract, - answers: Answer[], - user: User | undefined | null -) { - let outcomes = uniq(answers.map((answer) => answer.number.toString())) - outcomes = sortBy(outcomes, (outcome) => - getOutcomeProbability(contract, outcome) - ).reverse() - - return outcomes - .map((outcome) => { - const answer = answers.find((answer) => answer.id === outcome) as Answer - //unnecessary - return { - id: outcome, - type: 'answer' as const, - contract, - answer, - user, - } - }) - .filter((group) => group.answer) -} - function OpenAnswer(props: { contract: FreeResponseContract | MultipleChoiceContract answer: Answer - type: string }) { const { answer, contract } = props const { username, avatarUrl, name, text } = answer @@ -212,7 +182,7 @@ function OpenAnswer(props: { const [open, setOpen] = useState(false) return ( - + -
- -
+
answered
- - - - - -
- - {probPercent} - - setOpen(true)} - /> -
+ + + + {probPercent} + + setOpen(true)} + /> diff --git a/web/components/auth-context.tsx b/web/components/auth-context.tsx index d7c7b717..19ced0b2 100644 --- a/web/components/auth-context.tsx +++ b/web/components/auth-context.tsx @@ -17,7 +17,7 @@ import { setCookie } from 'web/lib/util/cookie' // Either we haven't looked up the logged in user yet (undefined), or we know // the user is not logged in (null), or we know the user is logged in. -type AuthUser = undefined | null | UserAndPrivateUser +export type AuthUser = undefined | null | UserAndPrivateUser const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index 44c37128..27861909 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -8,13 +8,14 @@ export function Avatar(props: { username?: string avatarUrl?: string noLink?: boolean - size?: number | 'xs' | 'sm' + size?: number | 'xxs' | 'xs' | 'sm' className?: string }) { const { username, noLink, size, className } = props const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) - const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 + const s = + size == 'xxs' ? 4 : size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const sizeInPx = s * 4 const onClick = @@ -40,7 +41,7 @@ export function Avatar(props: { style={{ maxWidth: `${s * 0.25}rem` }} src={avatarUrl} onClick={onClick} - alt={username} + alt={`${username ?? 'Unknown user'} avatar`} onError={() => { // If the image doesn't load, clear the avatarUrl to show the default // Mostly for localhost, when getting a 403 from googleusercontent diff --git a/web/components/award-bounty-button.tsx b/web/components/award-bounty-button.tsx new file mode 100644 index 00000000..7a69cf15 --- /dev/null +++ b/web/components/award-bounty-button.tsx @@ -0,0 +1,46 @@ +import clsx from 'clsx' +import { ContractComment } from 'common/comment' +import { useUser } from 'web/hooks/use-user' +import { awardCommentBounty } from 'web/lib/firebase/api' +import { track } from 'web/lib/service/analytics' +import { Row } from './layout/row' +import { Contract } from 'common/contract' +import { TextButton } from 'web/components/text-button' +import { COMMENT_BOUNTY_AMOUNT } from 'common/economy' +import { formatMoney } from 'common/util/format' + +export function AwardBountyButton(prop: { + comment: ContractComment + contract: Contract +}) { + const { comment, contract } = prop + + const me = useUser() + + const submit = () => { + const data = { + amount: COMMENT_BOUNTY_AMOUNT, + commentId: comment.id, + contractId: contract.id, + } + + awardCommentBounty(data) + .then((_) => { + console.log('success') + track('award comment bounty', data) + }) + .catch((reason) => console.log('Server error:', reason)) + + track('award comment bounty', data) + } + + const canUp = me && me.id !== comment.userId && contract.creatorId === me.id + if (!canUp) return
+ return ( + + + Award {formatMoney(COMMENT_BOUNTY_AMOUNT)} + + + ) +} diff --git a/web/components/bet-button.tsx b/web/components/bet-button.tsx index d207a7ab..3c401767 100644 --- a/web/components/bet-button.tsx +++ b/web/components/bet-button.tsx @@ -1,8 +1,12 @@ import { useState } from 'react' import clsx from 'clsx' -import { SimpleBetPanel } from './bet-panel' -import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' +import { BuyPanel, SimpleBetPanel } from './bet-panel' +import { + BinaryContract, + CPMMBinaryContract, + PseudoNumericContract, +} from 'common/contract' import { Modal } from './layout/modal' import { useUser } from 'web/hooks/use-user' import { useUserContractBets } from 'web/hooks/use-user-bets' @@ -10,6 +14,10 @@ import { useSaveBinaryShares } from './use-save-binary-shares' import { Col } from './layout/col' import { Button } from 'web/components/button' import { BetSignUpPrompt } from './sign-up-prompt' +import { User } from 'web/lib/firebase/users' +import { SellRow } from './sell-row' +import { useUnfilledBets } from 'web/hooks/use-bets' +import { PlayMoneyDisclaimer } from './play-money-disclaimer' /** Button that opens BetPanel in a new modal */ export default function BetButton(props: { @@ -64,7 +72,6 @@ export default function BetButton(props: { setOpen(false)} hasShares={hasYesShares || hasNoShares} /> @@ -72,3 +79,49 @@ export default function BetButton(props: { ) } + +export function BinaryMobileBetting(props: { contract: BinaryContract }) { + const { contract } = props + const user = useUser() + if (user) { + return + } else { + return ( + + + + + ) + } +} + +export function SignedInBinaryMobileBetting(props: { + contract: BinaryContract + user: User +}) { + const { contract, user } = props + const unfilledBets = useUnfilledBets(contract.id) ?? [] + + return ( + <> + + +