diff --git a/contracts/interfaces/README.adoc b/contracts/interfaces/README.adoc index 3dda81e3e82..e0da397317e 100644 --- a/contracts/interfaces/README.adoc +++ b/contracts/interfaces/README.adoc @@ -46,6 +46,7 @@ are useful to interact with third party contracts that implement them. - {IERC6909ContentURI} - {IERC6909Metadata} - {IERC6909TokenSupply} +- {IERC7246} - {IERC7579Module} - {IERC7579Validator} - {IERC7579Hook} @@ -113,6 +114,8 @@ are useful to interact with third party contracts that implement them. {{IERC6909TokenSupply}} +{{IERC7246}} + {{IERC7579Module}} {{IERC7579Validator}} diff --git a/contracts/interfaces/draft-IERC7246.sol b/contracts/interfaces/draft-IERC7246.sol new file mode 100644 index 00000000000..532704e8960 --- /dev/null +++ b/contracts/interfaces/draft-IERC7246.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT + +pragma solidity >=0.6.2; + +import {IERC20} from "./IERC20.sol"; + +interface IERC7246 is IERC20 { + /// @dev Emitted when `amount` tokens are encumbered from `owner` to `spender`. + event Encumber(address indexed owner, address indexed spender, uint256 amount); + + /// @dev Emitted when the encumbrance of a `spender` to an `owner` is reduced by `amount`. + event Release(address indexed owner, address indexed spender, uint256 amount); + + /** + * @dev Returns the total amount of tokens owned by `owner` that are currently encumbered. + * + * - MUST never exceed `balanceOf(owner)` + * - Any function which would reduce `balanceOf(owner)` below `encumberedBalanceOf(owner)` MUST revert + */ + function encumberedBalanceOf(address owner) external view returns (uint256); + + /** + * @dev Convenience function for reading the unencumbered balance of an address. + * Trivially implemented as `balanceOf(owner) - encumberedBalanceOf(owner)` + */ + function availableBalanceOf(address owner) external view returns (uint256); + + /** + * @dev Returns the number of tokens that `owner` has encumbered to `spender`. + * + * - This value increases when {encumber} or {encumberFrom} are called by the `owner` or by another permitted account. + * - This value decreases when {release} or {transferFrom} are called by `spender`. + */ + function encumbrances(address owner, address spender) external view returns (uint256); + + /** + * @dev Increases the amount of tokens that the caller has encumbered to `spender` by `amount`. + * Grants `spender` a guaranteed right to transfer `amount` from the caller's by using `transferFrom`. + * + * - MUST revert if caller does not have `amount` tokens available + * (e.g. if `balanceOf(caller) - encumberedBalanceOf(caller) < amount`). + * - Emits an {IERC7246-Encumber} event. + */ + function encumber(address spender, uint256 amount) external; + + /** + * @dev Increases the amount of tokens that `owner` has encumbered to `spender` by `amount`. + * Grants `spender` a guaranteed right to transfer `amount` from `owner` using transferFrom. + * + * - The function SHOULD revert unless the owner account has deliberately authorized the sender of the message via some mechanism. + * - MUST revert if `owner` does not have `amount` tokens available + * (e.g. if `balanceOf(owner) - encumberedBalanceOf(owner) < amount`). + * - Emits an {IERC7246-Encumber} event. + */ + function encumberFrom(address owner, address spender, uint256 amount) external; + + /** + * @dev Reduces amount of tokens encumbered from `owner` to caller by `amount` + * + * - Emits a {IERC7246-Release} event. + */ + function release(address owner, uint256 amount) external; +} diff --git a/contracts/token/ERC20/README.adoc b/contracts/token/ERC20/README.adoc index 8f8b7a438d9..8ba7aaa4867 100644 --- a/contracts/token/ERC20/README.adoc +++ b/contracts/token/ERC20/README.adoc @@ -27,6 +27,7 @@ Additionally there are multiple custom extensions, including: * {ERC20TemporaryApproval}: support for approvals lasting for only one transaction, as defined in ERC-7674. * {ERC1363}: support for calling the target of a transfer or approval, enabling code execution on the receiver within a single transaction. * {ERC4626}: tokenized vault that manages shares (represented as ERC-20) that are backed by assets (another ERC-20). +* {ERC7246} Finally, there are some utilities to interact with ERC-20 contracts in various ways: @@ -74,6 +75,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel {{ERC4626}} +{{ERC7246}} + == Utilities {{SafeERC20}} diff --git a/contracts/token/ERC20/extensions/draft-ERC7246.sol b/contracts/token/ERC20/extensions/draft-ERC7246.sol new file mode 100644 index 00000000000..af24d273890 --- /dev/null +++ b/contracts/token/ERC20/extensions/draft-ERC7246.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "../ERC20.sol"; +import {IERC7246} from "../../../interfaces/draft-IERC7246.sol"; +import {Math} from "../../../utils/math/Math.sol"; + +/** + * @title ERC7246 + * @dev An extension of {ERC20} that adds support for encumbrances of token balances. Encumbrances are a + * stronger version of allowances: they grant the `spender` an exclusive right to transfer tokens from the + * `owner`'s balance without reducing the `owner`'s balance until the tokens are transferred or the + * encumbrance is released. + */ +abstract contract ERC7246 is ERC20, IERC7246 { + /// @dev Thrown when the result of an {_update} or {_encumber} call would result in negative {availableBalanceOf}. + error ERC7246InsufficientAvailableBalance(uint256 available, uint256 required); + + /// @dev Thrown when an account tries to release more encumbered tokens than it has. + error ERC7246InsufficientEncumbrance(uint256 encumbered, uint256 required); + + /// @dev Thrown when an account tries to encumber tokens to itself. + error ERC7246SelfEncumbrance(); + + mapping(address owner => mapping(address spender => uint256)) private _encumbrances; + mapping(address owner => uint256) private _encumberedBalances; + + /// @inheritdoc IERC7246 + function encumberedBalanceOf(address owner) public view virtual returns (uint256) { + return _encumberedBalances[owner]; + } + + /// @inheritdoc IERC7246 + function availableBalanceOf(address owner) public view virtual returns (uint256) { + return balanceOf(owner) - encumberedBalanceOf(owner); + } + + /// @inheritdoc IERC7246 + function encumbrances(address owner, address spender) public view virtual returns (uint256) { + return _encumbrances[owner][spender]; + } + + /// @inheritdoc IERC7246 + function encumber(address spender, uint256 amount) public virtual { + _encumber(msg.sender, spender, amount); + } + + /// @inheritdoc IERC7246 + function encumberFrom(address owner, address spender, uint256 amount) public virtual { + _spendAllowance(owner, msg.sender, amount); + _encumber(owner, spender, amount); + } + + /// @inheritdoc IERC7246 + function release(address owner, uint256 amount) public virtual { + _releaseEncumbrance(owner, msg.sender, amount); + } + + /** + * @dev Encumber `amount` of tokens from `owner` to `spender`. Encumbering tokens grants an exclusive right + * to transfer the tokens without removing them from `owner`'s balance. Release the tokens by calling + * {release} or transfer them by calling {transferFrom}. + */ + function _encumber(address owner, address spender, uint256 amount) internal virtual { + require(owner != spender, ERC7246SelfEncumbrance()); + uint256 availableBalance = availableBalanceOf(owner); + require(availableBalance >= amount, ERC7246InsufficientAvailableBalance(availableBalance, amount)); + + // Given that the `availableBalanceOf` is `balanceOf(owner) - encumberedBalanceOf(owner)`, + // we know that the new `_encumberedBalances[owner] <= balanceOf(owner)` and thus no overflow is possible. + // `_encumberedBalances[owner] >= _encumbrances[owner][spender]`, so no overflow is possible there either. + unchecked { + _encumbrances[owner][spender] += amount; + _encumberedBalances[owner] += amount; + } + + emit Encumber(owner, spender, amount); + } + + /** + * @dev Release `amount` of encumbered tokens from `owner` to `spender`. + * + * - Will revert if there are insufficient encumbered tokens. + * - Emits the {IERC7246-Release} event. + */ + function _releaseEncumbrance(address owner, address spender, uint256 amount) internal virtual { + uint256 encumbered = encumbrances(owner, spender); + require(encumbered >= amount, ERC7246InsufficientEncumbrance(encumbered, amount)); + + unchecked { + _encumbrances[owner][spender] -= amount; + _encumberedBalances[owner] -= amount; + } + + emit Release(owner, spender, amount); + } + + /** + * @dev See {ERC20-_spendAllowance}. Encumbrances are consumed first, then the remaining amount + * is passed to `super._spendAllowance`. + */ + function _spendAllowance(address owner, address spender, uint256 amount) internal virtual override { + uint256 amountEncumbered = encumbrances(owner, spender); + uint256 remainingAllowance = amount; + + if (amountEncumbered != 0) { + uint256 encumberedToUse = Math.min(amount, amountEncumbered); + _releaseEncumbrance(owner, spender, encumberedToUse); + unchecked { + remainingAllowance -= encumberedToUse; + } + } + + super._spendAllowance(owner, spender, remainingAllowance); + } + + /// @dev See {ERC20-_update}. Ensures that `from` has sufficient {availableBalanceOf} to cover the `amount` being transferred. + function _update(address from, address to, uint256 amount) internal virtual override { + super._update(from, to, amount); + if (from != address(0)) { + uint256 balanceOfFrom = balanceOf(from); + uint256 encumberedBalanceOfFrom = encumberedBalanceOf(from); + require( + balanceOfFrom >= encumberedBalanceOfFrom, + ERC7246InsufficientAvailableBalance(balanceOfFrom + amount - encumberedBalanceOfFrom, amount) + ); + } + } +} diff --git a/package-lock.json b/package-lock.json index 73099e6e7a1..95384095446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@nomicfoundation/hardhat-chai-matchers": "^2.0.6", "@nomicfoundation/hardhat-ethers": "^3.0.9", "@nomicfoundation/hardhat-network-helpers": "^1.0.13", - "@openzeppelin/docs-utils": "^0.1.5", + "@openzeppelin/docs-utils": "^0.1.6", "@openzeppelin/merkle-tree": "^1.0.7", "@openzeppelin/upgrade-safe-transpiler": "^0.4.1", "@openzeppelin/upgrades-core": "^1.20.6", @@ -1412,14 +1412,14 @@ } }, "node_modules/@frangio/servbot": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@frangio/servbot/-/servbot-0.2.5.tgz", - "integrity": "sha512-ogja4iAPZ1VwM5MU3C1ZhB88358F0PGbmSTGOkIZwOyLaDoMHIqOVCnavHjR7DV5h+oAI4Z4KDqlam3myQUrmg==", + "version": "0.3.0-1", + "resolved": "https://registry.npmjs.org/@frangio/servbot/-/servbot-0.3.0-1.tgz", + "integrity": "sha512-eKXRqt8Zh3aqtVYoyayuLiktcW6vnYCuwlcqg91cv3HSdM5foTmIECJEJiwI+GBmSLY37mzczpt/ZfvYqzrQWQ==", "dev": true, "license": "MIT", "engines": { "node": ">=12.x", - "pnpm": "7.5.1" + "pnpm": "10.x" } }, "node_modules/@humanfs/core": { @@ -1941,7 +1941,6 @@ "integrity": "sha512-xBJdRUiCwKpr0OYrOzPwAyNGtsVzoBx32HFPJVv6S+sFA9TmBIBDaqNlFPmBH58ZjgNnGhEr/4oBZvGr4q4TjQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.1.1", "lodash.isequal": "^4.5.0" @@ -2071,13 +2070,13 @@ } }, "node_modules/@openzeppelin/docs-utils": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@openzeppelin/docs-utils/-/docs-utils-0.1.5.tgz", - "integrity": "sha512-GfqXArKmdq8rv+hsP+g8uS1VEkvMIzWs31dCONffzmqFwJ+MOsaNQNZNXQnLRgUkzk8i5mTNDjJuxDy+aBZImQ==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@openzeppelin/docs-utils/-/docs-utils-0.1.6.tgz", + "integrity": "sha512-cVLtDPrCdVgnLV9QRK9D1jrTB8ezQ8tCLTM4g6PHe9TIK3DbO6lSizLF98DhncK2bk6uodOLRT3LO1WtNzei1Q==", "dev": true, "license": "MIT", "dependencies": { - "@frangio/servbot": "^0.2.5", + "@frangio/servbot": "^0.3.0-1", "chalk": "^3.0.0", "chokidar": "^3.5.3", "env-paths": "^2.2.0", @@ -2611,7 +2610,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2679,7 +2677,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3283,7 +3280,6 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -4324,7 +4320,6 @@ "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4742,7 +4737,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -5525,7 +5519,6 @@ "integrity": "sha512-hwEUBvMJzl3Iuru5bfMOEDeF2d7cbMNNF46rkwdo8AeW2GDT4VxFLyYWTi6PTLrZiftHPDiKDlAdAiGvsR9FYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.1.2", @@ -8453,7 +8446,6 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10390,7 +10382,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10969,7 +10960,6 @@ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index 6f2d411dd5b..41095bfa529 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@nomicfoundation/hardhat-chai-matchers": "^2.0.6", "@nomicfoundation/hardhat-ethers": "^3.0.9", "@nomicfoundation/hardhat-network-helpers": "^1.0.13", - "@openzeppelin/docs-utils": "^0.1.5", + "@openzeppelin/docs-utils": "^0.1.6", "@openzeppelin/merkle-tree": "^1.0.7", "@openzeppelin/upgrade-safe-transpiler": "^0.4.1", "@openzeppelin/upgrades-core": "^1.20.6", diff --git a/test/token/ERC20/extensions/draft-ERC7246.test.js b/test/token/ERC20/extensions/draft-ERC7246.test.js new file mode 100644 index 00000000000..f239e1d9269 --- /dev/null +++ b/test/token/ERC20/extensions/draft-ERC7246.test.js @@ -0,0 +1,190 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { shouldBehaveLikeERC20 } = require('../ERC20.behavior.js'); + +const name = 'My Token'; +const symbol = 'MTKN'; +const value = 1000n; + +async function fixture() { + // this.accounts is used by shouldBehaveLikeERC20 + const accounts = await ethers.getSigners(); + const [holder, recipient, other] = accounts; + + const token = await ethers.deployContract('$ERC7246', [name, symbol]); + await token.$_mint(holder, value); + + return { + accounts, + holder, + other, + token, + recipient, + }; +} + +describe('ERC7246', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('encumber', function () { + beforeEach(async function () { + this.encumberAmount = 400n; + await this.token.connect(this.holder).encumber(this.other, this.encumberAmount); + }); + + it('should reduce `availableBalanceOf`', async function () { + await expect(this.token.availableBalanceOf(this.holder)).to.eventually.equal(value - this.encumberAmount); + }); + + it('should not reduce `balanceOf`', async function () { + await expect(this.token.balanceOf(this.holder)).to.eventually.equal(value); + }); + + it('should update `encumbrances`', async function () { + await expect(this.token.encumbrances(this.holder, this.other)).to.eventually.eq(this.encumberAmount); + }); + + it('should update `encumberedBalanceOf`', async function () { + await expect(this.token.encumberedBalanceOf(this.holder)).to.eventually.eq(this.encumberAmount); + }); + + it('should revert if self encumbrance', async function () { + await expect(this.token.connect(this.holder).encumber(this.holder, 1n)).to.be.revertedWithCustomError( + this.token, + 'ERC7246SelfEncumbrance', + ); + }); + + it('should revert if encumbrance is over `availableBalanceOf`', async function () { + const availableBalanceOf = value - this.encumberAmount; + await expect(this.token.connect(this.holder).encumber(this.other, availableBalanceOf + 1n)) + .to.be.revertedWithCustomError(this.token, 'ERC7246InsufficientAvailableBalance') + .withArgs(availableBalanceOf, availableBalanceOf + 1n); + }); + + it('should restrict `transfer` to available balance', async function () { + await expect(this.token.connect(this.holder).transfer(this.other, value)) + .to.be.revertedWithCustomError(this.token, 'ERC7246InsufficientAvailableBalance') + .withArgs(value - this.encumberAmount, value); + }); + + it('should restrict `transferFrom` to available balance', async function () { + await this.token.connect(this.holder).approve(this.recipient, value); + await this.token.connect(this.recipient).transferFrom(this.holder, this.recipient, value - this.encumberAmount); + + await expect(this.token.connect(this.recipient).transferFrom(this.holder, this.recipient, 1)) + .to.be.revertedWithCustomError(this.token, 'ERC7246InsufficientAvailableBalance') + .withArgs(0, 1); + }); + + it('should allow transfer within available balance', async function () { + await expect(this.token.connect(this.holder).transfer(this.other, value - 400n)) + .to.emit(this.token, 'Transfer') + .withArgs(this.holder.address, this.other.address, value - 400n); + }); + }); + + describe('encumberFrom', function () { + beforeEach(async function () { + await this.token.connect(this.holder).approve(this.other, 100n); + this.encumberTx = this.token.connect(this.other).encumberFrom(this.holder, this.other, 100n); + }); + + it('should increase `encumbrances`', async function () { + await this.encumberTx; + await expect(this.token.encumbrances(this.holder, this.other)).to.eventually.eq(100n); + }); + + it('should emit event', async function () { + await expect(this.encumberTx).to.emit(this.token, 'Encumber').withArgs(this.holder, this.other, 100n); + }); + + it('should consume approval', async function () { + await this.encumberTx; + await expect(this.token.allowance(this.holder, this.other)).to.eventually.eq(0); + }); + + // Encumbrances can be forwarded--meaning an account with an encumbrance can give it to another account + it('can forward encumbrance', async function () { + await this.encumberTx; + await expect(this.token.connect(this.other).encumberFrom(this.holder, this.recipient, 100n)) + .to.emit(this.token, 'Release') + .withArgs(this.holder, this.other, 100n) + .to.emit(this.token, 'Encumber') + .withArgs(this.holder, this.recipient, 100n); + + await expect(this.token.encumbrances(this.holder, this.other)).to.eventually.eq(0); + await expect(this.token.encumbrances(this.holder, this.recipient)).to.eventually.eq(100n); + }); + }); + + describe('release', function () { + beforeEach(async function () { + this.encumberAmount = 400n; + this.releaseAmount = 100n; + + await this.token.connect(this.holder).encumber(this.other, this.encumberAmount); + this.releaseTx = this.token.connect(this.other).release(this.holder, this.releaseAmount); + }); + + it('should emit event', async function () { + await expect(this.releaseTx).to.emit(this.token, 'Release').withArgs(this.holder, this.other, this.releaseAmount); + }); + + it('should reduce `encumberedBalanceOf`', async function () { + await this.releaseTx; + await expect(this.token.encumberedBalanceOf(this.holder)).to.eventually.eq( + this.encumberAmount - this.releaseAmount, + ); + }); + + it('should reduce `encumbrances`', async function () { + await this.releaseTx; + await expect(this.token.encumbrances(this.holder, this.other)).to.eventually.eq( + this.encumberAmount - this.releaseAmount, + ); + }); + + it('should revert if above respective `encumbrance`', async function () { + await this.releaseTx; + const remainingEncumbrance = this.encumberAmount - this.releaseAmount; + await expect(this.token.connect(this.other).release(this.holder, remainingEncumbrance + 1n)) + .to.be.revertedWithCustomError(this.token, 'ERC7246InsufficientEncumbrance') + .withArgs(remainingEncumbrance, remainingEncumbrance + 1n); + }); + }); + + // This is the main expected flow for consuming encumbrances. + describe('transferFrom', function () { + beforeEach(async function () { + this.encumberAmount = 400n; + await this.token.connect(this.holder).encumber(this.other, this.encumberAmount); + }); + + it('should emit release event', async function () { + await expect(this.token.connect(this.other).transferFrom(this.holder, this.recipient, this.encumberAmount)) + .to.emit(this.token, 'Release') + .withArgs(this.holder, this.other, this.encumberAmount); + }); + + it('should decrease encumbrances', async function () { + await this.token.connect(this.other).transferFrom(this.holder, this.recipient, this.encumberAmount - 100n); + await expect(this.token.encumbrances(this.holder, this.other)).to.eventually.eq(100n); + }); + + it('should consume encumbrance first, then allowance', async function () { + await this.token.connect(this.holder).approve(this.other, 200n); + await expect(this.token.connect(this.other).transferFrom(this.holder, this.recipient, this.encumberAmount + 150n)) + .to.emit(this.token, 'Release') + .withArgs(this.holder, this.other, this.encumberAmount); + await expect(this.token.encumbrances(this.holder, this.other)).to.eventually.eq(0n); + await expect(this.token.allowance(this.holder, this.other)).to.eventually.eq(50n); + }); + }); + + shouldBehaveLikeERC20(value); +});