-
Notifications
You must be signed in to change notification settings - Fork 4
Heleolabs/lottery blueprint #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
D45putspin
wants to merge
3
commits into
HathorNetwork:master
Choose a base branch
from
D45putspin:heleolabs/lottery-blueprint
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | int]: | ||
| return { | ||
| "description": self.description, | ||
| "price": self.price, | ||
| "commission": self.commission, | ||
| "pot": self.pot, | ||
| "state": self.state, | ||
| "creator": self.creator.hex(), | ||
| "participant_count": len(self.participants), | ||
| "winner": self.winner.hex() if self.state == "CLOSED" else "", | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.