diff --git a/blueprints/lottery/README.md b/blueprints/lottery/README.md new file mode 100644 index 0000000..2c31799 --- /dev/null +++ b/blueprints/lottery/README.md @@ -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": "", "fee": 1000} +{"event": "TicketBought", "buyer": "", "count": 3} +{"event": "WinnerDrawn", "winner": "", "prize": 1350} +{"event": "RewardClaimed", "claimer": "", "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 +``` diff --git a/blueprints/lottery/lottery.py b/blueprints/lottery/lottery.py new file mode 100644 index 0000000..ea1a9ef --- /dev/null +++ b/blueprints/lottery/lottery.py @@ -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 "", + } diff --git a/blueprints/lottery/tests.py b/blueprints/lottery/tests.py new file mode 100644 index 0000000..8871cad --- /dev/null +++ b/blueprints/lottery/tests.py @@ -0,0 +1,257 @@ +from hathor.reactor import get_global_reactor, initialize_global_reactor + +try: + get_global_reactor() +except Exception: + initialize_global_reactor(use_asyncio_reactor=True) + +from hathor.nanocontracts import HATHOR_TOKEN_UID +from hathor.nanocontracts.types import Address, NCDepositAction, NCWithdrawalAction, TokenUid +from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase + +from blueprints.lottery.lottery import ( + CREATION_FEE, + TIMEOUT_SECONDS, + Lottery, + InsufficientFunds, + InvalidPrice, + LotteryClosed, + Unauthorized, + EMPTY_ADDRESS, +) + + +class TestLottery(BlueprintTestCase): + def setUp(self) -> None: + super().setUp() + self.blueprint_id = self.gen_random_blueprint_id() + self.contract_id = self.gen_random_contract_id() + self._register_blueprint_class(Lottery, self.blueprint_id) + + genesis = self.manager.tx_storage.get_all_genesis() + self.tx = [t for t in genesis if t.is_transaction][0] + self.creator = self.gen_random_address() + + def _create_lottery( + self, + description: str = "Test Lottery", + ticket_price: int = 100, + commission_percent: int = 10, + creation_fee: int = CREATION_FEE, + timestamp: int = 100, + ) -> None: + token_uid = TokenUid(HATHOR_TOKEN_UID) + actions = [NCDepositAction(token_uid=token_uid, amount=creation_fee)] + ctx = self.create_context( + actions=actions, + vertex=self.tx, + caller_id=self.creator, + timestamp=timestamp, + ) + self.runner.create_contract( + self.contract_id, + self.blueprint_id, + ctx, + description, + ticket_price, + commission_percent, + ) + self.created_at = timestamp + + def _buy_ticket(self, buyer: Address, amount: int, timestamp: int) -> None: + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=amount)], + vertex=self.tx, + caller_id=buyer, + timestamp=timestamp, + ) + self.runner.call_public_method(self.contract_id, "buy_ticket", ctx) + + def test_initialize_requires_creation_fee(self) -> None: + ctx = self.create_context( + actions=[], + vertex=self.tx, + caller_id=self.creator, + timestamp=1, + ) + with self.assertRaises(InsufficientFunds): + self.runner.create_contract( + self.contract_id, + self.blueprint_id, + ctx, + "Lottery", + 100, + 10, + ) + + def test_initialize_validates_params(self) -> None: + token_uid = TokenUid(HATHOR_TOKEN_UID) + actions = [NCDepositAction(token_uid=token_uid, amount=CREATION_FEE)] + + ctx = self.create_context(actions=actions, vertex=self.tx, caller_id=self.creator, timestamp=1) + with self.assertRaises(InvalidPrice): + self.runner.create_contract( + self.gen_random_contract_id(), + self.blueprint_id, + ctx, + "Lottery", + 0, + 10, + ) + + ctx = self.create_context(actions=actions, vertex=self.tx, caller_id=self.creator, timestamp=2) + with self.assertRaises(InvalidPrice): + self.runner.create_contract( + self.gen_random_contract_id(), + self.blueprint_id, + ctx, + "Lottery", + 1, + 101, + ) + + def test_initialize_sets_state(self) -> None: + self._create_lottery(description="Weekly", ticket_price=250, commission_percent=15, timestamp=123) + + contract = self.get_readonly_contract(self.contract_id) + self.assertEqual(contract.description, "Weekly") + self.assertEqual(contract.price, 250) + self.assertEqual(contract.commission, 15) + self.assertEqual(contract.pot, 0) + self.assertEqual(contract.state, "OPEN") + self.assertEqual(contract.creator, self.creator) + self.assertEqual(contract.creation_timestamp, 123) + self.assertEqual(contract.participants, []) + self.assertEqual(contract.winner, EMPTY_ADDRESS) + self.assertEqual(contract.creator_payout, CREATION_FEE) + self.assertEqual(contract.winner_payout, 0) + + def test_buy_ticket_requires_payment(self) -> None: + self._create_lottery(ticket_price=200) + buyer = self.gen_random_address() + + ctx = self.create_context(vertex=self.tx, caller_id=buyer, timestamp=10) + with self.assertRaises(InsufficientFunds): + self.runner.call_public_method(self.contract_id, "buy_ticket", ctx) + + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=100)], + vertex=self.tx, + caller_id=buyer, + timestamp=11, + ) + with self.assertRaises(InsufficientFunds): + self.runner.call_public_method(self.contract_id, "buy_ticket", ctx) + + def test_buy_ticket_updates_pot_and_participants(self) -> None: + self._create_lottery(ticket_price=150) + buyer = self.gen_random_address() + self._buy_ticket(buyer=buyer, amount=150, timestamp=20) + + contract = self.get_readonly_contract(self.contract_id) + self.assertEqual(contract.pot, 150) + self.assertEqual(contract.participants, [buyer]) + + def test_draw_winner_requires_authority_before_timeout(self) -> None: + self._create_lottery() + other = self.gen_random_address() + ctx = self.create_context(vertex=self.tx, caller_id=other, timestamp=self.created_at + 1) + + with self.assertRaises(Unauthorized): + self.runner.call_public_method(self.contract_id, "draw_winner", ctx) + + def test_draw_winner_after_timeout(self) -> None: + self._create_lottery(ticket_price=300, commission_percent=20, timestamp=1000) + buyer_one = self.gen_random_address() + buyer_two = self.gen_random_address() + self._buy_ticket(buyer=buyer_one, amount=300, timestamp=1010) + self._buy_ticket(buyer=buyer_two, amount=300, timestamp=1020) + + ctx = self.create_context( + vertex=self.tx, + caller_id=self.gen_random_address(), + timestamp=self.created_at + TIMEOUT_SECONDS + 1, + ) + self.runner.call_public_method(self.contract_id, "draw_winner", ctx) + + contract = self.get_readonly_contract(self.contract_id) + self.assertEqual(contract.state, "CLOSED") + self.assertIn(contract.winner, [buyer_one, buyer_two]) + + total_pot = 600 + comm_amount = (total_pot * 20) // 100 + prize_amount = total_pot - comm_amount + self.assertEqual(contract.creator_payout, CREATION_FEE + comm_amount) + self.assertEqual(contract.winner_payout, prize_amount) + + def test_claim_reward_flow(self) -> None: + self._create_lottery(ticket_price=400, commission_percent=25, timestamp=200) + buyer = self.gen_random_address() + self._buy_ticket(buyer=buyer, amount=400, timestamp=201) + + draw_ctx = self.create_context(vertex=self.tx, caller_id=self.creator, timestamp=202) + self.runner.call_public_method(self.contract_id, "draw_winner", draw_ctx) + + contract = self.get_readonly_contract(self.contract_id) + winner = contract.winner + winner_amount = contract.winner_payout + creator_amount = contract.creator_payout + + token_uid = TokenUid(HATHOR_TOKEN_UID) + winner_ctx = self.create_context( + actions=[NCWithdrawalAction(token_uid=token_uid, amount=winner_amount)], + vertex=self.tx, + caller_id=winner, + timestamp=203, + ) + self.runner.call_public_method(self.contract_id, "claim_reward", winner_ctx) + + contract = self.get_readonly_contract(self.contract_id) + self.assertEqual(contract.winner_payout, 0) + + creator_ctx = self.create_context( + actions=[NCWithdrawalAction(token_uid=token_uid, amount=creator_amount)], + vertex=self.tx, + caller_id=self.creator, + timestamp=204, + ) + self.runner.call_public_method(self.contract_id, "claim_reward", creator_ctx) + + contract = self.get_readonly_contract(self.contract_id) + self.assertEqual(contract.creator_payout, 0) + + def test_claim_reward_rejects_unauthorized(self) -> None: + self._create_lottery(ticket_price=100, commission_percent=10, timestamp=300) + buyer = self.gen_random_address() + self._buy_ticket(buyer=buyer, amount=100, timestamp=301) + + draw_ctx = self.create_context(vertex=self.tx, caller_id=self.creator, timestamp=302) + self.runner.call_public_method(self.contract_id, "draw_winner", draw_ctx) + + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCWithdrawalAction(token_uid=token_uid, amount=1)], + vertex=self.tx, + caller_id=self.gen_random_address(), + timestamp=303, + ) + with self.assertRaises(Unauthorized): + self.runner.call_public_method(self.contract_id, "claim_reward", ctx) + + def test_buy_ticket_after_close_fails(self) -> None: + self._create_lottery() + draw_ctx = self.create_context(vertex=self.tx, caller_id=self.creator, timestamp=10) + self.runner.call_public_method(self.contract_id, "draw_winner", draw_ctx) + + buyer = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=100)], + vertex=self.tx, + caller_id=buyer, + timestamp=11, + ) + with self.assertRaises(LotteryClosed): + self.runner.call_public_method(self.contract_id, "buy_ticket", ctx)