diff --git a/.changeset/lovely-dodos-lay.md b/.changeset/lovely-dodos-lay.md new file mode 100644 index 00000000000..da225132630 --- /dev/null +++ b/.changeset/lovely-dodos-lay.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`NoncesKeyed`: Add a variant of `Nonces` that implements the ERC-4337 entrypoint nonce system. diff --git a/contracts/mocks/Stateless.sol b/contracts/mocks/Stateless.sol index 98e7eaf7443..5af11310bef 100644 --- a/contracts/mocks/Stateless.sol +++ b/contracts/mocks/Stateless.sol @@ -29,6 +29,8 @@ import {Heap} from "../utils/structs/Heap.sol"; import {Math} from "../utils/math/Math.sol"; import {MerkleProof} from "../utils/cryptography/MerkleProof.sol"; import {MessageHashUtils} from "../utils/cryptography/MessageHashUtils.sol"; +import {Nonces} from "../utils/Nonces.sol"; +import {NoncesKeyed} from "../utils/NoncesKeyed.sol"; import {P256} from "../utils/cryptography/P256.sol"; import {Panic} from "../utils/Panic.sol"; import {Packing} from "../utils/Packing.sol"; diff --git a/contracts/utils/NoncesKeyed.sol b/contracts/utils/NoncesKeyed.sol new file mode 100644 index 00000000000..133b0c3fe4a --- /dev/null +++ b/contracts/utils/NoncesKeyed.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Nonces} from "./Nonces.sol"; + +/** + * @dev Alternative to {Nonces}, that support key-ed nonces. + * + * Follows the https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337's semi-abstracted nonce system]. + */ +abstract contract NoncesKeyed is Nonces { + mapping(address owner => mapping(uint192 key => uint64)) private _nonces; + + /// @dev Returns the next unused nonce for an address and key. Result contains the key prefix. + function nonces(address owner, uint192 key) public view virtual returns (uint256) { + return key == 0 ? nonces(owner) : ((uint256(key) << 64) | _nonces[owner][key]); + } + + /** + * @dev Consumes the next unused nonce for an address and key. + * + * Returns the current value without the key prefix. Consumed nonce is increased, so calling this functions twice + * with the same arguments will return different (sequential) results. + */ + function _useNonce(address owner, uint192 key) internal virtual returns (uint256) { + // For each account, the nonce has an initial value of 0, can only be incremented by one, and cannot be + // decremented or reset. This guarantees that the nonce never overflows. + unchecked { + // It is important to do x++ and not ++x here. + return key == 0 ? _useNonce(owner) : _nonces[owner][key]++; + } + } + + /** + * @dev Same as {_useNonce} but checking that `nonce` is the next valid for `owner`. + * + * This version takes the key and the nonce in a single uint256 parameter: + * - use the first 8 bytes for the key + * - use the last 24 bytes for the nonce + */ + function _useCheckedNonce(address owner, uint256 keyNonce) internal virtual override { + _useCheckedNonce(owner, uint192(keyNonce >> 64), uint64(keyNonce)); + } + + /** + * @dev Same as {_useNonce} but checking that `nonce` is the next valid for `owner`. + * + * This version takes the key and the nonce as two different parameters. + */ + function _useCheckedNonce(address owner, uint192 key, uint64 nonce) internal virtual { + if (key == 0) { + super._useCheckedNonce(owner, nonce); + } else { + uint256 current = _useNonce(owner, key); + if (nonce != current) { + revert InvalidAccountNonce(owner, current); + } + } + } +} diff --git a/contracts/utils/README.adoc b/contracts/utils/README.adoc index eeef84aae7c..432b806e3c4 100644 --- a/contracts/utils/README.adoc +++ b/contracts/utils/README.adoc @@ -18,6 +18,7 @@ Miscellaneous contracts and libraries containing utility functions you can use t * {ReentrancyGuardTransient}: Variant of {ReentrancyGuard} that uses transient storage (https://eips.ethereum.org/EIPS/eip-1153[EIP-1153]). * {Pausable}: A common emergency response mechanism that can pause functionality while a remediation is pending. * {Nonces}: Utility for tracking and verifying address nonces that only increment. + * {NoncesKeyed}: Alternative to {Nonces}, that support key-ed nonces following https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337 speciciations]. * {ERC165}, {ERC165Checker}: Utilities for inspecting interfaces supported by contracts. * {BitMaps}: A simple library to manage boolean value mapped to a numerical index in an efficient way. * {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`). @@ -85,6 +86,8 @@ Because Solidity does not support generic types, {EnumerableMap} and {Enumerable {{Nonces}} +{{NoncesKeyed}} + == Introspection This set of interfaces and contracts deal with https://en.wikipedia.org/wiki/Type_introspection[type introspection] of contracts, that is, examining which functions can be called on them. This is usually referred to as a contract's _interface_. diff --git a/test/utils/Nonces.behavior.js b/test/utils/Nonces.behavior.js new file mode 100644 index 00000000000..17073966427 --- /dev/null +++ b/test/utils/Nonces.behavior.js @@ -0,0 +1,152 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); + +function shouldBehaveLikeNonces() { + describe('should behave like Nonces', function () { + const sender = ethers.Wallet.createRandom(); + const other = ethers.Wallet.createRandom(); + + it('gets a nonce', async function () { + expect(this.mock.nonces(sender)).to.eventually.equal(0n); + }); + + describe('_useNonce', function () { + it('increments a nonce', async function () { + expect(this.mock.nonces(sender)).to.eventually.equal(0n); + + const eventName = ['return$_useNonce', 'return$_useNonce_address'].find(name => + this.mock.interface.getEvent(name), + ); + + await expect(this.mock.$_useNonce(sender)).to.emit(this.mock, eventName).withArgs(0n); + + expect(this.mock.nonces(sender)).to.eventually.equal(1n); + }); + + it("increments only sender's nonce", async function () { + expect(this.mock.nonces(sender)).to.eventually.equal(0n); + expect(this.mock.nonces(other)).to.eventually.equal(0n); + + await this.mock.$_useNonce(sender); + + expect(this.mock.nonces(sender)).to.eventually.equal(1n); + expect(this.mock.nonces(other)).to.eventually.equal(0n); + }); + }); + + describe('_useCheckedNonce', function () { + it('increments a nonce', async function () { + // current nonce is 0n + expect(this.mock.nonces(sender)).to.eventually.equal(0n); + + await this.mock.$_useCheckedNonce(sender, 0n); + + expect(this.mock.nonces(sender)).to.eventually.equal(1n); + }); + + it("increments only sender's nonce", async function () { + // current nonce is 0n + expect(this.mock.nonces(sender)).to.eventually.equal(0n); + expect(this.mock.nonces(other)).to.eventually.equal(0n); + + await this.mock.$_useCheckedNonce(sender, 0n); + + expect(this.mock.nonces(sender)).to.eventually.equal(1n); + expect(this.mock.nonces(other)).to.eventually.equal(0n); + }); + + it('reverts when nonce is not the expected', async function () { + const currentNonce = await this.mock.nonces(sender); + + await expect(this.mock.$_useCheckedNonce(sender, currentNonce + 1n)) + .to.be.revertedWithCustomError(this.mock, 'InvalidAccountNonce') + .withArgs(sender, currentNonce); + }); + }); + }); +} + +function shouldBehaveLikeNoncesKeyed() { + describe('should support nonces with keys', function () { + const sender = ethers.Wallet.createRandom(); + + const keyOffset = key => key << 64n; + + it('gets a nonce', async function () { + expect(this.mock.nonces(sender, ethers.Typed.uint192(0n))).to.eventually.equal(keyOffset(0n) + 0n); + expect(this.mock.nonces(sender, ethers.Typed.uint192(17n))).to.eventually.equal(keyOffset(17n) + 0n); + }); + + describe('_useNonce', function () { + it('default variant uses key 0', async function () { + expect(this.mock.nonces(sender, ethers.Typed.uint192(0n))).to.eventually.equal(keyOffset(0n) + 0n); + expect(this.mock.nonces(sender, ethers.Typed.uint192(17n))).to.eventually.equal(keyOffset(17n) + 0n); + + await expect(this.mock.$_useNonce(sender)).to.emit(this.mock, 'return$_useNonce_address').withArgs(0n); + + await expect(this.mock.$_useNonce(sender, ethers.Typed.uint192(0n))) + .to.emit(this.mock, 'return$_useNonce_address_uint192') + .withArgs(1n); + + expect(this.mock.nonces(sender, ethers.Typed.uint192(0n))).to.eventually.equal(keyOffset(0n) + 2n); + expect(this.mock.nonces(sender, ethers.Typed.uint192(17n))).to.eventually.equal(keyOffset(17n) + 0n); + }); + + it('use nonce at another key', async function () { + expect(this.mock.nonces(sender, ethers.Typed.uint192(0n))).to.eventually.equal(keyOffset(0n) + 0n); + expect(this.mock.nonces(sender, ethers.Typed.uint192(17n))).to.eventually.equal(keyOffset(17n) + 0n); + + await expect(this.mock.$_useNonce(sender, ethers.Typed.uint192(17n))) + .to.emit(this.mock, 'return$_useNonce_address_uint192') + .withArgs(0n); + + await expect(this.mock.$_useNonce(sender, ethers.Typed.uint192(17n))) + .to.emit(this.mock, 'return$_useNonce_address_uint192') + .withArgs(1n); + + expect(this.mock.nonces(sender, ethers.Typed.uint192(0n))).to.eventually.equal(keyOffset(0n) + 0n); + expect(this.mock.nonces(sender, ethers.Typed.uint192(17n))).to.eventually.equal(keyOffset(17n) + 2n); + }); + }); + + describe('_useCheckedNonce', function () { + it('default variant uses key 0', async function () { + const currentNonce = await this.mock.nonces(sender, ethers.Typed.uint192(0n)); + + await this.mock.$_useCheckedNonce(sender, currentNonce); + + expect(this.mock.nonces(sender, ethers.Typed.uint192(0n))).to.eventually.equal(currentNonce + 1n); + }); + + it('use nonce at another key', async function () { + const currentNonce = await this.mock.nonces(sender, ethers.Typed.uint192(17n)); + + await this.mock.$_useCheckedNonce(sender, currentNonce); + + expect(this.mock.nonces(sender, ethers.Typed.uint192(17n))).to.eventually.equal(currentNonce + 1n); + }); + + it('reverts when nonce is not the expected', async function () { + const currentNonce = await this.mock.nonces(sender, ethers.Typed.uint192(42n)); + + // use and increment + await this.mock.$_useCheckedNonce(sender, currentNonce); + + // reuse same nonce + await expect(this.mock.$_useCheckedNonce(sender, currentNonce)) + .to.be.revertedWithCustomError(this.mock, 'InvalidAccountNonce') + .withArgs(sender, 1); + + // use "future" nonce too early + await expect(this.mock.$_useCheckedNonce(sender, currentNonce + 10n)) + .to.be.revertedWithCustomError(this.mock, 'InvalidAccountNonce') + .withArgs(sender, 1); + }); + }); + }); +} + +module.exports = { + shouldBehaveLikeNonces, + shouldBehaveLikeNoncesKeyed, +}; diff --git a/test/utils/Nonces.test.js b/test/utils/Nonces.test.js index 2cb4798dea6..85aa7358a00 100644 --- a/test/utils/Nonces.test.js +++ b/test/utils/Nonces.test.js @@ -1,13 +1,10 @@ const { ethers } = require('hardhat'); -const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { shouldBehaveLikeNonces } = require('./Nonces.behavior'); async function fixture() { - const [sender, other] = await ethers.getSigners(); - const mock = await ethers.deployContract('$Nonces'); - - return { sender, other, mock }; + return { mock }; } describe('Nonces', function () { @@ -15,61 +12,5 @@ describe('Nonces', function () { Object.assign(this, await loadFixture(fixture)); }); - it('gets a nonce', async function () { - expect(await this.mock.nonces(this.sender)).to.equal(0n); - }); - - describe('_useNonce', function () { - it('increments a nonce', async function () { - expect(await this.mock.nonces(this.sender)).to.equal(0n); - - await expect(await this.mock.$_useNonce(this.sender)) - .to.emit(this.mock, 'return$_useNonce') - .withArgs(0n); - - expect(await this.mock.nonces(this.sender)).to.equal(1n); - }); - - it("increments only sender's nonce", async function () { - expect(await this.mock.nonces(this.sender)).to.equal(0n); - expect(await this.mock.nonces(this.other)).to.equal(0n); - - await this.mock.$_useNonce(this.sender); - - expect(await this.mock.nonces(this.sender)).to.equal(1n); - expect(await this.mock.nonces(this.other)).to.equal(0n); - }); - }); - - describe('_useCheckedNonce', function () { - it('increments a nonce', async function () { - const currentNonce = await this.mock.nonces(this.sender); - - expect(currentNonce).to.equal(0n); - - await this.mock.$_useCheckedNonce(this.sender, currentNonce); - - expect(await this.mock.nonces(this.sender)).to.equal(1n); - }); - - it("increments only sender's nonce", async function () { - const currentNonce = await this.mock.nonces(this.sender); - - expect(currentNonce).to.equal(0n); - expect(await this.mock.nonces(this.other)).to.equal(0n); - - await this.mock.$_useCheckedNonce(this.sender, currentNonce); - - expect(await this.mock.nonces(this.sender)).to.equal(1n); - expect(await this.mock.nonces(this.other)).to.equal(0n); - }); - - it('reverts when nonce is not the expected', async function () { - const currentNonce = await this.mock.nonces(this.sender); - - await expect(this.mock.$_useCheckedNonce(this.sender, currentNonce + 1n)) - .to.be.revertedWithCustomError(this.mock, 'InvalidAccountNonce') - .withArgs(this.sender, currentNonce); - }); - }); + shouldBehaveLikeNonces(); }); diff --git a/test/utils/NoncesKeyed.test.js b/test/utils/NoncesKeyed.test.js new file mode 100644 index 00000000000..c46948ee402 --- /dev/null +++ b/test/utils/NoncesKeyed.test.js @@ -0,0 +1,17 @@ +const { ethers } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { shouldBehaveLikeNonces, shouldBehaveLikeNoncesKeyed } = require('./Nonces.behavior'); + +async function fixture() { + const mock = await ethers.deployContract('$NoncesKeyed'); + return { mock }; +} + +describe('NoncesKeyed', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + shouldBehaveLikeNonces(); + shouldBehaveLikeNoncesKeyed(); +});