22pragma solidity ^ 0.8.0 ;
33
44import { 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 */
1443abstract 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)
0 commit comments