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/contracts/utils/RootstockUsdtSwap.sol b/contracts/utils/RootstockUsdtSwap.sol new file mode 100644 index 000000000..8724bf936 --- /dev/null +++ b/contracts/utils/RootstockUsdtSwap.sol @@ -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"); + bool success = USDT0.transferFrom(usdt0Provider, from, usdt0Amount); + 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"); + } +} 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/script/DeployRootstockUsdtSwap.s.sol b/script/DeployRootstockUsdtSwap.s.sol new file mode 100644 index 000000000..2ed242ac8 --- /dev/null +++ b/script/DeployRootstockUsdtSwap.s.sol @@ -0,0 +1,15 @@ +// 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(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 new file mode 100644 index 000000000..a691fc178 --- /dev/null +++ b/tests-foundry/RootstockUsdtSwap.t.sol @@ -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); + } +} 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"