diff --git a/.gitmodules b/.gitmodules index a3f057f..d00f7ec 100644 --- a/.gitmodules +++ b/.gitmodules @@ -26,3 +26,6 @@ [submodule "riff/lib/solmate"] path = riff/lib/solmate url = https://github.com/Rari-Capital/solmate +[submodule "veil/lib/forge-std"] + path = veil/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index ceae66b..f702e25 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ Below is a quick summary of each prototype currently available in this repositor Participate in a global pay-it-forward chain. 1. **`NIBBLE`** Earn revenue share in your favorite restaurant. +1. **`VEIL`** + Win a raffle no one can rig, and no one can trace back to you. If you've already [installed](https://docs.seismic.systems/onboarding/publish-your-docs) Seismic on your local machine, you can `cd` into each directory and run ``` diff --git a/veil/README.md b/veil/README.md new file mode 100644 index 0000000..854c159 --- /dev/null +++ b/veil/README.md @@ -0,0 +1,37 @@ +# VEIL: Win a raffle no one can rig, and no one can trace back to you + +## Overview + +**Problem**: A public raffle leaks its own odds while it is still running. Anyone can watch the pool grow, time their entry against the visible total, or wait to see how many tickets are already in play before deciding whether to join. Randomness sourced from ordinary chain data fares no better: whoever proposes or times a block has some influence over it, and once a contract's "random" output becomes a public value, anyone who can read it before deciding whether to keep or discard a transaction can grind for a favorable outcome instead of accepting the one they were dealt. + +**Insight**: Two things have to be true at once for a raffle to be trustworthy: the draw has to be unbiasable by anyone including the party who triggers it, and the outcome has to be unobservable to anyone until they have committed to living with it. Seismic's native randomness gives the first property. Shielded storage, used correctly, gives the second, and it turns out the second property is also what makes an anonymous winner possible: if no one, including the winner's own wrapper contract, can read the outcome before it is final, then no one can act on it early, and no one but the winner themselves can ever connect an address to the result. + +**Solution**: Entrants buy tickets by pulling payment through a shielded SRC20 token, so the amount they pay for never appears as a public transaction field. Each entrant's stake is recorded as a range carved out of a running total that stays encrypted until the draw. At the draw, Seismic's RNG builtin picks a winning ticket number, but that number is never declassified. It stays shielded in contract storage permanently. The only way for anyone to learn anything about the outcome is to call `claim`, which reveals nothing beyond whether the caller's own private range happens to cover the hidden ticket. + +## How the draw resists grinding + +A naive version of this design has two holes, and closing both is the actual point of the contract, not an afterthought. + +The first hole is an off-chain self-oracle: if the winning ticket number is ever made public, an entrant who knows their own range boundaries, most obviously the entrant holding ticket 0 or the entrant holding the last ticket, can wrap `draw` in their own contract, read the now-public result, and revert the whole transaction if it does not favor them. Veil closes this by never declassifying the winning ticket. It is computed and stored as a shielded value from the moment Seismic's randomness builtin returns it, and there is no getter, no event field, and no other state variable anywhere in the contract that exposes it. There is nothing for a wrapper to read. + +The second hole is atomic bundling: even with a hidden result, a contract that lets the same transaction both trigger the draw and check whether it won can simply revert the whole thing when it loses, and try again in a fresh transaction. Veil closes this by recording the block the draw ran in and requiring `claim` to run in a strictly later block. Within a single transaction the block number cannot advance, so `claim` can never succeed in the same transaction as `draw`, no matter how the call is structured. The draw commits irrevocably in its own transaction, before anyone, including whoever triggered it, has any way to know what it will show. + +## Architecture + +- `Veil.sol`: the raffle itself, entries, the draw, and claims +- `ISRC20.sol`: interface for the shielded token entrants pay with +- Test suite in `test/`, including a mock SRC20 token and reproductions of both grinding attacks described above, proving each one fails against the current design + +## Limitations + +**The draw is not independently auditable after the fact.** Because the winning ticket is never declassified, nobody outside the contract, not even after the raffle has concluded, can recompute which ticket number should have won and check it against who actually claimed. All anyone can verify is that a specific claimed payout occurred. This is the direct and deliberate cost of the anonymity property this design is built around, not an oversight. A raffle that needs to be publicly auditable end to end would need a different design, one that accepts a public draw and gives up winner anonymity in exchange. + +**The raffle depends on someone calling `draw` after the deadline.** Nothing in the contract incentivizes this beyond entrants wanting their prize. If no one calls it, the raffle simply never draws. + +**Two small, accepted signals remain.** Attempting to enter with zero tickets reveals a zero-versus-nonzero fact about that one attempt before it reverts, the same pattern used throughout this design's ticket bookkeeping. And a losing `claim` call reveals, to the caller alone, that they did not win. Neither leaks anything about any other entrant, the pool total before the draw, or the raw winning ticket value, and neither can be used to influence the outcome; they are the minimum information a participant is owed about their own result. + +**Count privacy only means something with a healthy number of entrants.** Every address that enters is public through the `Entered` event, and `draw` declassifies the total ticket count. With one entrant, the total ticket count and that entrant's own ticket count are the same number: their stake is fully exposed and they are also guaranteed to win, so shielding buys them nothing. With two entrants, each one knows their own count and can subtract it from the declassified total to learn the other's count exactly. The privacy this design provides over ticket counts is a property of the crowd, not of the mechanism in isolation, and it only becomes meaningful once enough entrants have joined that the total no longer narrows down any single participant's stake. This is not a flaw to be fixed later; it is a real limit on what shielding a running total can do, and it should be stated plainly rather than implied away. + +## License + +MIT diff --git a/veil/foundry.toml b/veil/foundry.toml new file mode 100644 index 0000000..2bc70ba --- /dev/null +++ b/veil/foundry.toml @@ -0,0 +1,4 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] diff --git a/veil/lib/forge-std b/veil/lib/forge-std new file mode 160000 index 0000000..680ee66 --- /dev/null +++ b/veil/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 680ee6692649dcc7c617e05b2144932618264a83 diff --git a/veil/src/ISRC20.sol b/veil/src/ISRC20.sol new file mode 100644 index 0000000..eee54a6 --- /dev/null +++ b/veil/src/ISRC20.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @title ISRC20 +/// @notice Interface for Seismic's shielded fungible token standard +interface ISRC20 { + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + function balanceOf() external view returns (uint256); + function approve(address spender, suint256 amount) external returns (bool); + function transfer(address to, suint256 amount) external returns (bool); + function transferFrom(address from, address to, suint256 amount) external returns (bool); +} diff --git a/veil/src/Veil.sol b/veil/src/Veil.sol new file mode 100644 index 0000000..6f1d4ae --- /dev/null +++ b/veil/src/Veil.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ISRC20} from "./ISRC20.sol"; + +/// @title Veil +/// @notice A raffle where ticket stakes stay hidden until the draw, and the winner's identity +/// stays hidden until they choose to claim. +/// @dev Entrants buy tickets by pulling payment through a shielded SRC20 token, so the amount +/// moved never appears as a public transaction field. Each entrant's ticket range is +/// carved out of a pool total that stays encrypted until the draw declassifies it. The +/// winning ticket number itself is never declassified, so there is nothing for anyone, +/// including the entrant who triggers the draw, to read and react to. The only way to +/// learn anything about the outcome is to call claim, in a later block than the draw, which +/// reveals only whether the caller's own private range happens to cover it. +contract Veil { + /// @dev An entrant's private stake in the pool, plus their public claim state + struct Entrant { + suint256 rangeStart; + suint256 rangeEnd; + bool hasEntered; + bool claimed; + } + + /// @notice The token entrants pay with and the token the prize is paid out in + ISRC20 public immutable ticketToken; + + /// @notice Cost of a single ticket, denominated in ticketToken units + uint256 public immutable ticketPrice; + + /// @notice Timestamp after which entries close and the raffle can be drawn + uint256 public immutable entryDeadline; + + /// @notice Total tickets sold, hidden until draw() declassifies it + suint256 private totalTickets; + + /// @notice Set once the draw has run + bool public drawn; + + /// @notice The block the draw ran in + /// @dev claim() may only run in a later block, so the draw and the check of its outcome + /// can never be bundled into one atomic, revertible transaction + uint256 public drawBlock; + + /// @notice The winning ticket number, set at draw time + /// @dev Never declassified. There is no getter and no event field for this value. The only + /// way to learn anything about it is to call claim, which reveals nothing beyond + /// whether the caller's own range covers it. + suint256 private winningTicket; + + /// @notice Total tickets sold, declassified at draw time + /// @dev Equal to the pool's ticketToken balance divided by ticketPrice + uint256 public totalTicketsSold; + + mapping(address => Entrant) private entrants; + + /// @dev Ticket count is intentionally not part of this event, and neither is the winning + /// ticket number + event Entered(address indexed entrant); + event Drawn(uint256 totalTicketsSold); + event Claimed(address indexed winner, uint256 prize); + + constructor(ISRC20 _ticketToken, uint256 _ticketPrice, uint256 _duration) { + require(address(_ticketToken) != address(0), "ticket token cannot be the zero address"); + require(_ticketPrice > 0, "ticket price must be positive"); + require(_duration > 0, "duration must be positive"); + ticketToken = _ticketToken; + ticketPrice = _ticketPrice; + entryDeadline = block.timestamp + _duration; + } + + /// @notice Buy tickets before the deadline. Each address may enter once. + /// @dev Requires a prior approve() on the ticket token for at least ticketPrice * numTickets + /// @param numTickets Number of tickets to buy, encrypted end to end + function enter(suint256 numTickets) external { + require(block.timestamp < entryDeadline, "entries are closed"); + require(numTickets > suint256(0), "must buy at least one ticket"); + require(!entrants[msg.sender].hasEntered, "already entered"); + + bool success = ticketToken.transferFrom(msg.sender, address(this), suint256(ticketPrice) * numTickets); + require(success, "payment failed"); + + suint256 start = totalTickets; + totalTickets = start + numTickets; + + entrants[msg.sender] = + Entrant({rangeStart: start, rangeEnd: start + numTickets, hasEntered: true, claimed: false}); + + emit Entered(msg.sender); + } + + /// @notice Draw the winning ticket once entries have closed + /// @dev Declassifies the ticket total so a winning number can be chosen from it, but the + /// winning number itself stays shielded. Nothing here depends on the drawn value, so + /// this call always succeeds once its preconditions hold, and can never be bundled + /// with a later, outcome-dependent revert. + function draw() external { + require(block.timestamp >= entryDeadline, "entries still open"); + require(!drawn, "already drawn"); + + uint256 total = uint256(totalTickets); + require(total > 0, "no entrants"); + + drawn = true; + drawBlock = block.number; + totalTicketsSold = total; + winningTicket = unsafe_rng_u256() % suint256(total); + + emit Drawn(total); + } + + /// @notice Claim the prize if your ticket range covers the winning number + /// @dev Callable only in a block after the draw, so this can never be bundled atomically + /// with draw() itself. The shielded comparison below is the only place in the contract + /// where the drawn value is touched, and it runs once, for the caller alone. Its + /// outcome, revert or success, is the only thing anyone learns about the caller's + /// private range. + function claim() external { + require(drawn, "not drawn yet"); + require(block.number > drawBlock, "draw not finalized yet"); + + Entrant storage entrant = entrants[msg.sender]; + require(entrant.hasEntered, "did not enter"); + require(!entrant.claimed, "already claimed"); + + require(winningTicket >= entrant.rangeStart && winningTicket < entrant.rangeEnd, "not the winner"); + + entrant.claimed = true; + uint256 prize = ticketPrice * totalTicketsSold; + + bool success = ticketToken.transfer(msg.sender, suint256(prize)); + require(success, "payout failed"); + + emit Claimed(msg.sender, prize); + } +} diff --git a/veil/test/TestToken.sol b/veil/test/TestToken.sol new file mode 100644 index 0000000..03c3a6c --- /dev/null +++ b/veil/test/TestToken.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ISRC20} from "../src/ISRC20.sol"; + +/// @title TestToken +/// @notice Minimal SRC20 implementation for testing Veil against a real shielded token +contract TestToken is ISRC20 { + string public name = "Test Token"; + string public symbol = "TEST"; + uint8 public decimals = 18; + + mapping(address => suint256) private balance; + mapping(address => mapping(address => suint256)) private allowance; + + function mint(address to, suint256 amount) external { + unchecked { + balance[to] += amount; + } + } + + function balanceOf() external view returns (uint256) { + return uint256(balance[msg.sender]); + } + + function approve(address spender, suint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + + function transfer(address to, suint256 amount) external returns (bool) { + balance[msg.sender] -= amount; + unchecked { + balance[to] += amount; + } + return true; + } + + function transferFrom(address from, address to, suint256 amount) external returns (bool) { + suint256 allowed = allowance[from][msg.sender]; + if (allowed != suint256(type(uint256).max)) { + allowance[from][msg.sender] = allowed - amount; + } + balance[from] -= amount; + unchecked { + balance[to] += amount; + } + return true; + } +} diff --git a/veil/test/Veil.t.sol b/veil/test/Veil.t.sol new file mode 100644 index 0000000..0eaad58 --- /dev/null +++ b/veil/test/Veil.t.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {stdError} from "forge-std/StdError.sol"; +import {VeilTestBase} from "./VeilTestBase.sol"; + +contract VeilCorrectnessTest is VeilTestBase { + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + + function test_Enter_PullsExactPayment() public { + _fundAndApprove(alice, 4); + + uint256 veilBalanceBefore = _balanceOf(address(veil)); + + vm.prank(alice); + veil.enter(suint256(4)); + + assertEq(_balanceOf(alice), 0, "entrant should be debited the full payment"); + assertEq( + _balanceOf(address(veil)), veilBalanceBefore + TICKET_PRICE * 4, "veil should hold exactly the payment" + ); + } + + function test_Draw_DeclassifiesCorrectTotal() public { + _enter(alice, 3); + _enter(bob, 5); + _enter(carol, 2); + + _closeEntries(); + _drawWithRandomness(0); + + assertEq(veil.totalTicketsSold(), 10, "declassified total must equal the sum of all entries"); + } + + function test_Claim_WinnerReceivesFullPot() public { + _enter(alice, 3); + _enter(bob, 5); + _enter(carol, 2); + + uint256 pot = TICKET_PRICE * 10; + assertEq(_balanceOf(address(veil)), pot, "pot should equal every entrant's payment"); + + _closeEntries(); + // Raw RNG output 4 falls in bob's range [3, 8). + _drawWithRandomness(4); + vm.roll(block.number + 1); + + vm.prank(bob); + veil.claim(); + + assertEq(_balanceOf(bob), pot, "winner should receive the entire pot"); + assertEq(_balanceOf(address(veil)), 0, "veil should be fully drained after payout"); + } + + function test_TokenBalances_ReconcileEndToEnd() public { + _enter(alice, 3); + _enter(bob, 5); + _enter(carol, 2); + + uint256 totalPaid = TICKET_PRICE * 3 + TICKET_PRICE * 5 + TICKET_PRICE * 2; + assertEq(_balanceOf(address(veil)), totalPaid, "veil balance must equal the sum of all payments"); + + _closeEntries(); + _drawWithRandomness(0); + vm.roll(block.number + 1); + + vm.prank(alice); + veil.claim(); + + assertEq(_balanceOf(alice), totalPaid, "winner's payout must equal exactly what was collected"); + assertEq(_balanceOf(bob), 0, "non-winners receive nothing"); + assertEq(_balanceOf(carol), 0, "non-winners receive nothing"); + } + + /// @dev Sweeps every boundary and interior ticket value across three entrants and confirms + /// the ranges are contiguous, non-overlapping, and gapless: for every value in [0, total), + /// exactly one entrant can claim, and it is always the mathematically expected one. + function test_Ranges_ContiguousNonOverlappingGapless() public { + _enter(alice, 3); // expected range [0, 3) + _enter(bob, 5); // expected range [3, 8) + _enter(carol, 2); // expected range [8, 10) + _closeEntries(); + + uint256[7] memory winningValues = [uint256(0), 2, 3, 4, 7, 8, 9]; + address[7] memory expectedWinners = [alice, alice, bob, bob, bob, carol, carol]; + + for (uint256 i = 0; i < winningValues.length; i++) { + uint256 snapshot = vm.snapshotState(); + + _drawWithRandomness(winningValues[i]); + vm.roll(block.number + 1); + + address expected = expectedWinners[i]; + address[3] memory all = [alice, bob, carol]; + uint256 successes = 0; + + for (uint256 j = 0; j < all.length; j++) { + vm.prank(all[j]); + try veil.claim() { + successes++; + assertEq(all[j], expected, "the claiming entrant must match the expected range owner"); + } catch { + assertTrue(all[j] != expected, "the expected winner must not revert"); + } + } + + assertEq(successes, 1, "exactly one entrant must be able to claim for each winning value"); + + vm.revertToState(snapshot); + } + } +} + +contract VeilGuardsTest is VeilTestBase { + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + + /// @dev With no prior approve(), the token's own allowance bookkeeping underflows before + /// Veil ever gets a chance to check a return value: transferFrom reverts, it doesn't return + /// false. This is the SRC20's real failure mode and the honest one to assert against. + function test_Enter_RevertsWithoutApproval() public { + token.mint(alice, suint256(TICKET_PRICE)); + vm.prank(alice); + vm.expectRevert(stdError.arithmeticError); + veil.enter(suint256(1)); + } + + function test_Enter_RevertsWithInsufficientApproval() public { + token.mint(alice, suint256(TICKET_PRICE * 2)); + vm.prank(alice); + token.approve(address(veil), suint256(TICKET_PRICE)); + + vm.prank(alice); + vm.expectRevert(stdError.arithmeticError); + veil.enter(suint256(2)); + } + + function test_Enter_RevertsOnZeroTickets() public { + _fundAndApprove(alice, 1); + vm.prank(alice); + vm.expectRevert("must buy at least one ticket"); + veil.enter(suint256(0)); + } + + function test_Enter_RevertsOnDoubleEntry() public { + _enter(alice, 1); + _fundAndApprove(alice, 1); + vm.prank(alice); + vm.expectRevert("already entered"); + veil.enter(suint256(1)); + } + + function test_Enter_RevertsAfterDeadline() public { + _fundAndApprove(alice, 1); + _closeEntries(); + vm.prank(alice); + vm.expectRevert("entries are closed"); + veil.enter(suint256(1)); + } + + function test_Draw_RevertsBeforeDeadline() public { + _enter(alice, 1); + vm.expectRevert("entries still open"); + veil.draw(); + } + + function test_Draw_RevertsWithNoEntrants() public { + _closeEntries(); + vm.expectRevert("no entrants"); + veil.draw(); + } + + function test_Draw_RevertsIfAlreadyDrawn() public { + _enter(alice, 1); + _closeEntries(); + _drawWithRandomness(0); + + vm.expectRevert("already drawn"); + veil.draw(); + } + + function test_Claim_RevertsBeforeDraw() public { + _enter(alice, 1); + _closeEntries(); + + vm.prank(alice); + vm.expectRevert("not drawn yet"); + veil.claim(); + } + + function test_Claim_RevertsForNonEntrant() public { + _enter(alice, 1); + _closeEntries(); + _drawWithRandomness(0); + vm.roll(block.number + 1); + + vm.prank(bob); + vm.expectRevert("did not enter"); + veil.claim(); + } + + function test_Claim_RevertsOnDoubleClaim() public { + _enter(alice, 1); + _closeEntries(); + _drawWithRandomness(0); + vm.roll(block.number + 1); + + vm.prank(alice); + veil.claim(); + + vm.prank(alice); + vm.expectRevert("already claimed"); + veil.claim(); + } + + function test_Claim_RevertsForNonWinner() public { + _enter(alice, 1); // range [0, 1) + _enter(bob, 1); // range [1, 2) + _closeEntries(); + _drawWithRandomness(0); // winning ticket is 0, alice's range + vm.roll(block.number + 1); + + vm.prank(bob); + vm.expectRevert("not the winner"); + veil.claim(); + } +} diff --git a/veil/test/VeilFuzz.t.sol b/veil/test/VeilFuzz.t.sol new file mode 100644 index 0000000..df17e0d --- /dev/null +++ b/veil/test/VeilFuzz.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {VeilTestBase} from "./VeilTestBase.sol"; + +contract VeilFuzzTest is VeilTestBase { + address internal alice = makeAddr("fuzzAlice"); + address internal bob = makeAddr("fuzzBob"); + address internal carol = makeAddr("fuzzCarol"); + address internal dave = makeAddr("fuzzDave"); + + /// @dev For an arbitrary set of ticket counts and an arbitrary raw randomness value, exactly + /// one of the four entrants can claim, it is always the entrant whose cumulative range + /// covers the resulting winning ticket, a losing claim always reverts, and the winner is + /// paid exactly the full pot exactly once. + function testFuzz_ExactlyOneEntrantCanClaim( + uint8 aTickets, + uint8 bTickets, + uint8 cTickets, + uint8 dTickets, + uint256 rawRandomness + ) public { + uint256 a = bound(aTickets, 1, 50); + uint256 b = bound(bTickets, 1, 50); + uint256 c = bound(cTickets, 1, 50); + uint256 d = bound(dTickets, 1, 50); + + _enter(alice, a); + _enter(bob, b); + _enter(carol, c); + _enter(dave, d); + + uint256 total = a + b + c + d; + _closeEntries(); + _drawWithRandomness(rawRandomness); + vm.roll(block.number + 1); + + uint256 winningTicket = rawRandomness % total; + + address[4] memory entrants = [alice, bob, carol, dave]; + uint256[4] memory counts = [a, b, c, d]; + + address expectedWinner; + uint256 cumulative; + for (uint256 i = 0; i < 4; i++) { + if (winningTicket >= cumulative && winningTicket < cumulative + counts[i]) { + expectedWinner = entrants[i]; + } + cumulative += counts[i]; + } + + uint256 pot = TICKET_PRICE * total; + uint256 successes = 0; + + for (uint256 i = 0; i < 4; i++) { + vm.prank(entrants[i]); + try veil.claim() { + successes++; + assertEq(entrants[i], expectedWinner, "only the range owner covering the winning ticket may claim"); + assertEq(_balanceOf(entrants[i]), pot, "the winner must receive the full pot exactly once"); + } catch { + assertTrue(entrants[i] != expectedWinner, "the true winner must never have its claim revert"); + } + } + + assertEq(successes, 1, "exactly one entrant must be able to claim"); + } +} diff --git a/veil/test/VeilSecurity.t.sol b/veil/test/VeilSecurity.t.sol new file mode 100644 index 0000000..a7dc279 --- /dev/null +++ b/veil/test/VeilSecurity.t.sol @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Vm} from "forge-std/Vm.sol"; +import {VeilTestBase} from "./VeilTestBase.sol"; +import {Veil} from "../src/Veil.sol"; +import {ISRC20} from "../src/ISRC20.sol"; + +/// @dev Enters Veil on its own behalf and bundles draw() with claim() in a single call, so a +/// revert anywhere in that call undoes both. This is the atomic grinding pattern the block gate +/// on claim() is meant to defeat. +contract BundlingAttacker { + Veil internal immutable veil; + ISRC20 internal immutable token; + + constructor(Veil _veil, ISRC20 _token) { + veil = _veil; + token = _token; + } + + function enterWithTickets(uint256 numTickets, uint256 payment) external { + token.approve(address(veil), suint256(payment)); + veil.enter(suint256(numTickets)); + } + + function attempt() external { + veil.draw(); + veil.claim(); + } +} + +/// @dev A token that is also its own entrant. When Veil pays out through transfer(), it calls +/// back into claim() before returning, simulating a nonstandard token with a transfer hook. +/// The canonical SRC20 shape has no such hook, transfer() never calls the recipient, so this +/// is not exploitable against the token Veil actually ships against. It proves that Veil's own +/// ordering in claim() (marking the caller claimed before the external transfer call) would +/// still hold the line if it were ever pointed at a token that did call back. +contract ReentrantToken is ISRC20 { + Veil internal veil; + mapping(address => suint256) private balance; + + bool public reentryAttempted; + bool public reentrySucceeded; + + function setVeil(Veil _veil) external { + veil = _veil; + } + + function mint(address to, suint256 amount) external { + unchecked { + balance[to] += amount; + } + } + + function enterRaffle(suint256 numTickets, suint256 payment) external { + this.approve(address(veil), payment); + veil.enter(numTickets); + } + + function name() external pure returns (string memory) { + return "Reentrant Token"; + } + + function symbol() external pure returns (string memory) { + return "RE"; + } + + function decimals() external pure returns (uint8) { + return 18; + } + + function balanceOf() external view returns (uint256) { + return uint256(balance[msg.sender]); + } + + function approve(address, suint256) external pure returns (bool) { + return true; + } + + function transferFrom(address from, address to, suint256 amount) external returns (bool) { + unchecked { + balance[from] -= amount; + balance[to] += amount; + } + return true; + } + + function transfer(address to, suint256 amount) external returns (bool) { + reentryAttempted = true; + try veil.claim() { + reentrySucceeded = true; + } catch { + reentrySucceeded = false; + } + + unchecked { + balance[msg.sender] -= amount; + balance[to] += amount; + } + return true; + } +} + +contract VeilSecurityTest is VeilTestBase { + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + + function test_Claim_RevertsInSameBlockAsDraw() public { + _enter(alice, 1); + _closeEntries(); + _drawWithRandomness(0); + + vm.prank(alice); + vm.expectRevert("draw not finalized yet"); + veil.claim(); + } + + /// @dev Reproduces the atomic bundling grind directly: the attacker holds the exact ticket + /// range that the mocked randomness would make the winner, and still cannot collect, + /// because claim() cannot succeed in the same block as draw() no matter how the call is + /// composed. The same attacker succeeding afterward through the honest, unbundled path + /// proves this is the bundling attack failing, not the raffle being broken. + function test_BundledDrawAndClaim_CannotForceAWin() public { + BundlingAttacker attacker = new BundlingAttacker(veil, ISRC20(address(token))); + token.mint(address(attacker), suint256(TICKET_PRICE)); + attacker.enterWithTickets(1, TICKET_PRICE); // attacker's range is [0, 1) + + _closeEntries(); + + for (uint256 i = 0; i < 5; i++) { + _mockRng(0); // raw output 0 always lands the win on the attacker's range + vm.expectRevert("draw not finalized yet"); + attacker.attempt(); + + assertFalse(veil.drawn(), "a fully reverted bundle must leave the raffle undrawn"); + vm.roll(block.number + 1); + } + + assertEq(_balanceOf(address(attacker)), 0, "the attacker must never have collected a prize"); + + // The honest, unbundled path still works for the same attacker. + _drawWithRandomness(0); + vm.roll(block.number + 1); + vm.prank(address(attacker)); + veil.claim(); + assertEq(_balanceOf(address(attacker)), TICKET_PRICE, "claiming honestly in a later block must succeed"); + } + + function test_WinningTicket_HasNoGetter() public { + (bool success,) = address(veil).call(abi.encodeWithSignature("winningTicket()")); + assertFalse(success, "there must be no public accessor for the winning ticket"); + } + + function test_DrawnEvent_OnlyCarriesTotal() public { + _enter(alice, 3); + _enter(bob, 5); + _closeEntries(); + + vm.recordLogs(); + _drawWithRandomness(0); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool found = false; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].emitter != address(veil)) continue; + if (logs[i].topics[0] != keccak256("Drawn(uint256)")) continue; + + found = true; + assertEq(logs[i].topics.length, 1, "Drawn must have no indexed parameters"); + assertEq(logs[i].data.length, 32, "Drawn's data must be exactly one word, the total"); + assertEq(abi.decode(logs[i].data, (uint256)), 8, "the single word must be the declassified total"); + } + + assertTrue(found, "Drawn event must have been emitted"); + } + + /// @dev The first entrant holds the range that raw RNG output 0 always wins. If the winning + /// draw were observable through gas or trace differences, a wrapper controlling this range + /// could distinguish a favorable draw() call from an unfavorable one and choose whether to + /// keep it. Proving the gas cost is identical either way proves that signal does not exist. + function test_FirstEntrantWrapper_DrawGasIdenticalWinOrLose() public { + _enter(alice, 2); // alice's range is [0, 2) + _enter(bob, 8); // bob's range is [2, 10) + _closeEntries(); + + uint256 snapshot = vm.snapshotState(); + + _mockRng(0); // lands in alice's range: a win for the first entrant + uint256 gasBeforeWin = gasleft(); + veil.draw(); + uint256 gasUsedWin = gasBeforeWin - gasleft(); + + vm.revertToState(snapshot); + + _mockRng(9); // lands in bob's range: a loss for the first entrant + uint256 gasBeforeLose = gasleft(); + veil.draw(); + uint256 gasUsedLose = gasBeforeLose - gasleft(); + + assertEq(gasUsedWin, gasUsedLose, "draw() must cost identical gas regardless of who it favors"); + } + + /// @dev Same proof, for the last entrant's range, which is the other boundary an off-chain + /// wrapper could otherwise try to key its decision on. + function test_LastEntrantWrapper_DrawGasIdenticalWinOrLose() public { + _enter(alice, 8); // alice's range is [0, 8) + _enter(bob, 2); // bob's range is [8, 10), the last entrant + _closeEntries(); + + uint256 snapshot = vm.snapshotState(); + + _mockRng(9); // lands in bob's range: a win for the last entrant + uint256 gasBeforeWin = gasleft(); + veil.draw(); + uint256 gasUsedWin = gasBeforeWin - gasleft(); + + vm.revertToState(snapshot); + + _mockRng(0); // lands in alice's range: a loss for the last entrant + uint256 gasBeforeLose = gasleft(); + veil.draw(); + uint256 gasUsedLose = gasBeforeLose - gasleft(); + + assertEq(gasUsedWin, gasUsedLose, "draw() must cost identical gas regardless of who it favors"); + } + + /// @dev Deploys Veil against a token that calls back into claim() during its own payout + /// transfer. The reentrant call must revert, because claimed is set to true before the + /// transfer runs, and the original, outer claim() must still complete and pay out exactly + /// once. This is a defense-in-depth test of Veil's checks-effects-interactions ordering, + /// not a reproduction of an attack possible against the canonical SRC20 shape Veil actually + /// uses, since that token's transfer() never calls the recipient. + function test_Claim_ReentrancyDuringPayoutCannotDoubleClaim() public { + ReentrantToken reentrantToken = new ReentrantToken(); + Veil reentrantVeil = new Veil(ISRC20(address(reentrantToken)), TICKET_PRICE, DURATION); + reentrantToken.setVeil(reentrantVeil); + + reentrantToken.mint(address(reentrantToken), suint256(TICKET_PRICE)); + reentrantToken.enterRaffle(suint256(1), suint256(TICKET_PRICE)); + + vm.warp(block.timestamp + DURATION); + _mockRng(0); + reentrantVeil.draw(); + vm.roll(block.number + 1); + + vm.prank(address(reentrantToken)); + reentrantVeil.claim(); + + assertTrue(reentrantToken.reentryAttempted(), "the payout transfer must have attempted to reenter"); + assertFalse(reentrantToken.reentrySucceeded(), "the reentrant claim() call must have reverted"); + + vm.prank(address(reentrantToken)); + vm.expectRevert("already claimed"); + reentrantVeil.claim(); + } +} diff --git a/veil/test/VeilTestBase.sol b/veil/test/VeilTestBase.sol new file mode 100644 index 0000000..71bdb46 --- /dev/null +++ b/veil/test/VeilTestBase.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Veil} from "../src/Veil.sol"; +import {ISRC20} from "../src/ISRC20.sol"; +import {TestToken} from "./TestToken.sol"; + +/// @title VeilTestBase +/// @notice Shared setup and helpers for the Veil test suite +abstract contract VeilTestBase is Test { + address internal constant RNG_PRECOMPILE = address(0x64); + + uint256 internal constant TICKET_PRICE = 10e18; + uint256 internal constant DURATION = 1 days; + + TestToken internal token; + Veil internal veil; + + function setUp() public virtual { + token = new TestToken(); + veil = new Veil(ISRC20(address(token)), TICKET_PRICE, DURATION); + } + + /// @dev Mocks the RNG builtin to return a fixed 32 byte value on its next call + function _mockRng(uint256 value) internal { + vm.mockCall(RNG_PRECOMPILE, abi.encodePacked(uint32(32)), abi.encodePacked(bytes32(value))); + } + + /// @dev Funds an address with enough token to buy numTickets tickets and approves Veil + function _fundAndApprove(address entrant, uint256 numTickets) internal { + token.mint(entrant, suint256(TICKET_PRICE * numTickets)); + vm.prank(entrant); + token.approve(address(veil), suint256(TICKET_PRICE * numTickets)); + } + + /// @dev Funds, approves, and enters on behalf of an EOA-style test address + function _enter(address entrant, uint256 numTickets) internal { + _fundAndApprove(entrant, numTickets); + vm.prank(entrant); + veil.enter(suint256(numTickets)); + } + + function _balanceOf(address account) internal returns (uint256) { + vm.prank(account); + return token.balanceOf(); + } + + function _closeEntries() internal { + vm.warp(block.timestamp + DURATION); + } + + function _drawWithRandomness(uint256 rngValue) internal { + _mockRng(rngValue); + veil.draw(); + } +}