Skip to content

Commit 903287c

Browse files
committed
feat: add isValid, fitsEncoded helpers and fix ceil overflow
Address audit findings: 1. Add isValid(q) to validate hand-wrapped Quant values against create() invariants (forgeable Quant via Quant.wrap). 2. Add fitsEncoded(q, encoded) as the decode-side counterpart of fits(), letting callers check encoded values before decoding. 3. Add CeilOverflow error and revert in ceil() when rounding up would wrap past type(uint256).max (was a silent overflow). 4. Label showcase stake() as intentionally lossy in NatSpec and README (floor-encoded dust stays in contract). 5. Bound fuzz test for decodeMax to valid encoded range. Update ceil fuzz test to exercise the overflow revert path instead of assuming it away.
1 parent 55be97e commit 903287c

File tree

4 files changed

+94
-12
lines changed

4 files changed

+94
-12
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ The `Quant` value type is a `uint16` with the following bit layout:
5252
| `q.encode(value, true)` | Same as `encode(value)`, but also reverts with `NotAligned` if `value` is not step-aligned. |
5353
| `q.decode(encoded)` | Decompresses `encoded` back to the original scale. Discarded bits are restored as zeros (lower bound). |
5454
| `q.decodeMax(encoded)` | Like `decode`, but fills discarded bits with ones (upper bound within the step). |
55+
| `q.isValid()` | True if `q` satisfies the invariants enforced by `create`. Use to validate hand-wrapped `Quant` values. |
5556
| `q.fits(value)` | True if `value` fits within the scheme's representable range. |
57+
| `q.fitsEncoded(encoded)` | True if `encoded` is within the valid range for decoding (`encoded < 2^encodedBitWidth`). |
5658
| `q.floor(value)` | Rounds `value` down to the nearest step boundary. |
57-
| `q.ceil(value)` | Rounds `value` up to the nearest step boundary. |
59+
| `q.ceil(value)` | Rounds `value` up to the nearest step boundary. Reverts with `CeilOverflow` when rounding up would exceed `type(uint256).max`. |
5860
| `q.remainder(value)` | Resolution lost if `value` were floor-encoded (`value mod stepSize`). |
5961
| `q.isAligned(value)` | True if `value` is step-aligned (no resolution loss on encode). |
6062
| `q.stepSize()` | Smallest non-zero value the scheme can represent (`2^discardedBitWidth`). |
@@ -66,6 +68,7 @@ The `Quant` value type is a `uint16` with the following bit layout:
6668
error BadConfig(uint256 discardedBitWidth, uint256 encodedBitWidth);
6769
error Overflow(uint256 value, uint256 max);
6870
error NotAligned(uint256 value, uint256 stepSize);
71+
error CeilOverflow(uint256 value);
6972
```
7073

7174
### Solidity usage
@@ -161,8 +164,8 @@ This demonstrates where quantization creates real gas savings: fewer storage wri
161164
state layout.
162165

163166
The staking showcase intentionally exercises the full API surface:
164-
- `stake()` uses `encode`.
165-
- `stakeExact()` uses `encode(value, true)`.
167+
- `stake()` uses floor encoding (`encode`). This is intentionally lossy: the remainder stays in the contract as unrecoverable dust.
168+
- `stakeExact()` uses strict encoding (`encode(value, true)`). Reverts if the value is not step-aligned, guaranteeing lossless round-trips.
166169
- `unstake()` uses `decode`.
167170
- `maxDeposit()`, `stakeRemainder()`, and `isStakeAligned()` expose
168171
`max`, `remainder`, and `isAligned` for frontend UX.

src/UintQuantizationLib.sol

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ error NotAligned(uint256 value, uint256 stepSize);
3434
/// @notice Thrown by `create` when the (discardedBitWidth, encodedBitWidth) pair is invalid.
3535
error BadConfig(uint256 discardedBitWidth, uint256 encodedBitWidth);
3636

37+
/// @notice Thrown by `ceil` when rounding up would overflow uint256.
38+
error CeilOverflow(uint256 value);
39+
3740
library UintQuantizationLib {
3841
string internal constant VERSION = "6.0.3";
3942

@@ -145,26 +148,43 @@ library UintQuantizationLib {
145148
return remainder(q, value) == 0;
146149
}
147150

151+
/// @notice Returns true when `q` satisfies the invariants enforced by `create`.
152+
/// Use this to validate a `Quant` obtained via `Quant.wrap()` rather than `create()`.
153+
function isValid(Quant q) internal pure returns (bool) {
154+
uint256 d = discardedBitWidth(q);
155+
uint256 e = encodedBitWidth(q);
156+
return e > 0 && e < 256 && d + e <= 256;
157+
}
158+
148159
/// @notice Returns true when `value <= max(q)`.
149160
function fits(Quant q, uint256 value) internal pure returns (bool) {
150161
return value <= max(q);
151162
}
152163

164+
/// @notice Returns true when `encoded` is within the valid range for decoding.
165+
/// This is the decode-side counterpart of `fits()`.
166+
function fitsEncoded(Quant q, uint256 encoded) internal pure returns (bool) {
167+
return encoded < (uint256(1) << encodedBitWidth(q));
168+
}
169+
153170
/// @notice Rounds `value` down to the nearest step boundary (clears low `discardedBitWidth` bits).
154171
function floor(Quant q, uint256 value) internal pure returns (uint256) {
155172
return value & ~((uint256(1) << discardedBitWidth(q)) - 1);
156173
}
157174

158175
/// @notice Rounds `value` up to the nearest step boundary. Returns `value` unchanged when
159-
/// discardedBitWidth is 0 or `value` is already aligned.
160-
/// @dev Callers must ensure `value + stepSize - 1 <= type(uint256).max` to avoid overflow
161-
/// on non-aligned inputs. This function does not perform that check.
176+
/// discardedBitWidth is 0 or `value` is already aligned. Reverts with `CeilOverflow`
177+
/// when rounding up would exceed `type(uint256).max`.
162178
function ceil(Quant q, uint256 value) internal pure returns (uint256) {
163179
uint256 s = discardedBitWidth(q);
164180
if (s == 0) return value;
165181
uint256 mask = (uint256(1) << s) - 1;
166182
if (value & mask == 0) return value;
167-
return (value | mask) + 1;
183+
unchecked {
184+
uint256 ceiled = (value | mask) + 1;
185+
if (ceiled == 0) revert CeilOverflow(value);
186+
return ceiled;
187+
}
168188
}
169189
}
170190

src/showcase/ShowcaseSolidityFixtures.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ contract QuantizedETHStakingShowcase {
6767
SCHEME = UintQuantizationLib.create(16, 96);
6868
}
6969

70+
/// @notice Floor-encodes msg.value. Intentionally lossy: the remainder (msg.value mod stepSize)
71+
/// stays in the contract as unrecoverable dust. Use `stakeExact` for lossless deposits.
7072
function stake() external payable {
7173
if (msg.value == 0) revert QuantizedETHStakingShowcase__ZeroAmount();
7274
uint96 encoded = uint96(SCHEME.encode(msg.value));

test/UintQuantizationLib.t.sol

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
pragma solidity ^0.8.25;
33

44
import {Test} from "forge-std/Test.sol";
5-
import {Quant, UintQuantizationLib, Overflow, NotAligned, BadConfig} from "src/UintQuantizationLib.sol";
5+
import {Quant, UintQuantizationLib, Overflow, NotAligned, BadConfig, CeilOverflow} from "src/UintQuantizationLib.sol";
66

77
/// @notice Thin harness that exposes library functions via `using-for` so tests call them on
88
/// `Quant` values rather than through the library name directly.
@@ -51,10 +51,18 @@ contract QuantHarness {
5151
return q.isAligned(value);
5252
}
5353

54+
function isValid(Quant q) external pure returns (bool) {
55+
return q.isValid();
56+
}
57+
5458
function fits(Quant q, uint256 value) external pure returns (bool) {
5559
return q.fits(value);
5660
}
5761

62+
function fitsEncoded(Quant q, uint256 encoded) external pure returns (bool) {
63+
return q.fitsEncoded(encoded);
64+
}
65+
5866
function floor(Quant q, uint256 value) external pure returns (uint256) {
5967
return q.floor(value);
6068
}
@@ -201,7 +209,7 @@ contract UintQuantizationLibSmokeTest is Test {
201209
}
202210

203211
// -------------------------------------------------------------------------
204-
// Boundary: shift == 0 (identity / no compression)
212+
// Boundary: discardedBitWidth == 0 (identity / no compression)
205213
// -------------------------------------------------------------------------
206214

207215
function test_discardedBitWidth_zero_identity() public view {
@@ -280,6 +288,49 @@ contract UintQuantizationLibSmokeTest is Test {
280288
harness.encode(q, 2);
281289
}
282290

291+
// -------------------------------------------------------------------------
292+
// isValid: create-produced vs hand-wrapped
293+
// -------------------------------------------------------------------------
294+
295+
function test_isValid_createProduced() public view {
296+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
297+
assertTrue(harness.isValid(q));
298+
}
299+
300+
function test_isValid_handWrapped_invalid() public view {
301+
// encodedBitWidth=0 (invalid: rejected by create)
302+
assertFalse(harness.isValid(Quant.wrap(0)));
303+
// discardedBitWidth=255, encodedBitWidth=255: sum=510 > 256
304+
assertFalse(harness.isValid(Quant.wrap(uint16(0xFF00 | 0xFF))));
305+
}
306+
307+
// -------------------------------------------------------------------------
308+
// fitsEncoded: decode-side range check
309+
// -------------------------------------------------------------------------
310+
311+
function test_fitsEncoded_true() public view {
312+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
313+
// encodedBitWidth=8, so max encoded = 255
314+
assertTrue(harness.fitsEncoded(q, 0));
315+
assertTrue(harness.fitsEncoded(q, 255));
316+
}
317+
318+
function test_fitsEncoded_false() public view {
319+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
320+
assertFalse(harness.fitsEncoded(q, 256));
321+
}
322+
323+
// -------------------------------------------------------------------------
324+
// ceil: overflow revert
325+
// -------------------------------------------------------------------------
326+
327+
function test_ceil_overflow_reverts() public {
328+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
329+
// type(uint256).max is not aligned to 256; rounding up overflows
330+
vm.expectRevert(abi.encodeWithSelector(CeilOverflow.selector, type(uint256).max));
331+
harness.ceil(q, type(uint256).max);
332+
}
333+
283334
// -------------------------------------------------------------------------
284335
// Fuzz tests
285336
// -------------------------------------------------------------------------
@@ -303,6 +354,8 @@ contract UintQuantizationLibSmokeTest is Test {
303354
function testFuzz_decodeMax_ge_decode(uint8 discardedBitWidth_, uint8 encodedBitWidth_, uint256 encoded) public view {
304355
vm.assume(encodedBitWidth_ > 0 && uint256(discardedBitWidth_) + uint256(encodedBitWidth_) <= 256);
305356
Quant q = UintQuantizationLib.create(uint256(discardedBitWidth_), uint256(encodedBitWidth_));
357+
// Bound to valid encoded range so the test exercises the documented domain.
358+
encoded = bound(encoded, 0, (uint256(1) << harness.encodedBitWidth(q)) - 1);
306359
assertGe(harness.decodeMax(q, encoded), harness.decode(q, encoded));
307360
}
308361

@@ -324,14 +377,18 @@ contract UintQuantizationLibSmokeTest is Test {
324377
assertEq(harness.fits(q, value), value <= harness.max(q));
325378
}
326379

327-
function testFuzz_ceil_ge_value(uint8 discardedBitWidth_, uint8 encodedBitWidth_, uint256 value) public view {
380+
function testFuzz_ceil_ge_value(uint8 discardedBitWidth_, uint8 encodedBitWidth_, uint256 value) public {
328381
vm.assume(encodedBitWidth_ > 0 && uint256(discardedBitWidth_) + uint256(encodedBitWidth_) <= 256);
329382
Quant q = UintQuantizationLib.create(uint256(discardedBitWidth_), uint256(encodedBitWidth_));
330383
uint256 s = uint256(discardedBitWidth_);
331384
if (s > 0) {
332385
uint256 mask = (uint256(1) << s) - 1;
333-
// Exclude values where (value | mask) + 1 would overflow uint256
334-
vm.assume(value < type(uint256).max - mask);
386+
if (value >= type(uint256).max - mask && value & mask != 0) {
387+
// Overflow region: ceil should revert
388+
vm.expectRevert(abi.encodeWithSelector(CeilOverflow.selector, value));
389+
harness.ceil(q, value);
390+
return;
391+
}
335392
}
336393
assertGe(harness.ceil(q, value), value);
337394
}

0 commit comments

Comments
 (0)