Developing quadratic funding system
Quadratic funding is a matching fund distribution mechanism that mathematically amplifies broad popular support over concentrated donations. Invented by Glen Weyl and Vitalik Buterin. Gitcoin Grants uses it to distribute $50M+ among Web3 public goods. Technical implementation is non-trivial: you need to collect individual contributions, calculate square roots, aggregate, and distribute matching — all with Sybil attack protection.
Mathematics: how QF works
Classic formula for matching sum of a project:
matching = (Σ √contributionᵢ)² - Σ contributionᵢ
Where the sum is over all donors to the project. Important consequence: 100 donations of $1 give more matching than one donation of $100.
Example:
| Project | Donations | Sum | Matching calc | Matching |
|---|---|---|---|---|
| A | 1 × $100 | $100 | (√100)² - 100 = 100 - 100 = 0 | $0 |
| B | 100 × $1 | $100 | (100 × √1)² - 100 = 10000 - 100 | $9900 |
| C | 10 × $10 | $100 | (10 × √10)² - 100 ≈ 10000 - 100 | ~$900 |
Final matching is normalized by matching pool: if total matching > pool, all sums are proportionally decreased.
System architecture
QF system consists of four components:
1. Grant Registry: stores project list with metadata
2. Round Contract: manages round — donation period, matching pool
3. Donation Collector: accepts contributions, emits events
4. Distribution Engine: calculates matching and makes payouts
Matching calculation is done off-chain (too expensive on-chain) and verified through merkle proof or ZK proof before payout.
Smart contracts
Round Controller
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract QFRound is Ownable {
using SafeERC20 for IERC20;
IERC20 public immutable donationToken; // USDC or DAI
uint256 public immutable matchingPool; // fixed at round creation
uint256 public immutable roundStart;
uint256 public immutable roundEnd;
// project ID => donor => amount
mapping(uint256 => mapping(address => uint256)) public donations;
// project ID => total donations
mapping(uint256 => uint256) public projectTotalDonations;
// project ID => list of donors (for off-chain calculation)
mapping(uint256 => address[]) public projectDonors;
bytes32 public matchingMerkleRoot; // set after round end
mapping(uint256 => bool) public matchingClaimed;
event DonationMade(
uint256 indexed projectId,
address indexed donor,
uint256 amount
);
event MatchingDistributed(uint256 indexed projectId, uint256 amount);
constructor(
address _token,
uint256 _matchingPool,
uint256 _start,
uint256 _end
) Ownable(msg.sender) {
donationToken = IERC20(_token);
matchingPool = _matchingPool;
roundStart = _start;
roundEnd = _end;
}
function donate(uint256 projectId, uint256 amount) external {
require(block.timestamp >= roundStart, "Round not started");
require(block.timestamp <= roundEnd, "Round ended");
require(amount > 0, "Zero amount");
// If first donation — add to donor list
if (donations[projectId][msg.sender] == 0) {
projectDonors[projectId].push(msg.sender);
}
donations[projectId][msg.sender] += amount;
projectTotalDonations[projectId] += amount;
donationToken.safeTransferFrom(msg.sender, address(this), amount);
emit DonationMade(projectId, msg.sender, amount);
}
// Set after off-chain calculation
function setMatchingRoot(bytes32 _root) external onlyOwner {
require(block.timestamp > roundEnd, "Round not ended");
matchingMerkleRoot = _root;
}
// Project claims matching via merkle proof
function claimMatching(
uint256 projectId,
address recipient,
uint256 matchingAmount,
bytes32[] calldata proof
) external {
require(!matchingClaimed[projectId], "Already claimed");
require(matchingMerkleRoot != bytes32(0), "Root not set");
bytes32 leaf = keccak256(bytes.concat(
keccak256(abi.encode(projectId, recipient, matchingAmount))
));
require(
MerkleProof.verify(proof, matchingMerkleRoot, leaf),
"Invalid proof"
);
matchingClaimed[projectId] = true;
donationToken.safeTransfer(recipient, matchingAmount);
emit MatchingDistributed(projectId, matchingAmount);
}
}
Off-chain matching calculation
interface Donation {
projectId: number;
donor: string;
amount: bigint;
}
interface ProjectMatching {
projectId: number;
recipient: string;
matchingAmount: bigint;
}
function calculateQFMatching(
donations: Donation[],
matchingPool: bigint
): ProjectMatching[] {
// Group donations by projects
const projectDonations = new Map<number, Map<string, bigint>>();
for (const d of donations) {
if (!projectDonations.has(d.projectId)) {
projectDonations.set(d.projectId, new Map());
}
const donors = projectDonations.get(d.projectId)!;
donors.set(d.donor, (donors.get(d.donor) ?? 0n) + d.amount);
}
// Calculate QF score for each project
const projectScores = new Map<number, bigint>();
let totalScore = 0n;
for (const [projectId, donors] of projectDonations) {
// Σ √contributionᵢ with fixed-point arithmetic
// Use 1e9 scale for accuracy with bigint
const SCALE = 1_000_000_000n;
let sumSqrt = 0n;
for (const amount of donors.values()) {
// Integer square root with scaling
const scaledAmount = amount * SCALE * SCALE;
const sqrt = isqrt(scaledAmount);
sumSqrt += sqrt;
}
// QF score = (Σ √contributionᵢ)²
const score = (sumSqrt * sumSqrt) / SCALE / SCALE;
projectScores.set(projectId, score);
totalScore += score;
}
if (totalScore === 0n) return [];
// Normalize: matching = score / totalScore * matchingPool
const result: ProjectMatching[] = [];
for (const [projectId, score] of projectScores) {
const matchingAmount = (score * matchingPool) / totalScore;
result.push({
projectId,
recipient: getProjectRecipient(projectId),
matchingAmount
});
}
return result;
}
// Integer square root (Newton's method)
function isqrt(n: bigint): bigint {
if (n < 0n) throw new Error("Negative input");
if (n < 2n) return n;
let x = n;
let y = (x + 1n) / 2n;
while (y < x) {
x = y;
y = (x + n / x) / 2n;
}
return x;
}
Sybil resistance
Quadratic funding without Sybil protection is just uniform distribution, only more expensive. Attacker creates 100 wallets, makes minimal donations from each and gets maximum matching.
Defense methods
Gitcoin Passport: decentralized identity score. User verifies via GitHub, Twitter, ENS, Lens, BrightID and other providers. Each stamp gives score. Participation threshold in QF: usually 15-20 points.
// Check Gitcoin Passport score before donation
async function checkPassportScore(address: string): Promise<boolean> {
const response = await fetch(
`https://api.scorer.gitcoin.co/registry/score/${SCORER_ID}/${address}`,
{ headers: { "X-API-Key": PASSPORT_API_KEY } }
);
const { score } = await response.json();
return parseFloat(score) >= MINIMUM_PASSPORT_SCORE; // 15.0
}
Proof of Humanity / WorldID: biometric verification of unique person. WorldID uses ZK-proof to confirm "I'm a unique human" without revealing identity.
Connection-weighted QF (COCM): Gitcoin Grants 19+ algorithm. Accounts for donor connections in social graph. Donations from correlated wallet clusters get lower weight — reduces Sybil effectiveness even with verified accounts.
Pairwise coordination subsidy (PCS)
QF extension that reduces matching for donations from correlated donors:
adjustedMatching = originalMatching × (1 - correlationFactor)
If two donors frequently donate to same projects — their combined contribution gets penalized. This reduces coordinated group influence without complete exclusion.
Verifying calculations via ZK proof
Problem with merkle approach: operator could theoretically set wrong merkle root. ZK-proof allows verifying calculation correctness on-chain without revealing all data.
Uses Circom + SnarkJS to build ZK circuit proving:
- All donations from specific commitment set
- QF formula applied correctly
- Final matching sums match merkle leaves
This is an actively developing area — Gitcoin Allo Protocol moves in this direction. For production 2024-2025 — merkle approach with trusted operator (DAO multisig) remains practical.
Integration with Gitcoin Allo Protocol
Gitcoin Allo Protocol v2 — open-source framework for capital allocation with QF strategies. Instead of building from scratch:
import { IAllo } from "@gitcoin/allo-v2/contracts/core/interfaces/IAllo.sol";
// Create QF pool through Allo
allo.createPool(
profileId, // Gitcoin registry profile
QF_STRATEGY, // address of QFVotingStrategy contract
initData, // round parameters
token, // USDC
matchingAmount, // matching pool
metadata, // IPFS metadata
managers // who manages round
);
Allo Protocol already has QF strategy, Sybil protection via Passport, and project interface. Custom implementation makes sense for specific requirements — like native token instead of stablecoins or non-standard weighting formula.







