From 7c875f80da0f1feabbe5d792206b8562c4f5d74c Mon Sep 17 00:00:00 2001 From: mantikoros Date: Tue, 4 Jan 2022 23:51:26 -0600 Subject: [PATCH] subsidized markets; create contract cloud function --- functions/src/create-contract.ts | 149 ++++++++++++++++++ functions/src/index.ts | 1 + functions/src/resolve-market.ts | 2 +- functions/src/types/contract.ts | 1 + .../src}/util/random-string.ts | 0 {web/lib => functions/src}/util/slugify.ts | 0 web/lib/firebase/init.ts | 4 +- web/lib/service/create-contract.ts | 67 -------- web/pages/create.tsx | 114 +++++++++++--- 9 files changed, 244 insertions(+), 94 deletions(-) create mode 100644 functions/src/create-contract.ts rename {web/lib => functions/src}/util/random-string.ts (100%) rename {web/lib => functions/src}/util/slugify.ts (100%) delete mode 100644 web/lib/service/create-contract.ts diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts new file mode 100644 index 00000000..0b0796f6 --- /dev/null +++ b/functions/src/create-contract.ts @@ -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) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index dfb09c89..1d462422 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -6,3 +6,4 @@ export * from './keep-awake' export * from './place-bet' export * from './resolve-market' export * from './sell-bet' +export * from './create-contract' diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 4862703f..aad9d32c 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -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) => { const userDoc = firestore.doc(`users/${userId}`) const userSnap = await transaction.get(userDoc) diff --git a/functions/src/types/contract.ts b/functions/src/types/contract.ts index 2cb86ac2..816cb9f7 100644 --- a/functions/src/types/contract.ts +++ b/functions/src/types/contract.ts @@ -4,6 +4,7 @@ export type Contract = { creatorId: string creatorName: string + creatorUsername: string question: string description: string // More info about what the contract is about diff --git a/web/lib/util/random-string.ts b/functions/src/util/random-string.ts similarity index 100% rename from web/lib/util/random-string.ts rename to functions/src/util/random-string.ts diff --git a/web/lib/util/slugify.ts b/functions/src/util/slugify.ts similarity index 100% rename from web/lib/util/slugify.ts rename to functions/src/util/slugify.ts diff --git a/web/lib/firebase/init.ts b/web/lib/firebase/init.ts index 98734946..ba10545a 100644 --- a/web/lib/firebase/init.ts +++ b/web/lib/firebase/init.ts @@ -2,8 +2,8 @@ import { getFirestore } from '@firebase/firestore' import { initializeApp } from 'firebase/app' // 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 = true +export const isProd = process.env.NODE_ENV === 'production' +// export const isProd = true const firebaseConfig = isProd ? { diff --git a/web/lib/service/create-contract.ts b/web/lib/service/create-contract.ts deleted file mode 100644 index 7c7a59df..00000000 --- a/web/lib/service/create-contract.ts +++ /dev/null @@ -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 = { - 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 } -} diff --git a/web/pages/create.tsx b/web/pages/create.tsx index d14df94b..c4303148 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -1,15 +1,16 @@ import router from 'next/router' 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 { Spacer } from '../components/layout/spacer' import { Title } from '../components/title' import { useUser } from '../hooks/use-user' -import { path } from '../lib/firebase/contracts' -import { createContract } from '../lib/service/create-contract' +import { Contract, path } from '../lib/firebase/contracts' import { Page } from '../components/page' -import clsx from 'clsx' -import dayjs from 'dayjs' +import { formatMoney } from '../lib/util/format' // Allow user to create a new contract export default function NewContract() { @@ -22,29 +23,28 @@ export default function NewContract() { const [initialProb, setInitialProb] = useState(50) const [question, setQuestion] = useState('') const [description, setDescription] = useState('') + + const [ante, setAnte] = useState(0) + const [anteError, setAnteError] = useState('') const [closeDate, setCloseDate] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) const [collapsed, setCollapsed] = useState(true) - // 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() - } - const closeTime = dateToMillis(closeDate) + const closeTime = dateToMillis(closeDate) || undefined // 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 = initialProb > 0 && initialProb < 100 && question.length > 0 && + (ante === undefined || (ante >= 0 && ante <= remainingBalance)) && // If set, closeTime must be in the future - (!closeDate || closeTime > Date.now()) + (!closeTime || closeTime > Date.now()) async function submit() { // TODO: Tell users why their contract is invalid @@ -52,14 +52,31 @@ export default function NewContract() { setIsSubmitting(true) - const contract = await createContract( + const result: any = await createContract({ question, description, initialProb, - creator, - closeTime - ) - await router.push(path(contract)) + ante, + closeTime: closeTime || undefined, + }).then((r) => r.data || {}) + + 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)...` @@ -113,7 +130,7 @@ export default function NewContract() {