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

Failure to Transfer or Burn NFT in buyOutEstateNFT

Summary

The buyOutEstateNFT function is intended to facilitate a buyout of an NFT representing a real-world asset (RWA), but it does not transfer ownership of the NFT to the caller nor consistently burn it as expected. Instead, it burns the NFT only if the caller is not among the beneficiaries iterated before the return statement, effectively paying other beneficiaries without guaranteeing a change in ownership or removal of the NFT, rendering the payment potentially meaningless.

Vulnerability Details

The vulnerable code is in the buyOutEstateNFT function:

solidity

function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
//@audit-issue k this doesn't change the ownership of the nft or rwa nor burn it, just paying other beneficiary for nothing
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);
}
}
nft.burnEstate(_nftID);
}
POC
// SPDX-License-Identifier: MIT
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";
/// @title General InheritanceManager Tests
/// @notice Runs additional functional tests on `InheritanceManager`
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();
// ERC20Mock requires a name, symbol, and initial supply
usdc = new ERC20Mock();
weth = new ERC20Mock();
}
function test_buyOutEstateNFTDoesntChangeNftOwnershipOrBurnIt() public {
address user2 = makeAddr("user2");
address user3 = makeAddr("user3");
// Step 1: Setup Beneficiaries
vm.warp(1);
vm.startPrank(owner);
im.addBeneficiery(user1);
im.addBeneficiery(user2);
im.addBeneficiery(user3);
// Step 2: Create Estate NFT
im.createEstateNFT("our beach-house", 3e6, address(usdc));
vm.stopPrank();
// Step 3: Fund user3 and approve spending
usdc.mint(user3, 4e6);
usdc.mint(user2, 4e6);
vm.warp(1 + 90 days);
vm.startPrank(user3);
usdc.approve(address(im), 4e6);
// Step 4: Inherit and Attempt Buy-Out
im.inherit();
im.buyOutEstateNFT(1);
vm.stopPrank();
uint256 user3Bal1 = usdc.balanceOf(address(user3));
vm.startPrank(user2);
usdc.approve(address(im), 4e6);
im.buyOutEstateNFT(1);
vm.stopPrank();
uint256 user3Bal2 = usdc.balanceOf(address(user3));
//user3 balance before another user bought the nft + the price of the nft, should be equal to balance of user3 after
assertEq(user3Bal1 + 3e6, user3Bal2, "user3 doesn't receive the nft money nor the nft was burnt");
}
}

No Ownership Transfer:

  • The function does not call any mechanism (e.g., nft.transferFrom or nft.safeTransferFrom) to transfer the NFT’s ownership to the caller, leaving it unclear who owns the NFT post-buyout.

  • The intent of a "buyout" typically implies the caller gains control, but this is not implemented.

  • Conditional Burn:

    • The nft.burnEstate(_nftID) call is only reached if the caller (msg.sender) is not found in the beneficiaries array before the loop completes.

    • If msg.sender matches a beneficiary, the return statement exits the function early, skipping the burn. This means the NFT remains intact, and the caller pays without receiving or destroying the asset.

  • Purpose Misalignment:

    • The function transfers funds to other beneficiaries, but without transferring or reliably burning the NFT, the payment appears to be for "nothing," contradicting the buyout’s purpose.

Impact

  • Wasted Funds: The caller pays other beneficiaries without gaining ownership or ensuring the NFT is removed, effectively losing funds for no benefit.

  • Ambiguous Ownership: The NFT remains in its original state (owned by the contract or original minter), undermining the buyout’s intent and potentially allowing multiple buyouts of the same asset.

  • User Confusion: Beneficiaries expect a buyout to transfer or retire the asset, but this inconsistency

Tools Used

Manual Review and Foundry

Recommendations

Ensure the NFT is transferred to the caller or consistently burned:

Option 1: Transfer Ownership

solidity

function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
uint256 divisor = beneficiaries.length - 1;
uint256 totalAmount = value;
IERC20(assetToPay).safeTransferFrom(msg.sender, address(this), totalAmount);
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender != beneficiaries[i]) {
IERC20(assetToPay).safeTransfer(beneficiaries[i], value / divisor);
}
}
nft.safeTransferFrom(address(this), msg.sender, _nftID); // Assumes contract owns NFT
}

Option 2: Consistent Burn

solidity

function buyOutEstateNFT(uint256 _nftID) external onlyBeneficiaryWithIsInherited {
uint256 value = nftValue[_nftID];
uint256 divisor = beneficiaries.length - 1;
uint256 totalAmount = value;
IERC20(assetToPay).safeTransferFrom(msg.sender, address(this), totalAmount);
for (uint256 i = 0; i < beneficiaries.length; i++) {
if (msg.sender != beneficiaries[i]) {
IERC20(assetToPay).safeTransfer(beneficiaries[i], value / divisor);
}
}
nft.burnEstate(_nftID); // Always burn, regardless of caller
}
Updates

Lead Judging Commences

0xtimefliez Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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