From efba2221eba278026a0c1f3da4cf6c5cb19296b8 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Thu, 7 Jul 2022 14:57:10 -0700 Subject: [PATCH] Migrate addLiquidity and withdrawLiquidity functions to v2 --- functions/src/add-liquidity.ts | 149 +++++++++++----------- functions/src/index.ts | 2 +- functions/src/withdraw-liquidity.ts | 188 ++++++++++++---------------- web/components/liquidity-panel.tsx | 14 +-- web/lib/firebase/api-call.ts | 8 ++ web/lib/firebase/fn-call.ts | 11 -- 6 files changed, 167 insertions(+), 205 deletions(-) diff --git a/functions/src/add-liquidity.ts b/functions/src/add-liquidity.ts index eca0a056..3ef453c2 100644 --- a/functions/src/add-liquidity.ts +++ b/functions/src/add-liquidity.ts @@ -1,105 +1,96 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { Contract } from '../../common/contract' import { User } from '../../common/user' import { removeUndefinedProps } from '../../common/util/object' import { redeemShares } from './redeem-shares' import { getNewLiquidityProvision } from '../../common/add-liquidity' +import { APIError, newEndpoint, validate } from './api' -export const addLiquidity = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - amount: number - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) - const { amount, contractId } = data +export const addliquidity = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // run as transaction to prevent race conditions - return await firestore - .runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) - return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User + // run as transaction to prevent race conditions + return await firestore + .runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) - if (!contractSnap.exists) - return { status: 'error', message: 'Invalid contract' } - const contract = contractSnap.data() as Contract - if ( - contract.mechanism !== 'cpmm-1' || - (contract.outcomeType !== 'BINARY' && - contract.outcomeType !== 'PSEUDO_NUMERIC') - ) - return { status: 'error', message: 'Invalid contract' } + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + if ( + contract.mechanism !== 'cpmm-1' || + (contract.outcomeType !== 'BINARY' && + contract.outcomeType !== 'PSEUDO_NUMERIC') + ) + throw new APIError(400, 'Invalid contract') - const { closeTime } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - const newLiquidityProvisionDoc = firestore - .collection(`contracts/${contractId}/liquidity`) - .doc() + const newLiquidityProvisionDoc = firestore + .collection(`contracts/${contractId}/liquidity`) + .doc() - const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = - getNewLiquidityProvision( - user, - amount, - contract, - newLiquidityProvisionDoc.id - ) - - if (newP !== undefined && !isFinite(newP)) { - return { - status: 'error', - message: 'Liquidity injection rejected due to overflow error.', - } - } - - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - totalLiquidity: newTotalLiquidity, - }) + const { newLiquidityProvision, newPool, newP, newTotalLiquidity } = + getNewLiquidityProvision( + user, + amount, + contract, + newLiquidityProvisionDoc.id ) - const newBalance = user.balance - amount - const newTotalDeposits = user.totalDeposits - amount - - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) + if (newP !== undefined && !isFinite(newP)) { + return { + status: 'error', + message: 'Liquidity injection rejected due to overflow error.', } + } - transaction.update(userDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + totalLiquidity: newTotalLiquidity, }) + ) - transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + const newBalance = user.balance - amount + const newTotalDeposits = user.totalDeposits - amount - return { status: 'success', newLiquidityProvision } + if (!isFinite(newBalance)) { + throw new APIError(500, 'Invalid user balance for ' + user.username) + } + + transaction.update(userDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, }) - .then(async (result) => { - await redeemShares(userId, contractId) - return result - }) - } -) + + transaction.create(newLiquidityProvisionDoc, newLiquidityProvision) + + return newLiquidityProvision + }) + .then(async (result) => { + await redeemShares(auth.uid, contractId) + return result + }) +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 08639c7c..2800bb7d 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,7 +16,6 @@ export * from './update-metrics' export * from './update-stats' export * from './backup-db' export * from './market-close-notifications' -export * from './add-liquidity' export * from './on-create-answer' export * from './on-update-contract' export * from './on-create-contract' @@ -36,6 +35,7 @@ export * from './place-bet' export * from './sell-bet' export * from './sell-shares' export * from './create-contract' +export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index cc8c84cf..1bdb19de 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { CPMMContract } from '../../common/contract' import { User } from '../../common/user' @@ -10,129 +10,107 @@ import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' import { noFees } from '../../common/fees' -import { APIError } from './api' +import { APIError, newEndpoint, validate } from './api' import { redeemShares } from './redeem-shares' -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 bodySchema = z.object({ + contractId: z.string(), +}) - const { contractId } = data - if (!contractId) - return { status: 'error', message: 'Missing contract id' } +export const withdrawliquidity = newEndpoint({}, async (req, auth) => { + const { contractId } = validate(bodySchema, req.body) - return 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 + return await firestore + .runTransaction(async (trans) => { + const lpDoc = firestore.doc(`users/${auth.uid}`) + 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 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 liquidityCollection = firestore.collection( + `contracts/${contractId}/liquidity` + ) - const liquiditiesSnap = await trans.get(liquidityCollection) + const liquiditiesSnap = await trans.get(liquidityCollection) - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) - const userShares = getUserLiquidityShares( - userId, - contract, - liquidities - ) + const userShares = getUserLiquidityShares(auth.uid, 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 })) + // 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 === auth.uid + ) + .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} + const payout = Math.min(...Object.values(userShares)) + if (payout <= 0) return {} - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial) + const newBalance = lp.balance + payout + const newTotalDeposits = lp.totalDeposits + payout + trans.update(lpDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + } as Partial) - const newPool = subtractObjects(contract.pool, userShares) + const newPool = subtractObjects(contract.pool, userShares) - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout + const minPoolShares = Math.min(...Object.values(newPool)) + const adjustedTotal = contract.totalLiquidity - payout - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) + // total liquidity is a bogus number; use minPoolShares to prevent from going negative + const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) + trans.update(contractDoc, { + pool: newPool, + totalLiquidity: newTotalLiquidity, + }) - const prob = getProbability(contract) + 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: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit) - ) - .filter((x) => x !== undefined) + // 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: auth.uid, + contractId: contract.id, + amount: + (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), + shares: shares - payout, + outcome, + probBefore: prob, + probAfter: prob, + createdTime: Date.now(), + isLiquidityProvision: true, + 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 }) - } + for (const bet of bets) { + const doc = firestore.collection(`contracts/${contract.id}/bets`).doc() + trans.create(doc, { id: doc.id, ...bet }) + } - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(userId, contractId) - - console.log('userid', userId, 'withdraws', result) - return { status: 'success', userShares: result } - }) - .catch((e) => { - return { status: 'error', message: e.message } - }) - } - ) + return userShares + }) + .then(async (result) => { + // redeem surplus bet with pre-existing bets + await redeemShares(auth.uid, contractId) + console.log('userid', auth.uid, 'withdraws', result) + return result + }) +}) const firestore = admin.firestore() diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 33efb335..d1e066be 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -4,7 +4,7 @@ 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 { addLiquidity, withdrawLiquidity } from 'web/lib/firebase/api-call' import { AmountInput } from './amount-input' import { Row } from './layout/row' import { useUserLiquidity } from 'web/hooks/use-liquidity' @@ -90,14 +90,10 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { setIsSuccess(false) addLiquidity({ amount, contractId }) - .then((r) => { - if (r.status === 'success') { - setIsSuccess(true) - setError(undefined) - setIsLoading(false) - } else { - setError('Server error') - } + .then((_) => { + setIsSuccess(true) + setError(undefined) + setIsLoading(false) }) .catch((_) => setError('Server error')) diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 341e92b0..d169ea72 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -54,6 +54,14 @@ export function changeUserInfo(params: any) { return call(getFunctionUrl('changeuserinfo'), 'POST', params) } +export function addLiquidity(params: any) { + return call(getFunctionUrl('addliquidity'), 'POST', params) +} + +export function withdrawLiquidity(params: any) { + return call(getFunctionUrl('withdrawliquidity'), 'POST', params) +} + export function createMarket(params: any) { return call(getFunctionUrl('createmarket'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index b9b771b5..3b16af70 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -9,11 +9,6 @@ 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 transact = cloudFunction< Omit, { status: 'error' | 'success'; message?: string; txn?: Txn } @@ -42,12 +37,6 @@ export const createUser: () => Promise = () => { .catch(() => null) } -export const addLiquidity = (data: { amount: number; contractId: string }) => { - return cloudFunction('addLiquidity')(data) - .then((r) => r.data as { status: string }) - .catch((e) => ({ status: 'error', message: e.message })) -} - export const claimManalink = cloudFunction< string, { status: 'error' | 'success'; message?: string }