diff --git a/.changeset/modern-snakes-return.md b/.changeset/modern-snakes-return.md new file mode 100644 index 00000000..f660fd96 --- /dev/null +++ b/.changeset/modern-snakes-return.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-confidential-contracts': minor +--- + +`BatcherConfidential`: A batching primitive that enables routing between two {ERC7984ERC20Wrapper} contracts via a non-confidential route. diff --git a/contracts/finance/BatcherConfidential.sol b/contracts/finance/BatcherConfidential.sol new file mode 100644 index 00000000..3a919109 --- /dev/null +++ b/contracts/finance/BatcherConfidential.sol @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {FHE, externalEuint64, euint64, ebool, euint128} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {IERC7984Receiver} from "./../interfaces/IERC7984Receiver.sol"; +import {ERC7984ERC20Wrapper} from "./../token/ERC7984/extensions/ERC7984ERC20Wrapper.sol"; +import {FHESafeMath} from "./../utils/FHESafeMath.sol"; + +/** + * @dev `BatcherConfidential` is a batching primitive that enables routing between two {ERC7984ERC20Wrapper} contracts + * via a non-confidential route. Users deposit {fromToken} into the batcher and receive {toToken} in exchange. Deposits are + * made by using `ERC7984` transfer and call functions such as {ERC7984-confidentialTransferAndCall}. + * + * Developers must implement the virtual function {_executeRoute} to perform the batch's route. This function is called + * once the batch deposits are unwrapped into the underlying tokens. The function should swap the underlying {fromToken} for + * underlying {toToken}. If an issue is encountered, the function should return {ExecuteOutcome.Cancel} to cancel the batch. + * + * Developers must also implement the virtual function {routeDescription} to provide a human readable description of the batch's route. + * + * NOTE: The batcher could be used to maintain confidentiality of deposits--by default there are no confidentiality guarantees. + * If desired, developers should consider restricting certain functions to increase confidentiality. + * + * WARNING: The {toToken} and {fromToken} must be carefully inspected to ensure proper capacity is maintained. If {toToken} or + * {fromToken} are filled--resulting in denial of service--batches could get bricked. The batcher would be unable to wrap + * underlying tokens into {toToken}. Further, if {fromToken} is also filled, cancellation would also fail on rewrap. + */ +abstract contract BatcherConfidential is ReentrancyGuardTransient, IERC7984Receiver { + /// @dev Enum representing the lifecycle state of a batch. + enum BatchState { + Pending, // Batch is active and accepting deposits (batchId == currentBatchId) + Dispatched, // Batch has been dispatched but not yet finalized + Finalized, // Batch is complete, users can claim their tokens + Canceled // Batch is canceled, users can claim their refund + } + + /// @dev Enum representing the outcome of a route execution in {_executeRoute}. + enum ExecuteOutcome { + Complete, // Route execution is complete. Full balance of underlying {toToken} is assigned to the batch. + Partial, // Route execution is incomplete and will be called again. Intermediate steps *must* not result in underlying {toToken} being transferred into the batcher. + Cancel // Route execution failed. Batch is canceled. Underlying {fromToken} is rewrapped. + } + + struct Batch { + euint64 totalDeposits; + euint64 unwrapAmount; + uint64 exchangeRate; + bool canceled; + mapping(address => euint64) deposits; + } + + ERC7984ERC20Wrapper private immutable _fromToken; + ERC7984ERC20Wrapper private immutable _toToken; + mapping(uint256 => Batch) private _batches; + uint256 private _currentBatchId; + + /// @dev Emitted when a batch with id `batchId` is dispatched via {dispatchBatch}. + event BatchDispatched(uint256 indexed batchId); + + /// @dev Emitted when a batch with id `batchId` is canceled. + event BatchCanceled(uint256 indexed batchId); + + /// @dev Emitted when a batch with id `batchId` is finalized with an exchange rate of `exchangeRate`. + event BatchFinalized(uint256 indexed batchId, uint64 exchangeRate); + + /// @dev Emitted when an `account` joins a batch with id `batchId` with a deposit of `amount`. + event Joined(uint256 indexed batchId, address indexed account, euint64 amount); + + /// @dev Emitted when an `account` claims their `amount` from batch with id `batchId`. + event Claimed(uint256 indexed batchId, address indexed account, euint64 amount); + + /// @dev Emitted when an `account` quits a batch with id `batchId`. + event Quit(uint256 indexed batchId, address indexed account, euint64 amount); + + /// @dev The `batchId` does not exist. Batch IDs start at 1 and must be less than or equal to {currentBatchId}. + error BatchNonexistent(uint256 batchId); + + /** + * @dev The batch `batchId` is in the state `current`, which is invalid for the operation. + * The `expectedStates` is a bitmap encoding the expected/allowed states for the operation. + * + * See {_encodeStateBitmap}. + */ + error BatchUnexpectedState(uint256 batchId, BatchState current, bytes32 expectedStates); + + /** + * @dev Thrown when the given exchange rate is invalid. The exchange rate must be non-zero and the wrapped + * amount of {toToken} must be less than or equal to `type(uint64).max`. + */ + error InvalidExchangeRate(uint256 batchId, uint256 totalDeposits, uint64 exchangeRate); + + /// @dev The caller is not authorized to call this function. + error Unauthorized(); + + constructor(ERC7984ERC20Wrapper fromToken_, ERC7984ERC20Wrapper toToken_) { + _fromToken = fromToken_; + _toToken = toToken_; + _currentBatchId = 1; + + SafeERC20.forceApprove(IERC20(fromToken().underlying()), address(fromToken()), type(uint256).max); + SafeERC20.forceApprove(IERC20(toToken().underlying()), address(toToken()), type(uint256).max); + } + + /// @dev Claim the `toToken` corresponding to deposit in batch with id `batchId`. + function claim(uint256 batchId) public virtual nonReentrant returns (euint64) { + _validateStateBitmap(batchId, _encodeStateBitmap(BatchState.Finalized)); + + euint64 deposit = deposits(batchId, msg.sender); + + // Overflow is not possible on mul since `type(uint64).max ** 2 < type(uint128).max`. + // Given that the output of the entire batch must fit in uint64, individual user outputs must also fit. + euint64 amountToSend = FHE.asEuint64( + FHE.div(FHE.mul(FHE.asEuint128(deposit), exchangeRate(batchId)), uint128(10) ** exchangeRateDecimals()) + ); + FHE.allowTransient(amountToSend, address(toToken())); + + euint64 amountTransferred = toToken().confidentialTransfer(msg.sender, amountToSend); + + ebool transferSuccess = FHE.ne(amountTransferred, FHE.asEuint64(0)); + euint64 newDeposit = FHE.select(transferSuccess, FHE.asEuint64(0), deposit); + + FHE.allowThis(newDeposit); + FHE.allow(newDeposit, msg.sender); + _batches[batchId].deposits[msg.sender] = newDeposit; + + emit Claimed(batchId, msg.sender, amountTransferred); + + return amountTransferred; + } + + /** + * @dev Quit the batch with id `batchId`. Entire deposit is returned to the user. + * This can only be called if the batch has not yet been dispatched or if the batch was canceled. + * + * NOTE: Developers should consider adding additional restrictions to this function + * if maintaining confidentiality of deposits is critical to the application. + */ + function quit(uint256 batchId) public virtual nonReentrant returns (euint64) { + _validateStateBitmap(batchId, _encodeStateBitmap(BatchState.Pending) | _encodeStateBitmap(BatchState.Canceled)); + + euint64 deposit = deposits(batchId, msg.sender); + euint64 totalDeposits_ = totalDeposits(batchId); + + FHE.allowTransient(deposit, address(fromToken())); + euint64 sent = fromToken().confidentialTransfer(msg.sender, deposit); + euint64 newTotalDeposits = FHE.sub(totalDeposits_, sent); + euint64 newDeposit = FHE.sub(deposit, sent); + + FHE.allowThis(newTotalDeposits); + FHE.allowThis(newDeposit); + FHE.allow(newDeposit, msg.sender); + + _batches[batchId].totalDeposits = newTotalDeposits; + _batches[batchId].deposits[msg.sender] = newDeposit; + + emit Quit(batchId, msg.sender, sent); + + return sent; + } + + /** + * @dev Permissionless function to dispatch the current batch. Increments the {currentBatchId}. + * + * NOTE: Developers should consider adding additional restrictions to this function + * if maintaining confidentiality of deposits is critical to the application. + */ + function dispatchBatch() public virtual { + uint256 batchId = _getAndIncreaseBatchId(); + + euint64 amountToUnwrap = totalDeposits(batchId); + FHE.allowTransient(amountToUnwrap, address(fromToken())); + _batches[batchId].unwrapAmount = _calculateUnwrapAmount(amountToUnwrap); + + fromToken().unwrap(address(this), address(this), amountToUnwrap); + + emit BatchDispatched(batchId); + } + + /** + * @dev Dispatch batch callback callable by anyone. This function finalizes the unwrap of {fromToken} + * and calls {_executeRoute} to perform the batch's route. If `_executeRoute` returns `ExecuteOutcome.Partial`, + * this function should be called again with the same `batchId`, `unwrapAmountCleartext`, and `decryptionProof`. + */ + function dispatchBatchCallback( + uint256 batchId, + uint64 unwrapAmountCleartext, + bytes calldata decryptionProof + ) public virtual nonReentrant { + _validateStateBitmap(batchId, _encodeStateBitmap(BatchState.Dispatched)); + + euint64 unwrapAmount_ = unwrapAmount(batchId); + // finalize unwrap call will fail if already called by this contract or by anyone else + try ERC7984ERC20Wrapper(fromToken()).finalizeUnwrap(unwrapAmount_, unwrapAmountCleartext, decryptionProof) { + // No need to validate input since `finalizeUnwrap` request succeeded + } catch { + // Must validate input since `finalizeUnwrap` request failed + bytes32[] memory handles = new bytes32[](1); + handles[0] = euint64.unwrap(unwrapAmount_); + FHE.checkSignatures(handles, abi.encode(unwrapAmountCleartext), decryptionProof); + } + + ExecuteOutcome outcome; + if (unwrapAmountCleartext == 0) { + outcome = ExecuteOutcome.Cancel; + } else { + outcome = _executeRoute(batchId, unwrapAmountCleartext); + } + + if (outcome == ExecuteOutcome.Complete) { + uint256 swappedAmount = IERC20(toToken().underlying()).balanceOf(address(this)); + + // If wrapper is full, this reverts. Will brick batcher. + // If output is less than toToken().rate() batch can never be finalized. + // Any dust left after (amount % toToken().rate()) goes to the next batch. + toToken().wrap(address(this), swappedAmount); + + uint256 wrappedAmount = swappedAmount / toToken().rate(); + uint64 exchangeRate_ = SafeCast.toUint64( + Math.mulDiv(wrappedAmount, uint256(10) ** exchangeRateDecimals(), unwrapAmountCleartext) + ); + + // Ensure valid exchange rate: not 0 and will not overflow when calculating user outputs + require( + exchangeRate_ != 0 && wrappedAmount <= type(uint64).max, + InvalidExchangeRate(batchId, unwrapAmountCleartext, exchangeRate_) + ); + _batches[batchId].exchangeRate = exchangeRate_; + + emit BatchFinalized(batchId, exchangeRate_); + } else if (outcome == ExecuteOutcome.Cancel) { + // rewrap tokens so that users can quit and receive their original deposit back. + // This assumes that the unwrap was successful and that the batch has not executed any route logic. + fromToken().wrap(address(this), unwrapAmountCleartext * fromToken().rate()); + _batches[batchId].canceled = true; + + emit BatchCanceled(batchId); + } + } + + /// @inheritdoc IERC7984Receiver + function onConfidentialTransferReceived( + address, + address from, + euint64 amount, + bytes calldata + ) external returns (ebool) { + require(msg.sender == address(fromToken()), Unauthorized()); + ebool success = FHE.gt(_join(from, amount), FHE.asEuint64(0)); + FHE.allowTransient(success, msg.sender); + return success; + } + + /// @dev Batcher from token. Users deposit this token in exchange for {toToken}. + function fromToken() public view virtual returns (ERC7984ERC20Wrapper) { + return _fromToken; + } + + /// @dev Batcher to token. Users receive this token in exchange for their {fromToken} deposits. + function toToken() public view virtual returns (ERC7984ERC20Wrapper) { + return _toToken; + } + + /// @dev The ongoing batch id. New deposits join this batch. + function currentBatchId() public view virtual returns (uint256) { + return _currentBatchId; + } + + /// @dev The amount of {fromToken} unwrapped during {dispatchBatch} for batch with id `batchId`. + function unwrapAmount(uint256 batchId) public view virtual returns (euint64) { + return _batches[batchId].unwrapAmount; + } + + /// @dev The total deposits made in batch with id `batchId`. + function totalDeposits(uint256 batchId) public view virtual returns (euint64) { + return _batches[batchId].totalDeposits; + } + + /// @dev The deposits made by `account` in batch with id `batchId`. + function deposits(uint256 batchId, address account) public view virtual returns (euint64) { + return _batches[batchId].deposits[account]; + } + + /// @dev The exchange rate set for batch with id `batchId`. + function exchangeRate(uint256 batchId) public view virtual returns (uint64) { + return _batches[batchId].exchangeRate; + } + + /// @dev The number of decimals of precision for the exchange rate. + function exchangeRateDecimals() public pure virtual returns (uint8) { + return 6; + } + + /// @dev Human readable description of what the batcher does. + function routeDescription() public pure virtual returns (string memory); + + /// @dev Returns the current state of a batch. Reverts if the batch does not exist. + function batchState(uint256 batchId) public view virtual returns (BatchState) { + if (_batches[batchId].canceled) { + return BatchState.Canceled; + } + if (exchangeRate(batchId) != 0) { + return BatchState.Finalized; + } + if (euint64.unwrap(unwrapAmount(batchId)) != 0) { + return BatchState.Dispatched; + } + if (batchId == currentBatchId()) { + return BatchState.Pending; + } + + revert BatchNonexistent(batchId); + } + + /** + * @dev Joins a batch with amount `amount` on behalf of `to`. Does not do any transfers in. + * Returns the amount joined with. + */ + function _join(address to, euint64 amount) internal virtual returns (euint64) { + uint256 batchId = currentBatchId(); + + (ebool success, euint64 newTotalDeposits) = FHESafeMath.tryIncrease(totalDeposits(batchId), amount); + euint64 joinedAmount = FHE.select(success, amount, FHE.asEuint64(0)); + euint64 newDeposits = FHE.add(deposits(batchId, to), joinedAmount); + + FHE.allowThis(newTotalDeposits); + FHE.allowThis(newDeposits); + FHE.allow(newDeposits, to); + FHE.allow(joinedAmount, to); + + _batches[batchId].totalDeposits = newTotalDeposits; + _batches[batchId].deposits[to] = newDeposits; + + emit Joined(batchId, to, joinedAmount); + + return joinedAmount; + } + + /** + * @dev Function which is executed by {dispatchBatchCallback} after validation and unwrap finalization. The parameter + * `amount` is the plaintext amount of the `fromToken` which were unwrapped--to attain the underlying tokens received, + * evaluate `amount * fromToken().rate()`. This function should swap the underlying {fromToken} for underlying {toToken}. + * + * This function returns an {ExecuteOutcome} enum indicating the new state of the batch. If the route execution is complete, + * the balance of the underlying {toToken} is wrapped and the exchange rate is set. + * + * NOTE: {dispatchBatchCallback} (and in turn {_executeRoute}) can be repeatedly called until the route execution is complete. + * If a multi-step route is necessary, intermediate steps should return `ExecuteOutcome.Partial`. Intermediate steps *must* not + * result in underlying {toToken} being transferred into the batcher. + * + * WARNING: This function must eventually return `ExecuteOutcome.Complete` or `ExecuteOutcome.Cancel`. Failure to do so results + * in user deposits being locked indefinitely. + */ + function _executeRoute(uint256 batchId, uint256 amount) internal virtual returns (ExecuteOutcome); + + /** + * @dev Check that the current state of a batch matches the requirements described by the `allowedStates` bitmap. + * This bitmap should be built using `_encodeStateBitmap`. + * + * If requirements are not met, reverts with a {BatchUnexpectedState} error. + */ + function _validateStateBitmap(uint256 batchId, bytes32 allowedStates) internal view returns (BatchState) { + BatchState currentState = batchState(batchId); + if (_encodeStateBitmap(currentState) & allowedStates == bytes32(0)) { + revert BatchUnexpectedState(batchId, currentState, allowedStates); + } + return currentState; + } + + /// @dev Mirror calculations done on the token to attain the same cipher-text for unwrap tracking. + function _calculateUnwrapAmount(euint64 requestedUnwrapAmount) internal virtual returns (euint64) { + euint64 balance = fromToken().confidentialBalanceOf(address(this)); + + (ebool success, ) = FHESafeMath.tryDecrease(balance, requestedUnwrapAmount); + + return FHE.select(success, requestedUnwrapAmount, FHE.asEuint64(0)); + } + + /// @dev Gets the current batch id and increments it. + function _getAndIncreaseBatchId() internal virtual returns (uint256) { + return _currentBatchId++; + } + + /** + * @dev Encodes a `BatchState` into a `bytes32` representation where each bit enabled corresponds to + * the underlying position in the `BatchState` enum. For example: + * + * 0x000...1000 + * ^--- Canceled + * ^-- Finalized + * ^- Dispatched + * ^ Pending + */ + function _encodeStateBitmap(BatchState batchState_) internal pure returns (bytes32) { + return bytes32(1 << uint8(batchState_)); + } +} diff --git a/contracts/finance/README.adoc b/contracts/finance/README.adoc index 2705a04f..5e5469f8 100644 --- a/contracts/finance/README.adoc +++ b/contracts/finance/README.adoc @@ -8,6 +8,7 @@ This directory includes primitives for on-chain confidential financial systems: - {VestingWalletConfidential}: Handles the vesting of confidential tokens for a given beneficiary. Custody of multiple tokens can be given to this contract, which will release the token to the beneficiary following a given, customizable, vesting schedule. - {VestingWalletCliffConfidential}: Variant of {VestingWalletConfidential} which adds a cliff period to the vesting schedule. +- {BatcherConfidential}: A batching primitive that enables routing between two {ERC7984ERC20Wrapper} contracts via a non-confidential route. For convenience, this directory also includes: @@ -18,3 +19,4 @@ For convenience, this directory also includes: {{VestingWalletConfidential}} {{VestingWalletCliffConfidential}} {{VestingWalletConfidentialFactory}} +{{BatcherConfidential}} diff --git a/contracts/mocks/finance/BatcherConfidentialSwapMock.sol b/contracts/mocks/finance/BatcherConfidentialSwapMock.sol new file mode 100644 index 00000000..c09c2652 --- /dev/null +++ b/contracts/mocks/finance/BatcherConfidentialSwapMock.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {FHE, externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {BatcherConfidential} from "./../../finance/BatcherConfidential.sol"; +import {ExchangeMock} from "./../finance/ExchangeMock.sol"; + +abstract contract BatcherConfidentialSwapMock is ZamaEthereumConfig, BatcherConfidential { + ExchangeMock public exchange; + address public admin; + ExecuteOutcome public outcome = ExecuteOutcome.Complete; + + constructor(ExchangeMock exchange_, address admin_) { + exchange = exchange_; + admin = admin_; + } + + function routeDescription() public pure override returns (string memory) { + return "Exchange fromToken for toToken by swapping through the mock exchange."; + } + + function setExecutionOutcome(ExecuteOutcome outcome_) public { + outcome = outcome_; + } + + /// @dev Join the current batch with `externalAmount` and `inputProof`. + function join(externalEuint64 externalAmount, bytes calldata inputProof) public virtual returns (euint64) { + euint64 amount = FHE.fromExternal(externalAmount, inputProof); + FHE.allowTransient(amount, address(fromToken())); + euint64 transferred = fromToken().confidentialTransferFrom(msg.sender, address(this), amount); + + euint64 joinedAmount = _join(msg.sender, transferred); + euint64 refundAmount = FHE.sub(transferred, joinedAmount); + + FHE.allowTransient(refundAmount, address(fromToken())); + + fromToken().confidentialTransfer(msg.sender, refundAmount); + + return joinedAmount; + } + + function join(uint64 amount) public { + euint64 ciphertext = FHE.asEuint64(amount); + FHE.allowTransient(ciphertext, msg.sender); + + bytes memory callData = abi.encodeWithSignature( + "join(bytes32,bytes)", + externalEuint64.wrap(euint64.unwrap(ciphertext)), + hex"" + ); + + Address.functionDelegateCall(address(this), callData); + } + + function quit(uint256 batchId) public virtual override returns (euint64) { + euint64 amount = super.quit(batchId); + FHE.allow(totalDeposits(batchId), admin); + return amount; + } + + function _join(address to, euint64 amount) internal virtual override returns (euint64) { + euint64 joinedAmount = super._join(to, amount); + FHE.allow(totalDeposits(currentBatchId()), admin); + return joinedAmount; + } + + function _executeRoute(uint256, uint256 unwrapAmount) internal override returns (ExecuteOutcome) { + if (outcome == ExecuteOutcome.Complete) { + // Approve exchange to spend unwrapped tokens + uint256 rawAmount = unwrapAmount * fromToken().rate(); + IERC20(fromToken().underlying()).approve(address(exchange), rawAmount); + + // Swap unwrapped tokens via exchange + exchange.swapAToB(rawAmount); + } + return outcome; + } +} diff --git a/contracts/mocks/finance/ExchangeMock.sol b/contracts/mocks/finance/ExchangeMock.sol new file mode 100644 index 00000000..3e64bdd7 --- /dev/null +++ b/contracts/mocks/finance/ExchangeMock.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; + +contract ExchangeMock { + IERC20 public tokenA; + IERC20 public tokenB; + uint256 public exchangeRate; + + event ExchangeRateSet(uint256 oldExchangeRate, uint256 newExchangeRate); + + constructor(IERC20 tokenA_, IERC20 tokenB_, uint256 initialExchangeRate) { + tokenA = tokenA_; + tokenB = tokenB_; + exchangeRate = initialExchangeRate; + } + + function swapAToB(uint256 amount) public returns (uint256) { + uint256 amountOut = (amount * exchangeRate) / 1e18; + require(tokenA.transferFrom(msg.sender, address(this), amount), "Transfer of token A failed"); + require(tokenB.transfer(msg.sender, amountOut), "Transfer of token B failed"); + return amountOut; + } + + function swapBToA(uint256 amount) public returns (uint256) { + uint256 amountOut = (amount * 1e18) / exchangeRate; + require(tokenB.transferFrom(msg.sender, address(this), amount), "Transfer of token B failed"); + require(tokenA.transfer(msg.sender, amountOut), "Transfer of token A failed"); + return amountOut; + } + + function setExchangeRate(uint256 newExchangeRate) public { + emit ExchangeRateSet(exchangeRate, newExchangeRate); + + exchangeRate = newExchangeRate; + } +} diff --git a/contracts/mocks/token/ERC7984ERC20WrapperMock.sol b/contracts/mocks/token/ERC7984ERC20WrapperMock.sol index 12c34591..56603dab 100644 --- a/contracts/mocks/token/ERC7984ERC20WrapperMock.sol +++ b/contracts/mocks/token/ERC7984ERC20WrapperMock.sol @@ -2,14 +2,32 @@ pragma solidity ^0.8.27; import {ZamaEthereumConfig} from "@fhevm/solidity/config/ZamaConfig.sol"; +import {euint64} from "@fhevm/solidity/lib/FHE.sol"; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; -import {ERC7984ERC20Wrapper, ERC7984} from "../../token/ERC7984/extensions/ERC7984ERC20Wrapper.sol"; +import {ERC7984ERC20Wrapper, ERC7984} from "./../../token/ERC7984/extensions/ERC7984ERC20Wrapper.sol"; +import {ERC7984Mock} from "./ERC7984Mock.sol"; -contract ERC7984ERC20WrapperMock is ERC7984ERC20Wrapper, ZamaEthereumConfig { +contract ERC7984ERC20WrapperMock is ERC7984ERC20Wrapper, ZamaEthereumConfig, ERC7984Mock { constructor( IERC20 token, string memory name, string memory symbol, string memory uri - ) ERC7984ERC20Wrapper(token) ERC7984(name, symbol, uri) {} + ) ERC7984ERC20Wrapper(token) ERC7984Mock(name, symbol, uri) {} + + function supportsInterface(bytes4 interfaceId) public view override(ERC7984ERC20Wrapper, ERC7984) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function decimals() public view override(ERC7984ERC20Wrapper, ERC7984) returns (uint8) { + return super.decimals(); + } + + function _update( + address from, + address to, + euint64 amount + ) internal virtual override(ERC7984ERC20Wrapper, ERC7984Mock) returns (euint64) { + return super._update(from, to, amount); + } } diff --git a/contracts/mocks/token/ERC7984Mock.sol b/contracts/mocks/token/ERC7984Mock.sol index 63f75192..792d8625 100644 --- a/contracts/mocks/token/ERC7984Mock.sol +++ b/contracts/mocks/token/ERC7984Mock.sol @@ -73,6 +73,10 @@ contract ERC7984Mock is ERC7984, ZamaEthereumConfig { return _transferAndCall(from, to, FHE.fromExternal(encryptedAmount, inputProof), data); } + function $_burn(address from, uint64 amount) public returns (euint64 transferred) { + return _burn(from, FHE.asEuint64(amount)); + } + function $_burn( address from, externalEuint64 encryptedAmount, diff --git a/test/finance/BatcherConfidential.test.ts b/test/finance/BatcherConfidential.test.ts new file mode 100644 index 00000000..6dceaf22 --- /dev/null +++ b/test/finance/BatcherConfidential.test.ts @@ -0,0 +1,629 @@ +import { BatcherConfidentialSwapMock } from '../../types'; +import { $ERC20Mock } from '../../types/contracts-exposed/mocks/token/ERC20Mock.sol/$ERC20Mock'; +import { $ERC7984ERC20Wrapper } from '../../types/contracts-exposed/token/ERC7984/extensions/ERC7984ERC20Wrapper.sol/$ERC7984ERC20Wrapper'; +import { FhevmType } from '@fhevm/hardhat-plugin'; +import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs'; +import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; +import { expect } from 'chai'; +import { ethers, fhevm } from 'hardhat'; + +const name = 'ConfidentialFungibleToken'; +const symbol = 'CFT'; +const uri = 'https://example.com/metadata'; +const wrapAmount = BigInt(ethers.parseEther('10')); +const exchangeRateDecimals = 6n; +const exchangeRateMantissa = 10n ** exchangeRateDecimals; + +enum BatchState { + Pending, + Dispatched, + Finalized, + Canceled, +} + +enum ExecuteOutcome { + Complete, + Partial, + Cancel, +} + +// Helper to encode batch state as bitmap (mirrors _encodeStateBitmap in contract) +function encodeStateBitmap(...states: BatchState[]): bigint { + return states.reduce((acc, state) => acc | (1n << BigInt(state)), 0n); +} + +describe('BatcherConfidential', function () { + beforeEach(async function () { + const accounts = await ethers.getSigners(); + const [holder, recipient, operator] = accounts; + + const fromTokenUnderlying = (await ethers.deployContract('$ERC20Mock', [name, symbol, 18])) as any as $ERC20Mock; + const toTokenUnderlying = (await ethers.deployContract('$ERC20Mock', [name, symbol, 18])) as any as $ERC20Mock; + + const fromToken = (await ethers.deployContract('$ERC7984ERC20WrapperMock', [ + fromTokenUnderlying, + name, + symbol, + uri, + ])) as any as $ERC7984ERC20Wrapper; + const toToken = (await ethers.deployContract('$ERC7984ERC20WrapperMock', [ + toTokenUnderlying, + name, + symbol, + uri, + ])) as any as $ERC7984ERC20Wrapper; + + for (const { to, tokens } of [holder, recipient].flatMap(x => + [ + { underlying: fromTokenUnderlying, wrapper: fromToken }, + { underlying: toTokenUnderlying, wrapper: toToken }, + ].map(y => { + return { to: x, tokens: y }; + }), + )) { + await tokens.underlying.$_mint(to, wrapAmount); + await tokens.underlying.connect(to).approve(tokens.wrapper, wrapAmount); + await tokens.wrapper.connect(to).wrap(to, wrapAmount); + } + + const exchange = await ethers.deployContract('$ExchangeMock', [ + fromTokenUnderlying, + toTokenUnderlying, + ethers.parseEther('1'), + ]); + + await Promise.all( + [fromTokenUnderlying, toTokenUnderlying].map(async token => { + await token.$_mint(exchange, ethers.parseEther('1000')); + }), + ); + + const batcher = await ethers.deployContract('$BatcherConfidentialSwapMock', [ + fromToken, + toToken, + exchange, + operator, + ]); + + for (const approver of [holder, recipient]) { + await fromToken.connect(approver).setOperator(batcher, 2n ** 48n - 1n); + } + + Object.assign(this, { + exchange, + batcher, + fromTokenUnderlying, + toTokenUnderlying, + fromToken, + toToken, + accounts: accounts.slice(3), + holder, + recipient, + operator, + fromTokenRate: BigInt(await fromToken.rate()), + toTokenRate: BigInt(await toToken.rate()), + }); + }); + + for (const viaCallback of [true, false]) { + describe(`join ${viaCallback ? 'via callback' : 'directly'}`, async function () { + const join = async function ( + token: $ERC7984ERC20Wrapper, + sender: HardhatEthersSigner, + batcher: BatcherConfidentialSwapMock, + amount: bigint, + ) { + if (viaCallback) { + const encryptedInput = await fhevm + .createEncryptedInput(token.target.toString(), sender.address) + .add64(amount) + .encrypt(); + + return token + .connect(sender) + ['confidentialTransferAndCall(address,bytes32,bytes,bytes)']( + batcher, + encryptedInput.handles[0], + encryptedInput.inputProof, + ethers.ZeroHash, + ); + } else { + return batcher.connect(sender)['join(uint64)'](amount); + } + }; + + it('should increase individual deposits', async function () { + const batchId = await this.batcher.currentBatchId(); + + await expect(this.batcher.deposits(batchId, this.holder)).to.eventually.eq(ethers.ZeroHash); + + await join(this.fromToken, this.holder, this.batcher, 1000n); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq('1000'); + + await join(this.fromToken, this.holder, this.batcher, 2000n); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq('3000'); + }); + + it('should increase total deposits', async function () { + const batchId = await this.batcher.currentBatchId(); + await join(this.fromToken, this.holder, this.batcher, 1000n); + await join(this.fromToken, this.recipient, this.batcher, 2000n); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.totalDeposits(batchId), + this.batcher, + this.operator, + ), + ).to.eventually.eq('3000'); + }); + + it('should emit event', async function () { + const batchId = await this.batcher.currentBatchId(); + + await expect(join(this.fromToken, this.holder, this.batcher, 1000n)) + .to.emit(this.batcher, 'Joined') + .withArgs(batchId, this.holder.address, anyValue); + }); + + it('should not credit failed transaction', async function () { + const batchId = await this.batcher.currentBatchId(); + + await this.batcher.join(wrapAmount / this.fromTokenRate + 1n); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq(0); + }); + + if (viaCallback) { + it('must come from the token', async function () { + await expect( + this.batcher.onConfidentialTransferReceived(ethers.ZeroAddress, this.holder, ethers.ZeroHash, '0x'), + ).to.be.revertedWithCustomError(this.batcher, 'Unauthorized'); + }); + } + }); + } + + describe('claim', function () { + beforeEach(async function () { + this.batchId = await this.batcher.currentBatchId(); + + await this.batcher.join(1000); + await this.batcher.connect(this.holder).dispatchBatch(); + + const [, amount] = (await this.fromToken.queryFilter(this.fromToken.filters.UnwrapRequested()))[0].args; + const { abiEncodedClearValues, decryptionProof } = await fhevm.publicDecrypt([amount]); + await this.batcher.dispatchBatchCallback(this.batchId, abiEncodedClearValues, decryptionProof); + + this.exchangeRate = BigInt(await this.batcher.exchangeRate(this.batchId)); + this.deposit = 1000n; + }); + + it('should clear deposits', async function () { + await this.batcher.claim(this.batchId); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(this.batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq(0); + }); + + it('should transfer out correct amount of toToken', async function () { + const beforeBalanceToTokens = await fhevm.userDecryptEuint( + FhevmType.euint64, + await this.toToken.confidentialBalanceOf(this.holder), + this.toToken, + this.holder, + ); + + await this.batcher.claim(this.batchId); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.toToken.confidentialBalanceOf(this.holder), + this.toToken, + this.holder, + ), + ).to.eventually.eq( + BigInt(beforeBalanceToTokens) + BigInt(this.exchangeRate * this.deposit) / exchangeRateMantissa, + ); + }); + + it('should revert if not finalized', async function () { + const currentBatchId = await this.batcher.currentBatchId(); + await expect(this.batcher.claim(currentBatchId)) + .to.be.revertedWithCustomError(this.batcher, 'BatchUnexpectedState') + .withArgs(currentBatchId, BatchState.Pending, encodeStateBitmap(BatchState.Finalized)); + }); + + it('should emit event', async function () { + await expect(this.batcher.claim(this.batchId)) + .to.emit(this.batcher, 'Claimed') + .withArgs(this.batchId, this.holder.address, anyValue); + }); + + it('should allow retry claim (idempotent when fully claimed)', async function () { + // First claim should succeed and clear deposits + await this.batcher.claim(this.batchId); + + // Verify deposits are cleared + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(this.batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq(0); + + // Second claim should succeed (return 0, no-op since no deposit left) + await expect(this.batcher.claim(this.batchId)).to.emit(this.batcher, 'Claimed'); + + // Deposits should still be zero + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(this.batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq(0); + }); + + it('should track failed claims properly', async function () { + // will burn `toToken` from batcher to induce failed transfer + await this.toToken['$_burn(address,uint64)'](this.batcher, 100n); + + let claimEvent = (await (await this.batcher.claim(this.batchId)).wait()).logs.filter( + (log: any) => log.address === this.batcher.target, + )[0]; + let claimAmount = claimEvent.args[2]; + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, claimAmount, this.toToken.target, this.holder), + ).to.eventually.eq(0); + + await this.toToken['$_mint(address,uint64)'](this.batcher, 100n); + + claimEvent = (await (await this.batcher.claim(this.batchId)).wait()).logs.filter( + (log: any) => log.address === this.batcher.target, + )[0]; + claimAmount = claimEvent.args[2]; + + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, claimAmount, this.toToken.target, this.holder), + ).to.eventually.eq(1000n); + }); + }); + + describe('quit', function () { + beforeEach(async function () { + this.batchId = await this.batcher.currentBatchId(); + this.deposit = 1000n; + + await this.batcher.join(this.deposit); + }); + + it('should send back full deposit', async function () { + const beforeBalance = await fhevm.userDecryptEuint( + FhevmType.euint64, + await this.fromToken.confidentialBalanceOf(this.holder), + this.fromToken, + this.holder, + ); + + await this.batcher.quit(this.batchId); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.fromToken.confidentialBalanceOf(this.holder), + this.fromToken, + this.holder, + ), + ).to.eventually.eq(beforeBalance + this.deposit); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.deposits(this.batchId, this.holder), + this.batcher, + this.holder, + ), + ).to.eventually.eq(0); + }); + + it('should decrease total deposits', async function () { + await this.batcher.quit(this.batchId); + + await expect( + fhevm.userDecryptEuint( + FhevmType.euint64, + await this.batcher.totalDeposits(this.batchId), + this.batcher, + this.operator, + ), + ).to.eventually.eq(0); + }); + + it('should fail if batch already dispatched', async function () { + await this.batcher.connect(this.holder).dispatchBatch(); + + await expect(this.batcher.quit(this.batchId)) + .to.be.revertedWithCustomError(this.batcher, 'BatchUnexpectedState') + .withArgs(this.batchId, BatchState.Dispatched, encodeStateBitmap(BatchState.Pending, BatchState.Canceled)); + }); + + it('should emit event', async function () { + await expect(this.batcher.quit(this.batchId)) + .to.emit(this.batcher, 'Quit') + .withArgs(this.batchId, this.holder.address, anyValue); + }); + }); + + describe('dispatchBatchCallback', function () { + beforeEach(async function () { + const joinAmount = 1000n; + const batchId = await this.batcher.currentBatchId(); + + await this.batcher.connect(this.holder).join(joinAmount); + await this.batcher.connect(this.holder).dispatchBatch(); + + const [, amount] = (await this.fromToken.queryFilter(this.fromToken.filters.UnwrapRequested()))[0].args; + const { abiEncodedClearValues, decryptionProof } = await fhevm.publicDecrypt([amount]); + + await expect(this.batcher.unwrapAmount(batchId)).to.eventually.eq(amount); + + Object.assign(this, { joinAmount, batchId, unwrapAmount: amount, abiEncodedClearValues, decryptionProof }); + }); + + it('should finalize unwrap', async function () { + await expect(this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof)) + .to.emit(this.fromToken, 'UnwrapFinalized') + .withArgs(this.batcher, this.unwrapAmount, this.abiEncodedClearValues); + }); + + it('should revert if proof validation fails', async function () { + await this.fromToken.finalizeUnwrap(this.unwrapAmount, this.abiEncodedClearValues, this.decryptionProof); + await expect(this.batcher.dispatchBatchCallback(1, BigInt(this.abiEncodedClearValues) + 1n, this.decryptionProof)) + .to.be.reverted; + }); + + it('should succeed if unwrap already finalized', async function () { + await this.fromToken.finalizeUnwrap(this.unwrapAmount, this.abiEncodedClearValues, this.decryptionProof); + await this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + }); + + it('should emit event on batch finalization', async function () { + await expect(this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof)) + .to.emit(this.batcher, 'BatchFinalized') + .withArgs(this.batchId, 10n ** 6n); + }); + + it('should be able to call multiple times if `_executeRoute` returns partial', async function () { + await this.batcher.setExecutionOutcome(ExecuteOutcome.Partial); + + await this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + await this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + + await this.batcher.setExecutionOutcome(ExecuteOutcome.Complete); + + await this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + await expect( + this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof), + ).to.be.revertedWithCustomError(this.batcher, 'BatchUnexpectedState'); + }); + + it('should cancel if `_executeRoute` returns cancel', async function () { + await this.batcher.setExecutionOutcome(ExecuteOutcome.Cancel); + const tx = this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + await expect(tx).to.emit(this.batcher, 'BatchCanceled').withArgs(this.batchId); + + await expect(tx) + .to.emit(this.fromTokenUnderlying, 'Transfer') + .withArgs(this.fromToken, this.batcher, this.joinAmount * this.fromTokenRate) // unwrap + .to.emit(this.fromTokenUnderlying, 'Transfer') + .withArgs(this.batcher, this.fromToken, this.joinAmount * this.fromTokenRate); // rewrap + }); + + it("should revert if `_executeRoute` doesn't receive any to token underlying", async function () { + await this.exchange.setExchangeRate(0); + + await expect(this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof)) + .to.be.revertedWithCustomError(this.batcher, 'InvalidExchangeRate') + .withArgs(this.batchId, this.joinAmount, 0); + }); + + it('should cancel if unwrap amount is 0', async function () { + await this.batcher.connect(this.holder).join(0n); + + await this.batcher.connect(this.holder).dispatchBatch(); + + const [, amount] = (await this.fromToken.queryFilter(this.fromToken.filters.UnwrapRequested()))[1].args; + const { abiEncodedClearValues, decryptionProof } = await fhevm.publicDecrypt([amount]); + + await expect(this.batcher.dispatchBatchCallback(this.batchId + 1n, abiEncodedClearValues, decryptionProof)) + .to.emit(this.batcher, 'BatchCanceled') + .withArgs(this.batchId + 1n); + }); + }); + + describe('dispatchBatch', function () { + beforeEach(async function () { + this.batchId = await this.batcher.currentBatchId(); + + await this.batcher.join(1000); + }); + + it('should emit event', async function () { + await expect(this.batcher.dispatchBatch()).to.emit(this.batcher, 'BatchDispatched').withArgs(this.batchId); + }); + }); + + describe('batch state', async function () { + beforeEach(async function () { + const joinAmount = 1000n; + const batchId = await this.batcher.currentBatchId(); + + await this.batcher.connect(this.holder).join(joinAmount); + await this.batcher.connect(this.holder).dispatchBatch(); + + const [, amount] = (await this.fromToken.queryFilter(this.fromToken.filters.UnwrapRequested()))[0].args; + const { abiEncodedClearValues, decryptionProof } = await fhevm.publicDecrypt([amount]); + + Object.assign(this, { joinAmount, batchId, unwrapAmount: amount, abiEncodedClearValues, decryptionProof }); + }); + + it('should revert if batch does not exist', async function () { + const nonExistentBatchId = this.batchId + 2n; + await expect(this.batcher.batchState(nonExistentBatchId)) + .to.be.revertedWithCustomError(this.batcher, 'BatchNonexistent') + .withArgs(nonExistentBatchId); + }); + + it('should return canceled if canceled', async function () { + await this.batcher.setExecutionOutcome(ExecuteOutcome.Cancel); + await this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + + await expect(this.batcher.batchState(this.batchId)).to.eventually.eq(BatchState.Canceled); + }); + + it('should return finalized if finalized', async function () { + await this.batcher.dispatchBatchCallback(this.batchId, this.abiEncodedClearValues, this.decryptionProof); + + await expect(this.batcher.batchState(this.batchId)).to.eventually.eq(BatchState.Finalized); + }); + + it('should return dispatched if dispatched', async function () { + await expect(this.batcher.batchState(this.batchId)).to.eventually.eq(BatchState.Dispatched); + }); + + it('should return pending if pending', async function () { + await expect(this.batcher.batchState(this.batchId + 1n)).to.eventually.eq(BatchState.Pending); + }); + }); + + it('cancel and quit takes tokens from the next batch', async function () { + const amount1 = 1337n; + const amount2 = 4337n; + + const batcher = await ethers.deployContract('$BatcherConfidentialSwapMock', [ + this.fromToken, + this.toToken, + ethers.ZeroAddress, // no need for an exchange in this test + this.operator, + ]); + await this.fromToken.connect(this.holder).setOperator(batcher, 2n ** 48n - 1n); + + // ========================== First batch ========================== + const batchId1 = await batcher.currentBatchId(); + + // batch is empty + await expect(batcher.totalDeposits(batchId1)).to.eventually.eq(0n); + + // join + await batcher.connect(this.holder).join(amount1); + + // batch has deposit + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, await batcher.totalDeposits(batchId1), batcher, this.operator), + ).to.eventually.eq(amount1); + + // dispatch + await batcher.dispatchBatch(); + + // dispatch amount is publicly decryptable + const { abiEncodedClearValues, decryptionProof } = await batcher + .unwrapAmount(batchId1) + .then(amount => fhevm.publicDecrypt([amount])); + + expect(abiEncodedClearValues).to.eq(amount1); + + // cancel the batch + const rate = await this.fromToken.rate(); + await batcher.setExecutionOutcome(ExecuteOutcome.Cancel); + await expect(batcher.dispatchBatchCallback(batchId1, abiEncodedClearValues, decryptionProof)) + .to.emit(this.fromTokenUnderlying, 'Transfer') + .withArgs(this.fromToken, batcher, amount1 * rate) // unwrap + .to.emit(this.fromTokenUnderlying, 'Transfer') + .withArgs(batcher, this.fromToken, amount1 * rate); // unwrap + + // quit + const balanceBefore = await fhevm.userDecryptEuint( + FhevmType.euint64, + await this.fromToken.confidentialBalanceOf(this.holder), + this.fromToken, + this.holder, + ); + + await batcher.connect(this.holder).quit(batchId1); + + const balanceAfter = await fhevm.userDecryptEuint( + FhevmType.euint64, + await this.fromToken.confidentialBalanceOf(this.holder), + this.fromToken, + this.holder, + ); + + expect(balanceAfter - balanceBefore).to.eq(amount1); + + // batch size was reduced + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, await batcher.totalDeposits(batchId1), batcher, this.operator), + ).to.eventually.eq(0n); + + // ========================== Second batch ========================== + const batchId2 = await batcher.currentBatchId(); + + // batch is empty + await expect(batcher.totalDeposits(batchId2)).to.eventually.eq(0n); + + // join + await batcher.connect(this.holder).join(amount2); + + // batch has deposit + await expect( + fhevm.userDecryptEuint(FhevmType.euint64, await batcher.totalDeposits(batchId2), batcher, this.operator), + ).to.eventually.eq(amount2); + + // Second batch: dispatch + await batcher.dispatchBatch(); + + // Check unwrap amount + await expect( + batcher + .unwrapAmount(batchId2) + .then(amount => fhevm.publicDecrypt([amount])) + .then(({ abiEncodedClearValues }) => abiEncodedClearValues), + ).to.eventually.eq(amount2); + }); +}); diff --git a/test/token/ERC7984/ERC7984Votes.test.ts b/test/token/ERC7984/ERC7984Votes.test.ts index 3e5be5de..49e2029f 100644 --- a/test/token/ERC7984/ERC7984Votes.test.ts +++ b/test/token/ERC7984/ERC7984Votes.test.ts @@ -184,7 +184,7 @@ describe('ERC7984Votes', function () { // Burn total balance const amountToBurn = await this.token.confidentialBalanceOf(this.holder); - await this.token.$_burn(this.holder, amountToBurn); + await this.token['$_burn(address,bytes32)'](this.holder, amountToBurn); const afterBurnBlock = await ethers.provider.getBlockNumber(); await mine(); diff --git a/test/token/ERC7984/extensions/ERC7984ERC20Wrapper.test.ts b/test/token/ERC7984/extensions/ERC7984ERC20Wrapper.test.ts index d9ef5ca9..7bbe1097 100644 --- a/test/token/ERC7984/extensions/ERC7984ERC20Wrapper.test.ts +++ b/test/token/ERC7984/extensions/ERC7984ERC20Wrapper.test.ts @@ -290,9 +290,9 @@ describe('ERC7984ERC20Wrapper', function () { it('with a value not allowed to sender', async function () { const totalSupplyHandle = await this.wrapper.confidentialTotalSupply(); - await expect(this.wrapper.connect(this.holder).unwrap(this.holder, this.holder, totalSupplyHandle)) + await expect(this.wrapper.connect(this.recipient).unwrap(this.recipient, this.recipient, totalSupplyHandle)) .to.be.revertedWithCustomError(this.wrapper, 'ERC7984UnauthorizedUseOfEncryptedAmount') - .withArgs(totalSupplyHandle, this.holder); + .withArgs(totalSupplyHandle, this.recipient); }); it('finalized with invalid signature', async function () {