-
Notifications
You must be signed in to change notification settings - Fork 30
Add ERC7984Freezable
#151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
james-toussaint
merged 23 commits into
master
from
feature/confidential-freezable-token
Aug 25, 2025
Merged
Add ERC7984Freezable
#151
Changes from 10 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
b4ce8d9
Add `ERC7984Freezable` extension.
james-toussaint f04162b
Update pragma
james-toussaint ec16cdf
Merge remote-tracking branch 'origin/master' into feature/confidentia…
james-toussaint ff2a74f
Add freezable test
james-toussaint f485ec0
Remove unused functions in freezable mock
james-toussaint a961e33
Bring back previous HandleAccessManager
james-toussaint 3c759fb
Move allow available to access function in mock
james-toussaint cd81483
Swap event & error order
james-toussaint 3bb3f2e
Update inline documentation
james-toussaint ac9ae06
Remove useless var
james-toussaint 4095083
Merge branch 'master' into feature/confidential-freezable-token
arr00 0286f57
Apply suggestions from review
james-toussaint c790dc5
Move freezable test file to extensions dir
james-toussaint 754ecec
update tests
arr00 8e08500
Apply suggestions
james-toussaint 7e45893
Remove account from `_validateHandleAllowance` (#178)
arr00 ed13e2f
Call internal when setting frozen with proof
james-toussaint 9ae4074
Update doc
james-toussaint 6a52710
Update pragma & remove import in freezable mock
james-toussaint bc71379
Base freezable mock on ERC7984Mock
james-toussaint 4466341
Merge branch 'master' into feature/confidential-freezable-token
arr00 7f9b646
Apply suggestions
james-toussaint 9d8aa7d
Check transfer amount is zero if transferring more than available
james-toussaint File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'openzeppelin-confidential-contracts': minor | ||
| --- | ||
|
|
||
| Add `ERC7984Freezable` extension. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // 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 {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 | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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, | ||
| string memory tokenUri, | ||
| address freezer | ||
| ) ConfidentialFungibleToken(name, symbol, tokenUri) { | ||
| _grantRole(FREEZER_ROLE, freezer); | ||
| } | ||
|
|
||
| function confidentialAvailableAccess(address account) public { | ||
| euint64 available = confidentialAvailable(account); | ||
| FHE.allowThis(available); | ||
arr00 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| getHandleAllowance(euint64.unwrap(available), account, true); | ||
| } | ||
|
|
||
| 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)); | ||
| } | ||
|
|
||
| function _checkFreezer() internal override onlyRole(FREEZER_ROLE) {} | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| 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"; | ||
|
|
||
| /** | ||
| * Inspired by https://github.com/OpenZeppelin/openzeppelin-community-contracts/pull/186. | ||
| * | ||
| * @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 confidential | ||
| * amount of tokens held by an account won't be transferable until those | ||
| * tokens are unfrozen. | ||
| */ | ||
| abstract contract ERC7984Freezable is ConfidentialFungibleToken { | ||
| /// @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); | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| error ERC7984UnauthorizedUseOfEncryptedAmount(euint64 encryptedAmount, address user); | ||
|
|
||
| /// @dev Returns the confidential frozen balance of an account. | ||
| function confidentialFrozen(address account) public view virtual returns (euint64) { | ||
| return _frozenBalances[account]; | ||
| } | ||
|
|
||
| /// @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)); | ||
| } | ||
|
|
||
| /// @dev Freezes a confidential amount of token amount for an account with a proof. | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| function setConfidentialFrozen( | ||
| address account, | ||
| externalEuint64 encryptedAmount, | ||
| bytes calldata inputProof | ||
| ) public virtual { | ||
| return setConfidentialFrozen(account, FHE.fromExternal(encryptedAmount, inputProof)); | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /// @dev Freezes a confidential amount of token amount for an account. | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| function setConfidentialFrozen(address account, euint64 encryptedAmount) public virtual { | ||
| require( | ||
| FHE.isAllowed(encryptedAmount, msg.sender), | ||
| ERC7984UnauthorizedUseOfEncryptedAmount(encryptedAmount, msg.sender) | ||
| ); | ||
| return _setConfidentialFrozen(account, encryptedAmount); | ||
| } | ||
|
|
||
| /// @dev Internal function to freeze a confidential amount of token amount for an account. | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| function _setConfidentialFrozen(address account, euint64 encryptedAmount) internal virtual { | ||
| _checkFreezer(); | ||
| FHE.allowThis(encryptedAmount); | ||
| FHE.allow(encryptedAmount, account); | ||
| _frozenBalances[account] = encryptedAmount; | ||
| emit Frozen(account, encryptedAmount); | ||
| } | ||
|
|
||
| /// @dev Checks has freezer role. | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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. | ||
james-toussaint marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| */ | ||
| 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, FHE.asEuint64(0)); | ||
| } | ||
| return super._update(from, to, encryptedAmount); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| 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 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); | ||
| }); | ||
|
|
||
| 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 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); | ||
| 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()); | ||
| 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()); | ||
| expect( | ||
| await fhevm.userDecryptEuint( | ||
| FhevmType.euint64, | ||
| await token.confidentialBalanceOf(recipient.address), | ||
| await token.getAddress(), | ||
| recipient, | ||
| ), | ||
| ).to.equal(1000); | ||
| }); | ||
| }); | ||
| /* eslint-disable no-unexpected-multiline */ |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.