Summary
In src/InheritanceManager.sol
, the withdrawInheritedFunds
function distributes the contract’s remaining assets equally among the beneficiaries. However, if the total asset amount is not perfectly divisible by the number of beneficiaries, residual dust (leftover assets) remains trapped in the contract, leading to fund lockup.
Vulnerability Details
In InheritanceManager.sol#L236, the withdrawInheritedFunds
function calculates the share per beneficiary using integer division, which truncates any remainder.
Consider the following code snippet:
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);
}
}
}
When the contract holds assets that cannot be evenly divided, the leftover value (i.e., dust) remains locked because the calculation only distributes the integer portion.
PoC
The following Foundry test demonstrates the issue:
pragma solidity 0.8.26;
import {Test, console} from "forge-std/Test.sol";
import {InheritanceManager} from "../src/InheritanceManager.sol";
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
contract InheritanceManagerTest is Test {
InheritanceManager im;
ERC20Mock usdc;
ERC20Mock weth;
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
function setUp() public {
vm.prank(owner);
im = new InheritanceManager();
usdc = new ERC20Mock();
weth = new ERC20Mock();
}
function test_withdrawInheritedFundsEtherDust() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.startPrank(owner);
im.addBeneficiary(user1);
im.addBeneficiary(user2);
im.addBeneficiary(user3);
vm.stopPrank();
vm.warp(1);
vm.deal(address(im), 1e18);
vm.warp(1 + 90 days);
vm.startPrank(user1);
im.inherit();
im.withdrawInheritedFunds(address(0));
vm.stopPrank();
assertEq(1, address(im).balance);
}
}
Impact
Tools Used
Recommendations
To prevent dust accumulation, track the remaining balance and assign all leftover assets to the last beneficiary:
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++) {
if (i == divisor - 1) {
amountPerBeneficiary = ethAmountAvailable;
} else {
ethAmountAvailable -= amountPerBeneficiary;
}
address payable beneficiary = payable(beneficiaries[i]);
(bool success, ) = beneficiary.call{value: amountPerBeneficiary}("");
require(success, "Transfer failed");
}
} else {
uint256 assetAmountAvailable = IERC20(_asset).balanceOf(address(this));
uint256 amountPerBeneficiary = assetAmountAvailable / divisor;
for (uint256 i = 0; i < divisor; i++) {
if (i == divisor - 1) {
amountPerBeneficiary = assetAmountAvailable;
} else {
assetAmountAvailable -= amountPerBeneficiary;
}
IERC20(_asset).safeTransfer(beneficiaries[i], amountPerBeneficiary);
}
}
}
Although it is unfair to give the dust to a single beneficiary, this ensures that no assets remain trapped. This implementation guarantees that the contract fully distributes all available funds.