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 { 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 }) {
|
|||
{/* <h3 className="card-title line-clamp-3">{name}</h3> */}
|
||||
<div className="line-clamp-4 text-sm">{preview}</div>
|
||||
{raised > 0 && (
|
||||
<Row className="text-primary mt-4 flex-1 items-end justify-center gap-2">
|
||||
<span className="text-3xl">
|
||||
{raised < 100
|
||||
? manaToUSD(raised)
|
||||
: '$' + Math.floor(raised / 100)}
|
||||
</span>
|
||||
<span>raised</span>
|
||||
</Row>
|
||||
<>
|
||||
<Row className="mt-4 flex-1 items-end justify-center gap-6 text-gray-900">
|
||||
<Col>
|
||||
<span className="text-3xl font-semibold">
|
||||
{formatUsd(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>
|
||||
</>
|
||||
)}
|
||||
</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() {
|
||||
return (
|
||||
<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 { 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 (
|
||||
<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: {
|
||||
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 (
|
||||
<Page>
|
||||
<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" />
|
||||
<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>
|
||||
|
|
Loading…
Reference in New Issue
Block a user