* Simple limit order UI * Update bet schema * Restrict bet panel / bet row to only CPMMBinaryContracts (all binary DPM are resolved) * Limit orders partway implemented * Update follow leaderboard copy * Change cpmm code to take some state instead of whole contract * Write more of matching algorithm * Fill in more of placebet * Use client side contract search for emulator * More correct matching * Merge branch 'main' into limit-orders * Some cleanup * Listen for unfilled bets in bet panel. Calculate how the probability moves based on open limit orders. * Simpler switching between bet & limit bet. * Render your open bets (unfilled limit orders) * Cancel bet endpoint. * Fix build error * Rename open bets to limit bets. Tweak payout calculation * Limit probability selector to 1-99 * Deduct user balance only on each fill. Store orderAmount of bet. Timestamp of fills. * Use floating equal to check if have shares * Add limit order switcher to mobile bet dialog * Support limit orders on numeric markets * Allow CORS exception for Vercel deployments * Remove console.logs * Update user balance by new bet amount * Tweak vercel cors * Try another regexp for vercel cors * Test another vercel regex * Slight notifications refactor * Fix docs edit link (#624) * Fix docs edit link * Update github links * Small groups UX changes * Groups UX on mobile * Leaderboards => Rankings on groups * Unused vars * create: remove automatic setting of log scale * Use react-query to cache notifications (#625) * Use react-query to cache notifications * Fix imports * Cleanup * Limit unseen notifs query * Catch the bounced query * Don't use interval * Unused var * Avoid flash of page nav * Give notification question priority & 2 lines * Right justify timestamps * Rewording * Margin * Simplify error msg * Be explicit about limit for unseen notifs * Pass limit > 0 * Remove category filters * Remove category selector references * Track notification clicks * Analyze tab usage * Bold more on new group chats * Add API route for listing a bets by user (#567) * Add API route for getting a user's bets * Refactor bets API to use /bets * Update /markets to use zod validation * Update docs * Clone missing indexes from firestore * Minor notif spacing adjustments * Enable tipping on group chats w/ notif (#629) * Tweak cors regex for vercel * Your limit bets * Implement selling shares * Merge branch 'main' into limit-orders * Fix lint * Move binary search to util file * Add note that there might be closed form * Add tooltip to explain limit probability * Tweak * Cancel your limit orders if you run out of money * Don't show amount error in probability input * Require limit prob to be >= .1% and <= 99.9% * Fix focus input bug * Simplify mobile betting dialog * Move mobile limit bets list into bet dialog. * Small fixes to existing sell shares client * Lint * Refactor useSaveShares to actually read from localStorage, use less bug-prone interface. * Fix NaN error * Remove TODO * Simple bet fill notification * Tweak wording * Sort limit bets by limit prob * Padding on limit bets * Match header size Co-authored-by: Ian Philips <iansphilips@gmail.com> Co-authored-by: ahalekelly <ahalekelly@gmail.com> Co-authored-by: mantikoros <sgrugett@gmail.com> Co-authored-by: Ben Congdon <ben@congdon.dev> Co-authored-by: Austin Chen <akrolsmir@gmail.com>
		
			
				
	
	
		
			208 lines
		
	
	
		
			6.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			208 lines
		
	
	
		
			6.3 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import * as admin from 'firebase-admin'
 | |
| import { z } from 'zod'
 | |
| import {
 | |
|   DocumentReference,
 | |
|   FieldValue,
 | |
|   Query,
 | |
|   Transaction,
 | |
| } from 'firebase-admin/firestore'
 | |
| import { groupBy, mapValues, sumBy } from 'lodash'
 | |
| 
 | |
| import { APIError, newEndpoint, validate } from './api'
 | |
| import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
 | |
| import { User } from '../../common/user'
 | |
| import {
 | |
|   BetInfo,
 | |
|   getBinaryCpmmBetInfo,
 | |
|   getNewMultiBetInfo,
 | |
|   getNumericBetsInfo,
 | |
| } from '../../common/new-bet'
 | |
| import { addObjects, removeUndefinedProps } from '../../common/util/object'
 | |
| import { LimitBet } from '../../common/bet'
 | |
| import { floatingEqual } from '../../common/util/math'
 | |
| import { redeemShares } from './redeem-shares'
 | |
| import { log } from './utils'
 | |
| 
 | |
| const bodySchema = z.object({
 | |
|   contractId: z.string(),
 | |
|   amount: z.number().gte(1),
 | |
| })
 | |
| 
 | |
| const binarySchema = z.object({
 | |
|   outcome: z.enum(['YES', 'NO']),
 | |
|   limitProb: z.number().gte(0.001).lte(0.999).optional(),
 | |
| })
 | |
| 
 | |
| const freeResponseSchema = z.object({
 | |
|   outcome: z.string(),
 | |
| })
 | |
| 
 | |
| const numericSchema = z.object({
 | |
|   outcome: z.string(),
 | |
|   value: z.number(),
 | |
| })
 | |
| 
 | |
| export const placebet = newEndpoint({}, async (req, auth) => {
 | |
|   log('Inside endpoint handler.')
 | |
|   const { amount, 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 [contractSnap, userSnap] = await trans.getAll(contractDoc, userDoc)
 | |
|     if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
 | |
|     if (!userSnap.exists) throw new APIError(400, 'User not found.')
 | |
|     log('Loaded user and contract snapshots.')
 | |
| 
 | |
|     const contract = contractSnap.data() as Contract
 | |
|     const user = userSnap.data() as User
 | |
|     if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
 | |
| 
 | |
|     const loanAmount = 0
 | |
|     const { closeTime, outcomeType, mechanism, collectedFees, volume } =
 | |
|       contract
 | |
|     if (closeTime && Date.now() > closeTime)
 | |
|       throw new APIError(400, 'Trading is closed.')
 | |
| 
 | |
|     const {
 | |
|       newBet,
 | |
|       newPool,
 | |
|       newTotalShares,
 | |
|       newTotalBets,
 | |
|       newTotalLiquidity,
 | |
|       newP,
 | |
|       makers,
 | |
|     } = await (async (): Promise<
 | |
|       BetInfo & {
 | |
|         makers?: maker[]
 | |
|       }
 | |
|     > => {
 | |
|       if (
 | |
|         (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
 | |
|         mechanism == 'cpmm-1'
 | |
|       ) {
 | |
|         const { outcome, limitProb } = validate(binarySchema, req.body)
 | |
| 
 | |
|         const unfilledBetsSnap = await trans.get(
 | |
|           getUnfilledBetsQuery(contractDoc)
 | |
|         )
 | |
|         const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
 | |
| 
 | |
|         return getBinaryCpmmBetInfo(
 | |
|           outcome,
 | |
|           amount,
 | |
|           contract,
 | |
|           limitProb,
 | |
|           unfilledBets
 | |
|         )
 | |
|       } else if (outcomeType == 'FREE_RESPONSE' && mechanism == 'dpm-2') {
 | |
|         const { outcome } = validate(freeResponseSchema, req.body)
 | |
|         const answerDoc = contractDoc.collection('answers').doc(outcome)
 | |
|         const answerSnap = await trans.get(answerDoc)
 | |
|         if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
 | |
|         return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
 | |
|       } else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
 | |
|         const { outcome, value } = validate(numericSchema, req.body)
 | |
|         return getNumericBetsInfo(value, outcome, amount, contract)
 | |
|       } else {
 | |
|         throw new APIError(500, 'Contract has invalid type/mechanism.')
 | |
|       }
 | |
|     })()
 | |
|     log('Calculated new bet information.')
 | |
| 
 | |
|     if (
 | |
|       mechanism == 'cpmm-1' &&
 | |
|       (!newP ||
 | |
|         !isFinite(newP) ||
 | |
|         Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
 | |
|     ) {
 | |
|       throw new APIError(400, 'Bet too large for current liquidity pool.')
 | |
|     }
 | |
| 
 | |
|     const betDoc = contractDoc.collection('bets').doc()
 | |
|     trans.create(betDoc, { id: betDoc.id, userId: user.id, ...newBet })
 | |
|     log('Created new bet document.')
 | |
| 
 | |
|     if (makers) {
 | |
|       updateMakers(makers, betDoc.id, contractDoc, trans)
 | |
|     }
 | |
| 
 | |
|     trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
 | |
|     log('Updated user balance.')
 | |
|     trans.update(
 | |
|       contractDoc,
 | |
|       removeUndefinedProps({
 | |
|         pool: newPool,
 | |
|         p: newP,
 | |
|         totalShares: newTotalShares,
 | |
|         totalBets: newTotalBets,
 | |
|         totalLiquidity: newTotalLiquidity,
 | |
|         collectedFees: addObjects(newBet.fees, collectedFees),
 | |
|         volume: volume + newBet.amount,
 | |
|       })
 | |
|     )
 | |
|     log('Updated contract properties.')
 | |
| 
 | |
|     return { betId: betDoc.id }
 | |
|   })
 | |
| 
 | |
|   log('Main transaction finished.')
 | |
|   await redeemShares(auth.uid, contractId)
 | |
|   log('Share redemption transaction finished.')
 | |
|   return result
 | |
| })
 | |
| 
 | |
| const firestore = admin.firestore()
 | |
| 
 | |
| export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
 | |
|   return contractDoc
 | |
|     .collection('bets')
 | |
|     .where('isFilled', '==', false)
 | |
|     .where('isCancelled', '==', false) as Query<LimitBet>
 | |
| }
 | |
| 
 | |
| type maker = {
 | |
|   bet: LimitBet
 | |
|   amount: number
 | |
|   shares: number
 | |
|   timestamp: number
 | |
| }
 | |
| export const updateMakers = (
 | |
|   makers: maker[],
 | |
|   takerBetId: string,
 | |
|   contractDoc: DocumentReference,
 | |
|   trans: Transaction
 | |
| ) => {
 | |
|   const makersByBet = groupBy(makers, (maker) => maker.bet.id)
 | |
|   for (const makers of Object.values(makersByBet)) {
 | |
|     const bet = makers[0].bet
 | |
|     const newFills = makers.map((maker) => {
 | |
|       const { amount, shares, timestamp } = maker
 | |
|       return { amount, shares, matchedBetId: takerBetId, timestamp }
 | |
|     })
 | |
|     const fills = [...bet.fills, ...newFills]
 | |
|     const totalShares = sumBy(fills, 'shares')
 | |
|     const totalAmount = sumBy(fills, 'amount')
 | |
|     const isFilled = floatingEqual(totalAmount, bet.orderAmount)
 | |
| 
 | |
|     log('Updated a matched limit bet.')
 | |
|     trans.update(contractDoc.collection('bets').doc(bet.id), {
 | |
|       fills,
 | |
|       isFilled,
 | |
|       amount: totalAmount,
 | |
|       shares: totalShares,
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   // Deduct balance of makers.
 | |
|   const spentByUser = mapValues(
 | |
|     groupBy(makers, (maker) => maker.bet.userId),
 | |
|     (makers) => sumBy(makers, (maker) => maker.amount)
 | |
|   )
 | |
|   for (const [userId, spent] of Object.entries(spentByUser)) {
 | |
|     const userDoc = firestore.collection('users').doc(userId)
 | |
|     trans.update(userDoc, { balance: FieldValue.increment(-spent) })
 | |
|   }
 | |
| }
 |