Skip to content

Commit dd50d12

Browse files
committed
feat: pack ProperQF PackedProject into 1 slot (uint128 + uint128)
Compress per-project storage from 2 slots to 1 using symmetric uint128 field widths with lossy quantization for sumContributions via uint-quantization-lib v7.0.1. Layout: uint128 sumContributions (shift=32, lossy) + uint128 sumSquareRoots (lossless) sumContributions: quantized via CONTRIBUTIONS_SCHEME (step=2^32, max~2^160). sumSquareRoots: uint128 ceiling matches its arithmetic constraint (squared on-chain, which overflows uint256 at 2^128). Enforce weight alignment to MIN_VOTE_WEIGHT=2^16 so contribution=weight^2 is always step-aligned, making per-project sums lossless. Enforce 18-decimal input assumption via ProperQF constructor parameter to prevent miscalibration by future inheritors.
1 parent 5c93c00 commit dd50d12

30 files changed

+1025
-883
lines changed

foundry.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,6 @@ staker = { version = "1.0.1", git = "https://github.com/withtally/staker", tag =
6868
surl = { version = "0.0.0", git = "https://github.com/memester-xyz/surl", rev = "5f92a5260c8e0198ca3ee6a16255d6cda4e0887a" }
6969
tokenized-strategy = { version = "3.0.4", git = "https://github.com/yearn/tokenized-strategy", tag = "v3.0.4" }
7070
tokenized-strategy-periphery = { version = "3.0.2", git = "https://github.com/yearn/tokenized-strategy-periphery", tag = "v3.0.2" }
71+
uint-quantization-lib = "7.0.1"
7172

7273
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

remappings.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ solmate/=dependencies/solmate-6.8.0/
1515
staker/=dependencies/staker-1.0.1/src/
1616
surl/=dependencies/surl-0.0.0/src/
1717
tokenized-strategy/=dependencies/tokenized-strategy-3.0.4/
18+
uint-quantization-lib/=dependencies/uint-quantization-lib-7.0.1/
1819
zodiac/=dependencies/gnosisguild-zodiac-4.1.1/contracts/

soldeer.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,10 @@ name = "tokenized-strategy-periphery"
9898
version = "3.0.2"
9999
git = "https://github.com/yearn/tokenized-strategy-periphery"
100100
rev = "d732e918b208a9b2c33c71ea0ea9e8eb880cfc6e"
101+
102+
[[dependencies]]
103+
name = "uint-quantization-lib"
104+
version = "7.0.1"
105+
url = "https://soldeer-revisions.s3.amazonaws.com/uint-quantization-lib/7_0_1_13-03-2026_21:14:08_uint-quantization-lib.zip"
106+
checksum = "653124bc51721b850fe4bbb737b76fba234d1bee4f29ac970cb693898e568a6d"
107+
integrity = "b96a0a9995bc836ff598aae62177af294ce68a4d417d2abdbf54a1f7b9256869"

src/mechanisms/mechanism/QuadraticVotingMechanism.sol

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I
1919
* - Prevents whale dominance (expensive to cast many votes)
2020
* - Benefits: Small contributors have proportionally more impact
2121
*
22-
* VOTE COST EXAMPLES:
23-
* - 10 votes costs 100 voting power (10²)
24-
* - 20 votes costs 400 voting power (20²)
25-
* - 100 votes costs 10,000 voting power (100²)
22+
* VOTE COST EXAMPLES (voting power is in 18-decimal-normalized space):
23+
* - weight 65536 costs ~4.3e9 voting power (65536²) : minimum vote
24+
* - weight 1e9 costs 1e18 voting power (1e9²) : ~1 token
25+
* - weight 1e12 costs 1e24 voting power (1e12²) : ~1M tokens
2626
*
2727
* ONE-TIME VOTING:
2828
* ⚠️ Users can only vote ONCE per proposal
@@ -46,9 +46,17 @@ contract QuadraticVotingMechanism is BaseAllocationMechanism, ProperQF {
4646
error ZeroAddressCannotPropose();
4747
error OnlyForVotesSupported();
4848
error InsufficientVotingPowerForQuadraticCost();
49+
error WeightBelowMinimum(uint256 weight, uint256 minimum);
50+
error WeightNotAligned(uint256 weight, uint256 alignment);
4951

5052
error AlreadyVoted(address voter, uint256 pid);
5153

54+
/// @notice Minimum vote weight, derived from sumContributions quantization.
55+
/// @dev contribution = weight^2 must be >= CONTRIBUTIONS_SCHEME.stepSize() = 2^32,
56+
/// therefore weight >= 2^16 = 65536. Since voting power is normalized to 18 decimals
57+
/// (see _getVotingPowerHook), this costs ~4.3 nanotoken regardless of the asset.
58+
uint256 public constant MIN_VOTE_WEIGHT = 1 << 16;
59+
5260
// ============================================
5361
// STATE VARIABLES
5462
// ============================================
@@ -71,7 +79,7 @@ contract QuadraticVotingMechanism is BaseAllocationMechanism, ProperQF {
7179
AllocationConfig memory _config,
7280
uint256 _alphaNumerator,
7381
uint256 _alphaDenominator
74-
) BaseAllocationMechanism(_implementation, _config) {
82+
) BaseAllocationMechanism(_implementation, _config) ProperQF(18) {
7583
_setAlpha(_alphaNumerator, _alphaDenominator);
7684
}
7785

@@ -204,6 +212,8 @@ contract QuadraticVotingMechanism is BaseAllocationMechanism, ProperQF {
204212
uint256 oldPower
205213
) internal virtual override returns (uint256) {
206214
if (choice != TokenizedAllocationMechanism.VoteType.For) revert OnlyForVotesSupported();
215+
if (weight < MIN_VOTE_WEIGHT) revert WeightBelowMinimum(weight, MIN_VOTE_WEIGHT);
216+
if (weight % MIN_VOTE_WEIGHT != 0) revert WeightNotAligned(weight, MIN_VOTE_WEIGHT);
207217

208218
// Check if voter has already voted on this proposal
209219
if (hasVoted[pid][voter]) revert AlreadyVoted(voter, pid);

src/mechanisms/voting-strategy/ProperQF.sol

Lines changed: 115 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
pragma solidity ^0.8.0;
33

44
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
5+
import { Quant, UintQuantizationLib as QuantLib } from "uint-quantization-lib/src/UintQuantizationLib.sol";
56

67
/**
78
* @title Proper Quadratic Funding (QF) math and tallying
@@ -10,10 +11,55 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
1011
* @notice Incremental QF tallying utilities with alpha-weighted quadratic/linear funding.
1112
* @dev Provides storage isolation via deterministic slot, input validation helpers,
1213
* and funding aggregation with well-defined rounding behavior.
14+
*
15+
* Storage packing: each project occupies exactly 1 slot (256 bits):
16+
* sumContributions : uint128, shift=32 (lossy quantization via UintQuantizationLib)
17+
* sumSquareRoots : uint128, shift=0 (lossless, no quantization)
18+
*
19+
* sumContributions quantization (CONTRIBUTIONS_SCHEME):
20+
* encode: stored = value >> 32 (floors to nearest step)
21+
* decode: value = stored << 32
22+
* step = 2^32 = 4,294,967,296 wei (~4.3 nanotoken at 18 decimals)
23+
* max = (2^128 - 1) << 32 (~1.46e48 wei = ~1.46e30 tokens at 18 decimals)
24+
*
25+
* Contribution values entering ProperQF are quadratic costs (weight^2) in the
26+
* 18-decimal-normalized voting-power space (see QuadraticVotingMechanism
27+
* ._normalizeToDecimals). Since voting power is always normalized to 18 decimals
28+
* regardless of the underlying asset, the step size of ~4.3 nanotoken applies
29+
* uniformly. Minimum weight = sqrt(step) = 2^16 = 65536, enforced by the
30+
* ContributionBelowMinimum guard in _processVoteUnchecked.
31+
*
32+
* Per-project sumContributions readback is lossy (floored to step boundary).
33+
* Global totalLinearSum stays exact: the delta update pattern in
34+
* _processVoteUnchecked computes (old - decoded + (decoded + contribution)),
35+
* so the quantization errors cancel.
36+
*
37+
* sumSquareRoots: stored as uint128, no quantization.
38+
* The uint128 ceiling matches the arithmetic constraint: sumSquareRoots is
39+
* squared on-chain (sumSR * sumSR), which overflows uint256 at 2^128.
40+
* Overflow protection comes from Solidity 0.8's checked uint128() downcast
41+
* in _writeProject (defense-in-depth; the squaring catches it first).
1342
*/
1443
abstract contract ProperQF {
1544
using Math for uint256;
1645

46+
/// @notice Quantization scheme for sumContributions: stored as uint128, shift=32.
47+
/// @dev Contributions are quadratic costs (weight^2) in 18-decimal-normalized voting-power
48+
/// space (see QuadraticVotingMechanism._normalizeToDecimals), so these bounds hold
49+
/// uniformly regardless of the underlying asset's native decimals.
50+
/// Step size: 2^32 ~= 4.3e9 wei (~4.3 nanotoken) : floor-rounding error per encode.
51+
/// Max value: (2^128 - 1) << 32 ~= 1.46e48 wei (~1.46e30 tokens).
52+
/// Global sums (totalLinearSum) stay exact because quantization errors cancel in the
53+
/// delta update pattern of _processVoteUnchecked; only per-project readback is lossy.
54+
/// Equivalent to QuantLib.create(32, 128); constant required to avoid immutable bloat.
55+
Quant internal constant CONTRIBUTIONS_SCHEME = Quant.wrap(uint16((128 << 8) | 32));
56+
57+
/// @dev sumSquareRoots is stored as uint128 with no quantization.
58+
/// Max value: 2^128 - 1 ~= 3.40e38.
59+
/// The uint128 ceiling matches the arithmetic constraint: sumSquareRoots is squared
60+
/// on-chain (sumSR * sumSR), which overflows uint256 at 2^128.
61+
/// Overflow protection comes from Solidity 0.8's checked uint128() downcast in _writeProject.
62+
1763
// Custom Errors
1864
error ContributionMustBePositive();
1965
error VoteWeightMustBePositive();
@@ -24,24 +70,40 @@ abstract contract ProperQF {
2470
error LinearSumUnderflow();
2571
error DenominatorMustBePositive();
2672
error AlphaMustBeLessOrEqualToOne();
73+
error ContributionBelowMinimum(uint256 contribution, uint256 minimum);
74+
error UnsupportedInputDecimals(uint8 provided, uint8 expected);
75+
76+
/// @notice Expected decimal precision for all contribution/voting-power inputs.
77+
/// @dev CONTRIBUTIONS_SCHEME's step size (2^32 ~= 4.3 nanotoken) and MIN_VOTE_WEIGHT (2^16)
78+
/// are calibrated for 18-decimal-normalized values. Feeding lower-precision inputs
79+
/// (e.g., raw 6-decimal USDC) would cause the step to silently swallow entire contributions.
80+
uint8 internal constant INPUT_DECIMALS = 18;
2781

2882
/// @notice Storage slot for ProperQF storage (ERC-7201 namespaced storage)
2983
/// @dev https://eips.ethereum.org/EIPS/eip-7201
3084
bytes32 private constant STORAGE_SLOT =
3185
bytes32(uint256(keccak256(abi.encode(uint256(keccak256(bytes("proper.qf.storage"))) - 1))) & ~uint256(0xff));
3286

33-
/// @notice Per-project aggregated sums
87+
/// @notice Per-project aggregated sums (public return type, decoded)
3488
struct Project {
35-
/// @notice Sum of contributions for this project (asset base units)
89+
/// @notice Sum of contributions for this project (asset base units, lossy: floored to CONTRIBUTIONS_STEP)
3690
uint256 sumContributions;
3791
/// @notice Sum of square roots of all contributions (dimensionless)
3892
uint256 sumSquareRoots;
3993
}
4094

95+
/// @notice Packed per-project storage: 128 + 128 = 256 bits = 1 slot
96+
struct PackedProject {
97+
/// @notice Sum of contributions, quantized: stored = value >> 32
98+
uint128 sumContributions;
99+
/// @notice Sum of square roots, stored without quantization (ceiling matches squaring constraint)
100+
uint128 sumSquareRoots;
101+
}
102+
41103
/// @notice Main storage struct containing all mutable state for ProperQF
42104
struct ProperQFStorage {
43-
/// @notice Mapping of project IDs to project data
44-
mapping(uint256 => Project) projects;
105+
/// @notice Mapping of project IDs to packed project data (1 slot each)
106+
mapping(uint256 => PackedProject) projects;
45107
/// @notice Numerator for alpha (dimensionless; 1.0 = denominator)
46108
uint256 alphaNumerator;
47109
/// @notice Denominator for alpha (must be > 0)
@@ -62,8 +124,11 @@ abstract contract ProperQF {
62124
/// @param newDenominator New alpha denominator
63125
event AlphaUpdated(uint256 oldNumerator, uint256 oldDenominator, uint256 newNumerator, uint256 newDenominator);
64126

65-
/// @notice Constructor initializes default alpha values in storage
66-
constructor() {
127+
/// @notice Constructor validates input decimal precision and initializes default alpha values.
128+
/// @param inputDecimals Must be 18. Forces inheritors to explicitly acknowledge the
129+
/// decimal precision that CONTRIBUTIONS_SCHEME and MIN_VOTE_WEIGHT are calibrated for.
130+
constructor(uint8 inputDecimals) {
131+
if (inputDecimals != INPUT_DECIMALS) revert UnsupportedInputDecimals(inputDecimals, INPUT_DECIMALS);
67132
ProperQFStorage storage s = _getProperQFStorage();
68133
s.alphaNumerator = 10000; // Default alpha = 1.0 (10000/10000)
69134
s.alphaDenominator = 10000;
@@ -78,10 +143,11 @@ abstract contract ProperQF {
78143
}
79144
}
80145

81-
/// @notice Returns project aggregated sums
146+
/// @notice Returns project aggregated sums (decoded from packed storage)
82147
/// @param projectId ID of the project to query
83148
function projects(uint256 projectId) public view returns (Project memory) {
84-
return _getProperQFStorage().projects[projectId];
149+
(uint256 sumC, uint256 sumSR) = _readProject(projectId);
150+
return Project({ sumContributions: sumC, sumSquareRoots: sumSR });
85151
}
86152

87153
/// @notice Returns alpha numerator
@@ -136,35 +202,35 @@ abstract contract ProperQF {
136202
}
137203

138204
/**
139-
* @notice Process vote without validation - for trusted callers who have already validated
140-
* @dev Skips input validation for gas optimization when caller guarantees correctness
205+
* @notice Process vote without full validation - only enforces the quantization minimum.
206+
* @dev Skips sqrt-tolerance checks for gas optimization when caller guarantees correctness.
207+
* Enforces contribution >= CONTRIBUTIONS_SCHEME.stepSize() to prevent silent zeroing
208+
* of per-project sumContributions due to floor quantization.
209+
* Delta update pattern ensures totalLinearSum stays exact despite lossy per-project storage.
141210
* @param projectId ID of project to update
142-
* @param contribution Contribution amount (asset base units)
211+
* @param contribution Contribution amount (must be >= CONTRIBUTIONS_SCHEME.stepSize())
143212
* @param voteWeight Vote weight (dimensionless; sqrt of contribution)
144213
*/
145214
function _processVoteUnchecked(uint256 projectId, uint256 contribution, uint256 voteWeight) internal {
215+
uint256 minContribution = CONTRIBUTIONS_SCHEME.stepSize();
216+
if (contribution < minContribution) revert ContributionBelowMinimum(contribution, minContribution);
217+
146218
ProperQFStorage storage s = _getProperQFStorage();
147-
Project memory project = s.projects[projectId];
219+
(uint256 oldSumContributions, uint256 oldSumSquareRoots) = _readProject(projectId);
148220

149-
uint256 newSumSquareRoots = project.sumSquareRoots + voteWeight;
150-
uint256 newSumContributions = project.sumContributions + contribution;
221+
uint256 newSumSquareRoots = oldSumSquareRoots + voteWeight;
222+
uint256 newSumContributions = oldSumContributions + contribution;
151223

152-
uint256 oldQuadraticFunding = project.sumSquareRoots * project.sumSquareRoots;
224+
uint256 oldQuadraticFunding = oldSumSquareRoots * oldSumSquareRoots;
153225
uint256 newQuadraticFunding = newSumSquareRoots * newSumSquareRoots;
154226

155227
if (s.totalQuadraticSum < oldQuadraticFunding) revert QuadraticSumUnderflow();
156-
if (s.totalLinearSum < project.sumContributions) revert LinearSumUnderflow();
157-
158-
uint256 newTotalQuadraticSum = s.totalQuadraticSum - oldQuadraticFunding + newQuadraticFunding;
159-
uint256 newTotalLinearSum = s.totalLinearSum - project.sumContributions + newSumContributions;
228+
if (s.totalLinearSum < oldSumContributions) revert LinearSumUnderflow();
160229

161-
s.totalQuadraticSum = newTotalQuadraticSum;
162-
s.totalLinearSum = newTotalLinearSum;
230+
s.totalQuadraticSum = s.totalQuadraticSum - oldQuadraticFunding + newQuadraticFunding;
231+
s.totalLinearSum = s.totalLinearSum - oldSumContributions + newSumContributions;
163232

164-
project.sumSquareRoots = newSumSquareRoots;
165-
project.sumContributions = newSumContributions;
166-
167-
s.projects[projectId] = project;
233+
_writeProject(projectId, newSumContributions, newSumSquareRoots);
168234

169235
s.totalFunding = _calculateWeightedTotalFunding();
170236
}
@@ -201,18 +267,37 @@ abstract contract ProperQF {
201267
returns (uint256 sumContributions, uint256 sumSquareRoots, uint256 quadraticFunding, uint256 linearFunding)
202268
{
203269
ProperQFStorage storage s = _getProperQFStorage();
204-
Project storage project = s.projects[projectId];
270+
(uint256 sumC, uint256 sumSR) = _readProject(projectId);
205271

206-
uint256 rawQuadraticFunding = project.sumSquareRoots * project.sumSquareRoots;
272+
uint256 rawQuadraticFunding = sumSR * sumSR;
207273

208274
return (
209-
project.sumContributions,
210-
project.sumSquareRoots,
275+
sumC,
276+
sumSR,
211277
(rawQuadraticFunding * s.alphaNumerator) / s.alphaDenominator,
212-
(project.sumContributions * (s.alphaDenominator - s.alphaNumerator)) / s.alphaDenominator
278+
(sumC * (s.alphaDenominator - s.alphaNumerator)) / s.alphaDenominator
213279
);
214280
}
215281

282+
// ── Pack/unpack helpers ──────────────────────────────────────────────
283+
284+
/// @notice Decode packed project storage into full-width uint256 values
285+
function _readProject(uint256 projectId) internal view returns (uint256 sumContributions, uint256 sumSquareRoots) {
286+
PackedProject storage packed = _getProperQFStorage().projects[projectId];
287+
sumContributions = CONTRIBUTIONS_SCHEME.decode(uint256(packed.sumContributions));
288+
sumSquareRoots = uint256(packed.sumSquareRoots);
289+
}
290+
291+
/// @notice Encode and store full-width values into packed project storage
292+
/// @dev sumContributions: reverts with Overflow(value, max) if it exceeds CONTRIBUTIONS_SCHEME.max().
293+
/// sumSquareRoots: reverts via Solidity 0.8 checked downcast if it exceeds type(uint128).max.
294+
function _writeProject(uint256 projectId, uint256 sumContributions, uint256 sumSquareRoots) internal {
295+
_getProperQFStorage().projects[projectId] = PackedProject({
296+
sumContributions: uint128(CONTRIBUTIONS_SCHEME.encode(sumContributions)),
297+
sumSquareRoots: uint128(sumSquareRoots)
298+
});
299+
}
300+
216301
/**
217302
* @notice Set alpha parameter determining ratio between quadratic and linear funding
218303
* @param newNumerator Numerator of new alpha (0 ≤ numerator ≤ denominator)

test/integration/regen/RegenERC1271Integration.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ contract RegenERC1271IntegrationTest is Test {
248248
vm.warp(block.timestamp + VOTING_DELAY + 1);
249249

250250
// Prepare vote signature - use sqrt of voting power for quadratic voting
251-
uint256 voteWeight = 1; // Start with a small weight
251+
uint256 voteWeight = 1 << 16; // MIN_VOTE_WEIGHT: quadratic cost = 2^32
252252
uint256 voteDeadline = block.timestamp + 1 hours;
253253
uint256 voteNonce = TokenizedAllocationMechanism(address(allocationMechanism)).nonces(address(contractSigner));
254254

0 commit comments

Comments
 (0)