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
119 changes: 119 additions & 0 deletions blueprints/lottery/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Lottery — Simple HTR Lottery Blueprint

This folder contains the **Lottery** blueprint for Hathor — a single-round lottery where users buy tickets with HTR and a winner receives the prize after the lottery is drawn.

---

## Files

- `lottery.py` — Blueprint implementation
- `tests.py` — Automated test suite (Blueprint SDK / `BlueprintTestCase`)

---

## Blueprint Summary

### Purpose
The blueprint implements a **single lottery round** with a fixed ticket price and a configurable commission paid to the creator. The creator can draw the winner any time before timeout; after timeout anyone can draw.

### Key Features
- Configurable ticket price and commission (0–100%)
- 10 HTR creation fee (claimable by creator)
- Ticket purchases accumulate the pot
- Winner selection using the contract RNG (deterministic across nodes)
- Creator and winner payouts tracked independently
- Event emission for creation, ticket purchase, winner draw, and reward claims

### Roles
- **Creator** — Address that initializes the contract; receives creation fee and commission
- **Participants** — Addresses that buy tickets and are eligible to win

---

## Methods Overview

### Public Methods (State-Changing)

| Method | Description |
|--------|-------------|
| `initialize(description, ticket_price, commission_percent)` | Creates a new lottery with ticket price and commission |
| `buy_ticket()` | Buys a ticket (requires HTR deposit ≥ ticket price) |
| `draw_winner()` | Draws a winner; creator-only before timeout, anyone after |
| `claim_reward()` | Withdraws available reward for creator or winner |

### View Methods (Read-Only)

| Method | Returns |
|--------|---------|
| `get_state()` | Current lottery state snapshot |

---

## Custom Errors

| Error | Cause |
|-------|-------|
| `InvalidPrice` | Ticket price ≤ 0 or commission outside 0–100 |
| `InsufficientFunds` | Missing deposit or deposit below required amount |
| `LotteryClosed` | Action not allowed because lottery is closed |
| `Unauthorized` | Caller not allowed to draw/withdraw |

---

## Key Constants

| Parameter | Value |
|-----------|-------|
| `CREATION_FEE` | `1000` (10 HTR in cents) |
| `TIMEOUT_SECONDS` | `2,592,000` (30 days) |

---

## Example Usage

```python
# Initialize a lottery with a 5 HTR ticket price and 10% commission
contract.initialize("Weekly Lottery", ticket_price=500, commission_percent=10)

# Buy a ticket (requires deposit >= 5 HTR)
contract.buy_ticket()

# Draw winner (creator only until timeout)
contract.draw_winner()

# Claim reward (winner or creator)
contract.claim_reward()
```

---

## Events

The contract emits JSON-formatted events:

```json
{"event": "LotteryCreated", "creator": "<hex-address>", "fee": 1000}
{"event": "TicketBought", "buyer": "<hex-address>", "count": 3}
{"event": "WinnerDrawn", "winner": "<hex-address>", "prize": 1350}
{"event": "RewardClaimed", "claimer": "<hex-address>", "amount": 1350}
```

---

## Security Considerations

- Winner selection uses the contract RNG, which is deterministic across nodes.
- Ticket purchases accept deposits greater than the ticket price and add the full amount to the pot.
- Only the creator can draw before timeout; after timeout anyone can draw.
- Rewards are tracked per-role and can be claimed incrementally.

---

## How to Run Tests

From the root of a `hathor-core` checkout:

```bash
poetry install
poetry run pytest -v tests.py
```
197 changes: 197 additions & 0 deletions blueprints/lottery/lottery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
from hathor import (
Address,
Blueprint,
Context,
HATHOR_TOKEN_UID,
NCDepositAction,
NCFail,
NCWithdrawalAction,
Timestamp,
export,
public,
view,
)

CREATION_FEE = 1000 # 10 HTR in cents
TIMEOUT_SECONDS = 60 * 60 * 24 * 30 # 30 days
EMPTY_ADDRESS = Address(b"\x00" * 25)


class InvalidPrice(NCFail):
pass


class LotteryClosed(NCFail):
pass


class InsufficientFunds(NCFail):
pass


class Unauthorized(NCFail):
pass


@export
class Lottery(Blueprint):
# Single lottery state - top-level fields for API compatibility
description: str
price: int
commission: int
pot: int
state: str
creator: Address
creation_timestamp: Timestamp
participants: list[Address]
winner: Address

# Payout tracking
creator_payout: int
winner_payout: int

def _get_single_action(self, ctx: Context):
actions = ctx.actions.get(HATHOR_TOKEN_UID)
if actions is None or len(actions) != 1:
return None
return actions[0]

@public(allow_deposit=True)
def initialize(self, ctx: Context, description: str, ticket_price: int, commission_percent: int) -> None:
if ticket_price <= 0:
raise InvalidPrice("Ticket price must be positive.")
if not (0 <= commission_percent <= 100):
raise InvalidPrice("Commission must be between 0 and 100.")

# Require 10 HTR creation fee
action = self._get_single_action(ctx)
if not isinstance(action, NCDepositAction):
raise InsufficientFunds("10 HTR creation fee required.")
if action.amount < CREATION_FEE:
raise InsufficientFunds(f"Creation fee too low. Required: {CREATION_FEE}")

self.description = description
self.price = ticket_price
self.commission = commission_percent
self.pot = 0
self.state = "OPEN"
self.creator = ctx.get_caller_address()
self.creation_timestamp = ctx.block.timestamp
self.participants = []
self.winner = EMPTY_ADDRESS
self.creator_payout = action.amount # Fee stays in contract, claimable by creator
self.winner_payout = 0

event_data = (
f'{{"event": "LotteryCreated", "creator": "{self.creator.hex()}", "fee": {action.amount}}}'
)
self.syscall.emit_event(event_data.encode("utf-8"))

@public(allow_deposit=True)
def buy_ticket(self, ctx: Context) -> None:
if self.state != "OPEN":
raise LotteryClosed("Lottery is closed.")

action = self._get_single_action(ctx)
if not isinstance(action, NCDepositAction):
raise InsufficientFunds("Payment required.")

if action.amount < self.price:
raise InsufficientFunds(f"Amount too low. Required: {self.price}")

buyer = ctx.get_caller_address()
self.participants.append(buyer)
self.pot += action.amount

event_data = (
f'{{"event": "TicketBought", "buyer": "{buyer.hex()}", "count": {len(self.participants)}}}'
)
self.syscall.emit_event(event_data.encode("utf-8"))

@public
def draw_winner(self, ctx: Context) -> None:
is_owner = ctx.get_caller_address() == self.creator
is_expired = (ctx.block.timestamp - self.creation_timestamp) > TIMEOUT_SECONDS

if not is_owner and not is_expired:
raise Unauthorized("Only creator can draw before timeout.")

if self.state != "OPEN":
raise LotteryClosed("Not open.")

participant_count = len(self.participants)
if participant_count == 0:
self.state = "CLOSED"
return

# Pseudo-random winner selection from contract RNG (deterministic across nodes)
# Uses rejection sampling internally, avoiding modulo bias.
winner_index = self.syscall.rng.randbelow(participant_count)
self.winner = self.participants[winner_index]
self.state = "CLOSED"

total_pot = self.pot
comm_amount = (total_pot * self.commission) // 100
prize_amount = total_pot - comm_amount

self.creator_payout += comm_amount
self.winner_payout = prize_amount

event_data = (
f'{{"event": "WinnerDrawn", "winner": "{self.winner.hex()}", "prize": {prize_amount}}}'
)
self.syscall.emit_event(event_data.encode("utf-8"))

@public(allow_withdrawal=True)
def claim_reward(self, ctx: Context) -> None:
if self.state != "CLOSED":
raise LotteryClosed("Lottery not closed.")

caller = ctx.get_caller_address()
action = self._get_single_action(ctx)
if not isinstance(action, NCWithdrawalAction):
raise Unauthorized("Withdrawal needed.")

# Calculate total available for this specific caller
available = 0
if caller == self.winner:
available += self.winner_payout
if caller == self.creator:
available += self.creator_payout

if available == 0:
raise Unauthorized("No rewards available for this address.")

if action.amount > available:
raise Unauthorized(f"Amount exceeds available rewards: {available}")

# Deduct from balances (prioritize winner payout)
remaining = action.amount

if caller == self.winner:
to_deduct = min(remaining, self.winner_payout)
self.winner_payout -= to_deduct
remaining -= to_deduct

if remaining > 0 and caller == self.creator:
to_deduct = min(remaining, self.creator_payout)
self.creator_payout -= to_deduct
remaining -= to_deduct

event_data = (
f'{{"event": "RewardClaimed", "claimer": "{caller.hex()}", "amount": {action.amount}}}'
)
self.syscall.emit_event(event_data.encode("utf-8"))

@view
def get_state(self) -> dict[str, str]:
return {
"description": self.description,
"price": str(self.price),
"commission": str(self.commission),
"pot": str(self.pot),
"state": self.state,
"creator": self.creator.hex(),
"participant_count": str(len(self.participants)),
"winner": self.winner.hex() if self.state == "CLOSED" else "",
}
Loading