The PoC deploys SoulboundProfileNFT and LikeRegistry. It also deploys FixedLikeRegistryDD11, a copy of LikeRegistry with only the like-crediting fix (userBalances[msg.sender] += msg.value;) applied and the same empty receive(). A storage-layout check confirms that the private totalFees is at slot 2.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/LikeRegistry.sol";
import "../src/MultiSig.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract FixedLikeRegistryDD11 is Ownable {
struct Like {
address liker;
address liked;
uint256 timestamp;
}
SoulboundProfileNFT public profileNFT;
uint256 immutable FIXEDFEE = 10;
uint256 totalFees;
mapping(address => mapping(address => bool)) public likes;
mapping(address => address[]) public matches;
mapping(address => uint256) public userBalances;
constructor(address _profileNFT) Ownable(msg.sender) {
profileNFT = SoulboundProfileNFT(_profileNFT);
}
function likeUser(address liked) external payable {
require(msg.value >= 1 ether, "Must send at least 1 ETH");
require(!likes[msg.sender][liked], "Already liked");
require(msg.sender != liked, "Cannot like yourself");
require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT");
likes[msg.sender][liked] = true;
userBalances[msg.sender] += msg.value;
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
matchRewards(liked, msg.sender);
}
}
function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}
function withdrawFees() external onlyOwner {
require(totalFees > 0, "No fees to withdraw");
uint256 totalFeesToWithdraw = totalFees;
totalFees = 0;
(bool success,) = payable(owner()).call{value: totalFeesToWithdraw}("");
require(success, "Transfer failed");
}
receive() external payable {}
}
contract PoC_DD11 is Test {
bytes32 constant TOTAL_FEES_SLOT = bytes32(uint256(2));
SoulboundProfileNFT nft;
LikeRegistry registry;
FixedLikeRegistryDD11 fixedRegistry;
address owner = makeAddr("owner");
address sender = makeAddr("mistakenSender");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
function setUp() public {
nft = new SoulboundProfileNFT();
vm.startPrank(owner);
registry = new LikeRegistry(address(nft));
fixedRegistry = new FixedLikeRegistryDD11(address(nft));
vm.stopPrank();
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(bob);
nft.mintProfile("Bob", 27, "ipfs://bob");
vm.deal(sender, 10 ether);
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
}
function _totalFees(address reg) internal view returns (uint256) {
return uint256(vm.load(reg, TOTAL_FEES_SLOT));
}
function test_storageLayoutSanity() public view {
assertEq(address(uint160(uint256(vm.load(address(registry), bytes32(uint256(0)))))), owner);
assertEq(address(uint160(uint256(vm.load(address(registry), bytes32(uint256(1)))))), address(nft));
assertEq(registry.owner(), owner);
}
function test_DirectEthToLikeRegistry_IsAcceptedButUnaccountedAndUnwithdrawable() public {
uint256 senderBefore = sender.balance;
vm.prank(sender);
(bool ok,) = address(registry).call{value: 1 ether}("");
assertTrue(ok, "receive() accepted the plain transfer");
assertEq(address(registry).balance, 1 ether, "registry now holds the 1 ETH");
assertEq(sender.balance, senderBefore - 1 ether, "sender lost 1 ETH");
assertEq(registry.userBalances(sender), 0, "not credited to userBalances");
assertEq(_totalFees(address(registry)), 0, "not credited to totalFees");
vm.prank(owner);
vm.expectRevert(bytes("No fees to withdraw"));
registry.withdrawFees();
address newOwner = makeAddr("newOwner");
vm.prank(owner);
registry.transferOwnership(newOwner);
vm.prank(newOwner);
vm.expectRevert(bytes("No fees to withdraw"));
registry.withdrawFees();
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
assertEq(_totalFees(address(registry)), 0, "match added no fees");
vm.prank(newOwner);
vm.expectRevert(bytes("No fees to withdraw"));
registry.withdrawFees();
assertGe(address(registry).balance, 1 ether, "direct ETH still stranded");
emit log_named_decimal_uint("registry balance after all outflows tried", address(registry).balance, 18);
}
function test_Baseline_FixedRegistry_DrainsToZeroWithoutDirectTransfer() public {
vm.prank(alice);
fixedRegistry.likeUser{value: 1 ether}(bob);
vm.prank(bob);
fixedRegistry.likeUser{value: 1 ether}(alice);
assertEq(_totalFees(address(fixedRegistry)), 0.2 ether, "slot 2 holds totalFees");
assertEq(address(fixedRegistry).balance, 0.2 ether);
uint256 ownerBefore = owner.balance;
vm.prank(owner);
fixedRegistry.withdrawFees();
assertEq(owner.balance - ownerBefore, 0.2 ether);
assertEq(address(fixedRegistry).balance, 0, "baseline: nothing left behind");
}
function test_DirectEthStillStranded_AfterUserBalancesFix() public {
vm.prank(sender);
(bool ok,) = address(fixedRegistry).call{value: 1 ether}("");
assertTrue(ok);
assertEq(fixedRegistry.userBalances(sender), 0, "fixed likeUser path does not touch receive()");
vm.prank(alice);
fixedRegistry.likeUser{value: 1 ether}(bob);
vm.prank(bob);
fixedRegistry.likeUser{value: 1 ether}(alice);
assertEq(address(fixedRegistry).balance, 1.2 ether);
assertEq(_totalFees(address(fixedRegistry)), 0.2 ether, "direct ETH not counted as fees");
uint256 ownerBefore = owner.balance;
vm.prank(owner);
fixedRegistry.withdrawFees();
assertEq(owner.balance - ownerBefore, 0.2 ether, "owner can only take tracked fees");
assertEq(_totalFees(address(fixedRegistry)), 0);
assertEq(fixedRegistry.userBalances(alice), 0);
assertEq(fixedRegistry.userBalances(bob), 0);
assertEq(fixedRegistry.userBalances(sender), 0);
assertEq(address(fixedRegistry).balance, 1 ether, "exactly the direct transfer is stranded");
vm.prank(owner);
vm.expectRevert(bytes("No fees to withdraw"));
fixedRegistry.withdrawFees();
emit log_named_decimal_uint("untracked ETH stranded in fixed registry", address(fixedRegistry).balance, 18);
}
}