From d7f571f222f92bd935cfefe218f651850c332f6b Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Wed, 26 Nov 2025 01:39:00 +0300 Subject: [PATCH 1/7] initial contract implementation --- contracts/token/UsdtSwap.sol | 113 +++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 contracts/token/UsdtSwap.sol diff --git a/contracts/token/UsdtSwap.sol b/contracts/token/UsdtSwap.sol new file mode 100644 index 000000000..747bd96a5 --- /dev/null +++ b/contracts/token/UsdtSwap.sol @@ -0,0 +1,113 @@ +// 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 UsdtSwap 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"); + // Transfer USDT0 from provider to sender + uint256 allowance = USDT0.allowance(usdt0Provider, address(this)); + require(allowance >= amount, "USDT0 allowance too low"); + uint256 providerBalance = USDT0.balanceOf(usdt0Provider); + require(providerBalance >= amount, "USDT0 provider balance too low"); + bool success = USDT0.transferFrom(usdt0Provider, from, amount); + 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"); + require(USDT0.balanceOf(address(this)) == 0, "USDT0 left on contract"); + } + + /** + * @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) && token != address(USDT0), "Cannot rescue swap tokens"); + 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"); + } +} From 8c8642bc93b92ac069813e32cfab00a7b1cddcab Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Wed, 26 Nov 2025 03:39:21 +0300 Subject: [PATCH 2/7] add deployment script, rename contract --- .../{UsdtSwap.sol => RootstockUsdtSwap.sol} | 6 +- script/DeployRootstockUsdtSwap.s.sol | 18 ++ tests-foundry/RootstockUsdtSwap.t.sol | 192 ++++++++++++++++++ 3 files changed, 213 insertions(+), 3 deletions(-) rename contracts/token/{UsdtSwap.sol => RootstockUsdtSwap.sol} (95%) create mode 100644 script/DeployRootstockUsdtSwap.s.sol create mode 100644 tests-foundry/RootstockUsdtSwap.t.sol diff --git a/contracts/token/UsdtSwap.sol b/contracts/token/RootstockUsdtSwap.sol similarity index 95% rename from contracts/token/UsdtSwap.sol rename to contracts/token/RootstockUsdtSwap.sol index 747bd96a5..b4f27d645 100644 --- a/contracts/token/UsdtSwap.sol +++ b/contracts/token/RootstockUsdtSwap.sol @@ -7,14 +7,14 @@ import "@openzeppelin/contracts/interfaces/IERC777Recipient.sol"; import "@openzeppelin/contracts/utils/introspection/IERC1820Registry.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; -contract UsdtSwap is IERC777Recipient, ReentrancyGuard { +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); + IERC20 public constant RUSDT = IERC20(0xef213441A85dF4d7ACbDaE0Cf78004e1E486bB96); + IERC20 public constant USDT0 = IERC20(0x779Ded0c9e1022225f8E0630b35a9b54bE713736); IERC1820Registry private constant _ERC1820_REGISTRY = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24); // keccak256("ERC777TokensRecipient") = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b diff --git a/script/DeployRootstockUsdtSwap.s.sol b/script/DeployRootstockUsdtSwap.s.sol new file mode 100644 index 000000000..dca6b0050 --- /dev/null +++ b/script/DeployRootstockUsdtSwap.s.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "forge-std/Script.sol"; +import { RootstockUsdtSwap } from "../contracts/token/RootstockUsdtSwap.sol"; + +contract DeployRootstockUsdtSwap is Script { + function run() external { + // Set your deployment addresses here + address rusdtReceiver = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + address usdt0Provider = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address rescuer = 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65; + + vm.startBroadcast(); + new RootstockUsdtSwap(rusdtReceiver, usdt0Provider, rescuer); + vm.stopBroadcast(); + } +} diff --git a/tests-foundry/RootstockUsdtSwap.t.sol b/tests-foundry/RootstockUsdtSwap.t.sol new file mode 100644 index 000000000..c8f66999f --- /dev/null +++ b/tests-foundry/RootstockUsdtSwap.t.sol @@ -0,0 +1,192 @@ +// Minimal mock for ERC1820Registry +contract MockERC1820Registry { + function setInterfaceImplementer(address, bytes32, address) external {} +} +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "forge-std/Test.sol"; +import "../contracts/token/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 registryAddr = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24; + + function setUp() public { + // Deploy mock ERC1820Registry at the required address + MockERC1820Registry mockRegistry = new MockERC1820Registry(); + bytes memory registryCode = address(mockRegistry).code; + vm.etch(registryAddr, 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", 18); + 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 + //vm.prank(RUSDT_ADDR); + // 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 + assertEq(usdt0.balanceOf(user), 100e18); + // 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), 50e18); + 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), 1000e18); + usdt0.setBalance(usdt0Provider, 50e18); + 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 swap tokens"); + vm.prank(rescuer); + swap.rescue(address(rusdt)); + vm.expectRevert("Cannot rescue swap tokens"); + vm.prank(rescuer); + swap.rescue(address(usdt0)); + } +} From afdd252fb242394ea230508227a5fb317be45e08 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Wed, 26 Nov 2025 03:44:07 +0300 Subject: [PATCH 3/7] move RootstockUsdtSwap to utils folder --- contracts/{token => utils}/RootstockUsdtSwap.sol | 0 script/DeployRootstockUsdtSwap.s.sol | 2 +- tests-foundry/RootstockUsdtSwap.t.sol | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename contracts/{token => utils}/RootstockUsdtSwap.sol (100%) diff --git a/contracts/token/RootstockUsdtSwap.sol b/contracts/utils/RootstockUsdtSwap.sol similarity index 100% rename from contracts/token/RootstockUsdtSwap.sol rename to contracts/utils/RootstockUsdtSwap.sol diff --git a/script/DeployRootstockUsdtSwap.s.sol b/script/DeployRootstockUsdtSwap.s.sol index dca6b0050..900779eee 100644 --- a/script/DeployRootstockUsdtSwap.s.sol +++ b/script/DeployRootstockUsdtSwap.s.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.0; import "forge-std/Script.sol"; -import { RootstockUsdtSwap } from "../contracts/token/RootstockUsdtSwap.sol"; +import { RootstockUsdtSwap } from "../contracts/utils/RootstockUsdtSwap.sol"; contract DeployRootstockUsdtSwap is Script { function run() external { diff --git a/tests-foundry/RootstockUsdtSwap.t.sol b/tests-foundry/RootstockUsdtSwap.t.sol index c8f66999f..779281879 100644 --- a/tests-foundry/RootstockUsdtSwap.t.sol +++ b/tests-foundry/RootstockUsdtSwap.t.sol @@ -6,7 +6,7 @@ contract MockERC1820Registry { pragma solidity ^0.8.0; import "forge-std/Test.sol"; -import "../contracts/token/RootstockUsdtSwap.sol"; +import "../contracts/utils/RootstockUsdtSwap.sol"; contract MockERC20 is IERC20 { string public name; From f4d4d0fe7ac51999554bc4b6ab92c31e5341b414 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Wed, 26 Nov 2025 04:19:28 +0300 Subject: [PATCH 4/7] USDT swap: fix decimals diff RUSDT 18, USDT0 6 --- contracts/utils/RootstockUsdtSwap.sol | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/contracts/utils/RootstockUsdtSwap.sol b/contracts/utils/RootstockUsdtSwap.sol index b4f27d645..20de7f0db 100644 --- a/contracts/utils/RootstockUsdtSwap.sol +++ b/contracts/utils/RootstockUsdtSwap.sol @@ -68,12 +68,15 @@ contract RootstockUsdtSwap is IERC777Recipient, ReentrancyGuard { 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 >= amount, "USDT0 allowance too low"); + require(allowance >= usdt0Amount, "USDT0 allowance too low"); uint256 providerBalance = USDT0.balanceOf(usdt0Provider); - require(providerBalance >= amount, "USDT0 provider balance too low"); - bool success = USDT0.transferFrom(usdt0Provider, from, amount); + require(providerBalance >= usdt0Amount, "USDT0 provider balance too low"); + bool success = USDT0.transferFrom(usdt0Provider, from, usdt0Amount); require(success, "USDT0 transferFrom failed"); // Transfer RUSDT to receiver RUSDT.safeTransfer(rusdtReceiver, amount); From 3bb1b1a21858aed5c01dec68e6899ffbea84beb7 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Thu, 27 Nov 2025 01:13:26 +0300 Subject: [PATCH 5/7] address copilot comments --- script/DeployRootstockUsdtSwap.s.sol | 9 +++------ tests-foundry/RootstockUsdtSwap.t.sol | 9 ++++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/script/DeployRootstockUsdtSwap.s.sol b/script/DeployRootstockUsdtSwap.s.sol index 900779eee..2ed242ac8 100644 --- a/script/DeployRootstockUsdtSwap.s.sol +++ b/script/DeployRootstockUsdtSwap.s.sol @@ -1,16 +1,13 @@ // SPDX-License-Identifier: MIT +// how to use +// $ forge script script/DeployRootstockUsdtSwap.s.sol:DeployRootstockUsdtSwap --sig "run(address,address,address)" --broadcast pragma solidity ^0.8.0; import "forge-std/Script.sol"; import { RootstockUsdtSwap } from "../contracts/utils/RootstockUsdtSwap.sol"; contract DeployRootstockUsdtSwap is Script { - function run() external { - // Set your deployment addresses here - address rusdtReceiver = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; - address usdt0Provider = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; - address rescuer = 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65; - + function run(address rusdtReceiver, address usdt0Provider, address rescuer) external { vm.startBroadcast(); new RootstockUsdtSwap(rusdtReceiver, usdt0Provider, rescuer); vm.stopBroadcast(); diff --git a/tests-foundry/RootstockUsdtSwap.t.sol b/tests-foundry/RootstockUsdtSwap.t.sol index 779281879..6710b2326 100644 --- a/tests-foundry/RootstockUsdtSwap.t.sol +++ b/tests-foundry/RootstockUsdtSwap.t.sol @@ -1,8 +1,8 @@ +// SPDX-License-Identifier: MIT // Minimal mock for ERC1820Registry contract MockERC1820Registry { function setInterfaceImplementer(address, bytes32, address) external {} } -// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Test.sol"; @@ -72,13 +72,13 @@ contract RootstockUsdtSwapTest is Test { address otherToken = address(0x555); address RUSDT_ADDR = 0xef213441A85dF4d7ACbDaE0Cf78004e1E486bB96; address USDT0_ADDR = 0x779Ded0c9e1022225f8E0630b35a9b54bE713736; - address registryAddr = 0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24; + 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(registryAddr, registryCode); + vm.etch(erc1820RegistryAddr, registryCode); // Deploy mock RUSDT at the required address MockERC20 mockRusdt = new MockERC20("RUSDT", "RUSDT", 18); @@ -88,7 +88,7 @@ contract RootstockUsdtSwapTest is Test { // Deploy mock USDT0 at the required address - MockERC20 mockUsdt0 = new MockERC20("USDT0", "USDT0", 18); + MockERC20 mockUsdt0 = new MockERC20("USDT0", "USDT0", 6); bytes memory usdt0Code = address(mockUsdt0).code; vm.etch(USDT0_ADDR, usdt0Code); usdt0 = MockERC20(USDT0_ADDR); @@ -104,7 +104,6 @@ contract RootstockUsdtSwapTest is Test { function testSwapSuccess() public { // Simulate ERC777 tokensReceived call - //vm.prank(RUSDT_ADDR); // Give swap contract RUSDT for transfer vm.prank(user); rusdt.transfer(address(swap), 100e18); From 1627b1308a6665ac9528f950b094f2c039e85faf Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 28 Nov 2025 21:50:32 +0000 Subject: [PATCH 6/7] script to verify a contract in a tenderly foreked network [skip ci] --- .env.example | 9 ++++++ .gitignore | 1 + foundry/foundry.toml | 8 ++++++ verify_tenderly.sh | 67 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 .env.example create mode 100755 verify_tenderly.sh diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..ddc3c21f7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 8a601ab31..789116357 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ cache .idea/ *.code-workspace .env +!.env.example node_modules artifacts/ cache/ diff --git a/foundry/foundry.toml b/foundry/foundry.toml index 25b918f9c..66b43ae1c 100644 --- a/foundry/foundry.toml +++ b/foundry/foundry.toml @@ -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" +} diff --git a/verify_tenderly.sh b/verify_tenderly.sh new file mode 100755 index 000000000..7ec4798e1 --- /dev/null +++ b/verify_tenderly.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Env file '$ENV_FILE' not found. Provide one or run: cp .env.example .env" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +source "$ENV_FILE" + +required_vars=( + TENDERLY_ACCESS_TOKEN + TENDERLY_VIRTUAL_TESTNET_RPC_URL + TENDERLY_VERIFIER_URL + TENDERLY_CHAIN_ID + CONTRACT_ADDRESS + CONTRACT_FULLY_QUALIFIED_NAME + SOLC_VERSION +) + +missing=() +for var in "${required_vars[@]}"; do + if [[ -z "${!var:-}" ]]; then + missing+=("$var") + fi +done + +if [[ ${#missing[@]} -gt 0 ]]; then + echo "Missing required env vars: ${missing[*]}. Please set them in '$ENV_FILE'." >&2 + exit 1 +fi + +echo "Verifying $CONTRACT_FULLY_QUALIFIED_NAME at $CONTRACT_ADDRESS on chain $TENDERLY_CHAIN_ID via $TENDERLY_VERIFIER_URL" + +verify_cmd=( + forge verify-contract + "$CONTRACT_ADDRESS" + "$CONTRACT_FULLY_QUALIFIED_NAME" + --etherscan-api-key "$TENDERLY_ACCESS_TOKEN" + --verifier-url "$TENDERLY_VERIFIER_URL" + --compiler-version "$SOLC_VERSION" + --chain-id "$TENDERLY_CHAIN_ID" + --watch +) + +set +e +verify_output="$("${verify_cmd[@]}" 2>&1)" +verify_status=$? +set -e + +echo "$verify_output" + +if grep -qi "already verified" <<<"$verify_output"; then + echo "Contract is already verified on Tenderly." + exit 0 +fi + +if [[ $verify_status -eq 0 ]]; then + echo "Verification submitted successfully." + exit 0 +fi + +echo "Verification failed (exit code $verify_status)." >&2 +exit "$verify_status" From 283e9fe45bd7a8c28c9d34265296b6b7000afee2 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Wed, 17 Dec 2025 18:14:20 +0300 Subject: [PATCH 7/7] fix security issue & tests --- contracts/utils/RootstockUsdtSwap.sol | 5 +++-- tests-foundry/RootstockUsdtSwap.t.sol | 22 +++++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/contracts/utils/RootstockUsdtSwap.sol b/contracts/utils/RootstockUsdtSwap.sol index 20de7f0db..8724bf936 100644 --- a/contracts/utils/RootstockUsdtSwap.sol +++ b/contracts/utils/RootstockUsdtSwap.sol @@ -82,7 +82,8 @@ contract RootstockUsdtSwap is IERC777Recipient, ReentrancyGuard { RUSDT.safeTransfer(rusdtReceiver, amount); // Invariant: contract should not hold RUSDT or USDT0 require(RUSDT.balanceOf(address(this)) == 0, "RUSDT left on contract"); - require(USDT0.balanceOf(address(this)) == 0, "USDT0 left on contract"); + // USDT0 doesn't implement ERC777 recipient hook, + // can be sent to the contract directly and then rescued } /** @@ -92,7 +93,7 @@ contract RootstockUsdtSwap is IERC777Recipient, ReentrancyGuard { */ function rescue(address token) external nonReentrant { require(msg.sender == rescuer, "Not rescuer"); - require(token != address(RUSDT) && token != address(USDT0), "Cannot rescue swap tokens"); + require(token != address(RUSDT), "Cannot rescue RUSDT token"); uint256 bal = IERC20(token).balanceOf(address(this)); if (bal > 0) { IERC20(token).safeTransfer(rescuer, bal); diff --git a/tests-foundry/RootstockUsdtSwap.t.sol b/tests-foundry/RootstockUsdtSwap.t.sol index 6710b2326..a691fc178 100644 --- a/tests-foundry/RootstockUsdtSwap.t.sol +++ b/tests-foundry/RootstockUsdtSwap.t.sol @@ -113,8 +113,8 @@ contract RootstockUsdtSwapTest is Test { swap.tokensReceived(address(0), user, address(swap), 100e18, "", ""); // RUSDT sent to receiver assertEq(rusdt.balanceOf(rusdtReceiver), 100e18); - // USDT0 sent to user - assertEq(usdt0.balanceOf(user), 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); @@ -140,7 +140,7 @@ contract RootstockUsdtSwapTest is Test { function testSwapFailsIfAllowanceLow() public { vm.prank(usdt0Provider); - usdt0.approve(address(swap), 50e18); + 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, "", ""); @@ -148,8 +148,8 @@ contract RootstockUsdtSwapTest is Test { function testSwapFailsIfProviderBalanceLow() public { vm.prank(usdt0Provider); - usdt0.approve(address(swap), 1000e18); - usdt0.setBalance(usdt0Provider, 50e18); + 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, "", ""); @@ -181,11 +181,19 @@ contract RootstockUsdtSwapTest is Test { } function testRescueFailsForSwapTokens() public { - vm.expectRevert("Cannot rescue swap tokens"); + vm.expectRevert("Cannot rescue RUSDT token"); vm.prank(rescuer); swap.rescue(address(rusdt)); - vm.expectRevert("Cannot rescue swap tokens"); + // 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); } }