diff --git a/.changeset/stupid-fans-bow.md b/.changeset/stupid-fans-bow.md new file mode 100644 index 00000000..e8406193 --- /dev/null +++ b/.changeset/stupid-fans-bow.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +`ERC7984Omnibus`: Add an extension of `ERC7984` that exposes new functions for transferring between confidential subaccounts on omnibus wallets. diff --git a/contracts/mocks/token/ERC7984Mock.sol b/contracts/mocks/token/ERC7984Mock.sol index 7288eb91..47bd0ca7 100644 --- a/contracts/mocks/token/ERC7984Mock.sol +++ b/contracts/mocks/token/ERC7984Mock.sol @@ -3,12 +3,16 @@ 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, eaddress, euint64, externalEuint64} from "@fhevm/solidity/lib/FHE.sol"; import {ERC7984} from "../../token/ERC7984/ERC7984.sol"; + // solhint-disable func-name-mixedcase contract ERC7984Mock is ERC7984, SepoliaConfig { address private immutable _OWNER; + event EncryptedAmountCreated(euint64 amount); + event EncryptedAddressCreated(eaddress addr); + constructor( string memory name_, string memory symbol_, @@ -17,6 +21,22 @@ contract ERC7984Mock is ERC7984, SepoliaConfig { _OWNER = msg.sender; } + function createEncryptedAmount(uint64 amount) public returns (euint64 encryptedAmount) { + FHE.allowThis(encryptedAmount = FHE.asEuint64(amount)); + FHE.allow(encryptedAmount, msg.sender); + + emit EncryptedAmountCreated(encryptedAmount); + } + + function createEncryptedAddress(address addr) public returns (eaddress) { + eaddress encryptedAddr = FHE.asEaddress(addr); + FHE.allowThis(encryptedAddr); + FHE.allow(encryptedAddr, msg.sender); + + emit EncryptedAddressCreated(encryptedAddr); + return encryptedAddr; + } + function _update(address from, address to, euint64 amount) internal virtual override returns (euint64 transferred) { transferred = super._update(from, to, amount); FHE.allow(confidentialTotalSupply(), _OWNER); @@ -30,6 +50,10 @@ contract ERC7984Mock is ERC7984, SepoliaConfig { return _mint(to, FHE.fromExternal(encryptedAmount, inputProof)); } + function $_mint(address to, uint64 amount) public returns (euint64 transferred) { + return _mint(to, FHE.asEuint64(amount)); + } + function $_transfer( address from, address to, diff --git a/contracts/mocks/token/ERC7984OmnibusMock.sol b/contracts/mocks/token/ERC7984OmnibusMock.sol new file mode 100644 index 00000000..3c2f85cb --- /dev/null +++ b/contracts/mocks/token/ERC7984OmnibusMock.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC7984Omnibus} from "../../token/ERC7984/extensions/ERC7984Omnibus.sol"; +import {ERC7984Mock, ERC7984} from "./ERC7984Mock.sol"; + +abstract contract ERC7984OmnibusMock is ERC7984Omnibus, ERC7984Mock { + function _update( + address from, + address to, + euint64 amount + ) internal virtual override(ERC7984Mock, ERC7984) returns (euint64) { + return super._update(from, to, amount); + } +} diff --git a/contracts/token/ERC7984/extensions/ERC7984Omnibus.sol b/contracts/token/ERC7984/extensions/ERC7984Omnibus.sol new file mode 100644 index 00000000..45dca07a --- /dev/null +++ b/contracts/token/ERC7984/extensions/ERC7984Omnibus.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, euint64, externalEuint64, externalEaddress, eaddress} from "@fhevm/solidity/lib/FHE.sol"; +import {ERC7984} from "../ERC7984.sol"; + +/** + * @dev Extension of {ERC7984} that emits additional events for omnibus transfers. + * These events contain encrypted addresses for the sub-account sender and recipient. + * + * NOTE: There is no onchain accounting for sub-accounts--integrators must track sub-account + * balances externally. + */ +abstract contract ERC7984Omnibus is ERC7984 { + /** + * @dev Emitted when a confidential transfer is made representing the onchain settlement of + * an omnibus transfer from `sender` to `recipient` of amount `amount`. Settlement occurs between + * `omnibusFrom` and `omnibusTo` and is represented in a matching {IERC7984-ConfidentialTransfer} event. + * + * NOTE: `omnibusFrom` and `omnibusTo` get permanent ACL allowances for `sender` and `recipient`. + */ + event OmnibusConfidentialTransfer( + address indexed omnibusFrom, + address indexed omnibusTo, + eaddress sender, + eaddress indexed recipient, + euint64 amount + ); + + /** + * @dev The caller `user` does not have access to the encrypted address `addr`. + * + * NOTE: Try using the equivalent transfer function with an input proof. + */ + error ERC7984UnauthorizedUseOfEncryptedAddress(eaddress addr, address user); + + /// @dev Wraps the {confidentialTransfer-address-externalEuint64-bytes} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferOmnibus( + address omnibusTo, + externalEaddress externalSender, + externalEaddress externalRecipient, + externalEuint64 externalAmount, + bytes calldata inputProof + ) public virtual returns (euint64) { + return + confidentialTransferFromOmnibus( + msg.sender, + omnibusTo, + externalSender, + externalRecipient, + externalAmount, + inputProof + ); + } + + /// @dev Wraps the {confidentialTransfer-address-euint64} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferOmnibus( + address omnibusTo, + eaddress sender, + eaddress recipient, + euint64 amount + ) public virtual returns (euint64) { + return confidentialTransferFromOmnibus(msg.sender, omnibusTo, sender, recipient, amount); + } + + /// @dev Wraps the {confidentialTransferFrom-address-address-externalEuint64-bytes} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferFromOmnibus( + address omnibusFrom, + address omnibusTo, + externalEaddress externalSender, + externalEaddress externalRecipient, + externalEuint64 externalAmount, + bytes calldata inputProof + ) public virtual returns (euint64) { + eaddress sender = FHE.fromExternal(externalSender, inputProof); + eaddress recipient = FHE.fromExternal(externalRecipient, inputProof); + euint64 amount = FHE.fromExternal(externalAmount, inputProof); + + return _confidentialTransferFromOmnibus(omnibusFrom, omnibusTo, sender, recipient, amount); + } + + /// @dev Wraps the {confidentialTransferFrom-address-address-euint64} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferFromOmnibus( + address omnibusFrom, + address omnibusTo, + eaddress sender, + eaddress recipient, + euint64 amount + ) public virtual returns (euint64) { + require(FHE.isAllowed(sender, msg.sender), ERC7984UnauthorizedUseOfEncryptedAddress(sender, msg.sender)); + require(FHE.isAllowed(recipient, msg.sender), ERC7984UnauthorizedUseOfEncryptedAddress(recipient, msg.sender)); + + return _confidentialTransferFromOmnibus(omnibusFrom, omnibusTo, sender, recipient, amount); + } + + /// @dev Wraps the {confidentialTransferAndCall-address-externalEuint64-bytes-bytes} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferAndCallOmnibus( + address omnibusTo, + externalEaddress externalSender, + externalEaddress externalRecipient, + externalEuint64 externalAmount, + bytes calldata inputProof, + bytes calldata data + ) public virtual returns (euint64) { + return + confidentialTransferFromAndCallOmnibus( + msg.sender, + omnibusTo, + externalSender, + externalRecipient, + externalAmount, + inputProof, + data + ); + } + + /// @dev Wraps the {confidentialTransferAndCall-address-euint64-bytes} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferAndCallOmnibus( + address omnibusTo, + eaddress sender, + eaddress recipient, + euint64 amount, + bytes calldata data + ) public virtual returns (euint64) { + return confidentialTransferFromAndCallOmnibus(msg.sender, omnibusTo, sender, recipient, amount, data); + } + + /// @dev Wraps the {confidentialTransferFromAndCall-address-address-externalEuint64-bytes-bytes} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferFromAndCallOmnibus( + address omnibusFrom, + address omnibusTo, + externalEaddress externalSender, + externalEaddress externalRecipient, + externalEuint64 externalAmount, + bytes calldata inputProof, + bytes calldata data + ) public virtual returns (euint64) { + eaddress sender = FHE.fromExternal(externalSender, inputProof); + eaddress recipient = FHE.fromExternal(externalRecipient, inputProof); + euint64 amount = FHE.fromExternal(externalAmount, inputProof); + + return _confidentialTransferFromAndCallOmnibus(omnibusFrom, omnibusTo, sender, recipient, amount, data); + } + + /// @dev Wraps the {confidentialTransferFromAndCall-address-address-euint64-bytes} function and emits the {OmnibusConfidentialTransfer} event. + function confidentialTransferFromAndCallOmnibus( + address omnibusFrom, + address omnibusTo, + eaddress sender, + eaddress recipient, + euint64 amount, + bytes calldata data + ) public virtual returns (euint64) { + require(FHE.isAllowed(sender, msg.sender), ERC7984UnauthorizedUseOfEncryptedAddress(sender, msg.sender)); + require(FHE.isAllowed(recipient, msg.sender), ERC7984UnauthorizedUseOfEncryptedAddress(recipient, msg.sender)); + + return _confidentialTransferFromAndCallOmnibus(omnibusFrom, omnibusTo, sender, recipient, amount, data); + } + + /// @dev Handles the ACL allowances, does the transfer without a callback, and emits {OmnibusConfidentialTransfer}. + function _confidentialTransferFromOmnibus( + address omnibusFrom, + address omnibusTo, + eaddress sender, + eaddress recipient, + euint64 amount + ) internal virtual returns (euint64) { + FHE.allowThis(sender); + FHE.allow(sender, omnibusFrom); + FHE.allow(sender, omnibusTo); + + FHE.allowThis(recipient); + FHE.allow(recipient, omnibusFrom); + FHE.allow(recipient, omnibusTo); + + euint64 transferred = confidentialTransferFrom(omnibusFrom, omnibusTo, amount); + emit OmnibusConfidentialTransfer(omnibusFrom, omnibusTo, sender, recipient, transferred); + return transferred; + } + + /// @dev Handles the ACL allowances, does the transfer with a callback, and emits {OmnibusConfidentialTransfer}. + function _confidentialTransferFromAndCallOmnibus( + address omnibusFrom, + address omnibusTo, + eaddress sender, + eaddress recipient, + euint64 amount, + bytes calldata data + ) internal virtual returns (euint64) { + euint64 transferred = confidentialTransferFromAndCall(omnibusFrom, omnibusTo, amount, data); + + FHE.allowThis(sender); + FHE.allow(sender, omnibusFrom); + FHE.allow(sender, omnibusTo); + + FHE.allowThis(recipient); + FHE.allow(recipient, omnibusFrom); + FHE.allow(recipient, omnibusTo); + + FHE.allowThis(transferred); + FHE.allow(transferred, omnibusFrom); + FHE.allow(transferred, omnibusTo); + + emit OmnibusConfidentialTransfer(omnibusFrom, omnibusTo, sender, recipient, transferred); + return transferred; + } +} diff --git a/contracts/token/README.adoc b/contracts/token/README.adoc index 481318ae..977e5d24 100644 --- a/contracts/token/README.adoc +++ b/contracts/token/README.adoc @@ -10,6 +10,7 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a - {ERC7984Freezable}: An extension of {ERC7984}, which allows accounts granted the "freezer" role to freeze and unfreeze tokens. - {ERC7984ObserverAccess}: An extension of {ERC7984}, which allows each account to add an observer who is given access to their transfer and balance amounts. - {ERC7984Restricted}: An extension of {ERC7984} that implements user account transfer restrictions. +- {ERC7984Omnibus}: An extension of {ERC7984} that emits additional events for omnibus transfers, which contain encrypted addresses for the sub-account sender and recipient. - {ERC7984Utils}: A library that provides the on-transfer callback check used by {ERC7984}. == Core @@ -20,6 +21,7 @@ This set of interfaces, contracts, and utilities are all related to `ERC7984`, a {{ERC7984Freezable}} {{ERC7984ObserverAccess}} {{ERC7984Restricted}} +{{ERC7984Omnibus}} == Utilities {{ERC7984Utils}} \ No newline at end of file diff --git a/test/token/ERC7984/extensions/ERC7984Omnibus.test.ts b/test/token/ERC7984/extensions/ERC7984Omnibus.test.ts new file mode 100644 index 00000000..3c805550 --- /dev/null +++ b/test/token/ERC7984/extensions/ERC7984Omnibus.test.ts @@ -0,0 +1,230 @@ +import { IACL__factory } from '../../../../types'; +import { $ERC7984OmnibusMock } from '../../../../types/contracts-exposed/mocks/token/ERC7984OmnibusMock.sol/$ERC7984OmnibusMock'; +import { ACL_ADDRESS } from '../../../helpers/accounts'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { expect } from 'chai'; +import { ethers, fhevm } from 'hardhat'; + +const name = 'OmnibusToken'; +const symbol = 'OBT'; +const uri = 'https://example.com/metadata'; + +describe('ERC7984Omnibus', function () { + beforeEach(async function () { + const [holder, recipient, operator, subaccount] = await ethers.getSigners(); + const token = (await ethers.deployContract('$ERC7984OmnibusMock', [ + name, + symbol, + uri, + ])) as any as $ERC7984OmnibusMock; + const acl = IACL__factory.connect(ACL_ADDRESS, ethers.provider); + Object.assign(this, { token, acl, holder, recipient, operator, subaccount }); + + await this.token['$_mint(address,uint64)'](this.holder.address, 1000); + }); + + for (const transferFrom of [true, false]) { + describe(`omnibus ${transferFrom ? 'transferFrom' : 'transfer'}`, function () { + for (const withCallback of [true, false]) { + describe(withCallback ? 'with callback' : 'without callback', function () { + for (const withProof of [true, false]) { + describe(withProof ? 'with transfer proof' : 'without transfer proof', function () { + beforeEach(async function () { + if (transferFrom) { + await this.token.connect(this.holder).setOperator(this.operator.address, 999999999999); + } + }); + + it('normal transfer', async function () { + const caller = transferFrom ? this.operator : this.holder; + + let encryptedInputWithProof; + let encryptedInput: { handles: any[] } = { handles: [] }; + if (withProof) { + encryptedInputWithProof = await fhevm + .createEncryptedInput(this.token.target, caller.address) + .addAddress(this.holder.address) + .addAddress(this.subaccount.address) + .add64(100) + .encrypt(); + } else { + let tx = await this.token.connect(caller).createEncryptedAddress(this.holder.address); + encryptedInput.handles.push( + (await tx.wait()).logs.filter((log: any) => log.fragment?.name === 'EncryptedAddressCreated')[0] + .args[0], + ); + + tx = await this.token.connect(caller).createEncryptedAddress(this.subaccount); + encryptedInput.handles.push( + (await tx.wait()).logs.filter((log: any) => log.fragment?.name === 'EncryptedAddressCreated')[0] + .args[0], + ); + + tx = await this.token.connect(caller).createEncryptedAmount(100); + encryptedInput.handles.push( + (await tx.wait()).logs.filter((log: any) => log.fragment?.name === 'EncryptedAmountCreated')[0] + .args[0], + ); + } + + const args = [ + this.recipient.address, + (withProof ? encryptedInputWithProof : encryptedInput)?.handles[0], + (withProof ? encryptedInputWithProof : encryptedInput)?.handles[1], + (withProof ? encryptedInputWithProof : encryptedInput)?.handles[2], + ]; + if (transferFrom) { + args.unshift(this.holder.address); + } + if (withProof) { + args.push(encryptedInputWithProof?.inputProof); + } + if (withCallback) { + args.push('0x'); + } + + const tx = await doConfidentialTransferOmnibus( + this.token.connect(caller), + transferFrom, + withCallback, + withProof, + args, + ); + + const omnibusConfidentialTransferEvent = (await tx.wait()).logs.filter( + (log: any) => log.fragment?.name === 'OmnibusConfidentialTransfer', + )[0]; + expect(omnibusConfidentialTransferEvent.args[0]).to.equal(this.holder.address); + expect(omnibusConfidentialTransferEvent.args[1]).to.equal(this.recipient.address); + + await expect( + fhevm.userDecryptEaddress(omnibusConfidentialTransferEvent.args[2], this.token.target, this.holder), + ).to.eventually.equal(this.holder.address); + await expect( + fhevm.userDecryptEaddress(omnibusConfidentialTransferEvent.args[3], this.token.target, this.holder), + ).to.eventually.equal(this.subaccount.address); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + omnibusConfidentialTransferEvent.args[4], + this.token.target, + this.holder, + ), + ).to.eventually.equal(100); + + await expect( + this.acl.isAllowed(omnibusConfidentialTransferEvent.args[2], this.holder), + ).to.eventually.be.true; + await expect( + this.acl.isAllowed(omnibusConfidentialTransferEvent.args[2], this.recipient), + ).to.eventually.be.true; + }); + + it('transfer more than balance', async function () { + const caller = transferFrom ? this.operator : this.holder; + + let encryptedInputWithProof; + let encryptedInput: { handles: any[] } = { handles: [] }; + if (withProof) { + encryptedInputWithProof = await fhevm + .createEncryptedInput(this.token.target, caller.address) + .addAddress(this.holder.address) + .addAddress(this.subaccount.address) + .add64(10000) + .encrypt(); + } else { + let tx = await this.token.connect(caller).createEncryptedAddress(this.holder.address); + encryptedInput.handles.push( + (await tx.wait()).logs.filter((log: any) => log.fragment?.name === 'EncryptedAddressCreated')[0] + .args[0], + ); + + tx = await this.token.connect(caller).createEncryptedAddress(this.subaccount); + encryptedInput.handles.push( + (await tx.wait()).logs.filter((log: any) => log.fragment?.name === 'EncryptedAddressCreated')[0] + .args[0], + ); + + tx = await this.token.connect(caller).createEncryptedAmount(10000); + encryptedInput.handles.push( + (await tx.wait()).logs.filter((log: any) => log.fragment?.name === 'EncryptedAmountCreated')[0] + .args[0], + ); + } + + const args = [ + this.recipient.address, + (withProof ? encryptedInputWithProof : encryptedInput)?.handles[0], + (withProof ? encryptedInputWithProof : encryptedInput)?.handles[1], + (withProof ? encryptedInputWithProof : encryptedInput)?.handles[2], + ]; + if (transferFrom) { + args.unshift(this.holder.address); + } + if (withProof) { + args.push(encryptedInputWithProof?.inputProof); + } + if (withCallback) { + args.push('0x'); + } + + const tx = await doConfidentialTransferOmnibus( + this.token.connect(caller), + transferFrom, + withCallback, + withProof, + args, + ); + const omnibusConfidentialTransferEvent = (await tx.wait()).logs.filter( + (log: any) => log.fragment?.name === 'OmnibusConfidentialTransfer', + )[0]; + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + omnibusConfidentialTransferEvent.args[4], + this.token.target, + this.holder, + ), + ).to.eventually.equal(0); + + await expect( + this.acl.isAllowed(omnibusConfidentialTransferEvent.args[2], this.holder), + ).to.eventually.be.true; + await expect( + this.acl.isAllowed(omnibusConfidentialTransferEvent.args[2], this.recipient), + ).to.eventually.be.true; + }); + }); + } + }); + } + }); + } +}); + +const doConfidentialTransferOmnibus = ( + token: any, + transferFrom: boolean, + withCallback: boolean, + withProof: boolean, + args: any[], +): any => { + const functionSignature = transferFrom + ? withCallback + ? withProof + ? 'confidentialTransferFromAndCallOmnibus(address,address,bytes32,bytes32,bytes32,bytes,bytes)' + : 'confidentialTransferFromAndCallOmnibus(address,address,bytes32,bytes32,bytes32,bytes)' + : withProof + ? 'confidentialTransferFromOmnibus(address,address,bytes32,bytes32,bytes32,bytes)' + : 'confidentialTransferFromOmnibus(address,address,bytes32,bytes32,bytes32)' + : withCallback + ? withProof + ? 'confidentialTransferAndCallOmnibus(address,bytes32,bytes32,bytes32,bytes,bytes)' + : 'confidentialTransferAndCallOmnibus(address,bytes32,bytes32,bytes32,bytes)' + : withProof + ? 'confidentialTransferOmnibus(address,bytes32,bytes32,bytes32,bytes)' + : 'confidentialTransferOmnibus(address,bytes32,bytes32,bytes32)'; + + return token[functionSignature](...args); +}; diff --git a/test/utils/HandleAccessManager.test.ts b/test/utils/HandleAccessManager.test.ts index 086065a3..db896f56 100644 --- a/test/utils/HandleAccessManager.test.ts +++ b/test/utils/HandleAccessManager.test.ts @@ -12,7 +12,7 @@ describe('HandleAccessManager', function () { }); it('should not be allowed to reencrypt unallowed handle', async function () { - const handle = await createHandle(this.mock, 100); + const handle = await createHandle(this.mock, 101); await expect(fhevm.userDecryptEuint(FhevmType.euint64, handle, this.mock.target, this.holder)).to.be.rejectedWith( `User ${this.holder.address} is not authorized to user decrypt handle ${handle}`,