diff --git a/common/quadratic-funding.ts b/common/quadratic-funding.ts new file mode 100644 index 00000000..844e81a5 --- /dev/null +++ b/common/quadratic-funding.ts @@ -0,0 +1,27 @@ +import { groupBy, mapValues, sum, sumBy } from 'lodash' +import { Txn } from './txn' + +// Returns a map of charity ids to the amount of M$ matched +export function quadraticMatches( + allCharityTxns: Txn[], + matchingPool: number +): Record { + // For each charity, group the donations by each individual donor + const donationsByCharity = groupBy(allCharityTxns, 'toId') + const donationsByDonors = mapValues(donationsByCharity, (txns) => + groupBy(txns, 'fromId') + ) + + // Weight for each charity = [sum of sqrt(individual donor)] ^ 2 + const weights = mapValues(donationsByDonors, (byDonor) => { + const sumByDonor = Object.values(byDonor).map((txns) => + sumBy(txns, 'amount') + ) + const sumOfRoots = sumBy(sumByDonor, Math.sqrt) + return sumOfRoots ** 2 + }) + + // Then distribute the matching pool based on the individual weights + const totalWeight = sum(Object.values(weights)) + return mapValues(weights, (weight) => matchingPool * (weight / totalWeight)) +} diff --git a/common/util/math.ts b/common/util/math.ts index a255c19f..a89b31e1 100644 --- a/common/util/math.ts +++ b/common/util/math.ts @@ -1,3 +1,5 @@ +import { sortBy } from 'lodash' + export const logInterpolation = (min: number, max: number, value: number) => { if (value <= min) return 0 if (value >= max) return 1 @@ -16,4 +18,15 @@ export function normpdf(x: number, mean = 0, variance = 1) { ) } -const TAU = Math.PI * 2 +export const TAU = Math.PI * 2 + +export function median(values: number[]) { + if (values.length === 0) return NaN + + const sorted = sortBy(values, (x) => x) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] +} diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 1d0a4c23..dd4e2ec5 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { APIError, newEndpoint, validate } from './api' -import { Contract } from '../../common/contract' +import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' @@ -57,8 +57,12 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { prevLoanAmount ) - if (!isFinite(newP)) { - throw new APIError(500, 'Trade rejected due to overflow error.') + if ( + !newP || + !isFinite(newP) || + Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY + ) { + throw new APIError(400, 'Sale too large for current liquidity pool.') } const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 6c34b521..2f3ac31a 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -8,8 +8,15 @@ module.exports = { ], rules: { '@typescript-eslint/no-empty-function': 'off', - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], '@next/next/no-img-element': 'off', '@next/next/no-typos': 'off', 'lodash/import-scope': [2, 'member'], diff --git a/web/components/add-funds-button.tsx b/web/components/add-funds-button.tsx index 566f4716..14c9b063 100644 --- a/web/components/add-funds-button.tsx +++ b/web/components/add-funds-button.tsx @@ -24,7 +24,7 @@ export function AddFundsButton(props: { className?: string }) { className )} > - Add funds + Get M$ diff --git a/web/components/alert-box.tsx b/web/components/alert-box.tsx new file mode 100644 index 00000000..a8306583 --- /dev/null +++ b/web/components/alert-box.tsx @@ -0,0 +1,24 @@ +import { ExclamationIcon } from '@heroicons/react/solid' +import { Linkify } from './linkify' + +export function AlertBox(props: { title: string; text: string }) { + const { title, text } = props + return ( +
+
+
+
+
+

{title}

+
+ +
+
+
+
+ ) +} diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index c711abe8..e7bf4da8 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -1,5 +1,5 @@ import { sortBy, partition, sum, uniq } from 'lodash' -import { useLayoutEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { FreeResponseContract } from 'common/contract' import { Col } from '../layout/col' @@ -85,7 +85,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) { }) } - useLayoutEffect(() => { + useEffect(() => { setChosenAnswers({}) }, [resolveOption]) @@ -116,7 +116,7 @@ export function AnswersPanel(props: { contract: FreeResponseContract }) { {!resolveOption && (
- {answerItems.map((item, activityItemIdx) => ( + {answerItems.map((item) => (
diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index a4e90c35..d227ac88 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -138,9 +138,8 @@ export function BetsList(props: { user: User; hideBetsBefore?: number }) { return !hasSoldAll }) - const [settled, unsettled] = partition( - contracts, - (c) => c.isResolved || contractsMetrics[c.id].invested === 0 + const unsettled = contracts.filter( + (c) => !c.isResolved && contractsMetrics[c.id].invested !== 0 ) const currentInvested = sumBy( @@ -261,7 +260,7 @@ function ContractBets(props: { const isBinary = outcomeType === 'BINARY' - const { payout, profit, profitPercent, invested } = getContractBetMetrics( + const { payout, profit, profitPercent } = getContractBetMetrics( contract, bets ) @@ -657,7 +656,6 @@ function SellButton(props: { contract: Contract; bet: Bet }) { return ( txn.amount) @@ -32,14 +34,22 @@ export function CharityCard(props: { charity: Charity }) { {/*

{name}

*/}
{preview}
{raised > 0 && ( - - - {raised < 100 - ? manaToUSD(raised) - : '$' + Math.floor(raised / 100)} - - raised - + <> + + + + {formatUsd(raised)} + + raised + + {match && ( + + +{formatUsd(match)} + match + + )} + + )}
@@ -47,6 +57,10 @@ export function CharityCard(props: { charity: Charity }) { ) } +function formatUsd(mana: number) { + return mana < 100 ? manaToUSD(mana) : '$' + Math.floor(mana / 100) +} + function FeaturedBadge() { return ( diff --git a/web/components/choices-toggle-group.tsx b/web/components/choices-toggle-group.tsx index f974d72f..61c4e4fd 100644 --- a/web/components/choices-toggle-group.tsx +++ b/web/components/choices-toggle-group.tsx @@ -24,13 +24,12 @@ export function ChoicesToggleGroup(props: { null} + onChange={setChoice} > {Object.keys(choicesMap).map((choiceKey) => ( setChoice(choicesMap[choiceKey])} className={({ active }) => clsx( active ? 'ring-2 ring-indigo-500 ring-offset-2' : '', diff --git a/web/components/confirmation-button.tsx b/web/components/confirmation-button.tsx index e895467a..57a7bafe 100644 --- a/web/components/confirmation-button.tsx +++ b/web/components/confirmation-button.tsx @@ -5,7 +5,6 @@ import { Modal } from './layout/modal' import { Row } from './layout/row' export function ConfirmationButton(props: { - id: string openModalBtn: { label: string icon?: JSX.Element @@ -22,7 +21,7 @@ export function ConfirmationButton(props: { onSubmit: () => void children: ReactNode }) { - const { id, openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props + const { openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props const [open, setOpen] = useState(false) @@ -67,7 +66,6 @@ export function ResolveConfirmationButton(props: { props return ( @@ -257,7 +257,7 @@ function QuickOutcomeView(props: { // If there's a preview prob, display that instead of the current prob const override = previewProb === undefined ? undefined : formatPercent(previewProb) - const textColor = `text-${getColor(contract, previewProb)}` + const textColor = `text-${getColor(contract)}` let display: string | undefined switch (outcomeType) { @@ -306,7 +306,7 @@ function getNumericScale(contract: NumericContract) { return (ev - min) / (max - min) } -export function getColor(contract: Contract, previewProb?: number) { +export function getColor(contract: Contract) { // TODO: Try injecting a gradient here // return 'primary' const { resolution } = contract diff --git a/web/components/feed-create.tsx b/web/components/feed-create.tsx deleted file mode 100644 index 98b56d69..00000000 --- a/web/components/feed-create.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { sample } from 'lodash' -import { SparklesIcon, XIcon } from '@heroicons/react/solid' -import { Avatar } from './avatar' -import { useEffect, useRef, useState } from 'react' -import { Spacer } from './layout/spacer' -import { NewContract } from '../pages/create' -import { firebaseLogin, User } from 'web/lib/firebase/users' -import { ContractsGrid } from './contract/contracts-list' -import { Contract, MAX_QUESTION_LENGTH } from 'common/contract' -import { Col } from './layout/col' -import clsx from 'clsx' -import { Row } from './layout/row' -import { ENV_CONFIG } from '../../common/envs/constants' -import { SiteLink } from './site-link' -import { formatMoney } from 'common/util/format' - -export function FeedPromo(props: { hotContracts: Contract[] }) { - const { hotContracts } = props - - return ( - <> - - -

-
- Bet on{' '} - - anything! - -
-

- -
- Bet on any topic imaginable with play-money markets. Or create your - own! -
-
- Sign up and get {formatMoney(1000)} - worth $10 to your{' '} - - favorite charity. - -
-
- - {' '} - - - - - {}} - hasMore={false} - /> - - ) -} - -export default function FeedCreate(props: { - user?: User - tag?: string - placeholder?: string - className?: string -}) { - const { user, tag, className } = props - const [question, setQuestion] = useState('') - const [isExpanded, setIsExpanded] = useState(false) - const inputRef = useRef() - - // Rotate through a new placeholder each day - // Easter egg idea: click your own name to shuffle the placeholder - // const daysSinceEpoch = Math.floor(Date.now() / 1000 / 60 / 60 / 24) - - // Take care not to produce a different placeholder on the server and client - const [defaultPlaceholder, setDefaultPlaceholder] = useState('') - useEffect(() => { - setDefaultPlaceholder(`e.g. ${sample(ENV_CONFIG.newQuestionPlaceholders)}`) - }, []) - - const placeholder = props.placeholder ?? defaultPlaceholder - - return ( -
{ - !isExpanded && inputRef.current?.focus() - }} - > -
- - -
- -

Ask a question...

- {isExpanded && ( - - )} -
-