TAM: Writing a New Funding Mechanism (1‑person‑1‑vote)
Context. This section walks you through implementing a simple one‑person‑one‑vote (1p1v) mechanism on top of the shared TokenizedAllocationMechanism core. We’ll implement the minimal set of hooks to (a) grant each unique voter exactly one unit of voting power, (b) enforce single‑vote semantics per proposal, (c) define a clear quorum rule, and (d) convert votes to shares without exceeding the round’s budget. We keep business logic in hooks and reuse the audited lifecycle, timing gates, signatures, and accounting from the core, exactly as the product requirements intend.
What you will build
A small, auditable mechanism that inherits the proxy base, overrides a handful of hooks, and stores its own policy state in unstructured storage to avoid collisions with the core’s layout. It supports:
- Registration: each address can acquire exactly one unit of voting power (re‑signups don’t add power).
- Voting: one vote per proposal per voter, with optional For/Against/Abstainchoices.
- Quorum: a proposal passes if forVotes ≥ quorumFraction * registeredVotersandforVotes > againstVotes.
- Allocation: after finalization, each passing proposal receives shares proportional to its forVoteswithin the fixed budget set at finalization; queuing remains permissionless.
Design posture. We keep the mechanism tiny and deterministic, rely on the shared implementation for lifecycle and ERC‑20/transfer rules, and gate all policy via onlySelf hook wrappers (implementation → proxy delegatecall). This preserves the Yearn‑style tokenized pattern: logic reuse, storage in the instance, and a narrow audit surface.
Step 0 — Choose the base & storage pattern
Why. The core implementation expects its own storage layout to reside in the proxy. To add mechanism‑specific state safely, use an EIP‑1967‑style unstructured storage slot for your struct (the same pattern used by the QF math module).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {BaseAllocationMechanism} from "./BaseAllocationMechanism.sol";
import {IERC20} from "./interfaces/IERC20.sol";
contract OnePersonOneVoteMechanism is BaseAllocationMechanism {
    // EIP-1967-style slot to avoid layout collisions with the core
    bytes32 private constant OPOV_SLOT = keccak256("tam.mechanism.1p1v.storage.v1");
    struct OPOV {
        mapping(address => bool) registered;                  // has user registered (for count)
        uint256 registeredCount;                              // unique registrants
        mapping(uint256 => mapping(address => bool)) voted;   // has user voted on pid
        mapping(uint256 => uint256) forVotes;                 // tally: For
        mapping(uint256 => uint256) againstVotes;             // tally: Against
        uint256 totalForVotesSucceeded;                       // set at finalize for pro-rata
        uint256 quorumNum;                                    // fraction numerator (e.g., 10)
        uint256 quorumDen;                                    // fraction denominator (e.g., 100)
    }
    function _s() private pure returns (OPOV storage s) {
        bytes32 slot = OPOV_SLOT;
        assembly { s.slot := slot }
    }
    // owner-settable quorum (e.g., 10/100 = 10%)
    function setQuorum(uint256 num, uint256 den) external {
        require(msg.sender == address(_tokenizedAllocation().owner()), "!owner");
        require(den > 0 && num <= den, "bad quorum");
        OPOV storage s = _s();
        s.quorumNum = num; s.quorumDen = den;
    }
}
- BaseAllocationMechanism provides the onlySelf‑gated hook wrappers and assembly fallback so only the shared implementation can invoke your hooks.
- The _tokenizedAllocation()helper lets you read core state (owner, windows, voting power) through the same delegatecall channel—crucial for 1p1v power logic.
Step 1 — Registration: “exactly one” voting power
Goal. Allow re‑registration (can’t block it), but never grant more than one unit of power per address. We accomplish this by reading the core’s current voting power and returning 1 or 0 accordingly. That keeps idempotence without extra book‑keeping.
// HOOK: who may sign up; also track unique registrants
function _beforeSignupHook(address user) internal override returns (bool ok) {
    OPOV storage s = _s();
    if (!s.registered[user]) { s.registered[user] = true; s.registeredCount += 1; }
    return true; // allow signup; power is decided below
}
// HOOK: map deposit -> voting power; for 1p1v it's {0 or 1}
function _getVotingPowerHook(address user, uint256 /*deposit*/)
    internal view override returns (uint256)
{
    // If this address already has any power, do not grant more
    return _tokenizedAllocation().votingPower(user) == 0 ? 1 : 0;
}
- Why this shape? The requirements permit multiple signups, but you can implement deterministic power policies in the hook. Reading votingPower(user)from core storage is safe and canonical.
Step 2 — Proposals: authorization & uniqueness
Goal. Decide who can propose and keep each recipient unique. The core already enforces recipient uniqueness and zero‑address checks; you only decide who may call propose. Many deployments restrict it to keeper/management to prevent spam; you can relax this to “anyone.”
// HOOK: proposer policy; default to keeper/management
function _beforeProposeHook(address proposer) internal view override returns (bool) {
    address mgmt = _tokenizedAllocation().management();
    address keep = _tokenizedAllocation().keeper();
    return (proposer == mgmt || proposer == keep);
}
Step 3 — Voting: one vote per proposal
Goal. Enforce single‑vote per voter per proposal and optionally accept Against/Abstain. We treat weight as 1 (by convention), reject duplicates, and update tallies. The core validates timing and conserves power; you return the new remaining power.
// HOOK: process vote + ensure single vote; weight must be 1
enum VoteType { Against, For, Abstain }
function _processVoteHook(
    uint256 pid,
    address voter,
    VoteType choice,
    uint256 weight,
    uint256 oldPower
) internal override returns (uint256 newPower) {
    OPOV storage s = _s();
    require(!s.voted[pid][voter], "already voted");
    require(weight == 1, "1p1v: weight must be 1");
    s.voted[pid][voter] = true;
    if (choice == VoteType.For)       s.forVotes[pid]     += 1;
    else if (choice == VoteType.Against) s.againstVotes[pid] += 1;
    // Abstain updates no tally
    // Core will check newPower <= oldPower; we preserve exact decrement
    require(oldPower >= 1, "insufficient power");
    return oldPower - 1;
}
- Why keep Against/Abstain? The core API allows them; this is a policy decision distinct from QF’s “For‑only” mechanic. Pick the rule suited to your round.
Step 4 — Quorum & success
Goal. Define when a proposal “passes.” A simple rule is “forVotes ≥ q% of registered voters and forVotes > againstVotes.” This is deterministic and aligns with the requirements’ emphasis on objective quorum.
function _hasQuorumHook(uint256 pid) internal view override returns (bool) {
    OPOV storage s = _s();
    uint256 minVotes = (s.registeredCount * s.quorumNum) / s.quorumDen;
    return s.forVotes[pid] >= minVotes && s.forVotes[pid] > s.againstVotes[pid];
}
Step 5 — Finalization: compute the pro‑rata denominator
Goal. Fix the budget and compute a denominator so each passing proposal gets a proportional share of that budget when queued. We do this once at finalization (owner → proxy → implementation), then reuse it in the conversion hook.
// HOOK: optional guard + snapshot math before finalization locks
function _beforeFinalizeVoteTallyHook() internal override returns (bool) {
    OPOV storage s = _s();
    s.totalForVotesSucceeded = 0;
    // Iterate proposals 1..proposalCount; sum For votes for those that meet quorum
    uint256 n = _getProposalCount();
    for (uint256 pid = 1; pid <= n; pid++) {
        if (_hasQuorumHook(pid)) { s.totalForVotesSucceeded += s.forVotes[pid]; }
    }
    // Always allow finalization; the core enforces timing
    return true;
}
- Why here? Finalization is the single trusted moment that fixes totalAssetsand the redemption schedule in the core; taking a denominator snapshot here makes later per‑proposal queuing stateless and permissionless.
Step 6 — Conversion: votes → shares (budget‑safe)
Goal. For each passing proposal, mint shares equal to its fraction of the final budget. The shared core has already captured totalAssets at finalization; we use that value implicitly via ERC‑4626 math by minting shares equal to asset amount to maintain a 1:1 at the start of redemption.
function _convertVotesToShares(uint256 pid) internal view override returns (uint256) {
    OPOV storage s = _s();
    require(s.totalForVotesSucceeded > 0, "no passing proposals");
    // Proportional allocation by For votes (floor rounding)
    uint256 assets = (_tokenizedAllocation().totalAssets() * s.forVotes[pid])
                     / s.totalForVotesSucceeded;
    // Mint shares == assets to keep initial 1:1; core handles ERC-4626 math
    return assets;
}
Rounding dust. Floor rounding across many proposals leads to small residual assets; that dust remains in the vault and is recoverable via sweep after the grace period, consistent with the requirements’ dust and sweep model.
Step 7 — Distribution & redemption windows
Goal. Keep queuing permissionless (default), and use the default mint path. Redemptions are only valid in the global window; outside it, the limit is 0. Both behaviors are required by the core.
// HOOK: we don't need a custom distribution; return the default
function _requestCustomDistributionHook(address /*recipient*/, uint256 /*shares*/)
    internal override returns (bool handled, uint256 assetsTransferred)
{
    return (false, 0); // core will mint shares
}
// HOOK: enforce the global redemption window
function _availableWithdrawLimit(address /*owner*/)
    internal view override returns (uint256)
{
    // Respect the core’s schedule: 0 before start and after end, unlimited inside
    (uint48 start, uint48 grace) = (_tokenizedAllocation().globalRedemptionStart(),
                                    _tokenizedAllocation().gracePeriod());
    uint256 nowTs = block.timestamp;
    if (nowTs < start || nowTs > start + grace) return 0;
    return type(uint256).max;
}
Foundry checklist (PR‑sized tests)
Why. The goal is to make the docs ready for change and free from inaccuracies by pinning behavior to tests that exercise the hooks and the shared lifecycle. Use a mainnet‑fork or local chain.
- Registration. Multiple signups don’t increase power; votingPower(user)is 1 after the first call and remains 1.
- Proposal creation. Only keeper/management can propose (or your chosen rule); recipient uniqueness enforced by the core.
- Voting. One vote per proposal; weight=1required; tally updates exactly once;newPower = oldPower - 1.
- Finalization. Owner‑only, after the window; totalForVotesSucceededsnapshot equals the sum of forVotes for passing proposals.
- Queueing (permissionless). For passing proposals, convertVotesToSharesreturns floor(budget * for / totalFor); mint path executes; total minted shares ≤ budget.
- Redemption window. Redeem succeeds only in [start, start+grace];_transferblocks share transfers beforestartas per the core.
- Sweep. After grace, owner can recover residual dust via sweep.
Operational notes & extensions
- Open proposing. To allow anyone to propose, return truein_beforeProposeHookand rely solely on quorum.
- Fixed‑grant variant. Replace _convertVotesToShareswith a fixedgrantSharesper passing proposal (owner‑settable), and cap byremainingBudgettracked at finalization—keep queuing permissionless.
- Access‑controlled queuing. If your governance needs it, enforce a role in _requestCustomDistributionHookand still reportassetsTransferredwhen doing direct transfers sototalAssetsis correct.