Skip to content

Commit d59c678

Browse files
authored
feat(wrapper): add denyList in wrapper V3 (#175)
1 parent 9ccc8e9 commit d59c678

7 files changed

Lines changed: 2136 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.27;
3+
4+
import {ERC20Mock} from "./ERC20Mock.sol";
5+
6+
/// @dev cUSDC-style underlying: isBlacklisted(address) — selector 0xfe575a87
7+
contract ERC20MockCUSDC is ERC20Mock {
8+
mapping(address => bool) private _denyListed;
9+
10+
constructor() ERC20Mock("Mock USDC", "mUSDC", 6) {}
11+
12+
function mint(address to, uint256 amount) external {
13+
_mint(to, amount);
14+
}
15+
16+
function setDenyListed(address account, bool status) external {
17+
_denyListed[account] = status;
18+
}
19+
20+
function isBlacklisted(address account) external view returns (bool) {
21+
return _denyListed[account];
22+
}
23+
}
24+
25+
/// @dev cUSDT-style underlying: getBlackListStatus(address) — selector 0x59bf1abe
26+
contract ERC20MockCUSDT is ERC20Mock {
27+
mapping(address => bool) private _denyListed;
28+
29+
constructor() ERC20Mock("Mock USDT", "mUSDT", 6) {}
30+
31+
function mint(address to, uint256 amount) external {
32+
_mint(to, amount);
33+
}
34+
35+
function setDenyListed(address account, bool status) external {
36+
_denyListed[account] = status;
37+
}
38+
39+
function getBlackListStatus(address account) external view returns (bool) {
40+
return _denyListed[account];
41+
}
42+
}
43+
44+
/// @dev tGBP-style underlying: isBanned(address) — selector 0x97f735d5
45+
contract ERC20MockTGBP is ERC20Mock {
46+
mapping(address => bool) private _denyListed;
47+
48+
constructor() ERC20Mock("Mock GBP", "mGBP", 6) {}
49+
50+
function mint(address to, uint256 amount) external {
51+
_mint(to, amount);
52+
}
53+
54+
function setDenyListed(address account, bool status) external {
55+
_denyListed[account] = status;
56+
}
57+
58+
function isBanned(address account) external view returns (bool) {
59+
return _denyListed[account];
60+
}
61+
}
62+
63+
/// @dev XAUt-style underlying: isBlocked(address) — selector 0xfbac3951
64+
contract ERC20MockXAUt is ERC20Mock {
65+
mapping(address => bool) private _denyListed;
66+
67+
constructor() ERC20Mock("Mock Gold", "mXAUt", 6) {}
68+
69+
function mint(address to, uint256 amount) external {
70+
_mint(to, amount);
71+
}
72+
73+
function setDenyListed(address account, bool status) external {
74+
_denyListed[account] = status;
75+
}
76+
77+
function isBlocked(address account) external view returns (bool) {
78+
return _denyListed[account];
79+
}
80+
}
81+
82+
/// @dev Deny-list call always reverts — triggers UnderlyingDenyListCallFailed
83+
contract ERC20MockRevertingDenyList is ERC20Mock {
84+
constructor() ERC20Mock("Mock Reverting", "mREV", 6) {}
85+
86+
function mint(address to, uint256 amount) external {
87+
_mint(to, amount);
88+
}
89+
90+
// Same selector as isBlacklisted(address): 0xfe575a87 — always reverts
91+
function isBlacklisted(address) external pure returns (bool) {
92+
revert();
93+
}
94+
}
95+
96+
/// @dev Deny-list call returns empty data (0 bytes) — triggers InvalidUnderlyingDenyListResponse
97+
contract ERC20MockInvalidDenyList is ERC20Mock {
98+
constructor() ERC20Mock("Mock Invalid", "mINV", 6) {}
99+
100+
function mint(address to, uint256 amount) external {
101+
_mint(to, amount);
102+
}
103+
104+
// Same selector as isBlacklisted(address): 0xfe575a87 — returns nothing (length != 32)
105+
function isBlacklisted(address) external pure {}
106+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// SPDX-License-Identifier: BSD-3-Clause-Clear
2+
pragma solidity ^0.8.27;
3+
4+
import {externalEuint64, euint64} from "@fhevm/solidity/lib/FHE.sol";
5+
import {ConfidentialWrapperV2} from "./ConfidentialWrapperV2.sol";
6+
7+
/**
8+
* @title ConfidentialWrapperV3
9+
* @notice Upgrade contract that adds an owner-controlled denylist preventing blocked addresses
10+
* from participating in confidential transfers, wraps, and unwraps.
11+
*/
12+
contract ConfidentialWrapperV3 is ConfidentialWrapperV2 {
13+
/// @dev to persist context of the unwrap between the unwrap and the finalizeUnwrap calls
14+
struct UnwrapContext {
15+
address from;
16+
address operator;
17+
}
18+
19+
/// @custom:storage-location erc7201:fhevm_protocol.storage.ConfidentialWrapperV3
20+
struct ConfidentialWrapperV3Storage {
21+
mapping(address user => bool blocked) _blockedUsers;
22+
mapping(bytes32 unwrapRequestId => UnwrapContext unwrapContext) _unwrapContexts;
23+
bytes4 _underlyingDenyListSelector;
24+
bool _hasUnderlyingDenyListSelector;
25+
}
26+
27+
// keccak256(abi.encode(uint256(keccak256("fhevm_protocol.storage.ConfidentialWrapperV3")) - 1)) & ~bytes32(uint256(0xff))
28+
bytes32 private constant CONFIDENTIAL_WRAPPER_V3_STORAGE_LOCATION =
29+
0xfbb2c4771bcc77528b8fd58eedad6a4f84fdaf9eea4a56a2752391a0c87eee00;
30+
31+
/// @dev Emitted when `user` is added to the denylist.
32+
event UserBlocked(address indexed user);
33+
34+
/// @dev Emitted when `user` is removed from the denylist.
35+
event UserUnblocked(address indexed user);
36+
37+
/// @dev Thrown when `user` is on the denylist and attempts a restricted operation.
38+
error BlockedUser(address user);
39+
40+
/// @dev Thrown when attempting to block a user that is already on the denylist.
41+
error UserAlreadyBlocked(address user);
42+
43+
/// @dev Thrown when attempting to unblock a user that is not on the denylist.
44+
error UserAlreadyUnblocked(address user);
45+
46+
/// @dev Thrown when the underlying denylist call fails.
47+
error UnderlyingDenyListCallFailed();
48+
49+
/// @dev Thrown when the underlying denylist call returns an invalid response.
50+
error InvalidUnderlyingDenyListResponse();
51+
52+
/// @dev Thrown when the underlying denylist call returns a true value for the given address.
53+
error UnderlyingDenyListedAddress(address user);
54+
55+
function _getConfidentialWrapperV3Storage() internal pure returns (ConfidentialWrapperV3Storage storage $) {
56+
assembly {
57+
$.slot := CONFIDENTIAL_WRAPPER_V3_STORAGE_LOCATION
58+
}
59+
}
60+
61+
/**
62+
* @dev Reinitializer used when upgrading from V2. Optionally seeds the denylist with `blockedUsers`.
63+
* Reverts if any entry in `blockedUsers` appears more than once.
64+
*/
65+
/// @custom:oz-upgrades-validate-as-initializer
66+
function reinitializeV3(
67+
address[] memory blockedUsers,
68+
bytes4 underlyingDenyListSelector,
69+
bool hasUnderlyingDenyListSelector_
70+
) public virtual reinitializer(3) {
71+
uint256 length = blockedUsers.length;
72+
for (uint256 i = 0; i < length; i++) {
73+
_blockUser(blockedUsers[i]);
74+
}
75+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
76+
$._underlyingDenyListSelector = underlyingDenyListSelector;
77+
$._hasUnderlyingDenyListSelector = hasUnderlyingDenyListSelector_;
78+
}
79+
80+
/// @dev Adds `user` to the denylist.
81+
function blockUser(address user) external virtual onlyOwner {
82+
_blockUser(user);
83+
}
84+
85+
/// @dev Removes `user` from the denylist. Reverts if `user` is not currently blocked.
86+
function unblockUser(address user) external virtual onlyOwner {
87+
_unblockUser(user);
88+
}
89+
90+
/// @dev Returns whether `user` is currently on the denylist.
91+
function isBlocked(address user) public view virtual returns (bool) {
92+
return _getConfidentialWrapperV3Storage()._blockedUsers[user];
93+
}
94+
95+
/**
96+
* @dev Returns the underlying denylist configuration as a `(isSet, selector)` pair.
97+
* `isSet` indicates whether an underlying denylist check is enabled, and `selector`
98+
* is the 4-byte function selector to call on {underlying} when it is set.
99+
*/
100+
function getUnderlyingDenyListSelector() public view virtual returns (bool isSet, bytes4 selector) {
101+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
102+
return ($._hasUnderlyingDenyListSelector, $._underlyingDenyListSelector);
103+
}
104+
105+
function _blockUser(address user) internal virtual {
106+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
107+
require(!$._blockedUsers[user], UserAlreadyBlocked(user));
108+
$._blockedUsers[user] = true;
109+
emit UserBlocked(user);
110+
}
111+
112+
function _unblockUser(address user) internal virtual {
113+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
114+
require($._blockedUsers[user], UserAlreadyUnblocked(user));
115+
$._blockedUsers[user] = false;
116+
emit UserUnblocked(user);
117+
}
118+
119+
function _requireNotBlocked(address user) internal view {
120+
// to not block mints and burns, also needed because for e.g. USDT.getBlackListStatus(address(0))
121+
if (user == address(0)) return;
122+
require(!isBlocked(user), BlockedUser(user));
123+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
124+
if ($._hasUnderlyingDenyListSelector) {
125+
(bool success, bytes memory data) = underlying().staticcall(
126+
abi.encodeWithSelector($._underlyingDenyListSelector, user)
127+
);
128+
if (!success) revert UnderlyingDenyListCallFailed();
129+
if (data.length != 32) revert InvalidUnderlyingDenyListResponse();
130+
bool value = abi.decode(data, (bool));
131+
if (value == true) revert UnderlyingDenyListedAddress(user);
132+
}
133+
}
134+
135+
// ----- Overrides enforcing the denylist -----
136+
137+
/// @dev Catches confidential transfers (both parties), the wrap recipient (mint side), and the unwrap holder (burn side).
138+
function _update(address from, address to, euint64 amount) internal virtual override returns (euint64) {
139+
// to block operators in case of confidentialTransferFrom(AndCall)
140+
if (msg.sender != from) _requireNotBlocked(msg.sender);
141+
_requireNotBlocked(from);
142+
_requireNotBlocked(to);
143+
return super._update(from, to, amount);
144+
}
145+
146+
/// @dev Catches a blocked depositor on the direct {wrap} path; the recipient is covered via {_update}.
147+
function wrap(address to, uint256 amount) public virtual override returns (euint64) {
148+
// needed because _update is not aware of msg.sender, because it's doing a _mint, i.e from is null address
149+
_requireNotBlocked(msg.sender);
150+
return super.wrap(to, amount);
151+
}
152+
153+
/// @dev Catches a blocked depositor on the ERC-1363 callback path; the recipient is covered via {_update}.
154+
function onTransferReceived(
155+
address operator,
156+
address from,
157+
uint256 amount,
158+
bytes calldata data
159+
) public virtual override returns (bytes4) {
160+
// needed because _update is not aware of from nor operator, because it's doing a _mint, i.e from is null address
161+
_requireNotBlocked(from);
162+
if (operator != from) _requireNotBlocked(operator);
163+
return super.onTransferReceived(operator, from, amount, data);
164+
}
165+
166+
/// @dev Internal logic for handling the creation of unwrap requests. Returns the unwrap request id.
167+
function _unwrap(address from, address to, euint64 amount) internal virtual override returns (bytes32) {
168+
// needed because _update is not aware of to, because it's doing a _burn, i.e to is null address
169+
_requireNotBlocked(to);
170+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
171+
bytes32 unwrapRequestId = super._unwrap(from, to, amount);
172+
$._unwrapContexts[unwrapRequestId] = UnwrapContext(from, msg.sender);
173+
return unwrapRequestId;
174+
}
175+
176+
/**
177+
* @dev Prevents settlement of the underlying transfer to a recipient that became
178+
* blocked between {unwrap} and {finalizeUnwrap}.
179+
*/
180+
function finalizeUnwrap(
181+
bytes32 unwrapRequestId,
182+
uint64 unwrapAmountCleartext,
183+
bytes calldata decryptionProof
184+
) public virtual override {
185+
// needed because _update is no longer called in finalizeUnwrap, because cTokens were already burnt in unwrap
186+
_requireNotBlocked(unwrapRequester(unwrapRequestId));
187+
// also check both original holder and operator from the corresponding unwrap call
188+
ConfidentialWrapperV3Storage storage $ = _getConfidentialWrapperV3Storage();
189+
UnwrapContext memory unwrapContext = $._unwrapContexts[unwrapRequestId];
190+
_requireNotBlocked(unwrapContext.from);
191+
if (unwrapContext.from != unwrapContext.operator) {
192+
_requireNotBlocked(unwrapContext.operator);
193+
}
194+
delete $._unwrapContexts[unwrapRequestId];
195+
super.finalizeUnwrap(unwrapRequestId, unwrapAmountCleartext, decryptionProof);
196+
}
197+
}

contracts/confidential-wrapper/hardhat.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'hardhat-exposed';
1818
import './tasks/accounts';
1919
import './tasks/deploy';
2020
import './tasks/upgrades/confidentialWrapperV2';
21+
import './tasks/upgrades/confidentialWrapperV3';
2122
import './tasks/verify';
2223

2324
// Get the environment configuration from .env file
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { task, types } from 'hardhat/config';
2+
import { HardhatRuntimeEnvironment } from 'hardhat/types';
3+
4+
export const CONFIDENTIAL_WRAPPER_V3_CONTRACT = 'ConfidentialWrapperV3';
5+
6+
// Get the name for saving the implementation in deployments
7+
export function getConfidentialWrapperV3ImplName(): string {
8+
return CONFIDENTIAL_WRAPPER_V3_CONTRACT + '_Impl';
9+
}
10+
11+
// Deploy the ConfidentialWrapperV3 implementation contract (no proxy)
12+
// This is used for DAO proposals to upgrade the existing proxy
13+
async function deployConfidentialWrapperV3Impl(hre: HardhatRuntimeEnvironment) {
14+
const { getNamedAccounts, ethers, deployments, network } = hre;
15+
const { save, getArtifact } = deployments;
16+
17+
const { deployer } = await getNamedAccounts();
18+
const deployerSigner = await ethers.getSigner(deployer);
19+
20+
const contractName = CONFIDENTIAL_WRAPPER_V3_CONTRACT;
21+
22+
const factory = await ethers.getContractFactory(contractName, deployerSigner);
23+
const implementation = await factory.deploy();
24+
await implementation.waitForDeployment();
25+
26+
const implementationAddress = await implementation.getAddress();
27+
28+
console.log(
29+
[
30+
`✅ Deployed ${contractName} implementation:`,
31+
` - Implementation address: ${implementationAddress}`,
32+
` - Deployed by deployer account: ${deployer}`,
33+
` - Network: ${network.name}`,
34+
'',
35+
].join('\n'),
36+
);
37+
38+
const artifact = await getArtifact(contractName);
39+
await save(getConfidentialWrapperV3ImplName(), {
40+
address: implementationAddress,
41+
abi: artifact.abi,
42+
});
43+
44+
return implementationAddress;
45+
}
46+
47+
// Deploy the ConfidentialWrapperV3 implementation contract
48+
// After deploying the implementation, the owner should call `upgradeToAndCall` on the Proxy
49+
// Example usage:
50+
// npx hardhat task:deployConfidentialWrapperV3Impl --network testnet
51+
task('task:deployConfidentialWrapperV3Impl').setAction(async function (_, hre) {
52+
console.log('Deploying ConfidentialWrapperV3 implementation...\n');
53+
await deployConfidentialWrapperV3Impl(hre);
54+
});
55+
56+
// Verify the ConfidentialWrapperV3 implementation contract
57+
// Example usage:
58+
// npx hardhat task:verifyConfidentialWrapperV3Impl --impl-address 0x... --network testnet
59+
task('task:verifyConfidentialWrapperV3Impl')
60+
.addParam('implAddress', 'The address of the implementation contract to verify', '', types.string)
61+
.setAction(async function ({ implAddress }, hre) {
62+
const { run } = hre;
63+
64+
console.log(`Verifying ConfidentialWrapperV3 implementation at ${implAddress}...\n`);
65+
await run('verify:verify', {
66+
address: implAddress,
67+
constructorArguments: [],
68+
});
69+
});

0 commit comments

Comments
 (0)