Skip to content

Add new clz(bytes) and clz(uint256) functions #5725

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 22 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-hats-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`Bytes`: Add a `nibbles` function to split each byte into two nibbles.
5 changes: 5 additions & 0 deletions .changeset/whole-cats-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`Bytes`: Add a `clz` function to count the leading zero bytes in a `uint256` value.
41 changes: 40 additions & 1 deletion contracts/utils/Bytes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ library Bytes {
return result;
}

/// @dev Split each byte in `value` into two nibbles (4 bits each).
function nibbles(bytes memory value) internal pure returns (bytes memory) {
uint256 length = value.length;
bytes memory nibbles_ = new bytes(length * 2);
for (uint256 i = 0; i < length; i++) {
unchecked {
// Bounded to the array length, can't overflow realistically
(nibbles_[i * 2], nibbles_[i * 2 + 1]) = (value[i] & 0xf0, value[i] & 0x0f);
}
}
return nibbles_;
}

/**
* @dev Moves the content of `buffer`, from `start` (included) to the end of `buffer` to the start of that buffer.
*
Expand Down Expand Up @@ -128,7 +141,7 @@ library Bytes {
return buffer;
}

/*
/**
* @dev Returns true if the two byte buffers are equal.
*/
function equal(bytes memory a, bytes memory b) internal pure returns (bool) {
Expand Down Expand Up @@ -187,6 +200,32 @@ library Bytes {
return (value >> 8) | (value << 8);
}

/**
* @dev Counts the number of leading zeros in a bytes array. Returns `buffer.length`
* if the buffer is all zeros.
*/
function clz(bytes memory buffer) internal pure returns (uint256) {
for (uint256 i = 0; i < buffer.length; i += 32) {
uint256 value = uint256(_unsafeReadBytesOffset(buffer, i));
unchecked {
// Mask out bytes beyond buffer length
// left is buffer.length at most, can't overflow at realistic size
uint256 left = buffer.length - i;
if (left < 32) {
// left is less than 32, can't overflow
uint256 shift = (32 - left) * 8;
value = (value >> shift) << shift; // Clear the lower bits for the last iteration
}
}
if (value != 0) {
uint256 leadingZeros = Math.clz(value);
return Math.min(i + leadingZeros, buffer.length);
}
}

return buffer.length;
}

/**
* @dev Reads a bytes32 from a bytes array without bounds checking.
*
Expand Down
7 changes: 7 additions & 0 deletions contracts/utils/math/Math.sol
Original file line number Diff line number Diff line change
Expand Up @@ -746,4 +746,11 @@ library Math {
function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) {
return uint8(rounding) % 2 == 1;
}

/**
* @dev Counts the number of leading zeros in a uint256.
*/
function clz(uint256 x) internal pure returns (uint256) {
return ternary(x == 0, 32, 31 - log256(x));
}
}
54 changes: 54 additions & 0 deletions test/utils/Bytes.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,19 @@ contract BytesTest is Test {
}
}

function testNibbles(bytes memory value) public pure {
bytes memory result = Bytes.nibbles(value);
assertEq(result.length, value.length * 2);
for (uint256 i = 0; i < value.length; i++) {
bytes1 originalByte = value[i];
bytes1 highNibble = result[i * 2];
bytes1 lowNibble = result[i * 2 + 1];

assertEq(highNibble, originalByte & 0xf0);
assertEq(lowNibble, originalByte & 0x0f);
}
}

// REVERSE BITS
function testSymbolicReverseBytes32(bytes32 value) public pure {
assertEq(Bytes.reverseBytes32(Bytes.reverseBytes32(value)), value);
Expand Down Expand Up @@ -196,6 +209,47 @@ contract BytesTest is Test {
assertEq(Bytes.reverseBytes2(_dirtyBytes2(Bytes.reverseBytes2(value))), value);
}

// CLZ (Count Leading Zeros)
function testClz(bytes memory buffer) public pure {
uint256 result = Bytes.clz(buffer);

// Result should never exceed buffer length
assertLe(result, buffer.length);

if (buffer.length == 0) {
// Empty buffer should return 0
assertEq(result, 0);
} else if (result == buffer.length) {
// If result equals buffer length, all bytes must be zero
for (uint256 i = 0; i < buffer.length; ++i) {
assertEq(buffer[i], 0);
}
} else {
// If result < buffer.length, byte at result position must be non-zero
assertNotEq(buffer[result], 0);

// All bytes before result position must be zero
for (uint256 i = 0; i < result; ++i) {
assertEq(buffer[i], 0);
}
}
}

function testClzConsistentWithByteByByteCount(bytes memory buffer) public pure {
uint256 result = Bytes.clz(buffer);

// Manually count leading zeros byte by byte
uint256 manualCount = 0;
for (uint256 i = 0; i < buffer.length; ++i) {
if (buffer[i] != 0) {
break;
}
manualCount++;
}

assertEq(result, manualCount);
}

// Helpers
function _dirtyBytes16(bytes16 value) private pure returns (bytes16 dirty) {
assembly ("memory-safe") {
Expand Down
114 changes: 114 additions & 0 deletions test/utils/Bytes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,120 @@ describe('Bytes', function () {
});
});

describe('nibbles', function () {
it('converts single byte', async function () {
await expect(this.mock.$nibbles('0xab')).to.eventually.equal('0xa00b');
});

it('converts multiple bytes', async function () {
await expect(this.mock.$nibbles('0x1234')).to.eventually.equal('0x10023004');
});

it('handles empty bytes', async function () {
await expect(this.mock.$nibbles('0x')).to.eventually.equal('0x');
});

it('converts lorem text', async function () {
const result = await this.mock.$nibbles(lorem);
expect(ethers.dataLength(result)).to.equal(lorem.length * 2);

// Check nibble extraction for first few bytes
for (let i = 0; i < Math.min(lorem.length, 5); i++) {
const originalByte = lorem[i];
const highNibble = ethers.dataSlice(result, i * 2, i * 2 + 1);
const lowNibble = ethers.dataSlice(result, i * 2 + 1, i * 2 + 2);

expect(highNibble).to.equal(ethers.toBeHex(originalByte & 0xf0, 1));
expect(lowNibble).to.equal(ethers.toBeHex(originalByte & 0x0f, 1));
}
});
});

describe('clz bytes', function () {
it('empty buffer', async function () {
await expect(this.mock['$clz(bytes)']('0x')).to.eventually.equal(0);
});

it('single zero byte', async function () {
await expect(this.mock['$clz(bytes)']('0x00')).to.eventually.equal(1);
});

it('single non-zero byte', async function () {
await expect(this.mock['$clz(bytes)']('0x01')).to.eventually.equal(0);
await expect(this.mock['$clz(bytes)']('0xff')).to.eventually.equal(0);
});

it('multiple leading zeros', async function () {
await expect(this.mock['$clz(bytes)']('0x0000000001')).to.eventually.equal(4);
await expect(
this.mock['$clz(bytes)']('0x0000000000000000000000000000000000000000000000000000000000000001'),
).to.eventually.equal(31);
});

it('all zeros of various lengths', async function () {
await expect(this.mock['$clz(bytes)']('0x00000000')).to.eventually.equal(4);
await expect(
this.mock['$clz(bytes)']('0x0000000000000000000000000000000000000000000000000000000000000000'),
).to.eventually.equal(32);

// Complete chunks
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(32) + '01')).to.eventually.equal(32);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(64) + '01')).to.eventually.equal(64);

// Partial last chunk
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(33) + '01')).to.eventually.equal(33);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(34) + '01')).to.eventually.equal(34);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(40) + '01' + '00'.repeat(9))).to.eventually.equal(40);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(50))).to.eventually.equal(50);

// First byte of each chunk non-zero
await expect(this.mock['$clz(bytes)']('0x01' + '00'.repeat(31))).to.eventually.equal(0);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(32) + '01' + '00'.repeat(31))).to.eventually.equal(32);

// Last byte of each chunk non-zero
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(31) + '01')).to.eventually.equal(31);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(63) + '01')).to.eventually.equal(63);

// Middle byte of each chunk non-zero
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(16) + '01' + '00'.repeat(15))).to.eventually.equal(16);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(32) + '01' + '00'.repeat(31))).to.eventually.equal(32);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(48) + '01' + '00'.repeat(47))).to.eventually.equal(48);
await expect(this.mock['$clz(bytes)']('0x' + '00'.repeat(64) + '01' + '00'.repeat(63))).to.eventually.equal(64);
});
});

describe('equal', function () {
it('identical buffers', async function () {
await expect(this.mock.$equal(lorem, lorem)).to.eventually.be.true;
});

it('same content', async function () {
const copy = new Uint8Array(lorem);
await expect(this.mock.$equal(lorem, copy)).to.eventually.be.true;
});

it('different content', async function () {
const different = ethers.toUtf8Bytes('Different content');
await expect(this.mock.$equal(lorem, different)).to.eventually.be.false;
});

it('different lengths', async function () {
const shorter = lorem.slice(0, 10);
await expect(this.mock.$equal(lorem, shorter)).to.eventually.be.false;
});

it('empty buffers', async function () {
const empty1 = new Uint8Array(0);
const empty2 = new Uint8Array(0);
await expect(this.mock.$equal(empty1, empty2)).to.eventually.be.true;
});

it('one empty one not', async function () {
const empty = new Uint8Array(0);
await expect(this.mock.$equal(lorem, empty)).to.eventually.be.false;
});
});

describe('reverseBits', function () {
describe('reverseBytes32', function () {
it('reverses bytes correctly', async function () {
Expand Down
16 changes: 16 additions & 0 deletions test/utils/math/Math.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,22 @@ contract MathTest is Test {
}
}

function testSymbolicCountLeadingZeroes(uint256 x) public pure {
uint256 result = Math.clz(x);
assertLe(result, 32); // [0, 32]

if (x != 0) {
uint256 firstNonZeroBytePos = 32 - result - 1;
uint256 byteValue = (x >> (firstNonZeroBytePos * 8)) & 0xff;
assertNotEq(byteValue, 0);

// x != 0 implies result < 32
// most significant byte should be non-zero
uint256 msbValue = (x >> (248 - result * 8)) & 0xff;
assertNotEq(msbValue, 0);
}
}

// Helpers
function _asRounding(uint8 r) private pure returns (Math.Rounding) {
vm.assume(r < uint8(type(Math.Rounding).max));
Expand Down
33 changes: 33 additions & 0 deletions test/utils/math/Math.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -710,4 +710,37 @@ describe('Math', function () {
});
});
});

describe('clz', function () {
it('zero value', async function () {
await expect(this.mock.$clz(0)).to.eventually.equal(32);
});

it('small values', async function () {
await expect(this.mock.$clz(1)).to.eventually.equal(31);
await expect(this.mock.$clz(255)).to.eventually.equal(31);
});

it('larger values', async function () {
await expect(this.mock.$clz(256)).to.eventually.equal(30);
await expect(this.mock.$clz(0xff00)).to.eventually.equal(30);
await expect(this.mock.$clz(0x10000)).to.eventually.equal(29);
});

it('max value', async function () {
await expect(this.mock.$clz(ethers.MaxUint256)).to.eventually.equal(0);
});

it('specific patterns', async function () {
await expect(
this.mock.$clz('0x0000000000000000000000000000000000000000000000000000000000000100'),
).to.eventually.equal(30);
await expect(
this.mock.$clz('0x0000000000000000000000000000000000000000000000000000000000010000'),
).to.eventually.equal(29);
await expect(
this.mock.$clz('0x0000000000000000000000000000000000000000000000000000000001000000'),
).to.eventually.equal(28);
});
});
});