Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

Incorrect division logic in `InheritanceManager::buyOutEstateNFT` causes share miscalculation and loss of funds

Summary

The buyOutEstateNFT function incorrectly divides the NFT value by the total number of beneficiaries instead of the number of non-buying beneficiaries, leading to miscalculated share payouts and locked token shares.

Vulnerability Details

The buyOutEstateNFT function calculates and distributes the value of an NFT among beneficiaries when one of them wants to buy it out.

function buyOutEstateNFT(
uint256 _nftID
) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
uint256 divisor = beneficiaries.length;
uint256 multiplier = beneficiaries.length - 1;
// [1]
uint256 finalAmount = (value / divisor) * multiplier;
IERC20(assetToPay).safeTransferFrom(
msg.sender,
address(this),
finalAmount
);
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender == beneficiaries[i]) {
return;
} else {
// [2]
IERC20(assetToPay).safeTransfer(
beneficiaries[i],
finalAmount / divisor
);
}
}
// [3]
nft.burnEstate(_nftID);
}

finalAmount, which corresponds to the buyout amount, is calculated and transferred into the contract ([1]). Then, the contract distributes this amount among all other beneficiaries (excluding the caller) ([2]). However, the amount per-beneficiary is calculated as finalAmount/divisor, which is wrong. Consider the following setup:

  • three beneficiaries [0xA, 0xB, 0xC], asset value 100 USDC

  • 0xC buys out the NFT, paying the other two (0xA and 0xB) their corresponding shares

  • value / divisor = 100 / 3, 33 USDC

  • finalAmount = (100 / 3) * 2, where 2 is multiplier, 66 USDC

  • 0xC pays 66 USDC into the contract via safeTransferFrom

  • distribution per-beneficiary is finalAmount / divisor = 66 / 3, 22 USDC

  • each beneficiary gets a pay of 22 USDC while it should've been 33 USDC

  • InheritanceManager contract keeps 22 USDC (22% is effectively lost)

Proof of Concept

Place test_buyOutEstateNFTSharesLoss in InheritanceManager.t.sol:

function test_buyOutEstateNFTSharesLoss() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.warp(1);
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
im.createEstateNFT("beach-house", 100e6, address(usdc));
vm.stopPrank();
usdc.mint(user3, 100e6);
vm.warp(1 + 90 days);
vm.startPrank(user3);
usdc.approve(address(im), 100e6);
im.inherit();
im.buyOutEstateNFT(1);
vm.stopPrank();
assertEq(usdc.balanceOf(user1) / 1e6, 22);
assertEq(usdc.balanceOf(user2) / 1e6, 22);
assertEq(usdc.balanceOf(address(im)) / 1e6, 22);
}

And run the test:

$ forge test --mp test/InheritanceManagerTest.t.sol --mt test_buyOutEstateNFTSharesLoss
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/InheritanceManagerTest.t.sol:InheritanceManagerTest
[PASS] test_buyOutEstateNFTSharesLoss() (gas: 479768)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.03ms (248.58µs CPU time)
Ran 1 test suite in 134.01ms (1.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Note how user1 and user2 end up with 22 token shares each instead of 33., while the InheritanceManager contract keeps 22.

Impact

Beneficiaries receive less than their fair share and funds can get locked, which breaks one of the core assumptions/invariants of the InheritanceManager contract.

Recommendations

When calculating the amount per-beneficiary, divide by multiplier (the number of beneficiaries except for the buyer), instead of divisor (the total number of beneficiaries).

function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
uint256 divisor = beneficiaries.length;
uint256 multiplier = beneficiaries.length - 1;
uint256 finalAmount = (value / divisor) * multiplier;
IERC20(assetToPay).safeTransferFrom(msg.sender, address(this), finalAmount);
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender == beneficiaries[i]) {
return;
} else {
- IERC20(assetToPay).safeTransfer(beneficiaries[i], finalAmount / divisor);
+ IERC20(assetToPay).safeTransfer(beneficiaries[i], finalAmount / multiplier);
}
}
nft.burnEstate(_nftID);
}

The following test should now pass:

function test_buyOutEstateNFTShareDistributionFix() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
vm.warp(1);
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
im.createEstateNFT("beach-house", 100e6, address(usdc));
vm.stopPrank();
usdc.mint(user3, 100e6);
vm.warp(1 + 90 days);
vm.startPrank(user3);
usdc.approve(address(im), 100e6);
im.inherit();
im.buyOutEstateNFT(1);
vm.stopPrank();
console.log(usdc.balanceOf(user1));
console.log(usdc.balanceOf(user2));
console.log(usdc.balanceOf(address(im)));
assertEq(usdc.balanceOf(user1) / 1e6, 33);
assertEq(usdc.balanceOf(user2) / 1e6, 33);
assertEq(usdc.balanceOf(address(im)) / 1e6, 0);
}
$ forge test --mp test/InheritanceManagerTest.t.sol --mt test_buyOutEstateNFTShareDistributionFix
Ran 1 test for test/InheritanceManagerTest.t.sol:InheritanceManagerTest
[PASS] test_buyOutEstateNFTShareDistributionFix() (gas: 459855)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.09ms (296.79µs CPU time)
Ran 1 test suite in 135.74ms (1.09ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

0xtimefliez Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

buyOutNFT has wrong denominator

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!