Add quadratic matching to Manifold for Charity (#486)

* Calculate quadratic funding match

* Tweak copy

* More concise quadratic funding calculation

Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com>

* Fix imports and calculations

* Remove unused var for now

* Clean up styling

Co-authored-by: Sinclair Chen <abc.sinclair@gmail.com>
This commit is contained in:
Austin Chen 2022-06-13 20:53:29 -07:00 committed by GitHub
parent c4e3376313
commit af3895de79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 144 additions and 35 deletions

View File

@ -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<string, number> {
// 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))
}

View File

@ -6,9 +6,11 @@ import { Charity } from 'common/charity'
import { useCharityTxns } from 'web/hooks/use-charity-txns' import { useCharityTxns } from 'web/hooks/use-charity-txns'
import { manaToUSD } from '../../../common/util/format' import { manaToUSD } from '../../../common/util/format'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Col } from '../layout/col'
export function CharityCard(props: { charity: Charity }) { export function CharityCard(props: { charity: Charity; match?: number }) {
const { slug, photo, preview, id, tags } = props.charity const { charity, match } = props
const { slug, photo, preview, id, tags } = charity
const txns = useCharityTxns(id) const txns = useCharityTxns(id)
const raised = sumBy(txns, (txn) => txn.amount) const raised = sumBy(txns, (txn) => txn.amount)
@ -32,14 +34,22 @@ export function CharityCard(props: { charity: Charity }) {
{/* <h3 className="card-title line-clamp-3">{name}</h3> */} {/* <h3 className="card-title line-clamp-3">{name}</h3> */}
<div className="line-clamp-4 text-sm">{preview}</div> <div className="line-clamp-4 text-sm">{preview}</div>
{raised > 0 && ( {raised > 0 && (
<Row className="text-primary mt-4 flex-1 items-end justify-center gap-2"> <>
<span className="text-3xl"> <Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
{raised < 100 <Col>
? manaToUSD(raised) <span className="text-3xl font-semibold">
: '$' + Math.floor(raised / 100)} {formatUsd(raised)}
</span> </span>
<span>raised</span> <span>raised</span>
</Col>
{match && (
<Col className="text-gray-500">
<span className="text-xl">+{formatUsd(match)}</span>
<span className="">match</span>
</Col>
)}
</Row> </Row>
</>
)} )}
</div> </div>
</div> </div>
@ -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() { function FeaturedBadge() {
return ( return (
<span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800"> <span className="inline-flex items-center gap-1 bg-yellow-100 px-3 py-0.5 text-sm font-medium text-yellow-800">

View File

@ -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 { useState, useMemo } from 'react'
import { charities, Charity as CharityType } from 'common/charity' import { charities, Charity as CharityType } from 'common/charity'
import { CharityCard } from 'web/components/charity/charity-card' 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 { SiteLink } from 'web/components/site-link'
import { Title } from 'web/components/title' import { Title } from 'web/components/title'
import { getAllCharityTxns } from 'web/lib/firebase/txns' 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() { export async function getStaticProps() {
const txns = await getAllCharityTxns() const txns = await getAllCharityTxns()
@ -20,21 +30,55 @@ export async function getStaticProps() {
(charity) => (charity.tags?.includes('Featured') ? 0 : 1), (charity) => (charity.tags?.includes('Featured') ? 0 : 1),
(charity) => -totals[charity.id], (charity) => -totals[charity.id],
]) ])
const matches = quadraticMatches(txns, totalRaised)
const numDonors = uniqBy(txns, (txn) => txn.fromId).length
return { return {
props: { props: {
totalRaised, totalRaised,
charities: sortedCharities, charities: sortedCharities,
matches,
txns,
numDonors,
}, },
revalidate: 60, revalidate: 60,
} }
} }
type Stat = {
name: string
stat: string
}
function DonatedStats(props: { stats: Stat[] }) {
const { stats } = props
return (
<dl className="mt-3 grid grid-cols-1 gap-5 rounded-lg bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-4 sm:grid-cols-3">
{stats.map((item) => (
<div
key={item.name}
className="overflow-hidden rounded-lg bg-white px-4 py-5 shadow sm:p-6"
>
<dt className="truncate text-sm font-medium text-gray-500">
{item.name}
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{item.stat}
</dd>
</div>
))}
</dl>
)
}
export default function Charity(props: { export default function Charity(props: {
totalRaised: number totalRaised: number
charities: CharityType[] 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 [query, setQuery] = useState('')
const debouncedQuery = debounce(setQuery, 50) const debouncedQuery = debounce(setQuery, 50)
@ -54,26 +98,44 @@ export default function Charity(props: {
return ( return (
<Page> <Page>
<Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]"> <Col className="w-full rounded px-4 py-6 sm:px-8 xl:w-[125%]">
<Col className="max-w-xl gap-2"> <Col className="">
<Title className="!mt-0" text="Manifold for Charity" /> <Title className="!mt-0" text="Manifold for Charity" />
<div className="mb-6 text-gray-500"> <span className="text-gray-600">
Donate your winnings to charity! Every {formatMoney(100)} you give Donate your winnings: every {formatMoney(100)} you contribute turns
turns into $1 USD we send to your chosen charity. into $1 USD to your chosen charity!
<Spacer h={5} /> </span>
Together we've donated over ${Math.floor(totalRaised / 100)} USD so <DonatedStats
far! stats={[
</div> {
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 <input
type="text" type="text"
onChange={(e) => debouncedQuery(e.target.value)} onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search charities" placeholder="Find a charity"
className="input input-bordered mb-6 w-full" className="input input-bordered mb-6 w-full"
/> />
</Col> </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"> <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) => ( {filterCharities.map((charity) => (
<CharityCard charity={charity} key={charity.name} /> <CharityCard
charity={charity}
key={charity.name}
match={matches[charity.id]}
/>
))} ))}
</div> </div>
{filterCharities.length === 0 && ( {filterCharities.length === 0 && (
@ -91,22 +153,28 @@ export default function Charity(props: {
></iframe> ></iframe>
<div className="mt-10 text-gray-500"> <div className="mt-10 text-gray-500">
Don't see your favorite charity? Recommend it{' '} Don't see your favorite charity? Recommend it by emailing
<SiteLink charity@manifold.markets!
href="https://manifold.markets/Sinclair/which-charities-should-manifold-add"
className="text-indigo-700"
>
here
</SiteLink>
!
<br /> <br />
<br /> <br />
<span className="italic"> <span className="italic">
Note: Manifold is not affiliated with non-Featured charities; we're Notes:
just fans of their work!
<br /> <br />
As Manifold is a for-profit entity, your contributions will not be - Manifold is not affiliated with non-Featured charities; we're just
tax deductible. 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> </span>
</div> </div>
</Col> </Col>