From c60f14a041bfa8256b4d101e53f09f5fb4d288dc Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 28 Jun 2026 05:24:27 +0000 Subject: [PATCH 1/6] fix(security): bound vault and asset decimals to MAX_DECIMALS to prevent overflow DoS Adds MAX_DECIMALS=36 constant and UnsupportedDecimals error to LibERC4626. _decode() now reverts if vault.decimals() or asset.decimals() exceeds the bound, closing the griefing vector where a malicious vault could trigger toFixedDecimalLossless overflow via an arbitrarily large decimals value. Regenerates ERC4626Words.pointers.sol with updated BYTECODE_HASH. Adds four mutation-validated tests for both conversion paths. Closes #84 Co-Authored-By: Claude --- src/generated/ERC4626Words.pointers.sol | 2 +- src/lib/erc4626/LibERC4626.sol | 16 +++++++- test/src/lib/erc4626/LibERC4626.t.sol | 49 ++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/generated/ERC4626Words.pointers.sol b/src/generated/ERC4626Words.pointers.sol index f0170fc..1894777 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(0xd41833eb03f6751b5515145bec2c0b637ae762d7fc14b9e7fbc5110ba1794bd5); +bytes32 constant BYTECODE_HASH = bytes32(0x2bfa0cfca04772ea54885a59511ad5ef3003acd13b51f535f9642a4492e3b54c); /// @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 d4ff410..37d4827 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. /// Handles conversion between the float representation used by the Rain interpreter @@ -27,6 +37,7 @@ library LibERC4626 { /// Decodes a vault Float into its address and both decimal scales. /// Reads 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 vaultFloat Float encoding of the ERC-4626 vault contract address. /// @return vault The ERC-4626 vault contract. /// @return shareDecimals The decimal precision of the vault share token. @@ -38,7 +49,10 @@ library LibERC4626 { { vault = IERC4626Minimal(address(uint160(LibDecimalFloat.toFixedDecimalLossless(vaultFloat, 0)))); 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 7f45076..0d82ac3 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} from "src/lib/erc4626/LibERC4626.sol"; +import {LibERC4626, UnsupportedDecimals, MAX_DECIMALS} from "src/lib/erc4626/LibERC4626.sol"; import {LibDecimalFloat, Float} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; import {MockERC4626} from "test/utils/MockERC4626.sol"; import {MockERC20} from "test/utils/MockERC20.sol"; +import {MaliciousERC4626} from "test/utils/MaliciousERC4626.sol"; contract LibERC4626Test is Test { MockERC20 internal asset; @@ -196,6 +197,52 @@ 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 vaultFloat = LibDecimalFloat.packLossless(int256(uint256(uint160(address(malVault)))), 0); + Float sharesFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malVault), uint8(255))); + this._convertToAssets(vaultFloat, 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 vaultFloat = LibDecimalFloat.packLossless(int256(uint256(uint160(address(malVault)))), 0); + Float assetsFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malVault), uint8(255))); + this._convertToShares(vaultFloat, 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 vaultFloat = LibDecimalFloat.packLossless(int256(uint256(uint160(address(normalVault)))), 0); + Float sharesFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malAsset), uint8(255))); + this._convertToAssets(vaultFloat, 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 vaultFloat = LibDecimalFloat.packLossless(int256(uint256(uint160(address(normalVault)))), 0); + Float assetsFloat = LibDecimalFloat.packLossless(1, 0); + + vm.expectRevert(abi.encodeWithSelector(UnsupportedDecimals.selector, address(malAsset), uint8(255))); + this._convertToShares(vaultFloat, assetsFloat); + } + function testConvertToAssetsLargeInput() external view { Float vaultFloat = LibDecimalFloat.packLossless(int256(uint256(uint160(address(vault)))), 0); // 1e9 whole shares: well within int64 range, exercises large fixed-point packing. From f22088f76d2581246ada9cf69f62afc8f5768f53 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 28 Jun 2026 05:33:33 +0000 Subject: [PATCH 2/6] ci: retrigger after rate-limit transient From 5b7ef4f4416f83af703b087b131de0d995bdbb70 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 28 Jun 2026 09:05:27 +0000 Subject: [PATCH 3/6] fix(ci): rainix-sol/test/test [3b-attempt] empty-commit retrigger (HTTP 429 rate-limit flake) Co-Authored-By: Claude From 81dfdf0bdd1833e2f9be80ab1f6716cf2a5db7cc Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 28 Jun 2026 13:05:37 +0000 Subject: [PATCH 4/6] fix(ci): rainix-sol/test/test [3b-attempt] empty-commit retrigger (HTTP 429 rate-limit transient) Co-Authored-By: Claude From ce2148a8700eef027117b356d5cb7103f1b75456 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 28 Jun 2026 21:05:18 +0000 Subject: [PATCH 5/6] fix(ci): rainix-sol/test/test [3b-attempt] empty-commit retrigger (HTTP 429 rate-limit transient, 3rd) From e22105d681ca65a17d5e8067a30588ba92f910cb Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 3 Jul 2026 17:18:58 +0000 Subject: [PATCH 6/6] chore: regenerate BYTECODE_HASH after merge-update Co-Authored-By: Claude --- src/generated/ERC4626Words.pointers.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generated/ERC4626Words.pointers.sol b/src/generated/ERC4626Words.pointers.sol index 0fba240..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(0x2bfa0cfca04772ea54885a59511ad5ef3003acd13b51f535f9642a4492e3b54c); +bytes32 constant BYTECODE_HASH = bytes32(0x742585ed2b4e04325a26604e90eabd1cbe7552a83e9ccec3d894bfce1ae1fcbf); /// @dev The hash of the meta that describes the contract. bytes32 constant DESCRIBED_BY_META_HASH = bytes32(0xdde475220ea06da58884f3f7abbfc091637e02cb924b8b9a8a9d20dbd4ae703a);