From ae39c1175b515a2eb08d8c32aba46bdcda1517a2 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 12 Oct 2022 16:21:37 -0500 Subject: [PATCH] Better resolve market payouts (#1038) * Check payout preconditions first. Try to pay out market in 1 transaction. * Format * toBatch => lodash's chunk --- functions/src/resolve-market.ts | 77 ++++++++++++------------ functions/src/utils.ts | 100 ++++++++++++++++++++++++-------- 2 files changed, 117 insertions(+), 60 deletions(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 4230f0ac..f29ff124 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy, uniqBy } from 'lodash' import { Contract, @@ -15,14 +15,14 @@ import { getValues, isProd, log, - payUser, + payUsers, + payUsersMultipleTransactions, revalidateStaticProps, } from './utils' import { getLoanPayouts, getPayouts, groupPayoutsByUser, - Payout, } from '../../common/payouts' import { isAdmin, isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' @@ -131,15 +131,19 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { (doc) => doc.data() as LiquidityProvision ) - const { payouts, creatorPayout, liquidityPayouts, collectedFees } = - getPayouts( - outcome, - contract, - bets, - liquidities, - resolutions, - resolutionProbability - ) + const { + payouts: traderPayouts, + creatorPayout, + liquidityPayouts, + collectedFees, + } = getPayouts( + outcome, + contract, + bets, + liquidities, + resolutions, + resolutionProbability + ) const updatedContract = { ...contract, @@ -156,30 +160,43 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { subsidyPool: 0, } - await contractDoc.update(updatedContract) - - console.log('contract ', contractId, 'resolved to:', outcome) - const openBets = bets.filter((b) => !b.isSold && !b.sale) const loanPayouts = getLoanPayouts(openBets) + const payouts = [ + { userId: creatorId, payout: creatorPayout, deposit: creatorPayout }, + ...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })), + ...traderPayouts, + ...loanPayouts, + ] + if (!isProd()) console.log( - 'payouts:', - payouts, + 'trader payouts:', + traderPayouts, 'creator payout:', creatorPayout, - 'liquidity payout:' + 'liquidity payout:', + liquidityPayouts, + 'loan payouts:', + loanPayouts ) - if (creatorPayout) - await processPayouts([{ userId: creatorId, payout: creatorPayout }], true) + const userCount = uniqBy(payouts, 'userId').length - await processPayouts(liquidityPayouts, true) + if (userCount <= 499) { + await firestore.runTransaction(async (transaction) => { + payUsers(transaction, payouts) + transaction.update(contractDoc, updatedContract) + }) + } else { + await payUsersMultipleTransactions(payouts) + await contractDoc.update(updatedContract) + } + + console.log('contract ', contractId, 'resolved to:', outcome) - await processPayouts([...payouts, ...loanPayouts]) await undoUniqueBettorRewardsIfCancelResolution(contract, outcome) - await revalidateStaticProps(getContractPath(contract)) const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) @@ -211,18 +228,6 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { return updatedContract }) -const processPayouts = async (payouts: Payout[], isDeposit = false) => { - const userPayouts = groupPayoutsByUser(payouts) - - const payoutPromises = Object.entries(userPayouts).map(([userId, payout]) => - payUser(userId, payout, isDeposit) - ) - - return await Promise.all(payoutPromises) - .catch((e) => ({ status: 'error', message: e })) - .then(() => ({ status: 'success' })) -} - function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/utils.ts b/functions/src/utils.ts index e0cd269a..9516db64 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -1,7 +1,8 @@ import * as admin from 'firebase-admin' import fetch from 'node-fetch' +import { FieldValue, Transaction } from 'firebase-admin/firestore' +import { chunk, groupBy, mapValues, sumBy } from 'lodash' -import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' import { Group } from '../../common/group' @@ -128,38 +129,29 @@ export const getUserByUsername = async (username: string) => { return snap.empty ? undefined : (snap.docs[0].data() as User) } +const firestore = admin.firestore() + const updateUserBalance = ( + transaction: Transaction, userId: string, - delta: number, - isDeposit = false + balanceDelta: number, + depositDelta: number ) => { - const firestore = admin.firestore() - return firestore.runTransaction(async (transaction) => { - const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) - if (!userSnap.exists) return - const user = userSnap.data() as User + const userDoc = firestore.doc(`users/${userId}`) - const newUserBalance = user.balance + delta - - // if (newUserBalance < 0) - // throw new Error( - // `User (${userId}) balance cannot be negative: ${newUserBalance}` - // ) - - if (isDeposit) { - const newTotalDeposits = (user.totalDeposits || 0) + delta - transaction.update(userDoc, { totalDeposits: newTotalDeposits }) - } - - transaction.update(userDoc, { balance: newUserBalance }) + // Note: Balance is allowed to go negative. + transaction.update(userDoc, { + balance: FieldValue.increment(balanceDelta), + totalDeposits: FieldValue.increment(depositDelta), }) } export const payUser = (userId: string, payout: number, isDeposit = false) => { if (!isFinite(payout)) throw new Error('Payout is not finite: ' + payout) - return updateUserBalance(userId, payout, isDeposit) + return firestore.runTransaction(async (transaction) => { + updateUserBalance(transaction, userId, payout, isDeposit ? payout : 0) + }) } export const chargeUser = ( @@ -170,7 +162,67 @@ export const chargeUser = ( if (!isFinite(charge) || charge <= 0) throw new Error('User charge is not positive: ' + charge) - return updateUserBalance(userId, -charge, isAnte) + return payUser(userId, -charge, isAnte) +} + +const checkAndMergePayouts = ( + payouts: { + userId: string + payout: number + deposit?: number + }[] +) => { + for (const { payout, deposit } of payouts) { + if (!isFinite(payout)) { + throw new Error('Payout is not finite: ' + payout) + } + if (deposit !== undefined && !isFinite(deposit)) { + throw new Error('Deposit is not finite: ' + deposit) + } + } + + const groupedPayouts = groupBy(payouts, 'userId') + return Object.values( + mapValues(groupedPayouts, (payouts, userId) => ({ + userId, + payout: sumBy(payouts, 'payout'), + deposit: sumBy(payouts, (p) => p.deposit ?? 0), + })) + ) +} + +// Max 500 users in one transaction. +export const payUsers = ( + transaction: Transaction, + payouts: { + userId: string + payout: number + deposit?: number + }[] +) => { + const mergedPayouts = checkAndMergePayouts(payouts) + for (const { userId, payout, deposit } of mergedPayouts) { + updateUserBalance(transaction, userId, payout, deposit) + } +} + +export const payUsersMultipleTransactions = async ( + payouts: { + userId: string + payout: number + deposit?: number + }[] +) => { + const mergedPayouts = checkAndMergePayouts(payouts) + const payoutChunks = chunk(mergedPayouts, 500) + + for (const payoutChunk of payoutChunks) { + await firestore.runTransaction(async (transaction) => { + for (const { userId, payout, deposit } of payoutChunk) { + updateUserBalance(transaction, userId, payout, deposit) + } + }) + } } export const getContractPath = (contract: Contract) => {