Skip to content

Add new nibbles(bytes), 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

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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.
29 changes: 28 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,20 @@ library Bytes {
return (value >> 8) | (value << 8);
}

/**
* @dev Counts the number of leading zeros in bits a bytes array. Returns `8 * 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) {
bytes32 chunk = _unsafeReadBytesOffset(buffer, i);
if (chunk != bytes32(0)) {
return Math.min(8 * i + Math.clz(uint256(chunk)), 8 * buffer.length);
}
}
return 8 * 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 bits in a uint256.
*/
function clz(uint256 x) internal pure returns (uint256) {
return ternary(x == 0, 256, 255 - log2(x));
}
}
41 changes: 41 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,34 @@ 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);

// index and offset of the first non zero bit
uint256 index = result / 8;
uint256 offset = result % 8;

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

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

// If index < buffer.length, byte at index position must be non-zero
if (index < buffer.length) {
// bit at position offset must be non zero
bytes1 singleBitMask = bytes1(0x80) >> offset;
assertEq(buffer[index] & singleBitMask, singleBitMask);

// all bits before offset must be zero
bytes1 multiBitsMask = bytes1(0xff) << (8 - offset);
assertEq(buffer[index] & multiBitsMask, 0);
}
}

// Helpers
function _dirtyBytes16(bytes16 value) private pure returns (bytes16 dirty) {
assembly ("memory-safe") {
Expand Down
116 changes: 116 additions & 0 deletions test/utils/Bytes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,122 @@ 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('0x')).to.eventually.equal(0);
});

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

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

it('multiple leading zeros', async function () {
await expect(this.mock.$clz('0x0000000001')).to.eventually.equal(39);
await expect(
this.mock.$clz('0x0000000000000000000000000000000000000000000000000000000000000001'),
).to.eventually.equal(255);
});

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

// Complete chunks
await expect(this.mock.$clz('0x' + '00'.repeat(32) + '01')).to.eventually.equal(263); // 32*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(64) + '01')).to.eventually.equal(519); // 64*8+7

// Partial last chunk
await expect(this.mock.$clz('0x' + '00'.repeat(33) + '01')).to.eventually.equal(271); // 33*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(34) + '01')).to.eventually.equal(279); // 34*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(40) + '01' + '00'.repeat(9))).to.eventually.equal(327); // 40*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(50))).to.eventually.equal(400); // 50*8

// First byte of each chunk non-zero
await expect(this.mock.$clz('0x80' + '00'.repeat(31))).to.eventually.equal(0);
await expect(this.mock.$clz('0x01' + '00'.repeat(31))).to.eventually.equal(7);
await expect(this.mock.$clz('0x' + '00'.repeat(32) + '80' + '00'.repeat(31))).to.eventually.equal(256); // 32*8
await expect(this.mock.$clz('0x' + '00'.repeat(32) + '01' + '00'.repeat(31))).to.eventually.equal(263); // 32*8+7

// Last byte of each chunk non-zero
await expect(this.mock.$clz('0x' + '00'.repeat(31) + '01')).to.eventually.equal(255); // 31*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(63) + '01')).to.eventually.equal(511); // 63*8+7

// Middle byte of each chunk non-zero
await expect(this.mock.$clz('0x' + '00'.repeat(16) + '01' + '00'.repeat(15))).to.eventually.equal(135); // 16*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(32) + '01' + '00'.repeat(31))).to.eventually.equal(263); // 32*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(48) + '01' + '00'.repeat(47))).to.eventually.equal(391); // 48*8+7
await expect(this.mock.$clz('0x' + '00'.repeat(64) + '01' + '00'.repeat(63))).to.eventually.equal(519); // 64*8+7
});
});

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
19 changes: 19 additions & 0 deletions test/utils/math/Math.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,25 @@ contract MathTest is Test {
}
}

function testSymbolicCountLeadingZeroes(uint256 x) public pure {
uint256 result = Math.clz(x);

if (x == 0) {
assertEq(result, 256);
} else {
// result in [0, 255]
assertLe(result, 255);

// bit at position offset must be non zero
uint256 singleBitMask = uint256(1) << (255 - result);
assertEq(x & singleBitMask, singleBitMask);

// all bits before offset must be zero
uint256 multiBitsMask = type(uint256).max << (256 - result);
assertEq(x & multiBitsMask, 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(256);
});

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

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

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(247);
await expect(
this.mock.$clz('0x0000000000000000000000000000000000000000000000000000000000010000'),
).to.eventually.equal(239);
await expect(
this.mock.$clz('0x0000000000000000000000000000000000000000000000000000000001000000'),
).to.eventually.equal(231);
});
});
});