-
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
Open
D45putspin
wants to merge
5
commits into
HathorNetwork:master
Choose a base branch
from
D45putspin:add/pxiel_contract
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 all commits
Commits
Show all changes
5 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,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 | ||
| ``` |
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,220 @@ | ||
| 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) | ||
| assert len(ctx.actions) == 1, 'expected only one token' | ||
| 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 | ||
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note:
get_single_actiongets a single action for the passed token, in this case HTR. This doesn't mean there aren't more actions for other tokens, which I think wasn't the intention here. Same for the other methods that use it.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is only informational. Changing it is not required for approval.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry could you elaborate more on this? We only use Htr
We should Let owner set the token ? is that what you mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, sorry for not being clear. What I meant is, the
get_single_actionmethod only checks that a single action exists for the token passed in its argument. Soaction = ctx.get_single_action(HATHOR_TOKEN_UID)asserts that there's only one action for HTR.However, this doesn't mean there aren't other actions for other tokens in the transaction. For example, in you
paintmethod, the user could deposit 1 HTR and 1 hUSDC, and it would success. The hUSDC balance would be added to the contract, but would be ignored by your code.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:
Let me know if you find this confusing, so we can take it into account in the documentation and future improvements to the API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You would also have to do it on the
paint_batchandwithdrawmethods