|
1 | 1 | // SPDX-License-Identifier: BSD-3-Clause |
2 | | -pragma solidity 0.8.25; |
| 2 | +pragma solidity 0.8.28; |
3 | 3 |
|
4 | 4 | import { SafeERC20Upgradeable, IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; |
5 | 5 | import { AddressUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/AddressUpgradeable.sol"; |
6 | 6 | import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; |
7 | 7 | import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
| 8 | +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; |
| 9 | +import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol"; |
8 | 10 |
|
9 | 11 | import { IWBNB } from "../Interfaces.sol"; |
10 | 12 |
|
11 | | -contract SwapHelper is EIP712 { |
| 13 | +/** |
| 14 | + * @title SwapHelper |
| 15 | + * @author Venus Protocol |
| 16 | + * @notice Helper contract for executing multiple token operations atomically |
| 17 | + * @dev This contract provides utilities for wrapping native tokens, managing approvals, |
| 18 | + * and executing arbitrary calls in a single transaction. It supports optional |
| 19 | + * signature verification using EIP-712 for backend-authorized operations. |
| 20 | + * All functions except multicall are designed to be called internally via multicall. |
| 21 | + * @custom:security-contact security@venus.io |
| 22 | + */ |
| 23 | +contract SwapHelper is EIP712, Ownable, ReentrancyGuard { |
12 | 24 | using SafeERC20Upgradeable for IERC20Upgradeable; |
13 | 25 | using AddressUpgradeable for address; |
14 | 26 |
|
15 | | - uint256 internal constant REENTRANCY_LOCK_UNLOCKED = 1; |
16 | | - uint256 internal constant REENTRANCY_LOCK_LOCKED = 2; |
17 | | - bytes32 internal constant MULTICALL_TYPEHASH = keccak256("Multicall(bytes[] calls,uint256 deadline)"); |
| 27 | + /// @notice EIP-712 typehash for Multicall struct used in signature verification |
| 28 | + /// @dev keccak256("Multicall(bytes[] calls,uint256 deadline,bytes32 salt)") |
| 29 | + bytes32 internal constant MULTICALL_TYPEHASH = keccak256("Multicall(bytes[] calls,uint256 deadline,bytes32 salt)"); |
18 | 30 |
|
19 | | - /// @notice Wrapped native asset |
| 31 | + /// @notice Wrapped native asset contract (e.g., WBNB, WETH) |
20 | 32 | IWBNB public immutable WRAPPED_NATIVE; |
21 | 33 |
|
22 | | - /// @notice Venus backend signer |
23 | | - address public immutable BACKEND_SIGNER; |
| 34 | + /// @notice Address authorized to sign multicall operations |
| 35 | + /// @dev Can be updated by contract owner via setBackendSigner |
| 36 | + address public BACKEND_SIGNER; |
24 | 37 |
|
25 | | - /// @dev Reentrancy lock to prevent reentrancy attacks |
26 | | - uint256 private reentrancyLock; |
| 38 | + /// @notice Mapping to track used salts for replay protection |
| 39 | + /// @dev Maps salt => bool to prevent reuse of same salt |
| 40 | + mapping(bytes32 => bool) public usedSalts; |
27 | 41 |
|
28 | | - /// @notice Error thrown when reentrancy is detected |
29 | | - error Reentrancy(); |
30 | | - |
31 | | - /// @notice Error thrown when deadline is reached |
| 42 | + /// @notice Error thrown when transaction deadline has passed |
| 43 | + /// @dev Emitted when block.timestamp > deadline in multicall |
32 | 44 | error DeadlineReached(); |
33 | 45 |
|
34 | | - /// @notice Error thrown when caller is not the authorized backend signer |
| 46 | + /// @notice Error thrown when signature verification fails |
| 47 | + /// @dev Emitted when recovered signer doesn't match BACKEND_SIGNER |
35 | 48 | error Unauthorized(); |
36 | 49 |
|
37 | | - /// @notice In the locked state, allow contract to call itself, but block all external calls |
38 | | - modifier externalLock() { |
39 | | - bool isExternal = msg.sender != address(this); |
| 50 | + /// @notice Error thrown when zero address is provided as parameter |
| 51 | + /// @dev Used in constructor and setBackendSigner validation |
| 52 | + error ZeroAddress(); |
40 | 53 |
|
41 | | - if (isExternal) { |
42 | | - if (reentrancyLock == REENTRANCY_LOCK_LOCKED) revert Reentrancy(); |
43 | | - reentrancyLock = REENTRANCY_LOCK_LOCKED; |
44 | | - } |
| 54 | + /// @notice Error thrown when salt has already been used |
| 55 | + /// @dev Prevents replay attacks by ensuring each salt is used only once |
| 56 | + error SaltAlreadyUsed(); |
45 | 57 |
|
46 | | - _; |
| 58 | + /// @notice Event emitted when backend signer is updated |
| 59 | + /// @param oldSigner Previous backend signer address |
| 60 | + /// @param newSigner New backend signer address |
| 61 | + event BackendSignerUpdated(address indexed oldSigner, address indexed newSigner); |
47 | 62 |
|
48 | | - if (isExternal) reentrancyLock = REENTRANCY_LOCK_UNLOCKED; |
49 | | - } |
| 63 | + /// @notice Event emitted when multicall is successfully executed |
| 64 | + /// @param caller Address that initiated the multicall |
| 65 | + /// @param callsCount Number of calls executed in the batch |
| 66 | + /// @param deadline Deadline timestamp used for the operation |
| 67 | + /// @param signatureVerified Whether signature verification was performed |
| 68 | + event MulticallExecuted(address indexed caller, uint256 callsCount, uint256 deadline, bool signatureVerified); |
50 | 69 |
|
51 | 70 | /// @notice Constructor |
52 | | - /// @param wrappedNative_ Address of the wrapped native asset |
53 | | - /// @param backendSigner_ Address of the backend signer |
| 71 | + /// @param wrappedNative_ Address of the wrapped native asset contract |
| 72 | + /// @param backendSigner_ Address authorized to sign multicall operations |
| 73 | + /// @dev Initializes EIP-712 domain with name "VenusSwap" and version "1" |
| 74 | + /// @dev Transfers ownership to msg.sender |
| 75 | + /// @dev Reverts with ZeroAddress if either parameter is address(0) |
| 76 | + /// @custom:error ZeroAddress if wrappedNative_ is address(0) |
| 77 | + /// @custom:error ZeroAddress if backendSigner_ is address(0) |
54 | 78 | constructor(address wrappedNative_, address backendSigner_) EIP712("VenusSwap", "1") { |
| 79 | + if (wrappedNative_ == address(0) || backendSigner_ == address(0)) { |
| 80 | + revert ZeroAddress(); |
| 81 | + } |
| 82 | + |
55 | 83 | WRAPPED_NATIVE = IWBNB(wrappedNative_); |
56 | 84 | BACKEND_SIGNER = backendSigner_; |
| 85 | + |
| 86 | + _transferOwnership(msg.sender); |
57 | 87 | } |
58 | 88 |
|
59 | 89 | /// @notice Multicall function to execute multiple calls in a single transaction |
60 | | - /// @param calls Array of calldata to execute |
61 | | - /// @param deadline Deadline for the transaction |
62 | | - /// @param signature Backend signature |
| 90 | + /// @param calls Array of encoded function calls to execute on this contract |
| 91 | + /// @param deadline Unix timestamp after which the transaction will revert |
| 92 | + /// @param salt Unique value to ensure this exact multicall can only be executed once |
| 93 | + /// @param signature Optional EIP-712 signature from backend signer (empty bytes to skip verification) |
| 94 | + /// @dev All calls are executed atomically - if any call fails, entire transaction reverts |
| 95 | + /// @dev Calls must be to functions on this contract (address(this)) |
| 96 | + /// @dev Signature verification is only performed if signature.length != 0 |
| 97 | + /// @dev Protected by nonReentrant modifier to prevent reentrancy attacks |
| 98 | + /// @dev Emits MulticallExecuted event upon successful execution |
| 99 | + /// @custom:security Only the contract itself can call wrap, sweep, approveMax, and genericCall |
| 100 | + /// @custom:error DeadlineReached if block.timestamp > deadline |
| 101 | + /// @custom:error SaltAlreadyUsed if salt has been used before |
| 102 | + /// @custom:error Unauthorized if signature verification fails |
63 | 103 | function multicall( |
64 | 104 | bytes[] calldata calls, |
65 | 105 | uint256 deadline, |
| 106 | + bytes32 salt, |
66 | 107 | bytes calldata signature |
67 | | - ) external payable externalLock { |
| 108 | + ) external payable nonReentrant { |
68 | 109 | if (block.timestamp > deadline) { |
69 | 110 | revert DeadlineReached(); |
70 | 111 | } |
71 | 112 |
|
| 113 | + if (usedSalts[salt]) { |
| 114 | + revert SaltAlreadyUsed(); |
| 115 | + } |
| 116 | + usedSalts[salt] = true; |
| 117 | + |
| 118 | + bool signatureVerified = false; |
72 | 119 | if (signature.length != 0) { |
73 | | - bytes32 digest = _hashMulticall(calls, deadline); |
| 120 | + bytes32 digest = _hashMulticall(calls, deadline, salt); |
74 | 121 | address signer = ECDSA.recover(digest, signature); |
75 | 122 | if (signer != BACKEND_SIGNER) { |
76 | 123 | revert Unauthorized(); |
77 | 124 | } |
| 125 | + signatureVerified = true; |
78 | 126 | } |
79 | 127 |
|
80 | 128 | for (uint256 i = 0; i < calls.length; i++) { |
81 | | - address(this).functionCall(calls[i]); |
| 129 | + (bool success, bytes memory returnData) = address(this).call(calls[i]); |
| 130 | + if (!success) { |
| 131 | + assembly { |
| 132 | + revert(add(returnData, 0x20), mload(returnData)) |
| 133 | + } |
| 134 | + } |
82 | 135 | } |
| 136 | + |
| 137 | + emit MulticallExecuted(msg.sender, calls.length, deadline, signatureVerified); |
83 | 138 | } |
84 | 139 |
|
85 | 140 | /// @notice Generic call function to execute a call to an arbitrary address |
86 | | - /// @param target Address to call |
87 | | - /// @param data Calldata to execute |
88 | | - function genericCall(address target, bytes calldata data) external externalLock { |
| 141 | + /// @param target Address of the contract to call |
| 142 | + /// @param data Encoded function call data |
| 143 | + /// @dev This function can interact with any external contract |
| 144 | + /// @dev Should only be called via multicall for safety |
| 145 | + /// @custom:security Use with extreme caution - can call any contract with any data |
| 146 | + /// @custom:security Ensure proper validation of target and data in off-chain systems |
| 147 | + function genericCall(address target, bytes calldata data) external payable { |
89 | 148 | target.functionCall(data); |
90 | 149 | } |
91 | 150 |
|
92 | | - /// @notice Wraps native asset into an ERC-20 token |
93 | | - /// @param amount Amount of native asset to wrap |
94 | | - function wrap(uint256 amount) external externalLock { |
| 151 | + /// @notice Wraps native asset into an ERC-20 wrapped token |
| 152 | + /// @param amount Amount of native asset to wrap (must match msg.value) |
| 153 | + /// @dev Calls deposit() on WRAPPED_NATIVE contract with msg.value |
| 154 | + /// @dev Wrapped tokens remain in this contract until swept |
| 155 | + /// @dev Should only be called via multicall |
| 156 | + /// @custom:security Ensure msg.value matches amount parameter |
| 157 | + function wrap(uint256 amount) external payable { |
95 | 158 | WRAPPED_NATIVE.deposit{ value: amount }(); |
96 | 159 | } |
97 | 160 |
|
98 | | - /// @notice Sweeps an ERC-20 token to a specified address |
99 | | - /// @param token ERC-20 token to sweep |
100 | | - /// @param to Address to send the token to |
101 | | - function sweep(IERC20Upgradeable token, address to) external externalLock { |
| 161 | + /// @notice Sweeps entire balance of an ERC-20 token to a specified address |
| 162 | + /// @param token ERC-20 token contract to sweep |
| 163 | + /// @param to Recipient address for the swept tokens |
| 164 | + /// @dev Transfers the entire balance of token held by this contract |
| 165 | + /// @dev Uses SafeERC20 for safe transfer operations |
| 166 | + /// @dev Should only be called via multicall |
| 167 | + function sweep(IERC20Upgradeable token, address to) external { |
102 | 168 | token.safeTransfer(to, token.balanceOf(address(this))); |
103 | 169 | } |
104 | 170 |
|
105 | | - /// @notice Approves the maximum amount of an ERC-20 token to a specified address |
106 | | - /// @param token ERC-20 token to approve |
107 | | - /// @param spender Address to approve the token to |
108 | | - function approveMax(IERC20Upgradeable token, address spender) external externalLock { |
109 | | - token.forceApprove(spender, 0); |
| 171 | + /// @notice Approves maximum amount of an ERC-20 token to a specified spender |
| 172 | + /// @param token ERC-20 token contract to approve |
| 173 | + /// @param spender Address to grant approval to |
| 174 | + /// @dev Sets approval to type(uint256).max for unlimited spending |
| 175 | + /// @dev Uses forceApprove to handle tokens that require 0 approval first |
| 176 | + /// @dev Should only be called via multicall |
| 177 | + /// @custom:security Grants unlimited approval - ensure spender is trusted |
| 178 | + function approveMax(IERC20Upgradeable token, address spender) external { |
110 | 179 | token.forceApprove(spender, type(uint256).max); |
111 | 180 | } |
112 | 181 |
|
| 182 | + /// @notice Updates the backend signer address |
| 183 | + /// @param newSigner New backend signer address |
| 184 | + /// @dev Only callable by contract owner |
| 185 | + /// @dev Reverts with ZeroAddress if newSigner is address(0) |
| 186 | + /// @dev Emits BackendSignerUpdated event |
| 187 | + /// @custom:error ZeroAddress if newSigner is address(0) |
| 188 | + /// @custom:error Ownable: caller is not the owner (from OpenZeppelin Ownable) |
| 189 | + function setBackendSigner(address newSigner) external onlyOwner { |
| 190 | + if (newSigner == address(0)) { |
| 191 | + revert ZeroAddress(); |
| 192 | + } |
| 193 | + address oldSigner = BACKEND_SIGNER; |
| 194 | + BACKEND_SIGNER = newSigner; |
| 195 | + |
| 196 | + emit BackendSignerUpdated(oldSigner, newSigner); |
| 197 | + } |
| 198 | + |
113 | 199 | /// @notice Produces an EIP-712 digest of the multicall data |
114 | | - /// @param calls Array of calldata to execute |
115 | | - /// @param deadline Deadline for the transaction |
116 | | - /// @return Digest of the multicall data |
117 | | - function _hashMulticall(bytes[] calldata calls, uint256 deadline) internal view returns (bytes32) { |
| 200 | + /// @param calls Array of encoded function calls |
| 201 | + /// @param deadline Unix timestamp deadline |
| 202 | + /// @param salt Unique value to ensure replay protection |
| 203 | + /// @return EIP-712 typed data hash for signature verification |
| 204 | + /// @dev Hashes each call individually, then encodes with MULTICALL_TYPEHASH, deadline, and salt |
| 205 | + /// @dev Uses EIP-712 _hashTypedDataV4 for domain-separated hashing |
| 206 | + function _hashMulticall(bytes[] calldata calls, uint256 deadline, bytes32 salt) internal view returns (bytes32) { |
118 | 207 | bytes32[] memory callHashes = new bytes32[](calls.length); |
119 | 208 | for (uint256 i = 0; i < calls.length; i++) { |
120 | 209 | callHashes[i] = keccak256(calls[i]); |
121 | 210 | } |
122 | 211 | return |
123 | 212 | _hashTypedDataV4( |
124 | | - keccak256(abi.encode(MULTICALL_TYPEHASH, keccak256(abi.encodePacked(callHashes)), deadline)) |
| 213 | + keccak256(abi.encode(MULTICALL_TYPEHASH, keccak256(abi.encodePacked(callHashes)), deadline, salt)) |
125 | 214 | ); |
126 | 215 | } |
127 | 216 | } |
0 commit comments