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:
mantikoros 2022-06-08 13:00:49 -05:00 committed by GitHub
parent 45eb5a3e63
commit 0cd9943e0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 407 additions and 127 deletions

View File

@ -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,27 +260,30 @@ export function addCpmmLiquidity(
return { newPool, liquidity, newP }
}
const calculateLiquidityDelta = (p: number) => (l: LiquidityProvision) => {
const oldLiquidity = getCpmmLiquidity(l.pool, p)
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
const newLiquidity = getCpmmLiquidity(newPool, p)
const liquidity = newLiquidity - oldLiquidity
return liquidity
}
export function getCpmmLiquidityPoolWeights(
contract: CPMMContract,
liquidities: LiquidityProvision[]
) {
const { p } = contract
const [antes, nonAntes] = partition(liquidities, (l) => !!l.isAnte)
const liquidityShares = liquidities.map((l) => {
const oldLiquidity = getCpmmLiquidity(l.pool, p)
const calcLiqudity = calculateLiquidityDelta(contract.p)
const liquidityShares = nonAntes.map(calcLiqudity)
const newPool = addObjects(l.pool, { YES: l.amount, NO: l.amount })
const newLiquidity = getCpmmLiquidity(newPool, p)
const liquidity = newLiquidity - oldLiquidity
return liquidity
})
const shareSum = sum(liquidityShares)
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)
}

View File

@ -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
}

View File

@ -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));

View File

@ -35,3 +35,4 @@ export * from './place-bet'
export * from './sell-bet'
export * from './sell-shares'
export * from './create-contract'
export * from './withdraw-liquidity'

View 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()

View File

@ -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>}
</>
)
}

View File

@ -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,14 +113,9 @@ 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>
</>

View 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>
)
}

View 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
}

View File

@ -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 }

View 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)
})
}