Liquidity withdrawal (#457)
* withdrawLiquidity cloud function * update rules * exclude antes from getCpmmLiquidityPoolWeights * update correct lp shares * liquidity panel * don't create bet if less than 1 surplus share * withdrawLiquidity return type * static analysis fix * hook dependency * prettier * renaming * typo * getCpmmLiquidityPoolWeights: always exclude antes * delete unused function * casting
This commit is contained in:
parent
45eb5a3e63
commit
0cd9943e0d
|
@ -1,4 +1,4 @@
|
|||
import { sum, groupBy, mapValues, sumBy } from 'lodash'
|
||||
import { sum, groupBy, mapValues, sumBy, partition } from 'lodash'
|
||||
|
||||
import { CPMMContract } from './contract'
|
||||
import { CREATOR_FEE, Fees, LIQUIDITY_FEE, noFees, PLATFORM_FEE } from './fees'
|
||||
|
@ -260,13 +260,7 @@ export function addCpmmLiquidity(
|
|||
return { newPool, liquidity, newP }
|
||||
}
|
||||
|
||||
export function getCpmmLiquidityPoolWeights(
|
||||
contract: CPMMContract,
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const { p } = contract
|
||||
|
||||
const liquidityShares = liquidities.map((l) => {
|
||||
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
|
||||
const oldLiquidity = getCpmmLiquidity(l.pool, p)
|
||||
|
||||
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
|
||||
|
@ -274,13 +268,22 @@ export function getCpmmLiquidityPoolWeights(
|
|||
|
||||
const liquidity = newLiquidity - oldLiquidity
|
||||
return liquidity
|
||||
})
|
||||
}
|
||||
|
||||
const shareSum = sum(liquidityShares)
|
||||
export function getCpmmLiquidityPoolWeights(
|
||||
contract: CPMMContract,
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const [antes, nonAntes] = partition(liquidities, (l) => !!l.isAnte)
|
||||
|
||||
const calcLiqudity = calculateLiquidityDelta(contract.p)
|
||||
const liquidityShares = nonAntes.map(calcLiqudity)
|
||||
|
||||
const shareSum = sum(liquidityShares) + sum(antes.map(calcLiqudity))
|
||||
|
||||
const weights = liquidityShares.map((s, i) => ({
|
||||
weight: s / shareSum,
|
||||
providerId: liquidities[i].userId,
|
||||
providerId: nonAntes[i].userId,
|
||||
}))
|
||||
|
||||
const userWeights = groupBy(weights, (w) => w.providerId)
|
||||
|
@ -290,22 +293,13 @@ export function getCpmmLiquidityPoolWeights(
|
|||
return totalUserWeights
|
||||
}
|
||||
|
||||
// export function removeCpmmLiquidity(
|
||||
// contract: CPMMContract,
|
||||
// liquidity: number
|
||||
// ) {
|
||||
// const { YES, NO } = contract.pool
|
||||
// const poolLiquidity = getCpmmLiquidity({ YES, NO })
|
||||
// const p = getCpmmProbability({ YES, NO }, contract.p)
|
||||
export function getUserLiquidityShares(
|
||||
userId: string,
|
||||
contract: CPMMContract,
|
||||
liquidities: LiquidityProvision[]
|
||||
) {
|
||||
const weights = getCpmmLiquidityPoolWeights(contract, liquidities)
|
||||
const userWeight = weights[userId] ?? 0
|
||||
|
||||
// const f = liquidity / poolLiquidity
|
||||
// const [payoutYes, payoutNo] = [f * YES, f * NO]
|
||||
|
||||
// const betAmount = Math.abs(payoutYes - payoutNo)
|
||||
// const betOutcome = p >= 0.5 ? 'NO' : 'YES' // opposite side as adding liquidity
|
||||
// const payout = Math.min(payoutYes, payoutNo)
|
||||
|
||||
// const newPool = { YES: YES - payoutYes, NO: NO - payoutNo }
|
||||
|
||||
// return { newPool, payout, betAmount, betOutcome }
|
||||
// }
|
||||
return mapValues(contract.pool, (shares) => userWeight * shares)
|
||||
}
|
||||
|
|
|
@ -23,3 +23,18 @@ export const addObjects = <T extends { [key: string]: number }>(
|
|||
|
||||
return newObj as T
|
||||
}
|
||||
|
||||
export const subtractObjects = <T extends { [key: string]: number }>(
|
||||
obj1: T,
|
||||
obj2: T
|
||||
) => {
|
||||
const keys = union(Object.keys(obj1), Object.keys(obj2))
|
||||
const newObj = {} as any
|
||||
|
||||
for (const key of keys) {
|
||||
newObj[key] = (obj1[key] ?? 0) - (obj2[key] ?? 0)
|
||||
}
|
||||
|
||||
return newObj as T
|
||||
}
|
||||
|
||||
|
|
|
@ -65,6 +65,10 @@ service cloud.firestore {
|
|||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/liquidity/{liquidityId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
function commentMatchesUser(userId, comment) {
|
||||
// it's a bad look if someone can impersonate other ids/names/avatars so check everything
|
||||
let user = get(/databases/$(database)/documents/users/$(userId));
|
||||
|
|
|
@ -35,3 +35,4 @@ export * from './place-bet'
|
|||
export * from './sell-bet'
|
||||
export * from './sell-shares'
|
||||
export * from './create-contract'
|
||||
export * from './withdraw-liquidity'
|
111
functions/src/withdraw-liquidity.ts
Normal file
111
functions/src/withdraw-liquidity.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { CPMMContract } from '../../common/contract'
|
||||
import { User } from '../../common/user'
|
||||
import { subtractObjects } from '../../common/util/object'
|
||||
import { LiquidityProvision } from '../../common/liquidity-provision'
|
||||
import { getUserLiquidityShares } from '../../common/calculate-cpmm'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
import { noFees } from '../../common/fees'
|
||||
|
||||
import { APIError } from './api'
|
||||
|
||||
export const withdrawLiquidity = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(
|
||||
async (
|
||||
data: {
|
||||
contractId: string
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { contractId } = data
|
||||
if (!contractId)
|
||||
return { status: 'error', message: 'Missing contract id' }
|
||||
|
||||
const result = await firestore.runTransaction(async (trans) => {
|
||||
const lpDoc = firestore.doc(`users/${userId}`)
|
||||
const lpSnap = await trans.get(lpDoc)
|
||||
if (!lpSnap.exists) throw new APIError(400, 'User not found.')
|
||||
const lp = lpSnap.data() as User
|
||||
|
||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||
const contractSnap = await trans.get(contractDoc)
|
||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||
const contract = contractSnap.data() as CPMMContract
|
||||
|
||||
const liquidityCollection = firestore.collection(
|
||||
`contracts/${contractId}/liquidity`
|
||||
)
|
||||
|
||||
const liquiditiesSnap = await trans.get(liquidityCollection)
|
||||
|
||||
const liquidities = liquiditiesSnap.docs.map(
|
||||
(doc) => doc.data() as LiquidityProvision
|
||||
)
|
||||
|
||||
const userShares = getUserLiquidityShares(userId, contract, liquidities)
|
||||
|
||||
// zero all added amounts for now
|
||||
// can add support for partial withdrawals in the future
|
||||
liquiditiesSnap.docs
|
||||
.filter(
|
||||
(_, i) => !liquidities[i].isAnte && liquidities[i].userId === userId
|
||||
)
|
||||
.forEach((doc) => trans.update(doc.ref, { amount: 0 }))
|
||||
|
||||
const payout = Math.min(...Object.values(userShares))
|
||||
if (payout <= 0) return {}
|
||||
|
||||
const newBalance = lp.balance + payout
|
||||
trans.update(lpDoc, { balance: newBalance })
|
||||
|
||||
const newPool = subtractObjects(contract.pool, userShares)
|
||||
const newTotalLiquidity = contract.totalLiquidity - payout
|
||||
trans.update(contractDoc, {
|
||||
pool: newPool,
|
||||
totalLiquidity: newTotalLiquidity,
|
||||
})
|
||||
|
||||
const prob = getProbability(contract)
|
||||
|
||||
// surplus shares become user's bets
|
||||
const bets = Object.entries(userShares)
|
||||
.map(([outcome, shares]) =>
|
||||
shares - payout < 1 // don't create bet if less than 1 share
|
||||
? undefined
|
||||
: ({
|
||||
userId: userId,
|
||||
contractId: contract.id,
|
||||
amount: shares - payout,
|
||||
shares: shares - payout,
|
||||
outcome,
|
||||
probBefore: prob,
|
||||
probAfter: prob,
|
||||
createdTime: Date.now(),
|
||||
fees: noFees,
|
||||
} as Omit<Bet, 'id'>)
|
||||
)
|
||||
.filter((x) => x !== undefined)
|
||||
|
||||
for (const bet of bets) {
|
||||
const doc = firestore
|
||||
.collection(`contracts/${contract.id}/bets`)
|
||||
.doc()
|
||||
trans.create(doc, { id: doc.id, ...bet })
|
||||
}
|
||||
|
||||
return userShares
|
||||
})
|
||||
|
||||
console.log('userid', userId, 'withdraws', result)
|
||||
return { status: 'success', userShares: result }
|
||||
}
|
||||
)
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -1,87 +0,0 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Contract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { addLiquidity } from 'web/lib/firebase/fn-call'
|
||||
import { AmountInput } from './amount-input'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function AddLiquidityPanel(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { id: contractId } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [amount, setAmount] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const onAmountChange = (amount: number | undefined) => {
|
||||
setIsSuccess(false)
|
||||
setAmount(amount)
|
||||
|
||||
// Check for errors.
|
||||
if (amount !== undefined) {
|
||||
if (user && user.balance < amount) {
|
||||
setError('Insufficient balance')
|
||||
} else if (amount < 1) {
|
||||
setError('Minimum amount: ' + formatMoney(1))
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!amount) return
|
||||
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
addLiquidity({ amount, contractId })
|
||||
.then((r) => {
|
||||
if (r.status === 'success') {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError('Server error')
|
||||
}
|
||||
})
|
||||
.catch((e) => setError('Server error'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-gray-500">
|
||||
Subsidize this market by adding liquidity for traders.
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label="M$"
|
||||
error={error}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{isSuccess && amount && (
|
||||
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -12,7 +12,7 @@ import {
|
|||
contractPool,
|
||||
getBinaryProbPercent,
|
||||
} from 'web/lib/firebase/contracts'
|
||||
import { AddLiquidityPanel } from '../add-liquidity-panel'
|
||||
import { LiquidityPanel } from '../liquidity-panel'
|
||||
import { CopyLinkButton } from '../copy-link-button'
|
||||
import { Col } from '../layout/col'
|
||||
import { Modal } from '../layout/modal'
|
||||
|
@ -113,13 +113,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
|||
<TagsInput contract={contract} />
|
||||
<div />
|
||||
|
||||
{contract.mechanism === 'cpmm-1' &&
|
||||
!contract.resolution &&
|
||||
(!closeTime || closeTime > Date.now()) && (
|
||||
<>
|
||||
<div className="">Add liquidity</div>
|
||||
<AddLiquidityPanel contract={contract} />
|
||||
</>
|
||||
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
||||
<LiquidityPanel contract={contract} />
|
||||
)}
|
||||
</Col>
|
||||
</Modal>
|
||||
|
|
199
web/components/liquidity-panel.tsx
Normal file
199
web/components/liquidity-panel.tsx
Normal file
|
@ -0,0 +1,199 @@
|
|||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/fn-call'
|
||||
import { AmountInput } from './amount-input'
|
||||
import { Row } from './layout/row'
|
||||
import { useUserLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { Tabs } from './layout/tabs'
|
||||
import { NoLabel, YesLabel } from './outcome-label'
|
||||
import { Col } from './layout/col'
|
||||
|
||||
export function LiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
|
||||
const user = useUser()
|
||||
const lpShares = useUserLiquidity(contract, user?.id ?? '')
|
||||
|
||||
const [showWithdrawal, setShowWithdrawal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!showWithdrawal && lpShares && lpShares.YES && lpShares.NO)
|
||||
setShowWithdrawal(true)
|
||||
}, [showWithdrawal, lpShares])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
title: 'Add liquidity',
|
||||
content: <AddLiquidityPanel contract={contract} />,
|
||||
},
|
||||
...(showWithdrawal
|
||||
? [
|
||||
{
|
||||
title: 'Withdraw liquidity',
|
||||
content: (
|
||||
<WithdrawLiquidityPanel
|
||||
contract={contract}
|
||||
lpShares={lpShares as { YES: number; NO: number }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AddLiquidityPanel(props: { contract: CPMMContract }) {
|
||||
const { contract } = props
|
||||
const { id: contractId } = contract
|
||||
|
||||
const user = useUser()
|
||||
|
||||
const [amount, setAmount] = useState<number | undefined>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const onAmountChange = (amount: number | undefined) => {
|
||||
setIsSuccess(false)
|
||||
setAmount(amount)
|
||||
|
||||
// Check for errors.
|
||||
if (amount !== undefined) {
|
||||
if (user && user.balance < amount) {
|
||||
setError('Insufficient balance')
|
||||
} else if (amount < 1) {
|
||||
setError('Minimum amount: ' + formatMoney(1))
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!amount) return
|
||||
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
addLiquidity({ amount, contractId })
|
||||
.then((r) => {
|
||||
if (r.status === 'success') {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
setError('Server error')
|
||||
}
|
||||
})
|
||||
.catch((e) => setError('Server error'))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 text-gray-500">
|
||||
Subsidize this market by adding liquidity for traders.
|
||||
</div>
|
||||
|
||||
<Row>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label="M$"
|
||||
error={error}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
className={clsx('btn btn-primary ml-2', isLoading && 'btn-disabled')}
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{isSuccess && amount && (
|
||||
<div>Success! Added {formatMoney(amount)} in liquidity.</div>
|
||||
)}
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WithdrawLiquidityPanel(props: {
|
||||
contract: CPMMContract
|
||||
lpShares: { YES: number; NO: number }
|
||||
}) {
|
||||
const { contract, lpShares } = props
|
||||
const { YES: yesShares, NO: noShares } = lpShares
|
||||
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const submit = () => {
|
||||
setIsLoading(true)
|
||||
setIsSuccess(false)
|
||||
|
||||
withdrawLiquidity({ contractId: contract.id })
|
||||
.then((r) => {
|
||||
setIsSuccess(true)
|
||||
setError(undefined)
|
||||
setIsLoading(false)
|
||||
})
|
||||
.catch((e) => setError('Server error'))
|
||||
}
|
||||
|
||||
if (isSuccess)
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
Success! Your liquidity was withdrawn.
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!yesShares && !noShares)
|
||||
return (
|
||||
<div className="text-gray-500">
|
||||
You do not have any liquidity positions to withdraw.
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<div className="mb-4 text-gray-500">
|
||||
Your liquidity position is currently:
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{yesShares.toFixed(2)} <YesLabel /> shares
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{noShares.toFixed(2)} <NoLabel /> shares
|
||||
</span>
|
||||
|
||||
<Row className="mt-4 mb-2">
|
||||
<button
|
||||
className={clsx(
|
||||
'btn btn-outline btn-sm ml-2',
|
||||
isLoading && 'btn-disabled'
|
||||
)}
|
||||
onClick={submit}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
</Row>
|
||||
|
||||
{isLoading && <div>Processing...</div>}
|
||||
</Col>
|
||||
)
|
||||
}
|
26
web/hooks/use-liquidity.ts
Normal file
26
web/hooks/use-liquidity.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { CPMMContract } from 'common/contract'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
import { getUserLiquidityShares } from 'common/calculate-cpmm'
|
||||
|
||||
import { listenForLiquidity } from 'web/lib/firebase/liquidity'
|
||||
|
||||
export const useLiquidity = (contractId: string) => {
|
||||
const [liquidities, setLiquidities] = useState<
|
||||
LiquidityProvision[] | undefined
|
||||
>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return listenForLiquidity(contractId, setLiquidities)
|
||||
}, [contractId])
|
||||
|
||||
return liquidities
|
||||
}
|
||||
|
||||
export const useUserLiquidity = (contract: CPMMContract, userId: string) => {
|
||||
const liquidities = useLiquidity(contract.id)
|
||||
|
||||
const userShares = getUserLiquidityShares(userId, contract, liquidities ?? [])
|
||||
return userShares
|
||||
}
|
|
@ -10,6 +10,11 @@ import { safeLocalStorage } from '../util/local'
|
|||
export const cloudFunction = <RequestData, ResponseData>(name: string) =>
|
||||
httpsCallable<RequestData, ResponseData>(functions, name)
|
||||
|
||||
export const withdrawLiquidity = cloudFunction<
|
||||
{ contractId: string },
|
||||
{ status: 'error' | 'success'; userShares: { [outcome: string]: number } }
|
||||
>('withdrawLiquidity')
|
||||
|
||||
export const createFold = cloudFunction<
|
||||
{ name: string; about: string; tags: string[] },
|
||||
{ status: 'error' | 'success'; message?: string; fold?: Fold }
|
||||
|
|
17
web/lib/firebase/liquidity.ts
Normal file
17
web/lib/firebase/liquidity.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { collection, query } from 'firebase/firestore'
|
||||
|
||||
import { db } from './init'
|
||||
import { listenForValues } from './utils'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
||||
export function listenForLiquidity(
|
||||
contractId: string,
|
||||
setLiquidity: (lps: LiquidityProvision[]) => void
|
||||
) {
|
||||
const lpQuery = query(collection(db, 'contracts', contractId, 'liquidity'))
|
||||
|
||||
return listenForValues<LiquidityProvision>(lpQuery, (lps) => {
|
||||
lps.sort((lp1, lp2) => lp1.createdTime - lp2.createdTime)
|
||||
setLiquidity(lps)
|
||||
})
|
||||
}
|
Loading…
Reference in New Issue
Block a user