diff --git a/.changeset/safetrc20-try-get-decimals.md b/.changeset/safetrc20-try-get-decimals.md new file mode 100644 index 0000000..bb5c8cf --- /dev/null +++ b/.changeset/safetrc20-try-get-decimals.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-tron-solidity': minor +--- + +`SafeTRC20`: Add `tryGetDecimals` helper that safely queries a token's `decimals()` without reverting. diff --git a/contracts/token/TRC20/extensions/TRC20Wrapper.sol b/contracts/token/TRC20/extensions/TRC20Wrapper.sol index 485b1fb..290fed6 100644 --- a/contracts/token/TRC20/extensions/TRC20Wrapper.sol +++ b/contracts/token/TRC20/extensions/TRC20Wrapper.sol @@ -3,8 +3,9 @@ pragma solidity ^0.8.20; -import {ITRC20, ITRC20Metadata, TRC20} from "../TRC20.sol"; +import {ITRC20, TRC20} from "../TRC20.sol"; import {SafeTRC20} from "../utils/SafeTRC20.sol"; +import {Math} from "../../../utils/math/Math.sol"; /** * @dev Extension of the TRC-20 token contract to support token wrapping. @@ -33,13 +34,16 @@ abstract contract TRC20Wrapper is TRC20 { _underlying = underlyingToken; } - /// @inheritdoc ITRC20Metadata + /** + * @dev See {ITRC20Metadata}. Uses {Math-ternary} for branchless selection, which evaluates both branches. This is safe + * because the default {TRC20-decimals} is commonly a constant. + * + * NOTE: If a derived contract overrides `super.decimals()` to read from + * storage, it should also override this function and use a conditional ternary instead. + */ function decimals() public view virtual override returns (uint8) { - try ITRC20Metadata(address(_underlying)).decimals() returns (uint8 value) { - return value; - } catch { - return super.decimals(); - } + (bool success, uint8 decimals_) = SafeTRC20.tryGetDecimals(_underlying); + return uint8(Math.ternary(success, decimals_, super.decimals())); // Safe cast. Both are uint8. } /** diff --git a/contracts/token/TRC20/extensions/TRC4626.sol b/contracts/token/TRC20/extensions/TRC4626.sol index 323bbd6..ca9ecee 100644 --- a/contracts/token/TRC20/extensions/TRC4626.sol +++ b/contracts/token/TRC20/extensions/TRC4626.sol @@ -6,8 +6,6 @@ pragma solidity ^0.8.24; import {ITRC20, ITRC20Metadata, TRC20} from "../TRC20.sol"; import {SafeTRC20} from "../utils/SafeTRC20.sol"; import {ITRC4626} from "../../../interfaces/ITRC4626.sol"; -import {LowLevelCall} from "../../../utils/LowLevelCall.sol"; -import {Memory} from "../../../utils/Memory.sol"; import {Math} from "../../../utils/math/Math.sol"; /** @@ -98,28 +96,11 @@ abstract contract TRC4626 is TRC20, ITRC4626 { * @dev Set the underlying asset contract. This must be a TRC20-compatible contract (TRC-20 or TRC-777). */ constructor(ITRC20 asset_) { - (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_); + (bool success, uint8 assetDecimals) = SafeTRC20.tryGetDecimals(asset_); _underlyingDecimals = success ? assetDecimals : 18; _asset = asset_; } - /** - * @dev Attempts to fetch the asset decimals. A return value of false indicates that the attempt failed in some way. - */ - function _tryGetAssetDecimals(ITRC20 asset_) private view returns (bool ok, uint8 assetDecimals) { - Memory.Pointer ptr = Memory.getFreeMemoryPointer(); - (bool success, bytes32 returnedDecimals, ) = LowLevelCall.staticcallReturn64Bytes( - address(asset_), - abi.encodeCall(ITRC20Metadata.decimals, ()) - ); - Memory.unsafeSetFreeMemoryPointer(ptr); - - return - (success && LowLevelCall.returnDataSize() >= 32 && uint256(returnedDecimals) <= type(uint8).max) - ? (true, uint8(uint256(returnedDecimals))) - : (false, 0); - } - /** * @dev Decimals are computed by adding the decimal offset on top of the underlying asset's decimals. This * "original" value is cached during construction of the vault contract. If this read operation fails (e.g., the diff --git a/contracts/token/TRC20/utils/SafeTRC20.sol b/contracts/token/TRC20/utils/SafeTRC20.sol index b98b656..74c0411 100644 --- a/contracts/token/TRC20/utils/SafeTRC20.sol +++ b/contracts/token/TRC20/utils/SafeTRC20.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.20; import {ITRC20} from "../ITRC20.sol"; import {ITRC1363} from "../../../interfaces/ITRC1363.sol"; +import {ITRC20Metadata} from "../../../interfaces/ITRC20Metadata.sol"; /** * @title SafeTRC20 @@ -336,4 +337,15 @@ library SafeTRC20 { mstore(0x40, fmp) } } + + /// @dev Attempts to fetch the token decimals. A return value of false indicates that the attempt failed in some way. + function tryGetDecimals(ITRC20 token) internal view returns (bool success, uint8 decimals) { + bytes4 selector = ITRC20Metadata.decimals.selector; + assembly ("memory-safe") { + mstore(0x00, selector) + success := staticcall(gas(), token, 0x00, 4, 0x00, 0x20) + success := and(and(success, gt(returndatasize(), 0x1f)), lt(mload(0x00), 0x100)) + decimals := mul(success, mload(0x00)) + } + } } diff --git a/test/token/TRC20/utils/SafeTRC20.test.js b/test/token/TRC20/utils/SafeTRC20.test.js index ab0f321..d4ab3b3 100644 --- a/test/token/TRC20/utils/SafeTRC20.test.js +++ b/test/token/TRC20/utils/SafeTRC20.test.js @@ -17,6 +17,8 @@ async function fixture() { const trc20ForceApproveMock = await ethers.deployContract('$TRC20ForceApproveMock', [name, symbol]); const trc20UsdtMock = await ethers.deployContract('$TRC20USDTMock', [name, symbol]); const trc20UsdtFeeMock = await ethers.deployContract('$TRC20USDTFeeMock', [name, symbol]); + const trc20DecimalsMock = await ethers.deployContract('$TRC20DecimalsMock', [name, symbol, 6]); + const trc20ExcessDecimalsMock = await ethers.deployContract('$TRC20ExcessDecimalsMock'); const erc1363Mock = await ethers.deployContract('$TRC1363', [name, symbol]); const erc1363ReturnFalseOnErc20Mock = await ethers.deployContract('$TRC1363ReturnFalseOnTRC20Mock', [name, symbol]); const erc1363ReturnFalseMock = await ethers.deployContract('$TRC1363ReturnFalseMock', [name, symbol]); @@ -38,6 +40,8 @@ async function fixture() { trc20ForceApproveMock, trc20UsdtMock, trc20UsdtFeeMock, + trc20DecimalsMock, + trc20ExcessDecimalsMock, erc1363Mock, erc1363ReturnFalseOnErc20Mock, erc1363ReturnFalseMock, @@ -400,6 +404,32 @@ describe('SafeTRC20', function () { }); }); + describe('tryGetDecimals', function () { + it('returns decimals when token has standard 18 decimals', async function () { + const result = await this.mock.$tryGetDecimals(this.trc20ReturnTrueMock); + expect(result.success).to.be.true; + expect(result.decimals).to.equal(18n); + }); + + it('returns decimals when token has non-standard decimals', async function () { + const result = await this.mock.$tryGetDecimals(this.trc20DecimalsMock); + expect(result.success).to.be.true; + expect(result.decimals).to.equal(6n); + }); + + it('returns false when address has no code', async function () { + const result = await this.mock.$tryGetDecimals(this.hasNoCode); + expect(result.success).to.be.false; + expect(result.decimals).to.equal(0n); + }); + + it('returns false when token returns a value that does not fit in uint8', async function () { + const result = await this.mock.$tryGetDecimals(this.trc20ExcessDecimalsMock); + expect(result.success).to.be.false; + expect(result.decimals).to.equal(0n); + }); + }); + describe('with TRC1363 with usdt approval behaviour', function () { beforeEach(async function () { this.token = this.erc1363ForceApproveMock;