diff --git a/.changeset/old-memes-dress.md b/.changeset/old-memes-dress.md new file mode 100644 index 00000000000..b022bdf7bfc --- /dev/null +++ b/.changeset/old-memes-dress.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`Bytes`: Add `concat` that merges a `bytes[]` array of buffers into a single `bytes` buffer. diff --git a/contracts/utils/Bytes.sol b/contracts/utils/Bytes.sol index 2fabf4872db..a92d81f0aed 100644 --- a/contracts/utils/Bytes.sol +++ b/contracts/utils/Bytes.sol @@ -128,6 +128,37 @@ library Bytes { return buffer; } + /** + * @dev Concatenate an array of bytes into a single bytes object. + * + * For fixed bytes types, we recommend using the solidity built-in `bytes.concat` or (equivalent) + * `abi.encodePacked`. + * + * NOTE: this could be done in assembly with a single loop that expands starting at the FMP, but that would be + * significantly less readable. It might be worth benchmarking the savings of the full-assembly approach. + */ + function concat(bytes[] memory buffers) internal pure returns (bytes memory) { + uint256 length = 0; + for (uint256 i = 0; i < buffers.length; ++i) { + length += buffers[i].length; + } + + bytes memory result = new bytes(length); + + uint256 offset = 0x20; + for (uint256 i = 0; i < buffers.length; ++i) { + bytes memory input = buffers[i]; + assembly ("memory-safe") { + mcopy(add(result, offset), add(input, 0x20), mload(input)) + } + unchecked { + offset += input.length; + } + } + + return result; + } + /** * @dev Returns true if the two byte buffers are equal. */ diff --git a/test/utils/Bytes.test.js b/test/utils/Bytes.test.js index 38c2d626e17..9eb439cf9c9 100644 --- a/test/utils/Bytes.test.js +++ b/test/utils/Bytes.test.js @@ -2,6 +2,7 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { MAX_UINT128, MAX_UINT64, MAX_UINT32, MAX_UINT16 } = require('../helpers/constants'); +const { generators } = require('../helpers/random'); // Helper functions for fixed bytes types const bytes32 = value => ethers.toBeHex(value, 32); @@ -112,6 +113,47 @@ describe('Bytes', function () { }); }); + describe('concat', function () { + it('empty list', async function () { + await expect(this.mock.$concat([])).to.eventually.equal(generators.bytes.zero); + }); + + it('single item', async function () { + const item = generators.bytes(); + await expect(this.mock.$concat([item])).to.eventually.equal(item); + }); + + it('multiple (non-empty) items', async function () { + const items = Array.from({ length: 17 }, generators.bytes); + await expect(this.mock.$concat(items)).to.eventually.equal(ethers.concat(items)); + }); + + it('multiple (empty) items', async function () { + const items = Array.from({ length: 17 }).fill(generators.bytes.zero); + await expect(this.mock.$concat(items)).to.eventually.equal(ethers.concat(items)); + }); + + it('multiple (variable length) items', async function () { + const items = [ + generators.bytes.zero, + generators.bytes(17), + generators.bytes.zero, + generators.bytes(42), + generators.bytes(1), + generators.bytes(256), + generators.bytes(1024), + generators.bytes.zero, + generators.bytes(7), + generators.bytes(15), + generators.bytes(63), + generators.bytes.zero, + generators.bytes.zero, + ]; + + await expect(this.mock.$concat(items)).to.eventually.equal(ethers.concat(items)); + }); + }); + describe('clz bytes', function () { it('empty buffer', async function () { await expect(this.mock.$clz('0x')).to.eventually.equal(0);