diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index ac591f38..73f3deda 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -64,11 +64,7 @@ function calculateCpmmShares( : n + bet - (k * (bet + y) ** -p) ** (1 / (1 - p)) } -export function getCpmmLiquidityFee( - state: CpmmState, - bet: number, - outcome: string -) { +export function getCpmmFees(state: CpmmState, bet: number, outcome: string) { const prob = getCpmmProbabilityAfterBetBeforeFees(state, outcome, bet) const betP = outcome === 'YES' ? 1 - prob : prob @@ -89,7 +85,7 @@ export function calculateCpmmSharesAfterFee( outcome: string ) { const { pool, p } = state - const { remainingBet } = getCpmmLiquidityFee(state, bet, outcome) + const { remainingBet } = getCpmmFees(state, bet, outcome) return calculateCpmmShares(pool, p, remainingBet, outcome) } @@ -100,9 +96,7 @@ export function calculateCpmmPurchase( outcome: string ) { const { pool, p } = state - const { remainingBet, fees } = getCpmmLiquidityFee(state, bet, outcome) - // const remainingBet = bet - // const fees = noFees + const { remainingBet, fees } = getCpmmFees(state, bet, outcome) const shares = calculateCpmmShares(pool, p, remainingBet, outcome) const { YES: y, NO: n } = pool diff --git a/common/new-bet.ts b/common/new-bet.ts index 6025a2f8..ea5c7f89 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -76,14 +76,14 @@ const computeFill = ( ? Math.min(matchedBet.limitProb, limitProb ?? 1) : Math.max(matchedBet.limitProb, limitProb ?? 0) - const poolAmount = + const buyAmount = limit === undefined ? amount : Math.min(amount, calculateCpmmAmount(cpmmState, limit, outcome)) const { shares, newPool, newP, fees } = calculateCpmmPurchase( cpmmState, - poolAmount, + buyAmount, outcome ) const newState = { pool: newPool, p: newP } @@ -92,7 +92,7 @@ const computeFill = ( maker: { matchedBetId: null, shares, - amount: poolAmount, + amount: buyAmount, state: newState, fees, timestamp, @@ -100,7 +100,7 @@ const computeFill = ( taker: { matchedBetId: null, shares, - amount: poolAmount, + amount: buyAmount, timestamp, }, } 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/change-user-info.ts b/functions/src/change-user-info.ts index 118d5c67..aa041856 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -1,5 +1,5 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { getUser } from './utils' import { Contract } from '../../common/contract' @@ -11,37 +11,23 @@ import { } from '../../common/util/clean-username' import { removeUndefinedProps } from '../../common/util/object' import { Answer } from '../../common/answer' +import { APIError, newEndpoint, validate } from './api' -export const changeUserInfo = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - username?: string - name?: string - avatarUrl?: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + username: z.string().optional(), + name: z.string().optional(), + avatarUrl: z.string().optional(), +}) - const user = await getUser(userId) - if (!user) return { status: 'error', message: 'User not found' } +export const changeuserinfo = newEndpoint({}, async (req, auth) => { + const { username, name, avatarUrl } = validate(bodySchema, req.body) - const { username, name, avatarUrl } = data + const user = await getUser(auth.uid) + if (!user) throw new APIError(400, 'User not found') - return await changeUser(user, { username, name, avatarUrl }) - .then(() => { - console.log('succesfully changed', user.username, 'to', data) - return { status: 'success' } - }) - .catch((e) => { - console.log('Error', e.message) - return { status: 'error', message: e.message } - }) - } - ) + await changeUser(user, { username, name, avatarUrl }) + return { message: 'Successfully changed user info.' } +}) export const changeUser = async ( user: User, @@ -55,14 +41,14 @@ export const changeUser = async ( if (update.username) { update.username = cleanUsername(update.username) if (!update.username) { - throw new Error('Invalid username') + throw new APIError(400, 'Invalid username') } const sameNameUser = await transaction.get( firestore.collection('users').where('username', '==', update.username) ) if (!sameNameUser.empty) { - throw new Error('Username already exists') + throw new APIError(400, 'Username already exists') } } @@ -104,17 +90,10 @@ export const changeUser = async ( ) const answerUpdate: Partial = removeUndefinedProps(update) - await transaction.update(userRef, userUpdate) - - await Promise.all( - commentSnap.docs.map((d) => transaction.update(d.ref, commentUpdate)) - ) - - await Promise.all( - answerSnap.docs.map((d) => transaction.update(d.ref, answerUpdate)) - ) - - await contracts.docs.map((d) => transaction.update(d.ref, contractUpdate)) + transaction.update(userRef, userUpdate) + commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate)) + answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate)) + contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate)) }) } diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts index 4bcd8b16..3822bbf7 100644 --- a/functions/src/claim-manalink.ts +++ b/functions/src/claim-manalink.ts @@ -1,102 +1,104 @@ -import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' +import { z } from 'zod' import { User } from 'common/user' import { Manalink } from 'common/manalink' import { runTxn, TxnData } from './transact' +import { APIError, newEndpoint, validate } from './api' -export const claimManalink = functions - .runWith({ minInstances: 1 }) - .https.onCall(async (slug: string, context) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + slug: z.string(), +}) - // Run as transaction to prevent race conditions. - return await firestore.runTransaction(async (transaction) => { - // Look up the manalink - const manalinkDoc = firestore.doc(`manalinks/${slug}`) - const manalinkSnap = await transaction.get(manalinkDoc) - if (!manalinkSnap.exists) { - return { status: 'error', message: 'Manalink not found' } - } - const manalink = manalinkSnap.data() as Manalink +export const claimmanalink = newEndpoint({}, async (req, auth) => { + const { slug } = validate(bodySchema, req.body) - const { amount, fromId, claimedUserIds } = manalink + // Run as transaction to prevent race conditions. + return await firestore.runTransaction(async (transaction) => { + // Look up the manalink + const manalinkDoc = firestore.doc(`manalinks/${slug}`) + const manalinkSnap = await transaction.get(manalinkDoc) + if (!manalinkSnap.exists) { + throw new APIError(400, 'Manalink not found') + } + const manalink = manalinkSnap.data() as Manalink - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } + const { amount, fromId, claimedUserIds } = manalink - const fromDoc = firestore.doc(`users/${fromId}`) - const fromSnap = await transaction.get(fromDoc) - if (!fromSnap.exists) { - return { status: 'error', message: `User ${fromId} not found` } - } - const fromUser = fromSnap.data() as User + if (amount <= 0 || isNaN(amount) || !isFinite(amount)) + throw new APIError(500, 'Invalid amount') - // Only permit one redemption per user per link - if (claimedUserIds.includes(userId)) { - return { - status: 'error', - message: `${fromUser.name} already redeemed manalink ${slug}`, - } - } + const fromDoc = firestore.doc(`users/${fromId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + throw new APIError(500, `User ${fromId} not found`) + } + const fromUser = fromSnap.data() as User - // Disallow expired or maxed out links - if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { - return { - status: 'error', - message: `Manalink ${slug} expired on ${new Date( - manalink.expiresTime - ).toLocaleString()}`, - } - } - if ( - manalink.maxUses != null && - manalink.maxUses <= manalink.claims.length - ) { - return { - status: 'error', - message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`, - } - } + // Only permit one redemption per user per link + if (claimedUserIds.includes(auth.uid)) { + throw new APIError(400, `You already redeemed manalink ${slug}`) + } - if (fromUser.balance < amount) { - return { - status: 'error', - message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `, - } - } + // Disallow expired or maxed out links + if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) { + throw new APIError( + 400, + `Manalink ${slug} expired on ${new Date( + manalink.expiresTime + ).toLocaleString()}` + ) + } + if ( + manalink.maxUses != null && + manalink.maxUses <= manalink.claims.length + ) { + throw new APIError( + 400, + `Manalink ${slug} has reached its max uses of ${manalink.maxUses}` + ) + } - // Actually execute the txn - const data: TxnData = { - fromId, - fromType: 'USER', - toId: userId, - toType: 'USER', - amount, - token: 'M$', - category: 'MANALINK', - description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`, - } - const result = await runTxn(transaction, data) - const txnId = result.txn?.id - if (!txnId) { - return { status: 'error', message: result.message } - } + if (fromUser.balance < amount) { + throw new APIError( + 400, + `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} ` + ) + } - // Update the manalink object with this info - const claim = { - toId: userId, - txnId, - claimedTime: Date.now(), - } - transaction.update(manalinkDoc, { - claimedUserIds: [...claimedUserIds, userId], - claims: [...manalink.claims, claim], - }) + // Actually execute the txn + const data: TxnData = { + fromId, + fromType: 'USER', + toId: auth.uid, + toType: 'USER', + amount, + token: 'M$', + category: 'MANALINK', + description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${auth.uid}`, + } + const result = await runTxn(transaction, data) + const txnId = result.txn?.id + if (!txnId) { + throw new APIError( + 500, + result.message ?? 'An error occurred posting the transaction.' + ) + } - return { status: 'success', message: 'Manalink claimed' } + // Update the manalink object with this info + const claim = { + toId: auth.uid, + txnId, + claimedTime: Date.now(), + } + transaction.update(manalinkDoc, { + claimedUserIds: [...claimedUserIds, auth.uid], + claims: [...manalink.claims, claim], }) + + return { message: 'Manalink claimed' } }) +}) const firestore = admin.firestore() diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index cf3867b0..2abaf44d 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -1,5 +1,5 @@ -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' @@ -7,122 +7,103 @@ import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' import { getContract, getValues } from './utils' import { sendNewAnswerEmail } from './emails' +import { APIError, newEndpoint, validate } from './api' -export const createAnswer = functions - .runWith({ minInstances: 1, secrets: ['MAILGUN_KEY'] }) - .https.onCall( - async ( - data: { - contractId: string - amount: number - text: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string().max(MAX_ANSWER_LENGTH), + amount: z.number().gt(0), + text: z.string(), +}) - const { contractId, amount, text } = data +const opts = { secrets: ['MAILGUN_KEY'] } - if (amount <= 0 || isNaN(amount) || !isFinite(amount)) - return { status: 'error', message: 'Invalid amount' } +export const createanswer = newEndpoint(opts, async (req, auth) => { + const { contractId, amount, text } = validate(bodySchema, req.body) - if (!text || typeof text !== 'string' || text.length > MAX_ANSWER_LENGTH) - return { status: 'error', message: 'Invalid text' } + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') - // Run as transaction to prevent race conditions. - const result = 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. + const answer = 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 - if (user.balance < amount) - return { status: 'error', message: 'Insufficient balance' } + if (user.balance < amount) throw new APIError(400, 'Insufficient balance') - 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 + 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.outcomeType !== 'FREE_RESPONSE') - return { - status: 'error', - message: 'Requires a free response contract', - } + if (contract.outcomeType !== 'FREE_RESPONSE') + throw new APIError(400, 'Requires a free response contract') - const { closeTime, volume } = contract - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const { closeTime, volume } = contract + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed') - const [lastAnswer] = await getValues( - firestore - .collection(`contracts/${contractId}/answers`) - .orderBy('number', 'desc') - .limit(1) - ) + const [lastAnswer] = await getValues( + firestore + .collection(`contracts/${contractId}/answers`) + .orderBy('number', 'desc') + .limit(1) + ) - if (!lastAnswer) - return { status: 'error', message: 'Could not fetch last answer' } + if (!lastAnswer) throw new APIError(500, 'Could not fetch last answer') - const number = lastAnswer.number + 1 - const id = `${number}` + const number = lastAnswer.number + 1 + const id = `${number}` - const newAnswerDoc = firestore - .collection(`contracts/${contractId}/answers`) - .doc(id) + const newAnswerDoc = firestore + .collection(`contracts/${contractId}/answers`) + .doc(id) - const answerId = newAnswerDoc.id - const { username, name, avatarUrl } = user + const answerId = newAnswerDoc.id + const { username, name, avatarUrl } = user - const answer: Answer = { - id, - number, - contractId, - createdTime: Date.now(), - userId: user.id, - username, - name, - avatarUrl, - text, - } - transaction.create(newAnswerDoc, answer) - - const loanAmount = 0 - - const { newBet, newPool, newTotalShares, newTotalBets } = - getNewMultiBetInfo(answerId, amount, contract, loanAmount) - - const newBalance = user.balance - amount - const betDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() - transaction.create(betDoc, { - id: betDoc.id, - userId: user.id, - ...newBet, - }) - transaction.update(userDoc, { balance: newBalance }) - transaction.update(contractDoc, { - pool: newPool, - totalShares: newTotalShares, - totalBets: newTotalBets, - answers: [...(contract.answers ?? []), answer], - volume: volume + amount, - }) - - return { status: 'success', answerId, betId: betDoc.id, answer } - }) - - const { answer } = result - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - - return result + const answer: Answer = { + id, + number, + contractId, + createdTime: Date.now(), + userId: user.id, + username, + name, + avatarUrl, + text, } - ) + transaction.create(newAnswerDoc, answer) + + const loanAmount = 0 + + const { newBet, newPool, newTotalShares, newTotalBets } = + getNewMultiBetInfo(answerId, amount, contract, loanAmount) + + const newBalance = user.balance - amount + const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc() + transaction.create(betDoc, { + id: betDoc.id, + userId: user.id, + ...newBet, + }) + transaction.update(userDoc, { balance: newBalance }) + transaction.update(contractDoc, { + pool: newPool, + totalShares: newTotalShares, + totalBets: newTotalBets, + answers: [...(contract.answers ?? []), answer], + volume: volume + amount, + }) + + return answer + }) + + const contract = await getContract(contractId) + + if (answer && contract) await sendNewAnswerEmail(answer, contract) + + return answer +}) const firestore = admin.firestore() diff --git a/functions/src/index.ts b/functions/src/index.ts index 5b0bf89b..d3e9b42f 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,12 +3,9 @@ import * as admin from 'firebase-admin' admin.initializeApp() // v1 -// export * from './keep-awake' -export * from './claim-manalink' export * from './transact' export * from './stripe' export * from './create-user' -export * from './create-answer' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' @@ -16,9 +13,7 @@ export * from './unsubscribe' export * from './update-metrics' export * from './update-stats' export * from './backup-db' -export * from './change-user-info' export * from './market-close-notifications' -export * from './add-liquidity' export * from './on-create-answer' export * from './on-update-contract' export * from './on-create-contract' @@ -33,11 +28,15 @@ export * from './on-create-txn' // v2 export * from './health' +export * from './change-user-info' +export * from './create-answer' export * from './place-bet' export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' +export * from './claim-manalink' 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/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index ed9012c9..41745b09 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -6,7 +6,7 @@ import { findBestMatch } from 'string-similarity' import { FreeResponseContract } from 'common/contract' import { BuyAmountInput } from '../amount-input' import { Col } from '../layout/col' -import { createAnswer } from 'web/lib/firebase/fn-call' +import { APIError, createAnswer } from 'web/lib/firebase/api-call' import { Row } from '../layout/row' import { formatMoney, @@ -46,20 +46,23 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (canSubmit) { setIsSubmitting(true) - const result = await createAnswer({ - contractId: contract.id, - text, - amount: betAmount, - }).then((r) => r.data) - - setIsSubmitting(false) - - if (result.status === 'success') { + try { + await createAnswer({ + contractId: contract.id, + text, + amount: betAmount, + }) setText('') setBetAmount(10) setAmountError(undefined) setPossibleDuplicateAnswer(undefined) - } else setAmountError(result.message) + } catch (e) { + if (e instanceof APIError) { + setAmountError(e.toString()) + } + } + + setIsSubmitting(false) } } diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 84ac0584..fa044ac9 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -30,7 +30,7 @@ import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability, - getCpmmLiquidityFee, + getCpmmFees, } from 'common/calculate-cpmm' import { getFormattedMappedValue, @@ -356,7 +356,7 @@ function BuyPanel(props: { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) - const cpmmFees = getCpmmLiquidityFee( + const cpmmFees = getCpmmFees( contract, betAmount ?? 0, betChoice ?? 'YES' diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 9f40fb93..513ff8d6 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -134,6 +134,13 @@ export function QuickBet(props: { contract: Contract; user: User }) { }) } + if (outcomeType === 'FREE_RESPONSE') + return ( + + + + ) + return ( { - 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 d93068dd..695117f9 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -50,6 +50,21 @@ export function getFunctionUrl(name: string) { } } +export function createAnswer(params: any) { + return call(getFunctionUrl('createanswer'), 'POST', params) +} +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) } @@ -74,6 +89,10 @@ export function sellBet(params: any) { return call(getFunctionUrl('sellbet'), 'POST', params) } +export function claimManalink(params: any) { + return call(getFunctionUrl('claimmanalink'), 'POST', params) +} + export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index ce78ac3a..27a5e8f3 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -9,26 +9,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 transact = cloudFunction< Omit, { status: 'error' | 'success'; message?: string; txn?: Txn } >('transact') -export const createAnswer = cloudFunction< - { contractId: string; text: string; amount: number }, - { - status: 'error' | 'success' - message?: string - answerId?: string - betId?: string - } ->('createAnswer') - export const createUser: () => Promise = () => { const local = safeLocalStorage() let deviceToken = local?.getItem('device-token') @@ -41,24 +26,3 @@ export const createUser: () => Promise = () => { .then((r) => (r.data as any)?.user || null) .catch(() => null) } - -export const changeUserInfo = (data: { - username?: string - name?: string - avatarUrl?: string -}) => { - return cloudFunction('changeUserInfo')(data) - .then((r) => r.data as { status: string; message?: string }) - .catch((e) => ({ status: 'error', message: e.message })) -} - -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 } ->('claimManalink') diff --git a/web/pages/link/[slug].tsx b/web/pages/link/[slug].tsx index eed68e1a..b36a9057 100644 --- a/web/pages/link/[slug].tsx +++ b/web/pages/link/[slug].tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { useState } from 'react' import { SEO } from 'web/components/SEO' import { Title } from 'web/components/title' -import { claimManalink } from 'web/lib/firebase/fn-call' +import { claimManalink } from 'web/lib/firebase/api-call' import { useManalink } from 'web/lib/firebase/manalinks' import { ManalinkCard } from 'web/components/manalink-card' import { useUser } from 'web/hooks/use-user' @@ -42,10 +42,7 @@ export default function ClaimPage() { if (user == null) { await firebaseLogin() } - const result = await claimManalink(manalink.slug) - if (result.data.status == 'error') { - throw new Error(result.data.message) - } + await claimManalink({ slug: manalink.slug }) user && router.push(`/${user.username}?claimed-mana=yes`) } catch (e) { console.log(e) diff --git a/web/pages/profile.tsx b/web/pages/profile.tsx index ac06eaf2..62177825 100644 --- a/web/pages/profile.tsx +++ b/web/pages/profile.tsx @@ -9,7 +9,7 @@ import { Title } from 'web/components/title' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { cleanDisplayName, cleanUsername } from 'common/util/clean-username' -import { changeUserInfo } from 'web/lib/firebase/fn-call' +import { changeUserInfo } from 'web/lib/firebase/api-call' import { uploadImage } from 'web/lib/firebase/storage' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' @@ -85,12 +85,9 @@ export default function ProfilePage() { if (newName) { setName(newName) - - await changeUserInfo({ name: newName }) - .catch(() => ({ status: 'error' })) - .then((r) => { - if (r.status === 'error') setName(user?.name || '') - }) + await changeUserInfo({ name: newName }).catch((_) => + setName(user?.name || '') + ) } else { setName(user?.name || '') } @@ -101,11 +98,9 @@ export default function ProfilePage() { if (newUsername) { setUsername(newUsername) - await changeUserInfo({ username: newUsername }) - .catch(() => ({ status: 'error' })) - .then((r) => { - if (r.status === 'error') setUsername(user?.username || '') - }) + await changeUserInfo({ username: newUsername }).catch((_) => + setUsername(user?.username || '') + ) } else { setUsername(user?.username || '') }