diff --git a/config/tokenconverters.json b/config/tokenconverters.json new file mode 100644 index 000000000..9b1bf8c74 --- /dev/null +++ b/config/tokenconverters.json @@ -0,0 +1,5 @@ +{ + "stable": { + "converterAddress": "0xded1660192d4d82e7c0b628ba556861edbb5cada" + } +} diff --git a/script/deploy/facets/DeployTokenWrapper.s.sol b/script/deploy/facets/DeployTokenWrapper.s.sol index d29966559..95980856e 100644 --- a/script/deploy/facets/DeployTokenWrapper.s.sol +++ b/script/deploy/facets/DeployTokenWrapper.s.sol @@ -1,9 +1,12 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: LGPL-3.0-only +pragma solidity ^0.8.17; pragma solidity ^0.8.17; import { DeployScriptBase } from "./utils/DeployScriptBase.sol"; import { TokenWrapper } from "lifi/Periphery/TokenWrapper.sol"; import { stdJson } from "forge-std/Script.sol"; +import { InvalidContract } from "lifi/Errors/GenericErrors.sol"; +import { LibAsset } from "lifi/Libraries/LibAsset.sol"; contract DeployScript is DeployScriptBase { using stdJson for string; @@ -29,6 +32,29 @@ contract DeployScript is DeployScriptBase { string.concat(".", network, ".wrappedNativeAddress") ); + // Try to get converter address from tokenconverters.json, default to address(0) if not found + address converterAddress; + string memory convertersConfigPath = string.concat( + root, + "/config/tokenconverters.json" + ); + try + vm.parseJsonAddress( + vm.readFile(convertersConfigPath), + string.concat(".", network, ".converterAddress") + ) + returns (address addr) { + converterAddress = addr; + } catch { + converterAddress = address(0); + } + + // Verify converter is a contract if address is non-zero + if (converterAddress != address(0)) { + if (!LibAsset.isContract(converterAddress)) + revert InvalidContract(); + } + // get path of global config file string memory globalConfigPath = string.concat( root, @@ -43,6 +69,11 @@ contract DeployScript is DeployScriptBase { ".refundWallet" ); - return abi.encode(wrappedNativeAddress, refundWalletAddress); + return + abi.encode( + wrappedNativeAddress, + converterAddress, + refundWalletAddress + ); } } diff --git a/script/deploy/resources/deployRequirements.json b/script/deploy/resources/deployRequirements.json index 65882a1bb..c0d78663e 100644 --- a/script/deploy/resources/deployRequirements.json +++ b/script/deploy/resources/deployRequirements.json @@ -696,6 +696,25 @@ } } }, + "TokenWrapper": { + "configData": { + "_wrappedToken": { + "configFileName": "networks.json", + "keyInConfigFile": "..wrappedNativeAddress", + "allowToDeployWithZeroAddress": "false" + }, + "_converter": { + "configFileName": "networks.json", + "keyInConfigFile": "..converterAddress", + "allowToDeployWithZeroAddress": "true" + }, + "_owner": { + "configFileName": "global.json", + "keyInConfigFile": ".refundWallet", + "allowToDeployWithZeroAddress": "false" + } + } + }, "NEARIntentsFacet": { "configData": { "_backendSigner": { diff --git a/script/deploy/tron/deploy-and-register-periphery.ts b/script/deploy/tron/deploy-and-register-periphery.ts index bc781cc30..a790243d3 100755 --- a/script/deploy/tron/deploy-and-register-periphery.ts +++ b/script/deploy/tron/deploy-and-register-periphery.ts @@ -802,11 +802,28 @@ async function deployAndRegisterPeripheryImpl(options: { const refundWalletHex = globalConfig.refundWallet - const constructorArgs = [wrappedNativeHex, refundWalletHex] + // Try to get converter address, default to zero address if not found + const converterHex = tronConfig.converterAddress + ? '0x' + + tronWeb.address.toHex(tronConfig.converterAddress).substring(2) + : '0x0000000000000000000000000000000000000000' + + const constructorArgs = [ + wrappedNativeHex, + converterHex, + refundWalletHex, + ] consola.info( ` Using wrappedNative: ${wrappedNativeBase58} (hex: ${wrappedNativeHex})` ) + consola.info( + `Using converter: ${ + converterHex === '0x0000000000000000000000000000000000000000' + ? 'None (zero address)' + : converterHex + }` + ) consola.info(`Using refundWallet: ${refundWalletHex}`) consola.info(`Version: ${version}`) @@ -871,11 +888,28 @@ async function deployAndRegisterPeripheryImpl(options: { const refundWalletHex = globalConfig.refundWallet - const constructorArgs = [wrappedNativeHex, refundWalletHex] + // Try to get converter address, default to zero address if not found + const converterHex = tronConfig.converterAddress + ? '0x' + + tronWeb.address.toHex(tronConfig.converterAddress).substring(2) + : '0x0000000000000000000000000000000000000000' + + const constructorArgs = [ + wrappedNativeHex, + converterHex, + refundWalletHex, + ] consola.info( ` Using wrappedNative: ${wrappedNativeBase58} (hex: ${wrappedNativeHex})` ) + consola.info( + `Using converter: ${ + converterHex === '0x0000000000000000000000000000000000000000' + ? 'None (zero address)' + : converterHex + }` + ) consola.info(`Using refundWallet: ${refundWalletHex}`) consola.info(`Version: ${version}`) diff --git a/script/deploy/zksync/DeployTokenWrapper.zksync.s.sol b/script/deploy/zksync/DeployTokenWrapper.zksync.s.sol index d29966559..cf317e0f2 100644 --- a/script/deploy/zksync/DeployTokenWrapper.zksync.s.sol +++ b/script/deploy/zksync/DeployTokenWrapper.zksync.s.sol @@ -1,4 +1,5 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: LGPL-3.0-only + pragma solidity ^0.8.17; import { DeployScriptBase } from "./utils/DeployScriptBase.sol"; @@ -29,6 +30,22 @@ contract DeployScript is DeployScriptBase { string.concat(".", network, ".wrappedNativeAddress") ); + // Try to get converter address, default to address(0) if not found + address converterAddress; + try + vm.parseJsonAddress( + vm.readFile(path), + string.concat(".", network, ".converterAddress") + ) + returns (address addr) { + converterAddress = addr; + } catch { + converterAddress = address(0); + } + + // Note: On zkEVM, we cannot verify if the converter is a contract address + // using extcodesize during deployment, as contract code may not be available yet + // get path of global config file string memory globalConfigPath = string.concat( root, @@ -43,6 +60,11 @@ contract DeployScript is DeployScriptBase { ".refundWallet" ); - return abi.encode(wrappedNativeAddress, refundWalletAddress); + return + abi.encode( + wrappedNativeAddress, + converterAddress, + refundWalletAddress + ); } } diff --git a/src/Interfaces/IWrapper.sol b/src/Interfaces/IWrapper.sol new file mode 100644 index 000000000..3963669b0 --- /dev/null +++ b/src/Interfaces/IWrapper.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity ^0.8.17; + +/// @title IWrapper +/// @notice Interface for token wrapper contracts +/// @author LI.FI (https://li.fi) +/// @custom:version 1.0.0 +interface IWrapper { + function deposit() external payable; + + // solhint-disable-next-line explicit-types + function withdraw(uint wad) external; +} diff --git a/src/Periphery/TokenWrapper.sol b/src/Periphery/TokenWrapper.sol index 043978067..bf07fe87d 100644 --- a/src/Periphery/TokenWrapper.sol +++ b/src/Periphery/TokenWrapper.sol @@ -1,59 +1,94 @@ -// SPDX-License-Identifier: UNLICENSED +// SPDX-License-Identifier: LGPL-3.0-only + pragma solidity ^0.8.17; // solhint-disable-next-line no-unused-import import { LibAsset } from "../Libraries/LibAsset.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { WithdrawablePeriphery } from "../Helpers/WithdrawablePeriphery.sol"; import { SafeTransferLib } from "solady/utils/SafeTransferLib.sol"; - -/// External wrapper interface -interface IWrapper { - function deposit() external payable; - - // solhint-disable-next-line explicit-types - function withdraw(uint wad) external; -} +import { IWrapper } from "../Interfaces/IWrapper.sol"; +import { InvalidContract, InvalidConfig } from "../Errors/GenericErrors.sol"; /// @title TokenWrapper /// @author LI.FI (https://li.fi) /// @notice Provides functionality for wrapping and unwrapping tokens -/// @custom:version 1.1.0 +/// @custom:version 1.2.0 contract TokenWrapper is WithdrawablePeriphery { - uint256 private constant MAX_INT = 2 ** 256 - 1; - address public wrappedToken; + address public immutable WRAPPED_TOKEN; + address public immutable CONVERTER; + bool private immutable USE_CONVERTER; + uint256 private immutable SWAP_RATIO_MULTIPLIER; /// Errors /// error WithdrawFailure(); - /// Constructor /// + /// @notice Creates a new TokenWrapper contract + /// @param _wrappedToken Address of the wrapped token (e.g., WETH, or token returned by converter) + /// @param _converter Address of converter contract, or address(0) if wrapping 1:1 without conversion + /// @param _owner Address that will own this contract and can withdraw stuck tokens + /// @dev If converter is provided, all wrap/unwrap operations go through it for decimal or other conversions // solhint-disable-next-line no-empty-blocks constructor( address _wrappedToken, + address _converter, address _owner ) WithdrawablePeriphery(_owner) { - wrappedToken = _wrappedToken; - IERC20(wrappedToken).approve(address(this), MAX_INT); + if (_wrappedToken == address(0)) revert InvalidConfig(); + if (_owner == address(0)) revert InvalidConfig(); + + WRAPPED_TOKEN = _wrappedToken; + USE_CONVERTER = _converter != address(0); + + if (USE_CONVERTER) { + if (!LibAsset.isContract(_converter)) revert InvalidContract(); + CONVERTER = _converter; + // Approve converter once for all future withdrawals (gas optimization) + LibAsset.maxApproveERC20( + IERC20(_wrappedToken), + _converter, + type(uint256).max + ); + // Calculate swap ratio based on decimal difference between native (18) and wrapped token + uint8 wrappedDecimals = IERC20Metadata(_wrappedToken).decimals(); + SWAP_RATIO_MULTIPLIER = 10 ** wrappedDecimals; + } else { + CONVERTER = _wrappedToken; + SWAP_RATIO_MULTIPLIER = 1 ether; // 1:1 ratio for 18 decimals + } } /// External Methods /// - /// @notice Wraps the native token + /// @notice Wraps the native token and transfers wrapped tokens to caller + /// @dev If converter is set, uses it to convert native to wrapped tokens using precalculated ratio + /// @dev If no converter, wraps native 1:1 and transfers msg.value of wrapped tokens function deposit() external payable { - IWrapper(wrappedToken).deposit{ value: msg.value }(); - IERC20(wrappedToken).transfer(msg.sender, msg.value); + IWrapper(CONVERTER).deposit{ value: msg.value }(); + uint256 wrappedAmount = (msg.value * SWAP_RATIO_MULTIPLIER) / 1 ether; + SafeTransferLib.safeTransfer(WRAPPED_TOKEN, msg.sender, wrappedAmount); } - /// @notice Unwraps all the caller's balance of wrapped token + /// @notice Unwraps all the caller's balance of wrapped token and returns native tokens + /// @dev Pulls wrapped tokens from msg.sender based on their balance (requires prior approval) + /// @dev Uses precalculated ratio to determine native token amount to return function withdraw() external { // While in a general purpose contract it would make sense - // to have `wad` equal to the minimum between the balance and the + // to have `amount` equal to the minimum between the balance and the // given allowance, in our specific usecase allowance is always // nearly MAX_UINT256. Using the balance only is a gas optimisation. - uint256 wad = IERC20(wrappedToken).balanceOf(msg.sender); - IERC20(wrappedToken).transferFrom(msg.sender, address(this), wad); - IWrapper(wrappedToken).withdraw(wad); - SafeTransferLib.safeTransferETH(msg.sender, wad); + uint256 amount = IERC20(WRAPPED_TOKEN).balanceOf(msg.sender); + SafeTransferLib.safeTransferFrom( + WRAPPED_TOKEN, + msg.sender, + address(this), + amount + ); + + IWrapper(CONVERTER).withdraw(amount); + uint256 nativeAmount = (amount * 1 ether) / SWAP_RATIO_MULTIPLIER; + SafeTransferLib.safeTransferETH(msg.sender, nativeAmount); } // Needs to be able to receive native on `withdraw` diff --git a/test/solidity/Periphery/TokenWrapper.t.sol b/test/solidity/Periphery/TokenWrapper.t.sol index e05cedf8d..bef03fcea 100644 --- a/test/solidity/Periphery/TokenWrapper.t.sol +++ b/test/solidity/Periphery/TokenWrapper.t.sol @@ -1,10 +1,14 @@ -// SPDX-License-Identifier: Unlicense +// SPDX-License-Identifier: LGPL-3.0-only + pragma solidity ^0.8.17; import { DSTest } from "ds-test/test.sol"; import { Vm } from "forge-std/Vm.sol"; import { TokenWrapper } from "lifi/Periphery/TokenWrapper.sol"; import { TestWrappedToken as ERC20 } from "../utils/TestWrappedToken.sol"; +import { TestWrappedConverter } from "../utils/TestWrappedConverter.sol"; +import { TestBasicToken } from "../utils/TestBasicToken.sol"; +import { TestConverterWithDecimals } from "../utils/TestConverterWithDecimals.sol"; contract TokenWrapperTest is DSTest { // solhint-disable immutable-vars-naming @@ -12,24 +16,68 @@ contract TokenWrapperTest is DSTest { TokenWrapper private tokenWrapper; ERC20 private wrappedToken; + // Converter test variables + TokenWrapper private tokenWrapperWithConverter; + TestBasicToken private basicToken; + TestWrappedConverter private converter; + + // Decimal converter test variables + TokenWrapper private tokenWrapperWithDecimalConverter; + TestBasicToken private token6Decimals; + TestConverterWithDecimals private decimalConverter; + + uint256 private constant ONE_USDT = 1_000_000; + error ETHTransferFailed(); function setUp() public { wrappedToken = new ERC20("TestWrappedToken", "WTST", 18); - tokenWrapper = new TokenWrapper(address(wrappedToken), address(this)); + tokenWrapper = new TokenWrapper( + address(wrappedToken), + address(0), + address(this) + ); vm.deal(address(this), 100 ether); + + // Setup converter test scenario + basicToken = new TestBasicToken("BasicToken", "BASIC", 18); + converter = new TestWrappedConverter(address(basicToken)); + tokenWrapperWithConverter = new TokenWrapper( + address(basicToken), + address(converter), + address(this) + ); + + // Fund the converter with basic tokens and ETH for testing + basicToken.mint(address(converter), 100 ether); + vm.deal(address(converter), 100 ether); + + // Setup decimal converter test scenario (simulates GasUSDT0Converter) + token6Decimals = new TestBasicToken("USDT", "USDT", 6); + decimalConverter = new TestConverterWithDecimals( + address(token6Decimals) + ); + tokenWrapperWithDecimalConverter = new TokenWrapper( + address(token6Decimals), + address(decimalConverter), + address(this) + ); + + // Fund the decimal converter with 6-decimal tokens and ETH + token6Decimals.mint(address(decimalConverter), 100_000_000); // 100 USDT (6 decimals) + vm.deal(address(decimalConverter), 100 ether); } // Needed to receive ETH receive() external payable {} - function testCanDeposit() public { + function test_CanDeposit() public { assert(wrappedToken.balanceOf(address(this)) == 0); tokenWrapper.deposit{ value: 1 ether }(); assert(wrappedToken.balanceOf(address(this)) == 1 ether); } - function testCanWithdrawToken() public { + function test_CanWithdrawToken() public { // Send some ETH to the contract (bool success, ) = address(tokenWrapper).call{ value: 1 ether }(""); if (!success) revert ETHTransferFailed(); @@ -43,7 +91,7 @@ contract TokenWrapperTest is DSTest { assertEq(address(this).balance - initialBalance, 1 ether); } - function testCanWithdraw() public { + function test_CanWithdraw() public { uint256 initialBalance = address(this).balance; vm.deal(address(wrappedToken), 100 ether); wrappedToken.mint(address(this), 1 ether); @@ -51,4 +99,103 @@ contract TokenWrapperTest is DSTest { tokenWrapper.withdraw(); assert(address(this).balance - initialBalance == 1 ether); } + + // ========== CONVERTER TESTS ========== + + function test_CanDepositWithConverter() public { + // Initial state: user has no basic tokens + assertEq(basicToken.balanceOf(address(this)), 0); + + // User deposits ETH through TokenWrapper + tokenWrapperWithConverter.deposit{ value: 1 ether }(); + + // User should now have basic tokens + assertEq(basicToken.balanceOf(address(this)), 1 ether); + } + + function test_CanWithdrawWithConverter() public { + uint256 initialBalance = address(this).balance; + + // Give user some basic tokens + basicToken.mint(address(this), 1 ether); + + // Approve TokenWrapper to spend basic tokens + basicToken.approve(address(tokenWrapperWithConverter), 1 ether); + + // User withdraws through TokenWrapper + tokenWrapperWithConverter.withdraw(); + + // User should have received ETH + assertEq(address(this).balance - initialBalance, 1 ether); + + // User should have no basic tokens left + assertEq(basicToken.balanceOf(address(this)), 0); + } + + function test_ConverterReceivesApproval() public { + // Check that converter was approved in constructor + // (TokenWrapper approves converter once during deployment for gas efficiency) + assertEq( + basicToken.allowance( + address(tokenWrapperWithConverter), + address(converter) + ), + type(uint256).max + ); + } + + function test_CanDepositWithDecimalConverter() public { + // Initial state: user has no 6-decimal tokens + assertEq(token6Decimals.balanceOf(address(this)), 0); + + // User deposits 1 ETH (18 decimals) + // Should receive 1 USDT (6 decimals) after conversion + tokenWrapperWithDecimalConverter.deposit{ value: 1 ether }(); + + // User should now have 1 USDT (1e6, not 1e18) + assertEq(token6Decimals.balanceOf(address(this)), ONE_USDT); + } + + function test_CanWithdrawWithDecimalConverter() public { + uint256 initialBalance = address(this).balance; + + // Give user 1 USDT (6 decimals) + token6Decimals.mint(address(this), ONE_USDT); + + // Approve TokenWrapper to spend tokens + token6Decimals.approve( + address(tokenWrapperWithDecimalConverter), + ONE_USDT + ); + + // User withdraws 1 USDT (6 decimals) + tokenWrapperWithDecimalConverter.withdraw(); + + // User should have received 1 ETH (18 decimals) after conversion + assertEq(address(this).balance - initialBalance, 1 ether); + + // User should have no tokens left + assertEq(token6Decimals.balanceOf(address(this)), 0); + } + + function test_DecimalConverterRoundTrip() public { + // Start with 5 ETH + uint256 depositAmount = 5 ether; + + // Deposit 5 ETH, should receive 5 USDT (6 decimals) + tokenWrapperWithDecimalConverter.deposit{ value: depositAmount }(); + assertEq(token6Decimals.balanceOf(address(this)), 5 * ONE_USDT); + + // Approve and withdraw all USDT + token6Decimals.approve( + address(tokenWrapperWithDecimalConverter), + 5 * ONE_USDT + ); + + uint256 balanceBefore = address(this).balance; + tokenWrapperWithDecimalConverter.withdraw(); + + // Should receive back 5 ETH + assertEq(address(this).balance - balanceBefore, depositAmount); + } } diff --git a/test/solidity/utils/TestBasicToken.sol b/test/solidity/utils/TestBasicToken.sol new file mode 100644 index 000000000..7bf303f21 --- /dev/null +++ b/test/solidity/utils/TestBasicToken.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity ^0.8.17; + +import { ERC20 } from "solmate/tokens/ERC20.sol"; + +/// @notice Basic ERC20 token without deposit/withdraw functions +/// @dev Used to test converter functionality +contract TestBasicToken is ERC20 { + constructor( + string memory _name, + string memory _symbol, + uint8 _decimals + ) ERC20(_name, _symbol, _decimals) {} + + function mint(address _to, uint256 _amount) public { + _mint(_to, _amount); + } + + function burn(address _from, uint256 _amount) public { + _burn(_from, _amount); + } +} diff --git a/test/solidity/utils/TestConverterWithDecimals.sol b/test/solidity/utils/TestConverterWithDecimals.sol new file mode 100644 index 000000000..20cb40d86 --- /dev/null +++ b/test/solidity/utils/TestConverterWithDecimals.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity ^0.8.17; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Test converter that simulates decimal conversion (like GasUSDT0Converter) +/// @dev Converts 18 decimal native token to 6 decimal wrapped token (1e12 conversion) +contract TestConverterWithDecimals { + IERC20 public immutable WRAPPED_TOKEN; + + error WithdrawError(); + error InvalidAmount(); + error TransferFailed(); + error TransferFromFailed(); + + constructor(address _wrappedToken) { + WRAPPED_TOKEN = IERC20(_wrappedToken); + } + + function convertToWrapped(uint256 amount) internal pure returns (uint256) { + return amount / 1e12; + } + + function convertToNative(uint256 amount) internal pure returns (uint256) { + return amount * 1e12; + } + + /// @notice Accepts native token and sends wrapped tokens to caller + /// @dev Simulates 1e18 -> 1e6 conversion + function deposit() public payable { + if (msg.value % 1e12 != 0) revert InvalidAmount(); + uint256 amountOut = convertToWrapped(msg.value); + if (!WRAPPED_TOKEN.transfer(msg.sender, amountOut)) + revert TransferFailed(); + } + + /// @notice Pulls wrapped tokens from caller and sends native tokens back + /// @dev Simulates 1e6 -> 1e18 conversion + function withdraw(uint256 amount) public { + // Pull wrapped tokens from caller + if (!WRAPPED_TOKEN.transferFrom(msg.sender, address(this), amount)) + revert TransferFromFailed(); + + // Send native tokens to caller (with decimal conversion) + uint256 amountOut = convertToNative(amount); + (bool success, ) = payable(msg.sender).call{ value: amountOut }(""); + if (!success) { + revert WithdrawError(); + } + } + + // Needs to receive ETH for testing + receive() external payable {} +} diff --git a/test/solidity/utils/TestWrappedConverter.sol b/test/solidity/utils/TestWrappedConverter.sol new file mode 100644 index 000000000..3def1a36a --- /dev/null +++ b/test/solidity/utils/TestWrappedConverter.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LGPL-3.0-only + +pragma solidity ^0.8.17; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @notice Test converter contract that implements WETH9 interface +/// @dev Simulates a converter that wraps/unwraps native ETH to/from an ERC20 token +contract TestWrappedConverter { + IERC20 public immutable WRAPPED_TOKEN; + + error WithdrawError(); + error InsufficientBalance(uint256 available, uint256 required); + error TransferFailed(); + error TransferFromFailed(); + + constructor(address _wrappedToken) { + WRAPPED_TOKEN = IERC20(_wrappedToken); + } + + /// @notice Accepts ETH and sends wrapped tokens to the caller + function deposit() public payable { + // Transfer wrapped tokens to caller equal to msg.value + if (!WRAPPED_TOKEN.transfer(msg.sender, msg.value)) + revert TransferFailed(); + } + + /// @notice Pulls wrapped tokens from caller and sends ETH back + function withdraw(uint256 wad) public { + // Pull wrapped tokens from caller + if (!WRAPPED_TOKEN.transferFrom(msg.sender, address(this), wad)) + revert TransferFromFailed(); + + // Send ETH to caller + (bool success, ) = payable(msg.sender).call{ value: wad }(""); + if (!success) { + revert WithdrawError(); + } + } + + // Needs to receive ETH for testing + receive() external payable {} +}