Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ Versioning follows [Semantic Versioning](https://semver.org/).

---

## [Unreleased]

### Added

- **`BEOClient.destroy(beo_id, reason=None)`** — TS SDK parity for
LGPD Art. 18 / GDPR Art. 17 right-to-erasure. Signs a canonical payload
(`{function: "destroyBEO", beoId, nonce, timestamp_secs}`), POSTs to
`/api/relayer/beo/destroy`, and returns `{destroyed_at, aptos_tx}`.
- Wire-format alignment: sends hex `nonce` and integer `timestamp_secs`
(matches Registry API v2).
- 11 new tests covering happy path, signature verification, error paths,
and u64 id edge cases.

---

## [2.1.0] — 2026-04-20

### Added
Expand Down
105 changes: 101 additions & 4 deletions bsp_sdk/beo.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,43 @@
threshold = 2,
)
# ⚠️ Store seed_phrase offline — never digitally

# Destroy BEO (LGPD Art. 18 / GDPR Art. 17 — right to erasure)
client.beo.destroy(
beo_id = "42", # wire format: decimal string (u64 on Move)
reason = "user_requested_deletion",
)
"""

from __future__ import annotations
from typing import Optional

import time
from typing import Optional, Union

from .crypto import CryptoUtils
from .http_client import BSPApiError, HttpClient
from .types import BEO, BSPConfig, RecoveryConfig
from .http_client import HttpClient, BSPApiError


# BEO identifiers are `u64` on Move. In Python we accept either `int` or the
# wire-format decimal string. We always send the wire format to the API.
BeoId = Union[int, str]


def _serialize_beo_id(beo_id: BeoId) -> str:
"""Convert a BEO id into the canonical wire-format decimal string."""
if isinstance(beo_id, int):
if beo_id < 0:
raise ValueError(f"beo_id must be non-negative, got {beo_id}")
return str(beo_id)
if isinstance(beo_id, str) and beo_id.isdigit():
return beo_id
raise TypeError(f"beo_id must be int or decimal string, got {beo_id!r}")


def _now_secs() -> int:
"""Unix seconds as integer — matches the API's `timestamp_secs` field."""
return int(time.time())


class BEOClient:
Expand Down Expand Up @@ -61,8 +92,8 @@ def resolve(self, domain: str) -> BEO:
"""Resolve a .bsp domain to its BEO object."""
raise NotImplementedError("Registry connection required")

def get(self, beo_id: str) -> BEO:
"""Get a BEO by its UUID."""
def get(self, beo_id: BeoId) -> BEO:
"""Get a BEO by its on-chain u64 id."""
raise NotImplementedError("Registry connection required")

def is_available(self, domain: str) -> bool:
Expand All @@ -88,3 +119,69 @@ def update_recovery(self, config: RecoveryConfig) -> dict:
if config.threshold < 1 or config.threshold > len(config.guardians):
raise ValueError(f"threshold must be between 1 and {len(config.guardians)}")
raise NotImplementedError("Registry connection required")

def destroy(
self,
beo_id: BeoId,
reason: Optional[str] = None,
) -> dict:
"""Destroy a BEO permanently — LGPD Art. 18 / GDPR Art. 17 right to erasure.

Reaches parity with the TypeScript SDK's ``BEOClient.destroy(beoId)``.

Flow:
1. Sign a canonical payload with the BEO's Ed25519 private key.
2. POST ``/api/relayer/beo/destroy`` with ``timestamp_secs`` + hex nonce
(v2 API alignment — see bsp-registry-api).
3. The relayer invokes Move ``beo_registry::destroy_beo`` which
nullifies the public key, revokes all ConsentTokens and releases
the ``.bsp`` domain.

:param beo_id: On-chain BEO id. Accepts ``int`` or decimal string.
Internally serialised to the canonical wire format (string).
:param reason: Optional human-readable reason (audit-logged, not on-chain).
:returns: ``{ "destroyed_at": str, "aptos_tx": str }``
:raises BSPApiError: Non-2xx response from the registry API.

.. warning::
Irreversible. Once destroyed, the BEO cannot be recovered — even via
Social Recovery. The ``.bsp`` domain becomes available for re-use by
any other principal (typically the protocol enforces a cooldown).
"""
wire_beo_id = _serialize_beo_id(beo_id)
nonce = CryptoUtils.generate_nonce()
timestamp_secs = _now_secs()

payload_to_sign = {
"function": "destroyBEO",
"beoId": wire_beo_id,
"nonce": nonce,
"timestamp_secs": timestamp_secs,
}
if not self.config.private_key:
raise ValueError(
"BSPConfig.private_key is required to sign destroy() — "
"the relayer cannot forge this operation.",
)
signature = CryptoUtils.sign_payload(payload_to_sign, self.config.private_key)

body = {
"beoId": wire_beo_id,
"signature": signature,
"nonce": nonce,
"timestamp_secs": timestamp_secs,
}
if reason is not None:
body["reason"] = reason

result = self.http.post("/api/relayer/beo/destroy", body)
if not isinstance(result, dict):
raise BSPApiError(
"Registry returned a non-object response for destroy",
status_code=502,
retryable=True,
)
return {
"destroyed_at": result.get("destroyed_at"),
"aptos_tx": result.get("transactionHash") or result.get("transactionId"),
}
196 changes: 196 additions & 0 deletions tests/test_beo_destroy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""Tests for BEOClient.destroy() — parity with the TypeScript SDK.

The destroy flow is a signed-payload POST. We:
1. Assert the wire body shape (beoId as decimal string, timestamp_secs int,
hex nonce, Ed25519 signature, optional reason).
2. Verify the signature locally using CryptoUtils so we know the canonical
payload matches what the API (and Move) will re-verify.
3. Cover error paths: missing private key, invalid beo_id type, non-dict
response.
"""

from __future__ import annotations

import re
from typing import Any, Optional

import pytest

from bsp_sdk.beo import BEOClient, _serialize_beo_id
from bsp_sdk.crypto import CryptoUtils
from bsp_sdk.http_client import BSPApiError
from bsp_sdk.types import BSPConfig


# ── Fake HTTP client ──────────────────────────────────────────────────────────


class FakeHttp:
"""Records every request and returns pre-seeded responses."""

def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
self.responses: dict[tuple[str, str], Any] = {}

def seed(self, method: str, path: str, response: Any) -> None:
self.responses[(method.upper(), path)] = response

def _dispatch(self, method: str, path: str, **kwargs: Any) -> Any:
self.calls.append({"method": method.upper(), "path": path, **kwargs})
return self.responses.get((method.upper(), path), {})

def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
return self._dispatch("GET", path, params=params)

def post(self, path: str, body: dict[str, Any]) -> Any:
return self._dispatch("POST", path, body=body)

def delete(self, path: str, body: Optional[dict[str, Any]] = None) -> Any:
return self._dispatch("DELETE", path, body=body)


# ── Fixtures ──────────────────────────────────────────────────────────────────


@pytest.fixture
def keypair() -> dict[str, str]:
return CryptoUtils.generate_key_pair()


@pytest.fixture
def client(keypair: dict[str, str]) -> tuple[BEOClient, FakeHttp]:
http = FakeHttp()
config = BSPConfig(
ieo_domain="fleury.bsp",
private_key=keypair["private_key"],
environment="local",
)
return BEOClient(config, http=http), http


# ── _serialize_beo_id ─────────────────────────────────────────────────────────


class TestSerializeBeoId:
def test_accepts_int(self) -> None:
assert _serialize_beo_id(42) == "42"

def test_accepts_decimal_string(self) -> None:
assert _serialize_beo_id("999999999999999999") == "999999999999999999"

def test_rejects_negative_int(self) -> None:
with pytest.raises(ValueError, match="non-negative"):
_serialize_beo_id(-1)

def test_rejects_non_decimal_string(self) -> None:
with pytest.raises(TypeError):
_serialize_beo_id("not-a-number")

def test_rejects_hex_string(self) -> None:
with pytest.raises(TypeError):
_serialize_beo_id("0x2a")


# ── destroy() happy path ──────────────────────────────────────────────────────


class TestDestroy:
def test_sends_canonical_wire_payload(
self, client: tuple[BEOClient, FakeHttp], keypair: dict[str, str],
) -> None:
beo, http = client
http.seed(
"POST",
"/api/relayer/beo/destroy",
{"destroyed_at": "2026-04-20T10:00:00Z", "transactionHash": "0xabc"},
)

result = beo.destroy(beo_id=42, reason="user_requested_deletion")

assert result["destroyed_at"] == "2026-04-20T10:00:00Z"
assert result["aptos_tx"] == "0xabc"
assert len(http.calls) == 1
call = http.calls[0]
assert call["method"] == "POST"
assert call["path"] == "/api/relayer/beo/destroy"

body = call["body"]
# Wire-format assertions
assert body["beoId"] == "42"
assert isinstance(body["beoId"], str)
assert isinstance(body["timestamp_secs"], int)
assert body["timestamp_secs"] > 0
assert re.fullmatch(r"[0-9a-f]{32,}", body["nonce"]) is not None
assert len(body["nonce"]) % 2 == 0
assert body["reason"] == "user_requested_deletion"
# Signature is base64 (44 chars for a 64-byte Ed25519 sig incl. padding)
assert len(body["signature"]) > 0

# Canonical signed payload re-verifies under the private key's public half
payload = {
"function": "destroyBEO",
"beoId": body["beoId"],
"nonce": body["nonce"],
"timestamp_secs": body["timestamp_secs"],
}
assert CryptoUtils.verify_signature(payload, body["signature"], keypair["public_key"])

def test_omits_reason_when_not_provided(
self, client: tuple[BEOClient, FakeHttp],
) -> None:
beo, http = client
http.seed(
"POST",
"/api/relayer/beo/destroy",
{"destroyed_at": "2026-04-20T10:00:00Z", "transactionHash": "0xabc"},
)

beo.destroy(beo_id=7)

body = http.calls[0]["body"]
assert "reason" not in body

def test_accepts_decimal_string_beo_id(
self, client: tuple[BEOClient, FakeHttp],
) -> None:
beo, http = client
http.seed(
"POST",
"/api/relayer/beo/destroy",
{"destroyed_at": "2026-04-20T10:00:00Z", "transactionHash": "0xabc"},
)

beo.destroy(beo_id="18446744073709551615") # u64 max

assert http.calls[0]["body"]["beoId"] == "18446744073709551615"


# ── destroy() error paths ─────────────────────────────────────────────────────


class TestDestroyErrors:
def test_requires_private_key(self, keypair: dict[str, str]) -> None:
http = FakeHttp()
config = BSPConfig(
ieo_domain="fleury.bsp",
private_key="",
environment="local",
)
beo = BEOClient(config, http=http)
with pytest.raises(ValueError, match="private_key is required"):
beo.destroy(beo_id=42)

def test_raises_on_non_dict_response(
self, client: tuple[BEOClient, FakeHttp],
) -> None:
beo, http = client
http.seed("POST", "/api/relayer/beo/destroy", ["unexpected", "list"])
with pytest.raises(BSPApiError):
beo.destroy(beo_id=42)

def test_rejects_unsupported_beo_id_type(
self, client: tuple[BEOClient, FakeHttp],
) -> None:
beo, _ = client
with pytest.raises(TypeError):
beo.destroy(beo_id=3.14) # type: ignore[arg-type]
Loading