Skip to content

Commit 77db719

Browse files
yjaminyorhodes
andauthored
fix(solidity): scope Tron SafeERC20 workaround to USDT address only (#8467)
Co-authored-by: Yorke Rhodes <yorke@hyperlane.xyz>
1 parent 111d6fa commit 77db719

9 files changed

Lines changed: 395 additions & 17 deletions

File tree

.changeset/cuddly-meals-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperlane-xyz/core": patch
3+
---
4+
5+
Fixed SafeERC20.sol operations for Tron

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ solidity/artifacts/
1818
solidity/cache/
1919
solidity/core-utils/typechain/
2020
solidity/lib/
21+
solidity/overrides/
2122
tools/
2223
typescript/cli/configs/
2324
typescript/helloworld/artifacts/

solidity/build-tron.sh

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,57 @@ rm -rf ./cache-tron ./artifacts-tron ./dist/tron/typechain
1111
# gracefully since deps may already be present (e.g. forge v1.1.0 soldeer bug).
1212
forge soldeer install --quiet || echo "Warning: soldeer install failed, assuming dependencies are already present"
1313

14-
OZ_CREATE2="dependencies/@openzeppelin-contracts-4.9.3/contracts/utils/Create2.sol"
14+
# Files to patch for Tron compatibility (newline-separated, dest:src)
15+
PATCH_FILES="dependencies/@openzeppelin-contracts-4.9.3/contracts/utils/Create2.sol:overrides/tron/Create2.sol
16+
dependencies/@openzeppelin-contracts-4.9.3/contracts/token/ERC20/utils/SafeERC20.sol:overrides/tron/SafeERC20.sol"
1517

16-
# Collect all .sol files that use isContract (contracts + dependencies)
17-
ISCONTRACT_FILES=$(grep -rl '\.isContract\b' contracts/ dependencies/ --include='*.sol' || true)
18+
# Iterate PATCH_FILES and run a command on each entry.
19+
# Usage: for_each_patch cmd → cmd is called with each "dest:src" line as $1
20+
for_each_patch() {
21+
_saved_ifs="$IFS"; IFS="
22+
"
23+
for _entry in $PATCH_FILES; do
24+
"$@" "$_entry"
25+
done
26+
IFS="$_saved_ifs"
27+
}
28+
29+
# Collect all .sol files that use isContract (contracts + dependencies),
30+
# excluding files already handled by PATCH_FILES to avoid double-backup.
31+
PATCH_DESTS=""
32+
_collect_dest() { PATCH_DESTS="$PATCH_DESTS ${1%%:*}"; }
33+
for_each_patch _collect_dest
34+
35+
ISCONTRACT_FILES=""
36+
for f in $(grep -rl '\.isContract\b' contracts/ dependencies/ --include='*.sol' || true); do
37+
case "$PATCH_DESTS" in
38+
*"$f"*) ;; # skip files already in PATCH_FILES
39+
*) ISCONTRACT_FILES="$ISCONTRACT_FILES $f" ;;
40+
esac
41+
done
42+
43+
# Backup / restore helpers
44+
_backup_patch() { cp "${1%%:*}" "${1%%:*}.bak"; }
45+
_restore_patch() { mv "${1%%:*}.bak" "${1%%:*}"; }
46+
_apply_patch() { cp "${1##*:}" "${1%%:*}"; }
1847

19-
# Backup all files we'll modify
2048
backup_files() {
21-
cp "$OZ_CREATE2" "$OZ_CREATE2.bak"
22-
for f in $ISCONTRACT_FILES; do
23-
cp "$f" "$f.bak"
24-
done
49+
for_each_patch _backup_patch
50+
for f in $ISCONTRACT_FILES; do cp "$f" "$f.bak"; done
2551
}
2652

27-
# Restore all files from backups
2853
restore_files() {
29-
mv "$OZ_CREATE2.bak" "$OZ_CREATE2"
30-
for f in $ISCONTRACT_FILES; do
31-
mv "$f.bak" "$f"
32-
done
54+
for_each_patch _restore_patch
55+
for f in $ISCONTRACT_FILES; do mv "$f.bak" "$f"; done
3356
}
3457

3558
# Ensure restoration even on failure
3659
trap restore_files EXIT
3760

3861
backup_files
3962

40-
# Patch Create2.sol with Tron-specific version (0x41 prefix)
41-
cp overrides/tron/Create2.sol "$OZ_CREATE2"
63+
# Apply Tron-specific patches (Create2.sol, SafeERC20.sol)
64+
for_each_patch _apply_patch
4265

4366
# Patch isContract() calls → address.code.length > 0
4467
# Uses Node script to handle nested parentheses correctly
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
pragma solidity >=0.8.0;
3+
4+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
6+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7+
8+
/// @dev Mock that mimics Tron USDT: transfer() succeeds but returns false.
9+
contract TronUSDTMock {
10+
mapping(address => uint256) public balances;
11+
12+
function mint(address to, uint256 amount) external {
13+
balances[to] += amount;
14+
}
15+
16+
function transfer(address to, uint256 value) external returns (bool) {
17+
require(balances[msg.sender] >= value, "insufficient balance");
18+
balances[msg.sender] -= value;
19+
balances[to] += value;
20+
return false;
21+
}
22+
23+
function transferFrom(
24+
address from,
25+
address to,
26+
uint256 value
27+
) external returns (bool) {
28+
require(balances[from] >= value, "insufficient balance");
29+
balances[from] -= value;
30+
balances[to] += value;
31+
return true;
32+
}
33+
34+
function balanceOf(address account) external view returns (uint256) {
35+
return balances[account];
36+
}
37+
38+
function allowance(address, address) external pure returns (uint256) {
39+
return type(uint256).max;
40+
}
41+
42+
function approve(address, uint256) external pure returns (bool) {
43+
return true;
44+
}
45+
}
46+
47+
/// @dev Mock ERC20 that returns false on transfer without reverting.
48+
contract FalseReturningERC20Mock {
49+
mapping(address => uint256) public balances;
50+
51+
function mint(address to, uint256 amount) external {
52+
balances[to] += amount;
53+
}
54+
55+
function transfer(address, uint256) external pure returns (bool) {
56+
return false;
57+
}
58+
59+
function transferFrom(
60+
address,
61+
address,
62+
uint256
63+
) external pure returns (bool) {
64+
return false;
65+
}
66+
67+
function balanceOf(address account) external view returns (uint256) {
68+
return balances[account];
69+
}
70+
71+
function allowance(address, address) external pure returns (uint256) {
72+
return type(uint256).max;
73+
}
74+
75+
function approve(address, uint256) external pure returns (bool) {
76+
return true;
77+
}
78+
}
79+
80+
/// @dev Harness that exposes the Tron-patched SafeERC20 functions for testing.
81+
contract SafeERC20Harness {
82+
using SafeERC20 for IERC20;
83+
84+
function safeTransfer(IERC20 token, address to, uint256 value) external {
85+
token.safeTransfer(to, value);
86+
}
87+
88+
function safeTransferFrom(
89+
IERC20 token,
90+
address from,
91+
address to,
92+
uint256 value
93+
) external {
94+
token.safeTransferFrom(from, to, value);
95+
}
96+
}

solidity/foundry.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ optimizer_runs = 200
3131
out = 'out-tron'
3232
cache_path = 'forge-cache-tron'
3333
remappings = [
34+
"@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol=overrides/tron/SafeERC20.sol",
35+
"dependencies/@openzeppelin-contracts-4.9.3/contracts/token/ERC20/utils/SafeERC20.sol=overrides/tron/SafeERC20.sol",
3436
"@openzeppelin/contracts/utils/Create2.sol=overrides/tron/Create2.sol",
3537
"@openzeppelin/contracts/=dependencies/@openzeppelin-contracts-4.9.3/contracts/",
3638
"@openzeppelin/contracts-upgradeable/=dependencies/@openzeppelin-contracts-upgradeable-4.9.3/contracts/",
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: MIT
2+
// OpenZeppelin Contracts (last updated v4.9.3) (token/ERC20/utils/SafeERC20.sol)
3+
4+
pragma solidity ^0.8.0;
5+
6+
// ===== BEGIN TRON OVERRIDE =====
7+
// Modified for Tron: safeTransfer bypasses return-value check for Tron USDT only.
8+
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
9+
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
10+
import "@openzeppelin/contracts/utils/Address.sol";
11+
12+
/**
13+
* @title SafeERC20
14+
* @dev Wrappers around ERC20 operations that throw on failure (when the token
15+
* contract returns false). Tokens that return no value (and instead revert or
16+
* throw on failure) are also supported, non-reverting calls are assumed to be
17+
* successful.
18+
* To use this library you can add a `using SafeERC20 for IERC20;` statement to your contract,
19+
* which allows you to call the safe operations as `token.safeTransfer(...)`, etc.
20+
*/
21+
library SafeERC20 {
22+
using Address for address;
23+
// Tron USDT (TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t)
24+
// transfer() returns false despite success because the source code discards
25+
// super.transfer()'s return value without its own `return true`.
26+
// See: https://gist.github.com/yorhodes/a6eccbeba27ff76355c3d761e84d6a35
27+
address private constant TRON_USDT = 0xa614f803B6FD780986A42c78Ec9c7f77e6DeD13C;
28+
29+
/**
30+
* @dev Transfer `value` amount of `token` from the calling contract to `to`. If `token` returns no value,
31+
* non-reverting calls are assumed to be successful.
32+
*
33+
* For Tron USDT specifically, the return value is ignored because its transfer()
34+
* returns false on success due to a missing `return true` in the source code.
35+
*/
36+
function safeTransfer(IERC20 token, address to, uint256 value) internal {
37+
if (address(token) == TRON_USDT) {
38+
(bool success, ) = address(token).call(abi.encodeWithSelector(token.transfer.selector, to, value));
39+
require(success, "SafeERC20: ERC20 transfer failed");
40+
return;
41+
}
42+
// ===== END TRON OVERRIDE =====
43+
_callOptionalReturn(token, abi.encodeWithSelector(token.transfer.selector, to, value));
44+
}
45+
46+
/**
47+
* @dev Transfer `value` amount of `token` from `from` to `to`, spending the approval given by `from` to the
48+
* calling contract. If `token` returns no value, non-reverting calls are assumed to be successful.
49+
*/
50+
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
51+
_callOptionalReturn(token, abi.encodeWithSelector(token.transferFrom.selector, from, to, value));
52+
}
53+
54+
/**
55+
* @dev Deprecated. This function has issues similar to the ones found in
56+
* {IERC20-approve}, and its usage is discouraged.
57+
*
58+
* Whenever possible, use {safeIncreaseAllowance} and
59+
* {safeDecreaseAllowance} instead.
60+
*/
61+
function safeApprove(IERC20 token, address spender, uint256 value) internal {
62+
// safeApprove should only be called when setting an initial allowance,
63+
// or when resetting it to zero. To increase and decrease it, use
64+
// 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
65+
require(
66+
(value == 0) || (token.allowance(address(this), spender) == 0),
67+
"SafeERC20: approve from non-zero to non-zero allowance"
68+
);
69+
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
70+
}
71+
72+
/**
73+
* @dev Increase the calling contract's allowance toward `spender` by `value`. If `token` returns no value,
74+
* non-reverting calls are assumed to be successful.
75+
*/
76+
function safeIncreaseAllowance(IERC20 token, address spender, uint256 value) internal {
77+
uint256 oldAllowance = token.allowance(address(this), spender);
78+
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, oldAllowance + value));
79+
}
80+
81+
/**
82+
* @dev Decrease the calling contract's allowance toward `spender` by `value`. If `token` returns no value,
83+
* non-reverting calls are assumed to be successful.
84+
*/
85+
function safeDecreaseAllowance(IERC20 token, address spender, uint256 value) internal {
86+
unchecked {
87+
uint256 oldAllowance = token.allowance(address(this), spender);
88+
require(oldAllowance >= value, "SafeERC20: decreased allowance below zero");
89+
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, oldAllowance - value));
90+
}
91+
}
92+
93+
/**
94+
* @dev Set the calling contract's allowance toward `spender` to `value`. If `token` returns no value,
95+
* non-reverting calls are assumed to be successful. Meant to be used with tokens that require the approval
96+
* to be set to zero before setting it to a non-zero value, such as USDT.
97+
*/
98+
function forceApprove(IERC20 token, address spender, uint256 value) internal {
99+
bytes memory approvalCall = abi.encodeWithSelector(token.approve.selector, spender, value);
100+
101+
if (!_callOptionalReturnBool(token, approvalCall)) {
102+
_callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, 0));
103+
_callOptionalReturn(token, approvalCall);
104+
}
105+
}
106+
107+
/**
108+
* @dev Use a ERC-2612 signature to set the `owner` approval toward `spender` on `token`.
109+
* Revert on invalid signature.
110+
*/
111+
function safePermit(
112+
IERC20Permit token,
113+
address owner,
114+
address spender,
115+
uint256 value,
116+
uint256 deadline,
117+
uint8 v,
118+
bytes32 r,
119+
bytes32 s
120+
) internal {
121+
uint256 nonceBefore = token.nonces(owner);
122+
token.permit(owner, spender, value, deadline, v, r, s);
123+
uint256 nonceAfter = token.nonces(owner);
124+
require(nonceAfter == nonceBefore + 1, "SafeERC20: permit did not succeed");
125+
}
126+
127+
/**
128+
* @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
129+
* on the return value: the return value is optional (but if data is returned, it must not be false).
130+
* @param token The token targeted by the call.
131+
* @param data The call data (encoded using abi.encode or one of its variants).
132+
*/
133+
function _callOptionalReturn(IERC20 token, bytes memory data) private {
134+
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
135+
// we're implementing it ourselves. We use {Address-functionCall} to perform this call, which verifies that
136+
// the target address contains contract code and also asserts for success in the low-level call.
137+
138+
bytes memory returndata = address(token).functionCall(data, "SafeERC20: low-level call failed");
139+
require(returndata.length == 0 || abi.decode(returndata, (bool)), "SafeERC20: ERC20 operation did not succeed");
140+
}
141+
142+
/**
143+
* @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
144+
* on the return value: the return value is optional (but if data is returned, it must not be false).
145+
* @param token The token targeted by the call.
146+
* @param data The call data (encoded using abi.encode or one of its variants).
147+
*
148+
* This is a variant of {_callOptionalReturn} that silents catches all reverts and returns a bool instead.
149+
*/
150+
function _callOptionalReturnBool(IERC20 token, bytes memory data) private returns (bool) {
151+
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
152+
// we're implementing it ourselves. We cannot use {Address-functionCall} here since this should return false
153+
// and not revert is the subcall reverts.
154+
155+
(bool success, bytes memory returndata) = address(token).call(data);
156+
return
157+
success && (returndata.length == 0 || abi.decode(returndata, (bool))) && Address.isContract(address(token));
158+
}
159+
}

solidity/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
"build": "pnpm version:update && pnpm hardhat-esm compile && tsc && node fix-typechain-ethers.mjs ./dist/typechain/factories && ./exportBuildArtifact.sh",
7676
"build:zk": "pnpm hardhat-zk compile && tsc && node fix-typechain-ethers.mjs ./dist/typechain/factories && node generate-artifact-exports.mjs && ZKSYNC=true ./exportBuildArtifact.sh",
7777
"build:tron": "./build-tron.sh",
78-
"test:tron": "FOUNDRY_PROFILE=tron forge test --match-contract TronCreate2Test -vvv",
78+
"test:tron": "FOUNDRY_PROFILE=tron forge test --match-contract 'Tron(Create2|SafeERC20)Test' -vvv && bash test/tron/check-SafeERC20-diff.sh",
7979
"prepublishOnly": "pnpm build && pnpm build:zk && pnpm build:tron",
8080
"lint": "solhint contracts/**/*.sol && eslint -c ./eslint.config.mjs",
8181
"clean": "pnpm hardhat-esm clean && pnpm hardhat-zk clean && rm -rf ./dist ./cache ./cache-zk ./cache-tron ./artifacts-tron ./out-tron ./forge-cache-tron ./types ./coverage ./out ./forge-cache ./fixtures",
@@ -89,7 +89,7 @@
8989
"test": "pnpm version:exhaustive && pnpm hardhat-esm test && pnpm test:forge",
9090
"test:hardhat": "pnpm hardhat-esm test",
9191
"test:forge": "pnpm fixtures && forge test -vvv --decode-internal --no-match-contract 'Everclear|Tron'",
92-
"test:ci": "pnpm version:changed && pnpm test:hardhat && pnpm test:forge --no-match-test testFork",
92+
"test:ci": "pnpm version:changed && pnpm test:hardhat && pnpm test:forge --no-match-test testFork && pnpm test:tron",
9393
"test:fork": "sh -c '. ./.env.default && forge test --match-test testFork && forge test --match-contract ForkTest'",
9494
"gas": "forge snapshot",
9595
"gas-ci": "pnpm gas --check --tolerance 2 || (echo 'Manually update gas snapshot' && exit 1)",

0 commit comments

Comments
 (0)