Summary
In withdrawInheritedFunds function in InheritanceManager.sol, rounding errors can arise when dividing the total asset balance (ethAmountAvailable or assetAmountAvailable) by the number of beneficiaries (divisor).
In Solidity (and most programming languages), division of integers (uint256) truncates any fractional part. For example:
If ethAmountAvailable = 100 and divisor = 3, then amountPerBeneficiary = 100 / 3 = 33 (the fractional part 0.333... is discarded).
This means the total distributed amount would be 33 * 3 = 99, leaving 1 unit of the asset undistributed in the contract.
Vulnerability Details
function withdrawInheritedFunds(address _asset) external {
if (!isInherited) {
revert NotYetInherited();
}
uint256 divisor = beneficiaries.length;
if (_asset == address(0)) {
uint256 ethAmountAvailable = address(this).balance;
uint256 amountPerBeneficiary = ethAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
address payable beneficiary = payable(beneficiaries[i]);
(bool success,) = beneficiary.call{value: amountPerBeneficiary}("");
require(success, "something went wrong");
}
} else {
uint256 assetAmountAvailable = IERC20(_asset).balanceOf(address(this));
uint256 amountPerBeneficiary = assetAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
IERC20(_asset).safeTransfer(beneficiaries[i], amountPerBeneficiary);
}
}
}
POC
This test makes sure the fair distribution of funds and no rseidual funds should be left behind
function test_withdrawInheritedFundsEtherSuccess() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
vm.stopPrank();
vm.warp(1);
vm.deal(address(im), 10e18);
vm.warp(1 + 90 days);
vm.startPrank(user1);
im.inherit();
im.withdrawInheritedFunds(address(0));
vm.stopPrank();
assertEq(user1.balance, user2.balance);
assertEq(user2.balance, user3.balance);
assertEq(address(im).balance, 0);
}
The last assertion fails which tells that residual funds are trapped in contract
[FAIL. Reason: assertion failed: 1 != 0] test_withdrawInheritedFundsEtherSuccess() (gas: 264719)
And same goes for ERC20 withdrawal
function test_withdrawInheritedFundsERC20Success() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
vm.stopPrank();
vm.warp(1);
usdc.mint(address(im), 10e18);
vm.warp(1 + 90 days);
vm.startPrank(user1);
im.inherit();
im.withdrawInheritedFunds(address(usdc));
vm.stopPrank();
assertEq(usdc.balanceOf(user1)==usdc.balanceOf(user2), usdc.balanceOf(user1)==usdc.balanceOf(user3));
assertEq(0, usdc.balanceOf(address(im)));
console.log(usdc.balanceOf(user1));
console.log(usdc.balanceOf(user2));
console.log(usdc.balanceOf(user3));
console.log(usdc.balanceOf(address(im)));
}
The last assertion fails which tells that residual funds are locked in contract
[FAIL. Reason: assertion failed: 0 != 1] test_withdrawInheritedFundsERC20Success() (gas: 295217)
Impact
Tools Used
Recommendations
You can decide what to do with residual funds.
I have decided to give the residual funds to the first beneficiary(as crypto rewards the early adapters lol🥱)
function withdrawInheritedFunds(address _asset) external {
if (!isInherited) {
revert NotYetInherited();
}
uint256 divisor = beneficiaries.length;
if (_asset == address(0)) {
uint256 ethAmountAvailable = address(this).balance;
uint256 amountPerBeneficiary = ethAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
uint256 amountToSend = (i == 0)
? (ethAmountAvailable - (amountPerBeneficiary * (divisor - 1)))
: amountPerBeneficiary;
address payable beneficiary = payable(beneficiaries[i]);
(bool success, ) = beneficiary.call{value: amountToSend}("");
require(success, "Ether transfer failed");
}
} else {
uint256 assetAmountAvailable = IERC20(_asset).balanceOf(address(this));
uint256 amountPerBeneficiary = assetAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
uint256 amountToSend = (i == 0)
? (assetAmountAvailable - (amountPerBeneficiary * (divisor - 1)))
: amountPerBeneficiary;
IERC20(_asset).safeTransfer(beneficiaries[i], amountToSend);
}
}
}
The improved tests are below
function test_withdrawInheritedFundsEtherSuccess() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
vm.stopPrank();
vm.warp(1);
vm.deal(address(im), 10e18);
vm.warp(1 + 90 days);
vm.startPrank(user1);
im.inherit();
im.withdrawInheritedFunds(address(0));
vm.stopPrank();
assertGt(user1.balance, user2.balance);
assertEq(user2.balance, user3.balance);
assertEq(address(im).balance, 0);
}
function test_withdrawInheritedFundsERC20Success() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
vm.stopPrank();
vm.warp(1);
usdc.mint(address(im), 10e18);
vm.warp(1 + 90 days);
vm.startPrank(user1);
im.inherit();
im.withdrawInheritedFunds(address(usdc));
vm.stopPrank();
assertGt(usdc.balanceOf(user1), usdc.balanceOf(user2));
assertEq(usdc.balanceOf(user2), usdc.balanceOf(user3));
assertEq(0, usdc.balanceOf(address(im)));
console.log(usdc.balanceOf(user1));
console.log(usdc.balanceOf(user2));
console.log(usdc.balanceOf(user3));
console.log(usdc.balanceOf(address(im)));
}