diff --git a/common/challenge.ts b/common/challenge.ts
new file mode 100644
index 00000000..be59a1dc
--- /dev/null
+++ b/common/challenge.ts
@@ -0,0 +1,52 @@
+export type Challenge = {
+ // The link to send: https://manifold.markets/challenges/username/market-slug/{slug}
+ // Also functions as the unique id for the link.
+ slug: string
+
+ // The user that created the challenge.
+ creatorId: string
+ creatorUsername: string
+
+ // Displayed to people claiming the challenge
+ message: string
+
+ // How much to put up
+ amount: number
+
+ // YES or NO for now
+ creatorsOutcome: string
+
+ // Different than the creator
+ yourOutcome: string
+
+ // The probability the challenger thinks
+ creatorsOutcomeProb: number
+
+ contractId: string
+ contractSlug: string
+
+ createdTime: number
+ // If null, the link is valid forever
+ expiresTime: number | null
+
+ // How many times the challenge can be used
+ maxUses: number
+
+ // Used for simpler caching
+ acceptedByUserIds: string[]
+ // Successful redemptions of the link
+ acceptances: Acceptance[]
+
+ isResolved: boolean
+ resolutionOutcome?: string
+}
+
+export type Acceptance = {
+ userId: string
+ userUsername: string
+ userName: string
+ // The ID of the successful bet that tracks the money moved
+ betId: string
+
+ createdTime: number
+}
diff --git a/common/notification.ts b/common/notification.ts
index 5fd4236b..fa4cd90a 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -37,6 +37,7 @@ export type notification_source_types =
| 'group'
| 'user'
| 'bonus'
+ | 'challenge'
export type notification_source_update_types =
| 'created'
@@ -64,3 +65,4 @@ export type notification_reason_types =
| 'tip_received'
| 'bet_fill'
| 'user_joined_from_your_group_invite'
+ | 'challenge_accepted'
diff --git a/firestore.rules b/firestore.rules
index 96378d8b..c831ced5 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -39,6 +39,17 @@ service cloud.firestore {
allow read;
}
+ match /{somePath=**}/challenges/{challengeId}{
+ allow read;
+ }
+
+ match /contracts/{contractId}/challenges/{challengeId}{
+ allow read;
+ allow create: if request.auth.uid == request.resource.data.creatorId;
+ // allow update if there have been no claims yet and if the challenge is still open
+ allow update: if request.auth.uid == resource.data.creatorId;
+ }
+
match /users/{userId}/follows/{followUserId} {
allow read;
allow write: if request.auth.uid == userId;
diff --git a/functions/src/accept-challenge.ts b/functions/src/accept-challenge.ts
new file mode 100644
index 00000000..b9b83e1c
--- /dev/null
+++ b/functions/src/accept-challenge.ts
@@ -0,0 +1,173 @@
+import { z } from 'zod'
+import { APIError, newEndpoint, validate } from './api'
+import { log } from './utils'
+import { Contract, CPMMBinaryContract } from '../../common/contract'
+import { User } from '../../common/user'
+import * as admin from 'firebase-admin'
+import { FieldValue } from 'firebase-admin/firestore'
+import { removeUndefinedProps } from '../../common/util/object'
+import { Acceptance, Challenge } from '../../common/challenge'
+import { CandidateBet } from '../../common/new-bet'
+import {
+ calculateCpmmPurchase,
+ getCpmmProbability,
+} from '../../common/calculate-cpmm'
+import { createChallengeAcceptedNotification } from './create-notification'
+
+const bodySchema = z.object({
+ contractId: z.string(),
+ challengeSlug: z.string(),
+})
+const firestore = admin.firestore()
+
+export const acceptchallenge = newEndpoint({}, async (req, auth) => {
+ log('Inside endpoint handler.')
+ const { challengeSlug, contractId } = validate(bodySchema, req.body)
+ const result = await firestore.runTransaction(async (trans) => {
+ log('Inside main transaction.')
+ const contractDoc = firestore.doc(`contracts/${contractId}`)
+ const userDoc = firestore.doc(`users/${auth.uid}`)
+ const challengeDoc = firestore.doc(
+ `contracts/${contractId}/challenges/${challengeSlug}`
+ )
+ const [contractSnap, userSnap, challengeSnap] = await trans.getAll(
+ contractDoc,
+ userDoc,
+ challengeDoc
+ )
+ if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
+ if (!userSnap.exists) throw new APIError(400, 'User not found.')
+ if (!challengeSnap.exists) throw new APIError(400, 'Challenge not found.')
+ log('Loaded user and contract snapshots.')
+
+ const anyContract = contractSnap.data() as Contract
+ const user = userSnap.data() as User
+ const challenge = challengeSnap.data() as Challenge
+
+ if (challenge.acceptances.length > 0)
+ throw new APIError(400, 'Challenge already accepted.')
+
+ const creatorDoc = firestore.doc(`users/${challenge.creatorId}`)
+ const creatorSnap = await trans.get(creatorDoc)
+ if (!creatorSnap.exists) throw new APIError(400, 'User not found.')
+ const creator = creatorSnap.data() as User
+
+ const { amount, yourOutcome, creatorsOutcome, creatorsOutcomeProb } =
+ challenge
+ if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
+
+ const { closeTime, outcomeType } = anyContract
+ if (closeTime && Date.now() > closeTime)
+ throw new APIError(400, 'Trading is closed.')
+ if (outcomeType !== 'BINARY')
+ throw new APIError(400, 'Challenges only accepted for binary markets.')
+
+ const contract = anyContract as CPMMBinaryContract
+ log('contract stats:', contract.pool, contract.p)
+ const probs = getCpmmProbability(contract.pool, contract.p)
+ log('probs:', probs)
+
+ const yourShares = (1 / (1 - creatorsOutcomeProb)) * amount
+ const yourNewBet: CandidateBet = removeUndefinedProps({
+ orderAmount: amount,
+ amount: amount,
+ shares: yourShares,
+ isCancelled: false,
+ contractId: contract.id,
+ outcome: yourOutcome,
+ probBefore: probs,
+ probAfter: probs,
+ loanAmount: 0,
+ createdTime: Date.now(),
+ fees: { creatorFee: 0, platformFee: 0, liquidityFee: 0 },
+ })
+ const yourNewBetDoc = contractDoc.collection('bets').doc()
+ trans.create(yourNewBetDoc, {
+ id: yourNewBetDoc.id,
+ userId: user.id,
+ ...yourNewBet,
+ })
+ log('Created new bet document.')
+
+ trans.update(userDoc, { balance: FieldValue.increment(-yourNewBet.amount) })
+ log('Updated user balance.')
+
+ let cpmmState = { pool: contract.pool, p: contract.p }
+ const { newPool, newP } = calculateCpmmPurchase(
+ cpmmState,
+ yourNewBet.amount,
+ yourNewBet.outcome
+ )
+ cpmmState = { pool: newPool, p: newP }
+
+ const creatorShares = (1 / creatorsOutcomeProb) * amount
+ const creatorNewBet: CandidateBet = removeUndefinedProps({
+ orderAmount: amount,
+ amount: amount,
+ shares: creatorShares,
+ isCancelled: false,
+ contractId: contract.id,
+ outcome: creatorsOutcome,
+ probBefore: probs,
+ probAfter: probs,
+ loanAmount: 0,
+ createdTime: Date.now(),
+ fees: { creatorFee: 0, platformFee: 0, liquidityFee: 0 },
+ })
+ const creatorBetDoc = contractDoc.collection('bets').doc()
+ trans.create(creatorBetDoc, {
+ id: creatorBetDoc.id,
+ userId: creator.id,
+ ...creatorNewBet,
+ })
+ log('Created new bet document.')
+
+ trans.update(creatorDoc, {
+ balance: FieldValue.increment(-creatorNewBet.amount),
+ })
+ log('Updated user balance.')
+ const newPurchaseStats = calculateCpmmPurchase(
+ cpmmState,
+ creatorNewBet.amount,
+ creatorNewBet.outcome
+ )
+ cpmmState = { pool: newPurchaseStats.newPool, p: newPurchaseStats.newP }
+
+ trans.update(
+ contractDoc,
+ removeUndefinedProps({
+ pool: cpmmState.pool,
+ // p shouldn't have changed
+ p: contract.p,
+ volume: contract.volume + yourNewBet.amount + creatorNewBet.amount,
+ })
+ )
+ log('Updated contract properties.')
+
+ trans.update(
+ challengeDoc,
+ removeUndefinedProps({
+ acceptedByUserIds: [user.id],
+ acceptances: [
+ {
+ userId: user.id,
+ betId: yourNewBetDoc.id,
+ createdTime: Date.now(),
+ userUsername: user.username,
+ userName: user.name,
+ } as Acceptance,
+ ],
+ })
+ )
+
+ await createChallengeAcceptedNotification(
+ user,
+ creator,
+ challenge,
+ contract
+ )
+ return yourNewBetDoc
+ })
+
+ return { betId: result.id }
+})
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index bf2dd28a..480d72b1 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -16,6 +16,7 @@ import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
+import { Challenge } from 'common/lib/challenge'
const firestore = admin.firestore()
type user_to_reason_texts = {
@@ -468,3 +469,34 @@ export const createReferralNotification = async (
}
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
+
+export const createChallengeAcceptedNotification = async (
+ challenger: User,
+ challengeCreator: User,
+ challenge: Challenge,
+ contract: Contract
+) => {
+ const notificationRef = firestore
+ .collection(`/users/${challengeCreator.id}/notifications`)
+ .doc()
+ const notification: Notification = {
+ id: notificationRef.id,
+ userId: challengeCreator.id,
+ reason: 'challenge_accepted',
+ createdTime: Date.now(),
+ isSeen: false,
+ sourceId: challenge.slug,
+ sourceType: 'challenge',
+ sourceUpdateType: 'updated',
+ sourceUserName: challenger.name,
+ sourceUserUsername: challenger.username,
+ sourceUserAvatarUrl: challenger.avatarUrl,
+ sourceText: challenge.amount.toString(),
+ sourceContractCreatorUsername: contract.creatorUsername,
+ sourceContractTitle: contract.question,
+ sourceContractSlug: contract.slug,
+ sourceContractId: contract.id,
+ sourceSlug: `/challenges/${challengeCreator.username}/${challenge.contractSlug}/${challenge.slug}`,
+ }
+ return await notificationRef.set(removeUndefinedProps(notification))
+}
diff --git a/functions/src/index.ts b/functions/src/index.ts
index df311886..ce6b5f35 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -42,3 +42,4 @@ export * from './create-group'
export * from './resolve-market'
export * from './unsubscribe'
export * from './stripe'
+export * from './accept-challenge'
diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx
index 0cbee7b5..081f1571 100644
--- a/web/components/bet-panel.tsx
+++ b/web/components/bet-panel.tsx
@@ -41,6 +41,7 @@ import { LimitBets } from './limit-bets'
import { BucketInput } from './bucket-input'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
+import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button'
export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
@@ -366,24 +367,27 @@ function BuyPanel(props: {
{user && (
-
+
+
+
+
)}
{wasSubmitted && (
@@ -400,29 +404,41 @@ function QuickOrLimitBet(props: {
const { isLimitOrder, setIsLimitOrder } = props
return (
-
- Bet
-
- {
- setIsLimitOrder(false)
- track('select quick order')
- }}
- >
- Quick
-
- {
- setIsLimitOrder(true)
- track('select limit order')
- }}
- >
- Limit
-
+
+
+ Bet
+
+ {
+ setIsLimitOrder(false)
+ track('select quick order')
+ }}
+ >
+ Quick
+
+ {
+ setIsLimitOrder(true)
+ track('select limit order')
+ }}
+ >
+ Limit
+
+ {
+ setIsLimitOrder(true)
+ track('select limit order')
+ }}
+ >
+ Peer
+
+
-
+
+
)
}
diff --git a/web/components/bet-row.tsx b/web/components/bet-row.tsx
index 56fff9bd..933eb02f 100644
--- a/web/components/bet-row.tsx
+++ b/web/components/bet-row.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import React, { useState } from 'react'
import clsx from 'clsx'
import { SimpleBetPanel } from './bet-panel'
@@ -8,6 +8,7 @@ import { useUser } from 'web/hooks/use-user'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { Col } from './layout/col'
+import { CreateChallengeButton } from 'web/components/challenges/create-challenge-button'
// Inline version of a bet panel. Opens BetPanel in a new modal.
export default function BetRow(props: {
@@ -48,6 +49,9 @@ export default function BetRow(props: {
: ''}
+
+
+
{
+ setErrorText('')
+ }, [open])
+
+ if (!user) return
+
+ const iAcceptChallenge = () => {
+ setLoading(true)
+ if (user.id === challenge.creatorId) {
+ setErrorText('You cannot accept your own challenge!')
+ setLoading(false)
+ return
+ }
+ acceptChallenge({
+ contractId: contract.id,
+ challengeSlug: challenge.slug,
+ })
+ .then((r) => {
+ console.log('accepted challenge. Result:', r)
+ setLoading(false)
+ })
+ .catch((e) => {
+ setLoading(false)
+ if (e instanceof APIError) {
+ setErrorText(e.toString())
+ } else {
+ console.error(e)
+ setErrorText('Error accepting challenge')
+ }
+ })
+ }
+
+ return (
+ <>
+ setOpen(newOpen)} size={'sm'}>
+
+
+
+
+
+
+
+ Cost to you:{' '}
+
+ {formatMoney(challenge.amount)}
+
+
+ {/**/}
+ {/* Probability:{' '}*/}
+ {/* */}
+ {/* {' '}*/}
+ {/* {Math.round(yourProb * 100) + '%'}*/}
+ {/* */}
+ {/*
*/}
+
+
+
+ Potential payout:
+ {' '}
+
+
+ {formatMoney(challenge.amount / yourProb)}
+
+ {/**/}
+
+
+
+
+
+
+
+
+
+ {errorText}
+
+
+
+
+
+ {challenge.creatorId != user.id && (
+
+ )}
+ >
+ )
+}
diff --git a/web/components/challenges/create-challenge-button.tsx b/web/components/challenges/create-challenge-button.tsx
new file mode 100644
index 00000000..740f502d
--- /dev/null
+++ b/web/components/challenges/create-challenge-button.tsx
@@ -0,0 +1,267 @@
+import clsx from 'clsx'
+import { useState } from 'react'
+import { Col } from '../layout/col'
+import { Row } from '../layout/row'
+import { Title } from '../title'
+import { User } from 'common/user'
+import { Modal } from 'web/components/layout/modal'
+import dayjs from 'dayjs'
+import { Button } from '../button'
+import { DuplicateIcon } from '@heroicons/react/outline'
+import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
+import { Contract } from 'common/contract'
+import { track } from 'web/lib/service/analytics'
+import { CopyLinkButton } from 'web/components/copy-link-button'
+import { getOutcomeProbability } from 'common/lib/calculate'
+import { SiteLink } from 'web/components/site-link'
+
+type challengeInfo = {
+ amount: number
+ expiresTime: number | null
+ message: string
+ outcome: 'YES' | 'NO' | number
+ prob: number
+}
+export function CreateChallengeButton(props: {
+ user: User | null | undefined
+ contract: Contract
+}) {
+ const { user, contract } = props
+ const [open, setOpen] = useState(false)
+ const [highlightedSlug, setHighlightedSlug] = useState('')
+
+ return (
+ <>
+ setOpen(newOpen)}>
+
+ {/*// add a sign up to challenge button?*/}
+ {user && (
+ {
+ const challenge = await createChallenge({
+ creator: user,
+ amount: newChallenge.amount,
+ expiresTime: newChallenge.expiresTime,
+ message: newChallenge.message,
+ prob: newChallenge.prob / 100,
+ outcome: newChallenge.outcome,
+ contract: contract,
+ })
+ challenge && setHighlightedSlug(getChallengeUrl(challenge))
+ }}
+ highlightedSlug={highlightedSlug}
+ />
+ )}
+
+
+
+
+ >
+ )
+}
+
+function CreateChallengeForm(props: {
+ user: User
+ contract: Contract
+ onCreate: (m: challengeInfo) => Promise
+ highlightedSlug: string
+}) {
+ const { user, onCreate, contract, highlightedSlug } = props
+ const [isCreating, setIsCreating] = useState(false)
+ const [finishedCreating, setFinishedCreating] = useState(false)
+ const [copyPressed, setCopyPressed] = useState(false)
+ setTimeout(() => setCopyPressed(false), 300)
+ const defaultExpire = 'week'
+ const isBinary = contract.outcomeType === 'BINARY'
+ const isNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
+
+ const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}`
+
+ const [challengeInfo, setChallengeInfo] = useState({
+ expiresTime: dayjs().add(2, defaultExpire).valueOf(),
+ outcome: 'YES',
+ amount: 100,
+ prob: Math.round(getOutcomeProbability(contract, 'YES') * 100),
+ message: defaultMessage,
+ })
+
+ return (
+ <>
+ {!finishedCreating && (
+
+ )}
+ {finishedCreating && (
+ <>
+
+
+
+ {highlightedSlug}
+
+
+ {
+ setCopyPressed(true)
+ track('copy share challenge')
+ }}
+ buttonClassName="btn-sm rounded-l-none"
+ toastClassName={'-left-40 -top-20 mt-1'}
+ icon={DuplicateIcon}
+ label={''}
+ />
+
+
+ See your other
+
+ challenges
+
+
+ >
+ )}
+ >
+ )
+}
diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx
index ab6dd66f..fb0afdf5 100644
--- a/web/components/copy-link-button.tsx
+++ b/web/components/copy-link-button.tsx
@@ -2,32 +2,26 @@ import React, { Fragment } from 'react'
import { LinkIcon } from '@heroicons/react/outline'
import { Menu, Transition } from '@headlessui/react'
import clsx from 'clsx'
-
-import { Contract } from 'common/contract'
-import { copyToClipboard } from 'web/lib/util/copy'
-import { contractPath } from 'web/lib/firebase/contracts'
-import { ENV_CONFIG } from 'common/envs/constants'
import { ToastClipboard } from 'web/components/toast-clipboard'
-import { track } from 'web/lib/service/analytics'
-
-function copyContractUrl(contract: Contract) {
- copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`)
-}
+import { copyToClipboard } from 'web/lib/util/copy'
export function CopyLinkButton(props: {
- contract: Contract
+ link: string
+ onCopy?: () => void
buttonClassName?: string
toastClassName?: string
+ icon?: React.ComponentType<{ className?: string }>
+ label?: string
}) {
- const { contract, buttonClassName, toastClassName } = props
+ const { onCopy, link, buttonClassName, toastClassName, label } = props
return (