Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ cache
.idea/
*.code-workspace
.env
!.env.example
node_modules
artifacts/
cache/
Expand Down
117 changes: 117 additions & 0 deletions contracts/utils/RootstockUsdtSwap.sol
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");
Copy link
Contributor

Choose a reason for hiding this comment

The 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.
But in case you want to keep this for the better revert message, then i am fine

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better use SafeERC20 library here

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
but based on the technical documentation for USDT0 on Rootstock, it is explicitly designed to be fully ERC-20 compliant, which means it is safe to call transferFrom and check its boolean return value, meaning this is a gas saving approach in this particular case.

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");
}
}
8 changes: 8 additions & 0 deletions foundry/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@
src = "src"
out = "out"
libs = ["lib"]
cbor_metadata = true

# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options

[etherscan]
unknown_chain = {
key = "${TENDERLY_ACCESS_TOKEN}",
chain = 30,
url = "${TENDERLY_VIRTUAL_TESTNET_RPC_URL}/verify/etherscan"
}
15 changes: 15 additions & 0 deletions script/DeployRootstockUsdtSwap.s.sol
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();
}
}
199 changes: 199 additions & 0 deletions tests-foundry/RootstockUsdtSwap.t.sol
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);
}
}
Loading
Loading