From af3895de79fd38c2c730be1bed31181650ec4702 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Mon, 13 Jun 2022 20:53:29 -0700 Subject: [PATCH] Add quadratic matching to Manifold for Charity (#486) * Calculate quadratic funding match * Tweak copy * More concise quadratic funding calculation Co-authored-by: Sinclair Chen * Fix imports and calculations * Remove unused var for now * Clean up styling Co-authored-by: Sinclair Chen --- common/quadratic-funding.ts | 27 ++++++ web/components/charity/charity-card.tsx | 34 +++++-- web/pages/charity/index.tsx | 118 +++++++++++++++++++----- 3 files changed, 144 insertions(+), 35 deletions(-) create mode 100644 common/quadratic-funding.ts 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/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index c5b351a7..31995284 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -6,9 +6,11 @@ import { Charity } from 'common/charity' import { useCharityTxns } from 'web/hooks/use-charity-txns' import { manaToUSD } from '../../../common/util/format' import { Row } from '../layout/row' +import { Col } from '../layout/col' -export function CharityCard(props: { charity: Charity }) { - const { slug, photo, preview, id, tags } = props.charity +export function CharityCard(props: { charity: Charity; match?: number }) { + const { charity, match } = props + const { slug, photo, preview, id, tags } = charity const txns = useCharityTxns(id) const raised = sumBy(txns, (txn) => 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/pages/charity/index.tsx b/web/pages/charity/index.tsx index 81d74440..aff95fb7 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -1,4 +1,12 @@ -import { mapValues, groupBy, sumBy, sum, sortBy, debounce } from 'lodash' +import { + mapValues, + groupBy, + sumBy, + sum, + sortBy, + debounce, + uniqBy, +} from 'lodash' import { useState, useMemo } from 'react' import { charities, Charity as CharityType } from 'common/charity' import { CharityCard } from 'web/components/charity/charity-card' @@ -8,7 +16,9 @@ import { Page } from 'web/components/page' import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' import { getAllCharityTxns } from 'web/lib/firebase/txns' -import { formatMoney } from 'common/util/format' +import { formatMoney, manaToUSD } from 'common/util/format' +import { quadraticMatches } from 'common/quadratic-funding' +import { Txn } from 'common/txn' export async function getStaticProps() { const txns = await getAllCharityTxns() @@ -20,21 +30,55 @@ export async function getStaticProps() { (charity) => (charity.tags?.includes('Featured') ? 0 : 1), (charity) => -totals[charity.id], ]) + const matches = quadraticMatches(txns, totalRaised) + const numDonors = uniqBy(txns, (txn) => txn.fromId).length return { props: { totalRaised, charities: sortedCharities, + matches, + txns, + numDonors, }, revalidate: 60, } } +type Stat = { + name: string + stat: string +} + +function DonatedStats(props: { stats: Stat[] }) { + const { stats } = props + return ( +
+ {stats.map((item) => ( +
+
+ {item.name} +
+
+ {item.stat} +
+
+ ))} +
+ ) +} + export default function Charity(props: { totalRaised: number charities: CharityType[] + matches: { [charityId: string]: number } + txns: Txn[] + numDonors: number }) { - const { totalRaised, charities } = props + const { totalRaised, charities, matches, numDonors } = props const [query, setQuery] = useState('') const debouncedQuery = debounce(setQuery, 50) @@ -54,26 +98,44 @@ export default function Charity(props: { return ( - + - <div className="mb-6 text-gray-500"> - Donate your winnings to charity! Every {formatMoney(100)} you give - turns into $1 USD we send to your chosen charity. - <Spacer h={5} /> - Together we've donated over ${Math.floor(totalRaised / 100)} USD so - far! - </div> + <span className="text-gray-600"> + Donate your winnings: every {formatMoney(100)} you contribute turns + into $1 USD to your chosen charity! + </span> + <DonatedStats + stats={[ + { + name: 'Raised by Manifold users', + stat: manaToUSD(totalRaised), + }, + { + name: 'Number of donors', + stat: `${numDonors}`, + }, + { + name: 'Matched via quadratic funding', + stat: manaToUSD(sum(Object.values(matches))), + }, + ]} + /> + <Spacer h={10} /> <input type="text" onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search charities" + placeholder="Find a charity" className="input input-bordered mb-6 w-full" /> </Col> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> {filterCharities.map((charity) => ( - <CharityCard charity={charity} key={charity.name} /> + <CharityCard + charity={charity} + key={charity.name} + match={matches[charity.id]} + /> ))} </div> {filterCharities.length === 0 && ( @@ -91,22 +153,28 @@ export default function Charity(props: { ></iframe> <div className="mt-10 text-gray-500"> - Don't see your favorite charity? Recommend it{' '} - <SiteLink - href="https://manifold.markets/Sinclair/which-charities-should-manifold-add" - className="text-indigo-700" - > - here - </SiteLink> - ! + Don't see your favorite charity? Recommend it by emailing + charity@manifold.markets! <br /> <br /> <span className="italic"> - Note: Manifold is not affiliated with non-Featured charities; we're - just fans of their work! + Notes: <br /> - As Manifold is a for-profit entity, your contributions will not be - tax deductible. + - Manifold is not affiliated with non-Featured charities; we're just + fans of their work! + <br /> + - As Manifold itself is a for-profit entity, your contributions will + not be tax deductible. + <br />- Donation matches are courtesy of{' '} + <SiteLink href="https://ftxfuturefund.org/" className="font-bold"> + the FTX Future Fund + </SiteLink> + , and are allocated via{' '} + <SiteLink href="https://wtfisqf.com/" className="font-bold"> + quadratic funding + </SiteLink> + . + <br />- Donations + matches are wired once each quarter. </span> </div> </Col>