From 0cd9943e0d5e2138ebe0262fd1305cd9cbbc5706 Mon Sep 17 00:00:00 2001 From: mantikoros <95266179+mantikoros@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:00:49 -0500 Subject: [PATCH] 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 --- common/calculate-cpmm.ts | 56 +++-- common/util/object.ts | 15 ++ firestore.rules | 4 + functions/src/index.ts | 1 + functions/src/withdraw-liquidity.ts | 111 ++++++++++ web/components/add-liquidity-panel.tsx | 87 -------- .../contract/contract-info-dialog.tsx | 13 +- web/components/liquidity-panel.tsx | 199 ++++++++++++++++++ web/hooks/use-liquidity.ts | 26 +++ web/lib/firebase/fn-call.ts | 5 + web/lib/firebase/liquidity.ts | 17 ++ 11 files changed, 407 insertions(+), 127 deletions(-) create mode 100644 functions/src/withdraw-liquidity.ts delete mode 100644 web/components/add-liquidity-panel.tsx create mode 100644 web/components/liquidity-panel.tsx create mode 100644 web/hooks/use-liquidity.ts create mode 100644 web/lib/firebase/liquidity.ts diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 485f32c8..e7d56ba3 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -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) +} diff --git a/common/util/object.ts b/common/util/object.ts index c970cb24..5596286e 100644 --- a/common/util/object.ts +++ b/common/util/object.ts @@ -23,3 +23,18 @@ export const addObjects = ( return newObj as T } + +export const subtractObjects = ( + 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 +} + diff --git a/firestore.rules b/firestore.rules index b4a58074..3516de02 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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)); diff --git a/functions/src/index.ts b/functions/src/index.ts index 85e6e779..ecc17133 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -35,3 +35,4 @@ export * from './place-bet' export * from './sell-bet' export * from './sell-shares' export * from './create-contract' +export * from './withdraw-liquidity' \ No newline at end of file diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts new file mode 100644 index 00000000..a3b402a9 --- /dev/null +++ b/functions/src/withdraw-liquidity.ts @@ -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) + ) + .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() diff --git a/web/components/add-liquidity-panel.tsx b/web/components/add-liquidity-panel.tsx deleted file mode 100644 index c1deb637..00000000 --- a/web/components/add-liquidity-panel.tsx +++ /dev/null @@ -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(undefined) - const [error, setError] = useState(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 ( - <> -
- Subsidize this market by adding liquidity for traders. -
- - - - - - - {isSuccess && amount && ( -
Success! Added {formatMoney(amount)} in liquidity.
- )} - - {isLoading &&
Processing...
} - - ) -} diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 446d04e7..979997e1 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -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[] }) {
- {contract.mechanism === 'cpmm-1' && - !contract.resolution && - (!closeTime || closeTime > Date.now()) && ( - <> -
Add liquidity
- - - )} + {contract.mechanism === 'cpmm-1' && !contract.resolution && ( + + )} diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx new file mode 100644 index 00000000..e78376bc --- /dev/null +++ b/web/components/liquidity-panel.tsx @@ -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 ( + , + }, + ...(showWithdrawal + ? [ + { + title: 'Withdraw liquidity', + content: ( + + ), + }, + ] + : []), + ]} + /> + ) +} + +function AddLiquidityPanel(props: { contract: CPMMContract }) { + const { contract } = props + const { id: contractId } = contract + + const user = useUser() + + const [amount, setAmount] = useState(undefined) + const [error, setError] = useState(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 ( + <> +
+ Subsidize this market by adding liquidity for traders. +
+ + + + + + + {isSuccess && amount && ( +
Success! Added {formatMoney(amount)} in liquidity.
+ )} + + {isLoading &&
Processing...
} + + ) +} + +function WithdrawLiquidityPanel(props: { + contract: CPMMContract + lpShares: { YES: number; NO: number } +}) { + const { contract, lpShares } = props + const { YES: yesShares, NO: noShares } = lpShares + + const [error, setError] = useState(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 ( +
+ Success! Your liquidity was withdrawn. +
+ ) + + if (!yesShares && !noShares) + return ( +
+ You do not have any liquidity positions to withdraw. +
+ ) + + return ( + +
+ Your liquidity position is currently: +
+ + + {yesShares.toFixed(2)} shares + + + + {noShares.toFixed(2)} shares + + + + + + + {isLoading &&
Processing...
} + + ) +} diff --git a/web/hooks/use-liquidity.ts b/web/hooks/use-liquidity.ts new file mode 100644 index 00000000..9c610f3b --- /dev/null +++ b/web/hooks/use-liquidity.ts @@ -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 +} diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 198e59f8..deee709b 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -10,6 +10,11 @@ import { safeLocalStorage } from '../util/local' export const cloudFunction = (name: string) => httpsCallable(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 } diff --git a/web/lib/firebase/liquidity.ts b/web/lib/firebase/liquidity.ts new file mode 100644 index 00000000..28712c0c --- /dev/null +++ b/web/lib/firebase/liquidity.ts @@ -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(lpQuery, (lps) => { + lps.sort((lp1, lp2) => lp1.createdTime - lp2.createdTime) + setLiquidity(lps) + }) +}