From 93e6a480b765974b19f4d6f12d3dba1ef1ac936a Mon Sep 17 00:00:00 2001 From: Andre Ambrosio Date: Mon, 20 Apr 2026 12:24:08 -0300 Subject: [PATCH] feat(beo): add BEOClient.destroy(beo_id, reason) for TS SDK parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches `bsp-sdk-typescript` BEOClient.destroy() so Python callers can also exercise LGPD Art. 18 / GDPR Art. 17 right-to-erasure. Flow: 1. Sign a canonical payload (`{function: "destroyBEO", beoId, nonce, timestamp_secs}`) with the BEO's Ed25519 private key. 2. POST to `/api/relayer/beo/destroy` using the v2 API wire format — hex `nonce` and integer `timestamp_secs` (matches bsp-registry-api v2 alignment PR). 3. Return `{destroyed_at, aptos_tx}`. `beo_id` accepts both `int` and decimal-string; internally serialized to the canonical wire-format string (Move id is u64). * bsp_sdk/beo.py: `destroy()` + `_serialize_beo_id()` + `_now_secs()` helpers. Typed union `BeoId = int | str`. `get()` typed with BeoId. * tests/test_beo_destroy.py: 11 tests covering id serialisation, happy path (asserts canonical signed payload re-verifies locally), error paths (missing private key, invalid id type, non-dict response). * CHANGELOG.md: unreleased entry. Full suite: 64 passed (53 existing + 11 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 +++ bsp_sdk/beo.py | 105 +++++++++++++++++++- tests/test_beo_destroy.py | 196 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 tests/test_beo_destroy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 51aaf01..02be7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bsp_sdk/beo.py b/bsp_sdk/beo.py index fa44eec..1c547a6 100644 --- a/bsp_sdk/beo.py +++ b/bsp_sdk/beo.py @@ -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: @@ -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: @@ -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"), + } diff --git a/tests/test_beo_destroy.py b/tests/test_beo_destroy.py new file mode 100644 index 0000000..2f3e33a --- /dev/null +++ b/tests/test_beo_destroy.py @@ -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]