Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
37 changes: 37 additions & 0 deletions veil/README.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions veil/foundry.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
1 change: 1 addition & 0 deletions veil/lib/forge-std
Submodule forge-std added at 680ee6
14 changes: 14 additions & 0 deletions veil/src/ISRC20.sol
Original file line number Diff line number Diff line change
@@ -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);
}
136 changes: 136 additions & 0 deletions veil/src/Veil.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
50 changes: 50 additions & 0 deletions veil/test/TestToken.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading