diff --git a/common/charity.ts b/common/charity.ts index 0ebeeec1..16d73831 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -595,7 +595,8 @@ In addition to housing impact litigation, we provide free legal aid, education a photo: 'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2Fdefault%2Fci2h3hStFM.47?alt=media&token=0d2cdc3d-e4d8-4f5e-8f23-4a586b6ff637', preview: 'Donate supplies to soldiers in Ukraine', - description: 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.', + description: + 'Donate supplies to soldiers in Ukraine, including tourniquets and plate carriers.', }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') diff --git a/common/envs/dev.ts b/common/envs/dev.ts index ff3fd37d..78c48264 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -16,7 +16,6 @@ export const DEV_CONFIG: EnvConfig = { cloudRunId: 'w3txbmd3ba', cloudRunRegion: 'uc', amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', - // this is Phil's deployment - twitchBotEndpoint: 'https://king-prawn-app-5btyw.ondigitalocean.app', + twitchBotEndpoint: 'https://dev-twitch-bot.manifold.markets', sprigEnvironmentId: 'Tu7kRZPm7daP', } diff --git a/common/globalConfig.ts b/common/globalConfig.ts new file mode 100644 index 00000000..fc3e25e7 --- /dev/null +++ b/common/globalConfig.ts @@ -0,0 +1,3 @@ +export type GlobalConfig = { + pinnedItems: { itemId: string; type: 'post' | 'contract' }[] +} diff --git a/common/package.json b/common/package.json index 52195398..11f92e89 100644 --- a/common/package.json +++ b/common/package.json @@ -8,11 +8,13 @@ }, "sideEffects": false, "dependencies": { - "@tiptap/core": "2.0.0-beta.182", - "@tiptap/extension-image": "2.0.0-beta.30", - "@tiptap/extension-link": "2.0.0-beta.43", - "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.191", + "@tiptap/core": "2.0.0-beta.199", + "@tiptap/extension-image": "2.0.0-beta.199", + "@tiptap/extension-link": "2.0.0-beta.199", + "@tiptap/extension-mention": "2.0.0-beta.199", + "@tiptap/html": "2.0.0-beta.199", + "@tiptap/starter-kit": "2.0.0-beta.199", + "@tiptap/suggestion": "2.0.0-beta.199", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/payouts-fixed.ts b/common/payouts-fixed.ts index 74e9fe16..a08a5acf 100644 --- a/common/payouts-fixed.ts +++ b/common/payouts-fixed.ts @@ -56,7 +56,8 @@ export const getLiquidityPoolPayouts = ( liquidities: LiquidityProvision[] ) => { const { pool, subsidyPool } = contract - const finalPool = pool[outcome] + subsidyPool + const finalPool = pool[outcome] + (subsidyPool ?? 0) + if (finalPool < 1e-3) return [] const weights = getCpmmLiquidityPoolWeights(liquidities) @@ -95,7 +96,8 @@ export const getLiquidityPoolProbPayouts = ( liquidities: LiquidityProvision[] ) => { const { pool, subsidyPool } = contract - const finalPool = p * pool.YES + (1 - p) * pool.NO + subsidyPool + const finalPool = p * pool.YES + (1 - p) * pool.NO + (subsidyPool ?? 0) + if (finalPool < 1e-3) return [] const weights = getCpmmLiquidityPoolWeights(liquidities) diff --git a/common/post.ts b/common/post.ts index 77130a2c..4549b3be 100644 --- a/common/post.ts +++ b/common/post.ts @@ -8,6 +8,11 @@ export type Post = { creatorId: string // User id createdTime: number slug: string + + // denormalized user fields + creatorName: string + creatorUsername: string + creatorAvatarUrl?: string } export type DateDoc = Post & { diff --git a/common/util/parse.ts b/common/util/parse.ts index 53874c9e..102a9e90 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,5 @@ -import { generateText, JSONContent } from '@tiptap/core' +import { generateText, JSONContent, Node } from '@tiptap/core' +import { generateJSON } from '@tiptap/html' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -23,7 +24,7 @@ import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' -import { cloneDeep, uniq } from 'lodash' +import { uniq } from 'lodash' import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ @@ -51,8 +52,28 @@ export function parseMentions(data: JSONContent): string[] { return uniq(mentions) } -// can't just do [StarterKit, Image...] because it doesn't work with cjs imports +// TODO: this is a hack to get around the fact that tiptap doesn't have a +// way to add a node view without bundling in tsx +function skippableComponent(name: string): Node { + return Node.create({ + name, + + group: 'block', + + content: 'inline*', + + parseHTML() { + return [ + { + tag: 'grid-cards-component', + }, + ] + }, + }) +} + const stringParseExts = [ + // StarterKit extensions Blockquote, Bold, BulletList, @@ -69,38 +90,25 @@ const stringParseExts = [ Paragraph, Strike, Text, - - Image, + // other extensions Link, + Image.extend({ renderText: () => '[image]' }), Mention, // user @mention Mention.extend({ name: 'contract-mention' }), // market %mention - Iframe, - TiptapTweet, - TiptapSpoiler, + Iframe.extend({ + renderText: ({ node }) => + '[embed]' + node.attrs.src ? `(${node.attrs.src})` : '', + }), + skippableComponent('gridCardsComponent'), + TiptapTweet.extend({ renderText: () => '[tweet]' }), + TiptapSpoiler.extend({ renderHTML: () => ['span', '[spoiler]', 0] }), ] export function richTextToString(text?: JSONContent) { if (!text) return '' - // remove spoiler tags. - const newText = cloneDeep(text) - dfs(newText, (current) => { - if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) { - current.text = '[spoiler]' - } else if (current.type === 'image') { - current.text = '[Image]' - // This is a hack, I've no idea how to change a tiptap extenstion's schema - current.type = 'text' - } else if (current.type === 'iframe') { - const src = current.attrs?.['src'] ? current.attrs['src'] : '' - current.text = '[Iframe]' + (src ? ` url:${src}` : '') - // This is a hack, I've no idea how to change a tiptap extenstion's schema - current.type = 'text' - } - }) - return generateText(newText, stringParseExts) + return generateText(text, stringParseExts) } -const dfs = (data: JSONContent, f: (current: JSONContent) => any) => { - data.content?.forEach((d) => dfs(d, f)) - f(data) +export function htmlToRichText(html: string) { + return generateJSON(html, stringParseExts) } diff --git a/docs/docs/api.md b/docs/docs/api.md index d25a18be..5caf9f86 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -680,6 +680,17 @@ $ curl https://manifold.markets/api/v0/market/{marketId}/sell -X POST \ --data-raw '{"outcome": "YES", "shares": 10}' ``` +### `POST /v0/comment` + +Creates a comment in the specified market. Only supports top-level comments for now. + +Parameters: + +- `contractId`: Required. The ID of the market to comment on. +- `content`: The comment to post, formatted as [TipTap json](https://tiptap.dev/guide/output#option-1-json), OR +- `html`: The comment to post, formatted as an HTML string, OR +- `markdown`: The comment to post, formatted as a markdown string. + ### `GET /v0/bets` Gets a list of bets, ordered by creation date descending. diff --git a/firestore.rules b/firestore.rules index 993791b2..bfcb7183 100644 --- a/firestore.rules +++ b/firestore.rules @@ -23,6 +23,12 @@ service cloud.firestore { allow read; } + match /globalConfig/globalConfig { + allow read; + allow update: if isAdmin() + allow create: if isAdmin() + } + match /users/{userId} { allow read; allow update: if userId == request.auth.uid @@ -104,7 +110,7 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks', 'flaggedByUsernames']); allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) && resource.data.creatorId == request.auth.uid; diff --git a/functions/package.json b/functions/package.json index cd2a9ec5..399b307a 100644 --- a/functions/package.json +++ b/functions/package.json @@ -15,9 +15,9 @@ "dev": "nodemon src/serve.ts", "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", - "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", + "db:update-local-from-remote": "yarn db:backup-remote && gsutil -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", - "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", + "db:rename-remote-backup-folder": "gsutil -m mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "verify": "(cd .. && yarn verify)", "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" @@ -26,11 +26,13 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", - "@tiptap/core": "2.0.0-beta.182", - "@tiptap/extension-image": "2.0.0-beta.30", - "@tiptap/extension-link": "2.0.0-beta.43", - "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.191", + "@tiptap/core": "2.0.0-beta.199", + "@tiptap/extension-image": "2.0.0-beta.199", + "@tiptap/extension-link": "2.0.0-beta.199", + "@tiptap/extension-mention": "2.0.0-beta.199", + "@tiptap/html": "2.0.0-beta.199", + "@tiptap/starter-kit": "2.0.0-beta.199", + "@tiptap/suggestion": "2.0.0-beta.199", "cors": "2.8.5", "dayjs": "1.11.4", "express": "4.18.1", @@ -38,6 +40,7 @@ "firebase-functions": "3.21.2", "lodash": "4.17.21", "mailgun-js": "0.22.0", + "marked": "4.1.1", "module-alias": "2.2.2", "node-fetch": "2", "stripe": "8.194.0", @@ -45,6 +48,7 @@ }, "devDependencies": { "@types/mailgun-js": "0.22.12", + "@types/marked": "4.0.7", "@types/module-alias": "2.0.1", "@types/node-fetch": "2.6.2", "firebase-functions-test": "0.3.3", diff --git a/functions/src/create-comment.ts b/functions/src/create-comment.ts new file mode 100644 index 00000000..e0191276 --- /dev/null +++ b/functions/src/create-comment.ts @@ -0,0 +1,105 @@ +import * as admin from 'firebase-admin' + +import { getContract, getUser, log } from './utils' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { htmlToRichText } from '../../common/util/parse' +import { marked } from 'marked' + +const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const postSchema = z.object({ + contractId: z.string(), + content: contentSchema.optional(), + html: z.string().optional(), + markdown: z.string().optional(), +}) + +const MAX_COMMENT_JSON_LENGTH = 20000 + +// For now, only supports creating a new top-level comment on a contract. +// Replies, posts, chats are not supported yet. +export const createcomment = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { contractId, content, html, markdown } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + const contract = await getContract(contractId) + + if (!creator) { + throw new APIError(400, 'No user exists with the authenticated user ID.') + } + if (!contract) { + throw new APIError(400, 'No contract exists with the given ID.') + } + + let contentJson = null + if (content) { + contentJson = content + } else if (html) { + console.log('html', html) + contentJson = htmlToRichText(html) + } else if (markdown) { + const markedParse = marked.parse(markdown) + log('parsed', markedParse) + contentJson = htmlToRichText(markedParse) + log('json', contentJson) + } + + if (!contentJson) { + throw new APIError(400, 'No comment content provided.') + } + + if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) { + throw new APIError( + 400, + `Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.` + ) + } + + const ref = firestore.collection(`contracts/${contractId}/comments`).doc() + + const comment = removeUndefinedProps({ + id: ref.id, + content: contentJson, + createdTime: Date.now(), + + userId: creator.id, + userName: creator.name, + userUsername: creator.username, + userAvatarUrl: creator.avatarUrl, + + // OnContract fields + commentType: 'contract', + contractId: contractId, + contractSlug: contract.slug, + contractQuestion: contract.question, + }) + + await ref.set(comment) + + return { status: 'success', comment } +}) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 204105ac..e04ddedc 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -197,6 +197,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( return await notificationRef.set(removeUndefinedProps(notification)) } + const needNotFollowContractReasons = ['tagged_user'] const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } @@ -205,7 +206,12 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( userId: string, reason: notification_reason_types ) => { - if (!stillFollowingContract(userId) || sourceUser.id == userId) return + if ( + (!stillFollowingContract(userId) && + !needNotFollowContractReasons.includes(reason)) || + sourceUser.id == userId + ) + return const privateUser = await getPrivateUser(userId) if (!privateUser) return const { sendToBrowser, sendToEmail } = getNotificationDestinationsForUser( diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index d1864ac2..0bdb0894 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -100,6 +100,9 @@ export const createpost = newEndpoint({}, async (req, auth) => { createdTime: Date.now(), content: content, contractSlug, + creatorName: creator.name, + creatorUsername: creator.username, + creatorAvatarUrl: creator.avatarUrl, }) await postRef.create(post) diff --git a/functions/src/index.ts b/functions/src/index.ts index b64155a3..c4e9e5f7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -65,6 +65,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' +import { createcomment } from './create-comment' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -94,6 +95,7 @@ const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addSubsidyFunction = toCloudFunction(addsubsidy) const addCommentBounty = toCloudFunction(addcommentbounty) +const createCommentFunction = toCloudFunction(createcomment) const awardCommentBounty = toCloudFunction(awardcommentbounty) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -130,6 +132,7 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + createCommentFunction as createcomment, addCommentBounty as addcommentbounty, awardCommentBounty as awardcommentbounty, updateMetricsFunction as updatemetrics, diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index f0aa0252..cef4d99f 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -39,7 +39,8 @@ export const onUpdateContract = functions.firestore async function handleResolvedContract(contract: Contract) { if ( (contract.uniqueBettorCount ?? 0) < - MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE + MINIMUM_UNIQUE_BETTORS_FOR_PROVEN_CORRECT_BADGE || + contract.resolution === 'CANCEL' ) return diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 4230f0ac..16870699 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' @@ -36,6 +36,7 @@ import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, } from '../../common/antes' +import { User } from 'common/user' const bodySchema = z.object({ contractId: z.string(), @@ -89,13 +90,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { if (!contractSnap.exists) throw new APIError(404, 'No contract exists with the provided ID') const contract = contractSnap.data() as Contract - const { creatorId, closeTime } = contract + const { creatorId } = contract const firebaseUser = await admin.auth().getUser(auth.uid) - const { value, resolutions, probabilityInt, outcome } = getResolutionParams( - contract, - req.body - ) + const resolutionParams = getResolutionParams(contract, req.body) if ( creatorId !== auth.uid && @@ -109,6 +107,16 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const creator = await getUser(creatorId) if (!creator) throw new APIError(500, 'Creator not found') + return await resolveMarket(contract, creator, resolutionParams) +}) + +export const resolveMarket = async ( + contract: Contract, + creator: User, + { value, resolutions, probabilityInt, outcome }: ResolutionParams +) => { + const { creatorId, closeTime, id: contractId } = contract + const resolutionProbability = probabilityInt !== undefined ? probabilityInt / 100 : undefined @@ -131,15 +139,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,33 +168,47 @@ 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 payoutsWithoutLoans = [ + { userId: creatorId, payout: creatorPayout, deposit: creatorPayout }, + ...liquidityPayouts.map((p) => ({ ...p, deposit: p.payout })), + ...traderPayouts, + ] + const payouts = [...payoutsWithoutLoans, ...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 + const contractDoc = firestore.doc(`contracts/${contractId}`) - 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) + const userPayoutsWithoutLoans = groupPayoutsByUser(payoutsWithoutLoans) const userInvestments = mapValues( groupBy(bets, (bet) => bet.userId), @@ -209,18 +235,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) { @@ -287,6 +301,8 @@ function getResolutionParams(contract: Contract, body: string) { throw new APIError(500, `Invalid outcome type: ${outcomeType}`) } +type ResolutionParams = ReturnType + function validateAnswer( contract: FreeResponseContract | MultipleChoiceContract, answer: number diff --git a/functions/src/scripts/backfill-subsidy-pool.ts b/functions/src/scripts/backfill-subsidy-pool.ts new file mode 100644 index 00000000..092e026d --- /dev/null +++ b/functions/src/scripts/backfill-subsidy-pool.ts @@ -0,0 +1,24 @@ +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' + +initAdmin() +const firestore = admin.firestore() + +if (require.main === module) { + const contractsRef = firestore.collection('contracts') + contractsRef.get().then(async (contractsSnaps) => { + + console.log(`Loaded ${contractsSnaps.size} contracts.`) + + const needsFilling = contractsSnaps.docs.filter((ct) => { + return !('subsidyPool' in ct.data()) + }) + + console.log(`Found ${needsFilling.length} contracts to update.`) + await Promise.all( + needsFilling.map((ct) => ct.ref.update({ subsidyPool: 0 })) + ) + + console.log(`Updated all contracts.`) + }) +} diff --git a/functions/src/scripts/resolve-markets-again.ts b/functions/src/scripts/resolve-markets-again.ts new file mode 100644 index 00000000..c1ff3156 --- /dev/null +++ b/functions/src/scripts/resolve-markets-again.ts @@ -0,0 +1,59 @@ +import { initAdmin } from './script-init' +initAdmin() + +import { zip } from 'lodash' +import { filterDefined } from 'common/util/array' +import { resolveMarket } from '../resolve-market' +import { getContract, getUser } from '../utils' + +if (require.main === module) { + const contractIds = process.argv.slice(2) + if (contractIds.length === 0) { + throw new Error('No contract ids provided') + } + resolveMarketsAgain(contractIds).then(() => process.exit(0)) +} + +async function resolveMarketsAgain(contractIds: string[]) { + const maybeContracts = await Promise.all(contractIds.map(getContract)) + if (maybeContracts.some((c) => !c)) { + throw new Error('Invalid contract id') + } + const contracts = filterDefined(maybeContracts) + + const maybeCreators = await Promise.all( + contracts.map((c) => getUser(c.creatorId)) + ) + if (maybeCreators.some((c) => !c)) { + throw new Error('No creator found') + } + const creators = filterDefined(maybeCreators) + + if ( + !contracts.every((c) => c.resolution === 'YES' || c.resolution === 'NO') + ) { + throw new Error('Only YES or NO resolutions supported') + } + + const resolutionParams = contracts.map((c) => ({ + outcome: c.resolution as string, + value: undefined, + probabilityInt: undefined, + resolutions: undefined, + })) + + const params = zip(contracts, creators, resolutionParams) + + for (const [contract, creator, resolutionParams] of params) { + if (contract && creator && resolutionParams) { + console.log('Resolving', contract.question) + try { + await resolveMarket(contract, creator, resolutionParams) + } catch (e) { + console.log(e) + } + } + } + + console.log(`Resolved all contracts.`) +} diff --git a/functions/src/serve.ts b/functions/src/serve.ts index bc09029d..597e144c 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -19,6 +19,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' +import { createcomment } from './create-comment' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' @@ -53,6 +54,7 @@ addJsonEndpointRoute('/transact', transact) addJsonEndpointRoute('/changeuserinfo', changeuserinfo) addJsonEndpointRoute('/createuser', createuser) addJsonEndpointRoute('/createanswer', createanswer) +addJsonEndpointRoute('/createcomment', createcomment) addJsonEndpointRoute('/placebet', placebet) addJsonEndpointRoute('/cancelbet', cancelbet) addJsonEndpointRoute('/sellbet', sellbet) 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) => { diff --git a/web/.gitignore b/web/.gitignore index 32d4a19e..06749214 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -2,4 +2,5 @@ .next node_modules out -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +.env* diff --git a/web/components/add-funds-modal.tsx b/web/components/add-funds-modal.tsx index cac21f96..335410c7 100644 --- a/web/components/add-funds-modal.tsx +++ b/web/components/add-funds-modal.tsx @@ -35,7 +35,7 @@ export function AddFundsModal(props: {
{manaToUSD(amountSelected)}
-
+
diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 8cd43369..cae63cf2 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -7,6 +7,8 @@ import { ENV_CONFIG } from 'common/envs/constants' import { Row } from './layout/row' import { AddFundsModal } from './add-funds-modal' import { Input } from './input' +import Slider from 'rc-slider' +import 'rc-slider/assets/index.css' export function AmountInput(props: { amount: number | undefined @@ -40,18 +42,13 @@ export function AmountInput(props: { return ( <> - + {error && ( -
+
{error === 'Insufficient balance' ? ( <> Not enough funds. @@ -149,7 +147,7 @@ export function BuyAmountInput(props: { return ( <> - + {showSlider && ( - onAmountChange(parseRaw(parseInt(e.target.value)))} - className="range range-lg only-thumb my-auto align-middle xl:hidden" - step="5" + onChange={(value) => onAmountChange(parseRaw(value as number))} + className="mx-4 !h-4 xl:hidden [&>.rc-slider-rail]:bg-gray-200 [&>.rc-slider-track]:bg-indigo-400 [&>.rc-slider-handle]:bg-indigo-400" + railStyle={{ height: 16, top: 0, left: 0 }} + trackStyle={{ height: 16, top: 0 }} + handleStyle={{ + height: 32, + width: 32, + opacity: 1, + border: 'none', + boxShadow: 'none', + top: -2, + }} + step={5} /> )} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index cbe7bc1f..68ec16b8 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -126,7 +126,10 @@ export function AnswerBetPanel(props: {
{!isModal && ( -
))} {showChoice ? ( -
-