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