diff --git a/src/generated/ERC4626Words.pointers.sol b/src/generated/ERC4626Words.pointers.sol index 95d61cb..9cab1e7 100644 --- a/src/generated/ERC4626Words.pointers.sol +++ b/src/generated/ERC4626Words.pointers.sol @@ -10,7 +10,7 @@ pragma solidity ^0.8.25; // file needs the contract to exist so that it can be compiled. /// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0x9905f7f79bbbbea0cde049efb56d1aed955479c30db2ee9b6eeae543e3b33bfb); +bytes32 constant BYTECODE_HASH = bytes32(0x742585ed2b4e04325a26604e90eabd1cbe7552a83e9ccec3d894bfce1ae1fcbf); /// @dev The hash of the meta that describes the contract. bytes32 constant DESCRIBED_BY_META_HASH = bytes32(0xdde475220ea06da58884f3f7abbfc091637e02cb924b8b9a8a9d20dbd4ae703a); diff --git a/src/lib/erc4626/LibERC4626.sol b/src/lib/erc4626/LibERC4626.sol index caff4c2..9670482 100644 --- a/src/lib/erc4626/LibERC4626.sol +++ b/src/lib/erc4626/LibERC4626.sol @@ -19,6 +19,16 @@ interface IERC20MetadataMinimal { function decimals() external view returns (uint8); } +/// @dev Upper bound on decimals accepted from vault and asset contracts. Values +/// above this would cause toFixedDecimalLossless to overflow uint256 for any +/// non-trivial Float, enabling denial-of-service by a malicious vault. +uint8 constant MAX_DECIMALS = 36; + +/// Thrown when a vault or asset reports more decimals than MAX_DECIMALS. +/// @param token The vault or asset address that reported the excessive decimals. +/// @param decimals The reported decimals value. +error UnsupportedDecimals(address token, uint8 decimals); + /// @title LibERC4626 /// @notice Core library for interacting with ERC-4626 tokenised vaults on-chain. /// Takes the vault as a typed contract reference and handles conversion of the @@ -28,12 +38,16 @@ library LibERC4626 { /// Reads the share and underlying-asset decimal scales from the vault: /// vault.decimals() then vault.asset() then assetToken.decimals(), giving /// both conversion functions a single, symmetric read path. + /// Reverts with UnsupportedDecimals if either reported value exceeds MAX_DECIMALS. /// @param vault The ERC-4626 vault contract. /// @return shareDecimals The decimal precision of the vault share token. /// @return assetDecimals The decimal precision of the underlying asset token. function _vaultScales(IERC4626Minimal vault) private view returns (uint8 shareDecimals, uint8 assetDecimals) { shareDecimals = vault.decimals(); - assetDecimals = IERC20MetadataMinimal(vault.asset()).decimals(); + if (shareDecimals > MAX_DECIMALS) revert UnsupportedDecimals(address(vault), shareDecimals); + address assetAddr = vault.asset(); + assetDecimals = IERC20MetadataMinimal(assetAddr).decimals(); + if (assetDecimals > MAX_DECIMALS) revert UnsupportedDecimals(assetAddr, assetDecimals); } /// @notice Converts vault shares to underlying assets via ERC-4626 convertToAssets. diff --git a/test/src/lib/erc4626/LibERC4626.t.sol b/test/src/lib/erc4626/LibERC4626.t.sol index bdb401d..d1b3efd 100644 --- a/test/src/lib/erc4626/LibERC4626.t.sol +++ b/test/src/lib/erc4626/LibERC4626.t.sol @@ -3,10 +3,11 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibERC4626, IERC4626Minimal} from "../../../../src/lib/erc4626/LibERC4626.sol"; +import {LibERC4626, IERC4626Minimal, UnsupportedDecimals} from "../../../../src/lib/erc4626/LibERC4626.sol"; import {LibDecimalFloat, Float} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; import {MockERC4626} from "../../../utils/MockERC4626.sol"; import {MockERC20} from "../../../utils/MockERC20.sol"; +import {MaliciousERC4626} from "../../../utils/MaliciousERC4626.sol"; contract LibERC4626Test is Test { MockERC20 internal asset; @@ -196,6 +197,48 @@ contract LibERC4626Test is Test { assertEq(LibDecimalFloat.toFixedDecimalLossless(sharesFloat, 18), 0, "0 assets must yield 0 shares"); } + /// A vault reporting decimals() > MAX_DECIMALS must revert UnsupportedDecimals + /// for convertToAssets. Mutation: remove the shareDecimals guard → expectRevert fails. + function testShareDecimalsAboveMaxRevertsConvertToAssets() external { + MaliciousERC4626 malVault = new MaliciousERC4626(255, address(asset)); + Float sharesFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malVault), uint8(255))); + this._convertToAssets(IERC4626Minimal(address(malVault)), sharesFloat); + } + + /// A vault reporting decimals() > MAX_DECIMALS must revert UnsupportedDecimals + /// for convertToShares. Mutation: remove the shareDecimals guard → expectRevert fails. + function testShareDecimalsAboveMaxRevertsConvertToShares() external { + MaliciousERC4626 malVault = new MaliciousERC4626(255, address(asset)); + Float assetsFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malVault), uint8(255))); + this._convertToShares(IERC4626Minimal(address(malVault)), assetsFloat); + } + + /// An asset reporting decimals() > MAX_DECIMALS must revert UnsupportedDecimals + /// for convertToAssets. Mutation: remove the assetDecimals guard → expectRevert fails. + function testAssetDecimalsAboveMaxRevertsConvertToAssets() external { + MockERC20 malAsset = new MockERC20(255); + MockERC4626 normalVault = new MockERC4626(18, address(malAsset), 1e18); + Float sharesFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malAsset), uint8(255))); + this._convertToAssets(IERC4626Minimal(address(normalVault)), sharesFloat); + } + + /// An asset reporting decimals() > MAX_DECIMALS must revert UnsupportedDecimals + /// for convertToShares. Mutation: remove the assetDecimals guard → expectRevert fails. + function testAssetDecimalsAboveMaxRevertsConvertToShares() external { + MockERC20 malAsset = new MockERC20(255); + MockERC4626 normalVault = new MockERC4626(18, address(malAsset), 1e18); + Float assetsFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malAsset), uint8(255))); + this._convertToShares(IERC4626Minimal(address(normalVault)), assetsFloat); + } + function testConvertToAssetsLargeInput() external view { IERC4626Minimal typedVault = IERC4626Minimal(address(vault)); // 1e9 whole shares: well within int64 range, exercises large fixed-point packing.