Compare commits

...

13 Commits
main ... bounty

Author SHA1 Message Date
Austin Chen
2a134b3c9a Add a box for contributing to the bounty 2022-08-16 10:52:43 -07:00
Austin Chen
81f69fde21 Squash 2022-08-16 10:52:23 -07:00
Austin Chen
e3e08f449c Add to totalPrizes (not prizeTotal) 2022-08-16 10:52:07 -07:00
Austin Chen
97c0a5cfa8 Merge branch 'main' into bounty 2022-08-15 16:37:35 -07:00
Austin Chen
f73b13644f Scaffold endpoint to add to a bounty 2022-08-15 16:37:23 -07:00
Austin Chen
7e177c0ebf One more instance 2022-08-15 16:35:28 -07:00
Austin Chen
00717c90ef Add AnswerContract type to wrap FR, MCM, Bounty 2022-08-15 16:35:13 -07:00
Austin Chen
49f60cdb80 Tweak readme 2022-08-14 21:10:09 -07:00
Austin Chen
5690444135 Style the Bounty type in a few more places 2022-08-14 21:09:48 -07:00
Austin Chen
2359b5b337 Treat a bounty market like FR for now 2022-08-14 18:24:23 -07:00
Austin Chen
eec4e87da0 Configure Firebase emulator flow 2022-08-14 18:20:03 -07:00
Austin Chen
ac76094174 Add Bounty type, enable from /create 2022-08-14 17:47:27 -07:00
Austin Chen
977268e9fc Move Question field into NewContract 2022-08-14 16:12:13 -07:00
29 changed files with 354 additions and 105 deletions

View File

@ -1,3 +1,4 @@
import { JSONContent } from '@tiptap/core'
import { User } from './user' import { User } from './user'
export type Answer = { export type Answer = {
@ -12,6 +13,7 @@ export type Answer = {
avatarUrl?: string avatarUrl?: string
text: string text: string
description?: string | JSONContent
} }
export const getNoneAnswer = (contractId: string, creator: User) => { export const getNoneAnswer = (contractId: string, creator: User) => {

View File

@ -24,6 +24,7 @@ import {
FreeResponseContract, FreeResponseContract,
PseudoNumericContract, PseudoNumericContract,
MultipleChoiceContract, MultipleChoiceContract,
AnswerContract,
} from './contract' } from './contract'
import { floatingEqual } from './util/math' import { floatingEqual } from './util/math'
@ -201,9 +202,7 @@ export function getContractBetNullMetrics() {
} }
} }
export function getTopAnswer( export function getTopAnswer(contract: AnswerContract) {
contract: FreeResponseContract | MultipleChoiceContract
) {
const { answers } = contract const { answers } = contract
const top = maxBy( const top = maxBy(
answers?.map((answer) => ({ answers?.map((answer) => ({

View File

@ -10,6 +10,7 @@ export type AnyOutcomeType =
| PseudoNumeric | PseudoNumeric
| FreeResponse | FreeResponse
| Numeric | Numeric
| Bounty
export type AnyContractType = export type AnyContractType =
| (CPMM & Binary) | (CPMM & Binary)
| (CPMM & PseudoNumeric) | (CPMM & PseudoNumeric)
@ -17,6 +18,7 @@ export type AnyContractType =
| (DPM & FreeResponse) | (DPM & FreeResponse)
| (DPM & Numeric) | (DPM & Numeric)
| (DPM & MultipleChoice) | (DPM & MultipleChoice)
| (DPM & Bounty)
export type Contract<T extends AnyContractType = AnyContractType> = { export type Contract<T extends AnyContractType = AnyContractType> = {
id: string id: string
@ -64,10 +66,15 @@ export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse export type FreeResponseContract = Contract & FreeResponse
export type MultipleChoiceContract = Contract & MultipleChoice export type MultipleChoiceContract = Contract & MultipleChoice
export type BountyContract = Contract & Bounty
export type DPMContract = Contract & DPM export type DPMContract = Contract & DPM
export type CPMMContract = Contract & CPMM export type CPMMContract = Contract & CPMM
export type DPMBinaryContract = BinaryContract & DPM export type DPMBinaryContract = BinaryContract & DPM
export type CPMMBinaryContract = BinaryContract & CPMM export type CPMMBinaryContract = BinaryContract & CPMM
export type AnswerContract =
| FreeResponseContract
| MultipleChoiceContract
| BountyContract
export type DPM = { export type DPM = {
mechanism: 'dpm-2' mechanism: 'dpm-2'
@ -127,6 +134,21 @@ export type Numeric = {
resolutionValue?: number resolutionValue?: number
} }
export type Bounty = {
outcomeType: 'BOUNTY'
// One answer for each submission
answers: Answer[]
// Resolution = which answer id the bounty went to
resolution?: string | 'MKT' | 'CANCEL'
resolutions?: { [outcome: string]: number } // Used for MKT resolution
// Amount contributed to each bounty, and by who
prizes: {
[giverId: string]: number
}
totalPrizes: number
}
export type outcomeType = AnyOutcomeType['outcomeType'] export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL' export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
@ -136,6 +158,7 @@ export const OUTCOME_TYPES = [
'FREE_RESPONSE', 'FREE_RESPONSE',
'PSEUDO_NUMERIC', 'PSEUDO_NUMERIC',
'NUMERIC', 'NUMERIC',
'BOUNTY',
] as const ] as const
export const MAX_QUESTION_LENGTH = 480 export const MAX_QUESTION_LENGTH = 480

View File

@ -15,6 +15,7 @@ import {
getCpmmProbability, getCpmmProbability,
} from './calculate-cpmm' } from './calculate-cpmm'
import { import {
AnswerContract,
CPMMBinaryContract, CPMMBinaryContract,
DPMBinaryContract, DPMBinaryContract,
FreeResponseContract, FreeResponseContract,
@ -323,7 +324,7 @@ export const getNewBinaryDpmBetInfo = (
export const getNewMultiBetInfo = ( export const getNewMultiBetInfo = (
outcome: string, outcome: string,
amount: number, amount: number,
contract: FreeResponseContract | MultipleChoiceContract, contract: AnswerContract,
loanAmount: number loanAmount: number
) => { ) => {
const { pool, totalShares, totalBets } = contract const { pool, totalShares, totalBets } = contract

View File

@ -1,6 +1,7 @@
import { range } from 'lodash' import { range } from 'lodash'
import { import {
Binary, Binary,
Bounty,
Contract, Contract,
CPMM, CPMM,
DPM, DPM,
@ -45,16 +46,22 @@ export function getNewContract(
) )
const lowercaseTags = tags.map((tag) => tag.toLowerCase()) const lowercaseTags = tags.map((tag) => tag.toLowerCase())
const propsByOutcomeType = const PROPS = {
outcomeType === 'BINARY' BINARY: getBinaryCpmmProps(initialProb, ante), // getBinaryDpmProps(initialProb, ante)
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) PSEUDO_NUMERIC: getPseudoNumericCpmmProps(
: outcomeType === 'PSEUDO_NUMERIC' initialProb,
? getPseudoNumericCpmmProps(initialProb, ante, min, max, isLogScale) ante,
: outcomeType === 'NUMERIC' min,
? getNumericProps(ante, bucketCount, min, max) max,
: outcomeType === 'MULTIPLE_CHOICE' isLogScale
? getMultipleChoiceProps(ante, answers) ),
: getFreeAnswerProps(ante) FREE_RESPONSE: getFreeAnswerProps(ante),
MULTIPLE_CHOICE: getMultipleChoiceProps(ante, answers),
NUMERIC: getNumericProps(ante, bucketCount, min, max),
BOUNTY: getBountyProps(ante, creator),
}
const propsByOutcomeType = PROPS[outcomeType] || PROPS['FREE_RESPONSE']
const contract: Contract = removeUndefinedProps({ const contract: Contract = removeUndefinedProps({
id, id,
@ -157,6 +164,18 @@ const getFreeAnswerProps = (ante: number) => {
return system return system
} }
function getBountyProps(ante: number, creator: User) {
const system: DPM & Bounty = {
...getFreeAnswerProps(ante),
outcomeType: 'BOUNTY',
prizes: {
[creator.id]: ante,
},
totalPrizes: ante,
}
return system
}
const getMultipleChoiceProps = (ante: number, answers: string[]) => { const getMultipleChoiceProps = (ante: number, answers: string[]) => {
const numAnswers = answers.length const numAnswers = answers.length
const betAnte = ante / numAnswers const betAnte = ante / numAnswers

View File

@ -3,6 +3,7 @@ import { sum, groupBy, sumBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet' import { Bet, NumericBet } from './bet'
import { deductDpmFees, getDpmProbability } from './calculate-dpm' import { deductDpmFees, getDpmProbability } from './calculate-dpm'
import { import {
AnswerContract,
DPMContract, DPMContract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract, MultipleChoiceContract,
@ -184,7 +185,7 @@ export const getDpmMktPayouts = (
export const getPayoutsMultiOutcome = ( export const getPayoutsMultiOutcome = (
resolutions: { [outcome: string]: number }, resolutions: { [outcome: string]: number },
contract: FreeResponseContract | MultipleChoiceContract, contract: AnswerContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const poolTotal = sum(Object.values(contract.pool)) const poolTotal = sum(Object.values(contract.pool))

View File

@ -7,5 +7,11 @@
"firestore": { "firestore": {
"rules": "firestore.rules", "rules": "firestore.rules",
"indexes": "firestore.indexes.json" "indexes": "firestore.indexes.json"
},
"emulators": {
"firestore": {
"port": 8080,
"host": "127.0.0.1"
}
} }
} }

View File

@ -0,0 +1,55 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Contract } from '../../common/contract'
import { User } from '../../common/user'
import { APIError, newEndpoint, validate } from './api'
const firestore = admin.firestore()
const bodySchema = z.object({
contractId: z.string(),
amount: z.number().gt(0),
})
export const addbounty = newEndpoint({}, async (req, auth) => {
const { amount, contractId } = validate(bodySchema, req.body)
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 (contract.outcomeType !== 'BOUNTY')
throw new APIError(400, "Can't add bounties to non-BOUNTY contracts")
const { closeTime } = contract
if (closeTime && Date.now() > closeTime)
throw new APIError(400, 'Contract is closed')
if (user.balance < amount) throw new APIError(400, 'Insufficient balance')
// TODO: Capture with txns?
transaction.update(userDoc, {
balance: user.balance - amount,
totalDeposits: user.totalDeposits - amount,
})
const existingPrize = contract.prizes[user.id] ?? 0
transaction.update(contractDoc, {
prizes: {
...contract.prizes,
[user.id]: existingPrize + amount,
},
totalPrizes: contract.totalPrizes + amount,
})
return { status: 'success' }
})
})

View File

@ -36,7 +36,10 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') if (!contractSnap.exists) throw new APIError(400, 'Invalid contract')
const contract = contractSnap.data() as Contract const contract = contractSnap.data() as Contract
if (contract.outcomeType !== 'FREE_RESPONSE') if (
contract.outcomeType !== 'FREE_RESPONSE' &&
contract.outcomeType !== 'BOUNTY'
)
throw new APIError(400, 'Requires a free response contract') throw new APIError(400, 'Requires a free response contract')
const { closeTime, volume } = contract const { closeTime, volume } = contract

View File

@ -234,7 +234,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
) )
) )
await contractRef.update({ answers: answerObjects }) await contractRef.update({ answers: answerObjects })
} else if (outcomeType === 'FREE_RESPONSE') { } else if (outcomeType === 'FREE_RESPONSE' || outcomeType === 'BOUNTY') {
const noneAnswerDoc = firestore const noneAnswerDoc = firestore
.collection(`contracts/${contract.id}/answers`) .collection(`contracts/${contract.id}/answers`)
.doc('0') .doc('0')

View File

@ -66,6 +66,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge' import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token' import { getcustomtoken } from './get-custom-token'
import { addbounty } from './add-bounty'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any) return onRequest(opts, handler as any)
@ -91,6 +92,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser) const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge) const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken) const getCustomTokenFunction = toCloudFunction(getcustomtoken)
const addBountyFunction = toCloudFunction(addbounty)
export { export {
healthFunction as health, healthFunction as health,
@ -114,4 +116,5 @@ export {
getCurrentUserFunction as getcurrentuser, getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge, acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken, getCustomTokenFunction as getcustomtoken,
addBountyFunction as addbounty,
} }

View File

@ -2,12 +2,7 @@ import * as admin from 'firebase-admin'
import { z } from 'zod' import { z } from 'zod'
import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash'
import { import { AnswerContract, Contract, RESOLUTIONS } from '../../common/contract'
Contract,
FreeResponseContract,
MultipleChoiceContract,
RESOLUTIONS,
} from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { getUser, isProd, payUser } from './utils' import { getUser, isProd, payUser } from './utils'
@ -295,10 +290,7 @@ function getResolutionParams(contract: Contract, body: string) {
throw new APIError(500, `Invalid outcome type: ${outcomeType}`) throw new APIError(500, `Invalid outcome type: ${outcomeType}`)
} }
function validateAnswer( function validateAnswer(contract: AnswerContract, answer: number) {
contract: FreeResponseContract | MultipleChoiceContract,
answer: number
) {
const validIds = contract.answers.map((a) => a.id) const validIds = contract.answers.map((a) => a.id)
if (!validIds.includes(answer.toString())) { if (!validIds.includes(answer.toString())) {
throw new APIError(400, `${answer} is not a valid answer ID`) throw new APIError(400, `${answer} is not a valid answer ID`)

View File

@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token' import { getcustomtoken } from './get-custom-token'
import { addbounty } from './add-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express() const app = express()
@ -65,6 +66,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addJsonEndpointRoute('/addbounty', addbounty)
addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())

View File

@ -9,7 +9,7 @@ To run the development server, install [Yarn 1.x][yarn], and then in this direct
3. Your site will be available on http://localhost:3000 3. Your site will be available on http://localhost:3000
Check package.json for other command-line tasks. (e.g. `yarn dev` will point the development server at the prod Check package.json for other command-line tasks. (e.g. `yarn dev` will point the development server at the prod
database. `yarn emulate` will run against a local emulated database, if you are serving it via `yarn serve` from the database. `yarn dev:emulate` will run against a local emulated database, if you are serving it via `yarn serve` from the
[`functions/` package][functions-readme].) [`functions/` package][functions-readme].)
## Tech stack ## Tech stack

View File

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { AnswerContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input' import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, placeBet } from 'web/lib/firebase/api' import { APIError, placeBet } from 'web/lib/firebase/api'
@ -30,7 +30,7 @@ import { AlertBox } from '../alert-box'
export function AnswerBetPanel(props: { export function AnswerBetPanel(props: {
answer: Answer answer: Answer
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
closePanel: () => void closePanel: () => void
className?: string className?: string
isModal?: boolean isModal?: boolean

View File

@ -1,7 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { AnswerContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
@ -13,7 +13,7 @@ import { Linkify } from '../linkify'
export function AnswerItem(props: { export function AnswerItem(props: {
answer: Answer answer: Answer
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
showChoice: 'radio' | 'checkbox' | undefined showChoice: 'radio' | 'checkbox' | undefined
chosenProb: number | undefined chosenProb: number | undefined
totalChosenProb?: number totalChosenProb?: number

View File

@ -2,7 +2,7 @@ import clsx from 'clsx'
import { sum } from 'lodash' import { sum } from 'lodash'
import { useState } from 'react' import { useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import { AnswerContract } from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, resolveMarket } from 'web/lib/firebase/api' import { APIError, resolveMarket } from 'web/lib/firebase/api'
import { Row } from '../layout/row' import { Row } from '../layout/row'
@ -11,7 +11,7 @@ import { ResolveConfirmationButton } from '../confirmation-button'
import { removeUndefinedProps } from 'common/util/object' import { removeUndefinedProps } from 'common/util/object'
export function AnswerResolvePanel(props: { export function AnswerResolvePanel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined resolveOption: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined
setResolveOption: ( setResolveOption: (
option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined option: 'CHOOSE' | 'CHOOSE_MULTIPLE' | 'CANCEL' | undefined

View File

@ -5,14 +5,18 @@ import { groupBy, sortBy, sumBy } from 'lodash'
import { memo } from 'react' import { memo } from 'react'
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import {
AnswerContract,
FreeResponseContract,
MultipleChoiceContract,
} from 'common/contract'
import { getOutcomeProbability } from 'common/calculate' import { getOutcomeProbability } from 'common/calculate'
import { useWindowSize } from 'web/hooks/use-window-size' import { useWindowSize } from 'web/hooks/use-window-size'
const NUM_LINES = 6 const NUM_LINES = 6
export const AnswersGraph = memo(function AnswersGraph(props: { export const AnswersGraph = memo(function AnswersGraph(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
bets: Bet[] bets: Bet[]
height?: number height?: number
}) { }) {
@ -178,10 +182,7 @@ function formatTime(
return d.format(format) return d.format(format)
} }
const computeProbsByOutcome = ( const computeProbsByOutcome = (bets: Bet[], contract: AnswerContract) => {
bets: Bet[],
contract: FreeResponseContract | MultipleChoiceContract
) => {
const { totalBets, outcomeType } = contract const { totalBets, outcomeType } = contract
const betsByOutcome = groupBy(bets, (bet) => bet.outcome) const betsByOutcome = groupBy(bets, (bet) => bet.outcome)

View File

@ -1,7 +1,12 @@
import { sortBy, partition, sum, uniq } from 'lodash' import { sortBy, partition, sum, uniq } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { FreeResponseContract, MultipleChoiceContract } from 'common/contract' import {
AnswerContract,
BountyContract,
FreeResponseContract,
MultipleChoiceContract,
} from 'common/contract'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { getDpmOutcomeProbability } from 'common/calculate-dpm' import { getDpmOutcomeProbability } from 'common/calculate-dpm'
@ -25,9 +30,7 @@ import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import { BuyButton } from 'web/components/yes-no-selector' import { BuyButton } from 'web/components/yes-no-selector'
export function AnswersPanel(props: { export function AnswersPanel(props: { contract: AnswerContract }) {
contract: FreeResponseContract | MultipleChoiceContract
}) {
const { contract } = props const { contract } = props
const { creatorId, resolution, resolutions, totalBets, outcomeType } = const { creatorId, resolution, resolutions, totalBets, outcomeType } =
contract contract
@ -136,7 +139,7 @@ export function AnswersPanel(props: {
<div className="pb-4 text-gray-500">No answers yet...</div> <div className="pb-4 text-gray-500">No answers yet...</div>
)} )}
{outcomeType === 'FREE_RESPONSE' && {(outcomeType === 'FREE_RESPONSE' || outcomeType === 'BOUNTY') &&
tradingAllowed(contract) && tradingAllowed(contract) &&
(!resolveOption || resolveOption === 'CANCEL') && ( (!resolveOption || resolveOption === 'CANCEL') && (
<CreateAnswerPanel contract={contract} /> <CreateAnswerPanel contract={contract} />
@ -158,7 +161,7 @@ export function AnswersPanel(props: {
} }
function getAnswerItems( function getAnswerItems(
contract: FreeResponseContract | MultipleChoiceContract, contract: AnswerContract,
answers: Answer[], answers: Answer[],
user: User | undefined | null user: User | undefined | null
) { ) {
@ -184,7 +187,7 @@ function getAnswerItems(
} }
function OpenAnswer(props: { function OpenAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
answer: Answer answer: Answer
items: ActivityItem[] items: ActivityItem[]
type: string type: string

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract' import { BountyContract, FreeResponseContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input' import { BuyAmountInput } from '../amount-input'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { APIError, createAnswer } from 'web/lib/firebase/api' import { APIError, createAnswer } from 'web/lib/firebase/api'
@ -26,7 +26,9 @@ import { MAX_ANSWER_LENGTH } from 'common/answer'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { lowerCase } from 'lodash' import { lowerCase } from 'lodash'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { export function CreateAnswerPanel(props: {
contract: FreeResponseContract | BountyContract
}) {
const { contract } = props const { contract } = props
const user = useUser() const user = useUser()
const [text, setText] = useState('') const [text, setText] = useState('')

View File

@ -0,0 +1,73 @@
import clsx from 'clsx'
import { BountyContract } from 'common/contract'
import { User } from 'common/user'
import { useState } from 'react'
import { addBounty } from 'web/lib/firebase/api'
import { BuyAmountInput } from '../amount-input'
import { Spacer } from '../layout/spacer'
import { Title } from '../title'
export function BountyBox(props: {
className?: string
user?: User | null
contract: BountyContract
}) {
const { className, user, contract } = props
const [amount, setAmount] = useState<number | undefined>()
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | undefined>()
const donateDisabled = isSubmitting || !amount || error
const onSubmit: React.FormEventHandler = async (e) => {
if (!user || donateDisabled) return
e.preventDefault()
setIsSubmitting(true)
setError(undefined)
await addBounty({
amount,
contractId: contract.id,
})
setIsSubmitting(false)
setAmount(undefined)
}
return (
<div className={clsx(className, 'rounded-lg bg-white py-6 px-8 shadow-lg')}>
<Title text="Add to bounty" className="!mt-0" />
<form onSubmit={onSubmit}>
<label
className="mb-2 block text-sm text-gray-500"
htmlFor="donate-input"
>
Contribute
</label>
<BuyAmountInput
inputClassName="w-full max-w-none donate-input"
amount={amount}
onChange={setAmount}
error={error}
setError={setError}
/>
<Spacer h={8} />
{user && (
<button
type="submit"
className={clsx(
'btn w-full',
donateDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting && 'loading'
)}
>
Add to bounty
</button>
)}
</form>
</div>
)
}

View File

@ -1,11 +1,17 @@
import clsx from 'clsx' import clsx from 'clsx'
import Link from 'next/link' import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatLargeNumber, formatPercent } from 'common/util/format' import {
formatLargeNumber,
formatMoney,
formatPercent,
} from 'common/util/format'
import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts' import { contractPath, getBinaryProbPercent } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { import {
AnswerContract,
BinaryContract, BinaryContract,
BountyContract,
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract, MultipleChoiceContract,
@ -169,6 +175,13 @@ export function ContractCard(props: {
truncate="long" truncate="long"
/> />
)} )}
{outcomeType === 'BOUNTY' && (
<BountyValue
className="items-center self-center pr-5"
contract={contract}
/>
)}
<ProbBar contract={contract} /> <ProbBar contract={contract} />
</> </>
)} )}
@ -211,8 +224,25 @@ export function BinaryResolutionOrChance(props: {
) )
} }
export function BountyValue(props: {
contract: BountyContract
large?: boolean
className?: string
}) {
const { contract, large, className } = props
const textColor = `text-${getColor(contract)}`
return (
<Col className={clsx(large ? 'text-3xl' : 'text-2xl', className)}>
<div className={textColor}>{formatMoney(contract.totalPrizes)}</div>
<div className={clsx(textColor, large ? 'text-xl' : 'text-base')}>
bounty
</div>
</Col>
)
}
function FreeResponseTopAnswer(props: { function FreeResponseTopAnswer(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
className?: string className?: string
}) { }) {

View File

@ -32,14 +32,15 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
'userId' 'userId'
).length ).length
const typeDisplay = const TYPES = {
outcomeType === 'BINARY' BINARY: 'YES / NO',
? 'YES / NO' FREE_RESPONSE: 'Free Response',
: outcomeType === 'FREE_RESPONSE' MULTIPLE_CHOICE: 'Multiple Choice',
? 'Free response' NUMERIC: 'Numeric (deprecated)',
: outcomeType === 'MULTIPLE_CHOICE' PSEUDO_NUMERIC: 'Numeric',
? 'Multiple choice' BOUNTY: 'Bounty',
: 'Numeric' }
const typeDisplay = TYPES[outcomeType] || 'Unknown'
return ( return (
<> <>

View File

@ -10,6 +10,7 @@ import { Row } from '../layout/row'
import { Linkify } from '../linkify' import { Linkify } from '../linkify'
import { import {
BinaryResolutionOrChance, BinaryResolutionOrChance,
BountyValue,
FreeResponseResolutionOrChance, FreeResponseResolutionOrChance,
NumericResolutionOrExpectation, NumericResolutionOrExpectation,
PseudoNumericResolutionOrExpectation, PseudoNumericResolutionOrExpectation,
@ -44,7 +45,6 @@ export const ContractOverview = (props: {
<div className="text-2xl text-indigo-700 md:text-3xl"> <div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} /> <Linkify text={question} />
</div> </div>
{isBinary && ( {isBinary && (
<BinaryResolutionOrChance <BinaryResolutionOrChance
className="hidden items-end xl:flex" className="hidden items-end xl:flex"
@ -52,20 +52,24 @@ export const ContractOverview = (props: {
large large
/> />
)} )}
{isPseudoNumeric && ( {isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation <PseudoNumericResolutionOrExpectation
contract={contract} contract={contract}
className="hidden items-end xl:flex" className="hidden items-end xl:flex"
/> />
)} )}
{outcomeType === 'NUMERIC' && ( {outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation <NumericResolutionOrExpectation
contract={contract} contract={contract}
className="hidden items-end xl:flex" className="hidden items-end xl:flex"
/> />
)} )}
{outcomeType === 'BOUNTY' && (
<BountyValue
contract={contract}
className="hidden items-end xl:flex"
/>
)}
</Row> </Row>
{isBinary ? ( {isBinary ? (

View File

@ -56,7 +56,8 @@ export function ContractTabs(props: {
tips={tips} tips={tips}
user={user} user={user}
mode={ mode={
contract.outcomeType === 'FREE_RESPONSE' contract.outcomeType === 'FREE_RESPONSE' ||
contract.outcomeType === 'BOUNTY'
? 'free-response-comment-answer-groups' ? 'free-response-comment-answer-groups'
: 'comments' : 'comments'
} }

View File

@ -3,7 +3,9 @@ import { Answer } from 'common/answer'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { getValueFromBucket } from 'common/calculate-dpm' import { getValueFromBucket } from 'common/calculate-dpm'
import { import {
AnswerContract,
BinaryContract, BinaryContract,
BountyContract,
Contract, Contract,
FreeResponseContract, FreeResponseContract,
MultipleChoiceContract, MultipleChoiceContract,
@ -77,7 +79,7 @@ export function BinaryContractOutcomeLabel(props: {
} }
export function FreeResponseOutcomeLabel(props: { export function FreeResponseOutcomeLabel(props: {
contract: FreeResponseContract | MultipleChoiceContract contract: AnswerContract
resolution: string | 'CANCEL' | 'MKT' resolution: string | 'CANCEL' | 'MKT'
truncate: 'short' | 'long' | 'none' truncate: 'short' | 'long' | 'none'
answerClassName?: string answerClassName?: string

View File

@ -88,3 +88,7 @@ export function acceptChallenge(params: any) {
export function getCurrentUser(params: any) { export function getCurrentUser(params: any) {
return call(getFunctionUrl('getcurrentuser'), 'GET', params) return call(getFunctionUrl('getcurrentuser'), 'GET', params)
} }
export function addBounty(params: any) {
return call(getFunctionUrl('addbounty'), 'POST', params)
}

View File

@ -42,6 +42,7 @@ import { listUsers } from 'web/lib/firebase/users'
import { FeedComment } from 'web/components/feed/feed-comments' import { FeedComment } from 'web/components/feed/feed-comments'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { FeedBet } from 'web/components/feed/feed-bets' import { FeedBet } from 'web/components/feed/feed-bets'
import { BountyBox } from 'web/components/bounty/bounty-box'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { export async function getStaticPropz(props: {
@ -116,30 +117,38 @@ export function ContractPageSidebar(props: {
const isBinary = outcomeType === 'BINARY' const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const isNumeric = outcomeType === 'NUMERIC' const isNumeric = outcomeType === 'NUMERIC'
const isBounty = outcomeType === 'BOUNTY'
const allowTrade = tradingAllowed(contract) const allowTrade = tradingAllowed(contract)
const allowResolve = !isResolved && isCreator && !!user const allowResolve = !isResolved && isCreator && !!user
const hasSidePanel = const hasSidePanel =
(isBinary || isNumeric || isPseudoNumeric) && (allowTrade || allowResolve) (isBinary || isNumeric || isPseudoNumeric || isBounty) &&
(allowTrade || allowResolve)
if (!hasSidePanel) {
return null
}
return hasSidePanel ? ( return (
<Col className="gap-4"> <Col className="gap-4">
{allowTrade && {allowTrade &&
(isNumeric ? ( (isNumeric ? (
<NumericBetPanel className="hidden xl:flex" contract={contract} /> <NumericBetPanel className="hidden xl:flex" contract={contract} />
) : isBounty ? (
<BountyBox contract={contract} user={user ?? null} />
) : ( ) : (
<BetPanel <BetPanel
className="hidden xl:flex" className="hidden xl:flex"
contract={contract as CPMMBinaryContract} contract={contract as CPMMBinaryContract}
/> />
))} ))}
{allowResolve && {allowResolve &&
(isNumeric || isPseudoNumeric ? ( (isNumeric || isPseudoNumeric ? (
<NumericResolutionPanel creator={user} contract={contract} /> <NumericResolutionPanel creator={user} contract={contract} />
) : ( ) : isBinary ? (
<ResolutionPanel creator={user} contract={contract} /> <ResolutionPanel creator={user} contract={contract} />
))} ) : null)}
</Col> </Col>
) : null )
} }
export function ContractPageContent( export function ContractPageContent(
@ -227,7 +236,8 @@ export function ContractPageContent(
)} )}
{(outcomeType === 'FREE_RESPONSE' || {(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && ( outcomeType === 'MULTIPLE_CHOICE' ||
outcomeType === 'BOUNTY') && (
<> <>
<Spacer h={4} /> <Spacer h={4} />
<AnswersPanel contract={contract} /> <AnswersPanel contract={contract} />

View File

@ -56,12 +56,6 @@ export default function Create(props: { auth: { user: User } }) {
const { user } = props.auth const { user } = props.auth
const router = useRouter() const router = useRouter()
const params = router.query as NewQuestionParams const params = router.query as NewQuestionParams
// TODO: Not sure why Question is pulled out as its own component;
// Maybe merge into newContract and then we don't need useEffect here.
const [question, setQuestion] = useState('')
useEffect(() => {
setQuestion(params.q ?? '')
}, [params.q])
if (!router.isReady) return <div /> if (!router.isReady) return <div />
@ -76,26 +70,7 @@ export default function Create(props: { auth: { user: User } }) {
<div className="rounded-lg px-6 py-4 sm:py-0"> <div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a market" /> <Title className="!mt-0" text="Create a market" />
<form> <NewContract params={params} creator={user} />
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
Question<span className={'text-red-700'}>*</span>
</span>
</label>
<Textarea
placeholder="e.g. Will the Democrats win the 2024 US presidential election?"
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_QUESTION_LENGTH}
value={question}
onChange={(e) => setQuestion(e.target.value || '')}
/>
</div>
</form>
<Spacer h={6} />
<NewContract question={question} params={params} creator={user} />
</div> </div>
</div> </div>
</Page> </Page>
@ -103,12 +78,9 @@ export default function Create(props: { auth: { user: User } }) {
} }
// Allow user to create a new contract // Allow user to create a new contract
export function NewContract(props: { function NewContract(props: { creator: User; params?: NewQuestionParams }) {
creator: User const { creator, params } = props
question: string const [question, setQuestion] = useState('')
params?: NewQuestionParams
}) {
const { creator, question, params } = props
const { groupId, initValue } = params ?? {} const { groupId, initValue } = params ?? {}
const [outcomeType, setOutcomeType] = useState<outcomeType>( const [outcomeType, setOutcomeType] = useState<outcomeType>(
(params?.outcomeType as outcomeType) ?? 'BINARY' (params?.outcomeType as outcomeType) ?? 'BINARY'
@ -131,6 +103,24 @@ export function NewContract(props: {
} }
}) })
}, [creator.id, groupId]) }, [creator.id, groupId])
useEffect(() => {
setQuestion(params?.q ?? '')
}, [params?.q])
const questionLabel = outcomeType === 'BOUNTY' ? 'Bounty' : 'Question'
const placeholders = {
BINARY: 'e.g. Will the Democrats win the 2024 US presidential election?',
MULTIPLE_CHOICE:
'e.g. Which basketball team will be the March Madness champion?',
FREE_RESPONSE: 'e.g. What programming language should I learn next?',
NUMERIC: '', // Numeric type is deprecated
PSEUDO_NUMERIC:
'e.g. How many people will show up to Taco Tuesday this week?',
BOUNTY: 'e.g. Add Dark Mode to Manifold Markets',
}
const placeholder = placeholders[outcomeType] ?? placeholders['BINARY']
const [ante, _setAnte] = useState(FIXED_ANTE) const [ante, _setAnte] = useState(FIXED_ANTE)
// If params.closeTime is set, extract out the specified date and time // If params.closeTime is set, extract out the specified date and time
@ -255,7 +245,7 @@ export function NewContract(props: {
return ( return (
<div> <div>
<label className="label"> <label className="label">
<span className="mb-1">Answer type</span> <span className="mb-1">Market type</span>
</label> </label>
<ChoicesToggleGroup <ChoicesToggleGroup
currentChoice={outcomeType} currentChoice={outcomeType}
@ -272,6 +262,7 @@ export function NewContract(props: {
'Multiple choice': 'MULTIPLE_CHOICE', 'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE', 'Free response': 'FREE_RESPONSE',
Numeric: 'PSEUDO_NUMERIC', Numeric: 'PSEUDO_NUMERIC',
Bounty: 'BOUNTY',
}} }}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
className={'col-span-4'} className={'col-span-4'}
@ -284,6 +275,27 @@ export function NewContract(props: {
<Spacer h={6} /> <Spacer h={6} />
<form>
<div className="form-control w-full">
<label className="label">
<span className="mb-1">
{questionLabel}
<span className={'text-red-700'}>*</span>
</span>
</label>
<Textarea
placeholder={placeholder}
className="input input-bordered resize-none"
autoFocus
maxLength={MAX_QUESTION_LENGTH}
value={question}
onChange={(e) => setQuestion(e.target.value || '')}
/>
</div>
</form>
<Spacer h={6} />
{outcomeType === 'MULTIPLE_CHOICE' && ( {outcomeType === 'MULTIPLE_CHOICE' && (
<MultipleChoiceAnswers answers={answers} setAnswers={setAnswers} /> <MultipleChoiceAnswers answers={answers} setAnswers={setAnswers} />
)} )}
@ -380,7 +392,7 @@ export function NewContract(props: {
<div className="form-control mb-1 items-start"> <div className="form-control mb-1 items-start">
<label className="label mb-1 gap-2"> <label className="label mb-1 gap-2">
<span>Question closes in</span> <span>{questionLabel} closes in</span>
<InfoTooltip text="Betting will be halted after this time (local timezone)." /> <InfoTooltip text="Betting will be halted after this time (local timezone)." />
</label> </label>
<Row className={'w-full items-center gap-2'}> <Row className={'w-full items-center gap-2'}>
@ -436,7 +448,7 @@ export function NewContract(props: {
<Row className="items-end justify-between"> <Row className="items-end justify-between">
<div className="form-control mb-1 items-start"> <div className="form-control mb-1 items-start">
<label className="label mb-1 gap-2"> <label className="label mb-1 gap-2">
<span>Cost</span> <span>{outcomeType === 'BOUNTY' ? 'Initial Bounty' : 'Cost'}</span>
<InfoTooltip <InfoTooltip
text={`Cost to create your question. This amount is used to subsidize betting.`} text={`Cost to create your question. This amount is used to subsidize betting.`}
/> />