Add CircularBuffer data structure (#4913)
Co-authored-by: ernestognw <ernestognw@gmail.com>pull/5030/head
parent
60697cb09a
commit
c80b675b8d
@ -0,0 +1,5 @@ |
||||
--- |
||||
'openzeppelin-solidity': minor |
||||
--- |
||||
|
||||
`CircularBuffer`: Add a data structure that stores the last `N` values pushed to it. |
@ -0,0 +1,130 @@ |
||||
// 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 fixed-size buffer for keeping `bytes32` items in storage. |
||||
* |
||||
* This data structure allows for pushing elements to it, and when its length exceeds the specified fixed size, |
||||
* new items take the place of the oldest element in the buffer, keeping at most `N` elements in the |
||||
* structure. |
||||
* |
||||
* Elements can't be removed but the data structure can be cleared. See {clear}. |
||||
* |
||||
* 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. |
||||
* |
||||
* Example usage: |
||||
* |
||||
* ```solidity |
||||
* contract Example { |
||||
* // Add the library methods |
||||
* using CircularBuffer for CircularBuffer.Bytes32CircularBuffer; |
||||
* |
||||
* // Declare a buffer storage variable |
||||
* CircularBuffer.Bytes32CircularBuffer private myBuffer; |
||||
* } |
||||
* ``` |
||||
*/ |
||||
library CircularBuffer { |
||||
/** |
||||
* @dev Counts the number of items that have been pushed to the buffer. The residuo modulo _data.length indicates |
||||
* where the next value should be stored. |
||||
* |
||||
* 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 size. |
||||
* |
||||
* If the CircularBuffer was already setup and used, calling that function again will reset it to a blank state. |
||||
* |
||||
* NOTE: The size of the buffer will affect the execution of {includes} function, as it has a complexity of O(N). |
||||
* Consider a large buffer size may render the function unusable. |
||||
*/ |
||||
function setup(Bytes32CircularBuffer storage self, uint256 size) internal { |
||||
clear(self); |
||||
Arrays.unsafeSetLength(self._data, size); |
||||
} |
||||
|
||||
/** |
||||
* @dev Clear all data in the buffer without resetting memory, keeping the existing size. |
||||
*/ |
||||
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 modulus = self._data.length; |
||||
Arrays.unsafeAccess(self._data, index % modulus).value = value; |
||||
} |
||||
|
||||
/** |
||||
* @dev Number of values currently in the buffer. This value is 0 for an 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 length(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 modulus = self._data.length; |
||||
uint256 total = Math.min(index, modulus); // count(self) |
||||
if (i >= total) { |
||||
Panic.panic(Panic.ARRAY_OUT_OF_BOUNDS); |
||||
} |
||||
return Arrays.unsafeAccess(self._data, (index - i - 1) % modulus).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 modulus = self._data.length; |
||||
uint256 total = Math.min(index, modulus); // count(self) |
||||
for (uint256 i = 0; i < total; ++i) { |
||||
if (Arrays.unsafeAccess(self._data, (index - i - 1) % modulus).value == value) { |
||||
return true; |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
} |
@ -0,0 +1,79 @@ |
||||
const { ethers } = require('hardhat'); |
||||
const { expect } = require('chai'); |
||||
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); |
||||
const { PANIC_CODES } = require('@nomicfoundation/hardhat-chai-matchers/panic'); |
||||
|
||||
const { generators } = require('../../helpers/random'); |
||||
|
||||
const LENGTH = 4; |
||||
|
||||
async function fixture() { |
||||
const mock = await ethers.deployContract('$CircularBuffer'); |
||||
await mock.$setup(0, LENGTH); |
||||
return { mock }; |
||||
} |
||||
|
||||
describe('CircularBuffer', function () { |
||||
beforeEach(async function () { |
||||
Object.assign(this, await loadFixture(fixture)); |
||||
}); |
||||
|
||||
it('starts empty', async function () { |
||||
expect(await this.mock.$count(0)).to.equal(0n); |
||||
expect(await this.mock.$length(0)).to.equal(LENGTH); |
||||
expect(await this.mock.$includes(0, ethers.ZeroHash)).to.be.false; |
||||
await expect(this.mock.$last(0, 0)).to.be.revertedWithPanic(PANIC_CODES.ARRAY_ACCESS_OUT_OF_BOUNDS); |
||||
}); |
||||
|
||||
it('push', async function () { |
||||
const values = Array.from({ length: LENGTH + 3 }, generators.bytes32); |
||||
|
||||
for (const [i, value] of values.map((v, i) => [i, v])) { |
||||
// push value
|
||||
await this.mock.$push(0, value); |
||||
|
||||
// view of the values
|
||||
const pushed = values.slice(0, i + 1); |
||||
const stored = pushed.slice(-LENGTH); |
||||
const dropped = pushed.slice(0, -LENGTH); |
||||
|
||||
// check count
|
||||
expect(await this.mock.$length(0)).to.equal(LENGTH); |
||||
expect(await this.mock.$count(0)).to.equal(stored.length); |
||||
|
||||
// check last
|
||||
for (const j in stored) { |
||||
expect(await this.mock.$last(0, j)).to.equal(stored.at(-j - 1)); |
||||
} |
||||
await expect(this.mock.$last(0, stored.length + 1)).to.be.revertedWithPanic( |
||||
PANIC_CODES.ARRAY_ACCESS_OUT_OF_BOUNDS, |
||||
); |
||||
|
||||
// check included and non-included values
|
||||
for (const v of stored) { |
||||
expect(await this.mock.$includes(0, v)).to.be.true; |
||||
} |
||||
for (const v of dropped) { |
||||
expect(await this.mock.$includes(0, v)).to.be.false; |
||||
} |
||||
expect(await this.mock.$includes(0, ethers.ZeroHash)).to.be.false; |
||||
} |
||||
}); |
||||
|
||||
it('clear', async function () { |
||||
const value = generators.bytes32(); |
||||
await this.mock.$push(0, value); |
||||
|
||||
expect(await this.mock.$count(0)).to.equal(1n); |
||||
expect(await this.mock.$length(0)).to.equal(LENGTH); |
||||
expect(await this.mock.$includes(0, value)).to.be.true; |
||||
await this.mock.$last(0, 0); // not revert
|
||||
|
||||
await this.mock.$clear(0); |
||||
|
||||
expect(await this.mock.$count(0)).to.equal(0n); |
||||
expect(await this.mock.$length(0)).to.equal(LENGTH); |
||||
expect(await this.mock.$includes(0, value)).to.be.false; |
||||
await expect(this.mock.$last(0, 0)).to.be.revertedWithPanic(PANIC_CODES.ARRAY_ACCESS_OUT_OF_BOUNDS); |
||||
}); |
||||
}); |
Loading…
Reference in new issue