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:
parent
c4e3376313
commit
af3895de79
27
common/quadratic-funding.ts
Normal file
27
common/quadratic-funding.ts
Normal 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))
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user