Skip to main content

TAM: Writing a New Funding Mechanism (1-person-1-vote)

Purpose: Show how to build a minimal custom Tokenized Allocation Mechanism on top of Octant's shared TAM core. Audience: Developers comfortable working inside octant-v2-core and reading delegatecall-based proxy patterns. Level: Advanced Source of truth: [email protected], especially BaseAllocationMechanism, TokenizedAllocationMechanism, and the existing mechanism implementations. Use this page when: you want a concrete reference for custom TAM hooks, storage isolation, proposal flow, and finalization logic. Do not use this page for: a claim that this 1p1v policy is canonical, a standalone TAM starter template, or assumptions about deployments not documented elsewhere.

Before you read this page

This is a worked policy example, not a normative specification for all 1-person-1-vote designs. Use it to learn the hook surface, storage pattern, and test shape.

This guide walks through a minimal 1-person-1-vote (1p1v) mechanism built on top of the shared TAM core in [email protected].

Task card

Ask a coding assistant to follow this page in order, edit only the files named in each step, and run the verification commands before moving on. It should not modify dependencies/octant-v2-core, invent contract addresses, or skip compatibility notes.

FieldValue
GoalBuild and test a minimal 1-person-1-vote TAM on top of the shared TAM core
Start repooctant-v2-core@36ed6ad6665661a18f83394d561fa75c68ccf4ac (tag 1.2.0-develop.15)
Files to editsrc/mechanisms/OnePersonOneVoteMechanism.sol, test/unit/mechanisms/allocation/OnePersonOneVoteMechanismTest.t.sol, script/deploy/DeployOnePersonOneVote.s.sol, .env
Files NOT to editsrc/mechanisms/BaseAllocationMechanism.sol, src/mechanisms/TokenizedAllocationMechanism.sol, src/mechanisms/AllocationMechanismFactory.sol
Required setupFoundry installed; octant-v2-core checked out at 36ed6ad; dependencies installed via Path A of the Local Development Quickstart
Verificationforge test --match-contract OnePersonOneVoteMechanismTest -vvv — all tests pass
Do notUse octant-v2-strategy-foundry-mix for TAM work (it has no src/mechanisms/ tree), invent contract addresses not documented on the Deployed Addresses page, skip compatibility notes, deploy to mainnet without an independent audit

(See also: Agent Anti-Patterns)

Hook Reference Card

Function signaturePurposeRequired?Called when...
_beforeSignupHook(address user)Validate and register a voterYesA voter calls signup()
_getVotingPowerHook(address user, uint256 deposit)Calculate a voter's voting weightYesDuring signup processing
_beforeProposeHook(address proposer)Allow or block proposal creationYesA proposer creates a proposal
_validateProposalHook(uint256 pid)Validate that a proposal existsYesDuring vote processing, proposal queueing, state checks, and cancellation
_processVoteHook(uint256 pid, address voter, VoteType choice, uint256 weight, uint256 oldPower)Process a single vote and return updated powerYesA voter casts a vote
_hasQuorumHook(uint256 pid)Check if a proposal met quorumYesDuring proposal finalization
_beforeFinalizeVoteTallyHook()Pre-finalization logic (e.g. snapshot budgets)YesBefore vote tally is finalized
_convertVotesToShares(uint256 pid)Convert vote totals to recipient sharesYesDuring proposal queueing
_getRecipientAddressHook(uint256 pid)Determine the recipient address for a proposalYesDuring proposal queueing
_requestCustomDistributionHook(address, uint256)Custom distribution instead of share mintingYes (override required; return (false, 0) for default minting)Every queueProposal
_calculateTotalAssetsHook()Report total assets held by the mechanismYesDuring vote-tally finalization
_availableWithdrawLimit(address)Enforce timelock and grace-period redemption rulesOptionalBefore asset withdrawal

_validateProposalHook is not only a queue hook; keep it side-effect-free because the shared TAM core also uses it when votes are cast, proposal state is queried, and proposals are cancelled. _calculateTotalAssetsHook() is snapshotted during finalizeVoteTally() before queued proposals convert votes into shares.

The goal is to stay small and deterministic:

  • each address can hold at most one unit of voting power at any given time (but see the design note below — power can be re-granted after it is spent),
  • each voter can cast at most one vote per proposal,
  • proposals pass under a simple, explicit quorum rule,
  • successful proposals receive shares through the shared queuing and redemption lifecycle,
  • the mechanism reuses the core implementation for state transitions, ERC-20 share logic, EIP-712 helpers, and window enforcement.
TAM work happens in octant-v2-core

There is no standalone TAM starter template in octant-v2-strategy-foundry-mix. This guide assumes you are working directly inside octant-v2-core@36ed6ad6665661a18f83394d561fa75c68ccf4ac, where src/mechanisms/BaseAllocationMechanism.sol, src/mechanisms/TokenizedAllocationMechanism.sol, and src/mechanisms/AllocationMechanismFactory.sol exist at the import paths used below.

Set up the core repo first using Path A of the Local Development Quickstart before writing any mechanism code.

This page explains how to extend the shared TAM core, not which funding policy Octant considers canonical. If this worked example and any production mechanism in the pinned core differ, prefer the pinned core implementation for lifecycle semantics and hook expectations.

Prerequisite: obtaining the implementation_ address

Every custom TAM constructor requires an implementation_ address — the shared TokenizedAllocationMechanism singleton that the proxy delegates lifecycle and accounting calls to. The AllocationMechanismFactory is team-confirmed at 0x30B980fe1CaF8Fa275e9a364187DB953d08C3ACE (see the Deployed Addresses page). Note this is a team-confirmed pending-canonical deployment: the pinned [email protected] registry (DeployedAddresses.sol) still lists address(0) for this factory, so treat the Deployed Addresses page as the source of truth here.

The concrete path is:

  1. Read the implementation address. Call tokenizedAllocationImplementation() on the AllocationMechanismFactory (0x30B980fe1CaF8Fa275e9a364187DB953d08C3ACE). That returns the shared TokenizedAllocationMechanism singleton address you need.
  2. Pass that address into your custom mechanism constructor as implementation_. The BaseAllocationMechanism constructor stores it and immediately delegate-calls TokenizedAllocationMechanism.initialize(...) to set up shared state.
Factory deploy methods vs custom mechanisms

The factory's built-in deploy methods (such as deployQuadraticVotingMechanism() and deployOctantQFMechanism()) are for Octant's packaged mechanisms. For a custom TAM like the one in this guide, you deploy your mechanism contract directly and pass the implementation address into its constructor yourself. The factory is needed only to create the shared TokenizedAllocationMechanism singleton.

The AllocationConfig struct — the second constructor argument

Every BaseAllocationMechanism constructor takes an AllocationConfig memory struct as its second argument alongside implementation_. This struct bundles the nine parameters that BaseAllocationMechanism passes directly to TokenizedAllocationMechanism.initialize() when the proxy is first deployed.

FieldTypeDescription
assetIERC20Underlying ERC-20 token used for deposits and redemptions
namestringERC-20 name for the mechanism's share token
symbolstringERC-20 symbol for the mechanism's share token
votingDelayuint256Seconds between mechanism deployment and when voting opens
votingPerioduint256Duration in seconds of the active voting window
quorumSharesuint256Minimum shares that must vote For for a proposal to succeed (18-decimal share units)
timelockDelayuint256Seconds between vote finalization and when redemptions open
gracePerioduint256Seconds during which redemptions are allowed before they expire
owneraddressInitial owner of the deployed mechanism
Source of truth for field descriptions

The authoritative per-field description is the TokenizedAllocationMechanism.initialize() parameter table. The struct fields map one-to-one to that function's parameters, but the order differs: initialize() takes _owner as its first parameter, whereas the struct places owner last. BaseAllocationMechanism's constructor handles the reordering internally.

A minimal instantiation looks like this:

Illustrative only — AllocationConfig instantiation

AllocationConfig memory config = AllocationConfig({
asset: IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48), // e.g. USDC
name: "1P1V Mechanism",
symbol: "OPOV",
votingDelay: 1, // minimum delay in seconds (0 is rejected by the shared core)
votingPeriod: 7 days, // one-week voting window
quorumShares: 10e18, // 10 votes required to pass a proposal
timelockDelay: 1 days, // 1-day security buffer before redemptions open
gracePeriod: 7 days, // recipients have 7 days to redeem
owner: msg.sender
});

Pass both arguments into the constructor:

Illustrative only — constructor call

OnePersonOneVoteMechanism mechanism = new OnePersonOneVoteMechanism(
implementation_,
config
);

What this example is, and what it is not

This is a worked policy example showing how to use the TAM hook surface for a 1p1v round.

It is not a claim that the mechanism below is the only valid 1p1v design. In particular, you still need to make explicit choices about:

  • whether repeated signup(deposit) calls should ever grant more voting power,
  • whether proposers are restricted or open,
  • whether Against and Abstain are meaningful in your tally rule,
  • how passing proposals share the round budget,
  • whether you want default share minting or custom direct distribution.

The emphasis here is correctness against the shared core, not feature richness.

Step 0 — Start from the right base and storage pattern

The shared implementation expects the proxy instance to hold all storage. If your mechanism needs extra state, store it in an unstructured storage slot to avoid collisions with the core layout.

What is the onlySelf pattern?

onlySelf is a modifier that ensures a function can only be called by the contract itself — specifically, through a delegatecall from the shared TokenizedAllocationMechanism implementation. This prevents external callers from invoking your hook functions directly, which could bypass the lifecycle checks that the shared implementation enforces. When you see onlySelf on a hook wrapper, it means: "This function exists so the shared implementation can call back into my policy logic, but nobody else can call it."

Fragment — base contract, storage layout, and constructor

// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.20;

import {BaseAllocationMechanism, AllocationConfig} from "src/mechanisms/BaseAllocationMechanism.sol";
import {TokenizedAllocationMechanism} from "src/mechanisms/TokenizedAllocationMechanism.sol";

contract OnePersonOneVoteMechanism is BaseAllocationMechanism {
bytes32 private constant OPOV_SLOT = keccak256("tam.mechanism.1p1v.storage.v1");

struct OPOVStorage {
mapping(address => bool) registered;
uint256 registeredCount;

mapping(uint256 => mapping(address => bool)) voted;
mapping(uint256 => uint256) forVotes;
mapping(uint256 => uint256) againstVotes;
mapping(uint256 => uint256) abstainVotes;

mapping(uint256 => uint256) proposalBudgetShares;

uint256 quorumNum;
uint256 quorumDen;
}

function _s() private pure returns (OPOVStorage storage s) {
bytes32 slot = OPOV_SLOT;
assembly {
s.slot := slot
}
}

constructor(address implementation_, AllocationConfig memory config_)
BaseAllocationMechanism(implementation_, config_)
{
OPOVStorage storage s = _s();
s.quorumNum = 10;
s.quorumDen = 100;
}

function setQuorum(uint256 num, uint256 den) external {
require(msg.sender == _tokenizedAllocation().owner(), "!owner");
require(den != 0 && num <= den, "bad quorum");

OPOVStorage storage s = _s();
s.quorumNum = num;
s.quorumDen = den;
}
}

Why this shape

  • BaseAllocationMechanism provides the delegatecall proxy pattern and the onlySelf-gated hook wrappers.
  • TokenizedAllocationMechanism is the shared implementation that drives lifecycle and accounting.
  • The mechanism-specific struct lives in its own storage slot, so you do not collide with the core layout.
  • _tokenizedAllocation() is a typed helper provided by BaseAllocationMechanism that returns the shared TokenizedAllocationMechanism implementation as a typed interface. Use it whenever your mechanism needs to read shared state such as owner(), management(), keeper(), proposal data, or voting tallies. You will see it used throughout this guide in access-control checks (e.g. _tokenizedAllocation().owner()) and in proposer-policy hooks.
  • BaseAllocationMechanism also provides convenience view helpers that the code examples in this guide use freely. The most common ones: _getVotingPower(address user) returns a user's current voting power, _proposalExists(uint256 pid) checks whether a proposal ID is valid, _getProposal(uint256 pid) returns the full Proposal struct, and _getProposalCount() returns the number of proposals created so far. See the BaseAllocationMechanism contract reference for the full list.

Step 1 — Registration: grant at most one unit of power

The shared core calls two hooks during signup:

  • _beforeSignupHook(address user)
  • _getVotingPowerHook(address user, uint256 deposit)

For a simple 1p1v mechanism, a clean pattern is:

  • allow registration,
  • count unique registrants,
  • grant 1 voting power only if the user currently has 0,
  • return 0 on later signup attempts. In the shared core, a later signup with a nonzero deposit then reverts with InsufficientDeposit; a later signup with deposit == 0 preserves voting power but still runs the signup hooks and emits UserRegistered(user, 0).

Fragment — registration hooks

function _beforeSignupHook(address user) internal override returns (bool) {
OPOVStorage storage s = _s();
// WHY: Track unique registrants so we can count them for quorum calculations
if (!s.registered[user]) {
s.registered[user] = true;
s.registeredCount += 1;
}
return true;
}

function _getVotingPowerHook(address user, uint256 /* deposit */)
internal
view
override
returns (uint256)
{
// WHY: Grant exactly 1 power when user currently has 0; later signups get 0 additional power.
// CAUTION: If a later signup includes a nonzero deposit, the shared core reverts with InsufficientDeposit.
// CAUTION: After voting reduces power to 0, a re-signup will re-grant 1 — allowing votes on
// additional proposals. If you need one vote total per round, add a round-level guard here.
// See the "Important design note" below for the full trade-off.
return _getVotingPower(user) == 0 ? 1 : 0;
}

Important design note

This pattern gives an address at most one unit of voting power at any given time, but two interactions with the shared core require explicit policy decisions:

  1. Repeated signup before voting. The hook returns 0 when the user already has power, so the shared core's additive signup logic (votingPower[user] += newPower) would not increase their power. For a repeated signup with a nonzero deposit, the shared core rejects the call with InsufficientDeposit(deposit) because newPower == 0 && deposit > 0. A repeated signup with deposit == 0 preserves voting power, but it is not a silent no-op: the signup hooks still run and UserRegistered(user, 0) is emitted. If your mechanism is meant to accept additional economic contributions, the hook must grant nonzero voting power for those deposits or the shared core behavior must change.

  2. Re-signup after voting. The shared core's _processVoteHook overwrites the user's voting power with the hook's returned newPower (in this example, oldPower - 1 = 0). Once power is zero, a subsequent signup() call will see _getVotingPower(user) == 0 and grant 1 again — allowing the user to vote on additional proposals. If your policy requires one vote total per round (not one vote per proposal), you must add a guard in _beforeSignupHook or _getVotingPowerHook that tracks whether the user has already voted in this round and blocks re-enrollment.

Do not leave these ambiguities undocumented in a production mechanism.

If this mechanism will receive RegenStaker contributions

RegenStaker hard-casts a contribution destination to the OctantQFMechanism interface and calls canSignup(...) on it. The OnePersonOneVoteMechanism shown here does not implement canSignup(), so contributions routed through RegenStaker would revert even when the mechanism is allowlisted. If your mechanism needs to be a RegenStaker contribution destination, implement canSignup(). See RegenStaker — contribution flows.

Step 2 — Proposal creation: choose who may propose

The shared core already handles proposal storage, proposal IDs, recipient uniqueness, and zero-address checks. Your job here is only proposer policy.

A simple keeper/management-only rule looks like this:

Fragment — proposer policy hook

function _beforeProposeHook(address proposer)
internal
view
override
returns (bool)
{
// WHY: Restrict proposals to privileged roles to prevent spam and maintain governance quality
return proposer == _tokenizedAllocation().keeper()
|| proposer == _tokenizedAllocation().management();
}

If you want fully open proposing, return true here instead.

Step 3 — Proposal validation and recipient lookup

BaseAllocationMechanism requires both:

  • _validateProposalHook(uint256 pid)
  • _getRecipientAddressHook(uint256 pid)

A minimal implementation can reuse the helpers already provided by the base.

Fragment — proposal validation and recipient hooks

function _validateProposalHook(uint256 pid)
internal
view
override
returns (bool)
{
return _proposalExists(pid);
}

function _getRecipientAddressHook(uint256 pid)
internal
view
override
returns (address recipient)
{
return _getProposal(pid).recipient;
}

Step 4 — Voting: one vote per proposal

The vote-processing hook must use the correct core enum type:

Fragment — vote hook signature

function _processVoteHook(
uint256 pid,
address voter,
TokenizedAllocationMechanism.VoteType choice,
uint256 weight,
uint256 oldPower
) internal override returns (uint256 newPower)

For a 1p1v policy, a simple rule is:

  • one vote per proposal,
  • weight must be 1,
  • For, Against, and Abstain are accepted,
  • available voting power decreases by exactly 1.

Fragment — vote processing implementation

function _processVoteHook(
uint256 pid,
address voter,
TokenizedAllocationMechanism.VoteType choice,
uint256 weight,
uint256 oldPower
) internal override returns (uint256 newPower) {
OPOVStorage storage s = _s();

// WHY: Enforce one vote per proposal to prevent double-voting within a round
require(!s.voted[pid][voter], "already voted");
// WHY: 1p1v means each vote is weighted equally at 1 unit
require(weight == 1, "1p1v: weight must be 1");
// WHY: Voter must have at least 1 unit of power remaining to cast a vote
require(oldPower >= 1, "insufficient power");

// WHY: Mark this voter as having voted on this proposal
s.voted[pid][voter] = true;

// WHY: Count votes by type so we can evaluate quorum and majority
if (choice == TokenizedAllocationMechanism.VoteType.For) {
s.forVotes[pid] += 1;
} else if (choice == TokenizedAllocationMechanism.VoteType.Against) {
s.againstVotes[pid] += 1;
} else {
s.abstainVotes[pid] += 1;
}

// WHY: Decrement power by the weight cast (1 in 1p1v)
return oldPower - 1;
}

Step 5 — Quorum: keep it simple and explicit

A straightforward 1p1v quorum rule is:

  • forVotes >= registeredCount * quorumNum / quorumDen, and
  • forVotes > againstVotes.

Fragment — quorum hook

function _hasQuorumHook(uint256 pid)
internal
view
override
returns (bool)
{
OPOVStorage storage s = _s();

// WHY: Quorum is a percentage of total registered voters (e.g., 10% = quorumNum/quorumDen)
// Ceiling division ensures small voter counts don't produce a zero quorum
uint256 minVotes = (s.registeredCount * s.quorumNum + s.quorumDen - 1) / s.quorumDen;
if (s.registeredCount > 0 && minVotes == 0) minVotes = 1;

// WHY: Pass if (1) minimum votes reached AND (2) For votes outnumber Against votes
return s.forVotes[pid] >= minVotes
&& s.forVotes[pid] > s.againstVotes[pid];
}

Important configuration note

The shared core still stores its own quorumShares value in AllocationConfig (see the struct definition above).

If your mechanism defines quorum using registered voters instead, you should document that clearly and treat quorumShares as a configuration value required by the shared implementation, not as the normative quorum rule for this mechanism.

Step 6 — Finalization: precompute budgets for passing proposals

Queue all winners before opening redemption

This example uses share-based redemption. Because queued proposals mint shares against a shared totalAssets pool, the per-proposal share budget computed at finalization is only correct if every passing proposal is queued before any recipient redeems. If only some winners are queued, early redeemers withdraw a larger fraction of the pool than intended, leaving later redeemers short-changed. In production, either enforce all-or-nothing queueing in a single transaction or gate redeem() until a keeper confirms all winners have been queued.

The guide you uploaded tried to compute the denominator once and then mint shares == assets on demand. That is too hand-wavy for the shared core.

A safer pattern for a worked example is:

  • at finalization, compute how many proposals passed,
  • snapshot a per-proposal budget in share units,
  • later return that stored number from _convertVotesToShares(pid).

This keeps queuing permissionless and avoids recalculating a moving target during each queue call.

Fragment — finalization hook

function _beforeFinalizeVoteTallyHook()
internal
override
returns (bool)
{
OPOVStorage storage s = _s();

// WHY: Count how many proposals passed quorum so we can divide budget equally
uint256 n = _getProposalCount();
uint256 winners;

for (uint256 pid = 1; pid <= n; pid++) {
if (_hasQuorumHook(pid)) {
winners += 1;
}
}

// WHY: Snapshot total budget available for distribution in this round
uint256 totalAssets = _calculateTotalAssetsHook();
// WHY: Equal split: each winning proposal gets totalAssets / winnerCount
uint256 perWinner = winners == 0 ? 0 : totalAssets / winners;

// WHY: Precompute and store budget for each proposal so queueing is stateless
for (uint256 pid = 1; pid <= n; pid++) {
s.proposalBudgetShares[pid] = _hasQuorumHook(pid) ? perWinner : 0;
}

return true;
}

Why this is safer for the example

  • it keeps the worked example simple and deterministic,
  • it avoids implying that shares == assets is always the right mental model,
  • it avoids recomputing a denominator that depends on a changing winner set,
  • it makes queueing stateless from the perspective of later callers.

This example chooses equal split among passing proposals. That is not the only valid 1p1v policy, but it is much easier to reason about than reusing a QF-oriented vote-to-budget formula in a supposedly simple mechanism.

Step 7 — Convert votes to shares

Once finalization has stored the budget for each successful proposal, _convertVotesToShares(pid) becomes simple:

Fragment — vote-to-share conversion

function _convertVotesToShares(uint256 pid)
internal
view
override
returns (uint256 sharesToMint)
{
return _s().proposalBudgetShares[pid];
}

If you want a different funding policy, this is the place to encode it.

Examples:

  • proportional to forVotes,
  • fixed grant per winner,
  • owner-specified budget slices fixed at finalization,
  • custom matching logic outside QF.

Step 8 — Redemption and custom distribution

For a minimal 1p1v mechanism, you usually do not need a custom distribution hook.

Return (false, 0) and let the shared core mint shares in the default path.

Fragment — custom distribution hook (no-op)

function _requestCustomDistributionHook(address, uint256)
internal
pure
override
returns (bool handled, uint256 assetsTransferred)
{
return (false, 0);
}

You also do not need to override _availableWithdrawLimit(...) unless your mechanism wants custom redemption rules. BaseAllocationMechanism already provides a default implementation that:

  • blocks redemptions before global redemption start,
  • allows them during the grace period,
  • blocks them again after grace ends.

So for this simple 1p1v example, keeping the default is cleaner than re-implementing it.

Step 9 — Total asset accounting

BaseAllocationMechanism requires _calculateTotalAssetsHook().

For the simplest case, where the mechanism's budget is just the mechanism contract's idle balance of the underlying asset, the hook can be:

Fragment — total assets hook

function _calculateTotalAssetsHook()
internal
view
override
returns (uint256)
{
return asset.balanceOf(address(this));
}

If your round budget includes matching pools, pre-funded tranches, or other external accounting inputs, this hook is where you incorporate them.

Voter deposits inflate balanceOf

The shared core transfers tokens into the mechanism when voters call signup(deposit). A bare asset.balanceOf(address(this)) therefore returns matching-pool funds plus all accumulated voter deposits. In the minimal example below the over-count is negligible, but a production mechanism should track the matching pool separately — for example, record the matching-pool amount in a storage variable at funding time and return that instead of the raw balance, or maintain a running totalDeposits counter incremented in _signupHook and subtract it: asset.balanceOf(address(this)) - totalDeposits.

Complete minimal example

Complete file — src/mechanisms/OnePersonOneVoteMechanism.sol

// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {BaseAllocationMechanism, AllocationConfig} from "src/mechanisms/BaseAllocationMechanism.sol";
import {TokenizedAllocationMechanism} from "src/mechanisms/TokenizedAllocationMechanism.sol";

contract OnePersonOneVoteMechanism is BaseAllocationMechanism {
bytes32 private constant OPOV_SLOT = keccak256("tam.mechanism.1p1v.storage.v1");

struct OPOVStorage {
mapping(address => bool) registered;
uint256 registeredCount;
mapping(uint256 => mapping(address => bool)) voted;
mapping(uint256 => uint256) forVotes;
mapping(uint256 => uint256) againstVotes;
mapping(uint256 => uint256) abstainVotes;
mapping(uint256 => uint256) proposalBudgetShares;
uint256 quorumNum;
uint256 quorumDen;
}

function _s() private pure returns (OPOVStorage storage s) {
bytes32 slot = OPOV_SLOT;
assembly {
s.slot := slot
}
}

constructor(address implementation_, AllocationConfig memory config_)
BaseAllocationMechanism(implementation_, config_)
{
OPOVStorage storage s = _s();
s.quorumNum = 10;
s.quorumDen = 100;
}

function setQuorum(uint256 num, uint256 den) external {
require(msg.sender == _tokenizedAllocation().owner(), "!owner");
require(den != 0 && num <= den, "bad quorum");
OPOVStorage storage s = _s();
s.quorumNum = num;
s.quorumDen = den;
}

function _beforeSignupHook(address user) internal override returns (bool) {
OPOVStorage storage s = _s();
if (!s.registered[user]) {
s.registered[user] = true;
s.registeredCount += 1;
}
return true;
}

function _beforeProposeHook(address proposer)
internal
view
override
returns (bool)
{
return proposer == _tokenizedAllocation().keeper()
|| proposer == _tokenizedAllocation().management();
}

function _getVotingPowerHook(address user, uint256)
internal
view
override
returns (uint256)
{
// CAUTION: Repeated nonzero signup with 0 new power reverts in shared core.
// CAUTION: Re-signup after voting will re-grant power. See design note.
return _getVotingPower(user) == 0 ? 1 : 0;
}

function _validateProposalHook(uint256 pid)
internal
view
override
returns (bool)
{
return _proposalExists(pid);
}

function _processVoteHook(
uint256 pid,
address voter,
TokenizedAllocationMechanism.VoteType choice,
uint256 weight,
uint256 oldPower
) internal override returns (uint256 newPower) {
OPOVStorage storage s = _s();

require(!s.voted[pid][voter], "already voted");
require(weight == 1, "1p1v: weight must be 1");
require(oldPower >= 1, "insufficient power");

s.voted[pid][voter] = true;

if (choice == TokenizedAllocationMechanism.VoteType.For) {
s.forVotes[pid] += 1;
} else if (choice == TokenizedAllocationMechanism.VoteType.Against) {
s.againstVotes[pid] += 1;
} else {
s.abstainVotes[pid] += 1;
}

return oldPower - 1;
}

function _hasQuorumHook(uint256 pid)
internal
view
override
returns (bool)
{
OPOVStorage storage s = _s();
uint256 minVotes = (s.registeredCount * s.quorumNum + s.quorumDen - 1) / s.quorumDen;
if (s.registeredCount > 0 && minVotes == 0) minVotes = 1;
return s.forVotes[pid] >= minVotes && s.forVotes[pid] > s.againstVotes[pid];
}

function _convertVotesToShares(uint256 pid)
internal
view
override
returns (uint256 sharesToMint)
{
// WHY: Return the precomputed budget snapshot from finalization
return _s().proposalBudgetShares[pid];
}

function _beforeFinalizeVoteTallyHook()
internal
override
returns (bool)
{
OPOVStorage storage s = _s();

// WHY: Count how many proposals passed quorum so we can divide budget equally
uint256 n = _getProposalCount();
uint256 winners;

for (uint256 pid = 1; pid <= n; pid++) {
if (_hasQuorumHook(pid)) {
winners += 1;
}
}

// WHY: Snapshot total budget available for distribution in this round
uint256 totalAssets = _calculateTotalAssetsHook();
// WHY: Equal split: each winning proposal gets totalAssets / winnerCount
uint256 perWinner = winners == 0 ? 0 : totalAssets / winners;

// WHY: Precompute and store budget for each proposal so queueing is stateless
for (uint256 pid = 1; pid <= n; pid++) {
s.proposalBudgetShares[pid] = _hasQuorumHook(pid) ? perWinner : 0;
}

return true;
}

function _getRecipientAddressHook(uint256 pid)
internal
view
override
returns (address recipient)
{
// WHY: Look up the recipient from the stored proposal data
return _getProposal(pid).recipient;
}

function _requestCustomDistributionHook(address, uint256)
internal
pure
override
returns (bool handled, uint256 assetsTransferred)
{
// WHY: Use default share minting; no custom distribution for this simple mechanism
return (false, 0);
}

function _calculateTotalAssetsHook()
internal
view
override
returns (uint256)
{
// WHY: Budget equals the contract's current idle balance of the underlying asset
return asset.balanceOf(address(this));
}
}

Foundry checklist

Use tests to pin the policy decisions and the core lifecycle together.

  1. Registration

    • first signup gives voting power 1,
    • repeated signup before voting with a nonzero deposit reverts with InsufficientDeposit,
    • repeated signup with deposit == 0 preserves voting power and still runs hooks if your policy allows it,
    • re-signup after voting (power == 0) re-grants power to 1 — test this explicitly and decide whether your policy allows it,
    • registeredCount only counts unique users.
  2. Proposal creation

    • only keeper/management can propose under the default policy,
    • recipient uniqueness is still enforced by the shared core.
  3. Voting

    • one vote per proposal,
    • weight == 1 required,
    • oldPower -> oldPower - 1,
    • For, Against, Abstain tallies update as expected.
  4. Finalization

    • owner-only,
    • proposal budgets are fixed once at finalization,
    • equal-split funding is assigned only to proposals that pass quorum.
  5. Queueing

    • queueing remains permissionless,
    • passing proposals mint the expected number of shares,
    • failed proposals do not receive shares.
  6. Redemption window

    • redeem succeeds only during the global redemption window,
    • transfers remain restricted according to the shared core lifecycle.
  7. Sweep

    • after grace expires, owner can recover residual funds through sweep(...).

Practical extensions

Once the minimal mechanism works, the easiest extensions are:

  • open proposing by relaxing _beforeProposeHook,
  • fixed grants by replacing equal-split funding with a fixed per-winner budget,
  • proportional 1p1v funding by storing a vote-based budget snapshot at finalization,
  • custom direct distribution by implementing _requestCustomDistributionHook(...) and returning exact assetsTransferred.

The key discipline is always the same: keep policy in hooks, keep accounting exact, and let the shared core own the lifecycle.

AllocationConfig signature summary

Pinned commit: octant-v2-core@36ed6ad tag 1.2.0-develop.15

FieldTypeSolidity defaultNotes
assetIERC20-- (required)Underlying ERC-20 for deposits and redemptions
namestring-- (required)ERC-20 name for the share token
symbolstring-- (required)ERC-20 symbol for the share token
votingDelayuint256-- (required)Seconds before voting opens; must be >= 1
votingPerioduint256-- (required)Duration of the voting window in seconds
quorumSharesuint256-- (required)Minimum For-shares for quorum (18-decimal)
timelockDelayuint256-- (required)Seconds between finalization and redemption start
gracePerioduint256-- (required)Seconds during which redemptions are allowed
owneraddress-- (required)Initial owner of the deployed mechanism

See also: TokenizedAllocationMechanism contract reference, BaseAllocationMechanism contract reference.


Complete files — copy-paste bundle

The files below represent the final compilable state of every file touched in this guide. Copy them into the indicated paths relative to your octant-v2-core checkout pinned at 36ed6ad6665661a18f83394d561fa75c68ccf4ac.

src/mechanisms/OnePersonOneVoteMechanism.sol

Complete file — src/mechanisms/OnePersonOneVoteMechanism.sol

// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {BaseAllocationMechanism, AllocationConfig} from "src/mechanisms/BaseAllocationMechanism.sol";
import {TokenizedAllocationMechanism} from "src/mechanisms/TokenizedAllocationMechanism.sol";

contract OnePersonOneVoteMechanism is BaseAllocationMechanism {
bytes32 private constant OPOV_SLOT = keccak256("tam.mechanism.1p1v.storage.v1");

struct OPOVStorage {
mapping(address => bool) registered;
uint256 registeredCount;
mapping(uint256 => mapping(address => bool)) voted;
mapping(uint256 => uint256) forVotes;
mapping(uint256 => uint256) againstVotes;
mapping(uint256 => uint256) abstainVotes;
mapping(uint256 => uint256) proposalBudgetShares;
uint256 quorumNum;
uint256 quorumDen;
}

function _s() private pure returns (OPOVStorage storage s) {
bytes32 slot = OPOV_SLOT;
assembly {
s.slot := slot
}
}

constructor(address implementation_, AllocationConfig memory config_)
BaseAllocationMechanism(implementation_, config_)
{
OPOVStorage storage s = _s();
s.quorumNum = 10;
s.quorumDen = 100;
}

function setQuorum(uint256 num, uint256 den) external {
require(msg.sender == _tokenizedAllocation().owner(), "!owner");
require(den != 0 && num <= den, "bad quorum");
OPOVStorage storage s = _s();
s.quorumNum = num;
s.quorumDen = den;
}

function _beforeSignupHook(address user) internal override returns (bool) {
OPOVStorage storage s = _s();
if (!s.registered[user]) {
s.registered[user] = true;
s.registeredCount += 1;
}
return true;
}

function _beforeProposeHook(address proposer)
internal
view
override
returns (bool)
{
return proposer == _tokenizedAllocation().keeper()
|| proposer == _tokenizedAllocation().management();
}

function _getVotingPowerHook(address user, uint256)
internal
view
override
returns (uint256)
{
// CAUTION: Repeated nonzero signup with 0 new power reverts in shared core.
// CAUTION: Re-signup after voting will re-grant power. See design note.
return _getVotingPower(user) == 0 ? 1 : 0;
}

function _validateProposalHook(uint256 pid)
internal
view
override
returns (bool)
{
return _proposalExists(pid);
}

function _processVoteHook(
uint256 pid,
address voter,
TokenizedAllocationMechanism.VoteType choice,
uint256 weight,
uint256 oldPower
) internal override returns (uint256 newPower) {
OPOVStorage storage s = _s();

require(!s.voted[pid][voter], "already voted");
require(weight == 1, "1p1v: weight must be 1");
require(oldPower >= 1, "insufficient power");

s.voted[pid][voter] = true;

if (choice == TokenizedAllocationMechanism.VoteType.For) {
s.forVotes[pid] += 1;
} else if (choice == TokenizedAllocationMechanism.VoteType.Against) {
s.againstVotes[pid] += 1;
} else {
s.abstainVotes[pid] += 1;
}

return oldPower - 1;
}

function _hasQuorumHook(uint256 pid)
internal
view
override
returns (bool)
{
OPOVStorage storage s = _s();
uint256 minVotes = (s.registeredCount * s.quorumNum + s.quorumDen - 1) / s.quorumDen;
if (s.registeredCount > 0 && minVotes == 0) minVotes = 1;
return s.forVotes[pid] >= minVotes && s.forVotes[pid] > s.againstVotes[pid];
}

function _convertVotesToShares(uint256 pid)
internal
view
override
returns (uint256 sharesToMint)
{
return _s().proposalBudgetShares[pid];
}

function _beforeFinalizeVoteTallyHook()
internal
override
returns (bool)
{
OPOVStorage storage s = _s();

uint256 n = _getProposalCount();
uint256 winners;

for (uint256 pid = 1; pid <= n; pid++) {
if (_hasQuorumHook(pid)) {
winners += 1;
}
}

uint256 totalAssets = _calculateTotalAssetsHook();
uint256 perWinner = winners == 0 ? 0 : totalAssets / winners;

for (uint256 pid = 1; pid <= n; pid++) {
s.proposalBudgetShares[pid] = _hasQuorumHook(pid) ? perWinner : 0;
}

return true;
}

function _getRecipientAddressHook(uint256 pid)
internal
view
override
returns (address recipient)
{
return _getProposal(pid).recipient;
}

function _requestCustomDistributionHook(address, uint256)
internal
pure
override
returns (bool handled, uint256 assetsTransferred)
{
return (false, 0);
}

function _calculateTotalAssetsHook()
internal
view
override
returns (uint256)
{
return asset.balanceOf(address(this));
}
}

test/unit/mechanisms/allocation/OnePersonOneVoteMechanismTest.t.sol

Complete file — test/unit/mechanisms/allocation/OnePersonOneVoteMechanismTest.t.sol

// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import {OnePersonOneVoteMechanism} from "src/mechanisms/OnePersonOneVoteMechanism.sol";
import {AllocationConfig} from "src/mechanisms/BaseAllocationMechanism.sol";
import {TokenizedAllocationMechanism} from "src/mechanisms/TokenizedAllocationMechanism.sol";
import {AllocationMechanismFactory} from "src/mechanisms/AllocationMechanismFactory.sol";

contract OnePersonOneVoteMechanismTest is Test {
AllocationMechanismFactory internal factory;
ERC20Mock internal token;
OnePersonOneVoteMechanism internal mechanism;

address internal owner;
address internal keeper = makeAddr("keeper");
address internal voter1 = makeAddr("voter1");
address internal voter2 = makeAddr("voter2");
address internal voter3 = makeAddr("voter3");
address internal recipient1 = makeAddr("recipient1");
address internal recipient2 = makeAddr("recipient2");

// Timeline parameters — keep short for test ergonomics
uint256 constant VOTING_DELAY = 1;
uint256 constant VOTING_PERIOD = 7 days;
uint256 constant QUORUM_SHARES = 10e18;
uint256 constant TIMELOCK_DELAY = 1 days;
uint256 constant GRACE_PERIOD = 7 days;

// Matching pool deposited into the mechanism
uint256 constant MATCHING_POOL = 100_000e18;

/// @dev Convenience cast to access the TAM-level functions
/// (signup, castVote, propose, finalizeVoteTally, etc.)
function _tam() internal view returns (TokenizedAllocationMechanism) {
return TokenizedAllocationMechanism(address(mechanism));
}

function setUp() public {
owner = address(this); // test contract acts as owner
factory = new AllocationMechanismFactory();
token = new ERC20Mock();

// Mint tokens for voters (needed for signup deposits)
token.mint(voter1, 100e18);
token.mint(voter2, 100e18);
token.mint(voter3, 100e18);

AllocationConfig memory config = AllocationConfig({
asset: IERC20(address(token)),
name: "1P1V Test Mechanism",
symbol: "OPOV",
votingDelay: VOTING_DELAY,
votingPeriod: VOTING_PERIOD,
quorumShares: QUORUM_SHARES,
timelockDelay: TIMELOCK_DELAY,
gracePeriod: GRACE_PERIOD,
owner: owner // test contract is the owner; core reverts on address(0)
});

// Deploy via factory — mirrors real deployment path
address impl = factory.tokenizedAllocationImplementation();
mechanism = new OnePersonOneVoteMechanism(impl, config);

// Assign keeper and management roles
_tam().setKeeper(keeper);
_tam().setManagement(keeper);

// Fund the matching pool
token.mint(address(mechanism), MATCHING_POOL);
}

// ───────── Signup ─────────

function test_firstSignupGrantsPowerOne() public {
vm.startPrank(voter1);
token.approve(address(mechanism), 1e18);
_tam().signup(1e18);
vm.stopPrank();

assertEq(_tam().votingPower(voter1), 1, "first signup must grant 1 voting power");
}

function test_repeatedNonzeroSignupRevertsWithInsufficientDeposit() public {
vm.startPrank(voter1);
token.approve(address(mechanism), 2e18);
_tam().signup(1e18);

vm.expectRevert(
abi.encodeWithSelector(TokenizedAllocationMechanism.InsufficientDeposit.selector, 1e18)
);
_tam().signup(1e18);
vm.stopPrank();

assertEq(_tam().votingPower(voter1), 1, "failed repeated signup must preserve power");
}

function test_repeatedZeroDepositSignupPreservesPower() public {
vm.startPrank(voter1);
token.approve(address(mechanism), 1e18);
_tam().signup(1e18);
_tam().signup(0);
vm.stopPrank();

assertEq(_tam().votingPower(voter1), 1, "zero-deposit repeat signup must preserve power");
}

// ───────── Propose ─────────

function test_onlyKeeperOrManagementCanPropose() public {
// Non-keeper proposal should revert
vm.prank(voter1);
vm.expectRevert();
_tam().propose(recipient1, "should fail");

// Keeper proposal should succeed
vm.prank(keeper);
uint256 pid = _tam().propose(recipient1, "valid proposal");
assertGt(pid, 0, "keeper must be able to propose");
}

// ───────── Vote ─────────

function test_doubleVoteReverts() public {
// Setup: signup voter1 and create a proposal
vm.startPrank(voter1);
token.approve(address(mechanism), 1e18);
_tam().signup(1e18);
vm.stopPrank();

vm.prank(keeper);
uint256 pid = _tam().propose(recipient1, "proposal A");

// Warp into voting window
vm.warp(block.timestamp + VOTING_DELAY + 1);

// First vote succeeds
vm.prank(voter1);
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 1, recipient1);

// Second vote on same proposal reverts
vm.prank(voter1);
vm.expectRevert("already voted");
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 1, recipient1);
}

function test_reSignupAfterVotingReGrantsPower() public {
// Signup
vm.startPrank(voter1);
token.approve(address(mechanism), 2e18);
_tam().signup(1e18);
vm.stopPrank();

// Create proposal and vote (consuming power)
vm.prank(keeper);
uint256 pid = _tam().propose(recipient1, "proposal A");
vm.warp(block.timestamp + VOTING_DELAY + 1);

vm.prank(voter1);
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 1, recipient1);
assertEq(_tam().votingPower(voter1), 0, "power should be spent after voting");

// Re-signup grants power again
vm.prank(voter1);
_tam().signup(1e18);
assertEq(_tam().votingPower(voter1), 1, "re-signup must re-grant 1 power");
}

// ───────── Quorum, finalization, and redemption ─────────

function test_quorumFinalizationAndRedemption() public {
// --- Signup three voters ---
address[3] memory voters = [voter1, voter2, voter3];
for (uint256 i; i < voters.length; i++) {
vm.startPrank(voters[i]);
token.approve(address(mechanism), 1e18);
_tam().signup(1e18);
vm.stopPrank();
}

// --- Create a single proposal ---
vm.prank(keeper);
uint256 pid = _tam().propose(recipient1, "project alpha");

// --- Warp into voting window and vote ---
vm.warp(block.timestamp + VOTING_DELAY + 1);

// All three vote For
for (uint256 i; i < voters.length; i++) {
vm.prank(voters[i]);
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 1, recipient1);
}

// --- Warp past voting end and finalize ---
vm.warp(block.timestamp + VOTING_PERIOD + 1);
_tam().finalizeVoteTally(); // called by owner (this contract)

// --- Queue the winning proposal (the only one) ---
_tam().queueProposal(pid);

// pid had 3 For votes out of 3 registered — quorum met
uint256 recipientShares = _tam().balanceOf(recipient1);
assertGt(recipientShares, 0, "winning proposal recipient must receive shares");

// With one winning proposal, the recipient should receive the full budget.
// totalAssets() == the mechanism's token balance; shares convert 1:1
// when there is only one winner and no prior redemptions.
uint256 expectedAssets = _tam().convertToAssets(recipientShares);

// --- Warp into redemption window and redeem ---
vm.warp(block.timestamp + TIMELOCK_DELAY + 1);

uint256 balanceBefore = token.balanceOf(recipient1);
vm.prank(recipient1);
_tam().redeem(recipientShares, recipient1, recipient1);
uint256 balanceAfter = token.balanceOf(recipient1);

assertEq(
balanceAfter - balanceBefore,
expectedAssets,
"redeemed amount must equal convertToAssets(shares)"
);
}
}

script/deploy/DeployOnePersonOneVote.s.sol

Complete file — script/deploy/DeployOnePersonOneVote.s.sol

// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.20;

import "forge-std/Script.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {OnePersonOneVoteMechanism} from "src/mechanisms/OnePersonOneVoteMechanism.sol";
import {AllocationConfig} from "src/mechanisms/BaseAllocationMechanism.sol";
import {AllocationMechanismFactory} from "src/mechanisms/AllocationMechanismFactory.sol";

contract DeployOnePersonOneVote is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
// Explicitly derive the deployer address from the private key.
// Do NOT use msg.sender — inside a Forge script its value has
// historically been unreliable after vm.startBroadcast().
// If OWNER is set, use that instead (e.g. a multisig address).
address owner = vm.envOr("OWNER", vm.addr(deployerPrivateKey));
address factoryAddr = vm.envAddress("FACTORY_ADDRESS");
address assetAddr = vm.envAddress("ASSET_ADDRESS");

vm.startBroadcast(deployerPrivateKey);

AllocationMechanismFactory factory = AllocationMechanismFactory(factoryAddr);
address implementation = factory.tokenizedAllocationImplementation();

// owner is also assigned to management and keeper during
// initialization (TokenizedAllocationMechanism.sol L635-637).
// Reassign them post-deploy if they should differ.
AllocationConfig memory config = AllocationConfig({
asset: IERC20(assetAddr),
name: "1P1V Mechanism",
symbol: "OPOV",
votingDelay: 1,
votingPeriod: 7 days,
quorumShares: 10e18,
timelockDelay: 1 days,
gracePeriod: 7 days,
owner: owner
});

OnePersonOneVoteMechanism mechanism =
new OnePersonOneVoteMechanism(implementation, config);

console.log("OnePersonOneVoteMechanism deployed at:", address(mechanism));
console.log("Owner/management/keeper set to:", owner);

vm.stopBroadcast();
}
}

.env

Complete file — .env

# Required for DeployOnePersonOneVote.s.sol
PRIVATE_KEY=<your-deployer-private-key>
FACTORY_ADDRESS=0x30B980fe1CaF8Fa275e9a364187DB953d08C3ACE
ASSET_ADDRESS=<underlying-erc20-address>
RPC_URL=<your-rpc-url>
# Optional — defaults to the address derived from PRIVATE_KEY.
# Set this if the mechanism owner should be a multisig or different EOA.
# OWNER=<owner-address>

Verification commands

Command — build and test

# Build all contracts and check sizes
forge build --sizes

# Run mechanism-specific tests
forge test --match-contract OnePersonOneVoteMechanismTest -vvv

# (Optional) Deploy to a local Anvil fork
source .env
forge script script/deploy/DeployOnePersonOneVote.s.sol \
--rpc-url $RPC_URL \
--broadcast