Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions blueprints/pxiel/README.md
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
```
220 changes: 220 additions & 0 deletions blueprints/pxiel/pxiel.py
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: get_single_action gets 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.

Copy link
Contributor

@glevco glevco Jan 21, 2026

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.

Copy link
Author

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?

Copy link
Contributor

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_action method only checks that a single action exists for the token passed in its argument. So action = 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 paint method, 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:

Suggested change
action = ctx.get_single_action(HATHOR_TOKEN_UID)
action = ctx.get_single_action(HATHOR_TOKEN_UID)
assert len(ctx.actions) == 1, 'expected only one token'

Let me know if you find this confusing, so we can take it into account in the documentation and future improvements to the API.

Copy link
Author

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

Copy link
Contributor

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_batch and withdraw methods

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
Loading