diff --git a/blueprints/pxiel/README.md b/blueprints/pxiel/README.md new file mode 100644 index 0000000..047a97d --- /dev/null +++ b/blueprints/pxiel/README.md @@ -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 +``` diff --git a/blueprints/pxiel/pxiel.py b/blueprints/pxiel/pxiel.py new file mode 100644 index 0000000..0b395e7 --- /dev/null +++ b/blueprints/pxiel/pxiel.py @@ -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 \ No newline at end of file diff --git a/blueprints/pxiel/tests_pxiel.py b/blueprints/pxiel/tests_pxiel.py new file mode 100644 index 0000000..f7180d4 --- /dev/null +++ b/blueprints/pxiel/tests_pxiel.py @@ -0,0 +1,196 @@ + +from hathor.reactor.reactor import initialize_global_reactor +initialize_global_reactor() +from hathor.nanocontracts import HATHOR_TOKEN_UID +from hathor.nanocontracts.exception import NCFail +from hathor.nanocontracts.types import Address, NCDepositAction, NCWithdrawalAction, TokenUid +from hathor_tests.nanocontracts.blueprints.unittest import BlueprintTestCase + +from pxiel import ( + EmptyBatch, + FeeRequired, + InvalidColorFormat, + OutOfBounds, + Pxiel, +) + + + + +class TestPxiel(BlueprintTestCase): + + def setUp(self) -> None: + super().setUp() + self.pxiel_id = self._register_blueprint_class(Pxiel) + genesis = self.manager.tx_storage.get_all_genesis() + self.tx = [t for t in genesis if t.is_transaction][0] + + def _create_pxiel_contract(self, size: int = 10, fee_htr: int = 5) -> None: + """Helper to create and initialize a Pxiel contract.""" + caller = self.gen_random_address() + ctx = self.create_context(caller_id=caller) + self.runner.create_contract(self.pxiel_id, self.pxiel_id, ctx, size, fee_htr) + self._owner = caller + + def test_initialize_sets_state(self) -> None: + """Test that initialize correctly sets up the contract state.""" + self._create_pxiel_contract(size=10, fee_htr=5) + + contract = self.get_readonly_contract(self.pxiel_id) + self.assertEqual(contract.size, 10) + self.assertEqual(contract.fee_htr, 5) + self.assertEqual(contract.paint_count, 0) + self.assertEqual(contract.fees_collected, 0) + + def test_paint_success(self) -> None: + """Test painting a pixel with proper deposit.""" + self._create_pxiel_contract(size=8, fee_htr=3) + + caller = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=3)], + vertex=self.tx, + caller_id=caller, + timestamp=99, + ) + self.runner.call_public_method(self.pxiel_id, 'paint', ctx, 2, 3, '#abcdef') + + contract = self.get_readonly_contract(self.pxiel_id) + self.assertEqual(contract.pixels['2,3'], '#abcdef') + self.assertEqual(contract.paint_count, 1) + self.assertEqual(contract.fees_collected, 3) + + def test_paint_out_of_bounds(self) -> None: + """Test that painting outside canvas bounds raises OutOfBounds.""" + self._create_pxiel_contract(size=4, fee_htr=2) + + caller = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=2)], + vertex=self.tx, + caller_id=caller, + ) + + with self.assertRaises(OutOfBounds): + self.runner.call_public_method(self.pxiel_id, 'paint', ctx, 9, 0, '#ffffff') + + def test_paint_invalid_color(self) -> None: + """Test that invalid color format raises InvalidColorFormat.""" + self._create_pxiel_contract(size=4, fee_htr=2) + + caller = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=2)], + vertex=self.tx, + caller_id=caller, + ) + + with self.assertRaises(InvalidColorFormat): + self.runner.call_public_method(self.pxiel_id, 'paint', ctx, 1, 1, 'red') + + def test_paint_requires_fee(self) -> None: + """Test that painting without deposit raises FeeRequired.""" + self._create_pxiel_contract(size=4, fee_htr=2) + + caller = self.gen_random_address() + ctx = self.create_context( + vertex=self.tx, + caller_id=caller, + ) + + with self.assertRaises(NCFail): + self.runner.call_public_method(self.pxiel_id, 'paint', ctx, 1, 1, '#ffffff') + + def test_paint_batch_success(self) -> None: + """Test painting multiple pixels in a batch.""" + self._create_pxiel_contract(size=10, fee_htr=2) + + caller = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=6)], + vertex=self.tx, + caller_id=caller, + timestamp=55, + ) + self.runner.call_public_method( + self.pxiel_id, 'paint_batch', ctx, + [0, 1, 2], [0, 1, 2], ['#000000', '#111111', '#222222'] + ) + + contract = self.get_readonly_contract(self.pxiel_id) + self.assertEqual(contract.paint_count, 3) + self.assertEqual(contract.fees_collected, 6) + self.assertEqual(contract.pixels['0,0'], '#000000') + self.assertEqual(contract.pixels['1,1'], '#111111') + self.assertEqual(contract.pixels['2,2'], '#222222') + + def test_paint_batch_empty_raises(self) -> None: + """Test that empty batch raises EmptyBatch.""" + self._create_pxiel_contract(size=10, fee_htr=1) + + caller = self.gen_random_address() + ctx = self.create_context( + vertex=self.tx, + caller_id=caller, + ) + + with self.assertRaises(EmptyBatch): + self.runner.call_public_method( + self.pxiel_id, 'paint_batch', ctx, + [], [], [] + ) + + def test_withdraw_fees_success(self) -> None: + """Test that owner can withdraw collected fees.""" + self._create_pxiel_contract(size=5, fee_htr=10) + + # First, paint to collect some fees + painter = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + paint_ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=10)], + vertex=self.tx, + caller_id=painter, + ) + self.runner.call_public_method(self.pxiel_id, 'paint', paint_ctx, 1, 1, '#ff0000') + + # Now owner withdraws + withdraw_ctx = self.create_context( + actions=[NCWithdrawalAction(token_uid=token_uid, amount=10)], + vertex=self.tx, + caller_id=self._owner, + ) + self.runner.call_public_method(self.pxiel_id, 'withdraw_fees', withdraw_ctx) + + contract = self.get_readonly_contract(self.pxiel_id) + self.assertEqual(contract.fees_collected, 0) + + def test_get_pixels_page(self) -> None: + """Test retrieving a page of pixels.""" + self._create_pxiel_contract(size=10, fee_htr=1) + + painter = self.gen_random_address() + token_uid = TokenUid(HATHOR_TOKEN_UID) + ctx = self.create_context( + actions=[NCDepositAction(token_uid=token_uid, amount=3)], + vertex=self.tx, + caller_id=painter, + ) + self.runner.call_public_method( + self.pxiel_id, 'paint_batch', ctx, + [0, 1, 2], [0, 1, 2], ['#000000', '#111111', '#222222'] + ) + + # Call view method + count = self.runner.call_view_method(self.pxiel_id, 'get_pixels_count') + self.assertEqual(count, 3) + + page = self.runner.call_view_method(self.pxiel_id, 'get_pixels_page', 0, 2) + self.assertEqual(len(page), 2) + self.assertEqual(page[0], ['0,0', '#000000']) + self.assertEqual(page[1], ['1,1', '#111111'])