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 248c9745..2e9d94c4 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -62,6 +62,7 @@ export type Contract = { 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/prod.ts b/common/envs/prod.ts index 3014f4e3..b4f00b14 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -40,6 +40,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 = { diff --git a/common/txn.ts b/common/txn.ts index 2b7a32e8..25edc530 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -31,6 +31,7 @@ export type Txn = { | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS' + | 'COMMENT_BOUNTY' // Any extra data data?: { [key: string]: any } @@ -98,6 +99,25 @@ 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 + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink @@ -105,3 +125,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/functions/src/index.ts b/functions/src/index.ts index 9a8ec232..15d3b504 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -52,6 +52,7 @@ export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' export * from './close-market' +export * from 'functions/src/update-comment-bounty' import { health } from './health' import { transact } from './transact' @@ -65,6 +66,7 @@ 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' @@ -91,6 +93,8 @@ 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) @@ -127,4 +131,6 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + addCommentBounty as addcommentbounty, + awardCommentBounty as awardcommentbounty, } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 99ac6281..d861dcbc 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -29,6 +29,7 @@ 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() @@ -61,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) diff --git a/functions/src/update-comment-bounty.ts b/functions/src/update-comment-bounty.ts new file mode 100644 index 00000000..7b193079 --- /dev/null +++ b/functions/src/update-comment-bounty.ts @@ -0,0 +1,145 @@ +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 'functions/src/transact' +import { Comment } from 'common/comment' + +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 + 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.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 + }) +}) + +const firestore = admin.firestore() diff --git a/web/components/award-bounty-button.tsx b/web/components/award-bounty-button.tsx new file mode 100644 index 00000000..f746f594 --- /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' + +export function AwardBountyButton(prop: { + comment: ContractComment + contract: Contract +}) { + const { comment, contract } = prop + const { bountiesAwarded } = comment + const amountAwarded = bountiesAwarded ?? 0 + + const me = useUser() + + const submit = () => { + const data = { + amount: COMMENT_BOUNTY_AMOUNT, + commentId: comment.id, + contractId: contract.id, + } + + awardCommentBounty(data) + .then((_) => { + console.log('success') + }) + .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 + + + ) +} diff --git a/web/components/contract/add-comment-bounty.tsx b/web/components/contract/add-comment-bounty.tsx new file mode 100644 index 00000000..68f2bbf9 --- /dev/null +++ b/web/components/contract/add-comment-bounty.tsx @@ -0,0 +1,91 @@ +import { Contract } from 'common/contract' +import { useUser } from 'web/hooks/use-user' +import { useState } from 'react' +import { addCommentBounty } from 'web/lib/firebase/api' +import { track } from 'web/lib/service/analytics' +import { InfoTooltip } from 'web/components/info-tooltip' +import { BETTORS, PRESENT_BET } from 'common/user' +import { Row } from 'web/components/layout/row' +import { AmountInput } from 'web/components/amount-input' +import clsx from 'clsx' +import { formatMoney } from 'common/util/format' + +export function AddCommentBountyPanel(props: { contract: Contract }) { + const { contract } = props + const { id: contractId, slug } = contract + + const user = useUser() + + const [amount, setAmount] = useState(undefined) + const [error, setError] = useState(undefined) + const [isSuccess, setIsSuccess] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + const onAmountChange = (amount: number | undefined) => { + setIsSuccess(false) + setAmount(amount) + + // Check for errors. + if (amount !== undefined) { + if (user && user.balance < amount) { + setError('Insufficient balance') + } else if (amount < 1) { + setError('Minimum amount: ' + formatMoney(1)) + } else { + setError(undefined) + } + } + } + + const submit = () => { + if (!amount) return + + setIsLoading(true) + setIsSuccess(false) + + addCommentBounty({ amount, contractId }) + .then((_) => { + setIsSuccess(true) + setError(undefined) + setIsLoading(false) + }) + .catch((_) => setError('Server error')) + + track('add comment bounty', { amount, contractId, slug }) + } + + return ( + <> +
+ Contribute your M$ to make this market more accurate.{' '} + +
+ + + + + + + {isSuccess && amount && ( +
Success! Added {formatMoney(amount)} in bounties.
+ )} + + {isLoading &&
Processing...
} + + ) +} diff --git a/web/components/contract/bountied-contract-badge.tsx b/web/components/contract/bountied-contract-badge.tsx new file mode 100644 index 00000000..8e3e8c5b --- /dev/null +++ b/web/components/contract/bountied-contract-badge.tsx @@ -0,0 +1,9 @@ +import { CurrencyDollarIcon } from '@heroicons/react/outline' + +export function BountiedContractBadge() { + return ( + + Bounty + + ) +} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 3525b9f9..6b445fb2 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -32,6 +32,7 @@ import { PlusCircleIcon } from '@heroicons/react/solid' import { GroupLink } from 'common/group' import { Subtitle } from '../subtitle' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { BountiedContractBadge } from 'web/components/contract/bountied-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' @@ -63,6 +64,8 @@ export function MiscDetails(props: { ) : (contract?.featuredOnHomeRank ?? 0) > 0 ? ( + ) : (contract.openCommentBounties ?? 0) > 0 ? ( + ) : volume > 0 || !isNew ? ( {formatMoney(volume)} bet ) : ( diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 5187030d..df6695ed 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,7 +7,7 @@ import { capitalize } from 'lodash' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' import { contractPool, updateContract } from 'web/lib/firebase/contracts' -import { LiquidityPanel } from '../liquidity-panel' +import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' import { Title } from '../title' @@ -196,9 +196,7 @@ export function ContractInfoDialog(props: { - {contract.mechanism === 'cpmm-1' && !contract.resolution && ( - - )} + {!contract.resolution && } diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 17471796..f9462e41 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -5,7 +5,7 @@ import { FeedBet } from '../feed/feed-bets' import { FeedLiquidity } from '../feed/feed-liquidity' import { FeedAnswerCommentGroup } from '../feed/feed-answer-comment-group' import { FeedCommentThread, ContractCommentInput } from '../feed/feed-comments' -import { groupBy, sortBy } from 'lodash' +import { groupBy, sortBy, sum } from 'lodash' import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { PAST_BETS } from 'common/user' @@ -24,9 +24,13 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' import { useIsMobile } from 'web/hooks/use-is-mobile' +import { formatMoney } from 'common/lib/util/format' +import { Button } from 'web/components/button' +import { MINUTE_MS } from 'common/lib/util/time' export function ContractTabs(props: { contract: Contract; bets: Bet[] }) { const { contract, bets } = props + const { openCommentBounties } = contract const isMobile = useIsMobile() const user = useUser() @@ -53,7 +57,14 @@ export function ContractTabs(props: { contract: Contract; bets: Bet[] }) { currentPageForAnalytics={'contract'} tabs={[ { - title: 'Comments', + title: `Comments ${ + openCommentBounties + ? '(' + formatMoney(openCommentBounties) + ' Bounty)' + : '' + }`, + tooltip: openCommentBounties + ? 'The creator of this market will award bounties to good comments' + : undefined, content: , }, { @@ -78,6 +89,8 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { }) { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) + const [sort, setSort] = useState<'Newest' | 'Best'>('Best') + const me = useUser() const comments = useComments(contract.id) if (comments == null) { return @@ -130,12 +143,31 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { ) } else { - const commentsByParent = groupBy(comments, (c) => c.replyToCommentId ?? '_') + const commentsByParent = groupBy( + sortBy(comments, (c) => + sort === 'Newest' + ? -c.createdTime + : // Is this too magic? 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + c.createdTime > Date.now() - 10 * MINUTE_MS && c.userId === me?.id + ? -Infinity + : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) + ), + (c) => c.replyToCommentId ?? '_' + ) + const topLevelComments = commentsByParent['_'] ?? [] return ( <> + - {sortBy(topLevelComments, (c) => -c.createdTime).map((parent) => ( + {topLevelComments.map((parent) => ( , - }, - showWithdrawal && { - title: 'Withdraw', - content: ( - - ), - }, + (isCreator || isAdmin) && + isCPMM && { + title: (isAdmin ? '[Admin] ' : '') + 'Subsidize', + content: , + }, + showWithdrawal && + isCPMM && { + title: 'Withdraw', + content: ( + + ), + }, { + title: 'Bounty Comments', + content: , + }, + isCPMM && { title: 'Pool', content: , } diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 1b62690b..ed0c3f15 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -19,6 +19,7 @@ import { Content } from '../editor' import { Editor } from '@tiptap/react' import { UserLink } from 'web/components/user-link' import { CommentInput } from '../comment-input' +import { AwardBountyButton } from 'web/components/award-bounty-button' export type ReplyTo = { id: string; username: string } @@ -85,6 +86,7 @@ export function FeedComment(props: { commenterPositionShares, commenterPositionOutcome, createdTime, + bountiesAwarded, } = comment const betOutcome = comment.betOutcome let bought: string | undefined @@ -93,6 +95,7 @@ export function FeedComment(props: { bought = comment.betAmount >= 0 ? 'bought' : 'sold' money = formatMoney(Math.abs(comment.betAmount)) } + const totalAwarded = bountiesAwarded ?? 0 const router = useRouter() const highlighted = router.asPath.endsWith(`#${comment.id}`) @@ -162,6 +165,11 @@ export function FeedComment(props: { createdTime={createdTime} elementId={comment.id} /> + {totalAwarded > 0 && ( + + +{formatMoney(totalAwarded)} + + )}
{tips && } + {(contract.openCommentBounties ?? 0) > 0 && ( + + )} {onReplyClick && (