Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/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.
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;
}
}
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 {
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 {
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 {
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.
*
* Complexity:
* - insertion (`push`): O(1)
* - lookup (`last`): O(1)
* - inclusion (`includes`): O(N) (worst case)
* - reset (`clear`): O(1)
*
* * 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;
* ```
*/
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;
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;
}

/**
* @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
* 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) {
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) % self._data.length).value;
}

/**
* @dev Check if a given value is in the buffer.
*/
function includes(Bytes32CircularBuffer storage self, bytes32 value) internal view returns (bool) {
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