diff --git a/.cspell.json b/.cspell.json index e41bf644..f433cd41 100644 --- a/.cspell.json +++ b/.cspell.json @@ -16,6 +16,20 @@ "src/swarms/doc/iso3166-2" ], "ignoreWords": [ + "fundraise", + "Finalizable", + "tstore", + "fundraises", + "Fundraiser", + "unpledge", + "unpledges", + "blocklist", + "blocklisted", + "blocklisting", + "stablecoin", + "stablecoins", + "Juicebox", + "Allo", "AMPL", "NODL", "Nodle", diff --git a/README.md b/README.md index 0e7540da..bfc1924a 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,68 @@ npx hardhat deploy-zksync --script deploy_staking.dp.ts --network zkSyncSepoliaT The admin account (GOV_ADDR) holds the default-admin, rewards-manager, and emergency-manager roles. It can pause the contract (which blocks `claim`/`unstake`), toggle `unstakeAllowed` (which defaults to false, so before the period ends users can only exit once the admin enables it), and — while paused — call `emergencyWithdraw` to sweep the entire contract balance, including staked principal. Deployments intended for untrusted users should split these roles and/or place them behind a timelock or multisig. +### Deploying the fundraising contracts + +Deploys `FundraiserFactory`, which creates one `Fundraiser` contract per fundraise. There is no implementation contract and no proxy to deploy — the factory creates each fundraise with `new`, and zksolc registers that bytecode as a factory dependency at compile time. + +Please define the following environment variables: + +- `N_FUNDRAISING_ADMIN`: multisig that will hold `DEFAULT_ADMIN_ROLE`. +- `N_FUNDRAISING_TOKENS`: comma-separated ERC-20 addresses allowed at launch, e.g. USDC and NODL for the network. +- `N_FUNDRAISING_FEE_BPS`: optional, defaults to `0`. Capped by `MAX_FEE_BPS` (500). +- `N_FUNDRAISING_FEE_RECIPIENT`: optional, required only when the rate is non-zero. + +The allow-list is seeded in the constructor because the admin is expected to be a multisig the deploy script cannot act for. A fee rate set with no recipient is rejected rather than silently collecting nothing. + +```shell +export DEPLOYER_PRIVATE_KEY=0x... +export N_FUNDRAISING_ADMIN=0x... +export N_FUNDRAISING_TOKENS=0xUSDC...,0xNODL... + +forge script script/DeployFundraiserFactory.s.sol \ + --rpc-url https://sepolia.era.zksync.dev --broadcast --zksync +``` + +Only the factory needs verifying; each fundraise is a full contract created from bytecode already published by the factory. + +Verification uses the ZKsync explorer's own verifier rather than the manual Etherscan flow described further down: + +```shell +export ARGS=$(cast abi-encode "constructor(address,uint16,address,address[])" \ + $N_FUNDRAISING_ADMIN 0 0x0000000000000000000000000000000000000000 "[$NODL]") + +forge verify-contract src/fundraising/FundraiserFactory.sol:FundraiserFactory \ + --zksync --verifier zksync --verifier-url $L2_VERIFIER_URL \ + --constructor-args $ARGS --watch +``` + +Individual fundraises can be verified the same way against `src/fundraising/Fundraiser.sol:Fundraiser`, passing the constructor tuple. Their parameters are all readable from the deployed contract, so they can be reconstructed after the fact: + +```shell +cast abi-encode "constructor((string,address,uint128,uint40,uint8,address,uint128,uint128),address,uint16,address)" \ + "(\"\",,,,,,,)" \ + +``` + +> [!NOTE] +> Run deployments through [`ops/deploy_fundraising_zksync.sh`](ops/deploy_fundraising_zksync.sh) rather than calling `forge script` directly. `forge build --zksync` compiles the whole tree, and zksolc rejects an L1-only contract elsewhere in `src/` that uses `EXTCODECOPY`; the ops script temporarily moves those files aside and restores them on exit, the same pattern the swarms and collections deploy scripts use. It also gates on `factoryDependencies` being populated — empty means `createFundraiser` would revert on EraVM while passing every EVM-profile test — and verifies source through `ops/verify_zksync_contracts.py`, which rewrites imports to project-rooted paths that the ZKsync verifier will accept. + + +Fees ship switched off. The capability exists — the rate is snapshotted into each fundraise at creation, so raising it later cannot reach anything already in flight — but turning it on is a product decision: + +```shell +export ETH_RPC_URL=https://sepolia.era.zksync.dev +export FACTORY=0x... # from the deploy output + +# 250 = 2.5% +cast send -i $FACTORY "setFeeParams(uint16,address)" 250 0xFeeRecipient... + +# allowing another token for future fundraises +cast send -i $FACTORY "setTokenAllowed(address,bool)" 0xToken... true +``` + +De-listing a token only stops new fundraises choosing it. Deposits, withdrawals and refunds on live fundraises are never affected, so de-listing cannot become a freeze switch. + ## Scripts ### Checking on bridging proposals diff --git a/ops/deploy_fundraising_zksync.sh b/ops/deploy_fundraising_zksync.sh new file mode 100755 index 00000000..9cb627b9 --- /dev/null +++ b/ops/deploy_fundraising_zksync.sh @@ -0,0 +1,489 @@ +#!/bin/bash +# ============================================================================= +# deploy_fundraising_zksync.sh +# +# Deployment script for the fundraising system (FundraiserFactory) on ZkSync Era. +# +# OVERVIEW: +# --------- +# Deploys a single immutable FundraiserFactory. There is no implementation +# contract and no proxy: the factory creates each Fundraiser with `new`, and +# zksolc registers that bytecode as a factory dependency at compile time. +# +# Mirrors ops/deploy_collection_factory_zksync.sh: +# - Temp-move L1-incompatible files (SSTORE2/EXTCODECOPY) so zksolc compiles +# - Forge build with --zksync, skip tests +# - Run the Forge script via --broadcast (or dry-run without) +# - Source verification via ops/verify_zksync_contracts.py (the ZkSync +# verifier rejects absolute source paths, which forge sends) +# - Append the deployed address to .env-test or .env-prod +# +# WHY factoryDependencies IS GATED BELOW: +# --------------------------------------- +# On EraVM, `create` is lowered to a ContractDeployer call keyed on a bytecode +# hash the operator must already know. If FundraiserFactory's factoryDependencies +# are empty, createFundraiser reverts at runtime on-chain while passing every +# EVM-profile test. This is the same failure mode that sank the original +# Clones.clone() design in collections. +# +# USAGE: +# ------ +# ./ops/deploy_fundraising_zksync.sh testnet # dry run +# ./ops/deploy_fundraising_zksync.sh testnet --broadcast +# ./ops/deploy_fundraising_zksync.sh mainnet --broadcast +# +# REQUIRED ENVIRONMENT VARIABLES (loaded from .env-test / .env-prod): +# ------------------------------------------------------------------- +# - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas +# - N_FUNDRAISING_ADMIN: Address holding DEFAULT_ADMIN_ROLE. Should be the +# multisig that administers the other production +# contracts, not an EOA. +# - N_FUNDRAISING_TOKENS: Comma-separated ERC-20 addresses allowed at launch +# +# OPTIONAL ENVIRONMENT VARIABLES: +# ------------------------------- +# - N_FUNDRAISING_FEE_BPS: Fee rate, default 0. Capped by MAX_FEE_BPS. +# - N_FUNDRAISING_FEE_RECIPIENT: Required only when the rate is non-zero. +# - L2_RPC: Override the default RPC for the network +# - COMPILER_VERSION / ZKSOLC_VERSION: passed to source verification +# - CONFIRM_MAINNET=YES: Skip the interactive mainnet prompt +# - RUN_MAINNET_SMOKE_TEST=true: Allow the smoke test to create a permanent +# fundraise on mainnet; default skips it +# +# NOTE: For mainnet, prefer a keystore/--account over a raw private key in the +# env file — raw keys passed to `cast --private-key` are visible in `ps`. +# +# ============================================================================= + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +NETWORK="${1:-testnet}" +BROADCAST="${2:-}" + +case "$NETWORK" in + testnet) + ENV_FILE=".env-test" + EXPLORER_URL="https://sepolia.explorer.zksync.io" + VERIFIER_URL="https://explorer.sepolia.era.zksync.dev/contract_verification" + CHAIN_ID="300" + DEFAULT_RPC="https://sepolia.era.zksync.dev" + ;; + mainnet) + ENV_FILE=".env-prod" + EXPLORER_URL="https://explorer.zksync.io" + VERIFIER_URL="https://zksync2-mainnet-explorer.zksync.io/contract_verification" + CHAIN_ID="324" + DEFAULT_RPC="https://mainnet.era.zksync.io" + ;; + *) + echo "Error: Unknown network '$NETWORK'. Use 'testnet' or 'mainnet'." + exit 1 + ;; +esac + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +_lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +# ============================================================================= +# Pre-flight +# ============================================================================= + +preflight_checks() { + log_info "Running pre-flight checks..." + cd "$PROJECT_ROOT" + + command -v forge >/dev/null || { log_error "forge not found. Install foundry-zksync."; exit 1; } + forge --version | grep -q "zksync" || { log_error "forge lacks ZkSync support. Run: foundryup-zksync"; exit 1; } + command -v cast >/dev/null || { log_error "cast not found."; exit 1; } + command -v jq >/dev/null || { log_error "jq not found."; exit 1; } + + [ -f "$ENV_FILE" ] || { log_error "Environment file '$ENV_FILE' not found."; exit 1; } + + set -a; source "$ENV_FILE"; set +a + + [ -n "$DEPLOYER_PRIVATE_KEY" ] || { log_error "DEPLOYER_PRIVATE_KEY not set in $ENV_FILE"; exit 1; } + [ -n "$N_FUNDRAISING_ADMIN" ] || { log_error "N_FUNDRAISING_ADMIN not set in $ENV_FILE"; exit 1; } + [ -n "$N_FUNDRAISING_TOKENS" ] || { log_error "N_FUNDRAISING_TOKENS not set in $ENV_FILE (comma-separated ERC-20 addresses)"; exit 1; } + + # vm.envUint rejects a key without the 0x prefix. + [[ "$DEPLOYER_PRIVATE_KEY" != 0x* ]] && export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" + + export N_FUNDRAISING_FEE_BPS="${N_FUNDRAISING_FEE_BPS:-0}" + export N_FUNDRAISING_FEE_RECIPIENT="${N_FUNDRAISING_FEE_RECIPIENT:-0x0000000000000000000000000000000000000000}" + + # The factory rejects this too; failing here saves a broadcast round-trip. + if [ "$N_FUNDRAISING_FEE_BPS" != "0" ] && \ + [ "$(_lower "$N_FUNDRAISING_FEE_RECIPIENT")" = "0x0000000000000000000000000000000000000000" ]; then + log_error "N_FUNDRAISING_FEE_BPS is non-zero but N_FUNDRAISING_FEE_RECIPIENT is unset." + exit 1 + fi + + RPC_URL="${L2_RPC:-$DEFAULT_RPC}" + + # An admin that is an EOA is legal but almost never intended in production: + # DEFAULT_ADMIN_ROLE controls the token allow-list and fee parameters. + local admin_code + admin_code=$(cast code "$N_FUNDRAISING_ADMIN" --rpc-url "$RPC_URL" 2>/dev/null || echo "0x") + if [ "$admin_code" = "0x" ]; then + log_warning "N_FUNDRAISING_ADMIN ($N_FUNDRAISING_ADMIN) is an EOA, not a contract." + log_warning "Production contracts here are administered by a multisig. Confirm this is intended." + else + log_success "Admin is a contract (multisig): $N_FUNDRAISING_ADMIN" + fi + + # Every allow-listed token must actually be an ERC-20 on this network. A wrong + # or non-existent token address here is unrecoverable: it is baked into the + # constructor and fundraises would collect a token nobody holds. + IFS=',' read -ra _TOKENS <<< "$N_FUNDRAISING_TOKENS" + for t in "${_TOKENS[@]}"; do + t="$(echo "$t" | xargs)" + local code sym dec + code=$(cast code "$t" --rpc-url "$RPC_URL" 2>/dev/null || echo "0x") + if [ "$code" = "0x" ]; then + log_error "Token $t has no contract code on $NETWORK." + exit 1 + fi + sym=$(cast call "$t" 'symbol()(string)' --rpc-url "$RPC_URL" 2>/dev/null || echo "?") + dec=$(cast call "$t" 'decimals()(uint8)' --rpc-url "$RPC_URL" 2>/dev/null || echo "?") + log_success "Token $t -> symbol=$sym decimals=$dec" + done + + if [ "$NETWORK" = "mainnet" ] && [ "$BROADCAST" = "--broadcast" ]; then + if [ "${CONFIRM_MAINNET:-}" = "YES" ]; then + log_warning "CONFIRM_MAINNET=YES set — proceeding without prompt." + else + log_warning "About to deploy to ZkSync MAINNET. The factory is IMMUTABLE:" + log_warning " MAX_FEE_BPS and MAX_DURATION can never be changed after this." + log_warning " Admin: $N_FUNDRAISING_ADMIN" + log_warning " Tokens: $N_FUNDRAISING_TOKENS" + log_warning " Fee: ${N_FUNDRAISING_FEE_BPS} bps -> $N_FUNDRAISING_FEE_RECIPIENT" + read -r -p "Type 'YES' to confirm mainnet deployment: " confirm + [ "$confirm" = "YES" ] || { log_error "Aborted by user."; exit 1; } + fi + fi + + log_success "Pre-flight checks passed" +} + +# ============================================================================= +# Temporarily move L1-incompatible contracts so zksolc can compile the tree. +# ============================================================================= + +L1_BACKUP_DIR="/tmp/rollup-l1-backup-fundraising-deploy" + +move_l1_contracts() { + log_info "Moving L1-incompatible contracts to temporary location..." + if [ -d "$L1_BACKUP_DIR" ]; then + log_warning "Found previous backup, restoring first..." + restore_l1_contracts 2>/dev/null || true + fi + mkdir -p "$L1_BACKUP_DIR" + + [ -f "src/swarms/SwarmRegistryL1Upgradeable.sol" ] && mv "src/swarms/SwarmRegistryL1Upgradeable.sol" "$L1_BACKUP_DIR/" + [ -f "test/SwarmRegistryL1.t.sol" ] && mv "test/SwarmRegistryL1.t.sol" "$L1_BACKUP_DIR/" + [ -d "test/upgrade-demo" ] && mv "test/upgrade-demo" "$L1_BACKUP_DIR/" + [ -f "script/DeploySwarmUpgradeable.s.sol" ] && mv "script/DeploySwarmUpgradeable.s.sol" "$L1_BACKUP_DIR/" + [ -f "script/UpgradeSwarm.s.sol" ] && mv "script/UpgradeSwarm.s.sol" "$L1_BACKUP_DIR/" + + log_success "L1 contracts moved to $L1_BACKUP_DIR" +} + +restore_l1_contracts() { + [ -d "$L1_BACKUP_DIR" ] || return 0 + log_info "Restoring L1 contracts from backup..." + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" ] && mv "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" "src/swarms/" + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" ] && mv "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" "test/" + [ -d "$L1_BACKUP_DIR/upgrade-demo" ] && mv "$L1_BACKUP_DIR/upgrade-demo" "test/" + [ -f "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" ] && mv "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" "script/" + [ -f "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" ] && mv "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" "script/" + rm -rf "$L1_BACKUP_DIR" + log_success "L1 contracts restored" +} + +trap restore_l1_contracts EXIT + +# ============================================================================= +# Compile + artifact gates +# ============================================================================= + +compile_contracts() { + log_info "Compiling contracts with Forge for ZkSync..." + forge build --zksync --skip test + log_success "Compilation complete" +} + +verify_build_artifacts() { + log_info "Verifying FundraiserFactory factoryDependencies are populated..." + + local artifact="zkout/FundraiserFactory.sol/FundraiserFactory.json" + [ -f "$artifact" ] || { log_error "Compiled artifact not found: $artifact"; exit 1; } + + local dep_count + dep_count=$(jq -r '.factoryDependencies | length' "$artifact" 2>/dev/null || echo "") + if [ -z "$dep_count" ] || [ "$dep_count" -eq 0 ]; then + log_error "FundraiserFactory.factoryDependencies is empty." + log_error "createFundraiser would revert on EraVM while passing every EVM-profile test." + exit 1 + fi + log_success "factoryDependencies populated ($dep_count entries)" + + # The Fundraiser must be constructor-configured and immutable. An initializer + # or upgrade selector appearing here means someone reintroduced a proxy shape. + log_info "Verifying Fundraiser exposes no initializer or upgrade selectors..." + local fartifact="zkout/Fundraiser.sol/Fundraiser.json" + [ -f "$fartifact" ] || { log_error "Compiled artifact not found: $fartifact"; exit 1; } + + local hits + hits=$(jq -r '[.abi[] | select(.type=="function") | .name] + | map(select(. == "initialize" or . == "upgradeTo" or . == "upgradeToAndCall" or . == "proxiableUUID")) + | length' "$fartifact") + if [ "$hits" -ne 0 ]; then + log_error "Fundraiser exposes an initializer or upgrade selector." + log_error "Each fundraise is a full contract configured by its constructor — see the design spec, section 6." + exit 1 + fi + log_success "Fundraiser is constructor-configured with no upgrade surface" +} + +# ============================================================================= +# Deploy +# ============================================================================= + +deploy_contracts() { + log_info "Deploying FundraiserFactory to ZkSync ($NETWORK)..." + + FORGE_ARGS=( + "script" "script/DeployFundraiserFactory.s.sol:DeployFundraiserFactory" + "--rpc-url" "$RPC_URL" "--chain-id" "$CHAIN_ID" "--zksync" + ) + + if [ "$BROADCAST" = "--broadcast" ]; then + FORGE_ARGS+=("--broadcast" "--slow") + else + log_warning "DRY RUN MODE - Add '--broadcast' to actually deploy" + log_info "Would deploy with:" + log_info " Admin: $N_FUNDRAISING_ADMIN" + log_info " Tokens: $N_FUNDRAISING_TOKENS" + log_info " Fee: ${N_FUNDRAISING_FEE_BPS} bps -> $N_FUNDRAISING_FEE_RECIPIENT" + log_info " RPC: $RPC_URL" + forge "${FORGE_ARGS[@]}" + return 0 + fi + + DEPLOY_LOG="/tmp/fundraising-deploy-$$.txt" + forge "${FORGE_ARGS[@]}" 2>&1 | tee "$DEPLOY_LOG" + + FUNDRAISER_FACTORY=$(grep -oE 'FundraiserFactory: +0x[0-9a-fA-F]{40}' "$DEPLOY_LOG" | tail -1 | grep -oE '0x[0-9a-fA-F]{40}') + if [ -z "$FUNDRAISER_FACTORY" ]; then + log_error "Could not extract the factory address from deploy output" + cat "$DEPLOY_LOG" + exit 1 + fi + + rm -f "$DEPLOY_LOG" + log_success "Deployment complete: $FUNDRAISER_FACTORY" +} + +# ============================================================================= +# Post-deploy sanity checks +# ============================================================================= + +verify_deployment() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + log_info "Verifying deployment..." + + local ADMIN_ROLE="0x0000000000000000000000000000000000000000000000000000000000000000" + + local has_admin + has_admin=$(cast call "$FUNDRAISER_FACTORY" "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE" "$N_FUNDRAISING_ADMIN" --rpc-url "$RPC_URL") + [ "$has_admin" = "true" ] || { log_error "DEFAULT_ADMIN_ROLE not granted to $N_FUNDRAISING_ADMIN"; exit 1; } + log_success "Admin role granted to $N_FUNDRAISING_ADMIN" + + # The deployer must NOT retain admin — the script grants it to N_FUNDRAISING_ADMIN only. + local deployer_addr deployer_is_admin + deployer_addr=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + deployer_is_admin=$(cast call "$FUNDRAISER_FACTORY" "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE" "$deployer_addr" --rpc-url "$RPC_URL") + if [ "$deployer_is_admin" = "true" ] && \ + [ "$(_lower "$deployer_addr")" != "$(_lower "$N_FUNDRAISING_ADMIN")" ]; then + log_error "Deployer $deployer_addr unexpectedly holds DEFAULT_ADMIN_ROLE." + exit 1 + fi + log_success "Deployer holds no admin role beyond the configured admin" + + IFS=',' read -ra _TOKENS <<< "$N_FUNDRAISING_TOKENS" + for t in "${_TOKENS[@]}"; do + t="$(echo "$t" | xargs)" + local allowed + allowed=$(cast call "$FUNDRAISER_FACTORY" "isTokenAllowed(address)(bool)" "$t" --rpc-url "$RPC_URL") + [ "$allowed" = "true" ] || { log_error "Token $t is not allow-listed on the deployed factory"; exit 1; } + log_success "Token allow-listed: $t" + done + + local fee_bps fee_recipient max_fee max_duration + fee_bps=$(cast call "$FUNDRAISER_FACTORY" "feeBps()(uint16)" --rpc-url "$RPC_URL") + fee_recipient=$(cast call "$FUNDRAISER_FACTORY" "feeRecipient()(address)" --rpc-url "$RPC_URL") + max_fee=$(cast call "$FUNDRAISER_FACTORY" "MAX_FEE_BPS()(uint16)" --rpc-url "$RPC_URL") + max_duration=$(cast call "$FUNDRAISER_FACTORY" "MAX_DURATION()(uint40)" --rpc-url "$RPC_URL") + + [ "$fee_bps" = "$N_FUNDRAISING_FEE_BPS" ] || { log_error "feeBps mismatch: on-chain $fee_bps != configured $N_FUNDRAISING_FEE_BPS"; exit 1; } + log_success "feeBps=$fee_bps recipient=$fee_recipient" + log_success "Immutable bounds: MAX_FEE_BPS=$max_fee MAX_DURATION=$max_duration" + + log_success "Post-deploy sanity checks passed" +} + +# ============================================================================= +# Smoke test — the empirical check that EraVM deployment works at runtime. +# ============================================================================= + +smoke_test_createFundraiser() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + + # Creates a real, PERMANENT contract. On mainnet that pollutes the registry, + # so skip unless explicitly opted in. + if [ "$NETWORK" = "mainnet" ] && [ "${RUN_MAINNET_SMOKE_TEST:-}" != "true" ]; then + log_warning "Skipping createFundraiser smoke test on mainnet (would create a permanent contract)." + log_warning "Set RUN_MAINNET_SMOKE_TEST=true to run it intentionally." + return 0 + fi + + log_info "Running end-to-end smoke test: createFundraiser..." + + IFS=',' read -ra _TOKENS <<< "$N_FUNDRAISING_TOKENS" + local token deployer_addr deadline ext + token="$(echo "${_TOKENS[0]}" | xargs)" + deployer_addr=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + deadline=$(( $(cast block latest -f timestamp --rpc-url "$RPC_URL") + 3600 )) + ext=$(cast keccak "smoke-$(date +%s)") + + cast send "$FUNDRAISER_FACTORY" \ + "createFundraiser((string,address,uint128,uint40,uint8,address,uint128,uint128),bytes32)" \ + "(Smoke,$token,1000,$deadline,0,$deployer_addr,0,0)" "$ext" \ + --rpc-url "$RPC_URL" --private-key "$DEPLOYER_PRIVATE_KEY" --zksync \ + || { log_error "createFundraiser reverted on-chain"; exit 1; } + + log_success "Smoke test passed: createFundraiser succeeded on EraVM" +} + +# ============================================================================= +# Source verification +# ============================================================================= + +verify_source_code() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + log_info "Verifying source code on block explorer..." + + local broadcast_json="broadcast/DeployFundraiserFactory.s.sol/${CHAIN_ID}/run-latest.json" + if [ ! -f "$broadcast_json" ]; then + log_warning "Broadcast file not found: $broadcast_json — skipping source verification" + return 0 + fi + if ! command -v python3 >/dev/null; then + log_warning "python3 not found — skipping source verification" + return 0 + fi + + # Non-fatal: the contracts are already deployed, this just needs a manual retry. + local exit_code=0 + python3 "$SCRIPT_DIR/verify_zksync_contracts.py" \ + --broadcast "$broadcast_json" \ + --verifier-url "$VERIFIER_URL" \ + --compiler-version "${COMPILER_VERSION:-0.8.26}" \ + --zksolc-version "${ZKSOLC_VERSION:-v1.5.15}" \ + --project-root "$PROJECT_ROOT" || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + log_success "Source code verified on block explorer" + else + log_warning "Source verification failed (deployment itself succeeded)" + log_info "Retry: python3 ops/verify_zksync_contracts.py --broadcast $broadcast_json --verifier-url $VERIFIER_URL" + fi +} + +# ============================================================================= +# Record the address +# ============================================================================= + +update_env_file() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + log_info "Updating $ENV_FILE with the deployed address..." + + if grep -q "^FUNDRAISER_FACTORY=" "$ENV_FILE"; then + sed -i.bak '/^# Fundraising/d' "$ENV_FILE" + sed -i.bak '/^FUNDRAISER_FACTORY=/d' "$ENV_FILE" + rm -f "${ENV_FILE}.bak" + fi + + cat >> "$ENV_FILE" << EOF + +# Fundraising (ZkSync Era - deployed $(date +%Y-%m-%d)) +FUNDRAISER_FACTORY=$FUNDRAISER_FACTORY +EOF + + log_success "Environment file updated" +} + +print_summary() { + echo "" + echo "==============================================" + echo " DEPLOYMENT SUMMARY" + echo "==============================================" + echo "" + echo "Network: $NETWORK" + echo "Explorer: $EXPLORER_URL" + echo "" + + if [ "$BROADCAST" != "--broadcast" ]; then + echo "Mode: DRY RUN (no contracts deployed)" + echo "" + echo "To deploy for real:" + echo " $0 $NETWORK --broadcast" + return 0 + fi + + echo "FundraiserFactory: $FUNDRAISER_FACTORY" + echo " Explorer: $EXPLORER_URL/address/$FUNDRAISER_FACTORY" + echo "" + echo "Configuration:" + echo " Admin: $N_FUNDRAISING_ADMIN" + echo " Tokens: $N_FUNDRAISING_TOKENS" + echo " Fee: ${N_FUNDRAISING_FEE_BPS} bps -> $N_FUNDRAISING_FEE_RECIPIENT" + echo "" + echo "Each fundraise is created by the factory as its own contract." + echo "Only the factory needs verifying." + echo "" + echo "==============================================" +} + +main() { + echo "" + echo "==============================================" + echo " ZkSync Fundraising Deployment" + echo "==============================================" + echo "" + + cd "$PROJECT_ROOT" + + preflight_checks + move_l1_contracts + compile_contracts + verify_build_artifacts + deploy_contracts + verify_deployment + smoke_test_createFundraiser + verify_source_code + update_env_file + print_summary +} + +main "$@" diff --git a/ops/fundraising-deployments.md b/ops/fundraising-deployments.md new file mode 100644 index 00000000..99446ded --- /dev/null +++ b/ops/fundraising-deployments.md @@ -0,0 +1,58 @@ +# Fundraising — deployments + +Deployment record for the contracts in [`src/fundraising`](../src/fundraising). + +## Mainnet (ZKsync Era, chain 324) + +| Contract | Address | Verified | +|---|---|---| +| `FundraiserFactory` | [`0xCFaF15E15696b2e8D19C5B3bFc4Bf091422Dda5e`](https://explorer.zksync.io/address/0xCFaF15E15696b2e8D19C5B3bFc4Bf091422Dda5e#contract) | yes | + +- Admin (`DEFAULT_ADMIN_ROLE`): `0x5e097ac1bcf81e7ff2657045f72caa6cf06486c9` — the Gnosis Safe v1.3.0 2-of-4 that administers the other production contracts. The deployer holds no role. +- Allow-listed at creation, in this order: native USDC `0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4`, then bridged USDC.e `0x3355df6D4c9C3035724Fd0e3914dE96A5a83aaf4`. Both are 6 decimals and both display as "USDC" in most wallets, so whatever creates a fundraise must choose deliberately. +- `feeBps` 0 with a zero recipient — fees ship switched off. `MAX_FEE_BPS` 500 and `MAX_DURATION` 31536000 are constants and can never change. + +Each fundraise is created later by the factory as its own contract, so instances never appear in the deploy broadcast. Verify one with `ops/verify_fundraiser.sh` if the explorer has not matched it automatically. + +## Testnet (ZKsync Era Sepolia, chain 300) + +### Current + +| Contract | Address | Verified | +|---|---|---| +| `FundraiserFactory` | [`0x65d016A46a4339d8111b6006b852027eC8FB1f45`](https://sepolia.explorer.zksync.io/address/0x65d016A46a4339d8111b6006b852027eC8FB1f45#contract) | yes | +| `Fundraiser` (example) | [`0xefbaEaBcA6eb2d53C22644dDCc0759B70D74361c`](https://sepolia.explorer.zksync.io/address/0xefbaeabca6eb2d53c22644ddcc0759b70d74361c#contract) | yes | + +- Admin (`DEFAULT_ADMIN_ROLE`): `0xc1F2A7b888e4837aFACfc5E914AB647476ceCD46` +- Allow-listed token: NODL `0x37EDFB6d82c3194e0024c9340aa0993eb42Ec14c` +- `feeBps` 0, `MAX_FEE_BPS` 500, `MAX_DURATION` 31536000 + +### Superseded + +| Contract | Address | Note | +|---|---|---| +| `FundraiserFactory` | [`0x898A7dD2Be10e239c126ff19F99b62223f93279f`](https://sepolia.explorer.zksync.io/address/0x898A7dD2Be10e239c126ff19F99b62223f93279f#contract) | Predates the `groupId` → `externalId` rename, so its `createFundraiser` ABI differs from the current source | +| `Fundraiser` (success path) | [`0x68db256e6042105eff4877fe01d82689714121f4`](https://sepolia.explorer.zksync.io/address/0x68db256e6042105eff4877fe01d82689714121f4#contract) | `Closed`, raised 100 NODL and paid out | +| `Fundraiser` (refund path) | [`0x91305bdd97e1e78259321465ee056065195563fd`](https://sepolia.explorer.zksync.io/address/0x91305bdd97e1e78259321465ee056065195563fd#contract) | `Refunding`, fully refunded | + +These are verified and remain on-chain — nothing on ZKsync can be withdrawn once deployed. They have been superseded **functionally as well as in this document**: NODL was de-listed on the superseded factory, so `createFundraiser` now reverts `TokenNotAllowed` and nothing further can be created through it. + +De-listing deliberately does not reach the two fundraises already created by it. That is the designed behavior — an allow-list change must never become a freeze switch over funds already escrowed — and it is worth noting that superseding a factory therefore cannot strand anyone's money. + +### What was exercised on-chain + +Both outcomes, against the superseded factory and re-confirmed against the current one: + +- **Target reached** — deposit, unpledge below target, top up to the target, then `unpledge` and `cancel` both reverting `GoalReached`, `finalize` → `Succeeded`, `withdraw` paying the beneficiary in full and leaving the escrow at zero. +- **Target missed** — `finalize` before the deadline reverting `NotFinalizable`; after it, `finalize` → `Refunding`, `refund` returning the contribution exactly, and a second `refund` reverting `NothingToRefund`. + +Measured testnet gas: `createFundraiser` 216,226 · `deposit` 120,546. + +## Redeploying + +```shell +./ops/deploy_fundraising_zksync.sh testnet # dry run +./ops/deploy_fundraising_zksync.sh testnet --broadcast +``` + +The script handles the whole path: it checks each allow-listed address is really an ERC-20 on the target network, warns when the admin is an EOA rather than a multisig, moves the L1-only contracts aside so zksolc can compile, gates on `factoryDependencies` being populated, deploys, re-reads the admin role and allow-list from chain, runs a `createFundraiser` smoke test, and verifies source on the explorer. diff --git a/ops/verify_fundraiser.sh b/ops/verify_fundraiser.sh new file mode 100755 index 00000000..c792c7f6 --- /dev/null +++ b/ops/verify_fundraiser.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# ============================================================================= +# verify_fundraiser.sh +# +# Verify a single Fundraiser on the ZKsync block explorer. +# +# Fundraises are created by the factory, not by the deploy script, so they never +# appear in a broadcast file and ops/verify_zksync_contracts.py cannot pick them +# up. Every constructor argument is readable from the contract itself, so this +# reconstructs them from chain — no deployment record needed, and anyone can run +# it against a fundraise they did not create. +# +# USAGE: +# ./ops/verify_fundraiser.sh
[testnet|mainnet] +# ============================================================================= + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +ADDRESS="${1:-}" +NETWORK="${2:-testnet}" + +[ -n "$ADDRESS" ] || { echo "Usage: $0
[testnet|mainnet]"; exit 1; } + +case "$NETWORK" in + testnet) RPC="${L2_RPC:-https://sepolia.era.zksync.dev}" + VERIFIER="https://explorer.sepolia.era.zksync.dev/contract_verification" ;; + mainnet) RPC="${L2_RPC:-https://mainnet.era.zksync.io}" + VERIFIER="https://zksync2-mainnet-explorer.zksync.io/contract_verification" ;; + *) echo "Unknown network '$NETWORK'. Use testnet or mainnet."; exit 1 ;; +esac + +cd "$PROJECT_ROOT" + +# `forge verify-contract` recompiles and has no --skip, so the L1-only contracts +# zksolc rejects must be moved aside exactly as the deploy scripts do. +BK="/tmp/rollup-l1-verify-fundraiser" +restore() { + [ -d "$BK" ] || return 0 + [ -f "$BK/SwarmRegistryL1Upgradeable.sol" ] && mv "$BK/SwarmRegistryL1Upgradeable.sol" src/swarms/ + [ -f "$BK/SwarmRegistryL1.t.sol" ] && mv "$BK/SwarmRegistryL1.t.sol" test/ + [ -d "$BK/upgrade-demo" ] && mv "$BK/upgrade-demo" test/ + [ -f "$BK/DeploySwarmUpgradeable.s.sol" ] && mv "$BK/DeploySwarmUpgradeable.s.sol" script/ + [ -f "$BK/UpgradeSwarm.s.sol" ] && mv "$BK/UpgradeSwarm.s.sol" script/ + rmdir "$BK" 2>/dev/null || true +} +trap restore EXIT + +echo "Reading constructor parameters from $ADDRESS..." + +NAME=$(cast call "$ADDRESS" 'name()(string)' --rpc-url "$RPC") +TOKEN=$(cast call "$ADDRESS" 'token()(address)' --rpc-url "$RPC") +GOAL=$(cast call "$ADDRESS" 'goal()(uint128)' --rpc-url "$RPC" | awk '{print $1}') +DEADLINE=$(cast call "$ADDRESS" 'deadline()(uint40)' --rpc-url "$RPC" | awk '{print $1}') +ON_MISSED=$(cast call "$ADDRESS" 'onMissed()(uint8)' --rpc-url "$RPC") +ORGANIZER=$(cast call "$ADDRESS" 'organizer()(address)' --rpc-url "$RPC") +FEE_BPS=$(cast call "$ADDRESS" 'feeBps()(uint16)' --rpc-url "$RPC") +FACTORY=$(cast call "$ADDRESS" 'factory()(address)' --rpc-url "$RPC") +MIN=$(cast call "$ADDRESS" 'minContribution()(uint128)' --rpc-url "$RPC" | awk '{print $1}') +MAX=$(cast call "$ADDRESS" 'maxTotalContributions()(uint128)' --rpc-url "$RPC" | awk '{print $1}') + +# The beneficiary may have been repointed after success via setPayoutAddress, in +# which case the current value is NOT what the constructor received. Recover the +# original from the FundraiserCreated event on the factory instead. +BENEFICIARY=$(cast call "$ADDRESS" 'beneficiary()(address)' --rpc-url "$RPC") +CHANGED=$(cast logs --rpc-url "$RPC" --address "$ADDRESS" \ + "PayoutAddressChanged(address,address)" --from-block 1 2>/dev/null | grep -c "topics" || true) +if [ "${CHANGED:-0}" -gt 0 ]; then + echo " note: payout address was changed after deployment; recovering the original" + ORIGINAL=$(cast logs --rpc-url "$RPC" --address "$ADDRESS" \ + "PayoutAddressChanged(address,address)" --from-block 1 2>/dev/null \ + | grep -oE "0x0{24}[0-9a-f]{40}" | head -1 | sed 's/0x0\{24\}/0x/') + [ -n "$ORIGINAL" ] && BENEFICIARY="$ORIGINAL" +fi + +echo " name=$NAME token=$TOKEN goal=$GOAL deadline=$DEADLINE onMissed=$ON_MISSED" +echo " beneficiary=$BENEFICIARY organizer=$ORGANIZER feeBps=$FEE_BPS factory=$FACTORY" + +ARGS=$(cast abi-encode \ + "constructor((string,address,uint128,uint40,uint8,address,uint128,uint128),address,uint16,address)" \ + "($NAME,$TOKEN,$GOAL,$DEADLINE,$ON_MISSED,$BENEFICIARY,$MIN,$MAX)" \ + "$ORGANIZER" "$FEE_BPS" "$FACTORY") + +mkdir -p "$BK" +mv src/swarms/SwarmRegistryL1Upgradeable.sol "$BK/" 2>/dev/null || true +mv test/SwarmRegistryL1.t.sol "$BK/" 2>/dev/null || true +mv test/upgrade-demo "$BK/" 2>/dev/null || true +mv script/DeploySwarmUpgradeable.s.sol "$BK/" 2>/dev/null || true +mv script/UpgradeSwarm.s.sol "$BK/" 2>/dev/null || true + +FOUNDRY_PROFILE=zksync forge verify-contract "$ADDRESS" \ + src/fundraising/Fundraiser.sol:Fundraiser \ + --zksync --verifier zksync --verifier-url "$VERIFIER" \ + --constructor-args "$ARGS" --watch diff --git a/ops/verify_zksync_contracts.py b/ops/verify_zksync_contracts.py index 855dd01b..7be8463f 100755 --- a/ops/verify_zksync_contracts.py +++ b/ops/verify_zksync_contracts.py @@ -89,6 +89,8 @@ "CollectionFactory": "src/collections/CollectionFactory.sol:CollectionFactory", "UserCollection721": "src/collections/UserCollection721.sol:UserCollection721", "UserCollection1155": "src/collections/UserCollection1155.sol:UserCollection1155", + "FundraiserFactory": "src/fundraising/FundraiserFactory.sol:FundraiserFactory", + "Fundraiser": "src/fundraising/Fundraiser.sol:Fundraiser", } # Some zkSync forge broadcasts record deployments as calls to ContractDeployer @@ -116,6 +118,12 @@ "CollectionFactory", "ERC1967Proxy", ], + # A single deployment: the factory. Each Fundraiser is created later by the + # factory itself, so it never appears in this broadcast — verify instances + # separately with ops/verify_fundraiser.sh. + "DeployFundraiserFactory.s.sol": [ + "FundraiserFactory", + ], } diff --git a/script/DeployFundraiserFactory.s.sol b/script/DeployFundraiserFactory.s.sol new file mode 100644 index 00000000..3380bf40 --- /dev/null +++ b/script/DeployFundraiserFactory.s.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; + +import {FundraiserFactory} from "../src/fundraising/FundraiserFactory.sol"; + +/** + * @title DeployFundraiserFactory + * @notice Deployment script for the fundraising system on ZkSync Era. + * @dev See `src/fundraising/doc/spec/fundraising-design.md`. + * + * One deployment, and only one. There is no implementation contract and no proxy: + * each fundraise is a full `Fundraiser` deployed by the factory with `new`, whose + * bytecode zksolc registers as a factory dependency at compile time. That is what + * makes the deploy resolvable on EraVM, where `create` is lowered to a + * `ContractDeployer` call keyed on a bytecode hash the operator must already know. + * + * Fees ship switched off. The capability exists — the rate is snapshotted into each + * fundraise at creation and capped by a constant — but whether to charge at all is a + * product decision, so `N_FUNDRAISING_FEE_BPS` defaults to zero. + * + * Usage: + * forge script script/DeployFundraiserFactory.s.sol \ + * --rpc-url $L2_RPC --broadcast --zksync + * + * Environment Variables: + * - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas. + * - N_FUNDRAISING_ADMIN: Multisig that will hold DEFAULT_ADMIN_ROLE. + * - N_FUNDRAISING_TOKENS: Comma-separated ERC-20 addresses to allow at launch, + * e.g. the USDC and NODL addresses for the network. + * - N_FUNDRAISING_FEE_BPS: Optional, defaults to 0. Capped by MAX_FEE_BPS. + * - N_FUNDRAISING_FEE_RECIPIENT: Optional, required only when the rate is non-zero. + */ +contract DeployFundraiserFactory is Script { + FundraiserFactory public factory; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address admin = vm.envAddress("N_FUNDRAISING_ADMIN"); + + address[] memory noTokens = new address[](0); + address[] memory tokens = vm.envOr("N_FUNDRAISING_TOKENS", ",", noTokens); + + uint256 feeBpsRaw = vm.envOr("N_FUNDRAISING_FEE_BPS", uint256(0)); + address feeRecipient = vm.envOr("N_FUNDRAISING_FEE_RECIPIENT", address(0)); + + require(admin != address(0), "N_FUNDRAISING_ADMIN is zero"); + require(feeBpsRaw <= type(uint16).max, "N_FUNDRAISING_FEE_BPS out of range"); + // The constructor enforces this too; failing here saves a broadcast round-trip. + require(feeBpsRaw == 0 || feeRecipient != address(0), "fee rate set with no recipient"); + // An empty allow-list would deploy a factory that cannot create anything. + require(tokens.length != 0, "N_FUNDRAISING_TOKENS is empty"); + + uint16 feeBps = uint16(feeBpsRaw); + + console.log("=== Deploying Fundraising on ZkSync ==="); + console.log("Admin:", admin); + console.log("Fee bps:", feeBps); + console.log("Fee recipient:", feeRecipient); + console.log("Allowed tokens:", tokens.length); + for (uint256 i = 0; i < tokens.length; ++i) { + require(tokens[i] != address(0), "N_FUNDRAISING_TOKENS contains the zero address"); + console.log(" -", tokens[i]); + } + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + console.log("1. Deploying FundraiserFactory..."); + factory = new FundraiserFactory(admin, feeBps, feeRecipient, tokens); + console.log(" FundraiserFactory:", address(factory)); + + vm.stopBroadcast(); + + console.log(""); + console.log("=== Deployment Summary ==="); + console.log("FundraiserFactory: ", address(factory)); + console.log("Admin (DEFAULT_ADMIN_ROLE):", admin); + console.log("MAX_FEE_BPS:", factory.MAX_FEE_BPS()); + console.log("MAX_DURATION (seconds):", factory.MAX_DURATION()); + console.log(""); + console.log("No implementation and no proxy were deployed; each fundraise is a"); + console.log("full contract created by the factory. Verify the factory only."); + } +} diff --git a/src/fundraising/Fundraiser.sol b/src/fundraising/Fundraiser.sol new file mode 100644 index 00000000..08077204 --- /dev/null +++ b/src/fundraising/Fundraiser.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import {IFundraiser} from "./interfaces/IFundraiser.sol"; +import { + FundraiserParams, + OnMissed, + Status, + MAX_FUNDRAISE_DURATION, + MAX_FEE_BPS_LIMIT +} from "./interfaces/FundraisingTypes.sol"; + +/** + * @title Fundraiser + * @notice Escrow for a single fundraise: collects one ERC-20 toward a target and + * resolves to exactly one of two outcomes — the beneficiary is paid, or every + * contributor takes their money back. + * @dev One contract per fundraise, deployed by `FundraiserFactory` with `new`. Not a proxy + * and not a clone: EIP-1167 does not work on zkSync Era, and a proxy measured more + * expensive there than a direct deployment. Configuration is set by the constructor + * and never written again, so there is no initializer and nothing to seize or re-run. + * + * See `src/fundraising/doc/spec/fundraising-design.md`. + */ +contract Fundraiser is IFundraiser, ReentrancyGuard { + using SafeERC20 for IERC20; + + /// @dev `DEFAULT_ADMIN_ROLE` in OpenZeppelin's AccessControl. + bytes32 private constant _FACTORY_ADMIN_ROLE = 0x00; + + uint256 private constant _BPS_DENOMINATOR = 10_000; + + /// @notice Longest permitted time from creation to deadline. + uint40 public constant MAX_DURATION = MAX_FUNDRAISE_DURATION; + + /// @notice Hard ceiling on the protocol fee, in basis points. + uint16 public constant MAX_FEE_BPS = MAX_FEE_BPS_LIMIT; + + // ────────────────────────────────────────────── + // Configuration — written once by the constructor + // ────────────────────────────────────────────── + // + // Plain storage rather than `immutable`: on EraVM immutables are routed through the + // ImmutableSimulator system contract and measured more expensive to both write and + // read than storage. See section 6 of the specification. + + string public override name; + address public override token; + address public override organizer; + address public override beneficiary; + address public override factory; + + uint128 public override goal; + uint40 public override deadline; + OnMissed public override onMissed; + uint16 public override feeBps; + uint128 public override minContribution; + uint128 public override maxTotalContributions; + + // ────────────────────────────────────────────── + // Lifecycle state + // ────────────────────────────────────────────── + + Status public override status; + + /// @inheritdoc IFundraiser + /// @dev Not monotonic: `unpledge` decrements it. + uint128 public override raised; + + /// @inheritdoc IFundraiser + uint128 public override unpledged; + + /// @inheritdoc IFundraiser + uint128 public override refunded; + + /// @inheritdoc IFundraiser + mapping(address => uint256) public override contributions; + + /// @param p Fundraise configuration, fixed for the life of the contract. + /// @param organizer_ Creator, and the only address that may cancel while below goal. + /// @param feeBps_ Protocol fee rate, snapshotted by value so a later change to the + /// factory's rate cannot skim a fundraise already in flight. + /// @param factory_ Deploying factory, consulted for the live fee recipient and for the + /// admin role that gates surplus rescue. + /// @dev Validates everything except the token allow-list, which only the factory knows. + /// Makes no external calls, so the factory's bookkeeping after deployment cannot be + /// re-entered. + constructor(FundraiserParams memory p, address organizer_, uint16 feeBps_, address factory_) { + if (p.token == address(0) || p.beneficiary == address(0)) revert ZeroAddress(); + if (organizer_ == address(0) || factory_ == address(0)) revert ZeroAddress(); + if (p.goal == 0) revert ZeroGoal(); + if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(feeBps_, MAX_FEE_BPS); + + if (p.deadline == 0) { + // With no deadline the target is never "missed", so the policy could never + // fire. Rejected rather than stored as a setting that does nothing. + if (p.onMissed == OnMissed.PayBeneficiary) revert PayBeneficiaryRequiresDeadline(); + } else { + if (p.deadline <= block.timestamp) revert DeadlineInPast(); + uint40 latest = uint40(block.timestamp) + MAX_DURATION; + if (p.deadline > latest) revert DeadlineTooFar(p.deadline, latest); + } + + // A cap below the goal would make success unreachable. + if (p.maxTotalContributions != 0 && p.maxTotalContributions < p.goal) { + revert CapBelowGoal(p.maxTotalContributions, p.goal); + } + + name = p.name; + token = p.token; + organizer = organizer_; + beneficiary = p.beneficiary; + factory = factory_; + + goal = p.goal; + deadline = p.deadline; + onMissed = p.onMissed; + feeBps = feeBps_; + minContribution = p.minContribution; + maxTotalContributions = p.maxTotalContributions; + + status = Status.Funding; + } + + // ────────────────────────────────────────────── + // Contributing + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + function deposit(uint256 amount) external override nonReentrant { + _deposit(amount); + } + + /// @inheritdoc IFundraiser + function depositWithPermit(uint256 amount, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s) + external + override + nonReentrant + { + // A permit can be consumed by anyone who sees it in the mempool. That is not a + // reason to fail: if an allowance already covers the deposit it proceeds, and if + // it does not the transfer below reverts anyway. + try IERC20Permit(token).permit(msg.sender, address(this), amount, permitDeadline, v, r, s) {} catch {} + _deposit(amount); + } + + /// @dev Credits the amount **actually received**, not the amount requested. For a + /// fee-on-transfer token those differ, and crediting the request would overstate + /// what the contract owes until the last contributor out could not be paid. + /// `nonReentrant` is what makes the measured delta attributable to this transfer. + function _deposit(uint256 amount) private { + if (status != Status.Funding) revert InvalidState(status); + if (amount == 0) revert ZeroAmount(); + if (deadline != 0 && block.timestamp >= deadline) revert DepositAfterDeadline(); + + IERC20 t = IERC20(token); + uint256 balanceBefore = t.balanceOf(address(this)); + t.safeTransferFrom(msg.sender, address(this), amount); + uint256 credited = t.balanceOf(address(this)) - balanceBefore; + if (credited == 0) revert ZeroAmount(); + + uint256 newRaised = uint256(raised) + credited; + if (newRaised > type(uint128).max) revert RaisedOverflow(raised, credited); + + if (maxTotalContributions != 0 && newRaised > maxTotalContributions) { + revert CapExceeded(credited, maxTotalContributions - raised); + } + + // A contribution that reaches the goal is exempt from the minimum. A remaining gap + // smaller than `minContribution` must still be fillable, or the minimum becomes a + // rule that stands between a fundraise and its own resolution. + if (credited < minContribution && newRaised < goal) { + revert DepositBelowMinimum(credited, minContribution); + } + + contributions[msg.sender] += credited; + raised = uint128(newRaised); + + emit ContributionMade(msg.sender, credited, newRaised); + } + + /// @inheritdoc IFundraiser + /// @dev Deliberately not gated on the deadline. A fundraise past its deadline but not + /// yet finalized is still below goal, and keeping the exit open means nobody is + /// stranded in the window before someone calls `finalize`. + function unpledge(uint256 amount) external override nonReentrant { + if (status != Status.Funding) revert InvalidState(status); + if (raised >= goal) revert GoalReached(); + if (amount == 0) revert ZeroAmount(); + + uint256 contributed = contributions[msg.sender]; + if (amount > contributed) revert InsufficientContribution(amount, contributed); + + contributions[msg.sender] = contributed - amount; + raised -= uint128(amount); + unpledged += uint128(amount); + + IERC20(token).safeTransfer(msg.sender, amount); + + emit Unpledged(msg.sender, amount, raised); + } + + // ────────────────────────────────────────────── + // Resolution + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + /// @dev Checks only state, goal and deadline. No deposit-time rule is re-evaluated + /// here: a minimum-contribution check on this path is what made a well-known + /// audited crowdfund impossible to finalize, locking contributor funds until + /// expiry. + function finalize() external override { + if (status != Status.Funding) revert InvalidState(status); + + Status outcome; + if (raised >= goal) { + outcome = Status.Succeeded; + } else if (deadline != 0 && block.timestamp >= deadline) { + outcome = onMissed == OnMissed.Refund ? Status.Refunding : Status.Succeeded; + } else { + revert NotFinalizable(); + } + + status = outcome; + emit Finalized(outcome, raised, msg.sender); + } + + /// @inheritdoc IFundraiser + function cancel() external override { + if (status != Status.Funding) revert InvalidState(status); + if (msg.sender != organizer) revert NotOrganizer(msg.sender); + if (raised >= goal) revert GoalReached(); + + status = Status.Refunding; + emit Cancelled(msg.sender, raised); + } + + // ────────────────────────────────────────────── + // Payout and refunds + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + function withdraw() external override nonReentrant { + if (status != Status.Succeeded) revert InvalidState(status); + if (msg.sender != beneficiary) revert NotBeneficiary(msg.sender); + + uint256 amount = raised; + address recipient = IFundraiserFactoryFees(factory).feeRecipient(); + + // Rounded down, so any remainder favours the contributors rather than the protocol. + uint256 fee = (recipient == address(0)) ? 0 : (amount * feeBps) / _BPS_DENOMINATOR; + uint256 net = amount - fee; + address payTo = beneficiary; + + status = Status.Closed; + + if (fee != 0) IERC20(token).safeTransfer(recipient, fee); + if (net != 0) IERC20(token).safeTransfer(payTo, net); + + emit Withdrawn(payTo, net, fee); + } + + /// @inheritdoc IFundraiser + function setPayoutAddress(address newBeneficiary) external override { + if (status != Status.Succeeded) revert InvalidState(status); + if (msg.sender != beneficiary) revert NotBeneficiary(msg.sender); + if (newBeneficiary == address(0)) revert ZeroAddress(); + + emit PayoutAddressChanged(beneficiary, newBeneficiary); + beneficiary = newBeneficiary; + } + + /// @inheritdoc IFundraiser + function refund() external override nonReentrant { + _refund(msg.sender); + } + + /// @inheritdoc IFundraiser + function refundFor(address contributor) external override nonReentrant { + _refund(contributor); + } + + /// @dev Funds always go to `contributor`, never to the caller, so a third party can pay + /// the gas to return someone's money without being able to redirect it. + function _refund(address contributor) private { + if (status != Status.Refunding) revert InvalidState(status); + + uint256 amount = contributions[contributor]; + if (amount == 0) revert NothingToRefund(contributor); + + contributions[contributor] = 0; + refunded += uint128(amount); + + IERC20(token).safeTransfer(contributor, amount); + + emit Refunded(contributor, amount); + } + + /// @inheritdoc IFundraiser + /// @dev Bounded by arithmetic rather than by trust: for the escrow token only the + /// balance above `outstandingLiability()` can move, and unclaimed refunds are part + /// of that liability, so they stay untouchable indefinitely. + function rescueSurplus(address token_, address to) external override nonReentrant { + if (!IAccessControl(factory).hasRole(_FACTORY_ADMIN_ROLE, msg.sender)) { + revert NotFactoryAdmin(msg.sender); + } + if (to == address(0)) revert ZeroAddress(); + + uint256 balance = IERC20(token_).balanceOf(address(this)); + uint256 surplus; + if (token_ == token) { + uint256 liability = outstandingLiability(); + // Guarded rather than relying on checked arithmetic: a shortfall should surface + // as "there is no surplus", not as an arithmetic panic. + surplus = balance > liability ? balance - liability : 0; + } else { + surplus = balance; + } + if (surplus == 0) revert NoSurplus(); + + IERC20(token_).safeTransfer(to, surplus); + + emit SurplusRescued(token_, to, surplus); + } + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + function remainingToGoal() external view override returns (uint256) { + return raised >= goal ? 0 : goal - raised; + } + + /// @inheritdoc IFundraiser + function canUnpledge() external view override returns (bool) { + return status == Status.Funding && raised < goal; + } + + /// @inheritdoc IFundraiser + function outstandingLiability() public view override returns (uint256) { + if (status == Status.Refunding) return raised - refunded; + if (status == Status.Closed) return 0; + return raised; + } +} + + /// @dev Minimal view of the factory, kept local so the escrow does not depend on the + /// factory's full interface for a single call. + interface IFundraiserFactoryFees { + function feeRecipient() external view returns (address); + } diff --git a/src/fundraising/FundraiserFactory.sol b/src/fundraising/FundraiserFactory.sol new file mode 100644 index 00000000..b2e1fd27 --- /dev/null +++ b/src/fundraising/FundraiserFactory.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +import {Fundraiser} from "./Fundraiser.sol"; +import {IFundraiserFactory} from "./interfaces/IFundraiserFactory.sol"; +import {FundraiserParams, MAX_FUNDRAISE_DURATION, MAX_FEE_BPS_LIMIT} from "./interfaces/FundraisingTypes.sol"; + +/** + * @title FundraiserFactory + * @notice Deploys one `Fundraiser` contract per fundraise and holds what they share: + * which tokens may be collected, and the protocol fee. + * @dev Immutable and not proxied. Changing the escrow's behavior means deploying a new + * factory, which by construction cannot touch anything already live. + * + * Each fundraise is a full contract deployed with `new`, not a proxy or a clone. + * EIP-1167 clones do not work on zkSync Era at all, and a proxy measured more + * expensive there than deploying directly. See + * `src/fundraising/doc/spec/fundraising-design.md` section 6. + * + * The admin's entire reach is the token allow-list and the fee parameters, both of + * which affect only future fundraises, plus the fee recipient read at withdrawal + * time. It cannot resolve, cancel, redirect or touch the funds of any fundraise. + */ +contract FundraiserFactory is IFundraiserFactory, AccessControl { + /// @inheritdoc IFundraiserFactory + uint16 public constant override MAX_FEE_BPS = MAX_FEE_BPS_LIMIT; + + /// @inheritdoc IFundraiserFactory + uint40 public constant override MAX_DURATION = MAX_FUNDRAISE_DURATION; + + /// @inheritdoc IFundraiserFactory + mapping(address => bool) public override isTokenAllowed; + + /// @inheritdoc IFundraiserFactory + uint16 public override feeBps; + + /// @inheritdoc IFundraiserFactory + address public override feeRecipient; + + /// @inheritdoc IFundraiserFactory + mapping(address => bool) public override isFundraiser; + + /// @param admin Receives `DEFAULT_ADMIN_ROLE`. Expected to be a multisig. + /// @param initialFeeBps Starting fee rate. Zero ships the capability switched off. + /// @param initialFeeRecipient Where fees are sent. May be the zero address while the + /// rate is zero. + /// @param initialTokens Tokens allowed at launch. + /// @dev The allow-list is seeded here because the admin is expected to be a multisig + /// that a deploy script cannot act for. + constructor(address admin, uint16 initialFeeBps, address initialFeeRecipient, address[] memory initialTokens) { + if (admin == address(0)) revert ZeroAddress(); + _setFeeParams(initialFeeBps, initialFeeRecipient); + + for (uint256 i = 0; i < initialTokens.length; ++i) { + address t = initialTokens[i]; + if (t == address(0)) revert ZeroAddress(); + isTokenAllowed[t] = true; + emit TokenAllowed(t, true); + } + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiserFactory + /// @dev **No role gate, deliberately.** Anyone may deploy a fundraise. The allow-list + /// check is the only validation that belongs here rather than in the escrow's own + /// constructor, because it is the only rule the escrow cannot know for itself. + function createFundraiser(FundraiserParams calldata params, bytes32 externalId) + external + override + returns (address fundraiser) + { + if (!isTokenAllowed[params.token]) revert TokenNotAllowed(params.token); + + // SECURITY INVARIANT: the `isFundraiser` write below lands AFTER the deploy. That + // is reentrancy-safe ONLY because `Fundraiser`'s constructor makes no external + // calls — it validates arguments and writes its own storage, nothing more. If that + // ever changes, either reorder so the registry write precedes the deploy, or add a + // reentrancy guard here. + fundraiser = address(new Fundraiser(params, msg.sender, feeBps, address(this))); + + isFundraiser[fundraiser] = true; + + emit FundraiserCreated( + fundraiser, msg.sender, params.token, externalId, params.goal, params.deadline, params.beneficiary + ); + } + + // ────────────────────────────────────────────── + // Administration + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiserFactory + /// @dev De-listing only stops *new* fundraises choosing this token. Live ones never + /// consult the allow-list again, so de-listing can never become a freeze switch + /// over deposits, withdrawals or refunds already in flight. + function setTokenAllowed(address token, bool allowed) external override onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) revert ZeroAddress(); + isTokenAllowed[token] = allowed; + emit TokenAllowed(token, allowed); + } + + /// @inheritdoc IFundraiserFactory + function setFeeParams(uint16 newFeeBps, address newFeeRecipient) external override onlyRole(DEFAULT_ADMIN_ROLE) { + _setFeeParams(newFeeBps, newFeeRecipient); + } + + /// @dev A rate change reaches only fundraises created afterward: each snapshots the + /// rate by value at creation, so nothing in flight can be skimmed. The recipient + /// is read live at withdrawal, which lets a lost collection key be rotated without + /// touching live fundraises and cannot change how much anyone receives. + function _setFeeParams(uint16 newFeeBps, address newFeeRecipient) private { + if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(newFeeBps, MAX_FEE_BPS); + // A non-zero rate with nowhere to send it would silently collect nothing. + if (newFeeBps != 0 && newFeeRecipient == address(0)) revert ZeroAddress(); + + feeBps = newFeeBps; + feeRecipient = newFeeRecipient; + + emit FeeParamsUpdated(newFeeBps, newFeeRecipient); + } +} diff --git a/src/fundraising/doc/implementation-plan.md b/src/fundraising/doc/implementation-plan.md new file mode 100644 index 00000000..e1f8857f --- /dev/null +++ b/src/fundraising/doc/implementation-plan.md @@ -0,0 +1,117 @@ +# Fundraising — Implementation Plan + +Execution plan for [the specification](spec/fundraising-design.md). The spec says *what*; this says *in what order, and where the traps are*. + +--- + +## 1. The deployment mechanism — settled, and measured + +The spec first called for **minimal proxies (`Clones` / EIP-1167)**, then for an `ERC1967Proxy` per fundraise. Both are wrong for this contract on zkSync Era. It deploys **a full `Fundraiser` per fundraise, configured by its constructor**. No proxy, no initializer. + +### `Clones` is impossible + +On EraVM, `create`/`create2` are not opcodes — the compiler lowers them into `ContractDeployer` system-contract calls keyed on a bytecode hash the operator must already know, with the bytecode published in `factory_deps`. `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it and `factoryDependencies` comes up empty. It reverts `ERC1167: create failed`. + +Verified three ways, so do not re-litigate it: [zkSync docs](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment); [Matter Labs on this exact OpenZeppelin failure](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91); and this repo's own [Collections post-mortem](../../collections/doc/spec/design-and-implementation.md) §1.1. zksolc warns about it at compile time too. + +### A proxy is possible but loses on every axis + +Measured against `anvil-zksync` with a representative child contract, reading `gasUsed` from real receipts: + +| | Deploy (Era) | Call (Era) | Deploy (EVM) | +|---|---|---|---| +| Full contract, constructor, storage | **249,305** | **140,901** | 443,645 | +| Full contract, constructor, `immutable` | 272,841 | 144,897 | 391,904 | +| `ERC1967Proxy` + initializer | 276,855 | 143,994 | **269,470** | + +Bytecode published one-time: the direct route publishes 7,264 bytes; the proxy route publishes an implementation *and* the proxy, 11,712 bytes together. + +The proxy is ~40% cheaper on the EVM and ~11% more expensive on Era, because bytecode is published once by hash and later deployments only reference it — the saving proxies exist to capture is not there. It also costs ~3,000 gas more per call for the `delegatecall` hop. + +**And `immutable` costs more, not less.** EraVM routes immutables through the `ImmutableSimulator` system contract instead of baking them into code, so the `immutable` variant was more expensive both to deploy and to read. Configuration fields are ordinary storage, written once in the constructor. This inverts standard EVM guidance and is worth knowing beyond this feature. + +*(Caveat: a local node may not model L1 pubdata publication faithfully. The direct route publishes less total bytecode, so the conclusion holds either way.)* + +## 2. Build order + +Every step leaves the tree compiling. + +1. **`interfaces/IFundraiser.sol`** — types, events, errors, both interfaces. Locking names first stops interface churn rippling through tests later. +2. **`Fundraiser.sol`** — the escrow. Testable before the factory exists: `new Fundraiser(...)` directly. +3. **`FundraiserFactory.sol`** — thin by comparison. +4. **`forge build --zksync` checkpoint.** Do this *before* writing tests. This is where the `Clones` class of failure surfaces, and finding it after 2,000 lines of tests is the expensive path. +5. **Mocks** — fee-on-transfer, reentrant, blocklisting, and an ERC-2612 permit token (the spec's mock list omits permit; `depositWithPermit` needs it). +6. **Shared test base** — deploys factory plus a default fundraiser; every test file inherits it. +7. **Tests** — `Lifecycle` → `GoalLatch` → `Refunds` → `Permissionless` → `Invariants` (last; handlers want the final ABI). +8. **`script/DeployFundraiserFactory.s.sol`** plus its README usage section. +9. **Era smoke deploy** — create, deposit, finalize against era-test-node. Non-negotiable; see §6 risk 1. + +--- + +## 3. Files + +### `interfaces/IFundraiser.sol` + +Types per spec §6.1 and A.1: `Status { Funding, Succeeded, Refunding, Closed }`, `OnMissed { Refund, PayBeneficiary }`, `FundraiserParams { name, token, goal, deadline, onMissed, beneficiary, minContribution, maxTotalContributions }`. + +`IFundraiser`: `deposit(amount)`, `depositWithPermit(...)`, `unpledge(amount)`, `finalize()`, `cancel()`, `withdraw()`, `setPayoutAddress(addr)`, `refund()`, `refundFor(contributor)`, `rescueSurplus(token, to)`, plus views `state()`, `contributionOf(addr)`, `remainingToGoal()`, `canUnpledge()`. + +`IFundraiserFactory`: `createFundraiser(params, externalId) returns (address)`, `setTokenAllowed`, `setFeeParams`, `setImplementation`, and views including `isFundraiser(addr)`. + +Errors are custom and named for the condition, per repo convention — `PayBeneficiaryRequiresDeadline`, `GoalReached`, `RaisedOverflow`, `CapBelowGoal`, `NotFinalizable`, and the rest. + +Events carry what indexers need: `ContributionMade(contributor, credited, raised)` reports the **credited** amount, and both it and `Unpledged` carry the running `raised` so no indexer assumes monotonic growth. + +### `Fundraiser.sol` + +`ReentrancyGuard` (the plain one, not the upgradeable variant) and `SafeERC20`. Config is set once by the constructor and never written again — plain storage, not `immutable`, per §1. Mutable state is `status`, `raised`, `unpledged`, `refunded`, and the `contributions` mapping. No `Initializable`, no storage gap, no `_disableInitializers`: there is no proxy and nothing to initialize. + +**All parameter validation lives in the constructor, not the factory**, so the escrow enforces its own invariants no matter who deploys it. The one exception is the token allow-list, which only the factory knows. + +The constructor makes no external calls, so the factory's registry write after deployment stays reentrancy-safe. + +### `FundraiserFactory.sol` + +Immutable, non-proxied, `AccessControl`. Holds the allow-list, fee parameters, the implementation pointer, and an `isFundraiser` registry so indexers and the refund sweeper can verify provenance on-chain rather than trusting an address they were handed. + +`createFundraiser` has **no role gate** — do not copy `onlyRole(OPERATOR_ROLE)` from the Collections precedent. It checks the allow-list, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` with the fee snapshotted by value, records the registry entry, and emits `FundraiserCreated` carrying `externalId`. + +Note it holds no implementation address, because there is no implementation — one fewer admin lever, and one fewer thing to get wrong. + +`externalId` appears **only in the event**. Never stored, never verified — a hint, not a claim (spec §6.1). + +Admin functions touch the allow-list, fee parameters, and the implementation pointer. None reaches a live fundraise. + +--- + +## 4. The parts that will bite + +1. **The goal latch is two strict comparisons.** `raised < goal` in `unpledge` and `cancel`; `raised >= goal` in `finalize`. A deposit crossing the goal latches within that same transaction — no flag, no event, no grace period. Deposits *after* the latch are still accepted, so the invariant is "`raised` never re-crosses below `goal`", not "`raised` stops changing". +2. **Credit the balance delta, never the requested amount.** Measure `balanceOf` either side of `safeTransferFrom` and credit the difference; run every check and every accumulator on that number. `nonReentrant` is what makes the delta attributable to this transfer alone. +3. **Both guards on every exit path.** `unpledge`, `withdraw`, `refund`/`refundFor`, `rescueSurplus`: storage writes complete before the first transfer, *and* the function is `nonReentrant`. Spec §7 #4 requires both, not either. +4. **~~Initializer safety~~ — absent by construction.** The proxy design carried three hazards here: implementation takeover, initializer front-running, and re-initialization. A constructor has none of them. There is no bare implementation to seize, no window between deploy and configure, and no way to run it twice. This is the main reason the measured gas result was worth acting on: it removed a hazard class rather than shaving a cost. +5. **`deadline == 0` has exactly four read sites.** `block.timestamp >= 0` is always true, so naive logic finalizes an open-ended fundraise as missed at birth. Guard the deposit cutoff, the finalize missed-branch, and creation validation on `deadline != 0`; the fourth site is presentational. Keep it to four. +6. **`minContribution` must never stand between an fundraise and resolution.** A deposit that brings `raised` to at least `goal` is exempt from the minimum — a remaining gap smaller than the minimum must still be fillable. This is the direct generalization of the Party M-06 lesson, and `finalize` itself checks nothing about minimums, ever. +7. **Fee: rate snapshotted, recipient live.** `feeBps` is passed by value into the constructor and never re-read, bounded by `MAX_FEE_BPS` at both `setFeeParams` and construction. The recipient is read from the factory at withdraw time so a lost collection key can be rotated without touching fundraises — safe precisely because the rate is frozen. Applied only on `withdraw`, rounded down, remainder to the beneficiary. +8. **`uint128` truncation.** The credited delta is a `uint256`; require it fits before casting, with a named error. Unreachable for capped fundraises, a real branch for uncapped ones in an 18-decimal token. + +--- + +## 5. Tests + +- **`Lifecycle.t.sol`** — every edge in spec §5, permitted and reverting. Both `OnMissed` outcomes at a passed deadline. The exact boundary timestamp `t == deadline`, where deposits are closed and finalize is open. An open-ended fundraise warped ten years that still will not resolve. The fee snapshot proven by raising the factory fee mid-flight. The constructor rejecting every invalid parameter combination. Two regressions named for the prior art: a last contribution below the minimum must still finalize, and an organizer who never calls anything must not be able to freeze the fundraise. +- **`GoalLatch.t.sol`** — the `goal - 1` / `goal` / `goal + 1` battery with interleaved unpledges, atomic latching within a crossing deposit, deposits still accepted post-latch, and a fuzz run asserting `canUnpledge() == (raised < goal)` after every operation. +- **`Refunds.t.sol`** — the fee-on-transfer end-to-end case where all N contributors refund including the last (the insolvency that balance-delta crediting exists to prevent); reentrancy against each exit path; a blocklisted beneficiary recovering via `setPayoutAddress`; `rescueSurplus` moving only genuine surplus, with unclaimed refunds untouchable. +- **`Permissionless.t.sol`** — an arbitrary address depositing and refunding normally; `unpledge` returning only the caller's own money; a stranger funding the gap latching exactly as a member would, including the organizer-as-beneficiary self-funding case from spec §7 #10; two fundraisers sharing a `externalId` tag; a smart-account contributor. +- **`Invariants.t.sol`** — contributions sum to `raised`; balance covers outstanding liability in every state; `Refunding` never pays the beneficiary; once `raised >= goal` is observed it holds forever; status transitions only along spec §5 edges. + +--- + +## 6. Sequencing risks + +1. **`forge test` cannot catch EraVM deployment bugs.** Tests run on the vanilla EVM profile; the entire class of failure that sank the first Collections design only appears under `--zksync` on an Era node. Green tests are not evidence that this deploys. Hence the step-4 checkpoint and the step-9 smoke deploy. +2. **Pick the `ReentrancyGuard` flavour now.** The plain, non-upgradeable, non-transient one. EraVM `tstore` semantics are not worth gambling on, and switching later changes the storage layout. +3. **Fee recipient live-read vs. full snapshot** changes the constructor signature, both contracts, `Lifecycle`, and the deploy script. Overrule it before tests exist or not at all. +4. **Factory mutability** — immutable with `AccessControl` (this plan) versus UUPS like Collections. Settle before step 3 ends. It does not touch `Fundraiser`, which is immutable either way. +5. **`MAX_FEE_BPS` and `MAX_DURATION` need owners before audit.** Constants cannot be revisited after deployment. +6. Budget the explorer verification step; `foundry.toml` already sets `bytecode_hash = "none"` for Era, but the process is documented as fragile. diff --git a/src/fundraising/doc/integration.md b/src/fundraising/doc/integration.md new file mode 100644 index 00000000..b12cb4da --- /dev/null +++ b/src/fundraising/doc/integration.md @@ -0,0 +1,93 @@ +# Consuming the fundraising contracts + +How a service should sit alongside `FundraiserFactory` — what it owns, and what it must not touch. Written against the contracts in [`../`](../), for a module in `nodle-multi-token-api` shaped like the existing `envelope` one. + +--- + +## 1. The rule + +**Writes go direct from the client. The service sits beside the contract, never between a person and their money.** + +`deposit`, `unpledge`, `refund`, `finalize` and `withdraw` are permissionless — there is no role a service could hold that would let it do anything the caller cannot do themselves. Routing those calls through a service adds no authority, only a dependency that must be up for someone to contribute or get their money back. Removing that dependency is the reason this design is defensible; it is cheap to lose by accident. + +There is an existing pattern in that API to *not* copy. `user-collections` holds an operator key and writes on the user's behalf, because `createCollection` is role-gated and there is no alternative. Copying that here would mean a service key that moves user funds — the custody this design deliberately does without. + +## 2. What the service owns + +Four things the chain cannot do, and one that needs a signer. + +**The `externalId` → address mapping.** `externalId` is emitted, never stored, and never verified: anyone can create a fundraise carrying any tag, including one already in use. A fundraise must therefore be resolved from a record written when it was created. This is what makes a service mandatory rather than convenient — without it there is no trustworthy way to say which fundraise is which. + +**Listing and progress.** "Which fundraises exist and how far along are they" is an event-indexing question, not an RPC call. Mirror `envelope-index-cache` / `envelope-summary-cache`. + +**Refund sweeping.** `refundFor(contributor)` sends funds to the contributor regardless of who calls, so a service can return money without anyone claiming it. Refunds that require action do not get taken. Sweep on entering `Refunding`, and treat the manual `refund` path as the guarantee underneath rather than the mechanism. + +**Finalization.** Permissionless `finalize` is the safety net, not the mechanism. Run it on a schedule: at the deadline, and as soon as `raised >= goal`. + +**Gas, if fundraises should not require ETH.** `ERC20FeePaymaster` prices each transaction through an off-chain `erc20-fee-signer`. That is a service responsibility; `envelope-paymaster.service.ts` is the template. + +## 3. Module shape + +``` +fundraising/ + fundraising.module.ts + fundraising.controller.ts # read-only endpoints + fundraising.service.ts # chain reads, address resolution + fundraising-registry.service.ts # externalId <-> address records (the source of truth) + fundraising-index.service.ts # event indexing, progress cache + fundraising-sweeper.service.ts # scheduled refundFor + finalize + fundraising-paymaster.service.ts # optional, only if gasless is wanted + fundraising-dto.ts +``` + +**Endpoints** — all reads. No endpoint should accept a signed transaction or hold a key that can move escrowed funds. + +| Method | Path | Returns | +|---|---|---| +| `GET` | `/fundraising/:address` | Status, target, raised, deadline, `onMissed`, token, beneficiary | +| `GET` | `/fundraising/:address/contributions/:account` | One contributor's credited balance and whether they can still withdraw | +| `GET` | `/fundraising?externalId=…` | Addresses resolved from **our records**, never from the on-chain tag | +| `POST` | `/fundraising/records` | Records an `externalId` → address association after a client creates a fundraise | + +**Scheduled work** + +- Finalize anything past its deadline, or at or above its target. +- Sweep refunds for everything in `Refunding` with a non-zero balance. +- Reconcile the index against `FundraiserCreated` logs, so a fundraise created outside our flow is still visible rather than invisible. + +## 4. What to index + +``` +FundraiserCreated(fundraiser, organizer, token, externalId, goal, deadline, beneficiary) +ContributionMade(contributor, credited, raised) +Unpledged(contributor, amount, raised) +Finalized(outcome, raised, caller) +Cancelled(organizer, raised) +Withdrawn(to, net, fee) +Refunded(contributor, amount) +PayoutAddressChanged(previous, current) +``` + +Two traps that will otherwise produce an index that quietly disagrees with the chain: + +- **Use `credited`, not the call argument.** For a fee-on-transfer token the amount that arrived is less than the amount sent, and the contract credits what arrived. +- **`raised` can go down.** `unpledge` decrements it. Anything assuming monotonic growth is wrong. + +Also index `FundraiserCreated` from the factory rather than only recording what our own clients create — otherwise a fundraise created directly against the contract is invisible to us while being perfectly real on-chain. + +## 5. What the service must never do + +- Hold a key that can move escrowed funds. It has no such key today; none should be introduced. +- Be required for a deposit, a withdrawal, or a refund to succeed. +- Treat the on-chain `externalId` as authoritative. +- Gate `finalize`. If a scheduled job is the only thing that ever calls it, an outage becomes a freeze — the whole point of it being permissionless is that anyone else can. + +## 6. Failure modes worth handling explicitly + +| Situation | What happens on-chain | What the service should do | +|---|---|---| +| Service is down | Everything still works; people transact directly | Reconcile from logs on restart, not from its own write path | +| A fundraise is created outside our flow | Perfectly valid, invisible to us | Pick it up from `FundraiserCreated` | +| Two fundraises share an `externalId` | Both valid | Resolve from our records; never assume uniqueness | +| Contributor never claims a refund | Funds stay owed indefinitely | Sweep with `refundFor`; alert if a balance stays unswept | +| Beneficiary repoints payout | `PayoutAddressChanged` | Re-read; the constructor value is no longer current | diff --git a/src/fundraising/doc/spec/fundraising-design.md b/src/fundraising/doc/spec/fundraising-design.md new file mode 100644 index 00000000..3d9b3d48 --- /dev/null +++ b/src/fundraising/doc/spec/fundraising-design.md @@ -0,0 +1,432 @@ +--- +title: "Fundraising — Design Document" +subtitle: "A CrowdFund-shaped ERC-20 escrow, built on OpenZeppelin" +date: "August 2026" +version: "1.0" +--- + +# Fundraising + +## Design Document + +**A CrowdFund-shaped ERC-20 escrow, built on OpenZeppelin** + +Version 1.0 — August 2026 + +--- + +## Table of Contents + +1. [Scope](#1-scope) +2. [Why This Shape](#2-why-this-shape) +3. [The Decision](#3-the-decision) +4. [What We Build](#4-what-we-build) +5. [State Machine](#5-state-machine) +6. [Contract Surface](#6-contract-surface) +7. [Security Model](#7-security-model) +8. [Gas and Allowances](#8-gas-and-allowances) +9. [Test Harness Plan](#9-test-harness-plan) +10. [Open Decisions](#10-open-decisions) +- [Appendix A: Integration Notes](#appendix-a-integration-notes) +- [Sources](#sources) + +
+ +## 1. Scope + +A **fundraise** collects one ERC-20 toward a target by a deadline. Contributors deposit; the fundraise then resolves to exactly one of two outcomes — the beneficiary is paid, or every contributor takes their own money back. + +The on-chain scope is deliberately narrow: + +- The contract is an **escrow with a resolution rule**. It holds contributions, tracks who put in how much, and enforces one terminal outcome. Nothing else. +- **It is permissionless: anyone can create a fundraise, and anyone can contribute to one.** There is no membership, eligibility, or signature check anywhere in the flow. +- A fundraise's `name` is stored on-chain so it is self-describing at its own address. Any richer metadata belongs to whatever created it. +- Callers that need to associate fundraises with something of their own do so through the opaque `externalId` tag emitted at creation, and should resolve those associations from their own records — the tag is unverified (§6.1). + +**Hard constraint: this deploys new contracts only.** It modifies no deployed contract, requires no token migration, and needs no change to any live paymaster. Nothing currently in production is touched. Any option requiring a change to an existing deployment is out of scope by definition, not merely a low priority — that is what makes this shippable independently, and §8 is written to respect it. + +Non-goals for V1: yield on idle funds, contributor voting, milestone payouts, NFT receipts, native ETH, and multiple tokens in one fundraise. + +--- + +## 2. Why This Shape + +Three facts decided the design, and they are worth stating because they are not obvious: + +**There is nothing importable.** OpenZeppelin removed `Escrow`, `ConditionalEscrow` and `RefundEscrow` in 5.0.0, so the version this repo vendors has no escrow primitive to inherit. Party Protocol, Gitcoin Allo and Mirror's crowdfunds have all wound down. Juicebox is still running, but its model is rejected on its own merits (§3.1) rather than for lack of a maintainer. And no ERC standard for crowdfunding escrow was ever adopted. Writing our own is therefore the normal choice, not not-invented-here. + +**One shape converged twice.** OpenZeppelin's `RefundEscrow` (`Active → Refunding | Closed`) and Solidity by Example's `CrowdFund` (`launch / pledge / unpledge / claim / refund`) are the same state machine, reached independently a decade apart, and `CrowdFund` is among the most widely copied crowdfunding contracts in the community. That convergence is stronger evidence the model is right than any single audit. + +**The best-reviewed implementation is not the most-used one.** Party Protocol has the deepest published review history for this contract shape — a 0xMacro audit plus several Code4rena engagements, all collected in `PartyDAO/party-protocol/audits/` — and is also the one that no longer runs. So it is an audit checklist, not a dependency (§3.2). + +One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teaching reference — no reentrancy guard, no balance-delta accounting, no authorization, and `unpledge` open right to the deadline. §3 and §4 take the shape and add what a contract holding contributors' money needs. + +--- + +## 3. The Decision + +Two decisions: **which model**, and **whose code**. + +### 3.1 The model — a goal, and an exit that closes when it is reached + +A fundraise has a name, a target amount, an asset to collect, and either a deadline or none at all. Contributors deposit toward it. If the target is reached, the beneficiary withdraws. If a deadline passes below target, the fundraise does whatever it committed to at creation — refund everyone (the default) or pay out what was raised. A contributor may withdraw their own deposit at any time **before** the target is reached; that door shuts permanently the moment it is. + +Why this one, on the three axes: + +- **Security.** It is the only candidate where the failure path is guaranteed and needs nobody's cooperation. Once the goal is hit, or a deadline passes, *anyone* can trigger resolution, and every contributor pulls their own funds rather than waiting to be paid. No operator, no organizer, and no backend key can move a contributor's deposit anywhere except back to that contributor or to the declared beneficiary. +- **Functionality.** It is what "fundraise" means to a user. A goal that doesn't gate anything isn't a goal. +- **Usability.** The default failure mode explains itself in one sentence — *we didn't reach it, take your money back* — and the pre-goal exit removes the worst support ticket in the design: *I typed the wrong amount and now my money is stuck until September.* + +**The goal latch is what makes the last two compatible.** Free withdrawal all the way to the deadline lets a fundraise that hit its target be unwound at the last second. Locking from day one commits a contributor's money for months with no individual undo. Cutting the exit at the goal gives contributors a real way out while the outcome is still open, and gives the beneficiary certainty the instant it succeeds. Below the goal, everyone withdrawing is not an attack — it is the contributors collectively changing their minds, which is the correct outcome. + +Rejected, with what each trades away: + +| Model | Why not | +|---|---| +| Keep-what-you-raise **as the only mode** | Removes the refund guarantee that makes a backend-vouched escrow trustworthy. Adopted instead as a per-fundraise option chosen at creation and visible to contributors before they contribute (§6.1), never as the default | +| Milestone / approved payouts | Every tranche gate is a freeze lever, and whoever signs the approvals becomes custodial | +| Limited payout (Juicebox-style) | Periods and draw accounting solve a treasury problem a single-target fundraise does not have | +| ERC-4626 share vault | No goal, no deadline, no refund condition. Shares imply free exit — that is the open-unpledge model with extra steps and extra attack surface | +| Safe multisig per fundraise | Contributors must become signers, and a set of signers that stops responding is frozen forever. Custody, not fundraising | + +### 3.2 The code lineage — blueprint, not dependency + +**There is nothing importable.** No maintained, audited crowdfunding contract exists to take as a dependency: OpenZeppelin removed its escrow contracts in 5.0.0, Party Protocol has wound down, and no ERC standard for escrow was ever adopted (§2). + +So the decision is a three-part lineage: + +1. **Shape** — Solidity by Example's `CrowdFund` (MIT), among the most widely copied crowdfunding contracts in the community, and the same state machine as OpenZeppelin's old `RefundEscrow`. Two independent arrivals at the same design, a decade apart, is the strongest signal available that the model is right. +2. **Substance** — OpenZeppelin primitives. This is what we actually import and the audited surface we inherit. Most of the contract by line count ends up being OZ code rather than ours. +3. **Adversary** — Party Protocol, read-only. The deepest published review history for this shape: a 0xMacro audit and several Code4rena engagements. Its published findings become our test cases; its code becomes none of our dependencies. + +No forks and no upstream to track — but equally no upstream to inherit fixes from. The audit burden is entirely ours, which is why §7 and §8 carry the weight they do. + +--- + +## 4. What We Build + +### 4.1 `CrowdFund` mapped onto this design + +| `CrowdFund` | Here | Change | +|---|---|---| +| `launch(goal, startAt, endAt)` | `FundraiserFactory.createFundraiser` | Deploys a contract per fundraise; deadline optional | +| `pledge(id, amount)` | `deposit` | Credits the amount actually received rather than the amount requested | +| `unpledge(id, amount)` | `unpledge` | **Disabled once `raised >= goal`** — the latch | +| `claim(id)` — creator, if pledged ≥ goal | `withdraw` | Beneficiary only; optional protocol fee | +| `refund(id)` — each backer, if goal missed | `refund` | Unchanged in spirit; plus `refundFor` so a third party can push a contributor's refund *to that contributor* | +| *(implicit — resolution happens inside claim/refund)* | `finalize` | Made an explicit, **permissionless** step so nobody's inaction can freeze funds | + +### 4.2 What `CrowdFund` lacks that we add + +`CrowdFund` is a ~100-line teaching reference, not a library. Four additions turn it into something that can hold consumer money: + +1. **`SafeERC20`** — `CrowdFund` assumes a well-behaved token that returns a bool. +2. **`ReentrancyGuard` plus strict checks-effects-interactions** — zero the balance, then transfer, on every exit path. +3. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last contributor unable to get their money back. +4. **The goal latch** — `CrowdFund` leaves `unpledge` open right up to the deadline; here it closes the moment the target is reached (§3.1). + +Note what is *not* on that list: an authorization layer. Like `CrowdFund`, this contract asks nobody for permission. That is the simpler design, and §7 #7 records what it moves rather than removes. + +`CrowdFund` is MIT-licensed; re-implementing from the shape rather than copying keeps the provenance clean regardless. + +### 4.3 What we import + +OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, and `AccessControl` on the factory for the token allow-list and fee parameters. Nothing else — no proxy, no `Initializable`, and with deposits permissionless no `EIP712` or `SignatureChecker` either. Nothing else — with deposits permissionless, `EIP712` and `SignatureChecker` drop out of the design entirely. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. + +--- + +## 5. State Machine + +```mermaid +stateDiagram-v2 + [*] --> Funding: factory.createFundraiser(sig) + Funding --> Funding: deposit(sig) + Funding --> Funding: unpledge() — only while raised < goal + Funding --> Succeeded: finalize() — raised >= goal, ANYONE, any time + Funding --> Refunding: finalize() — deadline passed, below goal, onMissed=Refund + Funding --> Succeeded: finalize() — deadline passed, below goal, onMissed=PayBeneficiary + Funding --> Refunding: cancel() — organizer, only while raised < goal + Succeeded --> Closed: withdraw() — beneficiary pulls (minus fee) + Refunding --> Refunding: refund() — each contributor pulls + Closed --> [*] +``` + +Rules that hold everywhere: + +- Deposits are accepted **only** in `Funding`, and only before `deadline` where one is set. +- `unpledge` is available **only** in `Funding` and **only while `raised < goal`**. +- Once `raised >= goal` the fundraise is latched: no `unpledge`, no `cancel`, and `finalize` is callable by anyone immediately. +- An fundraise with **no deadline** stays in `Funding` until it reaches its goal or is cancelled, so `unpledge` stays available to every contributor indefinitely. In §6.2 this stops being a convenience and becomes the property that makes open-ended fundraises safe at all. +- `refund` is per-contributor and pull-only. No function anywhere loops over contributors. +- `Refunding` is terminal. There is no path back to `Funding`, and no admin path that redirects contributor funds to the beneficiary. +- `onMissed` is fixed at creation and read only on `finalize`. Nobody can change what a missed target means after contributors have contributed under it. +- `raised` is **not** monotonic — `unpledge` decrements it. Anything indexing this contract must not assume otherwise. + +--- + +## 6. Contract Surface + +Two contracts: a **factory** that deploys one **full `Fundraiser` contract per fundraise**. + +`FundraiserFactory` is a singleton holding the token allow-list and fee parameters. `Fundraiser` is deployed per fundraise, configured by its constructor, and holds only that fundraise's money. **No proxy, and therefore no initializer.** + +### The deployment mechanism, and why it is not what you would reach for on the EVM + +On EraVM, `create` and `create2` are not opcodes — the compiler lowers them into calls to the `ContractDeployer` system contract, keyed on a bytecode hash the operator must already know, with the bytecode published in the transaction's `factory_deps`. + +**Two consequences, and they point in opposite directions from EVM habit.** + +**`Clones` / EIP-1167 does not work at all.** `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it, `factoryDependencies` comes up empty, and the deploy cannot resolve — it reverts `ERC1167: create failed`. Confirmed three ways, so it does not need re-testing: [zkSync's documentation](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment) ("the operator must be aware of the contract's code before deployment"); [Matter Labs answering this exact OpenZeppelin failure](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91) ("EIP 1167 is written directly in EVM bytecode… not feasible to use on zkSync's Era"); and this repo, where Collections shipped on `Clones`, hit it, and replaced it ([post-mortem](../../../collections/doc/spec/design-and-implementation.md) §1.1). zksolc will also warn about it directly at compile time. + +**And a proxy is not worth its cost here either.** Because bytecode is published once by hash and every later deployment merely references it, the saving that justifies proxies on the EVM does not exist on Era. Measured on `anvil-zksync` with a representative child contract: + +| Per-fundraise deployment | Era | EVM, for contrast | +|---|---|---| +| Full contract, constructor | **249,305** | 443,645 | +| `ERC1967Proxy` + initializer | 276,855 | 269,470 | + +The proxy is ~40% cheaper on the EVM and ~11% *more expensive* on Era. It also costs about 3,000 gas more on every subsequent call for the `delegatecall` hop, and it publishes more bytecode one-time, not less — the proxy route publishes both an implementation and the proxy itself, where the direct route publishes only the contract. + +So: **`new Fundraiser(...)` with a compile-time-known type.** This is the pattern zkSync's own factory guidance teaches, and it is what the numbers favor. + +A related Era-specific finding, recorded because it inverts standard practice: **`immutable` costs more here, not less.** EraVM routes immutables through the `ImmutableSimulator` system contract rather than baking them into code, so a constructor using `immutable` measured *more* expensive than plain storage both to deploy (+23,000) and to read (+4,000). Configuration fields are ordinary storage, set once in the constructor and never written again. + +### What a contract per fundraise buys + +- **Fund isolation.** An accounting bug can only reach one fundraise's balance, never every fundraise's money at once. For funds held on behalf of others that is the deciding argument. +- **Simpler accounting.** Each contract holds exactly one token for exactly one fundraise, so what it owes is arithmetic over its own state — no per-token liability accumulator, no cross-fundraise solvency invariant, and surplus rescue becomes trivially safe. +- **No initialization surface.** A constructor cannot be front-run, cannot be called twice, and leaves no bare implementation for someone to seize. The entire class of proxy-initializer hazards is absent rather than mitigated. +- **Immutable by construction.** There is no implementation slot and no upgrade path. Changing the escrow's behavior means deploying a new factory, which cannot touch anything already live. +- **Its own address.** An fundraise is a thing a contributor can look up, watch, and verify independently of the app. + +### 6.1 Creation + +```solidity +struct FundraiserParams { + string name; // shown in-app; the app remains source of truth for richer metadata + address token; // must be allow-listed; USDC is the default offered by the app + uint128 goal; // > 0, in the token's smallest unit + uint40 deadline; // 0 = open-ended: runs until the goal is reached or it is cancelled + OnMissed onMissed; // what happens if the deadline passes below goal + address beneficiary; // fixed at creation; only the beneficiary can later repoint its own payout + uint128 minContribution; // 0 = none + uint128 maxTotalContributions; // 0 = uncapped +} + +enum OnMissed { Refund, PayBeneficiary } +``` + +`createFundraiser(params)` is **callable by anyone**. It checks the token is allow-listed, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` — snapshotting the fee by value — records the address in its registry, and emits `FundraiserCreated` with that address and an opaque `externalId` tag. All other parameter validation lives in the `Fundraiser` constructor, so the escrow enforces its own invariants regardless of who deploys it. + +That `externalId` is a **hint for reconciliation, not a claim**: nothing verifies it, so anyone can create a fundraise carrying any tag, including one already in use. Resolve a fundraise from the records written when it was created, never from the on-chain tag. Treating the tag as authoritative is how an unrelated contract ends up mistaken for a known one. + +**`Refund`** returns every contributor their money — all-or-nothing, the default. **`PayBeneficiary`** pays the beneficiary whatever was raised — keep-what-you-raise. + +The identifier is deliberately not `Distribute`. In product conversation "distribute" is the natural word, but as an on-chain enum it reads just as easily as *distribute back to the contributors*, which is the opposite behavior. The name that cannot be misread costs nothing here and prevents an implementer, an auditor, or an indexer from getting it backwards. The app can still say "pay out what we raised" or whatever tests best. + +The choice is per-fundraise, made at creation and immutable afterward, so a contributor can see which one they are contributing to before they contribute. That matters: under `PayBeneficiary` there is no guarantee of getting the money back, and the app must say so plainly rather than burying it. + +### 6.2 Open-ended fundraises (`deadline == 0`) + +An fundraise with no deadline runs until it reaches its goal or the organizer cancels. This is safe, but only because of a property that now becomes load-bearing: **`unpledge` is available whenever `raised < goal`**, and an open-ended fundraise that never reaches its goal is below goal forever. So every contributor can always leave. Without the goal latch (§3.2), an open-ended fundraise would be a way to trap money permanently. + +**`PayBeneficiary` requires a deadline.** With no deadline there is no moment at which the target is "missed", so the policy would be unreachable. Creation therefore **rejects `deadline == 0` combined with `OnMissed.PayBeneficiary`** rather than silently accepting a setting that can never fire. The app should hide the choice entirely when a contributor picks "no end date". + +### 6.3 Functions on `Fundraiser` + +| Function | Caller | State | Notes | +|---|---|---|---| +| `deposit(amount)` | **anyone** | `Funding` | Credits the amount actually received | +| `unpledge(amount)` | contributor | `Funding`, `raised < goal` | Returns only what that caller put in | +| `finalize()` | **anyone**, once `raised >= goal` or after a non-zero `deadline` | `Funding` | → `Succeeded`, or `Refunding` / `Succeeded` per `onMissed` | +| `cancel()` | organizer | `Funding`, `raised < goal` | → `Refunding`. The only terminal exit for an open-ended fundraise that stalls | +| `withdraw()` | beneficiary | `Succeeded` | Pays `raised - fee`, → `Closed` | +| `setPayoutAddress(addr)` | **beneficiary only** | `Succeeded` | Escape hatch for a lost or blocklisted key | +| `refund()` | any contributor | `Refunding` | Zeroes the balance, then transfers | +| `refundFor(contributor)` | anyone | `Refunding` | Funds always go to `contributor`, so a third party can sweep refunds without custody | + +Views for the app: `state()`, `contributionOf(account)`, `remainingToGoal()`, `canUnpledge()`. + +One role lives on the factory and none on fundraises: an **admin** managing the token allow-list and fee parameters. It cannot touch escrowed funds, finalize, cancel, or redirect a beneficiary on any fundraise — and with authorization gone there is no backend key in this design at all, so there is no signer to compromise, rotate, or wait on. + +## 7. Security Model + +The threat list, each item traceable to prior art or to a hazard this repo has already encountered. + +| # | Risk | Mitigation | +|---|---|---| +| 1 | **Funds frozen because nobody can resolve** — the failure mode that matters most, and the one Party Protocol's audits kept surfacing | `finalize` is permissionless once the goal is met *or* the deadline passes. No role, no signature, no organizer cooperation | +| 2 | **Deposit-time rules blocking resolution** (Party Protocol, Code4rena October 2023, finding M-06 — a minimum-contribution check made a crowdfund impossible to finalize, locking contributor funds until expiry) | `finalize` checks only state, deadline, and `raised >= goal` | +| 3 | Refund griefing via push payments | Pull only, everywhere | +| 4 | Reentrancy through token callbacks | `nonReentrant` + checks-effects-interactions. Both, not either | +| 5 | Fee-on-transfer token insolvency | Credit the amount actually received; pay out credited units | +| 6 | Rebasing tokens | Excluded by the token allow-list | +| 7 | Unbounded lock-up | `deadline <= now + MAX_DURATION` | +| 8 | Beneficiary key lost or blocklisted after success | `setPayoutAddress`, callable only by the beneficiary. No organizer or admin lever | +| 9 | Smart-account contributors | Never assume EOA; never use `tx.origin` | +| 10 | **Gap-funding force-close** — the cost of permissionless deposits | Anyone can top up the remaining gap to latch the target, closing every contributor's exit. With deposits open to all, this needs no cooperation from anyone. Worse, it is close to **free for an organizer who is also the beneficiary**: they fund the gap, the latch closes, they finalize, and they collect the whole pot including their own top-up. What they cannot do is redirect the money — it still goes to the beneficiary the contributors saw and agreed to at creation, and the contributors' loss is the *option* to change their mind, not the funds. Accepted, but it must be stated in the product rather than discovered: the honest framing of the goal latch is "your contribution is committed once the target is reached, and anyone can make that happen" | +| 11 | **Impersonated fundraises** — the cost of permissionless creation | Anyone can deploy a fundraise and tag it with any `externalId`. The contract cannot tell a known fundraise from an unrelated lookalike, so callers must resolve addresses from the records they wrote at creation, never from the on-chain tag (§6.1). Passing a raw contract address around as an invitation is a phishing vector | + +Because the contract is immutable, **`finalize` and `refund` are the two functions where a bug is unrecoverable.** Audit and testing effort should be concentrated there, deliberately and disproportionately. + +--- + +## 8. Gas and Allowances + +Two different allowances are involved, and only one of them is this contract's problem. + +### 8.1 Gas — already solved by infrastructure that exists + +`ERC20FeePaymaster` (`src/paymasters/ERC20FeePaymaster.sol`, merged in #127) is a zkSync `approvalBased` paymaster that lets a contributor pay gas in NODL. It is **destination-agnostic**: an off-chain `erc20-fee-signer` prices the fee, applies markup, and EIP-712-signs `(from, to, token, amount, expirationTime, maxFeePerGas, gasLimit)`. Which contracts it serves is therefore an off-chain policy decision, not an on-chain allow-list — **serving this escrow requires no change to the paymaster and no change to the escrow**, only that the fee signer agrees to price transactions whose `to` is the escrow. + +Three properties that matter to this design: + +- It is `approvalBased` **only** — the `general` (sponsored) flow reverts. The contributor always pays, in NODL. There is no free tier on this path. +- The allowance that flow grants is to the **paymaster, for gas**. The escrow's allowance is a different allowance to a different spender (§8.2). +- The fee amount is signed off-chain per transaction, so there is **no on-chain rate and no oracle** — a question this design does not have to answer. The paymaster caps signature lifetime at 15 minutes, checks the real on-chain allowance before pulling tokens, and bounds periodic ETH spend through `QuotaControl`. + +### 8.2 The contribution allowance — this is ours + +`deposit` calls `transferFrom`, so the contributor must have approved **the escrow**: + +- **Offer `depositWithPermit`** for tokens implementing EIP-2612: one transaction, no standing allowance left behind. Works for permit-capable stablecoins; **not** for L2 NODL, which is a plain `ERC20Burnable` with no permit. +- **One-time approval otherwise** — first deposit two transactions, every later one a single transaction. Smart-account wallets can batch the pair. +- **Not an option: adding `ERC20Permit` to the deployed L2 NODL.** It would collapse every NODL deposit to a single transaction, but it means changing a token already in production, which §1 rules out. NODL deposits therefore use the two-step approve path, and the escrow gains the single-transaction path automatically for any permit-capable token it is given. + +### 8.3 Rules this places on the escrow + +- **Never assume a paymaster exists.** Every function works when called by an ordinary self-paying transaction. This is what keeps `finalize`, `unpledge`, and `refund` reachable regardless of what happens to gas infrastructure. +- **No feature-specific paymaster is introduced.** +- **A validator hook is not needed for the NODL-fee path.** If *sponsored* gas is ever wanted — the contributor paying nothing — that requires a general-flow paymaster, and only then does the escrow need an `isValidGaslessOperation(from, data)` hook of the kind `EnvelopeLinks` exposes. + +One contributor-facing consequence: paying gas in NODL means holding NODL. Natural for a NODL fundraise; a contributor funding a stablecoin fundraise still needs either some NODL or ETH. + +--- + +## 9. Test Harness Plan + +What the harness must cover: + +- **Every edge in §5**, including the reverting ones: deposit after deadline, unpledge at or above goal, cancel at or above goal, refund while `Funding`, double `finalize`, withdraw by a non-beneficiary. +- **The latch specifically**: deposit to `goal - 1` and unpledge (allowed); cross to `goal` and unpledge (must revert); cross to `goal`, then confirm `cancel` reverts and `finalize` succeeds for a random caller. +- **Regression tests named after the prior art**: finalize an fundraise whose last contribution is below the minimum (the Party M-06 case); finalize with an organizer who never calls anything. +- **Fuzz**: amounts, contributor counts, deadlines, and the `goal - 1 / goal / goal + 1` boundary with interleaved unpledges. +- **Invariants**: contributions sum to `raised`; contract balance always covers outstanding liabilities; `Refunding` never pays the beneficiary; `raised` never crosses back below `goal` once reached. +- **Adversarial token mocks**: fee-on-transfer, reentrant, blocklisting. +- **Permissionless paths**: a non-contributor contributing succeeds and is refundable like any other contributor; `unpledge` returns only the caller's own contribution and never anyone else's; a stranger funding the gap latches the target exactly as a contributor would. +- **Paymaster-independence** (§8.3): every state-changing function must succeed when called by an ordinary self-paying transaction, with no paymaster in the picture at all. `depositWithPermit` against a permit-capable mock; the two-step approve path against a mock without permit. + +Everything must run under `forge test`. + +--- + +## 10. Open Decisions + +All of these concern the new contract only. None requires changing anything already deployed (§1). + +1. **Changing the escrow later.** Fundraises are immutable by construction — no proxy, no implementation slot, no upgrade path — which is survivable only because every fundraise has a signature-free, admin-free exit. That condition holds. Changing behavior therefore means deploying a new factory, and live fundraises are untouched by definition. What is open is only whether the *factory* should be replaceable in place or simply redeployed with the app pointed at the new address; redeployment is simpler and is the recommendation. +2. **Should `PayBeneficiary` carry a higher bar?** It is a creation-time option (§6.1), but it removes the contributor's refund guarantee. Worth deciding whether callers restrict it, or place it behind an extra confirmation, rather than presenting it as an equal peer of `Refund`. +3. **Protocol fee — on or off, and in which token?** +4. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) Off-chain configuration only: the paymaster contract needs no change and neither does the escrow, so this stays inside the §1 constraint. Cross-team, not a contract change, and not a launch blocker — without it contributors simply pay their own gas in ETH. +5. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the fundraise the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if that is wanted. +6. **How callers present "anyone can contribute."** The contract cannot restrict contributors, so whether a fundraise address is shared freely or held closely is entirely a decision for whatever surfaces it. +7. **What callers do about §7 #10.** The gap-funding force-close cannot be prevented on-chain. Whether that is disclosed plainly, mitigated in product terms, or simply accepted is a call to make deliberately. + +--- + +## Appendix A: Integration Notes + +Detail needed at implementation time. + +### A.1 Storage sketch + +Per fundraise, so there are no ids and no cross-fundraise bookkeeping: + +```solidity +enum Status { Funding, Succeeded, Refunding, Closed } +enum OnMissed { Refund, PayBeneficiary } + +// set once by the constructor, never written again. Plain storage, not +// `immutable`: on EraVM immutables measured more expensive both to write +// and to read (see section 6). +string name; +IERC20 token; +address organizer; +address beneficiary; // the beneficiary itself may repoint this while Succeeded +uint128 goal; +uint40 deadline; // 0 = open-ended +OnMissed onMissed; +uint16 feeBps; // snapshotted from the factory at creation +uint128 minContribution; +uint128 maxTotalContributions; + +// mutable +Status status; +uint128 raised; // net credited contributions; decremented by unpledge +uint128 unpledged; +uint128 refunded; +mapping(address => uint256) contributions; +``` + +What the singleton design needed and this one does not: an fundraise id threaded through every call, a per-token liability accumulator, and a solvency invariant spanning every fundraise at once. Here one contract holds one token for one fundraise, so what it owes is the sum of `contributions`, and anything above that is surplus. + +### A.2 No authorization layer + +There is none, deliberately. `createFundraiser` and `deposit` are callable by anyone, so there is no EIP-712 payload, no nonce, no replay map, no signer key, and no rotation procedure. + +Two consequences worth writing down because they read as absences rather than decisions: + +- **No backend liveness risk.** Contributing does not require the app, or a signature from it, to be reachable. An outage cannot block deposits and cannot sink a fundraise close to its deadline. +- **No key to compromise.** The earlier design's largest standing risk was a backend signer whose compromise would let an attacker bless arbitrary deposits and fundraises. That risk is not mitigated here, it is absent. + +What such a key would have bought — restricting who may contribute — cannot be enforced on-chain here at all, and certainly not against someone interacting with the contract directly. §7 #10 and #11 are the price. + +### A.3 Token handling + +One ERC-20 per fundraise, fixed at creation, drawn from an **admin-managed allow-list**. Truly permissionless token choice lets anyone create a fundraise in a token that makes the contract insolvent (fee-on-transfer, rebasing) or its funds unrecoverable. De-listing must never block deposits, unpledges, or refunds on live fundraises — otherwise de-listing becomes a freeze switch. + +Credit the balance delta on receipt, never the requested amount. Pay out credited units on every exit. + +A bounded `rescueSurplus(token)` recovers mis-sends and airdrops without ever being able to touch contributor money. No accumulator is needed for it — that was a singleton-era requirement. One contract holds one escrow token for one fundraise, so its outstanding liability is arithmetic over state that already exists (`raised` while `Funding` or `Succeeded`, `raised - refunded` while `Refunding`, zero once `Closed`), and any other token's balance is surplus in full. Unclaimed refunds stay liabilities forever, and stay untouchable. + +### A.4 Fees + +Optional, off by default. `feeBps` snapshotted into the fundraise at creation so a later increase cannot skim an in-flight fundraise; hard-capped by a constant; charged **only on withdraw**, never on refunds or unpledges; rounded down, remainder to the beneficiary. + +### A.5 Events + +``` +FundraiserCreated, ContributionMade, Unpledged, Finalized, Cancelled, +Withdrawn, PayoutAddressChanged, Refunded, TokenAllowed, +FeeParamsUpdated, SurplusRescued +``` + +Two indexer traps: use the **credited** amount, not the call argument; and `raised` can go **down**, because `unpledge` exists. + +### A.6 File layout + +``` +src/fundraising/FundraiserFactory.sol +src/fundraising/Fundraiser.sol +src/fundraising/interfaces/FundraisingTypes.sol # shared enums + params struct +src/fundraising/interfaces/IFundraiser.sol +src/fundraising/interfaces/IFundraiserFactory.sol +test/fundraising/{Lifecycle,GoalLatch,Permissionless,Refunds,Invariants}.t.sol +test/fundraising/mocks/{FeeOnTransferERC20,ReentrantERC20,BlocklistERC20}.sol +script/DeployFundraiserFactory.s.sol +src/fundraising/doc/spec/fundraising-design.md +``` + +License header `// SPDX-License-Identifier: BSD-3-Clause-Clear`, per repo convention. + +--- + +## Sources + +- Solidity by Example — `CrowdFund`, the shape this contract follows: https://solidity-by-example.org/app/crowd-fund/ +- OpenZeppelin Contracts CHANGELOG — removal of `Escrow` / `ConditionalEscrow` / `RefundEscrow` in 5.0.0 (2023-10-05): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md +- OpenZeppelin escrow API reference (the contracts survived through 4.x): https://docs.openzeppelin.com/contracts/4.x/api/utils#Escrow +- Party Protocol — Code4rena findings & analysis, October 2023: https://code4rena.com/reports/2023-10-party +- Party Protocol — `ETHCrowdfundBase` finalization DoS via `minContribution` (Code4rena Oct 2023, M-06), the source of §7 #2: https://github.com/code-423n4/2023-10-party-findings/issues/127 +- Party Protocol — 0xMacro audit: https://github.com/PartyDAO/party-protocol/blob/main/audits/Party-Protocol-Macro-Audit.pdf +- ERC-2612 permit (`depositWithPermit`, §8.2): https://eips.ethereum.org/EIPS/eip-2612 diff --git a/src/fundraising/interfaces/FundraisingTypes.sol b/src/fundraising/interfaces/FundraisingTypes.sol new file mode 100644 index 00000000..3dbf8160 --- /dev/null +++ b/src/fundraising/interfaces/FundraisingTypes.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +// FundraisingTypes +// +// Shared constants, enums and structs for the fundraising system. Solidity +// interfaces cannot declare enums, so these live at file level and are imported +// alongside the fundraising interfaces. + +// Longest permitted time from a fundraise's creation to its deadline. Bounds how long a +// contribution can be committed; defense in depth only, since the app offers far shorter +// presets. +uint40 constant MAX_FUNDRAISE_DURATION = 365 days; + +// Hard ceiling on the protocol fee, in basis points. A constant, so even a compromised +// admin cannot configure a confiscatory fee. +uint16 constant MAX_FEE_BPS_LIMIT = 500; + +/// @notice Lifecycle of a single fundraise. +/// @dev `Refunding` and `Closed` are terminal. There is no path back to `Funding`, +/// and no admin path that redirects contributor funds to the beneficiary. +enum Status { + Funding, + Succeeded, + Refunding, + Closed +} + +/// @notice What happens when a deadline passes with the target unmet. +/// @dev Deliberately not named `Distribute`: as an on-chain identifier that reads +/// just as easily as "distribute back to the contributors", which is the +/// opposite behavior. Product copy may still say "pay out what we raised". +enum OnMissed { + /// @notice Every contributor may claim their money back. The default. + Refund, + /// @notice The beneficiary receives whatever was raised. Requires a deadline, + /// since with no deadline the target is never "missed". + PayBeneficiary +} + +/// @notice Parameters supplied when creating a fundraise. +/// @dev Every field is fixed for the life of the fundraise. `name` is stored on-chain +/// so a fundraise is self-describing at its own address; richer metadata (image, +/// description) stays in the app. +struct FundraiserParams { + /// @notice Human-readable name, shown in-app. + string name; + /// @notice The ERC-20 collected. Must be allow-listed on the factory at creation. + address token; + /// @notice Target amount, in the token's smallest unit. Must be non-zero. + /// @dev Reaching this closes contributions permanently — see the goal latch on + /// `IFundraiser.unpledge`. It is a close trigger, not a soft minimum. + uint128 goal; + /// @notice Unix timestamp after which the fundraise resolves, or `0` for open-ended. + /// @dev An open-ended fundraise runs until it reaches its goal or is cancelled. This + /// is safe only because `unpledge` stays available for as long as `raised < goal`, + /// so contributors to a stalled open-ended fundraise can always leave. + uint40 deadline; + /// @notice Outcome when `deadline` passes below `goal`. + OnMissed onMissed; + /// @notice Receives the funds if the target is reached. Fixed at creation; only the + /// beneficiary itself may later repoint its own payout address. + address beneficiary; + /// @notice Smallest accepted contribution, or `0` for none. + /// @dev Enforced on deposit only, and never on the path that resolves the fundraise. + /// A contribution that reaches `goal` is exempt, so a remaining gap smaller than + /// this minimum is still fillable. + uint128 minContribution; + /// @notice Ceiling on total contributions, or `0` for uncapped. Must be `0` or `>= goal`. + uint128 maxTotalContributions; +} diff --git a/src/fundraising/interfaces/IFundraiser.sol b/src/fundraising/interfaces/IFundraiser.sol new file mode 100644 index 00000000..06402083 --- /dev/null +++ b/src/fundraising/interfaces/IFundraiser.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {FundraiserParams, OnMissed, Status} from "./FundraisingTypes.sol"; + +/** + * @title IFundraiser + * @notice Public API for a single fundraise: an escrow that collects one ERC-20 + * toward a target and resolves to exactly one of two outcomes — the beneficiary + * is paid, or every contributor takes their money back. + * @dev One contract per fundraise, deployed by `IFundraiserFactory`. Configuration is set + * by the constructor and never changes. See + * `src/fundraising/doc/spec/fundraising-design.md` for the specification. + * + * Two properties the rest of this interface is built to protect: + * + * 1. **Nobody can freeze the money.** `finalize` is callable by anyone once the goal + * is reached or a deadline has passed, and every exit is pull-based. No role, no + * signature, and no cooperation from the organizer or any backend is required to + * resolve a fundraise or to retrieve a contribution. + * 2. **The goal latch.** `unpledge` is available for exactly as long as `raised < goal`. + * Once the target is reached the commitment is binding — and anyone may reach it, + * including by covering the remaining gap. + */ +interface IFundraiser { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + /// @notice Emitted when a contribution is credited. + /// @param contributor The address whose balance was credited. + /// @param credited The amount actually received, which for a fee-on-transfer token is + /// less than the amount requested. Indexers must use this, not the call argument. + /// @param raised Total credited contributions after this deposit. + event ContributionMade(address indexed contributor, uint256 credited, uint256 raised); + + /// @notice Emitted when a contributor withdraws part or all of their own contribution. + /// @param raised Total credited contributions after this withdrawal. + /// @dev `raised` decreases here. Any indexer assuming monotonic growth will disagree + /// with the chain. + event Unpledged(address indexed contributor, uint256 amount, uint256 raised); + + /// @notice Emitted when the fundraise resolves. + /// @param outcome `Succeeded` or `Refunding`. + /// @param raised Total credited contributions at resolution. + /// @param caller Whoever resolved it — frequently not the organizer, by design. + event Finalized(Status outcome, uint256 raised, address indexed caller); + + /// @notice Emitted when the organizer cancels a fundraise that is still below its goal. + event Cancelled(address indexed organizer, uint256 raised); + + /// @notice Emitted when the beneficiary collects a successful raise. + /// @param net Amount paid to the payout address. + /// @param fee Protocol fee taken, which is zero unless a fee was configured at creation. + event Withdrawn(address indexed to, uint256 net, uint256 fee); + + /// @notice Emitted when the beneficiary repoints its own payout address. + event PayoutAddressChanged(address indexed previous, address indexed current); + + /// @notice Emitted when a contributor's money is returned. + /// @dev Also emitted for `refundFor`, where a third party pays the gas but the funds + /// still go to `contributor`. + event Refunded(address indexed contributor, uint256 amount); + + /// @notice Emitted when tokens that were never part of the escrow are swept out. + event SurplusRescued(address indexed token, address indexed to, uint256 amount); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + /// @notice Thrown when a required address argument is the zero address. + error ZeroAddress(); + + /// @notice Thrown when `goal` is zero. A fundraise with no target cannot resolve. + error ZeroGoal(); + + /// @notice Thrown when a deadline is at or before the current block timestamp. + error DeadlineInPast(); + + /// @notice Thrown when a deadline exceeds `MAX_DURATION` from now. + error DeadlineTooFar(uint40 deadline, uint40 maximum); + + /// @notice Thrown when `OnMissed.PayBeneficiary` is paired with no deadline. + /// @dev With no deadline there is no moment at which the target is missed, so the + /// setting could never fire. Rejected rather than silently stored. + error PayBeneficiaryRequiresDeadline(); + + /// @notice Thrown when a non-zero contribution cap is below the goal, which would make + /// success unreachable. + error CapBelowGoal(uint128 cap, uint128 goal); + + /// @notice Thrown when the configured fee exceeds the factory's hard cap. + error FeeTooHigh(uint16 feeBps, uint16 maximum); + + /// @notice Thrown when a function is called in the wrong lifecycle state. + error InvalidState(Status current); + + /// @notice Thrown when a deposit arrives at or after the deadline. + error DepositAfterDeadline(); + + /// @notice Thrown when the credited amount is below `minContribution` and does not + /// reach the goal. + error DepositBelowMinimum(uint256 credited, uint128 minimum); + + /// @notice Thrown when a deposit would push total contributions past the cap. + error CapExceeded(uint256 credited, uint256 remaining); + + /// @notice Thrown when credited contributions would exceed `type(uint128).max`. + error RaisedOverflow(uint256 raised, uint256 credited); + + /// @notice Thrown when an amount argument is zero. + /// @dev Zero-value calls are rejected rather than accepted as no-ops: they emit + /// misleading events and, where gas is sponsored, invite dust griefing. + error ZeroAmount(); + + /// @notice Thrown when `unpledge` or `cancel` is attempted at or above the goal. + /// @dev This is the goal latch. It never reopens, including if the goal is later + /// exceeded further. + error GoalReached(); + + /// @notice Thrown when a contributor tries to withdraw more than they put in. + error InsufficientContribution(uint256 requested, uint256 available); + + /// @notice Thrown when the fundraise can be neither succeeded nor refunded yet — + /// below goal, and either open-ended or before its deadline. + error NotFinalizable(); + + /// @notice Thrown when a caller is not the organizer. + error NotOrganizer(address caller); + + /// @notice Thrown when a caller is not the beneficiary. + error NotBeneficiary(address caller); + + /// @notice Thrown when a contributor has nothing to reclaim. + error NothingToRefund(address contributor); + + /// @notice Thrown when a rescue is attempted by an address without the factory's + /// admin role. + error NotFactoryAdmin(address caller); + + /// @notice Thrown when a rescue would reach into escrowed funds. + error NoSurplus(); + + // ────────────────────────────────────────────── + // Contributing + // ────────────────────────────────────────────── + + /// @notice Contribute `amount` of the fundraise token. + /// @dev Permissionless: there is no membership check on-chain. Requires an allowance to + /// this contract. Credits the amount actually received, which is what makes + /// fee-on-transfer tokens solvent here. + /// @param amount Amount to transfer in. Must be non-zero. + function deposit(uint256 amount) external; + + /// @notice Contribute using an EIP-2612 permit, avoiding a separate approval. + /// @dev Only usable with tokens implementing `permit`. A consumed or front-run permit + /// does not fail the deposit if an allowance already covers it. + function depositWithPermit(uint256 amount, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s) external; + + /// @notice Withdraw part or all of your own contribution. + /// @dev Available only while `raised < goal` — the goal latch. Needs no permission from + /// anyone and returns credited units, never more than the caller put in. + function unpledge(uint256 amount) external; + + // ────────────────────────────────────────────── + // Resolution + // ────────────────────────────────────────────── + + /// @notice Resolve the fundraise. + /// @dev **Callable by anyone**, deliberately: if resolution required a specific party, + /// that party's absence would freeze everyone's money. Succeeds once `raised >= goal`; + /// after a deadline passes below goal, resolves per `onMissed`. Checks only state, + /// deadline and goal — never a deposit-time rule such as `minContribution`. + function finalize() external; + + /// @notice Cancel a fundraise that is still below its goal, sending it to `Refunding`. + /// @dev Organizer only, and impossible once the goal is reached. It can only ever move + /// money back toward contributors. + function cancel() external; + + // ────────────────────────────────────────────── + // Payout and refunds + // ────────────────────────────────────────────── + + /// @notice Collect a successful raise, less any protocol fee. + function withdraw() external; + + /// @notice Repoint where a successful raise pays out. + /// @dev Callable only by the current beneficiary, and only after success. Exists so a + /// lost or blocked beneficiary key cannot strand the whole raise. Neither the + /// organizer nor any admin can call it. + function setPayoutAddress(address newBeneficiary) external; + + /// @notice Reclaim your own contribution after the fundraise entered `Refunding`. + function refund() external; + + /// @notice Reclaim on someone else's behalf; the funds go to `contributor` regardless + /// of who calls. + /// @dev Lets a third party sweep refunds so contributors are refunded rather than asked + /// to claim. Carries no custody: the caller cannot redirect the payment. + function refundFor(address contributor) external; + + /// @notice Sweep tokens that were never part of the escrow — mis-sends and airdrops. + /// @dev Restricted to the factory's admin and bounded to the surplus above what this + /// fundraise owes, so it is structurally incapable of touching contributor funds. + /// Unclaimed refunds remain liabilities and stay untouchable forever. + function rescueSurplus(address token_, address to) external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + function name() external view returns (string memory); + function token() external view returns (address); + function organizer() external view returns (address); + function beneficiary() external view returns (address); + function factory() external view returns (address); + + function goal() external view returns (uint128); + function deadline() external view returns (uint40); + function onMissed() external view returns (OnMissed); + function feeBps() external view returns (uint16); + function minContribution() external view returns (uint128); + function maxTotalContributions() external view returns (uint128); + + function status() external view returns (Status); + /// @notice Total credited contributions. Decreases when a contributor unpledges. + function raised() external view returns (uint128); + /// @notice Running total withdrawn by contributors before the goal was reached. + function unpledged() external view returns (uint128); + /// @notice Running total returned to contributors after entering `Refunding`. + function refunded() external view returns (uint128); + function contributions(address contributor) external view returns (uint256); + + /// @notice Amount still needed to reach the goal, or zero once reached. + function remainingToGoal() external view returns (uint256); + + /// @notice Whether contributors can currently withdraw — `Funding` and below goal. + function canUnpledge() external view returns (bool); + + /// @notice What this fundraise still owes its contributors and beneficiary. + /// @dev Anything the contract holds above this, in any token, is surplus. + function outstandingLiability() external view returns (uint256); +} diff --git a/src/fundraising/interfaces/IFundraiserFactory.sol b/src/fundraising/interfaces/IFundraiserFactory.sol new file mode 100644 index 00000000..20a3aec4 --- /dev/null +++ b/src/fundraising/interfaces/IFundraiserFactory.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {FundraiserParams} from "./FundraisingTypes.sol"; + +/** + * @title IFundraiserFactory + * @notice Deploys one `IFundraiser` contract per fundraise and holds the settings shared + * across them: which tokens may be collected, and the protocol fee. + * @dev Creation is **permissionless** — anyone may deploy a fundraise, and anyone may + * contribute to one. There is no membership or eligibility check on-chain. + * + * Each fundraise is a full contract deployed with `new`, not a proxy or a clone. + * EIP-1167 clones do not work on zkSync Era at all, and a proxy measured more + * expensive than a direct deployment there — see + * `src/fundraising/doc/spec/fundraising-design.md` section 6. + */ +interface IFundraiserFactory { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + /// @notice Emitted when a new fundraise is deployed. + /// @param fundraiser Address of the newly deployed escrow. + /// @param organizer Whoever created it, and the only address that may cancel it. + /// @param externalId An opaque tag supplied by the caller for off-chain reconciliation. + /// @dev `externalId` is **a hint, not a claim**. Nothing verifies it, and anyone may tag a + /// fundraise with any value, including one already in use. Resolve a fundraise from + /// records written when it was created, never from this tag, or an unrelated + /// contract can be mistaken for a known one. + event FundraiserCreated( + address indexed fundraiser, + address indexed organizer, + address indexed token, + bytes32 externalId, + uint128 goal, + uint40 deadline, + address beneficiary + ); + + /// @notice Emitted when a token is added to or removed from the allow-list. + /// @dev De-listing only prevents *new* fundraises choosing that token. It never blocks + /// deposits, withdrawals or refunds on live ones, which would make de-listing a + /// freeze switch. + event TokenAllowed(address indexed token, bool allowed); + + /// @notice Emitted when the protocol fee rate or recipient changes. + /// @dev A rate change applies only to fundraises created afterward. Live ones keep the + /// rate they were created with. + event FeeParamsUpdated(uint16 feeBps, address feeRecipient); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + /// @notice Thrown when a required address argument is the zero address. + error ZeroAddress(); + + /// @notice Thrown when the chosen token is not on the allow-list. + /// @dev The allow-list is what keeps rebasing and other unsupported tokens out of an + /// escrow whose accounting cannot survive them. + error TokenNotAllowed(address token); + + /// @notice Thrown when a fee rate above `MAX_FEE_BPS` is configured. + error FeeTooHigh(uint16 feeBps, uint16 maximum); + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + /// @notice Deploy a new fundraise. + /// @dev Callable by anyone. Checks the token allow-list and snapshots the current fee + /// rate into the new contract by value; all other validation happens in the + /// fundraise's own constructor, so it enforces its invariants regardless of who + /// deploys it. + /// @param externalId Opaque off-chain tag, emitted and never stored. See `FundraiserCreated`. + /// @return fundraiser Address of the newly deployed escrow. + function createFundraiser(FundraiserParams calldata params, bytes32 externalId) + external + returns (address fundraiser); + + // ────────────────────────────────────────────── + // Administration + // ────────────────────────────────────────────── + + /// @notice Add or remove a token from the allow-list for future fundraises. + function setTokenAllowed(address token, bool allowed) external; + + /// @notice Set the protocol fee rate and recipient for future fundraises. + /// @dev The rate is snapshotted per fundraise at creation, so this cannot skim anything + /// already in flight. The recipient is read at withdrawal time, so a lost + /// collection key can be rotated without touching live fundraises. + function setFeeParams(uint16 newFeeBps, address newFeeRecipient) external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @notice Hard ceiling on the protocol fee, in basis points. + /// @dev A constant, so even a compromised admin cannot set a confiscatory fee. + function MAX_FEE_BPS() external view returns (uint16); + + /// @notice Longest permitted time from creation to deadline. + function MAX_DURATION() external view returns (uint40); + + function isTokenAllowed(address token) external view returns (bool); + function feeBps() external view returns (uint16); + function feeRecipient() external view returns (address); + + /// @notice Whether an address was deployed by this factory. + /// @dev Lets indexers and refund sweepers verify provenance on-chain instead of trusting + /// an address they were handed. + function isFundraiser(address account) external view returns (bool); +} diff --git a/test/fundraising/Factory.t.sol b/test/fundraising/Factory.t.sol new file mode 100644 index 00000000..6b70092f --- /dev/null +++ b/test/fundraising/Factory.t.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; + +/// @notice The factory's own surface: the allow-list, the fee parameters, the registry, +/// and the bounds on what an admin can reach. +contract FactoryTest is FundraisingTestBase { + ERC20Mock internal otherToken; + + function setUp() public override { + super.setUp(); + otherToken = new ERC20Mock(); + } + + // ────────────────────────────────────────────── + // Allow-list + // ────────────────────────────────────────────── + + function test_rejectsTokenNotOnAllowList() public { + FundraiserParams memory p = defaultParams(); + p.token = address(otherToken); + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.TokenNotAllowed.selector, address(otherToken))); + create(p); + } + + /// @dev De-listing must stop new fundraises choosing a token without becoming a freeze + /// switch over live ones. + function test_deListingDoesNotTouchLiveFundraises() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + vm.prank(admin); + factory.setTokenAllowed(address(token), false); + + deposit(f, bob, 100e6); // deposits continue + vm.prank(alice); + f.unpledge(100e6); // so do withdrawals + + vm.prank(organizer); + f.cancel(); + vm.prank(bob); + f.refund(); // and refunds + + assertEq(balanceOf(f, bob), FUNDED); + + // but a new one cannot be created with it + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.TokenNotAllowed.selector, address(token))); + create(defaultParams()); + } + + // ────────────────────────────────────────────── + // Fees + // ────────────────────────────────────────────── + + /// @dev The property that makes the fee safe: a later rate change cannot reach a + /// fundraise whose contributors already committed under the old one. + function test_feeRateIsSnapshotAtCreation() public { + vm.prank(admin); + factory.setFeeParams(100, feeSink); // 1% + + Fundraiser f = createDefault(); + assertEq(f.feeBps(), 100); + + vm.prank(admin); + factory.setFeeParams(500, feeSink); // raised afterward + assertEq(f.feeBps(), 100, "in-flight fundraise must keep its rate"); + + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(balanceOf(f, feeSink), 10e6); // 1%, not 5% + } + + /// @dev The recipient is read live, so a lost collection key can be rotated without + /// touching live fundraises. It cannot change how much anyone receives. + function test_feeRecipientIsReadLive() public { + vm.prank(admin); + factory.setFeeParams(100, feeSink); + + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + address newSink = makeAddr("newSink"); + vm.prank(admin); + factory.setFeeParams(100, newSink); + + vm.prank(beneficiary); + f.withdraw(); + assertEq(balanceOf(f, newSink), 10e6); + assertEq(balanceOf(f, feeSink), 0); + } + + function test_feeCapIsEnforced() public { + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.FeeTooHigh.selector, uint16(501), uint16(500))); + factory.setFeeParams(501, feeSink); + + vm.prank(admin); + factory.setFeeParams(500, feeSink); // exactly at the cap is fine + assertEq(factory.feeBps(), 500); + } + + function test_rejectsNonZeroFeeWithNoRecipient() public { + vm.prank(admin); + vm.expectRevert(IFundraiserFactory.ZeroAddress.selector); + factory.setFeeParams(100, address(0)); + } + + // ────────────────────────────────────────────── + // Admin bounds + // ────────────────────────────────────────────── + + function test_adminFunctionsAreGated() public { + vm.prank(alice); + vm.expectRevert(); + factory.setTokenAllowed(address(otherToken), true); + + vm.prank(alice); + vm.expectRevert(); + factory.setFeeParams(10, feeSink); + } + + /// @dev The admin has no lever over a live fundraise at all. + function test_adminCannotTouchALiveFundraise() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + vm.startPrank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotOrganizer.selector, admin)); + f.cancel(); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Funding)); + f.withdraw(); + vm.stopPrank(); + } + + // ────────────────────────────────────────────── + // Surplus rescue + // ────────────────────────────────────────────── + + function test_rescueSurplus_onlyReachesNonEscrowFunds() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + token.mint(address(f), 25e6); // a mis-send + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotFactoryAdmin.selector, alice)); + f.rescueSurplus(address(token), alice); + + vm.prank(admin); + f.rescueSurplus(address(token), admin); + + assertEq(balanceOf(f, admin), 25e6); + assertEq(balanceOf(f, address(f)), 400e6, "escrow untouched"); + assertEq(f.contributions(alice), 400e6); + + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + } + + function test_unclaimedRefundsAreNeverSurplus() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + + // still true long after everyone has forgotten about it + vm.warp(block.timestamp + 3650 days); + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + } + + function test_rescueOfAnUnrelatedTokenTakesTheWholeBalance() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + otherToken.mint(address(f), 77e6); // airdrop + + vm.prank(admin); + f.rescueSurplus(address(otherToken), admin); + assertEq(otherToken.balanceOf(admin), 77e6); + assertEq(balanceOf(f, address(f)), 400e6); + } + + // ────────────────────────────────────────────── + // Registry + // ────────────────────────────────────────────── + + function test_registryRecordsOnlyWhatItDeployed() public { + Fundraiser f = createDefault(); + assertTrue(factory.isFundraiser(address(f))); + assertFalse(factory.isFundraiser(address(0xdead))); + assertFalse(factory.isFundraiser(address(token))); + } +} diff --git a/test/fundraising/FundraisingTestBase.sol b/test/fundraising/FundraisingTestBase.sol new file mode 100644 index 00000000..8242b0a1 --- /dev/null +++ b/test/fundraising/FundraisingTestBase.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {FundraiserFactory} from "../../src/fundraising/FundraiserFactory.sol"; +import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; +import {IFundraiser} from "../../src/fundraising/interfaces/IFundraiser.sol"; +import {IFundraiserFactory} from "../../src/fundraising/interfaces/IFundraiserFactory.sol"; +import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; + +/// @notice Shared fixture: a factory, an allow-listed token, and named actors. +abstract contract FundraisingTestBase is Test { + FundraiserFactory internal factory; + ERC20Mock internal token; + + address internal admin = makeAddr("admin"); + address internal organizer = makeAddr("organizer"); + address internal beneficiary = makeAddr("beneficiary"); + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + address internal stranger = makeAddr("stranger"); + address internal feeSink = makeAddr("feeSink"); + + uint128 internal constant GOAL = 1_000e6; + uint256 internal constant FUNDED = 10_000e6; + + function setUp() public virtual { + token = new ERC20Mock(); + address[] memory allowed = new address[](1); + allowed[0] = address(token); + factory = new FundraiserFactory(admin, 0, address(0), allowed); + + address[6] memory actors = [alice, bob, carol, stranger, organizer, beneficiary]; + for (uint256 i = 0; i < actors.length; ++i) { + token.mint(actors[i], FUNDED); + } + } + + // ── fixture helpers ─────────────────────────── + + function defaultParams() internal view returns (FundraiserParams memory) { + return FundraiserParams({ + name: "Lisbon trip, March", + token: address(token), + goal: GOAL, + deadline: uint40(block.timestamp + 30 days), + onMissed: OnMissed.Refund, + beneficiary: beneficiary, + minContribution: 0, + maxTotalContributions: 0 + }); + } + + function create(FundraiserParams memory p) internal returns (Fundraiser) { + vm.prank(organizer); + return Fundraiser(factory.createFundraiser(p, bytes32("external-1"))); + } + + function createDefault() internal returns (Fundraiser) { + return create(defaultParams()); + } + + function deposit(Fundraiser f, address who, uint256 amount) internal { + vm.startPrank(who); + IERC20Like(f.token()).approve(address(f), amount); + f.deposit(amount); + vm.stopPrank(); + } + + function balanceOf(Fundraiser f, address who) internal view returns (uint256) { + return IERC20Like(f.token()).balanceOf(who); + } +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); +} diff --git a/test/fundraising/GoalLatch.t.sol b/test/fundraising/GoalLatch.t.sol new file mode 100644 index 00000000..d6e02f64 --- /dev/null +++ b/test/fundraising/GoalLatch.t.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; + +/// @notice The goal latch: contributions are reversible below the target and binding at it. +/// @dev The rule is two strict comparisons. These tests exist so an off-by-one in either +/// direction — unwinding a met target, or locking contributors one unit early — fails +/// loudly. +contract GoalLatchTest is FundraisingTestBase { + function test_belowGoal_unpledgeAllowed() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL - 1); + + assertTrue(f.canUnpledge()); + vm.prank(alice); + f.unpledge(1); + + assertEq(f.raised(), GOAL - 2); + assertEq(f.unpledged(), 1); + assertEq(balanceOf(f, alice), FUNDED - (GOAL - 2)); + } + + function test_atExactlyGoal_latches() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + + function test_aboveGoal_latches() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL + 1); + + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + + /// @dev No flag, no event, no grace period: the crossing deposit latches in its own + /// transaction. + function test_crossingDepositLatchesAtomically() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL - 10); + assertTrue(f.canUnpledge()); + + deposit(f, bob, 10); + assertFalse(f.canUnpledge()); + } + + function test_cancelAlsoBlockedAtGoal() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + + vm.prank(organizer); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.cancel(); + } + + /// @dev Contributions are still accepted after the latch, so the invariant is that + /// `raised` never re-crosses below `goal` — not that it stops moving. + function test_depositsStillAcceptedAfterLatch() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + deposit(f, bob, 500e6); + + assertEq(f.raised(), GOAL + 500e6); + assertFalse(f.canUnpledge()); + } + + /// @dev Topping up and then dropping back must be impossible, or the latch would only + /// be advisory. + function test_latchNeverReopens() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(GOAL); + + // and still not after the deadline passes + vm.warp(block.timestamp + 31 days); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + + /// @dev Past the deadline but not yet finalized, the fundraise is still below goal and + /// still `Funding`. Keeping the exit open means nobody is stranded in that window. + function test_pastDeadlineButUnfinalized_exitStaysOpen() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + deposit(f, alice, 500e6); + + vm.warp(uint256(p.deadline) + 1 days); + assertTrue(f.canUnpledge()); + + vm.prank(alice); + f.unpledge(500e6); + assertEq(balanceOf(f, alice), FUNDED); + } + + function test_unpledgeOnlyReturnsYourOwn() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + deposit(f, bob, 100e6); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InsufficientContribution.selector, 200e6, 100e6)); + f.unpledge(200e6); + + vm.prank(bob); + f.unpledge(100e6); + assertEq(f.contributions(alice), 400e6); + assertEq(f.raised(), 400e6); + } + + function test_rejects_zeroAmountUnpledge() public { + Fundraiser f = createDefault(); + deposit(f, alice, 100e6); + vm.prank(alice); + vm.expectRevert(IFundraiser.ZeroAmount.selector); + f.unpledge(0); + } + + function test_unpledgeAfterResolutionRejected() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Refunding)); + f.unpledge(1); + } + + // ────────────────────────────────────────────── + // Fuzz + // ────────────────────────────────────────────── + + /// @dev After any sequence of deposits and withdrawals, the exit is open exactly when + /// the fundraise is below its target. That equivalence is the whole rule. + function testFuzz_canUnpledgeTracksRaisedBelowGoal(uint96 a, uint96 b, uint96 pull) public { + uint256 depA = bound(uint256(a), 1, FUNDED / 2); + uint256 depB = bound(uint256(b), 1, FUNDED / 2); + + Fundraiser f = createDefault(); + deposit(f, alice, depA); + assertEq(f.canUnpledge(), f.raised() < GOAL); + + if (f.canUnpledge()) { + uint256 amount = bound(uint256(pull), 1, depA); + vm.prank(alice); + f.unpledge(amount); + assertEq(f.canUnpledge(), f.raised() < GOAL); + } + + deposit(f, bob, depB); + assertEq(f.canUnpledge(), f.raised() < GOAL); + + // once reached, it must never reopen + if (f.raised() >= GOAL) { + vm.prank(bob); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + } + + function testFuzz_boundaryAroundGoal(uint8 offset) public { + // land anywhere in [goal-128, goal+127] and assert the rule holds exactly at goal + uint256 target = uint256(GOAL) + offset - 128; + Fundraiser f = createDefault(); + deposit(f, alice, target); + + if (target < GOAL) { + assertTrue(f.canUnpledge()); + vm.prank(alice); + f.unpledge(1); + } else { + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + } +} diff --git a/test/fundraising/Invariants.t.sol b/test/fundraising/Invariants.t.sol new file mode 100644 index 00000000..8887642f --- /dev/null +++ b/test/fundraising/Invariants.t.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {FundraiserFactory} from "../../src/fundraising/FundraiserFactory.sol"; +import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; +import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; + +/// @notice Drives a single fundraise through random sequences of every public action. +/// @dev Calls are wrapped in try/catch: a revert is a legitimate outcome (wrong state, +/// latched, nothing to refund), and what matters is that the invariants hold after +/// whatever did succeed. +contract FundraiserHandler is Test { + Fundraiser public f; + ERC20Mock public token; + address public beneficiary; + address public organizer; + address[] public actors; + + // ghosts + bool public goalWasReached; + uint256 public beneficiaryReceived; + Status public lastStatus; + bool public sawIllegalTransition; + + constructor(Fundraiser f_, ERC20Mock token_, address organizer_, address beneficiary_, address[] memory actors_) { + f = f_; + token = token_; + organizer = organizer_; + beneficiary = beneficiary_; + actors = actors_; + lastStatus = f_.status(); + } + + function _actor(uint256 seed) internal view returns (address) { + return actors[seed % actors.length]; + } + + function _sync() internal { + if (f.raised() >= f.goal()) goalWasReached = true; + + Status current = f.status(); + if (current != lastStatus) { + bool legal = (lastStatus == Status.Funding && (current == Status.Succeeded || current == Status.Refunding)) + || (lastStatus == Status.Succeeded && current == Status.Closed); + if (!legal) sawIllegalTransition = true; + lastStatus = current; + } + } + + function deposit(uint256 actorSeed, uint96 amount) external { + address a = _actor(actorSeed); + uint256 value = bound(uint256(amount), 1, 500e6); + vm.startPrank(a); + token.approve(address(f), value); + try f.deposit(value) {} catch {} + vm.stopPrank(); + _sync(); + } + + function unpledge(uint256 actorSeed, uint96 amount) external { + address a = _actor(actorSeed); + uint256 value = bound(uint256(amount), 1, 500e6); + vm.prank(a); + try f.unpledge(value) {} catch {} + _sync(); + } + + function finalize(uint256 warpBy) external { + vm.warp(block.timestamp + bound(warpBy, 0, 10 days)); + try f.finalize() {} catch {} + _sync(); + } + + function cancel() external { + vm.prank(organizer); + try f.cancel() {} catch {} + _sync(); + } + + function withdraw() external { + uint256 before = token.balanceOf(beneficiary); + vm.prank(beneficiary); + try f.withdraw() {} catch {} + beneficiaryReceived += token.balanceOf(beneficiary) - before; + _sync(); + } + + function refund(uint256 actorSeed) external { + vm.prank(_actor(actorSeed)); + try f.refund() {} catch {} + _sync(); + } + + function refundFor(uint256 actorSeed) external { + try f.refundFor(_actor(actorSeed)) {} catch {} + _sync(); + } + + function sumContributions() external view returns (uint256 total) { + for (uint256 i = 0; i < actors.length; ++i) { + total += f.contributions(actors[i]); + } + } + + function actorCount() external view returns (uint256) { + return actors.length; + } +} + +contract InvariantsTest is Test { + FundraiserFactory factory; + ERC20Mock token; + Fundraiser fundraiser; + FundraiserHandler handler; + + address admin = makeAddr("admin"); + address organizer = makeAddr("organizer"); + address beneficiary = makeAddr("beneficiary"); + + uint128 constant GOAL = 1_000e6; + + function setUp() public { + token = new ERC20Mock(); + address[] memory allowed = new address[](1); + allowed[0] = address(token); + factory = new FundraiserFactory(admin, 0, address(0), allowed); + + address[] memory actors = new address[](4); + actors[0] = makeAddr("a1"); + actors[1] = makeAddr("a2"); + actors[2] = makeAddr("a3"); + actors[3] = makeAddr("a4"); + for (uint256 i = 0; i < actors.length; ++i) { + token.mint(actors[i], 10_000e6); + } + + vm.prank(organizer); + fundraiser = Fundraiser( + factory.createFundraiser( + FundraiserParams({ + name: "invariant fundraise", + token: address(token), + goal: GOAL, + deadline: uint40(block.timestamp + 30 days), + onMissed: OnMissed.Refund, + beneficiary: beneficiary, + minContribution: 0, + maxTotalContributions: 0 + }), + bytes32("inv") + ) + ); + + handler = new FundraiserHandler(fundraiser, token, organizer, beneficiary, actors); + targetContract(address(handler)); + } + + /// @dev What the contract records as owed matches what contributors are individually + /// owed. Any drift here is a bookkeeping bug that would surface as a refund that cannot be + /// refund. + function invariant_contributionsSumToRaisedMinusRefunded() public view { + assertEq(handler.sumContributions(), fundraiser.raised() - fundraiser.refunded()); + } + + /// @dev Solvency: the contract always holds at least what it still owes. + function invariant_balanceCoversOutstandingLiability() public view { + assertGe(token.balanceOf(address(fundraiser)), fundraiser.outstandingLiability()); + } + + /// @dev The goal latch, as a property rather than a boundary case: once reached, never + /// released. + function invariant_goalOnceReachedStaysReached() public view { + if (handler.goalWasReached()) { + assertGe(fundraiser.raised(), fundraiser.goal()); + assertFalse(fundraiser.canUnpledge()); + } + } + + /// @dev The exit is open exactly while the fundraise is collecting and below target. + function invariant_canUnpledgeMatchesTheRule() public view { + assertEq( + fundraiser.canUnpledge(), fundraiser.status() == Status.Funding && fundraiser.raised() < fundraiser.goal() + ); + } + + /// @dev Money reaches the beneficiary only through a successful raise. + function invariant_beneficiaryOnlyPaidOnSuccess() public view { + if (handler.beneficiaryReceived() > 0) { + assertTrue(fundraiser.status() == Status.Closed); + assertFalse(handler.sawIllegalTransition()); + } + } + + function invariant_refundedNeverExceedsRaised() public view { + assertLe(fundraiser.refunded(), fundraiser.raised()); + } + + function invariant_statusOnlyMovesAlongLegalEdges() public view { + assertFalse(handler.sawIllegalTransition()); + } +} diff --git a/test/fundraising/Lifecycle.t.sol b/test/fundraising/Lifecycle.t.sol new file mode 100644 index 00000000..0137a0bc --- /dev/null +++ b/test/fundraising/Lifecycle.t.sol @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; + +/// @notice Every journey a fundraise can take, and every edge it must refuse. +contract LifecycleTest is FundraisingTestBase { + // ────────────────────────────────────────────── + // The three journeys that end in money moving + // ────────────────────────────────────────────── + + function test_journey_targetReached_beneficiaryCollects() public { + Fundraiser f = createDefault(); + assertEq(uint8(f.status()), uint8(Status.Funding)); + + deposit(f, alice, 600e6); + deposit(f, bob, 400e6); + assertEq(f.raised(), GOAL); + assertEq(f.remainingToGoal(), 0); + + vm.expectEmit(true, false, false, true, address(f)); + emit IFundraiser.Finalized(Status.Succeeded, GOAL, stranger); + vm.prank(stranger); + f.finalize(); + + vm.prank(beneficiary); + f.withdraw(); + + assertEq(uint8(f.status()), uint8(Status.Closed)); + assertEq(balanceOf(f, beneficiary), FUNDED + GOAL); + assertEq(balanceOf(f, address(f)), 0); + } + + function test_journey_targetMissed_everyoneRefunded() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + deposit(f, alice, 300e6); + deposit(f, bob, 200e6); + + vm.warp(p.deadline); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Refunding)); + + vm.prank(alice); + f.refund(); + vm.prank(bob); + f.refund(); + + assertEq(balanceOf(f, alice), FUNDED); + assertEq(balanceOf(f, bob), FUNDED); + assertEq(balanceOf(f, address(f)), 0); + assertEq(f.refunded(), 500e6); + } + + function test_journey_targetMissed_payBeneficiaryKeepsWhatWasRaised() public { + FundraiserParams memory p = defaultParams(); + p.onMissed = OnMissed.PayBeneficiary; + Fundraiser f = create(p); + + deposit(f, alice, 300e6); + vm.warp(p.deadline); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + + vm.prank(beneficiary); + f.withdraw(); + assertEq(balanceOf(f, beneficiary), FUNDED + 300e6); + + // and the contributor has no way back + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Closed)); + f.refund(); + } + + function test_journey_organizerCancels_beforeGoal() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + vm.expectEmit(true, false, false, true, address(f)); + emit IFundraiser.Cancelled(organizer, 400e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + assertEq(balanceOf(f, alice), FUNDED); + } + + function test_journey_openEnded_runsUntilGoalReached() public { + FundraiserParams memory p = defaultParams(); + p.deadline = 0; + Fundraiser f = create(p); + + deposit(f, alice, 500e6); + vm.warp(block.timestamp + 3650 days); + + // never resolves on its own + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + + // and the exit is what keeps that safe + assertTrue(f.canUnpledge()); + + deposit(f, bob, 500e6); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + } + + function test_journey_beneficiaryRepointsPayout() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + address newPayout = makeAddr("newPayout"); + vm.expectEmit(true, true, false, false, address(f)); + emit IFundraiser.PayoutAddressChanged(beneficiary, newPayout); + vm.prank(beneficiary); + f.setPayoutAddress(newPayout); + + vm.prank(newPayout); + f.withdraw(); + assertEq(balanceOf(f, newPayout), GOAL); + assertEq(balanceOf(f, beneficiary), FUNDED); + } + + function test_journey_withFee() public { + vm.prank(admin); + factory.setFeeParams(250, feeSink); // 2.5% + + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(balanceOf(f, feeSink), 25e6); + assertEq(balanceOf(f, beneficiary), FUNDED + GOAL - 25e6); + } + + function test_feeRoundsDownInFavourOfContributors() public { + vm.prank(admin); + factory.setFeeParams(1, feeSink); // 0.01% + + FundraiserParams memory p = defaultParams(); + p.goal = 999; // 999 * 1 / 10000 = 0 after flooring + Fundraiser f = create(p); + deposit(f, alice, 999); + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(balanceOf(f, feeSink), 0); + assertEq(balanceOf(f, beneficiary), FUNDED + 999); + } + + // ────────────────────────────────────────────── + // Deadline boundary + // ────────────────────────────────────────────── + + function test_atExactDeadline_depositsClosed_finalizeOpen() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + deposit(f, alice, 100e6); + + vm.warp(p.deadline); + + vm.startPrank(bob); + token.approve(address(f), 1e6); + vm.expectRevert(IFundraiser.DepositAfterDeadline.selector); + f.deposit(1e6); + vm.stopPrank(); + + f.finalize(); // open at the same instant + assertEq(uint8(f.status()), uint8(Status.Refunding)); + } + + function test_oneSecondBeforeDeadline_depositOpen_finalizeClosed() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + vm.warp(uint256(p.deadline) - 1); + deposit(f, alice, 100e6); + + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + } + + // ────────────────────────────────────────────── + // Regressions named for the prior art + // ────────────────────────────────────────────── + + /// @dev Party Protocol, Code4rena October 2023 finding M-06: a minimum-contribution + /// check made a crowdfund impossible to finalize, locking contributor funds until + /// expiry. A gap smaller than the minimum must still be fillable. + function test_partyM06_gapSmallerThanMinimumIsStillFillable() public { + FundraiserParams memory p = defaultParams(); + p.minContribution = 100e6; + Fundraiser f = create(p); + + deposit(f, alice, 950e6); + assertEq(f.remainingToGoal(), 50e6); + + // 50 is below the 100 minimum, but it reaches the goal, so it is accepted + deposit(f, bob, 50e6); + assertEq(f.raised(), GOAL); + + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + } + + function test_minimumStillEnforcedWhenItDoesNotReachGoal() public { + FundraiserParams memory p = defaultParams(); + p.minContribution = 100e6; + Fundraiser f = create(p); + + vm.startPrank(alice); + token.approve(address(f), 50e6); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.DepositBelowMinimum.selector, 50e6, uint128(100e6))); + f.deposit(50e6); + vm.stopPrank(); + } + + /// @dev An organizer who vanishes must not be able to freeze anyone's money. + function test_organizerNeverActs_strangerResolvesAndEveryoneRecovers() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + deposit(f, alice, 400e6); + + vm.warp(p.deadline); + vm.prank(stranger); + f.finalize(); + + vm.prank(stranger); + f.refundFor(alice); + assertEq(balanceOf(f, alice), FUNDED); + } + + // ────────────────────────────────────────────── + // Constructor validation + // ────────────────────────────────────────────── + + function test_rejects_zeroGoal() public { + FundraiserParams memory p = defaultParams(); + p.goal = 0; + vm.expectRevert(IFundraiser.ZeroGoal.selector); + create(p); + } + + function test_rejects_zeroBeneficiary() public { + FundraiserParams memory p = defaultParams(); + p.beneficiary = address(0); + vm.expectRevert(IFundraiser.ZeroAddress.selector); + create(p); + } + + function test_rejects_deadlineInPast() public { + FundraiserParams memory p = defaultParams(); + p.deadline = uint40(block.timestamp); + vm.expectRevert(IFundraiser.DeadlineInPast.selector); + create(p); + } + + function test_rejects_deadlineBeyondMaxDuration() public { + FundraiserParams memory p = defaultParams(); + p.deadline = uint40(block.timestamp + 366 days); + vm.expectRevert(); + create(p); + } + + function test_accepts_deadlineAtExactlyMaxDuration() public { + FundraiserParams memory p = defaultParams(); + p.deadline = uint40(block.timestamp) + factory.MAX_DURATION(); + Fundraiser f = create(p); + assertEq(f.deadline(), p.deadline); + } + + function test_rejects_openEndedPayBeneficiary() public { + FundraiserParams memory p = defaultParams(); + p.deadline = 0; + p.onMissed = OnMissed.PayBeneficiary; + vm.expectRevert(IFundraiser.PayBeneficiaryRequiresDeadline.selector); + create(p); + } + + function test_rejects_capBelowGoal() public { + FundraiserParams memory p = defaultParams(); + p.maxTotalContributions = GOAL - 1; + vm.expectRevert(abi.encodeWithSelector(IFundraiser.CapBelowGoal.selector, GOAL - 1, GOAL)); + create(p); + } + + function test_capIsEnforcedOnDeposit() public { + FundraiserParams memory p = defaultParams(); + p.maxTotalContributions = GOAL; + Fundraiser f = create(p); + + deposit(f, alice, 900e6); + vm.startPrank(bob); + token.approve(address(f), 200e6); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.CapExceeded.selector, 200e6, 100e6)); + f.deposit(200e6); + vm.stopPrank(); + } + + // ────────────────────────────────────────────── + // Wrong-state and wrong-caller edges + // ────────────────────────────────────────────── + + function test_rejects_zeroAmountDeposit() public { + Fundraiser f = createDefault(); + vm.prank(alice); + vm.expectRevert(IFundraiser.ZeroAmount.selector); + f.deposit(0); + } + + function test_rejects_depositAfterResolution() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + vm.startPrank(bob); + token.approve(address(f), 1e6); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Succeeded)); + f.deposit(1e6); + vm.stopPrank(); + } + + function test_rejects_doubleFinalize() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Succeeded)); + f.finalize(); + } + + function test_rejects_withdrawByNonBeneficiary() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(organizer); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotBeneficiary.selector, organizer)); + f.withdraw(); + } + + function test_rejects_withdrawBeforeSuccess() public { + Fundraiser f = createDefault(); + deposit(f, alice, 100e6); + vm.prank(beneficiary); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Funding)); + f.withdraw(); + } + + function test_rejects_cancelByNonOrganizer() public { + Fundraiser f = createDefault(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotOrganizer.selector, alice)); + f.cancel(); + } + + function test_rejects_refundWhileFunding() public { + Fundraiser f = createDefault(); + deposit(f, alice, 100e6); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Funding)); + f.refund(); + } + + function test_rejects_setPayoutAddressByOrganizerOrAdmin() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + vm.prank(organizer); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotBeneficiary.selector, organizer)); + f.setPayoutAddress(organizer); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotBeneficiary.selector, admin)); + f.setPayoutAddress(admin); + } + + function test_rejects_setPayoutAddressToZero() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(beneficiary); + vm.expectRevert(IFundraiser.ZeroAddress.selector); + f.setPayoutAddress(address(0)); + } +} diff --git a/test/fundraising/Permissionless.t.sol b/test/fundraising/Permissionless.t.sol new file mode 100644 index 00000000..cef5e685 --- /dev/null +++ b/test/fundraising/Permissionless.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; +import {PermitERC20} from "./mocks/PermitERC20.sol"; + +/// @notice A contract wallet, to prove nothing assumes an externally-owned account. +contract SmartWallet { + function call(address target, bytes memory data) external returns (bytes memory) { + (bool ok, bytes memory ret) = target.call(data); + require(ok, "SmartWallet: call failed"); + return ret; + } +} + +/// @notice The escrow asks nobody for permission. These are the consequences, including +/// the ones we accepted rather than prevented. +contract PermissionlessTest is FundraisingTestBase { + function test_anyoneCanCreate_organizerIsWhoeverCalled() public { + vm.prank(stranger); + address f = factory.createFundraiser(defaultParams(), bytes32("whatever")); + + assertEq(Fundraiser(f).organizer(), stranger); + assertTrue(factory.isFundraiser(f)); + } + + function test_nonMemberContributesAndRefundsLikeAnyoneElse() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + deposit(f, stranger, 400e6); + assertEq(f.contributions(stranger), 400e6); + + vm.warp(p.deadline); + f.finalize(); + vm.prank(stranger); + f.refund(); + assertEq(balanceOf(f, stranger), FUNDED); + } + + /// @dev `externalId` is a hint, not a claim. Two unrelated fundraises may carry the same + /// tag, which is why callers must resolve a fundraise from their own records. + function test_externalIdIsNotUnique_andNotVerified() public { + vm.prank(organizer); + address real = factory.createFundraiser(defaultParams(), bytes32("external-1")); + + FundraiserParams memory impostorParams = defaultParams(); + impostorParams.beneficiary = stranger; + vm.prank(stranger); + address impostor = factory.createFundraiser(impostorParams, bytes32("external-1")); + + assertTrue(real != impostor); + assertTrue(factory.isFundraiser(real) && factory.isFundraiser(impostor)); + assertEq(Fundraiser(impostor).beneficiary(), stranger); + } + + /// @dev Accepted residual, documented rather than prevented: anyone can cover the + /// remaining gap, which closes every contributor's exit. The money still goes to + /// the beneficiary the contributors saw at creation. + function test_strangerFundsTheGap_andClosesEveryExit() public { + Fundraiser f = createDefault(); + deposit(f, alice, 900e6); + assertTrue(f.canUnpledge()); + + deposit(f, stranger, 100e6); + + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + assertEq(balanceOf(f, beneficiary), FUNDED + GOAL); + } + + /// @dev The sharpest form: an organizer who is also the beneficiary recovers their own + /// top-up, so forcing a partial raise to completion is close to free for them. + function test_organizerIsBeneficiary_gapFundingIsNearlyFree() public { + FundraiserParams memory p = defaultParams(); + p.beneficiary = organizer; + Fundraiser f = create(p); + + deposit(f, alice, 900e6); + uint256 organizerBefore = balanceOf(f, organizer); + + deposit(f, organizer, 100e6); // covers the gap out of their own pocket + f.finalize(); + vm.prank(organizer); + f.withdraw(); + + // they got their 100 back plus alice's 900 + assertEq(balanceOf(f, organizer), organizerBefore + 900e6); + assertEq(f.contributions(alice), 900e6); + } + + function test_smartAccountCanContributeAndRefund() public { + SmartWallet wallet = new SmartWallet(); + token.mint(address(wallet), FUNDED); + + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + wallet.call(address(token), abi.encodeCall(IERC20Like.approve, (address(f), 500e6))); + wallet.call(address(f), abi.encodeCall(IFundraiser.deposit, (500e6))); + assertEq(f.contributions(address(wallet)), 500e6); + + vm.warp(p.deadline); + f.finalize(); + wallet.call(address(f), abi.encodeCall(IFundraiser.refund, ())); + assertEq(token.balanceOf(address(wallet)), FUNDED); + } + + function test_depositWithPermit_singleTransaction() public { + PermitERC20 prm = new PermitERC20(); + vm.prank(admin); + factory.setTokenAllowed(address(prm), true); + + (address signer, uint256 pk) = makeAddrAndKey("permitSigner"); + prm.mint(signer, FUNDED); + + FundraiserParams memory p = defaultParams(); + p.token = address(prm); + Fundraiser f = create(p); + + uint256 amount = 400e6; + uint256 permitDeadline = block.timestamp + 1 hours; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + signer, + address(f), + amount, + prm.nonces(signer), + permitDeadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", prm.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + + vm.prank(signer); + f.depositWithPermit(amount, permitDeadline, v, r, s); // no separate approval + + assertEq(f.contributions(signer), amount); + } + + /// @dev A permit consumed by someone else in the mempool must not fail the deposit. + function test_depositWithPermit_survivesAFrontRunPermit() public { + PermitERC20 prm = new PermitERC20(); + vm.prank(admin); + factory.setTokenAllowed(address(prm), true); + + (address signer, uint256 pk) = makeAddrAndKey("permitSigner2"); + prm.mint(signer, FUNDED); + + FundraiserParams memory p = defaultParams(); + p.token = address(prm); + Fundraiser f = create(p); + + uint256 amount = 400e6; + uint256 permitDeadline = block.timestamp + 1 hours; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + signer, + address(f), + amount, + prm.nonces(signer), + permitDeadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", prm.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + + // someone else submits the permit first, consuming the nonce + vm.prank(stranger); + prm.permit(signer, address(f), amount, permitDeadline, v, r, s); + + // the deposit still lands, because the allowance it needed now exists + vm.prank(signer); + f.depositWithPermit(amount, permitDeadline, v, r, s); + assertEq(f.contributions(signer), amount); + } + + /// @dev Nothing in the escrow assumes sponsored gas. Every state-changing call here is + /// an ordinary self-paying transaction with no paymaster in the picture. + function test_everyPathWorksWithoutAnyPaymaster() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + deposit(f, alice, 400e6); + vm.prank(alice); + f.unpledge(100e6); + deposit(f, bob, 200e6); + + vm.warp(p.deadline); + vm.prank(carol); + f.finalize(); + + vm.prank(alice); + f.refund(); + vm.prank(carol); + f.refundFor(bob); + + assertEq(balanceOf(f, alice), FUNDED); + assertEq(balanceOf(f, bob), FUNDED); + assertEq(balanceOf(f, address(f)), 0); + } +} diff --git a/test/fundraising/Refunds.t.sol b/test/fundraising/Refunds.t.sol new file mode 100644 index 00000000..a85f7fec --- /dev/null +++ b/test/fundraising/Refunds.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; +import {FeeOnTransferERC20} from "./mocks/FeeOnTransferERC20.sol"; +import {ReentrantERC20} from "./mocks/ReentrantERC20.sol"; +import {BlocklistERC20} from "./mocks/BlocklistERC20.sol"; + +/// @notice Getting money back out, including against tokens that misbehave. +contract RefundsTest is FundraisingTestBase { + function _allow(address t) internal { + vm.prank(admin); + factory.setTokenAllowed(t, true); + } + + function _paramsFor(address t, uint128 goal) internal view returns (FundraiserParams memory p) { + p = defaultParams(); + p.token = t; + p.goal = goal; + } + + // ────────────────────────────────────────────── + // The ordinary path + // ────────────────────────────────────────────── + + function test_refundReturnsExactlyWhatWasContributed() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + assertEq(balanceOf(f, alice), FUNDED); + assertEq(f.contributions(alice), 0); + } + + function test_secondRefundReverts() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NothingToRefund.selector, alice)); + f.refund(); + } + + function test_refundForSendsToContributorNotCaller() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + uint256 strangerBefore = balanceOf(f, stranger); + vm.prank(stranger); + f.refundFor(alice); + + assertEq(balanceOf(f, alice), FUNDED); + assertEq(balanceOf(f, stranger), strangerBefore, "caller must not receive the funds"); + } + + function test_refundForNonContributorReverts() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NothingToRefund.selector, carol)); + f.refundFor(carol); + } + + // ────────────────────────────────────────────── + // Fee-on-transfer: the insolvency balance-delta crediting prevents + // ────────────────────────────────────────────── + + /// @dev Every contributor must be able to get out, including the last one. Crediting + /// the requested amount instead of the received amount is what breaks this. + function test_feeOnTransfer_allContributorsCanRefundIncludingTheLast() public { + FeeOnTransferERC20 fot = new FeeOnTransferERC20(100); // 1% burned per transfer + _allow(address(fot)); + fot.mint(alice, FUNDED); + fot.mint(bob, FUNDED); + fot.mint(carol, FUNDED); + + Fundraiser f = create(_paramsFor(address(fot), GOAL)); + + deposit(f, alice, 300e6); + deposit(f, bob, 300e6); + deposit(f, carol, 300e6); + + // credited is the amount that arrived, not the amount sent + assertEq(f.contributions(alice), 297e6); + assertEq(f.raised(), 891e6); + assertEq(fot.balanceOf(address(f)), 891e6); + + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + vm.prank(bob); + f.refund(); + vm.prank(carol); + f.refund(); // the last one out must not be short + assertEq(fot.balanceOf(address(f)), 0); + } + + function test_feeOnTransfer_goalMeasuredInReceivedUnits() public { + FeeOnTransferERC20 fot = new FeeOnTransferERC20(100); + _allow(address(fot)); + fot.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(fot), GOAL)); + deposit(f, alice, GOAL); // 1% is burned, so this does not reach the goal + assertEq(f.raised(), 990e6); + assertTrue(f.canUnpledge()); + + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + } + + // ────────────────────────────────────────────── + // Reentrancy on every exit path + // ────────────────────────────────────────────── + + function test_reentrancy_blockedOnRefund() public { + ReentrantERC20 ree = new ReentrantERC20(); + _allow(address(ree)); + ree.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(ree), GOAL)); + deposit(f, alice, 300e6); + vm.prank(organizer); + f.cancel(); + + ree.arm(address(f), abi.encodeCall(IFundraiser.refund, ())); + vm.prank(alice); + f.refund(); + + assertTrue(ree.attempted(), "the mock should have tried to reenter"); + assertFalse(ree.succeeded(), "reentrancy must be refused"); + assertEq(ree.balanceOf(address(f)), 0); + } + + function test_reentrancy_blockedOnUnpledge() public { + ReentrantERC20 ree = new ReentrantERC20(); + _allow(address(ree)); + ree.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(ree), GOAL)); + deposit(f, alice, 300e6); + + ree.arm(address(f), abi.encodeCall(IFundraiser.unpledge, (100e6))); + vm.prank(alice); + f.unpledge(100e6); + + assertTrue(ree.attempted()); + assertFalse(ree.succeeded()); + assertEq(f.contributions(alice), 200e6); + } + + function test_reentrancy_blockedOnWithdraw() public { + ReentrantERC20 ree = new ReentrantERC20(); + _allow(address(ree)); + ree.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(ree), GOAL)); + deposit(f, alice, GOAL); + f.finalize(); + + ree.arm(address(f), abi.encodeCall(IFundraiser.withdraw, ())); + vm.prank(beneficiary); + f.withdraw(); + + assertTrue(ree.attempted()); + assertFalse(ree.succeeded()); + assertEq(ree.balanceOf(beneficiary), GOAL); + } + + // ────────────────────────────────────────────── + // Blocklisting + // ────────────────────────────────────────────── + + /// @dev A blocked beneficiary would otherwise strand the entire raise. Only the + /// beneficiary itself can repoint, so this adds no custody. + function test_blockedBeneficiaryRecoversViaSetPayoutAddress() public { + BlocklistERC20 blk = new BlocklistERC20(); + _allow(address(blk)); + blk.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(blk), GOAL)); + deposit(f, alice, GOAL); + f.finalize(); + + blk.setBlocked(beneficiary, true); + vm.prank(beneficiary); + vm.expectRevert(abi.encodeWithSelector(BlocklistERC20.Blocked.selector, beneficiary)); + f.withdraw(); + + address rescue = makeAddr("rescuePayout"); + vm.prank(beneficiary); + f.setPayoutAddress(rescue); + vm.prank(rescue); + f.withdraw(); + + assertEq(blk.balanceOf(rescue), GOAL); + } + + /// @dev A blocked contributor's funds stay put. That is the token's behavior, not + /// something the escrow should add an admin bypass for. + function test_blockedContributorCannotRefund_othersUnaffected() public { + BlocklistERC20 blk = new BlocklistERC20(); + _allow(address(blk)); + blk.mint(alice, FUNDED); + blk.mint(bob, FUNDED); + + Fundraiser f = create(_paramsFor(address(blk), GOAL)); + deposit(f, alice, 300e6); + deposit(f, bob, 200e6); + + vm.prank(organizer); + f.cancel(); + + blk.setBlocked(alice, true); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(BlocklistERC20.Blocked.selector, alice)); + f.refund(); + + vm.prank(bob); + f.refund(); + assertEq(blk.balanceOf(bob), FUNDED); + assertEq(f.contributions(alice), 300e6, "still owed"); + } +} diff --git a/test/fundraising/mocks/BlocklistERC20.sol b/test/fundraising/mocks/BlocklistERC20.sol new file mode 100644 index 00000000..7a42a131 --- /dev/null +++ b/test/fundraising/mocks/BlocklistERC20.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Refuses transfers touching a blocked address, as USDC and USDT can. +contract BlocklistERC20 is ERC20 { + mapping(address => bool) public blocked; + + error Blocked(address account); + + constructor() ERC20("Blocklist", "BLK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setBlocked(address account, bool value) external { + blocked[account] = value; + } + + function _update(address from, address to, uint256 value) internal override { + if (blocked[from]) revert Blocked(from); + if (blocked[to]) revert Blocked(to); + super._update(from, to, value); + } +} diff --git a/test/fundraising/mocks/FeeOnTransferERC20.sol b/test/fundraising/mocks/FeeOnTransferERC20.sol new file mode 100644 index 00000000..3d75d0bd --- /dev/null +++ b/test/fundraising/mocks/FeeOnTransferERC20.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Burns a fee on every transfer, so the receiver gets less than was sent. +/// @dev The reason `Fundraiser` credits a measured balance delta instead of the requested +/// amount. Crediting the request against this token would overstate liabilities until +/// the last contributor out could not be paid. +contract FeeOnTransferERC20 is ERC20 { + uint256 public feeBps; + + constructor(uint256 feeBps_) ERC20("FeeOnTransfer", "FOT") { + feeBps = feeBps_; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setFeeBps(uint256 feeBps_) external { + feeBps = feeBps_; + } + + function _update(address from, address to, uint256 value) internal override { + if (from == address(0) || to == address(0) || feeBps == 0) { + super._update(from, to, value); + return; + } + uint256 fee = (value * feeBps) / 10_000; + super._update(from, to, value - fee); + if (fee != 0) super._update(from, address(0), fee); + } +} diff --git a/test/fundraising/mocks/PermitERC20.sol b/test/fundraising/mocks/PermitERC20.sol new file mode 100644 index 00000000..f69876bd --- /dev/null +++ b/test/fundraising/mocks/PermitERC20.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; + +/// @notice ERC-2612 token, for the single-transaction deposit path. +contract PermitERC20 is ERC20, ERC20Permit { + constructor() ERC20("Permit", "PRM") ERC20Permit("Permit") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/fundraising/mocks/ReentrantERC20.sol b/test/fundraising/mocks/ReentrantERC20.sol new file mode 100644 index 00000000..9a9fd6d3 --- /dev/null +++ b/test/fundraising/mocks/ReentrantERC20.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Calls back into a target contract on every outbound transfer. +/// @dev Stands in for ERC-777 and other hook-bearing tokens. Fires once per armed run so a +/// failed reentry does not loop forever; the guard, not the mock, is what must stop it. +contract ReentrantERC20 is ERC20 { + address public target; + bytes public payload; + bool public armed; + + /// @notice Set when a reentrant call was attempted, and whether it succeeded. + bool public attempted; + bool public succeeded; + + constructor() ERC20("Reentrant", "REE") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function arm(address target_, bytes calldata payload_) external { + target = target_; + payload = payload_; + armed = true; + attempted = false; + succeeded = false; + } + + function _update(address from, address to, uint256 value) internal override { + super._update(from, to, value); + if (armed && target != address(0)) { + armed = false; // one shot + attempted = true; + (bool ok,) = target.call(payload); + succeeded = ok; + } + } +}