From b4ce8d9097f56f8737ce4a4de3b8fa2d8e4ffa29 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:18:53 +0200 Subject: [PATCH 01/25] Add `ERC7984Freezable` extension. --- .changeset/seven-books-dig.md | 5 ++ .../token/extensions/ERC7984Freezable.sol | 88 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .changeset/seven-books-dig.md create mode 100644 contracts/token/extensions/ERC7984Freezable.sol diff --git a/.changeset/seven-books-dig.md b/.changeset/seven-books-dig.md new file mode 100644 index 00000000..2118252d --- /dev/null +++ b/.changeset/seven-books-dig.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +Add `ERC7984Freezable` extension. diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol new file mode 100644 index 00000000..692f43d7 --- /dev/null +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ConfidentialFungibleToken} from "../ConfidentialFungibleToken.sol"; +import {TFHESafeMath} from "../../utils/TFHESafeMath.sol"; + +/** + * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/pull/186. + * + * @dev Extension of {ERC7984} that allows to implement a freezing + * mechanism that can be managed by an authorized account with the + * {_setConfidentialFrozen} function. + * + * The freezing mechanism provides the guarantee to the contract owner + * (e.g. a DAO or a well-configured multisig) that a specific amount + * of tokens held by an account won't be transferable until those + * tokens are unfrozen. + */ +abstract contract ERC7984Freezable is ConfidentialFungibleToken { + /// @dev Frozen amount of tokens per address. + mapping(address account => euint64 encryptedAmount) private _frozenBalances; + + error ERC7984UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address user); + + event Frozen(address indexed account, euint64 encryptedAmount); + + /// @dev Returns the frozen balance of an account. + function confidentialFrozen(address account) public view virtual returns (euint64) { + return _frozenBalances[account]; + } + + /// @dev Returns the available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. + function confidentialAvailable(address account) public virtual returns (euint64) { + (ebool success, euint64 unfrozen) = TFHESafeMath.tryDecrease( + confidentialBalanceOf(account), + confidentialFrozen(account) + ); + FHE.allowThis(unfrozen); + return FHE.select(success, unfrozen, FHE.asEuint64(0)); + } + + /// @dev Internal function to set the frozen token amount for an account. + function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual returns (euint64) { + require( + FHE.isAllowed(encryptedAmount, msg.sender), + ERC7984UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) + ); + return __setConfidentialFrozen(account, encryptedAmount); + } + + /// @dev Internal function to set the frozen token amount for an account. + function _setConfidentialFrozen( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) internal virtual returns (euint64) { + return __setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); + } + + function __setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual returns (euint64) { + _frozenBalances[account] = encryptedAmount; + emit Frozen(account, encryptedAmount); + } + + /** + * @dev See {ERC7984-_update}. + * + * Requirements: + * + * * `from` must have sufficient unfrozen balance. + */ + function _update( + address from, + address to, + euint64 encryptedAmount + ) internal virtual override returns (euint64 transferred) { + if (from != address(0)) { + euint64 unfrozen = confidentialAvailable(from); + encryptedAmount = FHE.select(FHE.le(encryptedAmount, unfrozen), encryptedAmount, euint64.wrap(0)); + } + return super._update(from, to, encryptedAmount); + } + + // We don't check frozen balance for approvals since the actual transfer + // will be checked in _update. This allows for more flexible approval patterns. +} From f04162b2d62e1a8ba27547c13e849350c3f405bb Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 25 Jul 2025 18:22:28 +0200 Subject: [PATCH 02/25] Update pragma --- contracts/token/extensions/ERC7984Freezable.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol index 692f43d7..7037e66e 100644 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.26; +pragma solidity ^0.8.27; import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; import {ConfidentialFungibleToken} from "../ConfidentialFungibleToken.sol"; From ff2a74f1727ad2422c89c591f5908390107b2324 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 6 Aug 2025 18:26:56 +0200 Subject: [PATCH 03/25] Add freezable test --- .../ConfidentialFungibleTokenVotesMock.sol | 2 +- .../mocks/token/ERC7984FreezableMock.sol | 76 ++++++++ .../mocks/utils/HandleAccessManagerMock.sol | 2 +- .../token/extensions/ERC7984Freezable.sol | 44 +++-- contracts/utils/HandleAccessManager.sol | 37 +++- test/helpers/accounts.ts | 2 +- .../token/extensions/ERC7984Freezable.test.ts | 184 ++++++++++++++++++ 7 files changed, 321 insertions(+), 26 deletions(-) create mode 100644 contracts/mocks/token/ERC7984FreezableMock.sol create mode 100644 test/token/extensions/ERC7984Freezable.test.ts diff --git a/contracts/mocks/token/ConfidentialFungibleTokenVotesMock.sol b/contracts/mocks/token/ConfidentialFungibleTokenVotesMock.sol index e0cf0fac..55662782 100644 --- a/contracts/mocks/token/ConfidentialFungibleTokenVotesMock.sol +++ b/contracts/mocks/token/ConfidentialFungibleTokenVotesMock.sol @@ -48,5 +48,5 @@ abstract contract ConfidentialFungibleTokenVotesMock is ConfidentialFungibleToke _clockOverrideVal = val; } - function _validateHandleAllowance(bytes32 handle) internal view override {} + function _validateHandleAllowance(bytes32 handle, address account) internal view override {} } diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol new file mode 100644 index 00000000..147a8995 --- /dev/null +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {Impl} from "@fhevm/solidity/lib/Impl.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +import {ConfidentialFungibleToken} from "../../token/ConfidentialFungibleToken.sol"; +import {ERC7984Freezable} from "../../token/extensions/ERC7984Freezable.sol"; +import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; + +// solhint-disable func-name-mixedcase +contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessManager, SepoliaConfig { + bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); + + constructor( + string memory name, + string memory symbol, + string memory tokenUri, + address freezer + ) ConfidentialFungibleToken(name, symbol, tokenUri) { + _grantRole(FREEZER_ROLE, freezer); + setHandleAccessFunctionSelector(this.confidentialBalanceOf.selector, this.confidentialBalanceAccess.selector); + setHandleAccessFunctionSelector(this.confidentialAvailable.selector, this.confidentialAvailableAccess.selector); + } + + function confidentialBalanceAccess(address account) public { + _getHandleAllowance(confidentialBalanceOf(account), account); + } + + function confidentialAvailableAccess(address account) public { + _getHandleAllowance(confidentialAvailable(account), account); + } + + function _validateHandleAllowance( + bytes32 handle, + address account + ) internal view override onlySenderAccess(handle, account) {} + + function $_mint(address to, uint64 amount) public returns (euint64 transferred) { + return _mint(to, FHE.asEuint64(amount)); + } + + function _checkFreezer() internal override onlyRole(FREEZER_ROLE) {} + + //TODO: Move + /** + * HandleAccessManager + */ + + /* + //TODO: Use storage namespace + mapping(bytes4 handleFunctionSelector => bytes4) private accessFunctionSelectors; + + function getHandleAccessFunctionSelector(bytes4 handleFunctionSelector) public view virtual returns (bytes4) { + return accessFunctionSelectors[handleFunctionSelector]; + } + + error UnallowedHandleAccess(bytes32 handle, address account); + + modifier onlySenderAccess(bytes32 handle, address account) { + require(msg.sender == account, UnallowedHandleAccess(handle, account)); + _; + } + + function allowHandleAccess(euint64 handle, address account) private { + allowHandleAccess(euint64.unwrap(handle), account); + } + + function allowHandleAccess(bytes32 handle, address account) private { + _validateHandleAccess(handle, account); + Impl.allow(handle, account); + } +*/ +} diff --git a/contracts/mocks/utils/HandleAccessManagerMock.sol b/contracts/mocks/utils/HandleAccessManagerMock.sol index 7c2c6aa2..743cdf14 100644 --- a/contracts/mocks/utils/HandleAccessManagerMock.sol +++ b/contracts/mocks/utils/HandleAccessManagerMock.sol @@ -8,7 +8,7 @@ import {HandleAccessManager} from "./../../utils/HandleAccessManager.sol"; contract HandleAccessManagerMock is HandleAccessManager, SepoliaConfig { event HandleCreated(euint64 handle); - function _validateHandleAllowance(bytes32 handle) internal view override {} + function _validateHandleAllowance(bytes32 handle, address account) internal view override {} function createHandle(uint64 amount) public returns (euint64) { euint64 handle = FHE.asEuint64(amount); diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol index 7037e66e..c618ae0c 100644 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.27; import {FHE, ebool, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHESafeMath} from "../../utils/FHESafeMath.sol"; import {ConfidentialFungibleToken} from "../ConfidentialFungibleToken.sol"; -import {TFHESafeMath} from "../../utils/TFHESafeMath.sol"; /** * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/pull/186. @@ -33,37 +33,43 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { /// @dev Returns the available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. function confidentialAvailable(address account) public virtual returns (euint64) { - (ebool success, euint64 unfrozen) = TFHESafeMath.tryDecrease( + (ebool success, euint64 unfrozen) = FHESafeMath.tryDecrease( confidentialBalanceOf(account), confidentialFrozen(account) ); + unfrozen = FHE.select(success, unfrozen, FHE.asEuint64(0)); FHE.allowThis(unfrozen); - return FHE.select(success, unfrozen, FHE.asEuint64(0)); + return unfrozen; } /// @dev Internal function to set the frozen token amount for an account. - function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual returns (euint64) { + function setConfidentialFrozen( + address account, + externalEuint64 encryptedAmount, + bytes calldata inputProof + ) public virtual { + return setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); + } + + /// @dev Internal function to set the frozen token amount for an account. + function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual { require( FHE.isAllowed(encryptedAmount, msg.sender), ERC7984UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) ); - return __setConfidentialFrozen(account, encryptedAmount); + return _setConfidentialFrozen(account, encryptedAmount); } - /// @dev Internal function to set the frozen token amount for an account. - function _setConfidentialFrozen( - address account, - externalEuint64 encryptedAmount, - bytes calldata inputProof - ) internal virtual returns (euint64) { - return __setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); - } - - function __setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual returns (euint64) { + function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual { + _checkFreezer(); + FHE.allowThis(encryptedAmount); + FHE.allow(encryptedAmount, account); _frozenBalances[account] = encryptedAmount; emit Frozen(account, encryptedAmount); } + function _checkFreezer() internal virtual; + /** * @dev See {ERC7984-_update}. * @@ -71,14 +77,10 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { * * * `from` must have sufficient unfrozen balance. */ - function _update( - address from, - address to, - euint64 encryptedAmount - ) internal virtual override returns (euint64 transferred) { + function _update(address from, address to, euint64 encryptedAmount) internal virtual override returns (euint64) { if (from != address(0)) { euint64 unfrozen = confidentialAvailable(from); - encryptedAmount = FHE.select(FHE.le(encryptedAmount, unfrozen), encryptedAmount, euint64.wrap(0)); + encryptedAmount = FHE.select(FHE.le(encryptedAmount, unfrozen), encryptedAmount, FHE.asEuint64(0)); } return super._update(from, to, encryptedAmount); } diff --git a/contracts/utils/HandleAccessManager.sol b/contracts/utils/HandleAccessManager.sol index 2d5fd3ed..76d4b7d5 100644 --- a/contracts/utils/HandleAccessManager.sol +++ b/contracts/utils/HandleAccessManager.sol @@ -1,9 +1,39 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Impl} from "@fhevm/solidity/lib/Impl.sol"; abstract contract HandleAccessManager { + //TODO: Use storage namespace + mapping(bytes4 handleFunctionSelector => bytes4) private _accessFunctionSelectors; + + error UnallowedHandleAccess(bytes32 handle, address account); + + modifier onlySenderAccess(bytes32 handle, address account) { + require(msg.sender == account, UnallowedHandleAccess(handle, account)); + _; + } + + /// @dev Support handle access functions discoverability + function getHandleAccessFunctionSelector(bytes4 handleFunctionSelector) public view virtual returns (bytes4) { + return _accessFunctionSelectors[handleFunctionSelector]; + } + + function setHandleAccessFunctionSelector(bytes4 handleSelector, bytes4 accessSelector) public virtual { + _accessFunctionSelectors[handleSelector] = accessSelector; + } + + //TODO: Add all other handle types (euint8, ...) + function _getHandleAllowance(euint64 handle, address account) internal virtual { + _getHandleAllowance(euint64.unwrap(handle), account); + } + + function _getHandleAllowance(bytes32 handle, address account) internal virtual { + _validateHandleAllowance(handle, account); + Impl.allow(handle, account); + } + /** * @dev Get handle access for the given handle `handle`. Access will be given to the * account `account` with the given persistence flag. @@ -11,8 +41,10 @@ abstract contract HandleAccessManager { * NOTE: This function call is gated by `msg.sender` and validated by the * {_validateHandleAllowance} function. */ + //TODO: Rename to allowHandleAccess + //TODO: Set internal function getHandleAllowance(bytes32 handle, address account, bool persistent) public virtual { - _validateHandleAllowance(handle); + _validateHandleAllowance(handle, account); if (persistent) { Impl.allow(handle, account); } else { @@ -24,5 +56,6 @@ abstract contract HandleAccessManager { * @dev Unimplemented function that must revert if the message sender is not allowed to call * {getHandleAllowance} for the given handle. */ - function _validateHandleAllowance(bytes32 handle) internal view virtual; + //TODO: Rename to _validateHandleAccess + function _validateHandleAllowance(bytes32 handle, address account) internal view virtual; } diff --git a/test/helpers/accounts.ts b/test/helpers/accounts.ts index 12f09f34..f63fc36e 100644 --- a/test/helpers/accounts.ts +++ b/test/helpers/accounts.ts @@ -4,7 +4,7 @@ import { Addressable, Signer, ethers } from 'ethers'; import fs from 'fs'; import { HardhatRuntimeEnvironment } from 'hardhat/types'; -const ACL_ADDRESS = constants.ACL_CONTRACT_ADDRESS; +export const ACL_ADDRESS = constants.ACL_CONTRACT_ADDRESS; const DEFAULT_BALANCE: bigint = 10000n * ethers.WeiPerEther; diff --git a/test/token/extensions/ERC7984Freezable.test.ts b/test/token/extensions/ERC7984Freezable.test.ts new file mode 100644 index 00000000..da2cbc77 --- /dev/null +++ b/test/token/extensions/ERC7984Freezable.test.ts @@ -0,0 +1,184 @@ +import { IACL__factory } from '../../../types'; +import { ACL_ADDRESS } from '../../helpers/accounts'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { expect } from 'chai'; +import { ethers, fhevm } from 'hardhat'; + +/* eslint-disable no-unexpected-multiline */ +describe('ERC7984Freezable', function () { + async function deployFixture() { + const [holder, recipient, freezer, operator, anyone] = await ethers.getSigners(); + const token = await ethers.deployContract('ERC7984FreezableMock', ['name', 'symbol', 'uri', freezer.address]); + const acl = IACL__factory.connect(ACL_ADDRESS, ethers.provider); + return { token, acl, holder, recipient, freezer, operator, anyone }; + } + + it('should set and get confidential frozen', async function () { + const { token, acl, holder, recipient, freezer } = await deployFixture(); + await token + .connect(holder) + .$_mint(recipient.address, 1000) + .then(tx => tx.wait()); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), freezer.address) + .add64(100) + .encrypt(); + await expect( + token + .connect(freezer) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.emit(token, 'Frozen') + .withArgs(recipient.address, encryptedInput.handles[0]); + const frozenHandle = await token.confidentialFrozen(recipient.address); + expect(frozenHandle).to.equal(ethers.hexlify(encryptedInput.handles[0])); + expect(await acl.isAllowed(frozenHandle, recipient.address)).to.be.true; + expect(await fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), recipient)).to.equal( + 100, + ); + const balanceHandle = await token.confidentialBalanceOf(recipient.address); + expect( + await fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), recipient), + ).to.equal(1000); + const confidentialAvailableFunction = 'confidentialAvailable'; + const confidentialAvailableArgs = recipient.address; + const availableHandle = await token[confidentialAvailableFunction].staticCall(confidentialAvailableArgs); + const confidentialAvailableAccessFunctionSelector = await token.getHandleAccessFunctionSelector( + token.interface.getFunction(confidentialAvailableFunction).selector, + ); + await (token as any) + .connect(recipient) + [confidentialAvailableAccessFunctionSelector](confidentialAvailableArgs) + .then(tx => tx.wait()); + expect( + await fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), + ).to.equal(900); + }); + + it('should not set confidential frozen if not called by freezer', async function () { + const { token, holder, recipient, anyone } = await deployFixture(); + await token.$_mint(holder.address, 1000).then(tx => tx.wait()); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), anyone.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(anyone) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount') + .withArgs(anyone.address, ethers.id('FREEZER_ROLE')); + }); + + it('should transfer max available', async function () { + const { token, holder, recipient, freezer, anyone } = await deployFixture(); + await token + .connect(holder) + .$_mint(recipient.address, 1000) + .then(tx => tx.wait()); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), freezer.address) + .add64(100) + .encrypt(); + await token + .connect(freezer) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ) + .then(tx => tx.wait()); + const confidentialAvailableFunction = 'confidentialAvailable'; + const confidentialAvailableArgs = recipient.address; + const availableHandle = await token[confidentialAvailableFunction].staticCall(confidentialAvailableArgs); + const confidentialAvailableAccessFunctionSelector = await token.getHandleAccessFunctionSelector( + token.interface.getFunction(confidentialAvailableFunction).selector, + ); + await (token as any) + .connect(recipient) + [confidentialAvailableAccessFunctionSelector](confidentialAvailableArgs) + .then(tx => tx.wait()); + expect( + await fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), + ).to.equal(900); + const encryptedInput2 = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(900) + .encrypt(); + await token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone.address, + encryptedInput2.handles[0], + encryptedInput2.inputProof, + ) + .then(tx => tx.wait()); + await token + .connect(recipient) + .confidentialBalanceAccess(recipient.address) + .then(tx => tx.wait()); + expect( + await fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient.address), + await token.getAddress(), + recipient, + ), + ).to.equal(100); + }); + + it('should transfer zero if transferring more than available', async function () { + const { token, holder, recipient, freezer, anyone } = await deployFixture(); + await token + .connect(holder) + .$_mint(recipient.address, 1000) + .then(tx => tx.wait()); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), freezer.address) + .add64(500) + .encrypt(); + await token + .connect(freezer) + ['setConfidentialFrozen(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ) + .then(tx => tx.wait()); + const encryptedInput2 = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(501) + .encrypt(); + await token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + anyone.address, + encryptedInput2.handles[0], + encryptedInput2.inputProof, + ) + .then(tx => tx.wait()); + await token + .connect(recipient) + .confidentialBalanceAccess(recipient.address) + .then(tx => tx.wait()); + expect( + await fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient.address), + await token.getAddress(), + recipient, + ), + ).to.equal(1000); + }); +}); +/* eslint-disable no-unexpected-multiline */ From f485ec06f98a69221912ad8a3bf525e07b9a955b Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 6 Aug 2025 18:28:37 +0200 Subject: [PATCH 04/25] Remove unused functions in freezable mock --- .../mocks/token/ERC7984FreezableMock.sol | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index 147a8995..63d8d180 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -43,34 +43,4 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa } function _checkFreezer() internal override onlyRole(FREEZER_ROLE) {} - - //TODO: Move - /** - * HandleAccessManager - */ - - /* - //TODO: Use storage namespace - mapping(bytes4 handleFunctionSelector => bytes4) private accessFunctionSelectors; - - function getHandleAccessFunctionSelector(bytes4 handleFunctionSelector) public view virtual returns (bytes4) { - return accessFunctionSelectors[handleFunctionSelector]; - } - - error UnallowedHandleAccess(bytes32 handle, address account); - - modifier onlySenderAccess(bytes32 handle, address account) { - require(msg.sender == account, UnallowedHandleAccess(handle, account)); - _; - } - - function allowHandleAccess(euint64 handle, address account) private { - allowHandleAccess(euint64.unwrap(handle), account); - } - - function allowHandleAccess(bytes32 handle, address account) private { - _validateHandleAccess(handle, account); - Impl.allow(handle, account); - } -*/ } From a961e3348ec451df5c00bb9ccc208210dde65570 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 19 Aug 2025 10:19:13 +0200 Subject: [PATCH 05/25] Bring back previous HandleAccessManager --- .../mocks/token/ERC7984FreezableMock.sol | 17 ++++------ contracts/utils/HandleAccessManager.sol | 33 ------------------- .../token/extensions/ERC7984Freezable.test.ts | 24 +++----------- 3 files changed, 10 insertions(+), 64 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index 63d8d180..e183ea07 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -14,6 +14,8 @@ import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessManager, SepoliaConfig { bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); + error UnallowedHandleAccess(bytes32 handle, address account); + constructor( string memory name, string memory symbol, @@ -21,22 +23,15 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa address freezer ) ConfidentialFungibleToken(name, symbol, tokenUri) { _grantRole(FREEZER_ROLE, freezer); - setHandleAccessFunctionSelector(this.confidentialBalanceOf.selector, this.confidentialBalanceAccess.selector); - setHandleAccessFunctionSelector(this.confidentialAvailable.selector, this.confidentialAvailableAccess.selector); - } - - function confidentialBalanceAccess(address account) public { - _getHandleAllowance(confidentialBalanceOf(account), account); } function confidentialAvailableAccess(address account) public { - _getHandleAllowance(confidentialAvailable(account), account); + getHandleAllowance(euint64.unwrap(confidentialAvailable(account)), account, true); } - function _validateHandleAllowance( - bytes32 handle, - address account - ) internal view override onlySenderAccess(handle, account) {} + function _validateHandleAllowance(bytes32 handle, address account) internal view override { + require(msg.sender == account, UnallowedHandleAccess(handle, account)); + } function $_mint(address to, uint64 amount) public returns (euint64 transferred) { return _mint(to, FHE.asEuint64(amount)); diff --git a/contracts/utils/HandleAccessManager.sol b/contracts/utils/HandleAccessManager.sol index 76d4b7d5..292bad73 100644 --- a/contracts/utils/HandleAccessManager.sol +++ b/contracts/utils/HandleAccessManager.sol @@ -1,39 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {Impl} from "@fhevm/solidity/lib/Impl.sol"; abstract contract HandleAccessManager { - //TODO: Use storage namespace - mapping(bytes4 handleFunctionSelector => bytes4) private _accessFunctionSelectors; - - error UnallowedHandleAccess(bytes32 handle, address account); - - modifier onlySenderAccess(bytes32 handle, address account) { - require(msg.sender == account, UnallowedHandleAccess(handle, account)); - _; - } - - /// @dev Support handle access functions discoverability - function getHandleAccessFunctionSelector(bytes4 handleFunctionSelector) public view virtual returns (bytes4) { - return _accessFunctionSelectors[handleFunctionSelector]; - } - - function setHandleAccessFunctionSelector(bytes4 handleSelector, bytes4 accessSelector) public virtual { - _accessFunctionSelectors[handleSelector] = accessSelector; - } - - //TODO: Add all other handle types (euint8, ...) - function _getHandleAllowance(euint64 handle, address account) internal virtual { - _getHandleAllowance(euint64.unwrap(handle), account); - } - - function _getHandleAllowance(bytes32 handle, address account) internal virtual { - _validateHandleAllowance(handle, account); - Impl.allow(handle, account); - } - /** * @dev Get handle access for the given handle `handle`. Access will be given to the * account `account` with the given persistence flag. @@ -41,8 +11,6 @@ abstract contract HandleAccessManager { * NOTE: This function call is gated by `msg.sender` and validated by the * {_validateHandleAllowance} function. */ - //TODO: Rename to allowHandleAccess - //TODO: Set internal function getHandleAllowance(bytes32 handle, address account, bool persistent) public virtual { _validateHandleAllowance(handle, account); if (persistent) { @@ -56,6 +24,5 @@ abstract contract HandleAccessManager { * @dev Unimplemented function that must revert if the message sender is not allowed to call * {getHandleAllowance} for the given handle. */ - //TODO: Rename to _validateHandleAccess function _validateHandleAllowance(bytes32 handle, address account) internal view virtual; } diff --git a/test/token/extensions/ERC7984Freezable.test.ts b/test/token/extensions/ERC7984Freezable.test.ts index da2cbc77..0ce87c94 100644 --- a/test/token/extensions/ERC7984Freezable.test.ts +++ b/test/token/extensions/ERC7984Freezable.test.ts @@ -44,15 +44,11 @@ describe('ERC7984Freezable', function () { expect( await fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), recipient), ).to.equal(1000); - const confidentialAvailableFunction = 'confidentialAvailable'; const confidentialAvailableArgs = recipient.address; - const availableHandle = await token[confidentialAvailableFunction].staticCall(confidentialAvailableArgs); - const confidentialAvailableAccessFunctionSelector = await token.getHandleAccessFunctionSelector( - token.interface.getFunction(confidentialAvailableFunction).selector, - ); + const availableHandle = await token.confidentialAvailable.staticCall(confidentialAvailableArgs); await (token as any) .connect(recipient) - [confidentialAvailableAccessFunctionSelector](confidentialAvailableArgs) + .confidentialAvailableAccess(confidentialAvailableArgs) .then(tx => tx.wait()); expect( await fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), @@ -98,15 +94,11 @@ describe('ERC7984Freezable', function () { encryptedInput.inputProof, ) .then(tx => tx.wait()); - const confidentialAvailableFunction = 'confidentialAvailable'; const confidentialAvailableArgs = recipient.address; - const availableHandle = await token[confidentialAvailableFunction].staticCall(confidentialAvailableArgs); - const confidentialAvailableAccessFunctionSelector = await token.getHandleAccessFunctionSelector( - token.interface.getFunction(confidentialAvailableFunction).selector, - ); + const availableHandle = await token.confidentialAvailable.staticCall(confidentialAvailableArgs); await (token as any) .connect(recipient) - [confidentialAvailableAccessFunctionSelector](confidentialAvailableArgs) + .confidentialAvailableAccess(confidentialAvailableArgs) .then(tx => tx.wait()); expect( await fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), @@ -123,10 +115,6 @@ describe('ERC7984Freezable', function () { encryptedInput2.inputProof, ) .then(tx => tx.wait()); - await token - .connect(recipient) - .confidentialBalanceAccess(recipient.address) - .then(tx => tx.wait()); expect( await fhevm.userDecryptEuint( FhevmType.euint64, @@ -167,10 +155,6 @@ describe('ERC7984Freezable', function () { encryptedInput2.inputProof, ) .then(tx => tx.wait()); - await token - .connect(recipient) - .confidentialBalanceAccess(recipient.address) - .then(tx => tx.wait()); expect( await fhevm.userDecryptEuint( FhevmType.euint64, From 3c759fb440334898109da47ff937af5166063a08 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:59:37 +0200 Subject: [PATCH 06/25] Move allow available to access function in mock --- contracts/mocks/token/ERC7984FreezableMock.sol | 5 +++-- contracts/token/extensions/ERC7984Freezable.sol | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index e183ea07..518373b0 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.24; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; -import {Impl} from "@fhevm/solidity/lib/Impl.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ConfidentialFungibleToken} from "../../token/ConfidentialFungibleToken.sol"; import {ERC7984Freezable} from "../../token/extensions/ERC7984Freezable.sol"; @@ -26,7 +25,9 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa } function confidentialAvailableAccess(address account) public { - getHandleAllowance(euint64.unwrap(confidentialAvailable(account)), account, true); + euint64 available = confidentialAvailable(account); + FHE.allowThis(available); + getHandleAllowance(euint64.unwrap(available), account, true); } function _validateHandleAllowance(bytes32 handle, address account) internal view override { diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol index c618ae0c..0ad5d2c8 100644 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -38,7 +38,6 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { confidentialFrozen(account) ); unfrozen = FHE.select(success, unfrozen, FHE.asEuint64(0)); - FHE.allowThis(unfrozen); return unfrozen; } From cd81483cc0452c0067ca20ab9a8c4960f3175dda Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:02:15 +0200 Subject: [PATCH 07/25] Swap event & error order --- contracts/token/extensions/ERC7984Freezable.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol index 0ad5d2c8..be5e5cc9 100644 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -22,10 +22,10 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { /// @dev Frozen amount of tokens per address. mapping(address account => euint64 encryptedAmount) private _frozenBalances; - error ERC7984UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address user); - event Frozen(address indexed account, euint64 encryptedAmount); + error ERC7984UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address user); + /// @dev Returns the frozen balance of an account. function confidentialFrozen(address account) public view virtual returns (euint64) { return _frozenBalances[account]; From 3bb3f2e8a296023a872e6375e565428a13d25a3f Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:15:42 +0200 Subject: [PATCH 08/25] Update inline documentation --- .../token/extensions/ERC7984Freezable.sol | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol index be5e5cc9..9e3a603d 100644 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -9,29 +9,30 @@ import {ConfidentialFungibleToken} from "../ConfidentialFungibleToken.sol"; /** * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/pull/186. * - * @dev Extension of {ERC7984} that allows to implement a freezing - * mechanism that can be managed by an authorized account with the - * {_setConfidentialFrozen} function. + * @dev Extension of {ERC7984} that allows to implement a confidential + * freezing mechanism that can be managed by an authorized account with + * the {_setConfidentialFrozen} function. * * The freezing mechanism provides the guarantee to the contract owner - * (e.g. a DAO or a well-configured multisig) that a specific amount - * of tokens held by an account won't be transferable until those + * (e.g. a DAO or a well-configured multisig) that a specific confidential + * amount of tokens held by an account won't be transferable until those * tokens are unfrozen. */ abstract contract ERC7984Freezable is ConfidentialFungibleToken { - /// @dev Frozen amount of tokens per address. + /// @dev Confidential frozen amount of tokens per address. mapping(address account => euint64 encryptedAmount) private _frozenBalances; + /// @dev Emitted when a confidential amount of token is frozen for an account event Frozen(address indexed account, euint64 encryptedAmount); error ERC7984UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address user); - /// @dev Returns the frozen balance of an account. + /// @dev Returns the confidential frozen balance of an account. function confidentialFrozen(address account) public view virtual returns (euint64) { return _frozenBalances[account]; } - /// @dev Returns the available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. + /// @dev Returns the confidential available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. function confidentialAvailable(address account) public virtual returns (euint64) { (ebool success, euint64 unfrozen) = FHESafeMath.tryDecrease( confidentialBalanceOf(account), @@ -41,7 +42,7 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { return unfrozen; } - /// @dev Internal function to set the frozen token amount for an account. + /// @dev Freezes a confidential amount of token amount for an account with a proof. function setConfidentialFrozen( address account, externalEuint64 encryptedAmount, @@ -50,7 +51,7 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { return setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); } - /// @dev Internal function to set the frozen token amount for an account. + /// @dev Freezes a confidential amount of token amount for an account. function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual { require( FHE.isAllowed(encryptedAmount, msg.sender), @@ -59,6 +60,7 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { return _setConfidentialFrozen(account, encryptedAmount); } + /// @dev Internal function to freeze a confidential amount of token amount for an account. function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual { _checkFreezer(); FHE.allowThis(encryptedAmount); @@ -67,14 +69,12 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { emit Frozen(account, encryptedAmount); } + /// @dev Checks has freezer role. function _checkFreezer() internal virtual; /** - * @dev See {ERC7984-_update}. - * - * Requirements: - * - * * `from` must have sufficient unfrozen balance. + * @dev See {ERC7984-_update}. The `from` account must have sufficient unfrozen balance, + * otherwise the update is performed with a zero amount. */ function _update(address from, address to, euint64 encryptedAmount) internal virtual override returns (euint64) { if (from != address(0)) { @@ -83,7 +83,4 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { } return super._update(from, to, encryptedAmount); } - - // We don't check frozen balance for approvals since the actual transfer - // will be checked in _update. This allows for more flexible approval patterns. } From ac9ae069b3df696534775b73e22e5142f1706285 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Tue, 19 Aug 2025 18:18:59 +0200 Subject: [PATCH 09/25] Remove useless var --- contracts/token/extensions/ERC7984Freezable.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/contracts/token/extensions/ERC7984Freezable.sol b/contracts/token/extensions/ERC7984Freezable.sol index 9e3a603d..2fb45362 100644 --- a/contracts/token/extensions/ERC7984Freezable.sol +++ b/contracts/token/extensions/ERC7984Freezable.sol @@ -38,8 +38,7 @@ abstract contract ERC7984Freezable is ConfidentialFungibleToken { confidentialBalanceOf(account), confidentialFrozen(account) ); - unfrozen = FHE.select(success, unfrozen, FHE.asEuint64(0)); - return unfrozen; + return FHE.select(success, unfrozen, FHE.asEuint64(0)); } /// @dev Freezes a confidential amount of token amount for an account with a proof. From 0286f57fc4743ed039894413f5d86f63a1b56532 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:02:25 +0200 Subject: [PATCH 10/25] Apply suggestions from review --- .../ERC7984/extensions/ERC7984Freezable.sol | 18 +++++++++--------- test/token/extensions/ERC7984Freezable.test.ts | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index 7d4be035..b3ef2725 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -23,7 +23,7 @@ abstract contract ERC7984Freezable is ERC7984 { mapping(address account => euint64 encryptedAmount) private _frozenBalances; /// @dev Emitted when a confidential amount of token is frozen for an account - event Frozen(address indexed account, euint64 encryptedAmount); + event TokensFrozen(address indexed account, euint64 encryptedAmount); /// @dev Returns the confidential frozen balance of an account. function confidentialFrozen(address account) public view virtual returns (euint64) { @@ -39,39 +39,39 @@ abstract contract ERC7984Freezable is ERC7984 { return FHE.select(success, unfrozen, FHE.asEuint64(0)); } - /// @dev Freezes a confidential amount of token amount for an account with a proof. + /// @dev Freezes a confidential amount of tokens for an account with a proof. function setConfidentialFrozen( address account, externalEuint64 encryptedAmount, bytes calldata inputProof ) public virtual { - return setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); + setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); } - /// @dev Freezes a confidential amount of token amount for an account. + /// @dev Freezes a confidential amount of tokens for an account. function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual { require( FHE.isAllowed(encryptedAmount, msg.sender), ERC7984UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) ); - return _setConfidentialFrozen(account, encryptedAmount); + _setConfidentialFrozen(account, encryptedAmount); } - /// @dev Internal function to freeze a confidential amount of token amount for an account. + /// @dev Internal function to freeze a confidential amount of tokens for an account. function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual { _checkFreezer(); FHE.allowThis(encryptedAmount); FHE.allow(encryptedAmount, account); _frozenBalances[account] = encryptedAmount; - emit Frozen(account, encryptedAmount); + emit TokensFrozen(account, encryptedAmount); } - /// @dev Checks has freezer role. + /// @dev Unimplemented function that must revert if `msg.sender` is not authorized as a freezer. function _checkFreezer() internal virtual; /** * @dev See {ERC7984-_update}. The `from` account must have sufficient unfrozen balance, - * otherwise the update is performed with a zero amount. + * otherwise 0 tokens are transferred. */ function _update(address from, address to, euint64 encryptedAmount) internal virtual override returns (euint64) { if (from != address(0)) { diff --git a/test/token/extensions/ERC7984Freezable.test.ts b/test/token/extensions/ERC7984Freezable.test.ts index 0ce87c94..b2f50345 100644 --- a/test/token/extensions/ERC7984Freezable.test.ts +++ b/test/token/extensions/ERC7984Freezable.test.ts @@ -32,7 +32,7 @@ describe('ERC7984Freezable', function () { encryptedInput.inputProof, ), ) - .to.emit(token, 'Frozen') + .to.emit(token, 'TokensFrozen') .withArgs(recipient.address, encryptedInput.handles[0]); const frozenHandle = await token.confidentialFrozen(recipient.address); expect(frozenHandle).to.equal(ethers.hexlify(encryptedInput.handles[0])); From c790dc5465b6606ab7f7afdaba3bc14a8e958164 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:05:18 +0200 Subject: [PATCH 11/25] Move freezable test file to extensions dir --- test/token/{ => ERC7984}/extensions/ERC7984Freezable.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename test/token/{ => ERC7984}/extensions/ERC7984Freezable.test.ts (98%) diff --git a/test/token/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts similarity index 98% rename from test/token/extensions/ERC7984Freezable.test.ts rename to test/token/ERC7984/extensions/ERC7984Freezable.test.ts index b2f50345..11778204 100644 --- a/test/token/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -1,5 +1,5 @@ -import { IACL__factory } from '../../../types'; -import { ACL_ADDRESS } from '../../helpers/accounts'; +import { IACL__factory } from '../../../../types'; +import { ACL_ADDRESS } from '../../../helpers/accounts'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; From b5f28121157ce5feac668096dac6a118e2af1813 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:51:50 +0200 Subject: [PATCH 12/25] ERC7984Freezable extension behave like ERC7984 --- .../mocks/token/ERC7984FreezableMock.sol | 13 +- contracts/mocks/token/ERC7984Mock.sol | 6 +- contracts/mocks/token/ERC7984VotesMock.sol | 2 +- .../ERC7984/extensions/ERC7984Freezable.sol | 8 +- contracts/utils/FHESafeMath.sol | 12 +- test/token/ERC7984/ERC7984.behaviour.ts | 607 ++++++++++++++++++ test/token/ERC7984/ERC7984.test.ts | 590 +---------------- .../extensions/ERC7984Freezable.test.ts | 69 +- 8 files changed, 709 insertions(+), 598 deletions(-) create mode 100644 test/token/ERC7984/ERC7984.behaviour.ts diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index 8fc0d8dc..3f1d4c2c 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -8,9 +8,10 @@ import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC7984} from "../../token/ERC7984/ERC7984.sol"; import {ERC7984Freezable} from "../../token/ERC7984/extensions/ERC7984Freezable.sol"; import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; +import {ERC7984Mock} from "./ERC7984Mock.sol"; // solhint-disable func-name-mixedcase -contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessManager, SepoliaConfig { +contract ERC7984FreezableMock is ERC7984Mock, ERC7984Freezable, AccessControl, HandleAccessManager { bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); error UnallowedHandleAccess(bytes32 handle, address account); @@ -20,7 +21,7 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa string memory symbol, string memory tokenUri, address freezer - ) ERC7984(name, symbol, tokenUri) { + ) ERC7984Mock(name, symbol, tokenUri) { _grantRole(FREEZER_ROLE, freezer); } @@ -34,8 +35,12 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa require(msg.sender == account, UnallowedHandleAccess(handle, account)); } - function $_mint(address to, uint64 amount) public returns (euint64 transferred) { - return _mint(to, FHE.asEuint64(amount)); + function _update( + address from, + address to, + euint64 amount + ) internal virtual override(ERC7984, ERC7984Freezable) returns (euint64) { + return super._update(from, to, amount); } function _checkFreezer() internal override onlyRole(FREEZER_ROLE) {} diff --git a/contracts/mocks/token/ERC7984Mock.sol b/contracts/mocks/token/ERC7984Mock.sol index 68f11e14..eca808a7 100644 --- a/contracts/mocks/token/ERC7984Mock.sol +++ b/contracts/mocks/token/ERC7984Mock.sol @@ -17,9 +17,9 @@ contract ERC7984Mock is ERC7984, SepoliaConfig { _OWNER = msg.sender; } - function _update(address from, address to, euint64 amount) internal virtual override returns (euint64 transferred) { - transferred = super._update(from, to, amount); - FHE.allow(confidentialTotalSupply(), _OWNER); + function confidentialTotalSupplyAccess() public { + require(msg.sender == _OWNER); + FHE.allow(confidentialTotalSupply(), msg.sender); } function $_mint( diff --git a/contracts/mocks/token/ERC7984VotesMock.sol b/contracts/mocks/token/ERC7984VotesMock.sol index 950d594d..d3dc56ca 100644 --- a/contracts/mocks/token/ERC7984VotesMock.sol +++ b/contracts/mocks/token/ERC7984VotesMock.sol @@ -34,7 +34,7 @@ abstract contract ERC7984VotesMock is ERC7984Mock, ERC7984Votes { address from, address to, euint64 amount - ) internal virtual override(ERC7984Mock, ERC7984Votes) returns (euint64) { + ) internal virtual override(ERC7984, ERC7984Votes) returns (euint64) { return super._update(from, to, amount); } diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index b3ef2725..f900ed4d 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -31,12 +31,8 @@ abstract contract ERC7984Freezable is ERC7984 { } /// @dev Returns the confidential available (unfrozen) balance of an account. Up to {confidentialBalanceOf}. - function confidentialAvailable(address account) public virtual returns (euint64) { - (ebool success, euint64 unfrozen) = FHESafeMath.tryDecrease( - confidentialBalanceOf(account), - confidentialFrozen(account) - ); - return FHE.select(success, unfrozen, FHE.asEuint64(0)); + function confidentialAvailable(address account) public virtual returns (euint64 unfrozen) { + (, unfrozen) = FHESafeMath.tryDecrease(confidentialBalanceOf(account), confidentialFrozen(account)); } /// @dev Freezes a confidential amount of tokens for an account with a proof. diff --git a/contracts/utils/FHESafeMath.sol b/contracts/utils/FHESafeMath.sol index e184413c..6cf164e9 100644 --- a/contracts/utils/FHESafeMath.sol +++ b/contracts/utils/FHESafeMath.sol @@ -31,7 +31,15 @@ library FHESafeMath { * and `updated` will be the original value. */ function tryDecrease(euint64 oldValue, euint64 delta) internal returns (ebool success, euint64 updated) { - success = FHE.ge(oldValue, delta); - updated = FHE.select(success, FHE.sub(oldValue, delta), oldValue); + if (!FHE.isInitialized(oldValue)) { + success = FHE.asEbool(false); + updated = FHE.asEuint64(0); + } else if (!FHE.isInitialized(delta)) { + success = FHE.asEbool(true); + updated = oldValue; + } else { + success = FHE.ge(oldValue, delta); + updated = FHE.select(success, FHE.sub(oldValue, delta), oldValue); + } } } diff --git a/test/token/ERC7984/ERC7984.behaviour.ts b/test/token/ERC7984/ERC7984.behaviour.ts new file mode 100644 index 00000000..0544e26c --- /dev/null +++ b/test/token/ERC7984/ERC7984.behaviour.ts @@ -0,0 +1,607 @@ +import { ERC7984ReceiverMock } from '../../../types'; +import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; +import { allowHandle } from '../../helpers/accounts'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import hre, { ethers, fhevm } from 'hardhat'; + +const name = 'ConfidentialFungibleToken'; +const symbol = 'CFT'; +const uri = 'https://example.com/metadata'; + +/* eslint-disable no-unexpected-multiline */ +function shouldBehaveLikeERC7984( + deployFixture: () => Promise<{ + token: $ERC7984Mock; + holder: HardhatEthersSigner; + recipient: HardhatEthersSigner; + operator: HardhatEthersSigner; + anyone: HardhatEthersSigner; + }>, +) { + describe('ERC7984', function () { + describe('constructor', function () { + it('sets the name', async function () { + const { token } = await deployFixture(); + await expect(token.name()).to.eventually.equal(name); + }); + + it('sets the symbol', async function () { + const { token } = await deployFixture(); + await expect(token.symbol()).to.eventually.equal(symbol); + }); + + it('sets the uri', async function () { + const { token } = await deployFixture(); + await expect(token.tokenURI()).to.eventually.equal(uri); + }); + + it('decimals is 6', async function () { + const { token } = await deployFixture(); + await expect(token.decimals()).to.eventually.equal(6); + }); + }); + + describe('confidentialBalanceOf', function () { + it('handle can be reencryped by owner', async function () { + const { token, holder } = await deployFixture(); + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(1000); + }); + + it('handle cannot be reencryped by non-owner', async function () { + const { token, holder, anyone } = await deployFixture(); + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), anyone), + ).to.be.rejectedWith(generateReencryptionErrorMessage(balanceOfHandleHolder, anyone.address)); + }); + }); + + describe('mint', function () { + for (const existingUser of [false, true]) { + it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { + const { token, holder } = await deployFixture(); + if (existingUser) { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + } + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token + .connect(holder) + .confidentialTotalSupplyAccess() + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('burn', function () { + for (const sufficientBalance of [false, true]) { + it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const { token, holder } = await deployFixture(); + const burnAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(burnAmount) + .encrypt(); + + await token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token + .connect(holder) + .confidentialTotalSupplyAccess() + .then(tx => tx.wait()); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('transfer', function () { + for (const asSender of [true, false]) { + describe(asSender ? 'as sender' : 'as operator', function () { + let [holder, recipient, operator]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + beforeEach(async function () { + ({ token, holder, recipient, operator } = await deployFixture()); + if (!asSender) { + const timestamp = (await ethers.provider.getBlock('latest'))!.timestamp + 100; + await token.connect(holder).setOperator(operator.address, timestamp); + } + }); + + if (!asSender) { + for (const withCallback of [false, true]) { + describe(withCallback ? 'with callback' : 'without callback', function () { + let encryptedInput: any; + let params: any; + + beforeEach(async function () { + encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), operator.address) + .add64(100) + .encrypt(); + + params = [holder.address, recipient.address, encryptedInput.handles[0], encryptedInput.inputProof]; + if (withCallback) { + params.push('0x'); + } + }); + + it('without operator approval should fail', async function () { + await token.$_setOperator(holder, operator, 0); + + await expect( + token + .connect(operator) + [ + withCallback + ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](...params), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') + .withArgs(holder.address, operator.address); + }); + + it('should be successful', async function () { + await token + .connect(operator) + [ + withCallback + ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](...params); + }); + }); + } + } + + // Edge cases to run with sender as caller + if (asSender) { + it('with no balance should revert', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + holder.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984ZeroBalance') + .withArgs(recipient.address); + }); + + it('to zero address', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(holder) + ['confidentialTransfer(address,bytes32,bytes)']( + ethers.ZeroAddress, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + } + + for (const sufficientBalance of [false, true]) { + it(`${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const transferAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), asSender ? holder.address : operator.address) + .add64(transferAmount) + .encrypt(); + + let tx; + if (asSender) { + tx = await token + .connect(holder) + ['confidentialTransfer(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + } else { + tx = await token + .connect(operator) + ['confidentialTransferFrom(address,address,bytes32,bytes)']( + holder.address, + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + } + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + expect(transferEvent.args[0]).to.equal(holder.address); + expect(transferEvent.args[1]).to.equal(recipient.address); + + const transferAmountHandle = transferEvent.args[2]; + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), recipient), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + // Other can not reencrypt the transfer amount + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), operator), + ).to.be.rejectedWith(generateReencryptionErrorMessage(transferAmountHandle, operator.address)); + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, holderBalanceHandle, await token.getAddress(), holder), + ).to.eventually.equal(1000 - (sufficientBalance ? transferAmount : 0)); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, recipientBalanceHandle, await token.getAddress(), recipient), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + }); + } + }); + } + + describe('without input proof', function () { + for (const [usingTransferFrom, withCallback] of [false, true].flatMap(val => [ + [val, false], + [val, true], + ])) { + describe(`using ${usingTransferFrom ? 'confidentialTransferFrom' : 'confidentialTransfer'} ${ + withCallback ? 'with callback' : '' + }`, function () { + async function callTransfer(contract: any, from: any, to: any, amount: any, sender: any = from) { + let functionParams = [to, amount]; + + if (withCallback) { + functionParams.push('0x'); + if (usingTransferFrom) { + functionParams.unshift(from); + await contract.connect(sender).confidentialTransferFromAndCall(...functionParams); + } else { + await contract.connect(sender).confidentialTransferAndCall(...functionParams); + } + } else { + if (usingTransferFrom) { + functionParams.unshift(from); + await contract.connect(sender).confidentialTransferFrom(...functionParams); + } else { + await contract.connect(sender).confidentialTransfer(...functionParams); + } + } + } + + it('full balance', async function () { + const { token, holder, recipient } = await deployFixture(); + const fullBalanceHandle = await token.confidentialBalanceOf(holder); + + await callTransfer(token, holder, recipient, fullBalanceHandle); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(1000); + }); + + it('other user balance should revert', async function () { + const { token, holder, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); + + const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); + await expect(callTransfer(token, holder, recipient, recipientBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(recipientBalanceHandle, holder); + }); + + if (usingTransferFrom) { + describe('without operator approval', function () { + let [holder, recipient, operator]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + beforeEach(async function () { + ({ token, holder, recipient, operator } = await deployFixture()); + await token.connect(holder).setOperator(operator.address, 0); + await allowHandle(hre, holder, operator, await token.confidentialBalanceOf(holder)); + }); + + it('should revert', async function () { + await expect( + callTransfer(token, holder, recipient, await token.confidentialBalanceOf(holder), operator), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') + .withArgs(holder.address, operator.address); + }); + }); + } + }); + } + }); + + it('internal function reverts on from address zero', async function () { + const { token, holder, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_transfer(address,address,bytes32,bytes)']( + ethers.ZeroAddress, + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('transfer with callback', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let recipientContract: ERC7984ReceiverMock; + let encryptedInput: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + recipientContract = await ethers.deployContract('ERC7984ReceiverMock'); + + encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + }); + + for (const callbackSuccess of [false, true]) { + it(`with callback running ${callbackSuccess ? 'successfully' : 'unsuccessfully'}`, async function () { + const tx = await token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.AbiCoder.defaultAbiCoder().encode(['bool'], [callbackSuccess]), + ); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(holder), + await token.getAddress(), + holder, + ), + ).to.eventually.equal(callbackSuccess ? 0 : 1000); + + // Verify event contents + expect(tx).to.emit(recipientContract, 'ConfidentialTransferCallback').withArgs(callbackSuccess); + const transferEvents = (await tx.wait()).logs.filter((log: any) => log.address === token.target); + + const outboundTransferEvent = transferEvents[0]; + const inboundTransferEvent = transferEvents[1]; + + expect(outboundTransferEvent.args[0]).to.equal(holder.address); + expect(outboundTransferEvent.args[1]).to.equal(recipientContract.target); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, outboundTransferEvent.args[2], await token.getAddress(), holder), + ).to.eventually.equal(1000); + + expect(inboundTransferEvent.args[0]).to.equal(recipientContract.target); + expect(inboundTransferEvent.args[1]).to.equal(holder.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, inboundTransferEvent.args[2], await token.getAddress(), holder), + ).to.eventually.equal(callbackSuccess ? 0 : 1000); + }); + } + + it('with callback reverting without a reason', async function () { + await expect( + token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + '0x', + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(recipientContract.target); + }); + + it('with callback reverting with a custom error', async function () { + await expect( + token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.AbiCoder.defaultAbiCoder().encode(['uint8'], [2]), + ), + ) + .to.be.revertedWithCustomError(recipientContract, 'InvalidInput') + .withArgs(2); + }); + + it('to an EOA', async function () { + await token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + '0x', + ); + + const balanceOfHandle = await token.confidentialBalanceOf(recipient); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandle, await token.getAddress(), recipient), + ).to.eventually.equal(1000); + }); + }); + + describe('disclose', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let expectedAmount: any; + let expectedHandle: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + expectedAmount = undefined; + expectedHandle = undefined; + }); + + it('user balance', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + expectedAmount = 1000n; + expectedHandle = holderBalanceHandle; + }); + + it('transaction amount', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + const tx = await token['confidentialTransfer(address,bytes32,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + const transferAmount = transferEvent.args[2]; + + await token.connect(recipient).discloseEncryptedAmount(transferAmount); + + expectedAmount = 400n; + expectedHandle = transferAmount; + }); + + it("other user's balance", async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(holderBalanceHandle, recipient); + }); + + it('invalid signature reverts', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; + }); + + afterEach(async function () { + if (expectedHandle === undefined || expectedAmount === undefined) return; + + await fhevm.awaitDecryptionOracle(); + + // Check that event was correctly emitted + const eventFilter = token.filters.AmountDisclosed(); + const discloseEvent = (await token.queryFilter(eventFilter))[0]; + expect(discloseEvent.args[0]).to.equal(expectedHandle); + expect(discloseEvent.args[1]).to.equal(expectedAmount); + }); + }); + }); +} +/* eslint-enable no-unexpected-multiline */ + +function generateReencryptionErrorMessage(handle: string, account: string): string { + return `User ${account} is not authorized to user decrypt handle ${handle}`; +} + +export { shouldBehaveLikeERC7984 }; diff --git a/test/token/ERC7984/ERC7984.test.ts b/test/token/ERC7984/ERC7984.test.ts index d28fcbb7..18093efc 100644 --- a/test/token/ERC7984/ERC7984.test.ts +++ b/test/token/ERC7984/ERC7984.test.ts @@ -1,7 +1,6 @@ -import { allowHandle } from '../../helpers/accounts'; -import { FhevmType } from '@fhevm/hardhat-plugin'; -import { expect } from 'chai'; -import hre, { ethers, fhevm } from 'hardhat'; +import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; +import { shouldBehaveLikeERC7984 } from './ERC7984.behaviour'; +import { ethers, fhevm } from 'hardhat'; const name = 'ConfidentialFungibleToken'; const symbol = 'CFT'; @@ -9,582 +8,19 @@ const uri = 'https://example.com/metadata'; /* eslint-disable no-unexpected-multiline */ describe('ERC7984', function () { - beforeEach(async function () { - const accounts = await ethers.getSigners(); - const [holder, recipient, operator] = accounts; - - const token = await ethers.deployContract('$ERC7984Mock', [name, symbol, uri]); - this.accounts = accounts.slice(3); - this.holder = holder; - this.recipient = recipient; - this.token = token; - this.operator = operator; - + async function deployFixture() { + const [holder, recipient, operator, anyone] = await ethers.getSigners(); + const token = (await ethers.deployContract('$ERC7984Mock', [name, symbol, uri])) as any as $ERC7984Mock; const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) + .createEncryptedInput(await token.getAddress(), holder.address) .add64(1000) .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + return { token, holder, recipient, operator, anyone }; + } - await this.token - .connect(this.holder) - ['$_mint(address,bytes32,bytes)'](this.holder, encryptedInput.handles[0], encryptedInput.inputProof); - }); - - describe('constructor', function () { - it('sets the name', async function () { - await expect(this.token.name()).to.eventually.equal(name); - }); - - it('sets the symbol', async function () { - await expect(this.token.symbol()).to.eventually.equal(symbol); - }); - - it('sets the uri', async function () { - await expect(this.token.tokenURI()).to.eventually.equal(uri); - }); - - it('decimals is 6', async function () { - await expect(this.token.decimals()).to.eventually.equal(6); - }); - }); - - describe('confidentialBalanceOf', function () { - it('handle can be reencryped by owner', async function () { - const balanceOfHandleHolder = await this.token.confidentialBalanceOf(this.holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, this.token.target, this.holder), - ).to.eventually.equal(1000); - }); - - it('handle cannot be reencryped by non-owner', async function () { - const balanceOfHandleHolder = await this.token.confidentialBalanceOf(this.holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, this.token.target, this.accounts[0]), - ).to.be.rejectedWith(generateReencryptionErrorMessage(balanceOfHandleHolder, this.accounts[0].address)); - }); - }); - - describe('mint', function () { - for (const existingUser of [false, true]) { - it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { - if (existingUser) { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(1000) - .encrypt(); - - await this.token - .connect(this.holder) - ['$_mint(address,bytes32,bytes)'](this.holder, encryptedInput.handles[0], encryptedInput.inputProof); - } - - const balanceOfHandleHolder = await this.token.confidentialBalanceOf(this.holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, this.token.target, this.holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - - // Check total supply - const totalSupplyHandle = await this.token.confidentialTotalSupply(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, this.token.target, this.holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - }); - } - - it('from zero address', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(400) - .encrypt(); - - await expect( - this.token - .connect(this.holder) - ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('burn', function () { - for (const sufficientBalance of [false, true]) { - it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const burnAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(burnAmount) - .encrypt(); - - await this.token - .connect(this.holder) - ['$_burn(address,bytes32,bytes)'](this.holder, encryptedInput.handles[0], encryptedInput.inputProof); - - const balanceOfHandleHolder = await this.token.confidentialBalanceOf(this.holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, this.token.target, this.holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - - // Check total supply - const totalSupplyHandle = await this.token.confidentialTotalSupply(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, this.token.target, this.holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - }); - } - - it('from zero address', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(400) - .encrypt(); - - await expect( - this.token - .connect(this.holder) - ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('transfer', function () { - for (const asSender of [true, false]) { - describe(asSender ? 'as sender' : 'as operator', function () { - beforeEach(async function () { - if (!asSender) { - const timestamp = (await ethers.provider.getBlock('latest'))!.timestamp + 100; - await this.token.connect(this.holder).setOperator(this.operator.address, timestamp); - } - }); - - if (!asSender) { - for (const withCallback of [false, true]) { - describe(withCallback ? 'with callback' : 'without callback', function () { - let encryptedInput: any; - let params: any; - - beforeEach(async function () { - encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.operator.address) - .add64(100) - .encrypt(); - - params = [ - this.holder.address, - this.recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ]; - if (withCallback) { - params.push('0x'); - } - }); - - it('without operator approval should fail', async function () { - await this.token.$_setOperator(this.holder, this.operator, 0); - - await expect( - this.token - .connect(this.operator) - [ - withCallback - ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' - : 'confidentialTransferFrom(address,address,bytes32,bytes)' - ](...params), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984UnauthorizedSpender') - .withArgs(this.holder.address, this.operator.address); - }); - - it('should be successful', async function () { - await this.token - .connect(this.operator) - [ - withCallback - ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' - : 'confidentialTransferFrom(address,address,bytes32,bytes)' - ](...params); - }); - }); - } - } - - // Edge cases to run with sender as caller - if (asSender) { - it('with no balance should revert', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.recipient.address) - .add64(100) - .encrypt(); - - await expect( - this.token - .connect(this.recipient) - ['confidentialTransfer(address,bytes32,bytes)']( - this.holder.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984ZeroBalance') - .withArgs(this.recipient.address); - }); - - it('to zero address', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(100) - .encrypt(); - - await expect( - this.token - .connect(this.holder) - ['confidentialTransfer(address,bytes32,bytes)']( - ethers.ZeroAddress, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - } - - for (const sufficientBalance of [false, true]) { - it(`${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const transferAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, asSender ? this.holder.address : this.operator.address) - .add64(transferAmount) - .encrypt(); - - let tx; - if (asSender) { - tx = await this.token - .connect(this.holder) - ['confidentialTransfer(address,bytes32,bytes)']( - this.recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - } else { - tx = await this.token - .connect(this.operator) - ['confidentialTransferFrom(address,address,bytes32,bytes)']( - this.holder.address, - this.recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - } - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === this.token.target)[0]; - expect(transferEvent.args[0]).to.equal(this.holder.address); - expect(transferEvent.args[1]).to.equal(this.recipient.address); - - const transferAmountHandle = transferEvent.args[2]; - const holderBalanceHandle = await this.token.confidentialBalanceOf(this.holder); - const recipientBalanceHandle = await this.token.confidentialBalanceOf(this.recipient); - - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, this.token.target, this.holder), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, this.token.target, this.recipient), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - // Other can not reencrypt the transfer amount - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, this.token.target, this.operator), - ).to.be.rejectedWith(generateReencryptionErrorMessage(transferAmountHandle, this.operator.address)); - - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, holderBalanceHandle, this.token.target, this.holder), - ).to.eventually.equal(1000 - (sufficientBalance ? transferAmount : 0)); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, recipientBalanceHandle, this.token.target, this.recipient), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - }); - } - }); - } - - describe('without input proof', function () { - for (const [usingTransferFrom, withCallback] of [false, true].flatMap(val => [ - [val, false], - [val, true], - ])) { - describe(`using ${usingTransferFrom ? 'confidentialTransferFrom' : 'confidentialTransfer'} ${ - withCallback ? 'with callback' : '' - }`, function () { - async function callTransfer(contract: any, from: any, to: any, amount: any, sender: any = from) { - let functionParams = [to, amount]; - - if (withCallback) { - functionParams.push('0x'); - if (usingTransferFrom) { - functionParams.unshift(from); - await contract.connect(sender).confidentialTransferFromAndCall(...functionParams); - } else { - await contract.connect(sender).confidentialTransferAndCall(...functionParams); - } - } else { - if (usingTransferFrom) { - functionParams.unshift(from); - await contract.connect(sender).confidentialTransferFrom(...functionParams); - } else { - await contract.connect(sender).confidentialTransfer(...functionParams); - } - } - } - - it('full balance', async function () { - const fullBalanceHandle = await this.token.confidentialBalanceOf(this.holder); - - await callTransfer(this.token, this.holder, this.recipient, fullBalanceHandle); - - await expect( - fhevm.userDecryptEuint( - FhevmType.euint64, - await this.token.confidentialBalanceOf(this.recipient), - this.token.target, - this.recipient, - ), - ).to.eventually.equal(1000); - }); - - it('other user balance should revert', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(100) - .encrypt(); - - await this.token - .connect(this.holder) - ['$_mint(address,bytes32,bytes)'](this.recipient, encryptedInput.handles[0], encryptedInput.inputProof); - - const recipientBalanceHandle = await this.token.confidentialBalanceOf(this.recipient); - await expect(callTransfer(this.token, this.holder, this.recipient, recipientBalanceHandle)) - .to.be.revertedWithCustomError(this.token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(recipientBalanceHandle, this.holder); - }); - - if (usingTransferFrom) { - describe('without operator approval', function () { - beforeEach(async function () { - await this.token.connect(this.holder).setOperator(this.operator.address, 0); - await allowHandle(hre, this.holder, this.operator, await this.token.confidentialBalanceOf(this.holder)); - }); - - it('should revert', async function () { - await expect( - callTransfer( - this.token, - this.holder, - this.recipient, - await this.token.confidentialBalanceOf(this.holder), - this.operator, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984UnauthorizedSpender') - .withArgs(this.holder.address, this.operator.address); - }); - }); - } - }); - } - }); - - it('internal function reverts on from address zero', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(100) - .encrypt(); - - await expect( - this.token - .connect(this.holder) - ['$_transfer(address,address,bytes32,bytes)']( - ethers.ZeroAddress, - this.recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('transfer with callback', function () { - beforeEach(async function () { - this.recipientContract = await ethers.deployContract('ERC7984ReceiverMock'); - - this.encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(1000) - .encrypt(); - }); - - for (const callbackSuccess of [false, true]) { - it(`with callback running ${callbackSuccess ? 'successfully' : 'unsuccessfully'}`, async function () { - const tx = await this.token - .connect(this.holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - this.recipientContract.target, - this.encryptedInput.handles[0], - this.encryptedInput.inputProof, - ethers.AbiCoder.defaultAbiCoder().encode(['bool'], [callbackSuccess]), - ); - - await expect( - fhevm.userDecryptEuint( - FhevmType.euint64, - await this.token.confidentialBalanceOf(this.holder), - this.token.target, - this.holder, - ), - ).to.eventually.equal(callbackSuccess ? 0 : 1000); - - // Verify event contents - expect(tx).to.emit(this.recipientContract, 'ConfidentialTransferCallback').withArgs(callbackSuccess); - const transferEvents = (await tx.wait()).logs.filter((log: any) => log.address === this.token.target); - - const outboundTransferEvent = transferEvents[0]; - const inboundTransferEvent = transferEvents[1]; - - expect(outboundTransferEvent.args[0]).to.equal(this.holder.address); - expect(outboundTransferEvent.args[1]).to.equal(this.recipientContract.target); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, outboundTransferEvent.args[2], this.token.target, this.holder), - ).to.eventually.equal(1000); - - expect(inboundTransferEvent.args[0]).to.equal(this.recipientContract.target); - expect(inboundTransferEvent.args[1]).to.equal(this.holder.address); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, inboundTransferEvent.args[2], this.token.target, this.holder), - ).to.eventually.equal(callbackSuccess ? 0 : 1000); - }); - } - - it('with callback reverting without a reason', async function () { - await expect( - this.token - .connect(this.holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - this.recipientContract.target, - this.encryptedInput.handles[0], - this.encryptedInput.inputProof, - '0x', - ), - ) - .to.be.revertedWithCustomError(this.token, 'ERC7984InvalidReceiver') - .withArgs(this.recipientContract.target); - }); - - it('with callback reverting with a custom error', async function () { - await expect( - this.token - .connect(this.holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - this.recipientContract.target, - this.encryptedInput.handles[0], - this.encryptedInput.inputProof, - ethers.AbiCoder.defaultAbiCoder().encode(['uint8'], [2]), - ), - ) - .to.be.revertedWithCustomError(this.recipientContract, 'InvalidInput') - .withArgs(2); - }); - - it('to an EOA', async function () { - await this.token - .connect(this.holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - this.recipient, - this.encryptedInput.handles[0], - this.encryptedInput.inputProof, - '0x', - ); - - const balanceOfHandle = await this.token.confidentialBalanceOf(this.recipient); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandle, this.token.target, this.recipient), - ).to.eventually.equal(1000); - }); - }); - - describe('disclose', function () { - let expectedAmount: any; - let expectedHandle: any; - - beforeEach(async function () { - expectedAmount = undefined; - expectedHandle = undefined; - }); - - it('user balance', async function () { - const holderBalanceHandle = await this.token.confidentialBalanceOf(this.holder); - - await this.token.connect(this.holder).discloseEncryptedAmount(holderBalanceHandle); - - expectedAmount = 1000n; - expectedHandle = holderBalanceHandle; - }); - - it('transaction amount', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(this.token.target, this.holder.address) - .add64(400) - .encrypt(); - - const tx = await this.token['confidentialTransfer(address,bytes32,bytes)']( - this.recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === this.token.target)[0]; - const transferAmount = transferEvent.args[2]; - - await this.token.connect(this.recipient).discloseEncryptedAmount(transferAmount); - - expectedAmount = 400n; - expectedHandle = transferAmount; - }); - - it("other user's balance", async function () { - const holderBalanceHandle = await this.token.confidentialBalanceOf(this.holder); - - await expect(this.token.connect(this.recipient).discloseEncryptedAmount(holderBalanceHandle)) - .to.be.revertedWithCustomError(this.token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(holderBalanceHandle, this.recipient); - }); - - it('invalid signature reverts', async function () { - const holderBalanceHandle = await this.token.confidentialBalanceOf(this.holder); - await this.token.connect(this.holder).discloseEncryptedAmount(holderBalanceHandle); - - await expect(this.token.connect(this.holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; - }); - - afterEach(async function () { - if (expectedHandle === undefined || expectedAmount === undefined) return; - - await fhevm.awaitDecryptionOracle(); - - // Check that event was correctly emitted - const eventFilter = this.token.filters.AmountDisclosed(); - const discloseEvent = (await this.token.queryFilter(eventFilter))[0]; - expect(discloseEvent.args[0]).to.equal(expectedHandle); - expect(discloseEvent.args[1]).to.equal(expectedAmount); - }); - }); + shouldBehaveLikeERC7984(deployFixture); }); /* eslint-enable no-unexpected-multiline */ - -function generateReencryptionErrorMessage(handle: string, account: string): string { - return `User ${account} is not authorized to user decrypt handle ${handle}`; -} diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index 11778204..b953651e 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -1,23 +1,50 @@ import { IACL__factory } from '../../../../types'; +import { $ERC7984FreezableMock } from '../../../../types/contracts-exposed/mocks/token/ERC7984FreezableMock.sol/$ERC7984FreezableMock'; +import { $ERC7984Mock } from '../../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; import { ACL_ADDRESS } from '../../../helpers/accounts'; +import { shouldBehaveLikeERC7984 } from '../ERC7984.behaviour'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; +const name = 'ConfidentialFungibleToken'; +const symbol = 'CFT'; +const uri = 'https://example.com/metadata'; + /* eslint-disable no-unexpected-multiline */ describe('ERC7984Freezable', function () { async function deployFixture() { const [holder, recipient, freezer, operator, anyone] = await ethers.getSigners(); - const token = await ethers.deployContract('ERC7984FreezableMock', ['name', 'symbol', 'uri', freezer.address]); + const token = (await ethers.deployContract('$ERC7984FreezableMock', [ + name, + symbol, + uri, + freezer.address, + ])) as any as $ERC7984FreezableMock; + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); const acl = IACL__factory.connect(ACL_ADDRESS, ethers.provider); return { token, acl, holder, recipient, freezer, operator, anyone }; } it('should set and get confidential frozen', async function () { const { token, acl, holder, recipient, freezer } = await deployFixture(); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); await token .connect(holder) - .$_mint(recipient.address, 1000) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ) .then(tx => tx.wait()); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) @@ -57,7 +84,18 @@ describe('ERC7984Freezable', function () { it('should not set confidential frozen if not called by freezer', async function () { const { token, holder, recipient, anyone } = await deployFixture(); - await token.$_mint(holder.address, 1000).then(tx => tx.wait()); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ) + .then(tx => tx.wait()); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) @@ -78,9 +116,17 @@ describe('ERC7984Freezable', function () { it('should transfer max available', async function () { const { token, holder, recipient, freezer, anyone } = await deployFixture(); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); await token .connect(holder) - .$_mint(recipient.address, 1000) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ) .then(tx => tx.wait()); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) @@ -127,9 +173,17 @@ describe('ERC7984Freezable', function () { it('should transfer zero if transferring more than available', async function () { const { token, holder, recipient, freezer, anyone } = await deployFixture(); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); await token .connect(holder) - .$_mint(recipient.address, 1000) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ) .then(tx => tx.wait()); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) @@ -164,5 +218,10 @@ describe('ERC7984Freezable', function () { ), ).to.equal(1000); }); + + shouldBehaveLikeERC7984(async () => { + const { token, holder, recipient, operator, anyone } = await deployFixture(); + return { token: token as any as $ERC7984Mock, holder, recipient, operator, anyone }; + }); }); /* eslint-disable no-unexpected-multiline */ From 45c9ba3a0ab9a4aa41e22e62aaead818b7e54763 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:11:17 +0200 Subject: [PATCH 13/25] Allow past total supply access in ERC7984 test --- contracts/mocks/token/ERC7984VotesMock.sol | 5 +++++ test/token/ERC7984/ERC7984Votes.test.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/contracts/mocks/token/ERC7984VotesMock.sol b/contracts/mocks/token/ERC7984VotesMock.sol index d3dc56ca..5a6f12fd 100644 --- a/contracts/mocks/token/ERC7984VotesMock.sol +++ b/contracts/mocks/token/ERC7984VotesMock.sol @@ -30,6 +30,11 @@ abstract contract ERC7984VotesMock is ERC7984Mock, ERC7984Votes { return super.confidentialTotalSupply(); } + function getPastTotalSupplyAccess(uint256 timepoint) public { + require(msg.sender == _OWNER); + FHE.allow(getPastTotalSupply(timepoint), msg.sender); + } + function _update( address from, address to, diff --git a/test/token/ERC7984/ERC7984Votes.test.ts b/test/token/ERC7984/ERC7984Votes.test.ts index 3e5be5de..35a9a43d 100644 --- a/test/token/ERC7984/ERC7984Votes.test.ts +++ b/test/token/ERC7984/ERC7984Votes.test.ts @@ -261,6 +261,7 @@ describe('ERC7984Votes', function () { // Check total supply for each block const afterFirstMintSupplyHandle = await this.token.getPastTotalSupply(afterFirstMintBlock); + await this.token.getPastTotalSupplyAccess(afterFirstMintBlock).then(tx => tx.wait()); await expect( fhevm.userDecryptEuint(FhevmType.euint64, afterFirstMintSupplyHandle, this.token.target, this.holder), ).to.eventually.equal(1000); @@ -268,6 +269,7 @@ describe('ERC7984Votes', function () { await expect(this.token.getPastTotalSupply(afterTransferBlock)).to.eventually.eq(afterFirstMintSupplyHandle); const afterSecondMintSupplyHandle = await this.token.getPastTotalSupply(afterSecondMintBlock); + await this.token.getPastTotalSupplyAccess(afterSecondMintBlock).then(tx => tx.wait()); await expect( fhevm.userDecryptEuint(FhevmType.euint64, afterSecondMintSupplyHandle, this.token.target, this.holder), ).to.eventually.equal(2000); From 754ecec1dc209bab72384c383841d440180b8551 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 20 Aug 2025 14:36:35 -0400 Subject: [PATCH 14/25] update tests --- .../extensions/ERC7984Freezable.test.ts | 72 +++++++------------ 1 file changed, 27 insertions(+), 45 deletions(-) diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index 11778204..baae5ccf 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -4,7 +4,6 @@ import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; -/* eslint-disable no-unexpected-multiline */ describe('ERC7984Freezable', function () { async function deployFixture() { const [holder, recipient, freezer, operator, anyone] = await ethers.getSigners(); @@ -15,10 +14,7 @@ describe('ERC7984Freezable', function () { it('should set and get confidential frozen', async function () { const { token, acl, holder, recipient, freezer } = await deployFixture(); - await token - .connect(holder) - .$_mint(recipient.address, 1000) - .then(tx => tx.wait()); + await token.connect(holder).$_mint(recipient.address, 1000); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) .add64(100) @@ -36,23 +32,20 @@ describe('ERC7984Freezable', function () { .withArgs(recipient.address, encryptedInput.handles[0]); const frozenHandle = await token.confidentialFrozen(recipient.address); expect(frozenHandle).to.equal(ethers.hexlify(encryptedInput.handles[0])); - expect(await acl.isAllowed(frozenHandle, recipient.address)).to.be.true; - expect(await fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), recipient)).to.equal( - 100, - ); + await expect(acl.isAllowed(frozenHandle, recipient.address)).to.eventually.be.true; + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), recipient), + ).to.eventually.equal(100); const balanceHandle = await token.confidentialBalanceOf(recipient.address); - expect( - await fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), recipient), - ).to.equal(1000); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), recipient), + ).to.eventually.equal(1000); const confidentialAvailableArgs = recipient.address; const availableHandle = await token.confidentialAvailable.staticCall(confidentialAvailableArgs); - await (token as any) - .connect(recipient) - .confidentialAvailableAccess(confidentialAvailableArgs) - .then(tx => tx.wait()); - expect( - await fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), - ).to.equal(900); + await (token as any).connect(recipient).confidentialAvailableAccess(confidentialAvailableArgs); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), + ).to.eventually.equal(900); }); it('should not set confidential frozen if not called by freezer', async function () { @@ -78,10 +71,7 @@ describe('ERC7984Freezable', function () { it('should transfer max available', async function () { const { token, holder, recipient, freezer, anyone } = await deployFixture(); - await token - .connect(holder) - .$_mint(recipient.address, 1000) - .then(tx => tx.wait()); + await token.connect(holder).$_mint(recipient.address, 1000); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) .add64(100) @@ -92,17 +82,13 @@ describe('ERC7984Freezable', function () { recipient.address, encryptedInput.handles[0], encryptedInput.inputProof, - ) - .then(tx => tx.wait()); + ); const confidentialAvailableArgs = recipient.address; const availableHandle = await token.confidentialAvailable.staticCall(confidentialAvailableArgs); - await (token as any) - .connect(recipient) - .confidentialAvailableAccess(confidentialAvailableArgs) - .then(tx => tx.wait()); - expect( - await fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), - ).to.equal(900); + await (token as any).connect(recipient).confidentialAvailableAccess(confidentialAvailableArgs); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), + ).to.eventually.equal(900); const encryptedInput2 = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(900) @@ -113,16 +99,15 @@ describe('ERC7984Freezable', function () { anyone.address, encryptedInput2.handles[0], encryptedInput2.inputProof, - ) - .then(tx => tx.wait()); - expect( - await fhevm.userDecryptEuint( + ); + await expect( + fhevm.userDecryptEuint( FhevmType.euint64, await token.confidentialBalanceOf(recipient.address), await token.getAddress(), recipient, ), - ).to.equal(100); + ).to.eventually.equal(100); }); it('should transfer zero if transferring more than available', async function () { @@ -141,8 +126,7 @@ describe('ERC7984Freezable', function () { recipient.address, encryptedInput.handles[0], encryptedInput.inputProof, - ) - .then(tx => tx.wait()); + ); const encryptedInput2 = await fhevm .createEncryptedInput(await token.getAddress(), recipient.address) .add64(501) @@ -153,16 +137,14 @@ describe('ERC7984Freezable', function () { anyone.address, encryptedInput2.handles[0], encryptedInput2.inputProof, - ) - .then(tx => tx.wait()); - expect( - await fhevm.userDecryptEuint( + ); + await expect( + fhevm.userDecryptEuint( FhevmType.euint64, await token.confidentialBalanceOf(recipient.address), await token.getAddress(), recipient, ), - ).to.equal(1000); + ).to.eventually.equal(1000); }); }); -/* eslint-disable no-unexpected-multiline */ From 8e08500b0a6d17ac6746f906dc17adeb9bc31bbc Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:03:11 +0200 Subject: [PATCH 15/25] Apply suggestions Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com> --- .changeset/seven-books-dig.md | 2 +- contracts/token/ERC7984/extensions/ERC7984Freezable.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/seven-books-dig.md b/.changeset/seven-books-dig.md index 2118252d..dc92ceb0 100644 --- a/.changeset/seven-books-dig.md +++ b/.changeset/seven-books-dig.md @@ -2,4 +2,4 @@ 'openzeppelin-confidential-contracts': minor --- -Add `ERC7984Freezable` extension. +`ERC7984Freezable`: Add an extension to `ERC7984`, which allows accounts granted the "freezer" role to freeze and unfreeze tokens. diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index b3ef2725..72f3107b 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -7,7 +7,7 @@ import {FHESafeMath} from "../../../utils/FHESafeMath.sol"; import {ERC7984} from "../ERC7984.sol"; /** - * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/pull/186. + * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Freezable.sol * * @dev Extension of {ERC7984} that allows to implement a confidential * freezing mechanism that can be managed by an authorized account with From 7e45893e8a29bac3b0fdf5023385821b5f48dbaa Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:48:55 -0400 Subject: [PATCH 16/25] Remove account from `_validateHandleAllowance` (#178) Co-authored-by: James Toussaint <33313130+james-toussaint@users.noreply.github.com> --- contracts/mocks/token/ERC7984FreezableMock.sol | 4 +--- contracts/mocks/token/ERC7984VotesMock.sol | 2 +- contracts/mocks/utils/HandleAccessManagerMock.sol | 2 +- contracts/utils/HandleAccessManager.sol | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index 8fc0d8dc..c25cfc74 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -30,9 +30,7 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa getHandleAllowance(euint64.unwrap(available), account, true); } - function _validateHandleAllowance(bytes32 handle, address account) internal view override { - require(msg.sender == account, UnallowedHandleAccess(handle, account)); - } + function _validateHandleAllowance(bytes32 handle) internal view override {} function $_mint(address to, uint64 amount) public returns (euint64 transferred) { return _mint(to, FHE.asEuint64(amount)); diff --git a/contracts/mocks/token/ERC7984VotesMock.sol b/contracts/mocks/token/ERC7984VotesMock.sol index 950d594d..def0c7c1 100644 --- a/contracts/mocks/token/ERC7984VotesMock.sol +++ b/contracts/mocks/token/ERC7984VotesMock.sol @@ -42,5 +42,5 @@ abstract contract ERC7984VotesMock is ERC7984Mock, ERC7984Votes { _clockOverrideVal = val; } - function _validateHandleAllowance(bytes32 handle, address account) internal view override {} + function _validateHandleAllowance(bytes32 handle) internal view override {} } diff --git a/contracts/mocks/utils/HandleAccessManagerMock.sol b/contracts/mocks/utils/HandleAccessManagerMock.sol index 743cdf14..7c2c6aa2 100644 --- a/contracts/mocks/utils/HandleAccessManagerMock.sol +++ b/contracts/mocks/utils/HandleAccessManagerMock.sol @@ -8,7 +8,7 @@ import {HandleAccessManager} from "./../../utils/HandleAccessManager.sol"; contract HandleAccessManagerMock is HandleAccessManager, SepoliaConfig { event HandleCreated(euint64 handle); - function _validateHandleAllowance(bytes32 handle, address account) internal view override {} + function _validateHandleAllowance(bytes32 handle) internal view override {} function createHandle(uint64 amount) public returns (euint64) { euint64 handle = FHE.asEuint64(amount); diff --git a/contracts/utils/HandleAccessManager.sol b/contracts/utils/HandleAccessManager.sol index 34f531c5..f8981ca5 100644 --- a/contracts/utils/HandleAccessManager.sol +++ b/contracts/utils/HandleAccessManager.sol @@ -13,7 +13,7 @@ abstract contract HandleAccessManager { * {_validateHandleAllowance} function. */ function getHandleAllowance(bytes32 handle, address account, bool persistent) public virtual { - _validateHandleAllowance(handle, account); + _validateHandleAllowance(handle); if (persistent) { Impl.allow(handle, account); } else { @@ -25,5 +25,5 @@ abstract contract HandleAccessManager { * @dev Unimplemented function that must revert if the message sender is not allowed to call * {getHandleAllowance} for the given handle. */ - function _validateHandleAllowance(bytes32 handle, address account) internal view virtual; + function _validateHandleAllowance(bytes32 handle) internal view virtual; } From ed13e2ff9e7cf1f731c7407be62ea3e9aa30b5ae Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:30:41 +0200 Subject: [PATCH 17/25] Call internal when setting frozen with proof --- .../mocks/token/ERC7984FreezableMock.sol | 5 + .../ERC7984/extensions/ERC7984Freezable.sol | 2 +- .../extensions/ERC7984Freezable.test.ts | 97 ++++++++++++------- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index c25cfc74..d468482f 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -24,6 +24,11 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa _grantRole(FREEZER_ROLE, freezer); } + function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { + FHE.allowThis(encryptedAmount = FHE.asEuint64(amount)); + FHE.allow(encryptedAmount, msg.sender); + } + function confidentialAvailableAccess(address account) public { euint64 available = confidentialAvailable(account); FHE.allowThis(available); diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index 72f3107b..7412c2a1 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -45,7 +45,7 @@ abstract contract ERC7984Freezable is ERC7984 { externalEuint64 encryptedAmount, bytes calldata inputProof ) public virtual { - setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); + _setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); } /// @dev Freezes a confidential amount of tokens for an account. diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index baae5ccf..44ba3937 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -2,6 +2,7 @@ import { IACL__factory } from '../../../../types'; import { ACL_ADDRESS } from '../../../helpers/accounts'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; +import { AddressLike, BytesLike } from 'ethers'; import { ethers, fhevm } from 'hardhat'; describe('ERC7984Freezable', function () { @@ -11,42 +12,53 @@ describe('ERC7984Freezable', function () { const acl = IACL__factory.connect(ACL_ADDRESS, ethers.provider); return { token, acl, holder, recipient, freezer, operator, anyone }; } - - it('should set and get confidential frozen', async function () { - const { token, acl, holder, recipient, freezer } = await deployFixture(); - await token.connect(holder).$_mint(recipient.address, 1000); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), freezer.address) - .add64(100) - .encrypt(); - await expect( - token - .connect(freezer) - ['setConfidentialFrozen(address,bytes32,bytes)']( - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.emit(token, 'TokensFrozen') - .withArgs(recipient.address, encryptedInput.handles[0]); - const frozenHandle = await token.confidentialFrozen(recipient.address); - expect(frozenHandle).to.equal(ethers.hexlify(encryptedInput.handles[0])); - await expect(acl.isAllowed(frozenHandle, recipient.address)).to.eventually.be.true; - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), recipient), - ).to.eventually.equal(100); - const balanceHandle = await token.confidentialBalanceOf(recipient.address); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), recipient), - ).to.eventually.equal(1000); - const confidentialAvailableArgs = recipient.address; - const availableHandle = await token.confidentialAvailable.staticCall(confidentialAvailableArgs); - await (token as any).connect(recipient).confidentialAvailableAccess(confidentialAvailableArgs); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), - ).to.eventually.equal(900); - }); + for (const withProof of [true, false]) { + it(`should set and get confidential frozen ${withProof ? 'with proof' : ''}`, async function () { + const { token, acl, holder, recipient, freezer } = await deployFixture(); + await token.connect(holder).$_mint(recipient.address, 1000); + const amount = 100; + let params = [recipient.address] as unknown as [ + account: AddressLike, + encryptedAmount: BytesLike, + inputProof: BytesLike, + ]; + if (withProof) { + const { handles, inputProof } = await fhevm + .createEncryptedInput(await token.getAddress(), freezer.address) + .add64(amount) + .encrypt(); + params.push(handles[0], inputProof); + } else { + await token.connect(freezer).createEncryptedAmount(amount); + params.push(await token.connect(freezer).createEncryptedAmount.staticCall(amount)); + } + await expect( + token + .connect(freezer) + [withProof ? 'setConfidentialFrozen(address,bytes32,bytes)' : 'setConfidentialFrozen(address,bytes32)']( + ...params, + ), + ) + .to.emit(token, 'TokensFrozen') + .withArgs(recipient.address, params[1]); + const frozenHandle = await token.confidentialFrozen(recipient.address); + expect(frozenHandle).to.equal(ethers.hexlify(params[1])); + await expect(acl.isAllowed(frozenHandle, recipient.address)).to.eventually.be.true; + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, frozenHandle, await token.getAddress(), recipient), + ).to.eventually.equal(100); + const balanceHandle = await token.confidentialBalanceOf(recipient.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceHandle, await token.getAddress(), recipient), + ).to.eventually.equal(1000); + const confidentialAvailableArgs = recipient.address; + const availableHandle = await token.confidentialAvailable.staticCall(confidentialAvailableArgs); + await (token as any).connect(recipient).confidentialAvailableAccess(confidentialAvailableArgs); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, availableHandle, await token.getAddress(), recipient), + ).to.eventually.equal(900); + }); + } it('should not set confidential frozen if not called by freezer', async function () { const { token, holder, recipient, anyone } = await deployFixture(); @@ -147,4 +159,17 @@ describe('ERC7984Freezable', function () { ), ).to.eventually.equal(1000); }); + + it('should not set confidential frozen if unauthorized', async function () { + const { token, recipient, freezer, anyone } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), freezer.address) + .add64(100) + .encrypt(); + await expect( + token.connect(anyone)['setConfidentialFrozen(address,bytes32)'](recipient.address, encryptedInput.handles[0]), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(encryptedInput.handles[0], anyone); + }); }); From 9ae40744f524bd05777a9938715329c817e171cf Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:53:33 +0200 Subject: [PATCH 18/25] Update doc --- .changeset/seven-books-dig.md | 2 +- contracts/token/ERC7984/extensions/ERC7984Freezable.sol | 6 +++--- contracts/token/README.adoc | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/seven-books-dig.md b/.changeset/seven-books-dig.md index dc92ceb0..4eefdfa8 100644 --- a/.changeset/seven-books-dig.md +++ b/.changeset/seven-books-dig.md @@ -2,4 +2,4 @@ 'openzeppelin-confidential-contracts': minor --- -`ERC7984Freezable`: Add an extension to `ERC7984`, which allows accounts granted the "freezer" role to freeze and unfreeze tokens. +`ERC7984Freezable`: Add an extension to `ERC7984`, which allows an account granted the "freezer" role to freeze and unfreeze a confidential amount of tokens for holders. diff --git a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol index 7412c2a1..8d49e7da 100644 --- a/contracts/token/ERC7984/extensions/ERC7984Freezable.sol +++ b/contracts/token/ERC7984/extensions/ERC7984Freezable.sol @@ -7,16 +7,16 @@ import {FHESafeMath} from "../../../utils/FHESafeMath.sol"; import {ERC7984} from "../ERC7984.sol"; /** - * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Freezable.sol - * * @dev Extension of {ERC7984} that allows to implement a confidential * freezing mechanism that can be managed by an authorized account with - * the {_setConfidentialFrozen} function. + * {setConfidentialFrozen} functions. * * The freezing mechanism provides the guarantee to the contract owner * (e.g. a DAO or a well-configured multisig) that a specific confidential * amount of tokens held by an account won't be transferable until those * tokens are unfrozen. + * + * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Freezable.sol */ abstract contract ERC7984Freezable is ERC7984 { /// @dev Confidential frozen amount of tokens per address. diff --git a/contracts/token/README.adoc b/contracts/token/README.adoc index 8e9384c0..3a0d0cb5 100644 --- a/contracts/token/README.adoc +++ b/contracts/token/README.adoc @@ -7,6 +7,7 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a - {ERC7984}: Implementation of {IERC7984}. - {ERC7984ERC20Wrapper}: Extension of {ERC7984} which wraps an `ERC20` into a confidential token. The wrapper allows for free conversion in both directions at a fixed rate. +- {ERC7984Freezable}: An extension for {ERC7984}, which allows an account granted the "freezer" role to freeze and unfreeze a confidential amount of tokens for holders. - {ERC7984Utils}: A library that provides the on-transfer callback check used by {ERC7984}. == Core @@ -14,6 +15,7 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a == Extensions {{ERC7984ERC20Wrapper}} +{{ERC7984Freezable}} == Utilities {{ERC7984Utils}} \ No newline at end of file From 6a52710781406b53029bac3bb415db8a2b886aae Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:13:06 +0200 Subject: [PATCH 19/25] Update pragma & remove import in freezable mock --- contracts/mocks/token/ERC7984FreezableMock.sol | 4 ++-- test/token/ERC7984/extensions/ERC7984Freezable.test.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index d468482f..e0966f5d 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity ^0.8.27; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC7984} from "../../token/ERC7984/ERC7984.sol"; import {ERC7984Freezable} from "../../token/ERC7984/extensions/ERC7984Freezable.sol"; diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index 44ba3937..fbcbd874 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -62,7 +62,7 @@ describe('ERC7984Freezable', function () { it('should not set confidential frozen if not called by freezer', async function () { const { token, holder, recipient, anyone } = await deployFixture(); - await token.$_mint(holder.address, 1000).then(tx => tx.wait()); + await token.$_mint(holder.address, 1000); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) @@ -124,10 +124,7 @@ describe('ERC7984Freezable', function () { it('should transfer zero if transferring more than available', async function () { const { token, holder, recipient, freezer, anyone } = await deployFixture(); - await token - .connect(holder) - .$_mint(recipient.address, 1000) - .then(tx => tx.wait()); + await token.connect(holder).$_mint(recipient.address, 1000); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) .add64(500) From bc71379bf1182496285ff3611069ba48812ffe1b Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:54:41 +0200 Subject: [PATCH 20/25] Base freezable mock on ERC7984Mock --- .../mocks/token/ERC7984FreezableMock.sol | 19 +++--- .../extensions/ERC7984Freezable.test.ts | 60 +++++++++++++++++-- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/contracts/mocks/token/ERC7984FreezableMock.sol b/contracts/mocks/token/ERC7984FreezableMock.sol index e0966f5d..2f6f67bc 100644 --- a/contracts/mocks/token/ERC7984FreezableMock.sol +++ b/contracts/mocks/token/ERC7984FreezableMock.sol @@ -3,14 +3,15 @@ pragma solidity ^0.8.27; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; -import {FHE, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; import {ERC7984} from "../../token/ERC7984/ERC7984.sol"; import {ERC7984Freezable} from "../../token/ERC7984/extensions/ERC7984Freezable.sol"; import {HandleAccessManager} from "../../utils/HandleAccessManager.sol"; +import {ERC7984Mock} from "./ERC7984Mock.sol"; // solhint-disable func-name-mixedcase -contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessManager, SepoliaConfig { +contract ERC7984FreezableMock is ERC7984Mock, ERC7984Freezable, AccessControl, HandleAccessManager { bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); error UnallowedHandleAccess(bytes32 handle, address account); @@ -20,10 +21,18 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa string memory symbol, string memory tokenUri, address freezer - ) ERC7984(name, symbol, tokenUri) { + ) ERC7984Mock(name, symbol, tokenUri) { _grantRole(FREEZER_ROLE, freezer); } + function _update( + address from, + address to, + euint64 amount + ) internal virtual override(ERC7984Mock, ERC7984Freezable) returns (euint64) { + return super._update(from, to, amount); + } + function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { FHE.allowThis(encryptedAmount = FHE.asEuint64(amount)); FHE.allow(encryptedAmount, msg.sender); @@ -37,9 +46,5 @@ contract ERC7984FreezableMock is ERC7984Freezable, AccessControl, HandleAccessMa function _validateHandleAllowance(bytes32 handle) internal view override {} - function $_mint(address to, uint64 amount) public returns (euint64 transferred) { - return _mint(to, FHE.asEuint64(amount)); - } - function _checkFreezer() internal override onlyRole(FREEZER_ROLE) {} } diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index fbcbd874..7e8ceea3 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -1,21 +1,41 @@ import { IACL__factory } from '../../../../types'; +import { $ERC7984FreezableMock } from '../../../../types/contracts-exposed/mocks/token/ERC7984FreezableMock.sol/$ERC7984FreezableMock'; import { ACL_ADDRESS } from '../../../helpers/accounts'; import { FhevmType } from '@fhevm/hardhat-plugin'; import { expect } from 'chai'; import { AddressLike, BytesLike } from 'ethers'; import { ethers, fhevm } from 'hardhat'; +const name = 'ConfidentialFungibleToken'; +const symbol = 'CFT'; +const uri = 'https://example.com/metadata'; + describe('ERC7984Freezable', function () { async function deployFixture() { const [holder, recipient, freezer, operator, anyone] = await ethers.getSigners(); - const token = await ethers.deployContract('ERC7984FreezableMock', ['name', 'symbol', 'uri', freezer.address]); + const token = (await ethers.deployContract('$ERC7984FreezableMock', [ + name, + symbol, + uri, + freezer.address, + ])) as any as $ERC7984FreezableMock; const acl = IACL__factory.connect(ACL_ADDRESS, ethers.provider); return { token, acl, holder, recipient, freezer, operator, anyone }; } for (const withProof of [true, false]) { it(`should set and get confidential frozen ${withProof ? 'with proof' : ''}`, async function () { const { token, acl, holder, recipient, freezer } = await deployFixture(); - await token.connect(holder).$_mint(recipient.address, 1000); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ); const amount = 100; let params = [recipient.address] as unknown as [ account: AddressLike, @@ -62,7 +82,17 @@ describe('ERC7984Freezable', function () { it('should not set confidential frozen if not called by freezer', async function () { const { token, holder, recipient, anyone } = await deployFixture(); - await token.$_mint(holder.address, 1000); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), anyone.address) .add64(100) @@ -83,7 +113,17 @@ describe('ERC7984Freezable', function () { it('should transfer max available', async function () { const { token, holder, recipient, freezer, anyone } = await deployFixture(); - await token.connect(holder).$_mint(recipient.address, 1000); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) .add64(100) @@ -124,7 +164,17 @@ describe('ERC7984Freezable', function () { it('should transfer zero if transferring more than available', async function () { const { token, holder, recipient, freezer, anyone } = await deployFixture(); - await token.connect(holder).$_mint(recipient.address, 1000); + const encryptedRecipientMintInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)']( + recipient.address, + encryptedRecipientMintInput.handles[0], + encryptedRecipientMintInput.inputProof, + ); const encryptedInput = await fhevm .createEncryptedInput(await token.getAddress(), freezer.address) .add64(500) From f51acb1360ec94e4587f57af5f87576305db1ef8 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:54:11 +0200 Subject: [PATCH 21/25] No wait --- test/token/ERC7984/ERC7984.behaviour.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/test/token/ERC7984/ERC7984.behaviour.ts b/test/token/ERC7984/ERC7984.behaviour.ts index 0544e26c..48f92090 100644 --- a/test/token/ERC7984/ERC7984.behaviour.ts +++ b/test/token/ERC7984/ERC7984.behaviour.ts @@ -83,10 +83,7 @@ function shouldBehaveLikeERC7984( // Check total supply const totalSupplyHandle = await token.confidentialTotalSupply(); - await token - .connect(holder) - .confidentialTotalSupplyAccess() - .then(tx => tx.wait()); + await token.connect(holder).confidentialTotalSupplyAccess(); await expect( fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), ).to.eventually.equal(existingUser ? 2000 : 1000); @@ -132,10 +129,7 @@ function shouldBehaveLikeERC7984( // Check total supply const totalSupplyHandle = await token.confidentialTotalSupply(); - await token - .connect(holder) - .confidentialTotalSupplyAccess() - .then(tx => tx.wait()); + await token.connect(holder).confidentialTotalSupplyAccess(); await expect( fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), ).to.eventually.equal(sufficientBalance ? 600 : 1000); From b111a13f9524becffd9650ddb6e24e0e022871dd Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:31:38 +0200 Subject: [PATCH 22/25] Order imports --- contracts/mocks/token/ERC7984ObserverAccessMock.sol | 2 +- test/token/ERC7984/ERC7984Votes.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/mocks/token/ERC7984ObserverAccessMock.sol b/contracts/mocks/token/ERC7984ObserverAccessMock.sol index 4bf598ef..c1e65f62 100644 --- a/contracts/mocks/token/ERC7984ObserverAccessMock.sol +++ b/contracts/mocks/token/ERC7984ObserverAccessMock.sol @@ -4,8 +4,8 @@ pragma solidity ^0.8.27; import {SepoliaConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; import {FHE, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; -import {ERC7984ObserverAccess} from "../../token/ERC7984/extensions/ERC7984ObserverAccess.sol"; import {ERC7984} from "../../token/ERC7984/ERC7984.sol"; +import {ERC7984ObserverAccess} from "../../token/ERC7984/extensions/ERC7984ObserverAccess.sol"; import {ERC7984Mock} from "./ERC7984Mock.sol"; contract ERC7984ObserverAccessMock is ERC7984ObserverAccess, ERC7984Mock { diff --git a/test/token/ERC7984/ERC7984Votes.test.ts b/test/token/ERC7984/ERC7984Votes.test.ts index 35a9a43d..913a44a5 100644 --- a/test/token/ERC7984/ERC7984Votes.test.ts +++ b/test/token/ERC7984/ERC7984Votes.test.ts @@ -261,7 +261,7 @@ describe('ERC7984Votes', function () { // Check total supply for each block const afterFirstMintSupplyHandle = await this.token.getPastTotalSupply(afterFirstMintBlock); - await this.token.getPastTotalSupplyAccess(afterFirstMintBlock).then(tx => tx.wait()); + await this.token.getPastTotalSupplyAccess(afterFirstMintBlock); await expect( fhevm.userDecryptEuint(FhevmType.euint64, afterFirstMintSupplyHandle, this.token.target, this.holder), ).to.eventually.equal(1000); @@ -269,7 +269,7 @@ describe('ERC7984Votes', function () { await expect(this.token.getPastTotalSupply(afterTransferBlock)).to.eventually.eq(afterFirstMintSupplyHandle); const afterSecondMintSupplyHandle = await this.token.getPastTotalSupply(afterSecondMintBlock); - await this.token.getPastTotalSupplyAccess(afterSecondMintBlock).then(tx => tx.wait()); + await this.token.getPastTotalSupplyAccess(afterSecondMintBlock); await expect( fhevm.userDecryptEuint(FhevmType.euint64, afterSecondMintSupplyHandle, this.token.target, this.holder), ).to.eventually.equal(2000); From c8afdb194c4fd1cc1ac297f5dd343dbfc5ff79d3 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:43:06 +0200 Subject: [PATCH 23/25] Reduce diff --- test/token/ERC7984/ERC7984.behaviour.ts | 602 +----------------------- test/token/ERC7984/ERC7984.test.ts | 598 ++++++++++++++++++++++- 2 files changed, 598 insertions(+), 602 deletions(-) diff --git a/test/token/ERC7984/ERC7984.behaviour.ts b/test/token/ERC7984/ERC7984.behaviour.ts index 48f92090..7e0c7456 100644 --- a/test/token/ERC7984/ERC7984.behaviour.ts +++ b/test/token/ERC7984/ERC7984.behaviour.ts @@ -1,601 +1,3 @@ -import { ERC7984ReceiverMock } from '../../../types'; -import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; -import { allowHandle } from '../../helpers/accounts'; -import { FhevmType } from '@fhevm/hardhat-plugin'; -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; -import { expect } from 'chai'; -import hre, { ethers, fhevm } from 'hardhat'; +import { _shouldBehaveLikeERC7984 } from './ERC7984.test'; -const name = 'ConfidentialFungibleToken'; -const symbol = 'CFT'; -const uri = 'https://example.com/metadata'; - -/* eslint-disable no-unexpected-multiline */ -function shouldBehaveLikeERC7984( - deployFixture: () => Promise<{ - token: $ERC7984Mock; - holder: HardhatEthersSigner; - recipient: HardhatEthersSigner; - operator: HardhatEthersSigner; - anyone: HardhatEthersSigner; - }>, -) { - describe('ERC7984', function () { - describe('constructor', function () { - it('sets the name', async function () { - const { token } = await deployFixture(); - await expect(token.name()).to.eventually.equal(name); - }); - - it('sets the symbol', async function () { - const { token } = await deployFixture(); - await expect(token.symbol()).to.eventually.equal(symbol); - }); - - it('sets the uri', async function () { - const { token } = await deployFixture(); - await expect(token.tokenURI()).to.eventually.equal(uri); - }); - - it('decimals is 6', async function () { - const { token } = await deployFixture(); - await expect(token.decimals()).to.eventually.equal(6); - }); - }); - - describe('confidentialBalanceOf', function () { - it('handle can be reencryped by owner', async function () { - const { token, holder } = await deployFixture(); - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(1000); - }); - - it('handle cannot be reencryped by non-owner', async function () { - const { token, holder, anyone } = await deployFixture(); - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), anyone), - ).to.be.rejectedWith(generateReencryptionErrorMessage(balanceOfHandleHolder, anyone.address)); - }); - }); - - describe('mint', function () { - for (const existingUser of [false, true]) { - it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { - const { token, holder } = await deployFixture(); - if (existingUser) { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(1000) - .encrypt(); - - await token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - } - - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - - // Check total supply - const totalSupplyHandle = await token.confidentialTotalSupply(); - await token.connect(holder).confidentialTotalSupplyAccess(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - }); - } - - it('from zero address', async function () { - const { token, holder } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('burn', function () { - for (const sufficientBalance of [false, true]) { - it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const { token, holder } = await deployFixture(); - const burnAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(burnAmount) - .encrypt(); - - await token - .connect(holder) - ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - - // Check total supply - const totalSupplyHandle = await token.confidentialTotalSupply(); - await token.connect(holder).confidentialTotalSupplyAccess(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - }); - } - - it('from zero address', async function () { - const { token, holder } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('transfer', function () { - for (const asSender of [true, false]) { - describe(asSender ? 'as sender' : 'as operator', function () { - let [holder, recipient, operator]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - beforeEach(async function () { - ({ token, holder, recipient, operator } = await deployFixture()); - if (!asSender) { - const timestamp = (await ethers.provider.getBlock('latest'))!.timestamp + 100; - await token.connect(holder).setOperator(operator.address, timestamp); - } - }); - - if (!asSender) { - for (const withCallback of [false, true]) { - describe(withCallback ? 'with callback' : 'without callback', function () { - let encryptedInput: any; - let params: any; - - beforeEach(async function () { - encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), operator.address) - .add64(100) - .encrypt(); - - params = [holder.address, recipient.address, encryptedInput.handles[0], encryptedInput.inputProof]; - if (withCallback) { - params.push('0x'); - } - }); - - it('without operator approval should fail', async function () { - await token.$_setOperator(holder, operator, 0); - - await expect( - token - .connect(operator) - [ - withCallback - ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' - : 'confidentialTransferFrom(address,address,bytes32,bytes)' - ](...params), - ) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') - .withArgs(holder.address, operator.address); - }); - - it('should be successful', async function () { - await token - .connect(operator) - [ - withCallback - ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' - : 'confidentialTransferFrom(address,address,bytes32,bytes)' - ](...params); - }); - }); - } - } - - // Edge cases to run with sender as caller - if (asSender) { - it('with no balance should revert', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), recipient.address) - .add64(100) - .encrypt(); - - await expect( - token - .connect(recipient) - ['confidentialTransfer(address,bytes32,bytes)']( - holder.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984ZeroBalance') - .withArgs(recipient.address); - }); - - it('to zero address', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(100) - .encrypt(); - - await expect( - token - .connect(holder) - ['confidentialTransfer(address,bytes32,bytes)']( - ethers.ZeroAddress, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - } - - for (const sufficientBalance of [false, true]) { - it(`${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const transferAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), asSender ? holder.address : operator.address) - .add64(transferAmount) - .encrypt(); - - let tx; - if (asSender) { - tx = await token - .connect(holder) - ['confidentialTransfer(address,bytes32,bytes)']( - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - } else { - tx = await token - .connect(operator) - ['confidentialTransferFrom(address,address,bytes32,bytes)']( - holder.address, - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - } - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; - expect(transferEvent.args[0]).to.equal(holder.address); - expect(transferEvent.args[1]).to.equal(recipient.address); - - const transferAmountHandle = transferEvent.args[2]; - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); - - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), recipient), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - // Other can not reencrypt the transfer amount - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), operator), - ).to.be.rejectedWith(generateReencryptionErrorMessage(transferAmountHandle, operator.address)); - - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, holderBalanceHandle, await token.getAddress(), holder), - ).to.eventually.equal(1000 - (sufficientBalance ? transferAmount : 0)); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, recipientBalanceHandle, await token.getAddress(), recipient), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - }); - } - }); - } - - describe('without input proof', function () { - for (const [usingTransferFrom, withCallback] of [false, true].flatMap(val => [ - [val, false], - [val, true], - ])) { - describe(`using ${usingTransferFrom ? 'confidentialTransferFrom' : 'confidentialTransfer'} ${ - withCallback ? 'with callback' : '' - }`, function () { - async function callTransfer(contract: any, from: any, to: any, amount: any, sender: any = from) { - let functionParams = [to, amount]; - - if (withCallback) { - functionParams.push('0x'); - if (usingTransferFrom) { - functionParams.unshift(from); - await contract.connect(sender).confidentialTransferFromAndCall(...functionParams); - } else { - await contract.connect(sender).confidentialTransferAndCall(...functionParams); - } - } else { - if (usingTransferFrom) { - functionParams.unshift(from); - await contract.connect(sender).confidentialTransferFrom(...functionParams); - } else { - await contract.connect(sender).confidentialTransfer(...functionParams); - } - } - } - - it('full balance', async function () { - const { token, holder, recipient } = await deployFixture(); - const fullBalanceHandle = await token.confidentialBalanceOf(holder); - - await callTransfer(token, holder, recipient, fullBalanceHandle); - - await expect( - fhevm.userDecryptEuint( - FhevmType.euint64, - await token.confidentialBalanceOf(recipient), - await token.getAddress(), - recipient, - ), - ).to.eventually.equal(1000); - }); - - it('other user balance should revert', async function () { - const { token, holder, recipient } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(100) - .encrypt(); - - await token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); - - const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); - await expect(callTransfer(token, holder, recipient, recipientBalanceHandle)) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(recipientBalanceHandle, holder); - }); - - if (usingTransferFrom) { - describe('without operator approval', function () { - let [holder, recipient, operator]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - beforeEach(async function () { - ({ token, holder, recipient, operator } = await deployFixture()); - await token.connect(holder).setOperator(operator.address, 0); - await allowHandle(hre, holder, operator, await token.confidentialBalanceOf(holder)); - }); - - it('should revert', async function () { - await expect( - callTransfer(token, holder, recipient, await token.confidentialBalanceOf(holder), operator), - ) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') - .withArgs(holder.address, operator.address); - }); - }); - } - }); - } - }); - - it('internal function reverts on from address zero', async function () { - const { token, holder, recipient } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(100) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_transfer(address,address,bytes32,bytes)']( - ethers.ZeroAddress, - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('transfer with callback', function () { - let [holder, recipient]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - let recipientContract: ERC7984ReceiverMock; - let encryptedInput: any; - beforeEach(async function () { - ({ token, holder, recipient } = await deployFixture()); - recipientContract = await ethers.deployContract('ERC7984ReceiverMock'); - - encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(1000) - .encrypt(); - }); - - for (const callbackSuccess of [false, true]) { - it(`with callback running ${callbackSuccess ? 'successfully' : 'unsuccessfully'}`, async function () { - const tx = await token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipientContract.target, - encryptedInput.handles[0], - encryptedInput.inputProof, - ethers.AbiCoder.defaultAbiCoder().encode(['bool'], [callbackSuccess]), - ); - - await expect( - fhevm.userDecryptEuint( - FhevmType.euint64, - await token.confidentialBalanceOf(holder), - await token.getAddress(), - holder, - ), - ).to.eventually.equal(callbackSuccess ? 0 : 1000); - - // Verify event contents - expect(tx).to.emit(recipientContract, 'ConfidentialTransferCallback').withArgs(callbackSuccess); - const transferEvents = (await tx.wait()).logs.filter((log: any) => log.address === token.target); - - const outboundTransferEvent = transferEvents[0]; - const inboundTransferEvent = transferEvents[1]; - - expect(outboundTransferEvent.args[0]).to.equal(holder.address); - expect(outboundTransferEvent.args[1]).to.equal(recipientContract.target); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, outboundTransferEvent.args[2], await token.getAddress(), holder), - ).to.eventually.equal(1000); - - expect(inboundTransferEvent.args[0]).to.equal(recipientContract.target); - expect(inboundTransferEvent.args[1]).to.equal(holder.address); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, inboundTransferEvent.args[2], await token.getAddress(), holder), - ).to.eventually.equal(callbackSuccess ? 0 : 1000); - }); - } - - it('with callback reverting without a reason', async function () { - await expect( - token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipientContract.target, - encryptedInput.handles[0], - encryptedInput.inputProof, - '0x', - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(recipientContract.target); - }); - - it('with callback reverting with a custom error', async function () { - await expect( - token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipientContract.target, - encryptedInput.handles[0], - encryptedInput.inputProof, - ethers.AbiCoder.defaultAbiCoder().encode(['uint8'], [2]), - ), - ) - .to.be.revertedWithCustomError(recipientContract, 'InvalidInput') - .withArgs(2); - }); - - it('to an EOA', async function () { - await token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - '0x', - ); - - const balanceOfHandle = await token.confidentialBalanceOf(recipient); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandle, await token.getAddress(), recipient), - ).to.eventually.equal(1000); - }); - }); - - describe('disclose', function () { - let [holder, recipient]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - let expectedAmount: any; - let expectedHandle: any; - beforeEach(async function () { - ({ token, holder, recipient } = await deployFixture()); - expectedAmount = undefined; - expectedHandle = undefined; - }); - - it('user balance', async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - - await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); - - expectedAmount = 1000n; - expectedHandle = holderBalanceHandle; - }); - - it('transaction amount', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - const tx = await token['confidentialTransfer(address,bytes32,bytes)']( - recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; - const transferAmount = transferEvent.args[2]; - - await token.connect(recipient).discloseEncryptedAmount(transferAmount); - - expectedAmount = 400n; - expectedHandle = transferAmount; - }); - - it("other user's balance", async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - - await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(holderBalanceHandle, recipient); - }); - - it('invalid signature reverts', async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); - - await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; - }); - - afterEach(async function () { - if (expectedHandle === undefined || expectedAmount === undefined) return; - - await fhevm.awaitDecryptionOracle(); - - // Check that event was correctly emitted - const eventFilter = token.filters.AmountDisclosed(); - const discloseEvent = (await token.queryFilter(eventFilter))[0]; - expect(discloseEvent.args[0]).to.equal(expectedHandle); - expect(discloseEvent.args[1]).to.equal(expectedAmount); - }); - }); - }); -} -/* eslint-enable no-unexpected-multiline */ - -function generateReencryptionErrorMessage(handle: string, account: string): string { - return `User ${account} is not authorized to user decrypt handle ${handle}`; -} - -export { shouldBehaveLikeERC7984 }; +export { _shouldBehaveLikeERC7984 as shouldBehaveLikeERC7984 }; diff --git a/test/token/ERC7984/ERC7984.test.ts b/test/token/ERC7984/ERC7984.test.ts index 18093efc..84cae53f 100644 --- a/test/token/ERC7984/ERC7984.test.ts +++ b/test/token/ERC7984/ERC7984.test.ts @@ -1,6 +1,11 @@ +import { ERC7984ReceiverMock } from '../../../types'; import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; +import { allowHandle } from '../../helpers/accounts'; import { shouldBehaveLikeERC7984 } from './ERC7984.behaviour'; -import { ethers, fhevm } from 'hardhat'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import hre, { ethers, fhevm } from 'hardhat'; const name = 'ConfidentialFungibleToken'; const symbol = 'CFT'; @@ -21,6 +26,595 @@ describe('ERC7984', function () { return { token, holder, recipient, operator, anyone }; } - shouldBehaveLikeERC7984(deployFixture); + _shouldBehaveLikeERC7984(deployFixture); }); + +//TODO: Move to ERC7984.behaviour.ts in a future PR. +/* eslint-disable no-unexpected-multiline */ +export function _shouldBehaveLikeERC7984( + deployFixture: () => Promise<{ + token: $ERC7984Mock; + holder: HardhatEthersSigner; + recipient: HardhatEthersSigner; + operator: HardhatEthersSigner; + anyone: HardhatEthersSigner; + }>, +) { + describe('ERC7984', function () { + describe('constructor', function () { + it('sets the name', async function () { + const { token } = await deployFixture(); + await expect(token.name()).to.eventually.equal(name); + }); + + it('sets the symbol', async function () { + const { token } = await deployFixture(); + await expect(token.symbol()).to.eventually.equal(symbol); + }); + + it('sets the uri', async function () { + const { token } = await deployFixture(); + await expect(token.tokenURI()).to.eventually.equal(uri); + }); + + it('decimals is 6', async function () { + const { token } = await deployFixture(); + await expect(token.decimals()).to.eventually.equal(6); + }); + }); + + describe('confidentialBalanceOf', function () { + it('handle can be reencryped by owner', async function () { + const { token, holder } = await deployFixture(); + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(1000); + }); + + it('handle cannot be reencryped by non-owner', async function () { + const { token, holder, anyone } = await deployFixture(); + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), anyone), + ).to.be.rejectedWith(generateReencryptionErrorMessage(balanceOfHandleHolder, anyone.address)); + }); + }); + + describe('mint', function () { + for (const existingUser of [false, true]) { + it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { + const { token, holder } = await deployFixture(); + if (existingUser) { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + } + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token.connect(holder).confidentialTotalSupplyAccess(); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('burn', function () { + for (const sufficientBalance of [false, true]) { + it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const { token, holder } = await deployFixture(); + const burnAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(burnAmount) + .encrypt(); + + await token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token.connect(holder).confidentialTotalSupplyAccess(); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('transfer', function () { + for (const asSender of [true, false]) { + describe(asSender ? 'as sender' : 'as operator', function () { + let [holder, recipient, operator]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + beforeEach(async function () { + ({ token, holder, recipient, operator } = await deployFixture()); + if (!asSender) { + const timestamp = (await ethers.provider.getBlock('latest'))!.timestamp + 100; + await token.connect(holder).setOperator(operator.address, timestamp); + } + }); + + if (!asSender) { + for (const withCallback of [false, true]) { + describe(withCallback ? 'with callback' : 'without callback', function () { + let encryptedInput: any; + let params: any; + + beforeEach(async function () { + encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), operator.address) + .add64(100) + .encrypt(); + + params = [holder.address, recipient.address, encryptedInput.handles[0], encryptedInput.inputProof]; + if (withCallback) { + params.push('0x'); + } + }); + + it('without operator approval should fail', async function () { + await token.$_setOperator(holder, operator, 0); + + await expect( + token + .connect(operator) + [ + withCallback + ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](...params), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') + .withArgs(holder.address, operator.address); + }); + + it('should be successful', async function () { + await token + .connect(operator) + [ + withCallback + ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](...params); + }); + }); + } + } + + // Edge cases to run with sender as caller + if (asSender) { + it('with no balance should revert', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + holder.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984ZeroBalance') + .withArgs(recipient.address); + }); + + it('to zero address', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(holder) + ['confidentialTransfer(address,bytes32,bytes)']( + ethers.ZeroAddress, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + } + + for (const sufficientBalance of [false, true]) { + it(`${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const transferAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), asSender ? holder.address : operator.address) + .add64(transferAmount) + .encrypt(); + + let tx; + if (asSender) { + tx = await token + .connect(holder) + ['confidentialTransfer(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + } else { + tx = await token + .connect(operator) + ['confidentialTransferFrom(address,address,bytes32,bytes)']( + holder.address, + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + } + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + expect(transferEvent.args[0]).to.equal(holder.address); + expect(transferEvent.args[1]).to.equal(recipient.address); + + const transferAmountHandle = transferEvent.args[2]; + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), recipient), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + // Other can not reencrypt the transfer amount + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), operator), + ).to.be.rejectedWith(generateReencryptionErrorMessage(transferAmountHandle, operator.address)); + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, holderBalanceHandle, await token.getAddress(), holder), + ).to.eventually.equal(1000 - (sufficientBalance ? transferAmount : 0)); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, recipientBalanceHandle, await token.getAddress(), recipient), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + }); + } + }); + } + + describe('without input proof', function () { + for (const [usingTransferFrom, withCallback] of [false, true].flatMap(val => [ + [val, false], + [val, true], + ])) { + describe(`using ${usingTransferFrom ? 'confidentialTransferFrom' : 'confidentialTransfer'} ${ + withCallback ? 'with callback' : '' + }`, function () { + async function callTransfer(contract: any, from: any, to: any, amount: any, sender: any = from) { + let functionParams = [to, amount]; + + if (withCallback) { + functionParams.push('0x'); + if (usingTransferFrom) { + functionParams.unshift(from); + await contract.connect(sender).confidentialTransferFromAndCall(...functionParams); + } else { + await contract.connect(sender).confidentialTransferAndCall(...functionParams); + } + } else { + if (usingTransferFrom) { + functionParams.unshift(from); + await contract.connect(sender).confidentialTransferFrom(...functionParams); + } else { + await contract.connect(sender).confidentialTransfer(...functionParams); + } + } + } + + it('full balance', async function () { + const { token, holder, recipient } = await deployFixture(); + const fullBalanceHandle = await token.confidentialBalanceOf(holder); + + await callTransfer(token, holder, recipient, fullBalanceHandle); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(1000); + }); + + it('other user balance should revert', async function () { + const { token, holder, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); + + const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); + await expect(callTransfer(token, holder, recipient, recipientBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(recipientBalanceHandle, holder); + }); + + if (usingTransferFrom) { + describe('without operator approval', function () { + let [holder, recipient, operator]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + beforeEach(async function () { + ({ token, holder, recipient, operator } = await deployFixture()); + await token.connect(holder).setOperator(operator.address, 0); + await allowHandle(hre, holder, operator, await token.confidentialBalanceOf(holder)); + }); + + it('should revert', async function () { + await expect( + callTransfer(token, holder, recipient, await token.confidentialBalanceOf(holder), operator), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') + .withArgs(holder.address, operator.address); + }); + }); + } + }); + } + }); + + it('internal function reverts on from address zero', async function () { + const { token, holder, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_transfer(address,address,bytes32,bytes)']( + ethers.ZeroAddress, + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('transfer with callback', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let recipientContract: ERC7984ReceiverMock; + let encryptedInput: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + recipientContract = await ethers.deployContract('ERC7984ReceiverMock'); + + encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + }); + + for (const callbackSuccess of [false, true]) { + it(`with callback running ${callbackSuccess ? 'successfully' : 'unsuccessfully'}`, async function () { + const tx = await token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.AbiCoder.defaultAbiCoder().encode(['bool'], [callbackSuccess]), + ); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(holder), + await token.getAddress(), + holder, + ), + ).to.eventually.equal(callbackSuccess ? 0 : 1000); + + // Verify event contents + expect(tx).to.emit(recipientContract, 'ConfidentialTransferCallback').withArgs(callbackSuccess); + const transferEvents = (await tx.wait()).logs.filter((log: any) => log.address === token.target); + + const outboundTransferEvent = transferEvents[0]; + const inboundTransferEvent = transferEvents[1]; + + expect(outboundTransferEvent.args[0]).to.equal(holder.address); + expect(outboundTransferEvent.args[1]).to.equal(recipientContract.target); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, outboundTransferEvent.args[2], await token.getAddress(), holder), + ).to.eventually.equal(1000); + + expect(inboundTransferEvent.args[0]).to.equal(recipientContract.target); + expect(inboundTransferEvent.args[1]).to.equal(holder.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, inboundTransferEvent.args[2], await token.getAddress(), holder), + ).to.eventually.equal(callbackSuccess ? 0 : 1000); + }); + } + + it('with callback reverting without a reason', async function () { + await expect( + token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + '0x', + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(recipientContract.target); + }); + + it('with callback reverting with a custom error', async function () { + await expect( + token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.AbiCoder.defaultAbiCoder().encode(['uint8'], [2]), + ), + ) + .to.be.revertedWithCustomError(recipientContract, 'InvalidInput') + .withArgs(2); + }); + + it('to an EOA', async function () { + await token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + '0x', + ); + + const balanceOfHandle = await token.confidentialBalanceOf(recipient); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandle, await token.getAddress(), recipient), + ).to.eventually.equal(1000); + }); + }); + + describe('disclose', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let expectedAmount: any; + let expectedHandle: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + expectedAmount = undefined; + expectedHandle = undefined; + }); + + it('user balance', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + expectedAmount = 1000n; + expectedHandle = holderBalanceHandle; + }); + + it('transaction amount', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + const tx = await token['confidentialTransfer(address,bytes32,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + const transferAmount = transferEvent.args[2]; + + await token.connect(recipient).discloseEncryptedAmount(transferAmount); + + expectedAmount = 400n; + expectedHandle = transferAmount; + }); + + it("other user's balance", async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(holderBalanceHandle, recipient); + }); + + it('invalid signature reverts', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; + }); + + afterEach(async function () { + if (expectedHandle === undefined || expectedAmount === undefined) return; + + await fhevm.awaitDecryptionOracle(); + + // Check that event was correctly emitted + const eventFilter = token.filters.AmountDisclosed(); + const discloseEvent = (await token.queryFilter(eventFilter))[0]; + expect(discloseEvent.args[0]).to.equal(expectedHandle); + expect(discloseEvent.args[1]).to.equal(expectedAmount); + }); + }); + }); +} +/* eslint-enable no-unexpected-multiline */ + +function generateReencryptionErrorMessage(handle: string, account: string): string { + return `User ${account} is not authorized to user decrypt handle ${handle}`; +} /* eslint-enable no-unexpected-multiline */ From 5ab888832c77580a7f2d3e53dcb319cd8e64bc87 Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:17:49 +0200 Subject: [PATCH 24/25] Lighten shouldBehaveLike calling --- test/token/ERC7984/ERC7984.behaviour.ts | 597 ++++++++++++++++- test/token/ERC7984/ERC7984.test.ts | 632 +----------------- .../extensions/ERC7984Freezable.test.ts | 12 +- 3 files changed, 623 insertions(+), 618 deletions(-) diff --git a/test/token/ERC7984/ERC7984.behaviour.ts b/test/token/ERC7984/ERC7984.behaviour.ts index 7e0c7456..288f45c3 100644 --- a/test/token/ERC7984/ERC7984.behaviour.ts +++ b/test/token/ERC7984/ERC7984.behaviour.ts @@ -1,3 +1,596 @@ -import { _shouldBehaveLikeERC7984 } from './ERC7984.test'; +import { ERC7984ReceiverMock } from '../../../types'; +import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; +import { allowHandle } from '../../helpers/accounts'; +import { deployERC7984Fixture } from './ERC7984.test'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import hre, { ethers, fhevm } from 'hardhat'; -export { _shouldBehaveLikeERC7984 as shouldBehaveLikeERC7984 }; +const name = 'ConfidentialFungibleToken'; +const symbol = 'CFT'; +const uri = 'https://example.com/metadata'; + +/* eslint-disable no-unexpected-multiline */ +function shouldBehaveLikeERC7984(contract?: string, ...extraDeploymentArgs: any[]) { + const deployFixture = () => deployERC7984Fixture(contract, extraDeploymentArgs); + + describe('ERC7984 behaviour', function () { + describe('constructor', function () { + it('sets the name', async function () { + const { token } = await deployFixture(); + await expect(token.name()).to.eventually.equal(name); + }); + + it('sets the symbol', async function () { + const { token } = await deployFixture(); + await expect(token.symbol()).to.eventually.equal(symbol); + }); + + it('sets the uri', async function () { + const { token } = await deployFixture(); + await expect(token.tokenURI()).to.eventually.equal(uri); + }); + + it('decimals is 6', async function () { + const { token } = await deployFixture(); + await expect(token.decimals()).to.eventually.equal(6); + }); + }); + + describe('confidentialBalanceOf', function () { + it('handle can be reencryped by owner', async function () { + const { token, holder } = await deployFixture(); + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(1000); + }); + + it('handle cannot be reencryped by non-owner', async function () { + const { token, holder, anyone } = await deployFixture(); + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), anyone), + ).to.be.rejectedWith(generateReencryptionErrorMessage(balanceOfHandleHolder, anyone.address)); + }); + }); + + describe('mint', function () { + for (const existingUser of [false, true]) { + it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { + const { token, holder } = await deployFixture(); + if (existingUser) { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + } + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token.connect(holder).confidentialTotalSupplyAccess(); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('burn', function () { + for (const sufficientBalance of [false, true]) { + it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const { token, holder } = await deployFixture(); + const burnAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(burnAmount) + .encrypt(); + + await token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token.connect(holder).confidentialTotalSupplyAccess(); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('transfer', function () { + for (const asSender of [true, false]) { + describe(asSender ? 'as sender' : 'as operator', function () { + let [holder, recipient, operator]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + beforeEach(async function () { + ({ token, holder, recipient, operator } = await deployFixture()); + if (!asSender) { + const timestamp = (await ethers.provider.getBlock('latest'))!.timestamp + 100; + await token.connect(holder).setOperator(operator.address, timestamp); + } + }); + + if (!asSender) { + for (const withCallback of [false, true]) { + describe(withCallback ? 'with callback' : 'without callback', function () { + let encryptedInput: any; + let params: any; + + beforeEach(async function () { + encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), operator.address) + .add64(100) + .encrypt(); + + params = [holder.address, recipient.address, encryptedInput.handles[0], encryptedInput.inputProof]; + if (withCallback) { + params.push('0x'); + } + }); + + it('without operator approval should fail', async function () { + await token.$_setOperator(holder, operator, 0); + + await expect( + token + .connect(operator) + [ + withCallback + ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](...params), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') + .withArgs(holder.address, operator.address); + }); + + it('should be successful', async function () { + await token + .connect(operator) + [ + withCallback + ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' + : 'confidentialTransferFrom(address,address,bytes32,bytes)' + ](...params); + }); + }); + } + } + + // Edge cases to run with sender as caller + if (asSender) { + it('with no balance should revert', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), recipient.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(recipient) + ['confidentialTransfer(address,bytes32,bytes)']( + holder.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984ZeroBalance') + .withArgs(recipient.address); + }); + + it('to zero address', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(holder) + ['confidentialTransfer(address,bytes32,bytes)']( + ethers.ZeroAddress, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + } + + for (const sufficientBalance of [false, true]) { + it(`${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const transferAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), asSender ? holder.address : operator.address) + .add64(transferAmount) + .encrypt(); + + let tx; + if (asSender) { + tx = await token + .connect(holder) + ['confidentialTransfer(address,bytes32,bytes)']( + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + } else { + tx = await token + .connect(operator) + ['confidentialTransferFrom(address,address,bytes32,bytes)']( + holder.address, + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + } + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + expect(transferEvent.args[0]).to.equal(holder.address); + expect(transferEvent.args[1]).to.equal(recipient.address); + + const transferAmountHandle = transferEvent.args[2]; + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), recipient), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + // Other can not reencrypt the transfer amount + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), operator), + ).to.be.rejectedWith(generateReencryptionErrorMessage(transferAmountHandle, operator.address)); + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, holderBalanceHandle, await token.getAddress(), holder), + ).to.eventually.equal(1000 - (sufficientBalance ? transferAmount : 0)); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, recipientBalanceHandle, await token.getAddress(), recipient), + ).to.eventually.equal(sufficientBalance ? transferAmount : 0); + }); + } + }); + } + + describe('without input proof', function () { + for (const [usingTransferFrom, withCallback] of [false, true].flatMap(val => [ + [val, false], + [val, true], + ])) { + describe(`using ${usingTransferFrom ? 'confidentialTransferFrom' : 'confidentialTransfer'} ${ + withCallback ? 'with callback' : '' + }`, function () { + async function callTransfer(contract: any, from: any, to: any, amount: any, sender: any = from) { + let functionParams = [to, amount]; + + if (withCallback) { + functionParams.push('0x'); + if (usingTransferFrom) { + functionParams.unshift(from); + await contract.connect(sender).confidentialTransferFromAndCall(...functionParams); + } else { + await contract.connect(sender).confidentialTransferAndCall(...functionParams); + } + } else { + if (usingTransferFrom) { + functionParams.unshift(from); + await contract.connect(sender).confidentialTransferFrom(...functionParams); + } else { + await contract.connect(sender).confidentialTransfer(...functionParams); + } + } + } + + it('full balance', async function () { + const { token, holder, recipient } = await deployFixture(); + const fullBalanceHandle = await token.confidentialBalanceOf(holder); + + await callTransfer(token, holder, recipient, fullBalanceHandle); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(recipient), + await token.getAddress(), + recipient, + ), + ).to.eventually.equal(1000); + }); + + it('other user balance should revert', async function () { + const { token, holder, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); + + const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); + await expect(callTransfer(token, holder, recipient, recipientBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(recipientBalanceHandle, holder); + }); + + if (usingTransferFrom) { + describe('without operator approval', function () { + let [holder, recipient, operator]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + beforeEach(async function () { + ({ token, holder, recipient, operator } = await deployFixture()); + await token.connect(holder).setOperator(operator.address, 0); + await allowHandle(hre, holder, operator, await token.confidentialBalanceOf(holder)); + }); + + it('should revert', async function () { + await expect( + callTransfer(token, holder, recipient, await token.confidentialBalanceOf(holder), operator), + ) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') + .withArgs(holder.address, operator.address); + }); + }); + } + }); + } + }); + + it('internal function reverts on from address zero', async function () { + const { token, holder, recipient } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(100) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_transfer(address,address,bytes32,bytes)']( + ethers.ZeroAddress, + recipient.address, + encryptedInput.handles[0], + encryptedInput.inputProof, + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('transfer with callback', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let recipientContract: ERC7984ReceiverMock; + let encryptedInput: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + recipientContract = await ethers.deployContract('ERC7984ReceiverMock'); + + encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + }); + + for (const callbackSuccess of [false, true]) { + it(`with callback running ${callbackSuccess ? 'successfully' : 'unsuccessfully'}`, async function () { + const tx = await token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.AbiCoder.defaultAbiCoder().encode(['bool'], [callbackSuccess]), + ); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await token.confidentialBalanceOf(holder), + await token.getAddress(), + holder, + ), + ).to.eventually.equal(callbackSuccess ? 0 : 1000); + + // Verify event contents + expect(tx).to.emit(recipientContract, 'ConfidentialTransferCallback').withArgs(callbackSuccess); + const transferEvents = (await tx.wait()).logs.filter((log: any) => log.address === token.target); + + const outboundTransferEvent = transferEvents[0]; + const inboundTransferEvent = transferEvents[1]; + + expect(outboundTransferEvent.args[0]).to.equal(holder.address); + expect(outboundTransferEvent.args[1]).to.equal(recipientContract.target); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, outboundTransferEvent.args[2], await token.getAddress(), holder), + ).to.eventually.equal(1000); + + expect(inboundTransferEvent.args[0]).to.equal(recipientContract.target); + expect(inboundTransferEvent.args[1]).to.equal(holder.address); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, inboundTransferEvent.args[2], await token.getAddress(), holder), + ).to.eventually.equal(callbackSuccess ? 0 : 1000); + }); + } + + it('with callback reverting without a reason', async function () { + await expect( + token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + '0x', + ), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(recipientContract.target); + }); + + it('with callback reverting with a custom error', async function () { + await expect( + token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipientContract.target, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.AbiCoder.defaultAbiCoder().encode(['uint8'], [2]), + ), + ) + .to.be.revertedWithCustomError(recipientContract, 'InvalidInput') + .withArgs(2); + }); + + it('to an EOA', async function () { + await token + .connect(holder) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + '0x', + ); + + const balanceOfHandle = await token.confidentialBalanceOf(recipient); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandle, await token.getAddress(), recipient), + ).to.eventually.equal(1000); + }); + }); + + describe('disclose', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let expectedAmount: any; + let expectedHandle: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + expectedAmount = undefined; + expectedHandle = undefined; + }); + + it('user balance', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + expectedAmount = 1000n; + expectedHandle = holderBalanceHandle; + }); + + it('transaction amount', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + const tx = await token['confidentialTransfer(address,bytes32,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + const transferAmount = transferEvent.args[2]; + + await token.connect(recipient).discloseEncryptedAmount(transferAmount); + + expectedAmount = 400n; + expectedHandle = transferAmount; + }); + + it("other user's balance", async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(holderBalanceHandle, recipient); + }); + + it('invalid signature reverts', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; + }); + + afterEach(async function () { + if (expectedHandle === undefined || expectedAmount === undefined) return; + + await fhevm.awaitDecryptionOracle(); + + // Check that event was correctly emitted + const eventFilter = token.filters.AmountDisclosed(); + const discloseEvent = (await token.queryFilter(eventFilter))[0]; + expect(discloseEvent.args[0]).to.equal(expectedHandle); + expect(discloseEvent.args[1]).to.equal(expectedAmount); + }); + }); + }); +} +/* eslint-enable no-unexpected-multiline */ + +function generateReencryptionErrorMessage(handle: string, account: string): string { + return `User ${account} is not authorized to user decrypt handle ${handle}`; +} + +export { shouldBehaveLikeERC7984 }; diff --git a/test/token/ERC7984/ERC7984.test.ts b/test/token/ERC7984/ERC7984.test.ts index 84cae53f..b8e71f9b 100644 --- a/test/token/ERC7984/ERC7984.test.ts +++ b/test/token/ERC7984/ERC7984.test.ts @@ -1,620 +1,32 @@ -import { ERC7984ReceiverMock } from '../../../types'; import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; -import { allowHandle } from '../../helpers/accounts'; import { shouldBehaveLikeERC7984 } from './ERC7984.behaviour'; -import { FhevmType } from '@fhevm/hardhat-plugin'; -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; -import { expect } from 'chai'; -import hre, { ethers, fhevm } from 'hardhat'; +import { ethers, fhevm } from 'hardhat'; +const contract = '$ERC7984Mock'; const name = 'ConfidentialFungibleToken'; const symbol = 'CFT'; const uri = 'https://example.com/metadata'; -/* eslint-disable no-unexpected-multiline */ -describe('ERC7984', function () { - async function deployFixture() { - const [holder, recipient, operator, anyone] = await ethers.getSigners(); - const token = (await ethers.deployContract('$ERC7984Mock', [name, symbol, uri])) as any as $ERC7984Mock; - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(1000) - .encrypt(); - await token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - return { token, holder, recipient, operator, anyone }; - } +async function deployFixture(_contract?: string, extraDeploymentArgs: any[] = []) { + const [holder, recipient, operator, anyone] = await ethers.getSigners(); + const token = (await ethers.deployContract(_contract ? _contract : contract, [ + name, + symbol, + uri, + ...extraDeploymentArgs, + ])) as any as $ERC7984Mock; + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + return { token, holder, recipient, operator, anyone }; +} - _shouldBehaveLikeERC7984(deployFixture); +describe('ERC7984', function () { + shouldBehaveLikeERC7984(contract); }); -//TODO: Move to ERC7984.behaviour.ts in a future PR. -/* eslint-disable no-unexpected-multiline */ -export function _shouldBehaveLikeERC7984( - deployFixture: () => Promise<{ - token: $ERC7984Mock; - holder: HardhatEthersSigner; - recipient: HardhatEthersSigner; - operator: HardhatEthersSigner; - anyone: HardhatEthersSigner; - }>, -) { - describe('ERC7984', function () { - describe('constructor', function () { - it('sets the name', async function () { - const { token } = await deployFixture(); - await expect(token.name()).to.eventually.equal(name); - }); - - it('sets the symbol', async function () { - const { token } = await deployFixture(); - await expect(token.symbol()).to.eventually.equal(symbol); - }); - - it('sets the uri', async function () { - const { token } = await deployFixture(); - await expect(token.tokenURI()).to.eventually.equal(uri); - }); - - it('decimals is 6', async function () { - const { token } = await deployFixture(); - await expect(token.decimals()).to.eventually.equal(6); - }); - }); - - describe('confidentialBalanceOf', function () { - it('handle can be reencryped by owner', async function () { - const { token, holder } = await deployFixture(); - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(1000); - }); - - it('handle cannot be reencryped by non-owner', async function () { - const { token, holder, anyone } = await deployFixture(); - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), anyone), - ).to.be.rejectedWith(generateReencryptionErrorMessage(balanceOfHandleHolder, anyone.address)); - }); - }); - - describe('mint', function () { - for (const existingUser of [false, true]) { - it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { - const { token, holder } = await deployFixture(); - if (existingUser) { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(1000) - .encrypt(); - - await token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - } - - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - - // Check total supply - const totalSupplyHandle = await token.confidentialTotalSupply(); - await token.connect(holder).confidentialTotalSupplyAccess(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - }); - } - - it('from zero address', async function () { - const { token, holder } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('burn', function () { - for (const sufficientBalance of [false, true]) { - it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const { token, holder } = await deployFixture(); - const burnAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(burnAmount) - .encrypt(); - - await token - .connect(holder) - ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - - // Check total supply - const totalSupplyHandle = await token.confidentialTotalSupply(); - await token.connect(holder).confidentialTotalSupplyAccess(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - }); - } - - it('from zero address', async function () { - const { token, holder } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('transfer', function () { - for (const asSender of [true, false]) { - describe(asSender ? 'as sender' : 'as operator', function () { - let [holder, recipient, operator]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - beforeEach(async function () { - ({ token, holder, recipient, operator } = await deployFixture()); - if (!asSender) { - const timestamp = (await ethers.provider.getBlock('latest'))!.timestamp + 100; - await token.connect(holder).setOperator(operator.address, timestamp); - } - }); - - if (!asSender) { - for (const withCallback of [false, true]) { - describe(withCallback ? 'with callback' : 'without callback', function () { - let encryptedInput: any; - let params: any; - - beforeEach(async function () { - encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), operator.address) - .add64(100) - .encrypt(); - - params = [holder.address, recipient.address, encryptedInput.handles[0], encryptedInput.inputProof]; - if (withCallback) { - params.push('0x'); - } - }); - - it('without operator approval should fail', async function () { - await token.$_setOperator(holder, operator, 0); - - await expect( - token - .connect(operator) - [ - withCallback - ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' - : 'confidentialTransferFrom(address,address,bytes32,bytes)' - ](...params), - ) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') - .withArgs(holder.address, operator.address); - }); - - it('should be successful', async function () { - await token - .connect(operator) - [ - withCallback - ? 'confidentialTransferFromAndCall(address,address,bytes32,bytes,bytes)' - : 'confidentialTransferFrom(address,address,bytes32,bytes)' - ](...params); - }); - }); - } - } - - // Edge cases to run with sender as caller - if (asSender) { - it('with no balance should revert', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), recipient.address) - .add64(100) - .encrypt(); - - await expect( - token - .connect(recipient) - ['confidentialTransfer(address,bytes32,bytes)']( - holder.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984ZeroBalance') - .withArgs(recipient.address); - }); - - it('to zero address', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(100) - .encrypt(); - - await expect( - token - .connect(holder) - ['confidentialTransfer(address,bytes32,bytes)']( - ethers.ZeroAddress, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - } - - for (const sufficientBalance of [false, true]) { - it(`${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const transferAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), asSender ? holder.address : operator.address) - .add64(transferAmount) - .encrypt(); - - let tx; - if (asSender) { - tx = await token - .connect(holder) - ['confidentialTransfer(address,bytes32,bytes)']( - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - } else { - tx = await token - .connect(operator) - ['confidentialTransferFrom(address,address,bytes32,bytes)']( - holder.address, - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - } - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; - expect(transferEvent.args[0]).to.equal(holder.address); - expect(transferEvent.args[1]).to.equal(recipient.address); - - const transferAmountHandle = transferEvent.args[2]; - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); - - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), recipient), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - // Other can not reencrypt the transfer amount - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, transferAmountHandle, await token.getAddress(), operator), - ).to.be.rejectedWith(generateReencryptionErrorMessage(transferAmountHandle, operator.address)); - - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, holderBalanceHandle, await token.getAddress(), holder), - ).to.eventually.equal(1000 - (sufficientBalance ? transferAmount : 0)); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, recipientBalanceHandle, await token.getAddress(), recipient), - ).to.eventually.equal(sufficientBalance ? transferAmount : 0); - }); - } - }); - } - - describe('without input proof', function () { - for (const [usingTransferFrom, withCallback] of [false, true].flatMap(val => [ - [val, false], - [val, true], - ])) { - describe(`using ${usingTransferFrom ? 'confidentialTransferFrom' : 'confidentialTransfer'} ${ - withCallback ? 'with callback' : '' - }`, function () { - async function callTransfer(contract: any, from: any, to: any, amount: any, sender: any = from) { - let functionParams = [to, amount]; - - if (withCallback) { - functionParams.push('0x'); - if (usingTransferFrom) { - functionParams.unshift(from); - await contract.connect(sender).confidentialTransferFromAndCall(...functionParams); - } else { - await contract.connect(sender).confidentialTransferAndCall(...functionParams); - } - } else { - if (usingTransferFrom) { - functionParams.unshift(from); - await contract.connect(sender).confidentialTransferFrom(...functionParams); - } else { - await contract.connect(sender).confidentialTransfer(...functionParams); - } - } - } - - it('full balance', async function () { - const { token, holder, recipient } = await deployFixture(); - const fullBalanceHandle = await token.confidentialBalanceOf(holder); - - await callTransfer(token, holder, recipient, fullBalanceHandle); - - await expect( - fhevm.userDecryptEuint( - FhevmType.euint64, - await token.confidentialBalanceOf(recipient), - await token.getAddress(), - recipient, - ), - ).to.eventually.equal(1000); - }); - - it('other user balance should revert', async function () { - const { token, holder, recipient } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(100) - .encrypt(); - - await token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](recipient, encryptedInput.handles[0], encryptedInput.inputProof); - - const recipientBalanceHandle = await token.confidentialBalanceOf(recipient); - await expect(callTransfer(token, holder, recipient, recipientBalanceHandle)) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(recipientBalanceHandle, holder); - }); - - if (usingTransferFrom) { - describe('without operator approval', function () { - let [holder, recipient, operator]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - beforeEach(async function () { - ({ token, holder, recipient, operator } = await deployFixture()); - await token.connect(holder).setOperator(operator.address, 0); - await allowHandle(hre, holder, operator, await token.confidentialBalanceOf(holder)); - }); - - it('should revert', async function () { - await expect( - callTransfer(token, holder, recipient, await token.confidentialBalanceOf(holder), operator), - ) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedSpender') - .withArgs(holder.address, operator.address); - }); - }); - } - }); - } - }); - - it('internal function reverts on from address zero', async function () { - const { token, holder, recipient } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(100) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_transfer(address,address,bytes32,bytes)']( - ethers.ZeroAddress, - recipient.address, - encryptedInput.handles[0], - encryptedInput.inputProof, - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('transfer with callback', function () { - let [holder, recipient]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - let recipientContract: ERC7984ReceiverMock; - let encryptedInput: any; - beforeEach(async function () { - ({ token, holder, recipient } = await deployFixture()); - recipientContract = await ethers.deployContract('ERC7984ReceiverMock'); - - encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(1000) - .encrypt(); - }); - - for (const callbackSuccess of [false, true]) { - it(`with callback running ${callbackSuccess ? 'successfully' : 'unsuccessfully'}`, async function () { - const tx = await token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipientContract.target, - encryptedInput.handles[0], - encryptedInput.inputProof, - ethers.AbiCoder.defaultAbiCoder().encode(['bool'], [callbackSuccess]), - ); - - await expect( - fhevm.userDecryptEuint( - FhevmType.euint64, - await token.confidentialBalanceOf(holder), - await token.getAddress(), - holder, - ), - ).to.eventually.equal(callbackSuccess ? 0 : 1000); - - // Verify event contents - expect(tx).to.emit(recipientContract, 'ConfidentialTransferCallback').withArgs(callbackSuccess); - const transferEvents = (await tx.wait()).logs.filter((log: any) => log.address === token.target); - - const outboundTransferEvent = transferEvents[0]; - const inboundTransferEvent = transferEvents[1]; - - expect(outboundTransferEvent.args[0]).to.equal(holder.address); - expect(outboundTransferEvent.args[1]).to.equal(recipientContract.target); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, outboundTransferEvent.args[2], await token.getAddress(), holder), - ).to.eventually.equal(1000); - - expect(inboundTransferEvent.args[0]).to.equal(recipientContract.target); - expect(inboundTransferEvent.args[1]).to.equal(holder.address); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, inboundTransferEvent.args[2], await token.getAddress(), holder), - ).to.eventually.equal(callbackSuccess ? 0 : 1000); - }); - } - - it('with callback reverting without a reason', async function () { - await expect( - token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipientContract.target, - encryptedInput.handles[0], - encryptedInput.inputProof, - '0x', - ), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(recipientContract.target); - }); - - it('with callback reverting with a custom error', async function () { - await expect( - token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipientContract.target, - encryptedInput.handles[0], - encryptedInput.inputProof, - ethers.AbiCoder.defaultAbiCoder().encode(['uint8'], [2]), - ), - ) - .to.be.revertedWithCustomError(recipientContract, 'InvalidInput') - .withArgs(2); - }); - - it('to an EOA', async function () { - await token - .connect(holder) - ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( - recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - '0x', - ); - - const balanceOfHandle = await token.confidentialBalanceOf(recipient); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandle, await token.getAddress(), recipient), - ).to.eventually.equal(1000); - }); - }); - - describe('disclose', function () { - let [holder, recipient]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - let expectedAmount: any; - let expectedHandle: any; - beforeEach(async function () { - ({ token, holder, recipient } = await deployFixture()); - expectedAmount = undefined; - expectedHandle = undefined; - }); - - it('user balance', async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - - await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); - - expectedAmount = 1000n; - expectedHandle = holderBalanceHandle; - }); - - it('transaction amount', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - const tx = await token['confidentialTransfer(address,bytes32,bytes)']( - recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; - const transferAmount = transferEvent.args[2]; - - await token.connect(recipient).discloseEncryptedAmount(transferAmount); - - expectedAmount = 400n; - expectedHandle = transferAmount; - }); - - it("other user's balance", async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - - await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(holderBalanceHandle, recipient); - }); - - it('invalid signature reverts', async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); - - await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; - }); - - afterEach(async function () { - if (expectedHandle === undefined || expectedAmount === undefined) return; - - await fhevm.awaitDecryptionOracle(); - - // Check that event was correctly emitted - const eventFilter = token.filters.AmountDisclosed(); - const discloseEvent = (await token.queryFilter(eventFilter))[0]; - expect(discloseEvent.args[0]).to.equal(expectedHandle); - expect(discloseEvent.args[1]).to.equal(expectedAmount); - }); - }); - }); -} -/* eslint-enable no-unexpected-multiline */ - -function generateReencryptionErrorMessage(handle: string, account: string): string { - return `User ${account} is not authorized to user decrypt handle ${handle}`; -} -/* eslint-enable no-unexpected-multiline */ +export { deployFixture as deployERC7984Fixture }; diff --git a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts index 90493a9b..38528198 100644 --- a/test/token/ERC7984/extensions/ERC7984Freezable.test.ts +++ b/test/token/ERC7984/extensions/ERC7984Freezable.test.ts @@ -1,6 +1,5 @@ import { IACL__factory } from '../../../../types'; import { $ERC7984FreezableMock } from '../../../../types/contracts-exposed/mocks/token/ERC7984FreezableMock.sol/$ERC7984FreezableMock'; -import { $ERC7984Mock } from '../../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; import { ACL_ADDRESS } from '../../../helpers/accounts'; import { shouldBehaveLikeERC7984 } from '../ERC7984.behaviour'; import { FhevmType } from '@fhevm/hardhat-plugin'; @@ -8,6 +7,7 @@ import { expect } from 'chai'; import { AddressLike, BytesLike, EventLog } from 'ethers'; import { ethers, fhevm } from 'hardhat'; +const contract = '$ERC7984FreezableMock'; const name = 'ConfidentialFungibleToken'; const symbol = 'CFT'; const uri = 'https://example.com/metadata'; @@ -15,7 +15,7 @@ const uri = 'https://example.com/metadata'; describe('ERC7984Freezable', function () { async function deployFixture() { const [holder, recipient, freezer, operator, anyone] = await ethers.getSigners(); - const token = (await ethers.deployContract('$ERC7984FreezableMock', [ + const token = (await ethers.deployContract(contract, [ name, symbol, uri, @@ -239,8 +239,8 @@ describe('ERC7984Freezable', function () { .withArgs(encryptedInput.handles[0], anyone); }); - shouldBehaveLikeERC7984(async () => { - const { token, holder, recipient, operator, anyone } = await deployFixture(); - return { token: token as any as $ERC7984Mock, holder, recipient, operator, anyone }; - }); + shouldBehaveLikeERC7984( + contract, + '0x0000000000000000000000000000000000000001', // freezer + ); }); From 27c50c673c0a6e7838636b57e863366ca35abdfc Mon Sep 17 00:00:00 2001 From: James Toussaint <33313130+james-toussaint@users.noreply.github.com> Date: Thu, 28 Aug 2025 19:12:38 +0200 Subject: [PATCH 25/25] Keep only tests related to ERC in behaviour --- test/token/ERC7984/ERC7984.behaviour.ts | 163 ----------------------- test/token/ERC7984/ERC7984.test.ts | 164 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 163 deletions(-) diff --git a/test/token/ERC7984/ERC7984.behaviour.ts b/test/token/ERC7984/ERC7984.behaviour.ts index 288f45c3..1d1fa753 100644 --- a/test/token/ERC7984/ERC7984.behaviour.ts +++ b/test/token/ERC7984/ERC7984.behaviour.ts @@ -11,7 +11,6 @@ const name = 'ConfidentialFungibleToken'; const symbol = 'CFT'; const uri = 'https://example.com/metadata'; -/* eslint-disable no-unexpected-multiline */ function shouldBehaveLikeERC7984(contract?: string, ...extraDeploymentArgs: any[]) { const deployFixture = () => deployERC7984Fixture(contract, extraDeploymentArgs); @@ -56,98 +55,6 @@ function shouldBehaveLikeERC7984(contract?: string, ...extraDeploymentArgs: any[ }); }); - describe('mint', function () { - for (const existingUser of [false, true]) { - it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { - const { token, holder } = await deployFixture(); - if (existingUser) { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(1000) - .encrypt(); - - await token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - } - - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - - // Check total supply - const totalSupplyHandle = await token.confidentialTotalSupply(); - await token.connect(holder).confidentialTotalSupplyAccess(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), - ).to.eventually.equal(existingUser ? 2000 : 1000); - }); - } - - it('from zero address', async function () { - const { token, holder } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') - .withArgs(ethers.ZeroAddress); - }); - }); - - describe('burn', function () { - for (const sufficientBalance of [false, true]) { - it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { - const { token, holder } = await deployFixture(); - const burnAmount = sufficientBalance ? 400 : 1100; - - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(burnAmount) - .encrypt(); - - await token - .connect(holder) - ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); - - const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - - // Check total supply - const totalSupplyHandle = await token.confidentialTotalSupply(); - await token.connect(holder).confidentialTotalSupplyAccess(); - await expect( - fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), - ).to.eventually.equal(sufficientBalance ? 600 : 1000); - }); - } - - it('from zero address', async function () { - const { token, holder } = await deployFixture(); - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - await expect( - token - .connect(holder) - ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), - ) - .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') - .withArgs(ethers.ZeroAddress); - }); - }); - describe('transfer', function () { for (const asSender of [true, false]) { describe(asSender ? 'as sender' : 'as operator', function () { @@ -516,78 +423,8 @@ function shouldBehaveLikeERC7984(contract?: string, ...extraDeploymentArgs: any[ ).to.eventually.equal(1000); }); }); - - describe('disclose', function () { - let [holder, recipient]: HardhatEthersSigner[] = []; - let token: $ERC7984Mock; - let expectedAmount: any; - let expectedHandle: any; - beforeEach(async function () { - ({ token, holder, recipient } = await deployFixture()); - expectedAmount = undefined; - expectedHandle = undefined; - }); - - it('user balance', async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - - await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); - - expectedAmount = 1000n; - expectedHandle = holderBalanceHandle; - }); - - it('transaction amount', async function () { - const encryptedInput = await fhevm - .createEncryptedInput(await token.getAddress(), holder.address) - .add64(400) - .encrypt(); - - const tx = await token['confidentialTransfer(address,bytes32,bytes)']( - recipient, - encryptedInput.handles[0], - encryptedInput.inputProof, - ); - - const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; - const transferAmount = transferEvent.args[2]; - - await token.connect(recipient).discloseEncryptedAmount(transferAmount); - - expectedAmount = 400n; - expectedHandle = transferAmount; - }); - - it("other user's balance", async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - - await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) - .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(holderBalanceHandle, recipient); - }); - - it('invalid signature reverts', async function () { - const holderBalanceHandle = await token.confidentialBalanceOf(holder); - await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); - - await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; - }); - - afterEach(async function () { - if (expectedHandle === undefined || expectedAmount === undefined) return; - - await fhevm.awaitDecryptionOracle(); - - // Check that event was correctly emitted - const eventFilter = token.filters.AmountDisclosed(); - const discloseEvent = (await token.queryFilter(eventFilter))[0]; - expect(discloseEvent.args[0]).to.equal(expectedHandle); - expect(discloseEvent.args[1]).to.equal(expectedAmount); - }); - }); }); } -/* eslint-enable no-unexpected-multiline */ function generateReencryptionErrorMessage(handle: string, account: string): string { return `User ${account} is not authorized to user decrypt handle ${handle}`; diff --git a/test/token/ERC7984/ERC7984.test.ts b/test/token/ERC7984/ERC7984.test.ts index b8e71f9b..c8810100 100644 --- a/test/token/ERC7984/ERC7984.test.ts +++ b/test/token/ERC7984/ERC7984.test.ts @@ -1,5 +1,8 @@ import { $ERC7984Mock } from '../../../types/contracts-exposed/mocks/token/ERC7984Mock.sol/$ERC7984Mock'; import { shouldBehaveLikeERC7984 } from './ERC7984.behaviour'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; import { ethers, fhevm } from 'hardhat'; const contract = '$ERC7984Mock'; @@ -26,6 +29,167 @@ async function deployFixture(_contract?: string, extraDeploymentArgs: any[] = [] } describe('ERC7984', function () { + describe('mint', function () { + for (const existingUser of [false, true]) { + it(`to ${existingUser ? 'existing' : 'new'} user`, async function () { + const { token, holder } = await deployFixture(); + if (existingUser) { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(1000) + .encrypt(); + + await token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + } + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token.connect(holder).confidentialTotalSupplyAccess(); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(existingUser ? 2000 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_mint(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidReceiver') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('burn', function () { + for (const sufficientBalance of [false, true]) { + it(`from a user with ${sufficientBalance ? 'sufficient' : 'insufficient'} balance`, async function () { + const { token, holder } = await deployFixture(); + const burnAmount = sufficientBalance ? 400 : 1100; + + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(burnAmount) + .encrypt(); + + await token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](holder, encryptedInput.handles[0], encryptedInput.inputProof); + + const balanceOfHandleHolder = await token.confidentialBalanceOf(holder); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, balanceOfHandleHolder, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + + // Check total supply + const totalSupplyHandle = await token.confidentialTotalSupply(); + await token.connect(holder).confidentialTotalSupplyAccess(); + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, totalSupplyHandle, await token.getAddress(), holder), + ).to.eventually.equal(sufficientBalance ? 600 : 1000); + }); + } + + it('from zero address', async function () { + const { token, holder } = await deployFixture(); + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + await expect( + token + .connect(holder) + ['$_burn(address,bytes32,bytes)'](ethers.ZeroAddress, encryptedInput.handles[0], encryptedInput.inputProof), + ) + .to.be.revertedWithCustomError(token, 'ERC7984InvalidSender') + .withArgs(ethers.ZeroAddress); + }); + }); + + describe('disclose', function () { + let [holder, recipient]: HardhatEthersSigner[] = []; + let token: $ERC7984Mock; + let expectedAmount: any; + let expectedHandle: any; + beforeEach(async function () { + ({ token, holder, recipient } = await deployFixture()); + expectedAmount = undefined; + expectedHandle = undefined; + }); + + it('user balance', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + expectedAmount = 1000n; + expectedHandle = holderBalanceHandle; + }); + + it('transaction amount', async function () { + const encryptedInput = await fhevm + .createEncryptedInput(await token.getAddress(), holder.address) + .add64(400) + .encrypt(); + + const tx = await token['confidentialTransfer(address,bytes32,bytes)']( + recipient, + encryptedInput.handles[0], + encryptedInput.inputProof, + ); + + const transferEvent = (await tx.wait()).logs.filter((log: any) => log.address === token.target)[0]; + const transferAmount = transferEvent.args[2]; + + await token.connect(recipient).discloseEncryptedAmount(transferAmount); + + expectedAmount = 400n; + expectedHandle = transferAmount; + }); + + it("other user's balance", async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + + await expect(token.connect(recipient).discloseEncryptedAmount(holderBalanceHandle)) + .to.be.revertedWithCustomError(token, 'ERC7984UnauthorizedUseOfEncryptedAmount') + .withArgs(holderBalanceHandle, recipient); + }); + + it('invalid signature reverts', async function () { + const holderBalanceHandle = await token.confidentialBalanceOf(holder); + await token.connect(holder).discloseEncryptedAmount(holderBalanceHandle); + + await expect(token.connect(holder).finalizeDiscloseEncryptedAmount(0, 0, [])).to.be.reverted; + }); + + afterEach(async function () { + if (expectedHandle === undefined || expectedAmount === undefined) return; + + await fhevm.awaitDecryptionOracle(); + + // Check that event was correctly emitted + const eventFilter = token.filters.AmountDisclosed(); + const discloseEvent = (await token.queryFilter(eventFilter))[0]; + expect(discloseEvent.args[0]).to.equal(expectedHandle); + expect(discloseEvent.args[1]).to.equal(expectedAmount); + }); + }); + shouldBehaveLikeERC7984(contract); });