diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 6be7b1b2..2c110861 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -14,6 +14,7 @@ export const DEV_CONFIG: EnvConfig = { }, functionEndpoints: { placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app', + sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app', createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', }, } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 909afec1..6538aa42 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -1,4 +1,4 @@ -export type V2CloudFunction = 'placebet' | 'createmarket' +export type V2CloudFunction = 'placebet' | 'sellshares' | 'createmarket' export type EnvConfig = { domain: string @@ -42,6 +42,7 @@ export const PROD_CONFIG: EnvConfig = { }, functionEndpoints: { placebet: 'https://placebet-nggbo3neva-uc.a.run.app', + sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', }, adminEmails: [ diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts index 19c64eef..a2ddba51 100644 --- a/common/envs/theoremone.ts +++ b/common/envs/theoremone.ts @@ -15,6 +15,7 @@ export const THEOREMONE_CONFIG: EnvConfig = { // TODO: fill in real endpoints for T1 functionEndpoints: { placebet: 'https://placebet-nggbo3neva-uc.a.run.app', + sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', }, adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], diff --git a/common/sell-bet.ts b/common/sell-bet.ts index 233e478f..3070399b 100644 --- a/common/sell-bet.ts +++ b/common/sell-bet.ts @@ -9,6 +9,8 @@ import { CPMMContract, DPMContract } from './contract' import { DPM_CREATOR_FEE, DPM_PLATFORM_FEE, Fees } from './fees' import { User } from './user' +export type CandidateBet = Omit + export const getSellBetInfo = ( user: User, bet: Bet, @@ -84,12 +86,10 @@ export const getSellBetInfo = ( } export const getCpmmSellBetInfo = ( - user: User, shares: number, outcome: 'YES' | 'NO', contract: CPMMContract, - prevLoanAmount: number, - newBetId: string + prevLoanAmount: number ) => { const { pool, p } = contract @@ -100,8 +100,6 @@ export const getCpmmSellBetInfo = ( ) const loanPaid = Math.min(prevLoanAmount, saleValue) - const netAmount = saleValue - loanPaid - const probBefore = getCpmmProbability(pool, p) const probAfter = getCpmmProbability(newPool, p) @@ -115,9 +113,7 @@ export const getCpmmSellBetInfo = ( fees.creatorFee ) - const newBet: Bet = { - id: newBetId, - userId: user.id, + const newBet: CandidateBet = { contractId: contract.id, amount: -saleValue, shares: -shares, @@ -129,13 +125,10 @@ export const getCpmmSellBetInfo = ( fees, } - const newBalance = user.balance + netAmount - return { newBet, newPool, newP, - newBalance, fees, } } diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index e6bd66ab..9a2240a5 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -1,114 +1,90 @@ import { partition, sumBy } from 'lodash' import * as admin from 'firebase-admin' -import * as functions from 'firebase-functions' +import { z } from 'zod' -import { BinaryContract } from '../../common/contract' +import { APIError, newEndpoint, validate } from './api' +import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' import { getValues } from './utils' import { Bet } from '../../common/bet' -export const sellShares = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - contractId: string - shares: number - outcome: 'YES' | 'NO' - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } +const bodySchema = z.object({ + contractId: z.string(), + shares: z.number(), + outcome: z.enum(['YES', 'NO']), +}) - const { contractId, shares, outcome } = data +export const sellshares = newEndpoint(['POST'], async (req, [bettor, _]) => { + const { contractId, shares, outcome } = validate(bodySchema, req.body) - // 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/${bettor.id}`) + 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 BinaryContract - const { closeTime, mechanism, collectedFees, volume } = contract + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as Contract + const { closeTime, mechanism, collectedFees, volume } = contract - if (mechanism !== 'cpmm-1') - return { - status: 'error', - message: 'Sell shares only works with mechanism cpmm-1', - } + if (mechanism !== 'cpmm-1') + throw new APIError(400, 'You can only sell shares on CPMM-1 contracts.') + if (closeTime && Date.now() > closeTime) + throw new APIError(400, 'Trading is closed.') - if (closeTime && Date.now() > closeTime) - return { status: 'error', message: 'Trading is closed' } + const userBets = await getValues( + contractDoc.collection('bets').where('userId', '==', bettor.id) + ) - const userBets = await getValues( - contractDoc.collection('bets').where('userId', '==', userId) - ) + const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) - const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0) + const [yesBets, noBets] = partition( + userBets ?? [], + (bet) => bet.outcome === 'YES' + ) + const [yesShares, noShares] = [ + sumBy(yesBets, (bet) => bet.shares), + sumBy(noBets, (bet) => bet.shares), + ] - const [yesBets, noBets] = partition( - userBets ?? [], - (bet) => bet.outcome === 'YES' - ) - const [yesShares, noShares] = [ - sumBy(yesBets, (bet) => bet.shares), - sumBy(noBets, (bet) => bet.shares), - ] + const maxShares = outcome === 'YES' ? yesShares : noShares + if (shares > maxShares + 0.000000000001) + throw new APIError(400, `You can only sell up to ${maxShares} shares.`) - const maxShares = outcome === 'YES' ? yesShares : noShares - if (shares > maxShares + 0.000000000001) { - return { - status: 'error', - message: `You can only sell ${maxShares} shares`, - } - } + const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( + shares, + outcome, + contract, + prevLoanAmount + ) - const newBetDoc = firestore - .collection(`contracts/${contractId}/bets`) - .doc() + if (!isFinite(newP)) { + throw new APIError(500, 'Trade rejected due to overflow error.') + } - const { newBet, newPool, newP, newBalance, fees } = getCpmmSellBetInfo( - user, - shares, - outcome, - contract, - prevLoanAmount, - newBetDoc.id - ) + const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() + const newBalance = user.balance - newBet.amount + (newBet.loanAmount ?? 0) + const userId = user.id - if (!isFinite(newP)) { - return { - status: 'error', - message: 'Trade rejected due to overflow error.', - } - } + transaction.update(userDoc, { balance: newBalance }) + transaction.create(newBetDoc, { id: newBetDoc.id, userId, ...newBet }) + transaction.update( + contractDoc, + removeUndefinedProps({ + pool: newPool, + p: newP, + collectedFees: addObjects(fees, collectedFees), + volume: volume + Math.abs(newBet.amount), + }) + ) - if (!isFinite(newBalance)) { - throw new Error('Invalid user balance for ' + user.username) - } - - transaction.update(userDoc, { balance: newBalance }) - transaction.create(newBetDoc, newBet) - transaction.update( - contractDoc, - removeUndefinedProps({ - pool: newPool, - p: newP, - collectedFees: addObjects(fees, collectedFees), - volume: volume + Math.abs(newBet.amount), - }) - ) - - return { status: 'success' } - }) - } -) + return { status: 'success' } + }) +}) const firestore = admin.firestore() diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 129356a1..109cddc2 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -17,7 +17,7 @@ import { Title } from './title' import { User } from 'web/lib/firebase/users' import { Bet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api-call' -import { sellShares } from 'web/lib/firebase/fn-call' +import { sellShares } from 'web/lib/firebase/api-call' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' import { BinaryOutcomeLabel } from './outcome-label' @@ -398,23 +398,27 @@ export function SellPanel(props: { // Sell all shares if remaining shares would be < 1 const sellAmount = amount === Math.floor(shares) ? shares : amount - const result = await sellShares({ + await sellShares({ shares: sellAmount, outcome: sharesOutcome, contractId: contract.id, - }).then((r) => r.data) - - console.log('Sold shares. Result:', result) - - if (result?.status === 'success') { - setIsSubmitting(false) - setWasSubmitted(true) - setAmount(undefined) - if (onSellSuccess) onSellSuccess() - } else { - setError(result?.message || 'Error selling') - setIsSubmitting(false) - } + }) + .then((r) => { + console.log('Sold shares. Result:', r) + setIsSubmitting(false) + setWasSubmitted(true) + setAmount(undefined) + if (onSellSuccess) onSellSuccess() + }) + .catch((e) => { + if (e instanceof APIError) { + setError(e.toString()) + } else { + console.error(e) + setError('Error selling') + } + setIsSubmitting(false) + }) } const initialProb = getProbability(contract) diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index c5633c0a..58b151e1 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -22,7 +22,7 @@ import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' import { OUTCOME_TO_COLOR } from '../outcome-label' import { useSaveShares } from '../use-save-shares' -import { sellShares } from 'web/lib/firebase/fn-call' +import { sellShares } from 'web/lib/firebase/api-call' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' const BET_SIZE = 10 diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index 0e833d1c..898ca3e7 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -50,3 +50,7 @@ export function createMarket(params: any) { export function placeBet(params: any) { return call(getFunctionUrl('placebet'), 'POST', params) } + +export function sellShares(params: any) { + return call(getFunctionUrl('sellshares'), 'POST', params) +} diff --git a/web/lib/firebase/fn-call.ts b/web/lib/firebase/fn-call.ts index 3713c4e5..1cb259b1 100644 --- a/web/lib/firebase/fn-call.ts +++ b/web/lib/firebase/fn-call.ts @@ -22,11 +22,6 @@ export const transact = cloudFunction< export const sellBet = cloudFunction('sellBet') -export const sellShares = cloudFunction< - { contractId: string; shares: number; outcome: 'YES' | 'NO' }, - { status: 'error' | 'success'; message?: string } ->('sellShares') - export const createAnswer = cloudFunction< { contractId: string; text: string; amount: number }, {