-
Notifications
You must be signed in to change notification settings - Fork 49
RUSDT -> USDT0 converter #565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
d7f571f
8c8642b
afdd252
f4d4d0f
3bb1b1a
1627b13
283e9fe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| TENDERLY_ACCESS_TOKEN=dagmhsE0M2uJbjARKcjCiAp2p0sj7Lan | ||
| TENDERLY_VIRTUAL_TESTNET_RPC_URL=https://virtual.rpc.tenderly.co/SOV/tenderlyfork/private/test-rusdt-usdt-swap/de337f04-eb68-46e9-bdd8-6f2c4adc7246 | ||
| TENDERLY_VERIFIER_URL=${TENDERLY_VIRTUAL_TESTNET_RPC_URL}/verify/etherscan | ||
| TENDERLY_CHAIN_ID=30 | ||
| CONTRACT_ADDRESS=0xa15bb66138824a1c7167f5e85b957d04dd34e468 | ||
| CONTRACT_FILE_PATH=contracts/utils/RootstockUsdtSwap.sol | ||
| CONTRACT_NAME=RootstockUsdtSwap | ||
| CONTRACT_FULLY_QUALIFIED_NAME=contracts/utils/RootstockUsdtSwap.sol:RootstockUsdtSwap | ||
| SOLC_VERSION=0.8.17 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ cache | |
| .idea/ | ||
| *.code-workspace | ||
| .env | ||
| !.env.example | ||
| node_modules | ||
| artifacts/ | ||
| cache/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
| import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
| import "@openzeppelin/contracts/interfaces/IERC777Recipient.sol"; | ||
| import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol"; | ||
| import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; | ||
|
|
||
| contract RootstockUsdtSwap is IERC777Recipient, ReentrancyGuard { | ||
| using SafeERC20 for IERC20; | ||
|
|
||
| address public immutable rusdtReceiver; | ||
| address public immutable usdt0Provider; | ||
| address public immutable rescuer; | ||
| IERC20 public constant RUSDT = IERC20(0xef213441A85dF4d7ACbDaE0Cf78004e1E486bB96); | ||
| IERC20 public constant USDT0 = IERC20(0x779Ded0c9e1022225f8E0630b35a9b54bE713736); | ||
| IERC1820Registry private constant _ERC1820_REGISTRY = | ||
| IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24); | ||
| // keccak256("ERC777TokensRecipient") = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b | ||
| bytes32 private constant TOKENS_RECIPIENT_INTERFACE_HASH = | ||
| 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b; | ||
|
|
||
| /** | ||
| * @notice Emitted when non-swap tokens are rescued from the contract. | ||
| * @param token The address of the rescued token. | ||
| * @param amount The amount of tokens rescued. | ||
| */ | ||
| event Rescue(address indexed token, uint256 amount); | ||
|
|
||
| /** | ||
| * @notice Deploys the USDT swap contract with required addresses. | ||
| * @param _rusdtReceiver Address to receive RUSDT tokens swapped for USDT0 | ||
| * @param _usdt0Provider Address providing USDT0 tokens for swap from RUSDT. | ||
| * @param _rescuer Address allowed to rescue non-swap tokens. | ||
| */ | ||
| constructor(address _rusdtReceiver, address _usdt0Provider, address _rescuer) { | ||
| require( | ||
| _rusdtReceiver != address(0) && _usdt0Provider != address(0) && _rescuer != address(0), | ||
| "Recipient/provider/rescuer is zero" | ||
| ); | ||
| rusdtReceiver = _rusdtReceiver; | ||
| usdt0Provider = _usdt0Provider; | ||
| rescuer = _rescuer; | ||
| _ERC1820_REGISTRY.setInterfaceImplementer( | ||
| address(this), | ||
| TOKENS_RECIPIENT_INTERFACE_HASH, | ||
| address(this) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @notice ERC777 recipient hook. Swaps RUSDT for USDT0 1:1 when RUSDT is sent to this contract. | ||
| * @dev Only accepts RUSDT tokens. Transfers RUSDT to receiver and USDT0 from provider to sender. | ||
| * Reverts if not enough allowance or balance, or if transfer fails. Enforces invariant. | ||
| * @param from The address sending RUSDT. | ||
| * @param to The address receiving RUSDT (should be this contract). | ||
| * @param amount Amount of RUSDT received and USDT0 to send. | ||
| */ | ||
| function tokensReceived( | ||
| address /*operator*/, | ||
| address from, | ||
| address to, | ||
| uint256 amount, | ||
| bytes calldata /*userData*/, | ||
| bytes calldata /*operatorData*/ | ||
| ) external override nonReentrant { | ||
| require(msg.sender == address(RUSDT), "Only RUSDT accepted"); | ||
| require(to == address(this), "Tokens not sent to contract"); | ||
| require(amount > 0, "Amount must be > 0"); | ||
| // Convert RUSDT (18 decimals) to USDT0 (6 decimals), rounding down | ||
| uint256 usdt0Amount = amount / 1e12; | ||
| require(usdt0Amount > 0, "Amount too small for USDT0"); | ||
| // Transfer USDT0 from provider to sender | ||
| uint256 allowance = USDT0.allowance(usdt0Provider, address(this)); | ||
| require(allowance >= usdt0Amount, "USDT0 allowance too low"); | ||
| uint256 providerBalance = USDT0.balanceOf(usdt0Provider); | ||
| require(providerBalance >= usdt0Amount, "USDT0 provider balance too low"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not required, since we will check the below transfer whether it's failed or not.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is just to revert early in case there is not enough balance or allowance and for clear revert msg. |
||
| bool success = USDT0.transferFrom(usdt0Provider, from, usdt0Amount); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better use SafeERC20 library here
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SafeERC20 is used to cover cases with not fully compatible ERC20, like USDT on Ethereum transfer function doesn't return bool which would at most lead to revert of the whole swap. |
||
| require(success, "USDT0 transferFrom failed"); | ||
| // Transfer RUSDT to receiver | ||
| RUSDT.safeTransfer(rusdtReceiver, amount); | ||
| // Invariant: contract should not hold RUSDT or USDT0 | ||
| require(RUSDT.balanceOf(address(this)) == 0, "RUSDT left on contract"); | ||
| // USDT0 doesn't implement ERC777 recipient hook, | ||
| // can be sent to the contract directly and then rescued | ||
| } | ||
|
|
||
| /** | ||
| * @notice Rescue any non-swap tokens accidentally sent to this contract. | ||
| * @dev Only callable by the rescuer address. Cannot rescue RUSDT or USDT0. | ||
| * @param token The address of the token to rescue. | ||
| */ | ||
| function rescue(address token) external nonReentrant { | ||
| require(msg.sender == rescuer, "Not rescuer"); | ||
| require(token != address(RUSDT), "Cannot rescue RUSDT token"); | ||
| uint256 bal = IERC20(token).balanceOf(address(this)); | ||
| if (bal > 0) { | ||
| IERC20(token).safeTransfer(rescuer, bal); | ||
| emit Rescue(token, bal); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @notice Prevent receiving native tokens (ETH/rBTC). | ||
| */ | ||
| receive() external payable { | ||
| revert("No native tokens accepted"); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Prevent fallback calls. | ||
| */ | ||
| fallback() external payable { | ||
| revert("No fallback calls"); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| // how to use | ||
| // $ forge script script/DeployRootstockUsdtSwap.s.sol:DeployRootstockUsdtSwap --sig "run(address,address,address)" <rusdtReceiver> <usdt0Provider> <rescuer> --broadcast | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import "forge-std/Script.sol"; | ||
| import { RootstockUsdtSwap } from "../contracts/utils/RootstockUsdtSwap.sol"; | ||
|
|
||
| contract DeployRootstockUsdtSwap is Script { | ||
| function run(address rusdtReceiver, address usdt0Provider, address rescuer) external { | ||
| vm.startBroadcast(); | ||
| new RootstockUsdtSwap(rusdtReceiver, usdt0Provider, rescuer); | ||
| vm.stopBroadcast(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| // Minimal mock for ERC1820Registry | ||
| contract MockERC1820Registry { | ||
| function setInterfaceImplementer(address, bytes32, address) external {} | ||
| } | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import "forge-std/Test.sol"; | ||
| import "../contracts/utils/RootstockUsdtSwap.sol"; | ||
|
|
||
| contract MockERC20 is IERC20 { | ||
| string public name; | ||
| string public symbol; | ||
| uint8 public decimals; | ||
| uint256 public override totalSupply; | ||
| mapping(address => uint256) public override balanceOf; | ||
| mapping(address => mapping(address => uint256)) public override allowance; | ||
|
|
||
| constructor(string memory _name, string memory _symbol, uint8 _decimals) { | ||
| name = _name; | ||
| symbol = _symbol; | ||
| decimals = _decimals; | ||
| } | ||
|
|
||
| function transfer(address to, uint256 amount) external override returns (bool) { | ||
| require(balanceOf[msg.sender] >= amount, "Insufficient balance"); | ||
| balanceOf[msg.sender] -= amount; | ||
| balanceOf[to] += amount; | ||
| emit Transfer(msg.sender, to, amount); | ||
| return true; | ||
| } | ||
|
|
||
| function approve(address spender, uint256 amount) external override returns (bool) { | ||
| allowance[msg.sender][spender] = amount; | ||
| emit Approval(msg.sender, spender, amount); | ||
| return true; | ||
| } | ||
|
|
||
| function transferFrom( | ||
| address from, | ||
| address to, | ||
| uint256 amount | ||
| ) external override returns (bool) { | ||
| require(balanceOf[from] >= amount, "Insufficient balance"); | ||
| require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); | ||
| allowance[from][msg.sender] -= amount; | ||
| balanceOf[from] -= amount; | ||
| balanceOf[to] += amount; | ||
| emit Transfer(from, to, amount); | ||
| return true; | ||
| } | ||
|
|
||
| function mint(address to, uint256 amount) external { | ||
| balanceOf[to] += amount; | ||
| totalSupply += amount; | ||
| emit Transfer(address(0), to, amount); | ||
| } | ||
|
|
||
| function setBalance(address account, uint256 amount) external { | ||
| balanceOf[account] = amount; | ||
| } | ||
| } | ||
|
|
||
| contract RootstockUsdtSwapTest is Test { | ||
| RootstockUsdtSwap swap; | ||
| MockERC20 rusdt; | ||
| MockERC20 usdt0; | ||
| address rusdtReceiver = address(0x111); | ||
| address usdt0Provider = address(0x222); | ||
| address rescuer = address(0x333); | ||
| address user = address(0x444); | ||
| address otherToken = address(0x555); | ||
| address RUSDT_ADDR = 0xef213441A85dF4d7ACbDaE0Cf78004e1E486bB96; | ||
| address USDT0_ADDR = 0x779Ded0c9e1022225f8E0630b35a9b54bE713736; | ||
| address erc1820RegistryAddr = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24; | ||
|
|
||
| function setUp() public { | ||
| // Deploy mock ERC1820Registry at the required address | ||
| MockERC1820Registry mockRegistry = new MockERC1820Registry(); | ||
| bytes memory registryCode = address(mockRegistry).code; | ||
| vm.etch(erc1820RegistryAddr, registryCode); | ||
|
|
||
| // Deploy mock RUSDT at the required address | ||
| MockERC20 mockRusdt = new MockERC20("RUSDT", "RUSDT", 18); | ||
| bytes memory rusdtCode = address(mockRusdt).code; | ||
| vm.etch(RUSDT_ADDR, rusdtCode); | ||
| rusdt = MockERC20(RUSDT_ADDR); | ||
|
|
||
| // Deploy mock USDT0 at the required address | ||
|
|
||
| MockERC20 mockUsdt0 = new MockERC20("USDT0", "USDT0", 6); | ||
| bytes memory usdt0Code = address(mockUsdt0).code; | ||
| vm.etch(USDT0_ADDR, usdt0Code); | ||
| usdt0 = MockERC20(USDT0_ADDR); | ||
|
|
||
| swap = new RootstockUsdtSwap(rusdtReceiver, usdt0Provider, rescuer); | ||
| // Set balances | ||
| rusdt.mint(user, 1000e18); | ||
| usdt0.mint(usdt0Provider, 1000e18); | ||
| // Approve swap contract | ||
| vm.prank(usdt0Provider); | ||
| usdt0.approve(address(swap), 1000e18); | ||
| } | ||
|
|
||
| function testSwapSuccess() public { | ||
| // Simulate ERC777 tokensReceived call | ||
| // Give swap contract RUSDT for transfer | ||
| vm.prank(user); | ||
| rusdt.transfer(address(swap), 100e18); | ||
|
|
||
| // Simulate ERC777 tokensReceived call | ||
| vm.prank(RUSDT_ADDR); | ||
| swap.tokensReceived(address(0), user, address(swap), 100e18, "", ""); | ||
| // RUSDT sent to receiver | ||
| assertEq(rusdt.balanceOf(rusdtReceiver), 100e18); | ||
| // USDT0 sent to user (should match decimals: 100e6) | ||
| assertEq(usdt0.balanceOf(user), 100e6); | ||
| // Invariant: no RUSDT or USDT0 left on contract | ||
| assertEq(rusdt.balanceOf(address(swap)), 0); | ||
| assertEq(usdt0.balanceOf(address(swap)), 0); | ||
| } | ||
|
|
||
| function testSwapFailsIfNotRUSDT() public { | ||
| vm.expectRevert("Only RUSDT accepted"); | ||
| vm.prank(address(usdt0)); | ||
| swap.tokensReceived(address(0), user, address(swap), 100e18, "", ""); | ||
| } | ||
|
|
||
| function testSwapFailsIfToNotContract() public { | ||
| vm.expectRevert("Tokens not sent to contract"); | ||
| vm.prank(address(rusdt)); | ||
| swap.tokensReceived(address(0), user, address(0x999), 100e18, "", ""); | ||
| } | ||
|
|
||
| function testSwapFailsIfAmountZero() public { | ||
| vm.expectRevert("Amount must be > 0"); | ||
| vm.prank(address(rusdt)); | ||
| swap.tokensReceived(address(0), user, address(swap), 0, "", ""); | ||
| } | ||
|
|
||
| function testSwapFailsIfAllowanceLow() public { | ||
| vm.prank(usdt0Provider); | ||
| usdt0.approve(address(swap), 50e6); // USDT0 has 6 decimals | ||
| vm.expectRevert("USDT0 allowance too low"); | ||
| vm.prank(address(rusdt)); | ||
| swap.tokensReceived(address(0), user, address(swap), 100e18, "", ""); | ||
| } | ||
|
|
||
| function testSwapFailsIfProviderBalanceLow() public { | ||
| vm.prank(usdt0Provider); | ||
| usdt0.approve(address(swap), 1000e6); // USDT0 has 6 decimals | ||
| usdt0.setBalance(usdt0Provider, 50e6); | ||
| vm.expectRevert("USDT0 provider balance too low"); | ||
| vm.prank(address(rusdt)); | ||
| swap.tokensReceived(address(0), user, address(swap), 100e18, "", ""); | ||
| } | ||
|
|
||
| function testSwapFailsIfTransferFromFails() public { | ||
| // Remove allowance | ||
| vm.prank(usdt0Provider); | ||
| usdt0.approve(address(swap), 0); | ||
| vm.expectRevert("USDT0 allowance too low"); | ||
| vm.prank(address(rusdt)); | ||
| swap.tokensReceived(address(0), user, address(swap), 100e18, "", ""); | ||
| } | ||
|
|
||
| function testRescueOtherToken() public { | ||
| MockERC20 other = new MockERC20("OTHER", "OTHER", 18); | ||
| other.mint(address(swap), 123e18); | ||
| vm.prank(rescuer); | ||
| swap.rescue(address(other)); | ||
| assertEq(other.balanceOf(rescuer), 123e18); | ||
| } | ||
|
|
||
| function testRescueFailsIfNotRescuer() public { | ||
| MockERC20 other = new MockERC20("OTHER", "OTHER", 18); | ||
| other.mint(address(swap), 123e18); | ||
| vm.expectRevert("Not rescuer"); | ||
| vm.prank(user); | ||
| swap.rescue(address(other)); | ||
| } | ||
|
|
||
| function testRescueFailsForSwapTokens() public { | ||
| vm.expectRevert("Cannot rescue RUSDT token"); | ||
| vm.prank(rescuer); | ||
| swap.rescue(address(rusdt)); | ||
| // USDT0 can only be rescued if it was sent directly, otherwise revert is not triggered | ||
| // so we only test the revert for RUSDT, as per contract logic | ||
| } | ||
|
|
||
| function testRescueUsdt0DirectlySent() public { | ||
| // Simulate sending USDT0 directly to the swap contract (no ERC777 hook) | ||
| usdt0.mint(address(swap), 42e6); // USDT0 has 6 decimals | ||
| // Rescuer should be able to rescue it | ||
| vm.prank(rescuer); | ||
| swap.rescue(address(usdt0)); | ||
| assertEq(usdt0.balanceOf(rescuer), 42e6); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.