Merge branch 'main' into austin/dc-hackathon

This commit is contained in:
Austin Chen 2022-10-07 22:18:02 -04:00
commit 69a785fd74
30 changed files with 496 additions and 170 deletions

View File

@ -0,0 +1,17 @@
name: Merge main into main2 on every commit
on:
push:
branches:
- 'main'
jobs:
merge-branch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Merge main -> main2
uses: devmasx/merge-branch@master
with:
type: now
target_branch: main2
github_token: ${{ github.token }}

View File

@ -147,7 +147,8 @@ function calculateAmountToBuyShares(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
// Search for amount between bounds (0, shares).
// Min share price is M$0, and max is M$1 each.
@ -157,7 +158,8 @@ function calculateAmountToBuyShares(
amount,
state,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
const totalShares = sumBy(takers, (taker) => taker.shares)
@ -169,7 +171,8 @@ export function calculateCpmmSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
if (Math.round(shares) < 0) {
throw new Error('Cannot sell non-positive shares')
@ -180,15 +183,17 @@ export function calculateCpmmSale(
state,
shares,
oppositeOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
const { cpmmState, makers, takers, totalFees } = computeFills(
const { cpmmState, makers, takers, totalFees, ordersToCancel } = computeFills(
oppositeOutcome,
buyAmount,
state,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
// Transform buys of opposite outcome into sells.
@ -211,6 +216,7 @@ export function calculateCpmmSale(
fees: totalFees,
makers,
takers: saleTakers,
ordersToCancel,
}
}
@ -218,9 +224,16 @@ export function getCpmmProbabilityAfterSale(
state: CpmmState,
shares: number,
outcome: 'YES' | 'NO',
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
const { cpmmState } = calculateCpmmSale(state, shares, outcome, unfilledBets)
const { cpmmState } = calculateCpmmSale(
state,
shares,
outcome,
unfilledBets,
balanceByUserId
)
return getCpmmProbability(cpmmState.pool, cpmmState.p)
}

View File

@ -1,4 +1,4 @@
import { last, sortBy, sum, sumBy } from 'lodash'
import { last, sortBy, sum, sumBy, uniq } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet, LimitBet } from './bet'
import { Contract, CPMMContract, DPMContract } from './contract'
@ -62,16 +62,28 @@ export const computeBinaryCpmmElasticity = (
const limitBets = bets
.filter(
(b) =>
!b.isFilled && !b.isSold && !b.isRedemption && !b.sale && !b.isCancelled
!b.isFilled &&
!b.isSold &&
!b.isRedemption &&
!b.sale &&
!b.isCancelled &&
b.limitProb !== undefined
)
.sort((a, b) => a.createdTime - b.createdTime)
.sort((a, b) => a.createdTime - b.createdTime) as LimitBet[]
const userIds = uniq(limitBets.map((b) => b.userId))
// Assume all limit orders are good.
const userBalances = Object.fromEntries(
userIds.map((id) => [id, Number.MAX_SAFE_INTEGER])
)
const { newPool: poolY, newP: pY } = getBinaryCpmmBetInfo(
'YES',
betAmount,
contract,
undefined,
limitBets as LimitBet[]
limitBets,
userBalances
)
const resultYes = getCpmmProbability(poolY, pY)
@ -80,7 +92,8 @@ export const computeBinaryCpmmElasticity = (
betAmount,
contract,
undefined,
limitBets as LimitBet[]
limitBets,
userBalances
)
const resultNo = getCpmmProbability(poolN, pN)

View File

@ -78,7 +78,8 @@ export function calculateShares(
export function calculateSaleAmount(
contract: Contract,
bet: Bet,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
return contract.mechanism === 'cpmm-1' &&
(contract.outcomeType === 'BINARY' ||
@ -87,7 +88,8 @@ export function calculateSaleAmount(
contract,
Math.abs(bet.shares),
bet.outcome as 'YES' | 'NO',
unfilledBets
unfilledBets,
balanceByUserId
).saleValue
: calculateDpmSaleAmount(contract, bet)
}
@ -102,14 +104,16 @@ export function getProbabilityAfterSale(
contract: Contract,
outcome: string,
shares: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) {
return contract.mechanism === 'cpmm-1'
? getCpmmProbabilityAfterSale(
contract,
shares,
outcome as 'YES' | 'NO',
unfilledBets
unfilledBets,
balanceByUserId
)
: getDpmProbabilityAfterSale(contract.totalShares, outcome, shares)
}

View File

@ -1,3 +1,5 @@
export const FLAT_TRADE_FEE = 0.1 // M$0.1
export const PLATFORM_FEE = 0
export const CREATOR_FEE = 0
export const LIQUIDITY_FEE = 0

View File

@ -143,7 +143,8 @@ export const computeFills = (
betAmount: number,
state: CpmmState,
limitProb: number | undefined,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
if (isNaN(betAmount)) {
throw new Error('Invalid bet amount: ${betAmount}')
@ -165,10 +166,12 @@ export const computeFills = (
shares: number
timestamp: number
}[] = []
const ordersToCancel: LimitBet[] = []
let amount = betAmount
let cpmmState = { pool: state.pool, p: state.p }
let totalFees = noFees
const currentBalanceByUserId = { ...balanceByUserId }
let i = 0
while (true) {
@ -185,9 +188,20 @@ export const computeFills = (
takers.push(taker)
} else {
// Matched against bet.
i++
const { userId } = maker.bet
const makerBalance = currentBalanceByUserId[userId]
if (floatingGreaterEqual(makerBalance, maker.amount)) {
currentBalanceByUserId[userId] = makerBalance - maker.amount
} else {
// Insufficient balance. Cancel maker bet.
ordersToCancel.push(maker.bet)
continue
}
takers.push(taker)
makers.push(maker)
i++
}
amount -= taker.amount
@ -195,7 +209,7 @@ export const computeFills = (
if (floatingEqual(amount, 0)) break
}
return { takers, makers, totalFees, cpmmState }
return { takers, makers, totalFees, cpmmState, ordersToCancel }
}
export const getBinaryCpmmBetInfo = (
@ -203,15 +217,17 @@ export const getBinaryCpmmBetInfo = (
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number | undefined,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
const { pool, p } = contract
const { takers, makers, cpmmState, totalFees } = computeFills(
const { takers, makers, cpmmState, totalFees, ordersToCancel } = computeFills(
outcome,
betAmount,
{ pool, p },
limitProb,
unfilledBets
unfilledBets,
balanceByUserId
)
const probBefore = getCpmmProbability(contract.pool, contract.p)
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
@ -246,6 +262,7 @@ export const getBinaryCpmmBetInfo = (
newP: cpmmState.p,
newTotalLiquidity,
makers,
ordersToCancel,
}
}
@ -254,14 +271,16 @@ export const getBinaryBetStats = (
betAmount: number,
contract: CPMMBinaryContract | PseudoNumericContract,
limitProb: number,
unfilledBets: LimitBet[]
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number }
) => {
const { newBet } = getBinaryCpmmBetInfo(
outcome,
betAmount ?? 0,
contract,
limitProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const remainingMatched =
((newBet.orderAmount ?? 0) - newBet.amount) /

View File

@ -84,15 +84,17 @@ export const getCpmmSellBetInfo = (
outcome: 'YES' | 'NO',
contract: CPMMContract,
unfilledBets: LimitBet[],
balanceByUserId: { [userId: string]: number },
loanPaid: number
) => {
const { pool, p } = contract
const { saleValue, cpmmState, fees, makers, takers } = calculateCpmmSale(
const { saleValue, cpmmState, fees, makers, takers, ordersToCancel } = calculateCpmmSale(
contract,
shares,
outcome,
unfilledBets
unfilledBets,
balanceByUserId,
)
const probBefore = getCpmmProbability(pool, p)
@ -134,5 +136,6 @@ export const getCpmmSellBetInfo = (
fees,
makers,
takers,
ordersToCancel
}
}

View File

@ -15,6 +15,22 @@ Our community is the beating heart of Manifold; your individual contributions ar
## Awarded bounties
💥 *Awarded on 2022-10-07*
**[Pepe](https://manifold.markets/Pepe): M$10,000**
**[Jack](https://manifold.markets/jack): M$2,000**
**[Martin](https://manifold.markets/MartinRandall): M$2,000**
**[Yev](https://manifold.markets/Yev): M$2,000**
**[Michael](https://manifold.markets/MichaelWheatley): M$2,000**
- For discovering an infinite mana exploit using limit orders, and informing the Manifold team of it privately.
**[Matt](https://manifold.markets/MattP): M$5,000**
**[Adrian](https://manifold.markets/ahalekelly): M$5,000**
**[Yev](https://manifold.markets/Yev): M$5,000**
- For discovering an AMM liquidity exploit and informing the Manifold team of it privately.
🎈 *Awarded on 2022-06-14*
**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000**

View File

@ -146,3 +146,24 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => {
},
} as EndpointDefinition
}
export const newEndpointNoAuth = (
endpointOpts: EndpointOptions,
fn: (req: Request) => Promise<Output>
) => {
const opts = Object.assign({}, DEFAULT_OPTS, endpointOpts)
return {
opts,
handler: async (req: Request, res: Response) => {
log(`${req.method} ${req.url} ${JSON.stringify(req.body)}`)
try {
if (opts.method !== req.method) {
throw new APIError(405, `This endpoint supports only ${opts.method}.`)
}
res.status(200).json(await fn(req))
} catch (e) {
writeResponseError(e, res)
}
},
} as EndpointDefinition
}

View File

@ -9,7 +9,7 @@ export * from './on-create-user'
export * from './on-create-bet'
export * from './on-create-comment-on-contract'
export * from './on-view'
export * from './update-metrics'
export { updateMetrics } from './update-metrics'
export * from './update-stats'
export * from './update-loans'
export * from './backup-db'
@ -78,6 +78,7 @@ import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
import { updatemetrics } from './update-metrics'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
return onRequest(opts, handler as any)
@ -108,6 +109,7 @@ const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const createPostFunction = toCloudFunction(createpost)
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
const updateMetricsFunction = toCloudFunction(updatemetrics)
export {
healthFunction as health,
@ -136,4 +138,5 @@ export {
createCommentFunction as createcomment,
addCommentBounty as addcommentbounty,
awardCommentBounty as awardcommentbounty,
updateMetricsFunction as updatemetrics,
}

View File

@ -5,8 +5,6 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { createReferralNotification } from './create-notification'
import { ReferralTxn } from '../../common/txn'
import { Contract } from '../../common/contract'
import { LimitBet } from '../../common/bet'
import { QuerySnapshot } from 'firebase-admin/firestore'
import { Group } from '../../common/group'
import { REFERRAL_AMOUNT } from '../../common/economy'
const firestore = admin.firestore()
@ -21,10 +19,6 @@ export const onUpdateUser = functions.firestore
if (prevUser.referredByUserId !== user.referredByUserId) {
await handleUserUpdatedReferral(user, eventId)
}
if (user.balance <= 0) {
await cancelLimitOrders(user.id)
}
})
async function handleUserUpdatedReferral(user: User, eventId: string) {
@ -123,15 +117,3 @@ async function handleUserUpdatedReferral(user: User, eventId: string) {
)
})
}
async function cancelLimitOrders(userId: string) {
const snapshot = (await firestore
.collectionGroup('bets')
.where('userId', '==', userId)
.where('isFilled', '==', false)
.get()) as QuerySnapshot<LimitBet>
await Promise.all(
snapshot.docs.map((doc) => doc.ref.update({ isCancelled: true }))
)
}

View File

@ -11,6 +11,7 @@ import { groupBy, mapValues, sumBy, uniq } from 'lodash'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
import { User } from '../../common/user'
import { FLAT_TRADE_FEE } from '../../common/fees'
import {
BetInfo,
getBinaryCpmmBetInfo,
@ -23,6 +24,7 @@ import { floatingEqual } from '../../common/util/math'
import { redeemShares } from './redeem-shares'
import { log } from './utils'
import { addUserToContractFollowers } from './follow-market'
import { filterDefined } from '../../common/util/array'
const bodySchema = z.object({
contractId: z.string(),
@ -73,9 +75,11 @@ export const placebet = newEndpoint({}, async (req, auth) => {
newTotalLiquidity,
newP,
makers,
ordersToCancel,
} = await (async (): Promise<
BetInfo & {
makers?: maker[]
ordersToCancel?: LimitBet[]
}
> => {
if (
@ -99,17 +103,16 @@ export const placebet = newEndpoint({}, async (req, auth) => {
limitProb = Math.round(limitProb * 100) / 100
}
const unfilledBetsSnap = await trans.get(
getUnfilledBetsQuery(contractDoc)
)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const { unfilledBets, balanceByUserId } =
await getUnfilledBetsAndUserBalances(trans, contractDoc)
return getBinaryCpmmBetInfo(
outcome,
amount,
contract,
limitProb,
unfilledBets
unfilledBets,
balanceByUserId
)
} else if (
(outcomeType == 'FREE_RESPONSE' || outcomeType === 'MULTIPLE_CHOICE') &&
@ -152,11 +155,25 @@ export const placebet = newEndpoint({}, async (req, auth) => {
if (makers) {
updateMakers(makers, betDoc.id, contractDoc, trans)
}
if (ordersToCancel) {
for (const bet of ordersToCancel) {
trans.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
}
const balanceChange =
newBet.amount !== 0
? // quick bet
newBet.amount + FLAT_TRADE_FEE
: // limit order
FLAT_TRADE_FEE
trans.update(userDoc, { balance: FieldValue.increment(-balanceChange) })
log('Updated user balance.')
if (newBet.amount !== 0) {
trans.update(userDoc, { balance: FieldValue.increment(-newBet.amount) })
log('Updated user balance.')
trans.update(
contractDoc,
removeUndefinedProps({
@ -193,13 +210,36 @@ export const placebet = newEndpoint({}, async (req, auth) => {
const firestore = admin.firestore()
export const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
const getUnfilledBetsQuery = (contractDoc: DocumentReference) => {
return contractDoc
.collection('bets')
.where('isFilled', '==', false)
.where('isCancelled', '==', false) as Query<LimitBet>
}
export const getUnfilledBetsAndUserBalances = async (
trans: Transaction,
contractDoc: DocumentReference
) => {
const unfilledBetsSnap = await trans.get(getUnfilledBetsQuery(contractDoc))
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
// Get balance of all users with open limit orders.
const userIds = uniq(unfilledBets.map((bet) => bet.userId))
const userDocs =
userIds.length === 0
? []
: await trans.getAll(
...userIds.map((userId) => firestore.doc(`users/${userId}`))
)
const users = filterDefined(userDocs.map((doc) => doc.data() as User))
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
return { unfilledBets, balanceByUserId }
}
type maker = {
bet: LimitBet
amount: number

View File

@ -1,6 +1,7 @@
import { mapValues, groupBy, sumBy, uniq } from 'lodash'
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { FieldValue } from 'firebase-admin/firestore'
import { APIError, newEndpoint, validate } from './api'
import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
@ -10,8 +11,7 @@ import { addObjects, removeUndefinedProps } from '../../common/util/object'
import { log } from './utils'
import { Bet } from '../../common/bet'
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
import { FieldValue } from 'firebase-admin/firestore'
import { getUnfilledBetsAndUserBalances, updateMakers } from './place-bet'
import { redeemShares } from './redeem-shares'
import { removeUserFromContractFollowers } from './follow-market'
@ -29,16 +29,18 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
const contractDoc = firestore.doc(`contracts/${contractId}`)
const userDoc = firestore.doc(`users/${auth.uid}`)
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
transaction.get(getUnfilledBetsQuery(contractDoc)),
])
const [
[contractSnap, userSnap],
userBetsSnap,
{ unfilledBets, balanceByUserId },
] = await Promise.all([
transaction.getAll(contractDoc, userDoc),
transaction.get(betsQ),
getUnfilledBetsAndUserBalances(transaction, contractDoc),
])
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
const contract = contractSnap.data() as Contract
const user = userSnap.data() as User
@ -86,13 +88,15 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
let loanPaid = saleFrac * loanAmount
if (!isFinite(loanPaid)) loanPaid = 0
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
unfilledBets,
loanPaid
)
const { newBet, newPool, newP, fees, makers, ordersToCancel } =
getCpmmSellBetInfo(
soldShares,
chosenOutcome,
contract,
unfilledBets,
balanceByUserId,
loanPaid
)
if (
!newP ||
@ -127,6 +131,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
})
)
for (const bet of ordersToCancel) {
transaction.update(contractDoc.collection('bets').doc(bet.id), {
isCancelled: true,
})
}
return { newBet, makers, maxShares, soldShares }
})

View File

@ -1,10 +1,11 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import fetch from 'node-fetch'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { getLoanUpdates } from '../../common/loans'
@ -20,13 +21,35 @@ import {
import { getProbability } from '../../common/calculate'
import { Group } from '../../common/group'
import { batchedWaitAll } from '../../common/util/promise'
import { newEndpointNoAuth } from './api'
import { getFunctionUrl } from '../../common/api'
const firestore = admin.firestore()
export const updateMetrics = functions
.runWith({ memory: '8GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
export const updateMetrics = functions.pubsub
.schedule('every 15 minutes')
.onRun(async () => {
const response = await fetch(getFunctionUrl('updatemetrics'), {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify({}),
})
const json = await response.json()
if (response.ok) console.log(json)
else console.error(json)
})
export const updatemetrics = newEndpointNoAuth(
{ timeoutSeconds: 2000, memory: '8GiB', minInstances: 0 },
async (_req) => {
await updateMetricsCore()
return { success: true }
}
)
export async function updateMetricsCore() {
console.log('Loading users')

View File

@ -2,12 +2,12 @@ import clsx from 'clsx'
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'
import { MenuIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
import { XCircleIcon } from '@heroicons/react/outline'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from 'web/components/subtitle'
import { keyBy } from 'lodash'
import { XCircleIcon } from '@heroicons/react/outline'
import { Button } from './button'
import { updateUser } from 'web/lib/firebase/users'
import { leaveGroup } from 'web/lib/firebase/groups'

View File

@ -16,7 +16,7 @@ import { Button } from 'web/components/button'
import { BetSignUpPrompt } from './sign-up-prompt'
import { User } from 'web/lib/firebase/users'
import { SellRow } from './sell-row'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { PlayMoneyDisclaimer } from './play-money-disclaimer'
/** Button that opens BetPanel in a new modal */
@ -100,7 +100,9 @@ export function SignedInBinaryMobileBetting(props: {
user: User
}) {
const { contract, user } = props
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<>
@ -111,6 +113,7 @@ export function SignedInBinaryMobileBetting(props: {
contract={contract as CPMMBinaryContract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
mobileView={true}
/>
</Col>

View File

@ -10,7 +10,7 @@ import { BuyAmountInput } from './amount-input'
import { Button } from './button'
import { Row } from './layout/row'
import { YesNoSelector } from './yes-no-selector'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { useUser } from 'web/hooks/use-user'
import { BetSignUpPrompt } from './sign-up-prompt'
import { getCpmmProbability } from 'common/calculate-cpmm'
@ -34,14 +34,17 @@ export function BetInline(props: {
const [error, setError] = useState<string>()
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { newPool, newP } = getBinaryCpmmBetInfo(
outcome ?? 'YES',
amount ?? 0,
contract,
undefined,
unfilledBets
unfilledBets,
balanceByUserId
)
const resultProb = getCpmmProbability(newPool, newP)
useEffect(() => setProbAfter(resultProb), [setProbAfter, resultProb])

View File

@ -35,7 +35,7 @@ import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt'
import { ProbabilityOrNumericInput } from './probability-input'
import { track } from 'web/lib/service/analytics'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector'
@ -55,7 +55,9 @@ export function BetPanel(props: {
const { contract, className } = props
const user = useUser()
const userBets = useUserContractBets(user?.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { sharesOutcome } = useSaveBinaryShares(contract, userBets)
const [isLimitOrder, setIsLimitOrder] = useState(false)
@ -86,12 +88,14 @@ export function BetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
<LimitOrderPanel
hidden={!isLimitOrder}
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
</>
) : (
@ -117,7 +121,9 @@ export function SimpleBetPanel(props: {
const user = useUser()
const [isLimitOrder, setIsLimitOrder] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<Col className={className}>
@ -142,6 +148,7 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess}
/>
<LimitOrderPanel
@ -149,6 +156,7 @@ export function SimpleBetPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
onBuySuccess={onBetSuccess}
/>
@ -167,13 +175,21 @@ export function SimpleBetPanel(props: {
export function BuyPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean
onBuySuccess?: () => void
mobileView?: boolean
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess, mobileView } =
props
const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
mobileView,
} = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -261,7 +277,8 @@ export function BuyPanel(props: {
betAmount ?? 0,
contract,
undefined,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const [seeLimit, setSeeLimit] = useState(false)
@ -416,6 +433,7 @@ export function BuyPanel(props: {
contract={contract}
user={user}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
<LimitBets
contract={contract}
@ -431,11 +449,19 @@ export function BuyPanel(props: {
function LimitOrderPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract
user: User | null | undefined
unfilledBets: Bet[]
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
hidden: boolean
onBuySuccess?: () => void
}) {
const { contract, user, unfilledBets, hidden, onBuySuccess } = props
const {
contract,
user,
unfilledBets,
balanceByUserId,
hidden,
onBuySuccess,
} = props
const initialProb = getProbability(contract)
const isPseudoNumeric = contract.outcomeType === 'PSEUDO_NUMERIC'
@ -581,7 +607,8 @@ function LimitOrderPanel(props: {
yesAmount,
contract,
yesLimitProb ?? initialProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const yesReturnPercent = formatPercent(yesReturn)
@ -595,7 +622,8 @@ function LimitOrderPanel(props: {
noAmount,
contract,
noLimitProb ?? initialProb,
unfilledBets as LimitBet[]
unfilledBets,
balanceByUserId
)
const noReturnPercent = formatPercent(noReturn)
@ -830,7 +858,9 @@ export function SellPanel(props: {
const [isSubmitting, setIsSubmitting] = useState(false)
const [wasSubmitted, setWasSubmitted] = useState(false)
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const betDisabled = isSubmitting || !amount || error !== undefined
@ -889,7 +919,8 @@ export function SellPanel(props: {
contract,
sellQuantity ?? 0,
sharesOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
const netProceeds = saleValue - loanPaid
const profit = saleValue - costBasis

View File

@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
import { Bet, MAX_USER_BETS_LOADED } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
import {
formatMoney,
@ -17,6 +17,7 @@ import {
Contract,
contractPath,
getBinaryProbPercent,
MAX_USER_BET_CONTRACTS_LOADED,
} from 'web/lib/firebase/contracts'
import { Row } from './layout/row'
import { sellBet } from 'web/lib/firebase/api'
@ -37,7 +38,7 @@ import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
import { useUserBets } from 'web/hooks/use-user-bets'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { LimitBet } from 'common/bet'
import { Pagination } from './pagination'
import { LimitOrderTable } from './limit-bets'
@ -50,6 +51,7 @@ import {
usePersistentState,
} from 'web/hooks/use-persistent-state'
import { safeLocalStorage } from 'web/lib/util/local'
import { ExclamationIcon } from '@heroicons/react/outline'
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
type BetFilter = 'open' | 'limit_bet' | 'sold' | 'closed' | 'resolved' | 'all'
@ -80,6 +82,10 @@ export function BetsList(props: { user: User }) {
return contractList ? keyBy(contractList, 'id') : undefined
}, [contractList])
const loadedPartialData =
userBets?.length === MAX_USER_BETS_LOADED ||
contractList?.length === MAX_USER_BET_CONTRACTS_LOADED
const [sort, setSort] = usePersistentState<BetSort>('newest', {
key: 'bets-list-sort',
store: storageStore(safeLocalStorage()),
@ -167,6 +173,13 @@ export function BetsList(props: { user: User }) {
return (
<Col>
{loadedPartialData && (
<Row className="my-4 items-center gap-2 self-start rounded bg-yellow-50 p-4">
<ExclamationIcon className="h-5 w-5" />
<div>Partial trade data only</div>
</Row>
)}
<Col className="justify-between gap-4 sm:flex-row">
<Row className="gap-4">
<Col>
@ -412,7 +425,9 @@ export function ContractBetsTable(props: {
const isNumeric = outcomeType === 'NUMERIC'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
const unfilledBets = useUnfilledBets(contract.id) ?? []
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
return (
<div className="overflow-x-auto">
@ -461,6 +476,7 @@ export function ContractBetsTable(props: {
contract={contract}
isYourBet={isYourBets}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
))}
</tbody>
@ -475,8 +491,10 @@ function BetRow(props: {
saleBet?: Bet
isYourBet: boolean
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) {
const { bet, saleBet, contract, isYourBet, unfilledBets } = props
const { bet, saleBet, contract, isYourBet, unfilledBets, balanceByUserId } =
props
const {
amount,
outcome,
@ -504,9 +522,9 @@ function BetRow(props: {
} else if (contract.isResolved) {
return resolvedPayout(contract, bet)
} else {
return calculateSaleAmount(contract, bet, unfilledBets)
return calculateSaleAmount(contract, bet, unfilledBets, balanceByUserId)
}
}, [contract, bet, saleBet, unfilledBets])
}, [contract, bet, saleBet, unfilledBets, balanceByUserId])
const saleDisplay = isAnte ? (
'ANTE'
@ -545,6 +563,7 @@ function BetRow(props: {
contract={contract}
bet={bet}
unfilledBets={unfilledBets}
balanceByUserId={balanceByUserId}
/>
)}
</td>
@ -590,8 +609,9 @@ function SellButton(props: {
contract: Contract
bet: Bet
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}) {
const { contract, bet, unfilledBets } = props
const { contract, bet, unfilledBets, balanceByUserId } = props
const { outcome, shares, loanAmount } = bet
const [isSubmitting, setIsSubmitting] = useState(false)
@ -605,10 +625,16 @@ function SellButton(props: {
contract,
outcome,
shares,
unfilledBets
unfilledBets,
balanceByUserId
)
const saleAmount = calculateSaleAmount(contract, bet, unfilledBets)
const saleAmount = calculateSaleAmount(
contract,
bet,
unfilledBets,
balanceByUserId
)
const profit = saleAmount - bet.amount
return (

View File

@ -1,12 +1,16 @@
import clsx from 'clsx'
import { useState } from 'react'
import { CurrencyDollarIcon } from '@heroicons/react/outline'
import { Contract } from 'common/contract'
import { Tooltip } from 'web/components/tooltip'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { CommentBountyDialog } from './comment-bounty-dialog'
export function BountiedContractBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-3 py-0.5 text-sm font-medium text-blue-800">
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-300 px-3 py-0.5 text-sm font-medium text-white">
<CurrencyDollarIcon className={'h4 w-4'} /> Bounty
</span>
)
@ -18,30 +22,50 @@ export function BountiedContractSmallBadge(props: {
}) {
const { contract, showAmount } = props
const { openCommentBounties } = contract
if (!openCommentBounties) return <div />
return (
<Tooltip
text={CommentBountiesTooltipText(
contract.creatorName,
openCommentBounties
)}
placement="bottom"
>
<span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white">
<CurrencyDollarIcon className={'h3 w-3'} />
{showAmount && formatMoney(openCommentBounties)} Bounty
</span>
</Tooltip>
const [open, setOpen] = useState(false)
if (!openCommentBounties && !showAmount) return <></>
const modal = (
<CommentBountyDialog open={open} setOpen={setOpen} contract={contract} />
)
}
if (!openCommentBounties)
return (
<>
{modal}
<SmallBadge text="Add bounty" onClick={() => setOpen(true)} />
</>
)
export const CommentBountiesTooltipText = (
creator: string,
openCommentBounties: number
) =>
`${creator} may award ${formatMoney(
const tooltip = `${contract.creatorName} may award ${formatMoney(
COMMENT_BOUNTY_AMOUNT
)} for good comments. ${formatMoney(
openCommentBounties
)} currently available.`
return (
<Tooltip text={tooltip} placement="bottom">
{modal}
<SmallBadge
text={`${formatMoney(openCommentBounties)} bounty`}
onClick={() => setOpen(true)}
/>
</Tooltip>
)
}
function SmallBadge(props: { text: string; onClick?: () => void }) {
const { text, onClick } = props
return (
<button
onClick={onClick}
className={clsx(
'inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-indigo-300 px-2 py-0.5 text-xs font-medium text-white'
)}
>
<CurrencyDollarIcon className={'h4 w-4'} />
{text}
</button>
)
}

View File

@ -8,9 +8,16 @@ import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { COMMENT_BOUNTY_AMOUNT } from 'common/economy'
import { Button } from 'web/components/button'
import { Title } from '../title'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
export function AddCommentBountyPanel(props: { contract: Contract }) {
const { contract } = props
export function CommentBountyDialog(props: {
contract: Contract
open: boolean
setOpen: (open: boolean) => void
}) {
const { contract, open, setOpen } = props
const { id: contractId, slug } = contract
const user = useUser()
@ -45,30 +52,34 @@ export function AddCommentBountyPanel(props: { contract: Contract }) {
}
return (
<>
<div className="mb-4 text-gray-500">
Add a {formatMoney(amount)} bounty for good comments that the creator
can award.{' '}
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
</div>
<Modal open={open} setOpen={setOpen}>
<Col className="gap-4 rounded bg-white p-6">
<Title className="!mt-0 !mb-0" text="Comment bounty" />
<Row className={'items-center gap-2'}>
<Button
className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
<div className="mb-4 text-gray-500">
Add a {formatMoney(amount)} bounty for good comments that the creator
can award.{' '}
{totalAdded > 0 && `(${formatMoney(totalAdded)} currently added)`}
</div>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in bounties.</div>
)}
<Row className={'items-center gap-2'}>
<Button
className={clsx('ml-2', isLoading && 'btn-disabled')}
onClick={submit}
disabled={isLoading}
color={'blue'}
>
Add {formatMoney(amount)} bounty
</Button>
<span className={'text-error'}>{error}</span>
</Row>
{isLoading && <div>Processing...</div>}
</>
{isSuccess && amount && (
<div>Success! Added {formatMoney(amount)} in bounties.</div>
)}
{isLoading && <div>Processing...</div>}
</Col>
</Modal>
)
}

View File

@ -5,7 +5,7 @@ import { useState } from 'react'
import { capitalize } from 'lodash'
import { Contract } from 'common/contract'
import { formatMoney } from 'common/util/format'
import { formatMoney, formatPercent } from 'common/util/format'
import { contractPool, updateContract } from 'web/lib/firebase/contracts'
import { LiquidityBountyPanel } from 'web/components/contract/liquidity-bounty-panel'
import { Col } from '../layout/col'
@ -54,6 +54,7 @@ export function ContractInfoDialog(props: {
mechanism,
outcomeType,
id,
elasticity,
} = contract
const typeDisplay =
@ -142,7 +143,10 @@ export function ContractInfoDialog(props: {
)}
<tr>
<td>Volume</td>
<td>
<span className="mr-1">Volume</span>
<InfoTooltip text="Total amount bought or sold" />
</td>
<td>{formatMoney(contract.volume)}</td>
</tr>
@ -151,6 +155,22 @@ export function ContractInfoDialog(props: {
<td>{uniqueBettorCount ?? '0'}</td>
</tr>
<tr>
<td>
<Row>
<span className="mr-1">Elasticity</span>
<InfoTooltip
text={
mechanism === 'cpmm-1'
? 'Probability change between a M$50 bet on YES and NO'
: 'Probability change from a M$100 bet'
}
/>
</Row>
</td>
<td>{formatPercent(elasticity)}</td>
</tr>
<tr>
<td>
{mechanism === 'cpmm-1' ? 'Liquidity pool' : 'Betting pool'}

View File

@ -80,7 +80,7 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
const { contract } = props
const tips = useTipTxns({ contractId: contract.id })
const comments = useComments(contract.id) ?? props.comments
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Best', {
const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', {
key: `contract-comments-sort`,
store: storageStore(safeLocalStorage()),
})
@ -177,8 +177,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
<Col className="mt-8 flex w-full">
<div className="text-md mt-8 mb-2 text-left">General Comments</div>
<div className="mb-4 w-full border-b border-gray-200" />
{sortRow}
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{generalTopLevelComments.map((comment) => (
<FeedCommentThread
key={comment.id}
@ -194,8 +195,9 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
} else {
return (
<>
{sortRow}
<ContractCommentInput className="mb-5" contract={contract} />
{sortRow}
{topLevelComments.map((parent) => (
<FeedCommentThread
key={parent.id}

View File

@ -16,7 +16,6 @@ import { InfoTooltip } from 'web/components/info-tooltip'
import { BETTORS, PRESENT_BET } from 'common/user'
import { buildArray } from 'common/util/array'
import { useAdmin } from 'web/hooks/use-admin'
import { AddCommentBountyPanel } from 'web/components/contract/add-comment-bounty'
export function LiquidityBountyPanel(props: { contract: Contract }) {
const { contract } = props
@ -36,13 +35,11 @@ export function LiquidityBountyPanel(props: { contract: Contract }) {
const isCreator = user?.id === contract.creatorId
const isAdmin = useAdmin()
if (!isCreator && !isAdmin && !showWithdrawal) return <></>
return (
<Tabs
tabs={buildArray(
{
title: 'Bounty Comments',
content: <AddCommentBountyPanel contract={contract} />,
},
(isCreator || isAdmin) &&
isCPMM && {
title: (isAdmin ? '[Admin] ' : '') + 'Subsidize',

View File

@ -33,7 +33,7 @@ import { sellShares } from 'web/lib/firebase/api'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { track } from 'web/lib/service/analytics'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUnfilledBets } from 'web/hooks/use-bets'
import { useUnfilledBetsAndBalanceByUserId } from 'web/hooks/use-bets'
import { getBinaryProb } from 'common/contract-details'
const BET_SIZE = 10
@ -48,7 +48,10 @@ export function QuickBet(props: {
const isCpmm = mechanism === 'cpmm-1'
const userBets = useUserContractBets(user.id, contract.id)
const unfilledBets = useUnfilledBets(contract.id) ?? []
// TODO: Below hook fetches a decent amount of data. Maybe not worth it to show prob change on hover?
const { unfilledBets, balanceByUserId } = useUnfilledBetsAndBalanceByUserId(
contract.id
)
const { hasYesShares, hasNoShares, yesShares, noShares } =
useSaveBinaryShares(contract, userBets)
@ -94,7 +97,8 @@ export function QuickBet(props: {
contract,
sharesSold,
sellOutcome,
unfilledBets
unfilledBets,
balanceByUserId
)
saleAmount = saleValue
previewProb = getCpmmProbability(cpmmState.pool, cpmmState.p)

View File

@ -109,12 +109,18 @@ export const FeedComment = memo(function FeedComment(props: {
}
const totalAwarded = bountiesAwarded ?? 0
const router = useRouter()
const highlighted = router.asPath.endsWith(`#${comment.id}`)
const { isReady, asPath } = useRouter()
const [highlighted, setHighlighted] = useState(false)
const commentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (highlighted && commentRef.current != null) {
if (isReady && asPath.endsWith(`#${comment.id}`)) {
setHighlighted(true)
}
}, [isReady, asPath, comment.id])
useEffect(() => {
if (highlighted && commentRef.current) {
commentRef.current.scrollIntoView(true)
}
}, [highlighted])
@ -126,7 +132,7 @@ export const FeedComment = memo(function FeedComment(props: {
className={clsx(
'relative',
indent ? 'ml-6' : '',
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] p-1.5` : ''
highlighted ? `-m-1.5 rounded bg-indigo-500/[0.2] px-2 py-4` : ''
)}
>
{/*draw a gray line from the comment to the left:*/}

View File

@ -11,13 +11,13 @@ function SidebarButton(props: {
}) {
const { text, children } = props
return (
<button className="group flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<div className="group flex w-full items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:cursor-pointer hover:bg-gray-100">
<props.icon
className="-ml-1 mr-3 h-6 w-6 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
aria-hidden="true"
/>
<span className="truncate">{text}</span>
{children}
</button>
</div>
)
}

View File

@ -8,6 +8,7 @@ import {
withoutAnteBets,
} from 'web/lib/firebase/bets'
import { LimitBet } from 'common/bet'
import { getUser } from 'web/lib/firebase/users'
export const useBets = (
contractId: string,
@ -62,3 +63,31 @@ export const useUnfilledBets = (contractId: string) => {
)
return unfilledBets
}
export const useUnfilledBetsAndBalanceByUserId = (contractId: string) => {
const [data, setData] = useState<{
unfilledBets: LimitBet[]
balanceByUserId: { [userId: string]: number }
}>({ unfilledBets: [], balanceByUserId: {} })
useEffect(() => {
let requestCount = 0
return listenForUnfilledBets(contractId, (unfilledBets) => {
requestCount++
const count = requestCount
Promise.all(unfilledBets.map((bet) => getUser(bet.userId))).then(
(users) => {
if (count === requestCount) {
const balanceByUserId = Object.fromEntries(
users.map((user) => [user.id, user.balance])
)
setData({ unfilledBets, balanceByUserId })
}
}
)
})
}, [contractId])
return data
}

View File

@ -74,11 +74,13 @@ export async function getUserBets(userId: string) {
return getValues<Bet>(getUserBetsQuery(userId))
}
export const MAX_USER_BETS_LOADED = 10000
export function getUserBetsQuery(userId: string) {
return query(
collectionGroup(db, 'bets'),
where('userId', '==', userId),
orderBy('createdTime', 'desc')
orderBy('createdTime', 'desc'),
limit(MAX_USER_BETS_LOADED)
) as Query<Bet>
}

View File

@ -168,10 +168,12 @@ export function getUserBetContracts(userId: string) {
return getValues<Contract>(getUserBetContractsQuery(userId))
}
export const MAX_USER_BET_CONTRACTS_LOADED = 1000
export function getUserBetContractsQuery(userId: string) {
return query(
contracts,
where('uniqueBettorIds', 'array-contains', userId)
where('uniqueBettorIds', 'array-contains', userId),
limit(MAX_USER_BET_CONTRACTS_LOADED)
) as Query<Contract>
}