Skip to content

Commit 17f7bf2

Browse files
committed
feat: pack ProperQF PackedProject into 1 slot (uint96 + uint160)
Compress per-project storage from 2 slots to 1 by using asymmetric field widths with inline shift-based quantization: - sumContributions: uint96 with shift=32 (lossy, step ~4.3 nanotoken) - sumSquareRoots: uint160 lossless (sqrt-compressed range needs full resolution) Global sums (totalLinearSum, totalQuadraticSum) stay exact because quantization errors cancel in the delta update pattern. Test vote weights scaled by W=65536 so weight^2 aligns to the quantization step.
1 parent 5c93c00 commit 17f7bf2

17 files changed

+573
-407
lines changed

src/mechanisms/voting-strategy/ProperQF.sol

Lines changed: 82 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,40 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
1010
* @notice Incremental QF tallying utilities with alpha-weighted quadratic/linear funding.
1111
* @dev Provides storage isolation via deterministic slot, input validation helpers,
1212
* and funding aggregation with well-defined rounding behavior.
13+
*
14+
* Storage packing: each project occupies exactly 1 slot (256 bits):
15+
* sumContributions : uint96, shift=32 (lossy quantization)
16+
* sumSquareRoots : uint160, no quantization (plain checked downcast)
17+
*
18+
* sumContributions quantization:
19+
* encode: stored = value >> 32 (floors to nearest step)
20+
* decode: value = stored << 32
21+
* step = 2^32 = 4,294,967,296 wei (~4.3 nanotoken at 18 decimals)
22+
* max = (2^96 - 1) << 32 (~3.40e38 wei = ~3.40e20 tokens at 18 decimals)
23+
*
24+
* Contribution values entering ProperQF are dimensionless quadratic costs
25+
* (weight^2) from the mechanism layer, NOT raw token amounts. For any
26+
* realistic deposit the resulting contributions are well above the step size.
27+
*
28+
* Per-project sumContributions readback is lossy (floored to step boundary).
29+
* Global totalLinearSum stays exact: the delta update pattern in
30+
* _processVoteUnchecked computes (old - decoded + (decoded + contribution)),
31+
* so the quantization errors cancel.
32+
*
33+
* sumSquareRoots: stored as uint160, plain checked downcast from uint256.
34+
* Solidity 0.8+ reverts on truncation. No quantization applied.
1335
*/
1436
abstract contract ProperQF {
1537
using Math for uint256;
1638

39+
// ── Quantization constants for sumContributions ────────────────────────
40+
/// @dev Right-shift applied when encoding sumContributions into uint96
41+
uint256 internal constant CONTRIBUTIONS_SHIFT = 32;
42+
/// @dev Minimum non-zero representable value for sumContributions
43+
uint256 internal constant CONTRIBUTIONS_STEP = uint256(1) << CONTRIBUTIONS_SHIFT;
44+
/// @dev Maximum representable value for sumContributions after decode
45+
uint256 internal constant CONTRIBUTIONS_MAX = uint256(type(uint96).max) << CONTRIBUTIONS_SHIFT;
46+
1747
// Custom Errors
1848
error ContributionMustBePositive();
1949
error VoteWeightMustBePositive();
@@ -24,24 +54,33 @@ abstract contract ProperQF {
2454
error LinearSumUnderflow();
2555
error DenominatorMustBePositive();
2656
error AlphaMustBeLessOrEqualToOne();
57+
error ContributionsOverflow(uint256 value, uint256 max);
2758

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

33-
/// @notice Per-project aggregated sums
64+
/// @notice Per-project aggregated sums (public return type, decoded)
3465
struct Project {
35-
/// @notice Sum of contributions for this project (asset base units)
66+
/// @notice Sum of contributions for this project (asset base units, lossy: floored to CONTRIBUTIONS_STEP)
3667
uint256 sumContributions;
3768
/// @notice Sum of square roots of all contributions (dimensionless)
3869
uint256 sumSquareRoots;
3970
}
4071

72+
/// @notice Packed per-project storage: 96 + 160 = 256 bits = 1 slot
73+
struct PackedProject {
74+
/// @notice Sum of contributions, quantized: stored = value >> 32
75+
uint96 sumContributions;
76+
/// @notice Sum of square roots, stored without quantization
77+
uint160 sumSquareRoots;
78+
}
79+
4180
/// @notice Main storage struct containing all mutable state for ProperQF
4281
struct ProperQFStorage {
43-
/// @notice Mapping of project IDs to project data
44-
mapping(uint256 => Project) projects;
82+
/// @notice Mapping of project IDs to packed project data (1 slot each)
83+
mapping(uint256 => PackedProject) projects;
4584
/// @notice Numerator for alpha (dimensionless; 1.0 = denominator)
4685
uint256 alphaNumerator;
4786
/// @notice Denominator for alpha (must be > 0)
@@ -78,10 +117,11 @@ abstract contract ProperQF {
78117
}
79118
}
80119

81-
/// @notice Returns project aggregated sums
120+
/// @notice Returns project aggregated sums (decoded from packed storage)
82121
/// @param projectId ID of the project to query
83122
function projects(uint256 projectId) public view returns (Project memory) {
84-
return _getProperQFStorage().projects[projectId];
123+
(uint256 sumC, uint256 sumSR) = _readProject(projectId);
124+
return Project({ sumContributions: sumC, sumSquareRoots: sumSR });
85125
}
86126

87127
/// @notice Returns alpha numerator
@@ -137,34 +177,29 @@ abstract contract ProperQF {
137177

138178
/**
139179
* @notice Process vote without validation - for trusted callers who have already validated
140-
* @dev Skips input validation for gas optimization when caller guarantees correctness
180+
* @dev Skips input validation for gas optimization when caller guarantees correctness.
181+
* Delta update pattern ensures totalLinearSum stays exact despite lossy per-project storage.
141182
* @param projectId ID of project to update
142183
* @param contribution Contribution amount (asset base units)
143184
* @param voteWeight Vote weight (dimensionless; sqrt of contribution)
144185
*/
145186
function _processVoteUnchecked(uint256 projectId, uint256 contribution, uint256 voteWeight) internal {
146187
ProperQFStorage storage s = _getProperQFStorage();
147-
Project memory project = s.projects[projectId];
188+
(uint256 oldSumContributions, uint256 oldSumSquareRoots) = _readProject(projectId);
148189

149-
uint256 newSumSquareRoots = project.sumSquareRoots + voteWeight;
150-
uint256 newSumContributions = project.sumContributions + contribution;
190+
uint256 newSumSquareRoots = oldSumSquareRoots + voteWeight;
191+
uint256 newSumContributions = oldSumContributions + contribution;
151192

152-
uint256 oldQuadraticFunding = project.sumSquareRoots * project.sumSquareRoots;
193+
uint256 oldQuadraticFunding = oldSumSquareRoots * oldSumSquareRoots;
153194
uint256 newQuadraticFunding = newSumSquareRoots * newSumSquareRoots;
154195

155196
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;
160-
161-
s.totalQuadraticSum = newTotalQuadraticSum;
162-
s.totalLinearSum = newTotalLinearSum;
197+
if (s.totalLinearSum < oldSumContributions) revert LinearSumUnderflow();
163198

164-
project.sumSquareRoots = newSumSquareRoots;
165-
project.sumContributions = newSumContributions;
199+
s.totalQuadraticSum = s.totalQuadraticSum - oldQuadraticFunding + newQuadraticFunding;
200+
s.totalLinearSum = s.totalLinearSum - oldSumContributions + newSumContributions;
166201

167-
s.projects[projectId] = project;
202+
_writeProject(projectId, newSumContributions, newSumSquareRoots);
168203

169204
s.totalFunding = _calculateWeightedTotalFunding();
170205
}
@@ -201,18 +236,39 @@ abstract contract ProperQF {
201236
returns (uint256 sumContributions, uint256 sumSquareRoots, uint256 quadraticFunding, uint256 linearFunding)
202237
{
203238
ProperQFStorage storage s = _getProperQFStorage();
204-
Project storage project = s.projects[projectId];
239+
(uint256 sumC, uint256 sumSR) = _readProject(projectId);
205240

206-
uint256 rawQuadraticFunding = project.sumSquareRoots * project.sumSquareRoots;
241+
uint256 rawQuadraticFunding = sumSR * sumSR;
207242

208243
return (
209-
project.sumContributions,
210-
project.sumSquareRoots,
244+
sumC,
245+
sumSR,
211246
(rawQuadraticFunding * s.alphaNumerator) / s.alphaDenominator,
212-
(project.sumContributions * (s.alphaDenominator - s.alphaNumerator)) / s.alphaDenominator
247+
(sumC * (s.alphaDenominator - s.alphaNumerator)) / s.alphaDenominator
213248
);
214249
}
215250

251+
// ── Pack/unpack helpers ──────────────────────────────────────────────
252+
253+
/// @notice Decode packed project storage into full-width uint256 values
254+
function _readProject(uint256 projectId) internal view returns (uint256 sumContributions, uint256 sumSquareRoots) {
255+
PackedProject storage packed = _getProperQFStorage().projects[projectId];
256+
sumContributions = uint256(packed.sumContributions) << CONTRIBUTIONS_SHIFT;
257+
sumSquareRoots = uint256(packed.sumSquareRoots);
258+
}
259+
260+
/// @notice Encode and store full-width values into packed project storage
261+
/// @dev sumContributions is right-shifted (lossy floor) then checked against uint96.
262+
/// sumSquareRoots uses a plain checked downcast to uint160.
263+
function _writeProject(uint256 projectId, uint256 sumContributions, uint256 sumSquareRoots) internal {
264+
uint256 encoded = sumContributions >> CONTRIBUTIONS_SHIFT;
265+
if (encoded > type(uint96).max) revert ContributionsOverflow(sumContributions, CONTRIBUTIONS_MAX);
266+
_getProperQFStorage().projects[projectId] = PackedProject({
267+
sumContributions: uint96(encoded),
268+
sumSquareRoots: uint160(sumSquareRoots)
269+
});
270+
}
271+
216272
/**
217273
* @notice Set alpha parameter determining ratio between quadratic and linear funding
218274
* @param newNumerator Numerator of new alpha (0 ≤ numerator ≤ denominator)

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

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,13 +1034,14 @@ contract ProperQFBranchCoverageTest is Test {
10341034

10351035
// _processVote: valid vote (success path - contributes to branch coverage of all checks passing)
10361036
function test_processVote_validVote_succeeds() public {
1037-
// contribution = 10000, sqrt(10000) = 100, voteWeight = 100
1038-
qf.exposed_processVote(1, 10000, 100);
1037+
// contribution = 10000e18, sqrt(10000e18) = 100e9, voteWeight = 100e9
1038+
qf.exposed_processVote(1, 10000e18, 100e9);
10391039

1040-
// Verify it was recorded
1040+
// Verify it was recorded (sumContributions is lossy due to quantization)
1041+
uint256 step = uint256(1) << 32;
10411042
ProperQF.Project memory project = qf.projects(1);
1042-
assertEq(project.sumContributions, 10000);
1043-
assertEq(project.sumSquareRoots, 100);
1043+
assertApproxEqAbs(project.sumContributions, 10000e18, step);
1044+
assertEq(project.sumSquareRoots, 100e9);
10441045
}
10451046

10461047
// _calculateOptimalAlpha: quadraticSum == linearSum exactly (edge case)
@@ -1098,7 +1099,7 @@ contract ProperQFBranchCoverageTest is Test {
10981099

10991100
// totalFunding getter
11001101
function test_totalFunding_afterVotes() public {
1101-
qf.exposed_processVote(1, 10000, 100);
1102+
qf.exposed_processVote(1, 10000e18, 100e9);
11021103
assertTrue(qf.totalFunding() > 0);
11031104
}
11041105
}
@@ -1990,6 +1991,7 @@ contract QVMBranchCoverageTest is Test {
19901991
uint256 constant QUORUM = 10;
19911992
uint256 constant TIMELOCK_DELAY = 1 days;
19921993
uint256 constant GRACE_PERIOD = 7 days;
1994+
uint256 constant W = 65536;
19931995

19941996
function _tam() internal view returns (TokenizedAllocationMechanism) {
19951997
return TokenizedAllocationMechanism(address(mechanism));
@@ -2009,7 +2011,7 @@ contract QVMBranchCoverageTest is Test {
20092011
symbol: "QVB",
20102012
votingDelay: VOTING_DELAY,
20112013
votingPeriod: VOTING_PERIOD,
2012-
quorumShares: QUORUM,
2014+
quorumShares: QUORUM * W * W,
20132015
timelockDelay: TIMELOCK_DELAY,
20142016
gracePeriod: GRACE_PERIOD,
20152017
owner: address(0)
@@ -2060,7 +2062,7 @@ contract QVMBranchCoverageTest is Test {
20602062

20612063
vm.warp(block.timestamp + VOTING_DELAY + 1);
20622064
vm.prank(alice);
2063-
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 20, recipient1);
2065+
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 20 * W, recipient1);
20642066

20652067
// Call calculateOptimalAlpha through the mechanism
20662068
(, uint256 den) = mechanism.calculateOptimalAlpha(50_000e18, DEPOSIT);
@@ -2082,7 +2084,7 @@ contract QVMBranchCoverageTest is Test {
20822084

20832085
vm.prank(alice);
20842086
vm.expectRevert(abi.encodeWithSelector(TokenizedAllocationMechanism.InvalidProposal.selector, 0));
2085-
_tam().castVote(0, TokenizedAllocationMechanism.VoteType.For, 10, recipient1);
2087+
_tam().castVote(0, TokenizedAllocationMechanism.VoteType.For, 10 * W, recipient1);
20862088
}
20872089

20882090
// ===== getProposalFunding: canceled proposal returns zeros =====
@@ -2094,7 +2096,7 @@ contract QVMBranchCoverageTest is Test {
20942096

20952097
vm.warp(block.timestamp + VOTING_DELAY + 1);
20962098
vm.prank(alice);
2097-
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 10, recipient1);
2099+
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 10 * W, recipient1);
20982100

20992101
// Cancel the proposal
21002102
vm.prank(alice);
@@ -2116,7 +2118,7 @@ contract QVMBranchCoverageTest is Test {
21162118

21172119
vm.warp(block.timestamp + VOTING_DELAY + 1);
21182120
vm.prank(alice);
2119-
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 20, recipient1);
2121+
_tam().castVote(pid, TokenizedAllocationMechanism.VoteType.For, 20 * W, recipient1);
21202122

21212123
(uint256 sumC, uint256 sumSq, uint256 qf, uint256 lf) = mechanism.getProposalFunding(pid);
21222124
assertTrue(sumC > 0, "Should have contributions");
@@ -3013,35 +3015,40 @@ contract ProperQFDeepBranchTest is Test {
30133015

30143016
// ===== _processVote: exact boundary - voteWeight^2 == contribution (success) =====
30153017
function test_processVote_exactSqrt_succeeds() public {
3016-
qf.exposed_processVote(1, 100, 10);
3018+
// 100e18 = (10e9)^2
3019+
qf.exposed_processVote(1, 100e18, 10e9);
30173020

3021+
uint256 step = uint256(1) << 32;
30183022
ProperQF.Project memory project = qf.projects(1);
3019-
assertEq(project.sumContributions, 100);
3020-
assertEq(project.sumSquareRoots, 10);
3023+
assertApproxEqAbs(project.sumContributions, 100e18, step);
3024+
assertEq(project.sumSquareRoots, 10e9);
30213025
}
30223026

30233027
// ===== _processVote: voteWeight at lower tolerance boundary =====
30243028
function test_processVote_voteWeightAtLowerTolerance() public {
3025-
qf.exposed_processVote(1, 10000, 90);
3029+
// sqrt(10000e18) = 100e9, tolerance = 100e9/10 = 10e9, lower = 90e9
3030+
qf.exposed_processVote(1, 10000e18, 90e9);
30263031

30273032
ProperQF.Project memory project = qf.projects(1);
3028-
assertEq(project.sumSquareRoots, 90);
3033+
assertEq(project.sumSquareRoots, 90e9);
30293034
}
30303035

30313036
// ===== _processVote: voteWeight below lower tolerance =====
30323037
function test_processVote_voteWeightBelowLowerTolerance_reverts() public {
30333038
vm.expectRevert(ProperQF.VoteWeightOutsideTolerance.selector);
3034-
qf.exposed_processVote(1, 10000, 89);
3039+
qf.exposed_processVote(1, 10000e18, 89e9);
30353040
}
30363041

30373042
// ===== _processVote: multiple votes on same project =====
30383043
function test_processVote_multipleVotesSameProject() public {
3039-
qf.exposed_processVote(1, 10000, 100);
3040-
qf.exposed_processVote(1, 40000, 200);
3044+
qf.exposed_processVote(1, 10000e18, 100e9);
3045+
qf.exposed_processVote(1, 40000e18, 200e9);
30413046

3047+
uint256 step = uint256(1) << 32;
30423048
ProperQF.Project memory project = qf.projects(1);
3043-
assertEq(project.sumContributions, 50000);
3044-
assertEq(project.sumSquareRoots, 300);
3049+
// 2 votes: tolerance = 2 * STEP
3050+
assertApproxEqAbs(project.sumContributions, 50000e18, 2 * step);
3051+
assertEq(project.sumSquareRoots, 300e9);
30453052
}
30463053
}
30473054

@@ -3359,7 +3366,7 @@ contract ProperQFUnderflowBranchTest is Test {
33593366
qf.exposed_setAlpha(50, 100);
33603367

33613368
// Process a vote - this internally calls _calculateWeightedTotalFunding
3362-
qf.exposed_processVote(1, 10000, 100);
3369+
qf.exposed_processVote(1, 10000e18, 100e9);
33633370

33643371
// totalFunding should be updated
33653372
uint256 totalFunding = qf.totalFunding();
@@ -3909,29 +3916,32 @@ contract ProperQFStorageUnderflowTest is Test {
39093916
// ===== ProperQF line 156: QuadraticSumUnderflow =====
39103917
// When totalQuadraticSum < oldQuadraticFunding (project.sumSquareRoots^2)
39113918
function test_processVoteUnchecked_quadraticSumUnderflow_reverts() public {
3919+
uint256 step = uint256(1) << 32;
39123920
// Set up a project with sumSquareRoots = 10, so oldQuadraticFunding = 100
3913-
qf.setProject(1, 100, 10);
3921+
// sumContributions must be step-aligned for exact round-trip
3922+
qf.setProject(1, 100 * step, 10);
39143923
// Set totalQuadraticSum to a value less than 100
39153924
qf.setTotalQuadraticSum(50);
3916-
qf.setTotalLinearSum(200);
3925+
qf.setTotalLinearSum(200 * step);
39173926

39183927
// Now calling _processVoteUnchecked should trigger QuadraticSumUnderflow
39193928
// because totalQuadraticSum (50) < oldQuadraticFunding (10*10 = 100)
39203929
vm.expectRevert(ProperQF.QuadraticSumUnderflow.selector);
3921-
qf.exposed_processVoteUnchecked(1, 400, 20);
3930+
qf.exposed_processVoteUnchecked(1, 400 * step, 20);
39223931
}
39233932

39243933
// ===== ProperQF line 157: LinearSumUnderflow =====
39253934
// When totalLinearSum < project.sumContributions
39263935
function test_processVoteUnchecked_linearSumUnderflow_reverts() public {
3927-
// Set up a project with sumContributions = 500, sumSquareRoots = 10
3928-
qf.setProject(1, 500, 10);
3936+
uint256 step = uint256(1) << 32;
3937+
// Set up a project with sumContributions = 500*STEP, sumSquareRoots = 10
3938+
qf.setProject(1, 500 * step, 10);
39293939
// Set totalQuadraticSum correctly (>= 10^2 = 100) but totalLinearSum too low
39303940
qf.setTotalQuadraticSum(200);
3931-
qf.setTotalLinearSum(100); // 100 < 500 = project.sumContributions
3941+
qf.setTotalLinearSum(100 * step); // 100*STEP < 500*STEP = project.sumContributions
39323942

39333943
vm.expectRevert(ProperQF.LinearSumUnderflow.selector);
3934-
qf.exposed_processVoteUnchecked(1, 400, 20);
3944+
qf.exposed_processVoteUnchecked(1, 400 * step, 20);
39353945
}
39363946

39373947
// ===== ProperQF: alphaNumerator() view function coverage =====

0 commit comments

Comments
 (0)