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-coreand reading delegatecall-based proxy patterns. Level: Advanced Source of truth:[email protected], especiallyBaseAllocationMechanism,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.
- Required: Introduction to TAM, TAM Architecture, and TAM Mental Model & Lifecycle
- Helpful: Local Development Quickstart
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.
| Field | Value |
|---|---|
| Goal | Build and test a minimal 1-person-1-vote TAM on top of the shared TAM core |
| Start repo | octant-v2-core@36ed6ad6665661a18f83394d561fa75c68ccf4ac (tag 1.2.0-develop.15) |
| Files to edit | src/mechanisms/OnePersonOneVoteMechanism.sol, test/unit/mechanisms/allocation/OnePersonOneVoteMechanismTest.t.sol, script/deploy/DeployOnePersonOneVote.s.sol, .env |
| Files NOT to edit | src/mechanisms/BaseAllocationMechanism.sol, src/mechanisms/TokenizedAllocationMechanism.sol, src/mechanisms/AllocationMechanismFactory.sol |
| Required setup | Foundry installed; octant-v2-core checked out at 36ed6ad; dependencies installed via Path A of the Local Development Quickstart |
| Verification | forge test --match-contract OnePersonOneVoteMechanismTest -vvv — all tests pass |
| Do not | Use 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 signature | Purpose | Required? | Called when... |
|---|---|---|---|
_beforeSignupHook(address user) | Validate and register a voter | Yes | A voter calls signup() |
_getVotingPowerHook(address user, uint256 deposit) | Calculate a voter's voting weight | Yes | During signup processing |
_beforeProposeHook(address proposer) | Allow or block proposal creation | Yes | A proposer creates a proposal |
_validateProposalHook(uint256 pid) | Validate that a proposal exists | Yes | During 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 power | Yes | A voter casts a vote |
_hasQuorumHook(uint256 pid) | Check if a proposal met quorum | Yes | During proposal finalization |
_beforeFinalizeVoteTallyHook() | Pre-finalization logic (e.g. snapshot budgets) | Yes | Before vote tally is finalized |
_convertVotesToShares(uint256 pid) | Convert vote totals to recipient shares | Yes | During proposal queueing |
_getRecipientAddressHook(uint256 pid) | Determine the recipient address for a proposal | Yes | During proposal queueing |
_requestCustomDistributionHook(address, uint256) | Custom distribution instead of share minting | Yes (override required; return (false, 0) for default minting) | Every queueProposal |
_calculateTotalAssetsHook() | Report total assets held by the mechanism | Yes | During vote-tally finalization |
_availableWithdrawLimit(address) | Enforce timelock and grace-period redemption rules | Optional | Before 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.
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:
- Read the implementation address. Call
tokenizedAllocationImplementation()on theAllocationMechanismFactory(0x30B980fe1CaF8Fa275e9a364187DB953d08C3ACE). That returns the sharedTokenizedAllocationMechanismsingleton address you need. - Pass that address into your custom mechanism constructor as
implementation_. TheBaseAllocationMechanismconstructor stores it and immediately delegate-callsTokenizedAllocationMechanism.initialize(...)to set up shared state.
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.
| Field | Type | Description |
|---|---|---|
asset | IERC20 | Underlying ERC-20 token used for deposits and redemptions |
name | string | ERC-20 name for the mechanism's share token |
symbol | string | ERC-20 symbol for the mechanism's share token |
votingDelay | uint256 | Seconds between mechanism deployment and when voting opens |
votingPeriod | uint256 | Duration in seconds of the active voting window |
quorumShares | uint256 | Minimum shares that must vote For for a proposal to succeed (18-decimal share units) |
timelockDelay | uint256 | Seconds between vote finalization and when redemptions open |
gracePeriod | uint256 | Seconds during which redemptions are allowed before they expire |
owner | address | Initial owner of the deployed mechanism |
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
AgainstandAbstainare 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.
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
BaseAllocationMechanismprovides the delegatecall proxy pattern and theonlySelf-gated hook wrappers.TokenizedAllocationMechanismis 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 byBaseAllocationMechanismthat returns the sharedTokenizedAllocationMechanismimplementation as a typed interface. Use it whenever your mechanism needs to read shared state such asowner(),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.BaseAllocationMechanismalso 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 fullProposalstruct, 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
1voting power only if the user currently has0, - return
0on later signup attempts. In the shared core, a later signup with a nonzero deposit then reverts withInsufficientDeposit; a later signup withdeposit == 0preserves voting power but still runs the signup hooks and emitsUserRegistered(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:
-
Repeated signup before voting. The hook returns
0when 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 withInsufficientDeposit(deposit)becausenewPower == 0 && deposit > 0. A repeated signup withdeposit == 0preserves voting power, but it is not a silent no-op: the signup hooks still run andUserRegistered(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. -
Re-signup after voting. The shared core's
_processVoteHookoverwrites the user's voting power with the hook's returnednewPower(in this example,oldPower - 1 = 0). Once power is zero, a subsequentsignup()call will see_getVotingPower(user) == 0and grant1again — 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_beforeSignupHookor_getVotingPowerHookthat tracks whether the user has already voted in this round and blocks re-enrollment.
Do not leave these ambiguities undocumented in a production mechanism.
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,
weightmust be1,For,Against, andAbstainare 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, andforVotes > 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
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 == assetsis 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.
balanceOfThe 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.
-
Registration
- first signup gives voting power
1, - repeated signup before voting with a nonzero deposit reverts with
InsufficientDeposit, - repeated signup with
deposit == 0preserves 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, registeredCountonly counts unique users.
- first signup gives voting power
-
Proposal creation
- only keeper/management can propose under the default policy,
- recipient uniqueness is still enforced by the shared core.
-
Voting
- one vote per proposal,
weight == 1required,oldPower -> oldPower - 1,For,Against,Abstaintallies update as expected.
-
Finalization
- owner-only,
- proposal budgets are fixed once at finalization,
- equal-split funding is assigned only to proposals that pass quorum.
-
Queueing
- queueing remains permissionless,
- passing proposals mint the expected number of shares,
- failed proposals do not receive shares.
-
Redemption window
- redeem succeeds only during the global redemption window,
- transfers remain restricted according to the shared core lifecycle.
-
Sweep
- after grace expires, owner can recover residual funds through
sweep(...).
- after grace expires, owner can recover residual funds through
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 exactassetsTransferred.
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
| Field | Type | Solidity default | Notes |
|---|---|---|---|
asset | IERC20 | -- (required) | Underlying ERC-20 for deposits and redemptions |
name | string | -- (required) | ERC-20 name for the share token |
symbol | string | -- (required) | ERC-20 symbol for the share token |
votingDelay | uint256 | -- (required) | Seconds before voting opens; must be >= 1 |
votingPeriod | uint256 | -- (required) | Duration of the voting window in seconds |
quorumShares | uint256 | -- (required) | Minimum For-shares for quorum (18-decimal) |
timelockDelay | uint256 | -- (required) | Seconds between finalization and redemption start |
gracePeriod | uint256 | -- (required) | Seconds during which redemptions are allowed |
owner | address | -- (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