Skip to content
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

Add CircularBuffer data structure #4913

Merged
merged 16 commits into from
Apr 26, 2024
Merged
5 changes: 5 additions & 0 deletions .changeset/cold-cheetahs-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`CircularBuffer`: add a datastructure that stored the last N values pushed to it.
Amxx marked this conversation as resolved.
Show resolved Hide resolved
24 changes: 24 additions & 0 deletions contracts/mocks/ArraysMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ contract Uint256ArraysMock {
function _reverse(uint256 a, uint256 b) private pure returns (bool) {
return a > b;
}

function unsafeSetLength(uint256 newLength) external {
_array.unsafeSetLength(newLength);
}

function length() external view returns (uint256) {
return _array.length;
}
}

contract AddressArraysMock {
Expand All @@ -74,6 +82,14 @@ contract AddressArraysMock {
function _reverse(address a, address b) private pure returns (bool) {
return uint160(a) > uint160(b);
}

function unsafeSetLength(uint256 newLength) external {
_array.unsafeSetLength(newLength);
}

function length() external view returns (uint256) {
return _array.length;
}
}

contract Bytes32ArraysMock {
Expand All @@ -100,4 +116,12 @@ contract Bytes32ArraysMock {
function _reverse(bytes32 a, bytes32 b) private pure returns (bool) {
return uint256(a) > uint256(b);
}

function unsafeSetLength(uint256 newLength) external {
_array.unsafeSetLength(newLength);
}

function length() external view returns (uint256) {
return _array.length;
}
}
2 changes: 2 additions & 0 deletions contracts/mocks/Stateless.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {AuthorityUtils} from "../access/manager/AuthorityUtils.sol";
import {Base64} from "../utils/Base64.sol";
import {BitMaps} from "../utils/structs/BitMaps.sol";
import {Checkpoints} from "../utils/structs/Checkpoints.sol";
import {CircularBuffer} from "../utils/structs/CircularBuffer.sol";
import {Clones} from "../proxy/Clones.sol";
import {Create2} from "../utils/Create2.sol";
import {DoubleEndedQueue} from "../utils/structs/DoubleEndedQueue.sol";
Expand All @@ -24,6 +25,7 @@ import {ERC721Holder} from "../token/ERC721/utils/ERC721Holder.sol";
import {Math} from "../utils/math/Math.sol";
import {MerkleProof} from "../utils/cryptography/MerkleProof.sol";
import {MessageHashUtils} from "../utils/cryptography/MessageHashUtils.sol";
import {Panic} from "../utils/Panic.sol";
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
import {SafeCast} from "../utils/math/SafeCast.sol";
import {SafeERC20} from "../token/ERC20/utils/SafeERC20.sol";
import {ShortStrings} from "../utils/ShortStrings.sol";
Expand Down
33 changes: 33 additions & 0 deletions contracts/utils/Arrays.sol
Original file line number Diff line number Diff line change
Expand Up @@ -440,4 +440,37 @@ library Arrays {
res := mload(add(add(arr, 0x20), mul(pos, 0x20)))
}
}

/**
* @dev Helper to set the length of an dynamic array. Directly writing to `.length` is forbidden.
*
* WARNING: this does not clear elements if length is reduced, of initialize elements if length is increased.
*/
function unsafeSetLength(address[] storage array, uint256 len) internal {
assembly {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
sstore(array.slot, len)
}
}

/**
* @dev Helper to set the length of an dynamic array. Directly writing to `.length` is forbidden.
*
* WARNING: this does not clear elements if length is reduced, of initialize elements if length is increased.
*/
function unsafeSetLength(bytes32[] storage array, uint256 len) internal {
assembly {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
sstore(array.slot, len)
}
}

/**
* @dev Helper to set the length of an dynamic array. Directly writing to `.length` is forbidden.
*
* WARNING: this does not clear elements if length is reduced, of initialize elements if length is increased.
*/
function unsafeSetLength(uint256[] storage array, uint256 len) internal {
assembly {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
sstore(array.slot, len)
}
}
}
3 changes: 3 additions & 0 deletions contracts/utils/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t
* {EnumerableMap}: A type like Solidity's https://solidity.readthedocs.io/en/latest/types.html#mapping-types[`mapping`], but with key-value _enumeration_: this will let you know how many entries a mapping has, and iterate over them (which is not possible with `mapping`).
* {EnumerableSet}: Like {EnumerableMap}, but for https://en.wikipedia.org/wiki/Set_(abstract_data_type)[sets]. Can be used to store privileged accounts, issued IDs, etc.
* {DoubleEndedQueue}: An implementation of a https://en.wikipedia.org/wiki/Double-ended_queue[double ended queue] whose values can be removed added or remove from both sides. Useful for FIFO and LIFO structures.
* {CircularBuffer}: A data structure to store the last N values pushed to it.
* {Checkpoints}: A data structure to store values mapped to an strictly increasing key. Can be used for storing and accessing values over time.
* {Create2}: Wrapper around the https://blog.openzeppelin.com/getting-the-most-out-of-create2/[`CREATE2` EVM opcode] for safe use without having to deal with low-level assembly.
* {Address}: Collection of functions for overloading Solidity's https://docs.soliditylang.org/en/latest/types.html#address[`address`] type.
Expand Down Expand Up @@ -86,6 +87,8 @@ Ethereum contracts have no native concept of an interface, so applications must

{{DoubleEndedQueue}}

{{CircularBuffer}}

{{Checkpoints}}

== Libraries
Expand Down
114 changes: 114 additions & 0 deletions contracts/utils/structs/CircularBuffer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {Math} from "../math/Math.sol";
import {Arrays} from "../Arrays.sol";
import {Panic} from "../Panic.sol";

/**
* @dev A buffer of items of fixed size. When a new item is pushed, it takes the place of the oldest one in the buffer
* so that at all times, only the last N elements are kept. Items cannot be removed. The entier buffer can be reset.
* Last N elements can be accessed using their index from the end.
Amxx marked this conversation as resolved.
Show resolved Hide resolved
*
* Complexity:
* - insertion (`push`): O(1)
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
* - lookup (`last`): O(1)
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
* - inclusion (`includes`): O(N) (worst case)
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
* - reset (`clear`): O(1)
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
*
* * The struct is called `Bytes32CircularBuffer`. Other types can be cast to and from `bytes32`. This data structure
* can only be used in storage, and not in memory.
Comment on lines +23 to +24
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the description I'm proposing, I don't think it's needed to specify these apply only to storage.

Suggested change
* * The struct is called `Bytes32CircularBuffer`. Other types can be cast to and from `bytes32`. This data structure
* can only be used in storage, and not in memory.
* Example usage:
*

* ```solidity
* CircularBuffer.Bytes32CircularBuffer buffer;
* ```
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
*/
library CircularBuffer {
/**
* @dev Counts the number of items that have been pushed to the buffer. The residu modulo _data.length indicates
* where the next value should be stored.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @dev Counts the number of items that have been pushed to the buffer. The residu modulo _data.length indicates
* where the next value should be stored.
* @dev Keeps track of the items pushed to the buffer. New items should be added at `(_count + 1) % _data.length`

*
* Struct members have an underscore prefix indicating that they are "private" and should not be read or written to
* directly. Use the functions provided below instead. Modifying the struct manually may violate assumptions and
* lead to unexpected behavior.
*
* The last item is at data[(index - 1) % data.length] and the last item is at data[index % data.length]. This
* range can wrap around.
*/
struct Bytes32CircularBuffer {
uint256 _count;
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
bytes32[] _data;
}

/**
* @dev Initialize a new CircularBuffer of given length.
*
* If the CircularBuffer was already setup and used, calling that function again will reset it to a blank state.
*/
function setup(Bytes32CircularBuffer storage self, uint256 length) internal {
clear(self);
Arrays.unsafeSetLength(self._data, length);
}

/**
* @dev Clear all data in the buffer, keeping the existing length.
*/
function clear(Bytes32CircularBuffer storage self) internal {
self._count = 0;
}
ernestognw marked this conversation as resolved.
Show resolved Hide resolved

/**
* @dev Push a new value to the buffer. If the buffer is already full, the new value replaces the oldest value in
* the buffer.
*/
function push(Bytes32CircularBuffer storage self, bytes32 value) internal {
uint256 index = self._count++;
uint256 length = self._data.length;
Arrays.unsafeAccess(self._data, index % length).value = value;
}

/**
* @dev Number of values currently in the buffer. This values is 0 for empty buffer, and cannot exceed the size of
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
* the buffer.
*/
function count(Bytes32CircularBuffer storage self) internal view returns (uint256) {
return Math.min(self._count, self._data.length);
}

/**
* @dev Length of the buffer. This is the maximum number of elements kepts in the buffer.
*/
function size(Bytes32CircularBuffer storage self) internal view returns (uint256) {
Amxx marked this conversation as resolved.
Show resolved Hide resolved
return self._data.length;
}

/**
* @dev Getter for the i-th value in the buffer, from the end.
*
* Reverts with {Panic-ARRAY_OUT_OF_BOUNDS} if trying to access an element that was not pushed, or that was
* dropped to make room for newer elements.
*/
function last(Bytes32CircularBuffer storage self, uint256 i) internal view returns (bytes32) {
uint256 index = self._count;
uint256 length = self._data.length;
uint256 total = Math.min(index, length); // count(self)
if (i >= total) {
Panic.panic(Panic.ARRAY_OUT_OF_BOUNDS);
}
return Arrays.unsafeAccess(self._data, (index - i - 1) % length).value;
}

/**
* @dev Check if a given value is in the buffer.
*/
function includes(Bytes32CircularBuffer storage self, bytes32 value) internal view returns (bool) {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
uint256 index = self._count;
uint256 length = self._data.length;
uint256 total = Math.min(index, length); // count(self)
for (uint256 i = 0; i < total; ++i) {
if (Arrays.unsafeAccess(self._data, (index - i - 1) % length).value == value) {
return true;
}
}
return false;
}
}
48 changes: 32 additions & 16 deletions test/utils/Arrays.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const upperBound = (array, value) => {
};

const bigintSign = x => (x > 0n ? 1 : x < 0n ? -1 : 0);
const comparator = (a, b) => bigintSign(ethers.toBigInt(a) - ethers.toBigInt(b));
const hasDuplicates = array => array.some((v, i) => array.indexOf(v) != i);

describe('Arrays', function () {
Expand Down Expand Up @@ -116,23 +117,22 @@ describe('Arrays', function () {
}
});

for (const [type, { artifact, elements, comp }] of Object.entries({
for (const [type, { artifact, format }] of Object.entries({
address: {
artifact: 'AddressArraysMock',
elements: Array.from({ length: 10 }, generators.address),
comp: (a, b) => bigintSign(ethers.toBigInt(a) - ethers.toBigInt(b)),
format: x => ethers.getAddress(ethers.toBeHex(x, 20)),
},
bytes32: {
artifact: 'Bytes32ArraysMock',
elements: Array.from({ length: 10 }, generators.bytes32),
comp: (a, b) => bigintSign(ethers.toBigInt(a) - ethers.toBigInt(b)),
format: x => ethers.toBeHex(x, 32),
},
uint256: {
artifact: 'Uint256ArraysMock',
elements: Array.from({ length: 10 }, generators.uint256),
comp: (a, b) => bigintSign(a - b),
format: x => ethers.toBigInt(x),
},
})) {
const elements = Array.from({ length: 10 }, generators[type]);

describe(type, function () {
const fixture = async () => {
return { instance: await ethers.deployContract(artifact, [elements]) };
Expand All @@ -146,14 +146,14 @@ describe('Arrays', function () {
for (const length of [0, 1, 2, 8, 32, 128]) {
describe(`${type}[] of length ${length}`, function () {
beforeEach(async function () {
this.elements = Array.from({ length }, generators[type]);
this.array = Array.from({ length }, generators[type]);
});

afterEach(async function () {
const expected = Array.from(this.elements).sort(comp);
const expected = Array.from(this.array).sort(comparator);
const reversed = Array.from(expected).reverse();
expect(await this.instance.sort(this.elements)).to.deep.equal(expected);
expect(await this.instance.sortReverse(this.elements)).to.deep.equal(reversed);
expect(await this.instance.sort(this.array)).to.deep.equal(expected);
expect(await this.instance.sortReverse(this.array)).to.deep.equal(reversed);
});

it('sort array', async function () {
Expand All @@ -163,23 +163,23 @@ describe('Arrays', function () {
if (length > 1) {
it('sort array for identical elements', async function () {
// duplicate the first value to all elements
this.elements.fill(this.elements.at(0));
this.array.fill(this.array.at(0));
});

it('sort already sorted array', async function () {
// pre-sort the elements
this.elements.sort(comp);
this.array.sort(comparator);
});

it('sort reversed array', async function () {
// pre-sort in reverse order
this.elements.sort(comp).reverse();
this.array.sort(comparator).reverse();
});

it('sort almost sorted array', async function () {
// pre-sort + rotate (move the last element to the front) for an almost sorted effect
this.elements.sort(comp);
this.elements.unshift(this.elements.pop());
this.array.sort(comparator);
this.array.unshift(this.array.pop());
});
}
});
Expand All @@ -197,6 +197,14 @@ describe('Arrays', function () {
it('unsafeAccess outside bounds', async function () {
await expect(this.instance.unsafeAccess(elements.length)).to.not.be.rejected;
});

it('unsafeSetLength changes the length or the array', async function () {
const newLength = generators.uint256();

expect(await this.instance.length()).to.equal(elements.length);
await expect(this.instance.unsafeSetLength(newLength)).to.not.be.rejected;
expect(await this.instance.length()).to.equal(newLength);
});
});

describe('memory', function () {
Expand All @@ -211,6 +219,14 @@ describe('Arrays', function () {
it('unsafeMemoryAccess outside bounds', async function () {
await expect(this.mock[fragment](elements, elements.length)).to.not.be.rejected;
});

it('unsafeMemoryAccess loop around', async function () {
for (let i = 251n; i < 256n; ++i) {
expect(await this.mock[fragment](elements, 2n ** i - 1n)).to.equal(format(elements.length));
expect(await this.mock[fragment](elements, 2n ** i + 0n)).to.equal(elements[0]);
expect(await this.mock[fragment](elements, 2n ** i + 1n)).to.equal(elements[1]);
}
});
});
});
});
Expand Down
Loading
Loading