From 07378a8fc1f5e335b47ba62932edfe694df2adb6 Mon Sep 17 00:00:00 2001 From: m00npl Date: Sat, 18 Oct 2025 00:57:52 +0200 Subject: [PATCH 1/3] feat: add expires_in API with backward compatibility for BTL This PR introduces a more intuitive API for managing entity expiration times while maintaining full backward compatibility with the existing BTL (Block-to-Live) system. ## Changes ### New ExpirationTime Builder Class - Helper class for time-to-blocks conversion - Class methods: from_seconds(), from_hours(), from_days(), from_blocks() - Automatic conversion: 1 block = 2 seconds ### New API Fields - **expires_in** (int | ExpirationTime) replaces btl in ArkivCreate and ArkivUpdate - **duration** (int | ExpirationTime) replaces number_of_blocks in ArkivExtend - When using int, it represents duration in **seconds** (not blocks) ### Examples **Simple way - seconds as int:** ```python create = ArkivCreate( data=b"Hello", expires_in=3600, # 1 hour in seconds string_annotations=[], numeric_annotations=[] ) ``` **With builder - more readable:** ```python create = ArkivCreate( data=b"Hello", expires_in=ExpirationTime.from_hours(24), string_annotations=[], numeric_annotations=[] ) ``` **Legacy API (deprecated but still works):** ```python create = ArkivCreate( data=b"Hello", btl=1800, # blocks - shows deprecation warning string_annotations=[], numeric_annotations=[] ) ``` ## Backward Compatibility ### Deprecated but Functional - btl field (in ArkivCreate and ArkivUpdate) - number_of_blocks field (in ArkivExtend) ### Priority System - expires_in > btl - duration > number_of_blocks - Deprecation warnings displayed when using old API ### Migration - Existing code continues to work without changes - Console warnings encourage migration to new API - No breaking changes ## Technical Details - Added resolve_expiration_blocks() for expires_in/btl resolution - Added resolve_extension_blocks() for duration/number_of_blocks resolution - Updated utils.py to use resolution functions in RLP encoding - All conversions handle both int (seconds) and ExpirationTime objects --- arkiv_sdk/types.py | 319 +++++++++++++++++++++++++++++++++++++++++++-- arkiv_sdk/utils.py | 8 +- 2 files changed, 312 insertions(+), 15 deletions(-) diff --git a/arkiv_sdk/types.py b/arkiv_sdk/types.py index cdede03..bda0d34 100644 --- a/arkiv_sdk/types.py +++ b/arkiv_sdk/types.py @@ -1,5 +1,6 @@ """Arkiv SDK Types.""" +import warnings from collections.abc import Callable, Coroutine, Sequence from dataclasses import dataclass from typing import ( @@ -30,7 +31,7 @@ def as_address(self) -> ChecksumAddress: # @override def __repr__(self) -> str: - """Encode bytes as a string.""" + """Return bytes encoded as a string.""" return f"{type(self).__name__}({self.as_hex_string()})" @staticmethod @@ -46,6 +47,213 @@ def from_hex_string(hexstr: str) -> "GenericBytes": Address = NewType("Address", GenericBytes) +class ExpirationTime: + """ + Helper class for creating expiration time values with conversion methods. + + Arkiv uses a block-based expiration system where each block is produced + every 2 seconds. This class provides type-safe methods to convert various + time units to block counts. + + Examples: + >>> # Create from seconds (recommended) + >>> exp1 = ExpirationTime.from_seconds(3600) # 1 hour + >>> print(exp1.blocks) # 1800 + + >>> # Create from hours + >>> exp2 = ExpirationTime.from_hours(24) # 1 day + >>> print(exp2.blocks) # 43200 + + >>> # Create from days + >>> exp3 = ExpirationTime.from_days(7) # 1 week + >>> print(exp3.blocks) # 302400 + + >>> # Create from blocks (legacy) + >>> exp4 = ExpirationTime.from_blocks(1800) + >>> print(exp4.to_seconds()) # 3600 + + """ + + BLOCK_TIME_SECONDS = 2 + """Block time in seconds (Arkiv produces blocks every 2 seconds)""" + + def __init__(self, blocks: int): + """ + Initialize ExpirationTime with block count. + + Args: + blocks: Number of blocks representing this expiration time + + Raises: + ValueError: If blocks is not positive + + """ + if blocks <= 0: + raise ValueError("Expiration time must be positive") + self._blocks = int(blocks) + + @property + def blocks(self) -> int: + """Get the number of blocks.""" + return self._blocks + + @classmethod + def from_seconds(cls, seconds: int | float) -> "ExpirationTime": + """ + Create expiration time from seconds. + + Args: + seconds: Duration in seconds + + Returns: + ExpirationTime instance + + """ + return cls(int(seconds / cls.BLOCK_TIME_SECONDS)) + + @classmethod + def from_blocks(cls, blocks: int) -> "ExpirationTime": + """ + Create expiration time from block count. + + Args: + blocks: Number of blocks + + Returns: + ExpirationTime instance + + """ + return cls(blocks) + + @classmethod + def from_hours(cls, hours: int | float) -> "ExpirationTime": + """ + Create expiration time from hours. + + Args: + hours: Duration in hours + + Returns: + ExpirationTime instance + + """ + return cls.from_seconds(hours * 3600) + + @classmethod + def from_days(cls, days: int | float) -> "ExpirationTime": + """ + Create expiration time from days. + + Args: + days: Duration in days + + Returns: + ExpirationTime instance + + """ + return cls.from_seconds(days * 86400) + + def to_seconds(self) -> int: + """ + Convert expiration time to seconds. + + Returns: + Duration in seconds + + """ + return self._blocks * self.BLOCK_TIME_SECONDS + + def __repr__(self) -> str: + """Return string representation of ExpirationTime.""" + return f"ExpirationTime(blocks={self._blocks}, seconds={self.to_seconds()})" + + +def resolve_expiration_blocks( + expires_in: int | ExpirationTime | None, + btl: int | None, +) -> int: + """ + Resolve expiration time from either new API or legacy BTL. + + Priority: expires_in > btl + + Args: + expires_in: Duration in seconds (int) or ExpirationTime object + btl: Legacy block count (deprecated) + + Returns: + Number of blocks + + Raises: + ValueError: If neither expires_in nor btl is specified + + """ + # Priority: expires_in takes precedence + if expires_in is not None: + if isinstance(expires_in, int): + # Treat as seconds and convert to blocks + return ExpirationTime.from_seconds(expires_in).blocks + # It's an ExpirationTime object + return expires_in.blocks + + if btl is not None: + # Warn about deprecated BTL + warnings.warn( + "⚠️ BTL is deprecated and will be removed in a future version. " + "Please use 'expires_in' instead. " + "Example: expires_in=3600 (seconds) or " + "expires_in=ExpirationTime.from_hours(1)", + DeprecationWarning, + stacklevel=3, + ) + return btl + + raise ValueError("Either 'expires_in' or 'btl' must be specified") + + +def resolve_extension_blocks( + duration: int | ExpirationTime | None, + number_of_blocks: int | None, +) -> int: + """ + Resolve extension duration from either new API or legacy numberOfBlocks. + + Priority: duration > number_of_blocks + + Args: + duration: Duration in seconds (int) or ExpirationTime object + number_of_blocks: Legacy block count (deprecated) + + Returns: + Number of blocks + + Raises: + ValueError: If neither duration nor number_of_blocks is specified + + """ + # Priority: duration takes precedence + if duration is not None: + if isinstance(duration, int): + # Treat as seconds and convert to blocks + return ExpirationTime.from_seconds(duration).blocks + # It's an ExpirationTime object + return duration.blocks + + if number_of_blocks is not None: + # Warn about deprecated number_of_blocks + warnings.warn( + "⚠️ number_of_blocks is deprecated and will be removed in a " + "future version. Please use 'duration' instead. " + "Example: duration=86400 (seconds) or " + "duration=ExpirationTime.from_days(1)", + DeprecationWarning, + stacklevel=3, + ) + return number_of_blocks + + raise ValueError("Either 'duration' or 'number_of_blocks' must be specified") + + # TODO: use new generic syntax once we can bump to python 3.12 or higher V = TypeVar("V") @@ -59,29 +267,92 @@ class Annotation(Generic[V]): # @override def __repr__(self) -> str: - """Encode annotation as a string.""" + """Return annotation encoded as a string.""" return f"{type(self).__name__}({self.key} -> {self.value})" @dataclass(frozen=True) class ArkivCreate: - """Class to represent a create operation in Arkiv.""" + """ + Class to represent a create operation in Arkiv. + + Examples: + >>> # New API - using seconds as int + >>> create = ArkivCreate( + ... data=b"Hello", + ... string_annotations=[], + ... numeric_annotations=[], + ... expires_in=3600 # 1 hour in seconds + ... ) + + >>> # New API - using ExpirationTime + >>> create = ArkivCreate( + ... data=b"Hello", + ... string_annotations=[], + ... numeric_annotations=[], + ... expires_in=ExpirationTime.from_hours(24) + ... ) + + >>> # Legacy API (deprecated but still works) + >>> create = ArkivCreate( + ... data=b"Hello", + ... btl=1800, # blocks + ... string_annotations=[], + ... numeric_annotations=[] + ... ) + + """ data: bytes - btl: int - string_annotations: Sequence[Annotation[str]] - numeric_annotations: Sequence[Annotation[int]] + btl: int | None = None # Deprecated: use expires_in instead + string_annotations: Sequence[Annotation[str]] = () + numeric_annotations: Sequence[Annotation[int]] = () + # Preferred: seconds or ExpirationTime + expires_in: int | ExpirationTime | None = None @dataclass(frozen=True) class ArkivUpdate: - """Class to represent an update operation in Arkiv.""" + """ + Class to represent an update operation in Arkiv. + + Examples: + >>> # New API - using seconds + >>> update = ArkivUpdate( + ... entity_key=entity_key, + ... data=b"Updated", + ... string_annotations=[], + ... numeric_annotations=[], + ... expires_in=86400 # 1 day in seconds + ... ) + + >>> # New API - using ExpirationTime + >>> update = ArkivUpdate( + ... entity_key=entity_key, + ... data=b"Updated", + ... string_annotations=[], + ... numeric_annotations=[], + ... expires_in=ExpirationTime.from_days(7) + ... ) + + >>> # Legacy API (deprecated) + >>> update = ArkivUpdate( + ... entity_key=entity_key, + ... data=b"Updated", + ... btl=2000, # blocks + ... string_annotations=[], + ... numeric_annotations=[] + ... ) + + """ entity_key: EntityKey data: bytes - btl: int - string_annotations: Sequence[Annotation[str]] - numeric_annotations: Sequence[Annotation[int]] + btl: int | None = None # Deprecated: use expires_in instead + string_annotations: Sequence[Annotation[str]] = () + numeric_annotations: Sequence[Annotation[int]] = () + # Preferred: seconds or ExpirationTime + expires_in: int | ExpirationTime | None = None @dataclass(frozen=True) @@ -93,10 +364,34 @@ class ArkivDelete: @dataclass(frozen=True) class ArkivExtend: - """Class to represent a BTL extend operation in Arkiv.""" + """ + Class to represent an extend operation in Arkiv. + + Examples: + >>> # New API - using seconds + >>> extend = ArkivExtend( + ... entity_key=entity_key, + ... duration=86400 # 1 day in seconds + ... ) + + >>> # New API - using ExpirationTime + >>> extend = ArkivExtend( + ... entity_key=entity_key, + ... duration=ExpirationTime.from_hours(48) + ... ) + + >>> # Legacy API (deprecated) + >>> extend = ArkivExtend( + ... entity_key=entity_key, + ... number_of_blocks=500 # blocks + ... ) + + """ entity_key: EntityKey - number_of_blocks: int + number_of_blocks: int | None = None # Deprecated: use duration instead + # Preferred: seconds or ExpirationTime + duration: int | ExpirationTime | None = None @dataclass(frozen=True) diff --git a/arkiv_sdk/utils.py b/arkiv_sdk/utils.py index ebe6bd8..fb2ebee 100644 --- a/arkiv_sdk/utils.py +++ b/arkiv_sdk/utils.py @@ -8,6 +8,8 @@ from .types import ( Annotation, ArkivTransaction, + resolve_expiration_blocks, + resolve_extension_blocks, ) logger = logging.getLogger(__name__) @@ -29,7 +31,7 @@ def format_annotation(annotation: Annotation[T]) -> tuple[str, T]: list( map( lambda el: [ - el.btl, + resolve_expiration_blocks(el.expires_in, el.btl), el.data, list(map(format_annotation, el.string_annotations)), list(map(format_annotation, el.numeric_annotations)), @@ -42,7 +44,7 @@ def format_annotation(annotation: Annotation[T]) -> tuple[str, T]: map( lambda el: [ el.entity_key.generic_bytes, - el.btl, + resolve_expiration_blocks(el.expires_in, el.btl), el.data, list(map(format_annotation, el.string_annotations)), list(map(format_annotation, el.numeric_annotations)), @@ -62,7 +64,7 @@ def format_annotation(annotation: Annotation[T]) -> tuple[str, T]: map( lambda el: [ el.entity_key.generic_bytes, - el.number_of_blocks, + resolve_extension_blocks(el.duration, el.number_of_blocks), ], tx.extensions, ) From fe5c3b674e9b0392d8d022d27e9b44aa993e529e Mon Sep 17 00:00:00 2001 From: r-vdp Date: Mon, 20 Oct 2025 14:07:18 +0200 Subject: [PATCH 2/3] Avoid duplicate logic --- arkiv_sdk/types.py | 68 ++++++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/arkiv_sdk/types.py b/arkiv_sdk/types.py index bda0d34..020e3f2 100644 --- a/arkiv_sdk/types.py +++ b/arkiv_sdk/types.py @@ -168,6 +168,30 @@ def __repr__(self) -> str: return f"ExpirationTime(blocks={self._blocks}, seconds={self.to_seconds()})" +def getBTL(duration: int | ExpirationTime | None, blocks: int | None) -> int: + """Resolve the BTL given either a duration or a number of blocks.""" + if duration is not None: + if isinstance(duration, int): + # Treat as seconds and convert to blocks + return ExpirationTime.from_seconds(duration).blocks + # It's an ExpirationTime object + return duration.blocks + + if blocks is not None: + # Warn about deprecated BTL + warnings.warn( + "⚠️ BTL is deprecated and will be removed in a future version. " + "Please use 'expires_in' instead. " + "Example: expires_in=3600 (seconds) or " + "expires_in=ExpirationTime.from_hours(1)", + DeprecationWarning, + stacklevel=3, + ) + return blocks + + raise ValueError("Either 'expires_in' or 'btl' must be specified") + + def resolve_expiration_blocks( expires_in: int | ExpirationTime | None, btl: int | None, @@ -188,27 +212,7 @@ def resolve_expiration_blocks( ValueError: If neither expires_in nor btl is specified """ - # Priority: expires_in takes precedence - if expires_in is not None: - if isinstance(expires_in, int): - # Treat as seconds and convert to blocks - return ExpirationTime.from_seconds(expires_in).blocks - # It's an ExpirationTime object - return expires_in.blocks - - if btl is not None: - # Warn about deprecated BTL - warnings.warn( - "⚠️ BTL is deprecated and will be removed in a future version. " - "Please use 'expires_in' instead. " - "Example: expires_in=3600 (seconds) or " - "expires_in=ExpirationTime.from_hours(1)", - DeprecationWarning, - stacklevel=3, - ) - return btl - - raise ValueError("Either 'expires_in' or 'btl' must be specified") + return getBTL(expires_in, btl) def resolve_extension_blocks( @@ -231,27 +235,7 @@ def resolve_extension_blocks( ValueError: If neither duration nor number_of_blocks is specified """ - # Priority: duration takes precedence - if duration is not None: - if isinstance(duration, int): - # Treat as seconds and convert to blocks - return ExpirationTime.from_seconds(duration).blocks - # It's an ExpirationTime object - return duration.blocks - - if number_of_blocks is not None: - # Warn about deprecated number_of_blocks - warnings.warn( - "⚠️ number_of_blocks is deprecated and will be removed in a " - "future version. Please use 'duration' instead. " - "Example: duration=86400 (seconds) or " - "duration=ExpirationTime.from_days(1)", - DeprecationWarning, - stacklevel=3, - ) - return number_of_blocks - - raise ValueError("Either 'duration' or 'number_of_blocks' must be specified") + return getBTL(duration, number_of_blocks) # TODO: use new generic syntax once we can bump to python 3.12 or higher From 2eed942748e28354e456c3130f5c542bc48a5fa2 Mon Sep 17 00:00:00 2001 From: r-vdp Date: Wed, 29 Oct 2025 16:34:24 +0100 Subject: [PATCH 3/3] Fix mypy issues --- arkiv_sdk/__init__.py | 31 +++++++++++++++++++------------ arkiv_sdk/types.py | 1 + arkiv_sdk/wallet.py | 3 ++- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/arkiv_sdk/__init__.py b/arkiv_sdk/__init__.py index 67acb83..1411c7a 100755 --- a/arkiv_sdk/__init__.py +++ b/arkiv_sdk/__init__.py @@ -12,12 +12,14 @@ Sequence, ) from typing import ( + TYPE_CHECKING, Any, + TypeAlias, cast, ) from eth_typing import ChecksumAddress, HexStr -from web3 import AsyncWeb3, WebSocketProvider +from web3 import AsyncHTTPProvider, AsyncWeb3, WebSocketProvider from web3.contract import AsyncContract from web3.exceptions import ProviderConnectionError, Web3RPCError, Web3ValueError from web3.method import Method, default_root_munger @@ -86,18 +88,23 @@ "Wei", ] +if TYPE_CHECKING: + HTTPClient: TypeAlias = AsyncWeb3[AsyncHTTPProvider] + WSClient: TypeAlias = AsyncWeb3[WebSocketProvider] +else: + HTTPClient: TypeAlias = AsyncWeb3 + WSClient: TypeAlias = AsyncWeb3 + logger = logging.getLogger(__name__) """@private""" -class ArkivHttpClient(AsyncWeb3): +class ArkivHttpClient(HTTPClient): """Subclass of AsyncWeb3 with added Arkiv methods.""" def __init__(self, rpc_url: str): - super().__init__( - AsyncWeb3.AsyncHTTPProvider(rpc_url, request_kwargs={"timeout": 60}) - ) + super().__init__(AsyncHTTPProvider(rpc_url, request_kwargs={"timeout": 60})) self.eth.attach_methods( { @@ -215,7 +222,7 @@ async def query_entities(self, query: str) -> Sequence[QueryEntitiesResult]: class ArkivROClient: _http_client: ArkivHttpClient - _ws_client: AsyncWeb3 + _ws_client: WSClient _arkiv_contract: AsyncContract _background_tasks: set[asyncio.Task[None]] @@ -229,11 +236,11 @@ async def create_ro_client(rpc_url: str, ws_url: str) -> "ArkivROClient": return ArkivROClient(rpc_url, await ArkivROClient._create_ws_client(ws_url)) @staticmethod - async def _create_ws_client(ws_url: str) -> "AsyncWeb3": - ws_client: AsyncWeb3 = await AsyncWeb3(WebSocketProvider(ws_url)) + async def _create_ws_client(ws_url: str) -> "AsyncWeb3[WebSocketProvider]": + ws_client: WSClient = await AsyncWeb3(WebSocketProvider(ws_url)) return ws_client - def __init__(self, rpc_url: str, ws_client: AsyncWeb3) -> None: + def __init__(self, rpc_url: str, ws_client: WSClient) -> None: """Initialise the ArkivClient instance.""" self._http_client = ArkivHttpClient(rpc_url) self._ws_client = ws_client @@ -242,7 +249,7 @@ def __init__(self, rpc_url: str, ws_client: AsyncWeb3) -> None: self._background_tasks = set() def is_connected( - client: AsyncWeb3, + client: HTTPClient, ) -> Callable[[bool], Coroutine[Any, Any, bool]]: async def inner(show_traceback: bool) -> bool: try: @@ -291,7 +298,7 @@ def http_client(self) -> ArkivHttpClient: """Get the underlying web3 http client.""" return self._http_client - def ws_client(self) -> AsyncWeb3: + def ws_client(self) -> WSClient: """Get the underlying web3 websocket client.""" return self._ws_client @@ -582,7 +589,7 @@ async def create(rpc_url: str, ws_url: str, private_key: bytes) -> "ArkivClient" """ return await ArkivClient.create_rw_client(rpc_url, ws_url, private_key) - def __init__(self, rpc_url: str, ws_client: AsyncWeb3, private_key: bytes) -> None: + def __init__(self, rpc_url: str, ws_client: WSClient, private_key: bytes) -> None: """Initialise the ArkivClient instance.""" super().__init__(rpc_url, ws_client) diff --git a/arkiv_sdk/types.py b/arkiv_sdk/types.py index 020e3f2..3e913cc 100644 --- a/arkiv_sdk/types.py +++ b/arkiv_sdk/types.py @@ -74,6 +74,7 @@ class ExpirationTime: """ + # TODO: derive this from the chain using RPC BLOCK_TIME_SECONDS = 2 """Block time in seconds (Arkiv produces blocks every 2 seconds)""" diff --git a/arkiv_sdk/wallet.py b/arkiv_sdk/wallet.py index ce4351f..7eade9f 100644 --- a/arkiv_sdk/wallet.py +++ b/arkiv_sdk/wallet.py @@ -12,11 +12,13 @@ WALLET_PATH = Path(BaseDirectory.xdg_config_home) / "golembase" / "wallet.json" + class WalletError(Exception): """Base class for wallet-related errors.""" pass + async def decrypt_wallet() -> bytes: """Decrypts the wallet and returns the private key bytes.""" if not WALLET_PATH.exists(): @@ -41,4 +43,3 @@ async def decrypt_wallet() -> bytes: raise WalletError("Incorrect password or corrupted wallet file.") from e return cast(bytes, private_key) -