-
Notifications
You must be signed in to change notification settings - Fork 4
Add:Pxiel Contract , tests and readme #6
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
base: master
Are you sure you want to change the base?
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # Pxiel — Collaborative Pixel Canvas Blueprint | ||
|
|
||
| This folder contains the **Pxiel** blueprint for Hathor — a collaborative pixel art canvas where users can paint pixels by paying HTR fees. | ||
|
|
||
| --- | ||
|
|
||
| ## Files | ||
|
|
||
| - `pxiel.py` — Blueprint implementation | ||
| - `tests_pxiel.py` — Automated test suite (Blueprint SDK / `BlueprintTestCase`) | ||
|
|
||
| --- | ||
|
|
||
| ## Blueprint Summary | ||
|
|
||
| ### Purpose | ||
| The blueprint provides a **collaborative pixel art canvas** (similar to Reddit's r/place) where anyone can paint pixels on a shared grid by depositing HTR as a fee. The canvas owner can withdraw accumulated fees. | ||
|
|
||
| ### Key Features | ||
| - Configurable canvas size and fee per pixel | ||
| - Single pixel painting with fee deposit | ||
| - Batch painting (up to 32 pixels per transaction) | ||
| - Fee collection and owner withdrawal | ||
| - Paginated view of all painted pixels | ||
| - Event emission for real-time updates | ||
|
|
||
| ### Roles | ||
| - **Owner** — The address that initialized the contract; can withdraw accumulated fees | ||
| - **Painter** — Any user who pays the fee to paint pixels | ||
|
|
||
| --- | ||
|
|
||
| ## Methods Overview | ||
|
|
||
| ### Public Methods (State-Changing) | ||
|
|
||
| | Method | Description | | ||
| |--------|-------------| | ||
| | `initialize(size, fee_htr)` | Creates a new canvas with given size (NxN) and fee per pixel | | ||
| | `paint(x, y, color)` | Paints a single pixel (requires HTR deposit ≥ fee) | | ||
| | `paint_batch(xs, ys, colors)` | Paints multiple pixels in one transaction (max 32) | | ||
| | `withdraw_fees()` | Owner withdraws collected fees | | ||
|
|
||
| ### View Methods (Read-Only) | ||
|
|
||
| | Method | Returns | | ||
| |--------|---------| | ||
| | `get_pixel_info(x, y)` | Color, last painter address, and timestamp for a pixel | | ||
| | `get_stats()` | Total paint count and fees collected | | ||
| | `get_owner()` | Owner address | | ||
| | `get_canvas_size()` | Canvas dimension (N for NxN grid) | | ||
| | `get_paint_fee()` | Fee in HTR cents per pixel | | ||
| | `get_pixels_count()` | Number of painted pixels | | ||
| | `get_pixels_page(offset, limit)` | Paginated list of painted pixels (max 1000 per page) | | ||
|
|
||
| --- | ||
|
|
||
| ## Custom Errors | ||
|
|
||
| | Error | Cause | | ||
| |-------|-------| | ||
| | `OutOfBounds` | Coordinates (x, y) are outside canvas bounds | | ||
| | `InvalidColorFormat` | Color is not in `#RRGGBB` hex format | | ||
| | `EmptyBatch` | Batch is empty, too large (>32), or arrays have mismatched sizes | | ||
| | `FeeRequired` | No HTR deposit or deposit amount is below required fee | | ||
|
|
||
| --- | ||
|
|
||
| ## Key Constants | ||
|
|
||
| | Parameter | Value | | ||
| |-----------|-------| | ||
| | `MAX_BATCH_SIZE` | 32 pixels | | ||
| | `MAX_PIXELS_PAGE_SIZE` | 1000 pixels | | ||
|
|
||
| --- | ||
|
|
||
| ## Example Usage | ||
|
|
||
| ```python | ||
| # Initialize a 100x100 canvas with 1 HTR cent fee per pixel | ||
| contract.initialize(size=100, fee_htr=1) | ||
|
|
||
| # Paint pixel at (10, 20) with color #FF5733 | ||
| contract.paint(x=10, y=20, color="#FF5733") # requires 1 HTR cent deposit | ||
|
|
||
| # Batch paint 3 pixels | ||
| contract.paint_batch( | ||
| xs=[0, 1, 2], | ||
| ys=[0, 1, 2], | ||
| colors=["#FF0000", "#00FF00", "#0000FF"] | ||
| ) # requires 3 HTR cents deposit | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Events | ||
|
|
||
| The contract emits a `Paint` event for each pixel painted: | ||
|
|
||
| ```json | ||
| {"event": "Paint", "x": 10, "y": 20, "color": "#FF5733", "fee": 1} | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Security Considerations | ||
|
|
||
| - All coordinates are validated against canvas bounds | ||
| - Color format is strictly validated (`#RRGGBB` hex) | ||
| - Fee deposits are validated against required minimum | ||
| - Only the owner can withdraw accumulated fees | ||
| - Batch size is capped to prevent gas exhaustion | ||
|
|
||
| --- | ||
|
|
||
| ## How to Run Tests | ||
|
|
||
| From the root of a `hathor-core` checkout: | ||
|
|
||
| ```bash | ||
| poetry install | ||
| poetry run pytest -v tests_pxiel.py | ||
| ``` |
glevco marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,219 @@ | ||||||||
| from typing import Optional | ||||||||
|
|
||||||||
| from hathor import ( | ||||||||
| Address, | ||||||||
| Blueprint, | ||||||||
| Context, | ||||||||
| HATHOR_TOKEN_UID, | ||||||||
| NCDepositAction, | ||||||||
| NCFail, | ||||||||
| NCWithdrawalAction, | ||||||||
| Timestamp, | ||||||||
| export, | ||||||||
| public, | ||||||||
| view, | ||||||||
| ) | ||||||||
|
|
||||||||
|
|
||||||||
| class OutOfBounds(NCFail): | ||||||||
| pass | ||||||||
|
|
||||||||
|
|
||||||||
| class InvalidColorFormat(NCFail): | ||||||||
| pass | ||||||||
|
|
||||||||
|
|
||||||||
| class EmptyBatch(NCFail): | ||||||||
|
|
||||||||
| pass | ||||||||
|
|
||||||||
|
|
||||||||
| class FeeRequired(NCFail): | ||||||||
|
|
||||||||
| pass | ||||||||
|
|
||||||||
|
|
||||||||
| MAX_BATCH_SIZE = 32 | ||||||||
| MAX_PIXELS_PAGE_SIZE = 1000 | ||||||||
|
|
||||||||
|
|
||||||||
| @export | ||||||||
| class Pxiel(Blueprint): | ||||||||
|
|
||||||||
| owner: Address | ||||||||
| size: int | ||||||||
| fee_htr: int | ||||||||
| paint_count: int | ||||||||
| fees_collected: int | ||||||||
| pixels: dict[str, str] | ||||||||
| pixel_keys: list[str] | ||||||||
| last_painted_by: dict[str, Address] | ||||||||
| last_painted_at: dict[str, Timestamp] | ||||||||
|
|
||||||||
| @public | ||||||||
| def initialize(self, ctx: Context, size: int, fee_htr: int) -> None: | ||||||||
|
|
||||||||
| self.owner = ctx.get_caller_address() | ||||||||
| self.size = size | ||||||||
| self.fee_htr = fee_htr | ||||||||
| self.paint_count = 0 | ||||||||
| self.fees_collected = 0 | ||||||||
| self.pixels = {} | ||||||||
| self.pixel_keys = [] | ||||||||
| self.last_painted_by = {} | ||||||||
| self.last_painted_at = {} | ||||||||
|
|
||||||||
| def _make_key(self, x: int, y: int) -> str: | ||||||||
|
|
||||||||
| return f"{x},{y}" | ||||||||
|
|
||||||||
| def _validate_pixel(self, x: int, y: int, color: str) -> None: | ||||||||
|
|
||||||||
| if not (0 <= x < self.size and 0 <= y < self.size): | ||||||||
| raise OutOfBounds("Coordinates (x, y) are outside canvas bounds.") | ||||||||
|
|
||||||||
| if not (len(color) == 7 and color.startswith('#')): | ||||||||
| raise InvalidColorFormat("Color format must be '#RRGGBB'.") | ||||||||
|
|
||||||||
| hex_part = color[1:] | ||||||||
| if any(ch not in "0123456789abcdefABCDEF" for ch in hex_part): | ||||||||
| raise InvalidColorFormat("Use only hexadecimal digits in '#RRGGBB'.") | ||||||||
|
|
||||||||
| def _emit_paint_event(self, x: int, y: int, color: str, fee: int) -> None: | ||||||||
|
|
||||||||
| event_data = f'{{"event":"Paint","x":{x},"y":{y},"color":"{color}","fee":{fee}}}' | ||||||||
| self.syscall.emit_event(event_data.encode('utf-8')) | ||||||||
|
|
||||||||
| def _apply_paint(self, caller_address: Address, current_timestamp: Timestamp, x: int, y: int, color: str, fee: int) -> None: | ||||||||
|
|
||||||||
| self._validate_pixel(x, y, color) | ||||||||
| key = self._make_key(x, y) | ||||||||
| if key not in self.pixels: | ||||||||
| self.pixel_keys.append(key) | ||||||||
| self.pixels[key] = color | ||||||||
| self.last_painted_by[key] = caller_address | ||||||||
| self.last_painted_at[key] = current_timestamp | ||||||||
| self._emit_paint_event(x, y, color, fee) | ||||||||
|
|
||||||||
| @public(allow_deposit=True) | ||||||||
| def paint(self, ctx: Context, x: int, y: int, color: str) -> None: | ||||||||
|
|
||||||||
| action = ctx.get_single_action(HATHOR_TOKEN_UID) | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is only informational. Changing it is not required for approval.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry could you elaborate more on this? We only use Htr
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, sorry for not being clear. What I meant is, the However, this doesn't mean there aren't other actions for other tokens in the transaction. For example, in you What you probably meant to do (I think), was to also assert that there's a single action across all tokens, and that it is HTR, so your code should be:
Suggested change
Let me know if you find this confusing, so we can take it into account in the documentation and future improvements to the API.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, ok i got it , yeah probably better to add it to avoid user misuse
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You would also have to do it on the |
||||||||
| if not isinstance(action, NCDepositAction): | ||||||||
| raise FeeRequired("An HTR deposit is required to paint.") | ||||||||
|
|
||||||||
| if action.amount < self.fee_htr: | ||||||||
| raise FeeRequired(f"Minimum fee of {self.fee_htr} HTR cents is required.") | ||||||||
|
|
||||||||
| caller_address = ctx.get_caller_address() | ||||||||
| current_timestamp = ctx.block.timestamp | ||||||||
| self._apply_paint(caller_address, current_timestamp, x, y, color, action.amount) | ||||||||
| self.paint_count += 1 | ||||||||
| self.fees_collected += action.amount | ||||||||
|
|
||||||||
| @public(allow_deposit=True) | ||||||||
| def paint_batch(self, ctx: Context, xs: list[int], ys: list[int], colors: list[str]) -> None: | ||||||||
|
|
||||||||
| if not (len(xs) == len(ys) == len(colors)): | ||||||||
| raise EmptyBatch("Lists of coordinates and colors must have the same size.") | ||||||||
|
|
||||||||
| total = len(xs) | ||||||||
| if total == 0: | ||||||||
| raise EmptyBatch("Empty batch is not allowed.") | ||||||||
| if total > MAX_BATCH_SIZE: | ||||||||
| raise EmptyBatch(f"Maximum batch size is {MAX_BATCH_SIZE} pixels.") | ||||||||
|
|
||||||||
| required_fee = self.fee_htr * total | ||||||||
| action = ctx.get_single_action(HATHOR_TOKEN_UID) | ||||||||
| if not isinstance(action, NCDepositAction): | ||||||||
| raise FeeRequired("An HTR deposit is required to paint.") | ||||||||
| if action.amount < required_fee: | ||||||||
| raise FeeRequired(f"Minimum fee of {required_fee} HTR cents is required.") | ||||||||
|
|
||||||||
| caller_address = ctx.get_caller_address() | ||||||||
| current_timestamp = ctx.block.timestamp | ||||||||
|
|
||||||||
| for i in range(total): | ||||||||
| self._apply_paint(caller_address, current_timestamp, xs[i], ys[i], colors[i], self.fee_htr) | ||||||||
|
|
||||||||
| self.paint_count += total | ||||||||
| self.fees_collected += action.amount | ||||||||
|
|
||||||||
| @public(allow_withdrawal=True) | ||||||||
| def withdraw_fees(self, ctx: Context) -> None: | ||||||||
|
|
||||||||
| if ctx.get_caller_address() != self.owner: | ||||||||
| raise NCFail("Only the owner can withdraw fees.") | ||||||||
|
|
||||||||
| action = ctx.get_single_action(HATHOR_TOKEN_UID) | ||||||||
| if not isinstance(action, NCWithdrawalAction): | ||||||||
| raise NCFail("Withdrawal action expected.") | ||||||||
|
|
||||||||
| if action.amount > self.fees_collected: | ||||||||
| raise NCFail("Withdrawal amount exceeds collected fees.") | ||||||||
|
|
||||||||
| self.fees_collected -= action.amount | ||||||||
|
|
||||||||
| @view | ||||||||
| def get_pixel_info(self, x: int, y: int) -> Optional[tuple[str, str, Timestamp]]: | ||||||||
|
|
||||||||
| key = self._make_key(x, y) | ||||||||
| if key in self.pixels: | ||||||||
| return ( | ||||||||
| self.pixels[key], | ||||||||
| str(self.last_painted_by[key]), | ||||||||
| self.last_painted_at[key], | ||||||||
| ) | ||||||||
| return None | ||||||||
|
|
||||||||
| @view | ||||||||
| def get_stats(self) -> tuple[int, int]: | ||||||||
|
|
||||||||
| return (self.paint_count, self.fees_collected) | ||||||||
|
|
||||||||
| @view | ||||||||
| def get_owner(self) -> str: | ||||||||
|
|
||||||||
| return str(self.owner) | ||||||||
|
|
||||||||
| @view | ||||||||
| def get_canvas_size(self) -> int: | ||||||||
|
|
||||||||
| return self.size | ||||||||
|
|
||||||||
|
|
||||||||
| @view | ||||||||
| def get_paint_fee(self) -> int: | ||||||||
|
|
||||||||
| return self.fee_htr | ||||||||
|
|
||||||||
| @view | ||||||||
| def get_pixels_count(self) -> int: | ||||||||
|
|
||||||||
| return len(self.pixel_keys) | ||||||||
|
|
||||||||
| @view | ||||||||
| def get_pixels_page(self, offset: int, limit: int) -> list[list[str]]: | ||||||||
|
|
||||||||
| offset = int(offset) | ||||||||
| limit = int(limit) | ||||||||
|
|
||||||||
| if offset < 0: | ||||||||
| raise NCFail("Invalid offset.") | ||||||||
| if limit <= 0 or limit > MAX_PIXELS_PAGE_SIZE: | ||||||||
| raise NCFail(f"Limit must be between 1 and {MAX_PIXELS_PAGE_SIZE}.") | ||||||||
|
|
||||||||
| total = len(self.pixel_keys) | ||||||||
| if offset >= total: | ||||||||
| return [] | ||||||||
|
|
||||||||
| end = offset + limit | ||||||||
| if end > total: | ||||||||
| end = total | ||||||||
|
|
||||||||
| out: list[list[str]] = [] | ||||||||
| for i in range(offset, end): | ||||||||
| key = self.pixel_keys[i] | ||||||||
| out.append([key, self.pixels[key]]) | ||||||||
|
|
||||||||
| return out | ||||||||
Uh oh!
There was an error while loading. Please reload this page.