Skip to content

Commit 3b82e83

Browse files
committed
feat: make decode/decodeMax checked by default, add unchecked variants
decode() and decodeMax() now revert with Overflow when encoded is out of range, matching encode()'s safe-by-default behavior. Added decodeUnchecked() and decodeMaxUnchecked() for gas-optimized paths where encoded values are known valid (e.g. from encode()). Showcase contracts switched to unchecked variants since their values always come from encode(). Also: strengthened Quant NatSpec warning against Quant.wrap(), added lossy-deposit warning to README StakingVault example, consolidated empty CHANGELOG entries from pipeline iteration.
1 parent 1d7bce5 commit 3b82e83

File tree

5 files changed

+74
-26
lines changed

5 files changed

+74
-26
lines changed

CHANGELOG.md

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,9 @@
66

77
* add isValid, fitsEncoded helpers and fix ceil overflow ([903287c](https://github.com/0xferit/uint-quantization-lib/commit/903287c19bd7b97b4d8f096a1dce5479638805f5))
88

9-
## [6.0.3](https://github.com/0xferit/uint-quantization-lib/compare/v6.0.2...v6.0.3) (2026-03-13)
9+
## 1.0.1 through 6.0.3 (2026-03-13)
1010

11-
## [6.0.2](https://github.com/0xferit/uint-quantization-lib/compare/v6.0.1...v6.0.2) (2026-03-13)
12-
13-
## [6.0.1](https://github.com/0xferit/uint-quantization-lib/compare/v6.0.0...v6.0.1) (2026-03-13)
14-
15-
## [6.0.0](https://github.com/0xferit/uint-quantization-lib/compare/v5.0.0...v6.0.0) (2026-03-13)
16-
17-
## [5.0.0](https://github.com/0xferit/uint-quantization-lib/compare/v4.0.0...v5.0.0) (2026-03-13)
18-
19-
## [4.0.0](https://github.com/0xferit/uint-quantization-lib/compare/v3.0.0...v4.0.0) (2026-03-13)
20-
21-
## [3.0.0](https://github.com/0xferit/uint-quantization-lib/compare/v2.0.0...v3.0.0) (2026-03-13)
22-
23-
## [2.0.0](https://github.com/0xferit/uint-quantization-lib/compare/v1.0.1...v2.0.0) (2026-03-13)
24-
25-
## [1.0.1](https://github.com/0xferit/uint-quantization-lib/compare/v1.0.0...v1.0.1) (2026-03-13)
11+
Versions 1.0.1 through 6.0.3 were created by release pipeline iteration during initial CI setup. The library was renamed from `shift`/`targetBits` to `discardedBitWidth`/`encodedBitWidth` (breaking API change, hence the major bumps). No functional changes between these versions beyond the rename and CI fixes. See [v1.0.0...v6.0.3](https://github.com/0xferit/uint-quantization-lib/compare/v1.0.0...v6.0.3) for the full diff.
2612

2713
## 1.0.0 (2026-03-13)
2814

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ The `Quant` value type is a `uint16` with the following bit layout:
4848
| `UintQuantizationLib.create(discardedBitWidth, encodedBitWidth)` | Creates a `Quant` scheme. Reverts with `BadConfig` on invalid parameters. |
4949
| `q.discardedBitWidth()` | Number of low bits discarded during encoding (set at creation). |
5050
| `q.encodedBitWidth()` | Bit-width of the encoded value (set at creation). |
51-
| `q.encode(value)` | Compresses `value` by discarding the low bits (floor). Reverts with `Overflow` if `value > max(q)`. |
51+
| `q.encode(value)` | Quantizes `value` by discarding the low bits (floor). Reverts with `Overflow` if `value > max(q)`. |
5252
| `q.encode(value, true)` | Same as `encode(value)`, but also reverts with `NotAligned` if `value` is not step-aligned. |
53-
| `q.decode(encoded)` | Restores `encoded` back to the original scale. Discarded bits are restored as zeros (lower bound). |
54-
| `q.decodeMax(encoded)` | Like `decode`, but fills discarded bits with ones (upper bound within the step). |
53+
| `q.decode(encoded)` | Restores `encoded` back to the original scale (lower bound). Reverts with `Overflow` if `encoded` is out of range. |
54+
| `q.decodeMax(encoded)` | Like `decode`, but fills discarded bits with ones (upper bound). Reverts with `Overflow` if out of range. |
55+
| `q.decodeUnchecked(encoded)` | Gas-optimized `decode` without bounds check. Use when `encoded` is known valid (e.g., from `encode`). |
56+
| `q.decodeMaxUnchecked(encoded)` | Gas-optimized `decodeMax` without bounds check. |
5557
| `q.isValid()` | True if `q` satisfies the invariants enforced by `create`. Use to validate hand-wrapped `Quant` values. |
5658
| `q.fits(value)` | True if `value` fits within the scheme's representable range. |
5759
| `q.fitsEncoded(encoded)` | True if `encoded` is within the valid range for decoding (`encoded < 2^encodedBitWidth`). |
@@ -82,6 +84,8 @@ contract StakingVault {
8284
mapping(address => uint96) internal stakes;
8385
8486
/// Floor-encodes msg.value and stores the quantized amount.
87+
/// Lossy: the remainder (msg.value mod stepSize) is not tracked.
88+
/// Use `stakeExact` for lossless deposits.
8589
function stake() external payable {
8690
require(SCHEME.fits(msg.value), "amount exceeds scheme max");
8791
stakes[msg.sender] = uint96(SCHEME.encode(msg.value));

src/UintQuantizationLib.sol

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ pragma solidity ^0.8.25;
2222
* stored = uint24(SCHEME.encode(value));
2323
* restored = SCHEME.decode(stored);
2424
* ```
25+
*
26+
* **Important:** Always construct `Quant` values via `create()`. Using `Quant.wrap()`
27+
* directly bypasses validation and produces undefined behavior in all library functions.
28+
* Use `isValid()` to check a `Quant` of unknown origin.
2529
*/
2630
type Quant is uint16;
2731

@@ -116,18 +120,36 @@ library UintQuantizationLib {
116120
// -------------------------------------------------------------------------
117121

118122
/// @notice Left-shifts `encoded` by discardedBitWidth, restoring discarded bits as zeros (lower bound).
119-
/// @dev The caller must ensure `encoded < 2**encodedBitWidth(q)`. Passing a larger value
120-
/// produces a result that may silently wrap or exceed the scheme's representable range.
121-
/// Values returned by `encode` always satisfy this constraint.
123+
/// Reverts with `Overflow` when `encoded >= 2**encodedBitWidth(q)`.
122124
function decode(Quant q, uint256 encoded) internal pure returns (uint256) {
125+
uint256 e = encodedBitWidth(q);
126+
if (encoded >= (uint256(1) << e)) revert Overflow(encoded, (uint256(1) << e) - 1);
123127
unchecked {
124128
return encoded << discardedBitWidth(q);
125129
}
126130
}
127131

128132
/// @notice Like `decode` but fills the discarded bits with ones (upper bound within the step).
129-
/// @dev Same precondition as `decode`: `encoded` must be less than `2**encodedBitWidth(q)`.
133+
/// Reverts with `Overflow` when `encoded >= 2**encodedBitWidth(q)`.
130134
function decodeMax(Quant q, uint256 encoded) internal pure returns (uint256) {
135+
uint256 e = encodedBitWidth(q);
136+
if (encoded >= (uint256(1) << e)) revert Overflow(encoded, (uint256(1) << e) - 1);
137+
unchecked {
138+
uint256 s = discardedBitWidth(q);
139+
return (encoded << s) | ((uint256(1) << s) - 1);
140+
}
141+
}
142+
143+
/// @notice Unchecked `decode`: no bounds check on `encoded`. Use when `encoded` is known to be
144+
/// valid (e.g., returned by `encode`). Passing an out-of-range value wraps silently.
145+
function decodeUnchecked(Quant q, uint256 encoded) internal pure returns (uint256) {
146+
unchecked {
147+
return encoded << discardedBitWidth(q);
148+
}
149+
}
150+
151+
/// @notice Unchecked `decodeMax`: no bounds check on `encoded`. Same precondition as `decodeUnchecked`.
152+
function decodeMaxUnchecked(Quant q, uint256 encoded) internal pure returns (uint256) {
131153
unchecked {
132154
uint256 s = discardedBitWidth(q);
133155
return (encoded << s) | ((uint256(1) << s) - 1);

src/showcase/ShowcaseSolidityFixtures.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ contract QuantizedETHStakingShowcase {
9595
UserStake memory s = stakes[msg.sender];
9696
if (!s.active) revert QuantizedETHStakingShowcase__NoStake();
9797

98-
uint256 amount = SCHEME.decode(s.amount);
98+
uint256 amount = SCHEME.decodeUnchecked(s.amount);
9999
delete stakes[msg.sender];
100100

101101
(bool ok,) = msg.sender.call{value: amount}("");
@@ -112,7 +112,7 @@ contract QuantizedETHStakingShowcase {
112112
}
113113

114114
function getStake(address user) external view returns (uint256) {
115-
return SCHEME.decode(stakes[user].amount);
115+
return SCHEME.decodeUnchecked(stakes[user].amount);
116116
}
117117

118118
function maxDeposit() external view returns (uint256) {
@@ -183,7 +183,7 @@ contract QuantizedExtremePackingShowcase {
183183
function decodeExtremeFloor() external view returns (uint256[12] memory values) {
184184
uint256 p = packedExtreme;
185185
for (uint256 i; i < LANES; ++i) {
186-
values[i] = SCHEME.decode((p >> (i * 20)) & LANE_MASK);
186+
values[i] = SCHEME.decodeUnchecked((p >> (i * 20)) & LANE_MASK);
187187
}
188188
}
189189
}

test/UintQuantizationLib.t.sol

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ contract QuantHarness {
4343
return q.decodeMax(encoded);
4444
}
4545

46+
function decodeUnchecked(Quant q, uint256 encoded) external pure returns (uint256) {
47+
return q.decodeUnchecked(encoded);
48+
}
49+
50+
function decodeMaxUnchecked(Quant q, uint256 encoded) external pure returns (uint256) {
51+
return q.decodeMaxUnchecked(encoded);
52+
}
53+
4654
function remainder(Quant q, uint256 value) external pure returns (uint256) {
4755
return q.remainder(value);
4856
}
@@ -320,6 +328,34 @@ contract UintQuantizationLibSmokeTest is Test {
320328
assertFalse(harness.fitsEncoded(q, 256));
321329
}
322330

331+
// -------------------------------------------------------------------------
332+
// decode: checked revert on oversized encoded
333+
// -------------------------------------------------------------------------
334+
335+
function test_decode_oversized_reverts() public {
336+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
337+
// encodedBitWidth=8, so max encoded = 255; 256 is out of range
338+
vm.expectRevert(abi.encodeWithSelector(Overflow.selector, uint256(256), uint256(255)));
339+
harness.decode(q, 256);
340+
}
341+
342+
function test_decodeMax_oversized_reverts() public {
343+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
344+
vm.expectRevert(abi.encodeWithSelector(Overflow.selector, uint256(256), uint256(255)));
345+
harness.decodeMax(q, 256);
346+
}
347+
348+
function test_decodeUnchecked_valid() public view {
349+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
350+
// Same result as checked decode for valid input
351+
assertEq(harness.decodeUnchecked(q, 3), harness.decode(q, 3));
352+
}
353+
354+
function test_decodeMaxUnchecked_valid() public view {
355+
Quant q = harness.create(DISCARDED_8, ENCODED_8);
356+
assertEq(harness.decodeMaxUnchecked(q, 3), harness.decodeMax(q, 3));
357+
}
358+
323359
// -------------------------------------------------------------------------
324360
// ceil: overflow revert
325361
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)