subsidized markets; create contract cloud function

This commit is contained in:
mantikoros 2022-01-04 23:51:26 -06:00
parent 57ee53e133
commit 7c875f80da
9 changed files with 244 additions and 94 deletions

View File

@ -0,0 +1,149 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { randomString } from './util/random-string'
import { slugify } from './util/slugify'
import { Contract } from './types/contract'
import { getUser } from './utils'
import { payUser } from '.'
import { User } from './types/user'
export const createContract = functions
.runWith({ minInstances: 1 })
.https.onCall(
async (
data: {
question: string
description: string
initialProb: number
ante?: number
closeTime?: number
},
context
) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const creator = await getUser(userId)
if (!creator) return { status: 'error', message: 'User not found' }
const { question, description, initialProb, ante, closeTime } = data
if (ante !== undefined && (ante < 0 || ante > creator.balance))
return { status: 'error', message: 'Invalid ante' }
console.log(
'creating contract for',
creator.username,
'on',
question,
'ante:',
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
creator,
question,
description,
initialProb,
ante,
closeTime
)
if (ante) await payUser([creator.id, -ante])
await contractRef.create(contract)
return { status: 'success', contract }
}
)
const getSlug = async (question: string) => {
const proposedSlug = slugify(question).substring(0, 35)
const preexistingContract = await getContractFromSlug(proposedSlug)
return preexistingContract
? proposedSlug + '-' + randomString()
: proposedSlug
}
function getNewContract(
id: string,
slug: string,
creator: User,
question: string,
description: string,
initialProb: number,
ante?: number,
closeTime?: number
) {
const { startYes, startNo, poolYes, poolNo } = calcStartPool(
initialProb,
ante
)
const contract: Contract = {
id,
slug,
outcomeType: 'BINARY',
creatorId: creator.id,
creatorName: creator.name,
creatorUsername: creator.username,
question: question.trim(),
description: description.trim(),
startPool: { YES: startYes, NO: startNo },
pool: { YES: poolYes, NO: poolNo },
totalShares: { YES: 0, NO: 0 },
totalBets: { YES: 0, NO: 0 },
isResolved: false,
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
}
if (closeTime) contract.closeTime = closeTime
return contract
}
const calcStartPool = (
initialProbInt: number,
ante?: number,
phantomAnte = 200
) => {
const p = initialProbInt / 100.0
const totalAnte = phantomAnte + (ante || 0)
const poolYes =
p === 0.5
? p * totalAnte
: -(totalAnte * (-p + Math.sqrt((-1 + p) * -p))) / (-1 + 2 * p)
const poolNo = totalAnte - poolYes
const f = phantomAnte / totalAnte
const startYes = f * poolYes
const startNo = f * poolNo
return { startYes, startNo, poolYes, poolNo }
}
const firestore = admin.firestore()
export async function getContractFromSlug(slug: string) {
const snap = await firestore
.collection('contracts')
.where('slug', '==', slug)
.get()
return snap.empty ? undefined : (snap.docs[0].data() as Contract)
}

View File

@ -6,3 +6,4 @@ export * from './keep-awake'
export * from './place-bet' export * from './place-bet'
export * from './resolve-market' export * from './resolve-market'
export * from './sell-bet' export * from './sell-bet'
export * from './create-contract'

View File

@ -222,7 +222,7 @@ const getMktPayouts = (truePool: number, contract: Contract, bets: Bet[]) => {
] ]
} }
const payUser = ([userId, payout]: [string, number]) => { export const payUser = ([userId, payout]: [string, number]) => {
return firestore.runTransaction(async (transaction) => { return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`) const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc) const userSnap = await transaction.get(userDoc)

View File

@ -4,6 +4,7 @@ export type Contract = {
creatorId: string creatorId: string
creatorName: string creatorName: string
creatorUsername: string
question: string question: string
description: string // More info about what the contract is about description: string // More info about what the contract is about

View File

@ -2,8 +2,8 @@ import { getFirestore } from '@firebase/firestore'
import { initializeApp } from 'firebase/app' import { initializeApp } from 'firebase/app'
// TODO: Reenable this when we have a way to set the Firebase db in dev // TODO: Reenable this when we have a way to set the Firebase db in dev
// export const isProd = process.env.NODE_ENV === 'production' export const isProd = process.env.NODE_ENV === 'production'
export const isProd = true // export const isProd = true
const firebaseConfig = isProd const firebaseConfig = isProd
? { ? {

View File

@ -1,67 +0,0 @@
import {
Contract,
getContractFromSlug,
pushNewContract,
} from '../firebase/contracts'
import { User } from '../firebase/users'
import { randomString } from '../util/random-string'
import { slugify } from '../util/slugify'
// consider moving to cloud function for security
export async function createContract(
question: string,
description: string,
initialProb: number,
creator: User,
closeTime?: number
) {
const proposedSlug = slugify(question).substring(0, 35)
const preexistingContract = await getContractFromSlug(proposedSlug)
const slug = preexistingContract
? proposedSlug + '-' + randomString()
: proposedSlug
const { startYes, startNo } = calcStartPool(initialProb)
const contract: Omit<Contract, 'id'> = {
slug,
outcomeType: 'BINARY',
creatorId: creator.id,
creatorName: creator.name,
creatorUsername: creator.username,
question: question.trim(),
description: description.trim(),
startPool: { YES: startYes, NO: startNo },
pool: { YES: startYes, NO: startNo },
totalShares: { YES: 0, NO: 0 },
totalBets: { YES: 0, NO: 0 },
isResolved: false,
// TODO: Set create time to Firestore timestamp
createdTime: Date.now(),
lastUpdatedTime: Date.now(),
}
if (closeTime) {
contract.closeTime = closeTime
}
return await pushNewContract(contract)
}
export function calcStartPool(initialProbInt: number, initialCapital = 200) {
const p = initialProbInt / 100.0
const startYes =
p === 0.5
? p * initialCapital
: -(initialCapital * (-p + Math.sqrt((-1 + p) * -p))) / (-1 + 2 * p)
const startNo = initialCapital - startYes
return { startYes, startNo }
}

View File

@ -1,15 +1,16 @@
import router from 'next/router' import router from 'next/router'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import clsx from 'clsx'
import dayjs from 'dayjs'
import { getFunctions, httpsCallable } from 'firebase/functions'
import { CreatorContractsList } from '../components/contracts-list' import { CreatorContractsList } from '../components/contracts-list'
import { Spacer } from '../components/layout/spacer' import { Spacer } from '../components/layout/spacer'
import { Title } from '../components/title' import { Title } from '../components/title'
import { useUser } from '../hooks/use-user' import { useUser } from '../hooks/use-user'
import { path } from '../lib/firebase/contracts' import { Contract, path } from '../lib/firebase/contracts'
import { createContract } from '../lib/service/create-contract'
import { Page } from '../components/page' import { Page } from '../components/page'
import clsx from 'clsx' import { formatMoney } from '../lib/util/format'
import dayjs from 'dayjs'
// Allow user to create a new contract // Allow user to create a new contract
export default function NewContract() { export default function NewContract() {
@ -22,29 +23,28 @@ export default function NewContract() {
const [initialProb, setInitialProb] = useState(50) const [initialProb, setInitialProb] = useState(50)
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [ante, setAnte] = useState<number | undefined>(0)
const [anteError, setAnteError] = useState('')
const [closeDate, setCloseDate] = useState('') const [closeDate, setCloseDate] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [collapsed, setCollapsed] = useState(true) const [collapsed, setCollapsed] = useState(true)
// Given a date string like '2022-04-02', const closeTime = dateToMillis(closeDate) || undefined
// return the time just before midnight on that date (in the user's local time), as millis since epoch
function dateToMillis(date: string) {
return dayjs(date)
.set('hour', 23)
.set('minute', 59)
.set('second', 59)
.valueOf()
}
const closeTime = dateToMillis(closeDate)
// We'd like this to look like "Apr 2, 2022, 23:59:59 PM PT" but timezones are hard with dayjs // We'd like this to look like "Apr 2, 2022, 23:59:59 PM PT" but timezones are hard with dayjs
const formattedCloseTime = new Date(closeTime).toString() const formattedCloseTime = closeTime ? new Date(closeTime).toString() : ''
const user = useUser()
const remainingBalance = (user?.balance || 0) - (ante || 0)
const isValid = const isValid =
initialProb > 0 && initialProb > 0 &&
initialProb < 100 && initialProb < 100 &&
question.length > 0 && question.length > 0 &&
(ante === undefined || (ante >= 0 && ante <= remainingBalance)) &&
// If set, closeTime must be in the future // If set, closeTime must be in the future
(!closeDate || closeTime > Date.now()) (!closeTime || closeTime > Date.now())
async function submit() { async function submit() {
// TODO: Tell users why their contract is invalid // TODO: Tell users why their contract is invalid
@ -52,14 +52,31 @@ export default function NewContract() {
setIsSubmitting(true) setIsSubmitting(true)
const contract = await createContract( const result: any = await createContract({
question, question,
description, description,
initialProb, initialProb,
creator, ante,
closeTime closeTime: closeTime || undefined,
) }).then((r) => r.data || {})
await router.push(path(contract))
if (result.status !== 'success') {
console.log('error creating contract', result)
return
}
await router.push(path(result.contract as Contract))
}
function onAnteChange(str: string) {
const amount = parseInt(str)
if (str && isNaN(amount)) return
setAnte(str ? amount : undefined)
if (user && user.balance < amount) setAnteError('Insufficient balance')
else setAnteError('')
} }
const descriptionPlaceholder = `e.g. This market will resolve to “Yes” if, by June 2, 2021, 11:59:59 PM ET, Paxlovid (also known under PF-07321332)...` const descriptionPlaceholder = `e.g. This market will resolve to “Yes” if, by June 2, 2021, 11:59:59 PM ET, Paxlovid (also known under PF-07321332)...`
@ -113,7 +130,7 @@ export default function NewContract() {
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Description (optional)</span> <span className="label-text">Description</span>
</label> </label>
<textarea <textarea
className="textarea w-full h-24 textarea-bordered" className="textarea w-full h-24 textarea-bordered"
@ -145,8 +162,40 @@ export default function NewContract() {
}} }}
/> />
</div> </div>
<div className="collapse-content !p-0 m-0 !bg-transparent"> <div className="collapse-content !p-0 m-0 !bg-transparent">
<div className="form-control mb-1"> <div className="form-control mb-1">
<label className="label">
<span className="label-text">Subsidize your market</span>
</label>
<label className="input-group">
<span className="text-sm bg-gray-200">M$</span>
<input
className={clsx(
'input input-bordered',
anteError && 'input-error'
)}
type="text"
placeholder="0"
maxLength={9}
value={ante ?? ''}
disabled={isSubmitting}
onChange={(e) => onAnteChange(e.target.value)}
/>
</label>
<div className="mt-3 mb-1 text-sm text-gray-400">
Remaining balance
</div>
<div>
{formatMoney(remainingBalance > 0 ? remainingBalance : 0)}
</div>
</div>
<Spacer h={4} />
<div className="form-control">
<label className="label"> <label className="label">
<span className="label-text">Close date (optional)</span> <span className="label-text">Close date (optional)</span>
</label> </label>
@ -156,6 +205,7 @@ export default function NewContract() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => setCloseDate(e.target.value || '')} onChange={(e) => setCloseDate(e.target.value || '')}
min={new Date().toISOString().split('T')[0]} min={new Date().toISOString().split('T')[0]}
disabled={isSubmitting}
value={closeDate} value={closeDate}
/> />
</div> </div>
@ -173,14 +223,17 @@ export default function NewContract() {
<div className="flex justify-end my-4"> <div className="flex justify-end my-4">
<button <button
type="submit" type="submit"
className="btn btn-primary" className={clsx(
'btn btn-primary',
isSubmitting && 'loading disabled'
)}
disabled={isSubmitting || !isValid} disabled={isSubmitting || !isValid}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
submit() submit()
}} }}
> >
Create market {isSubmitting ? 'Creating...' : 'Create market'}
</button> </button>
</div> </div>
</form> </form>
@ -194,3 +247,16 @@ export default function NewContract() {
</Page> </Page>
) )
} }
const functions = getFunctions()
export const createContract = httpsCallable(functions, 'createContract')
// Given a date string like '2022-04-02',
// return the time just before midnight on that date (in the user's local time), as millis since epoch
function dateToMillis(date: string) {
return dayjs(date)
.set('hour', 23)
.set('minute', 59)
.set('second', 59)
.valueOf()
}