From f956c3e176c5c3d18ecbc6de58e515892eaa6dd1 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 5 Aug 2025 12:45:31 +0200 Subject: [PATCH 1/6] to revert: pointing sdk to aleph-sdk-python@1yam-voucher-service --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1b1d3dd2..d035a067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", "aleph-message>=1.0.1", - "aleph-sdk-python>=2.0.5", + "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@1yam-voucher-service", "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", "py-sr25519-bindings==0.2", # Needed for DOT signatures From b9af9c16e63fff84a65208333b57442905db886f Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 5 Aug 2025 13:18:35 +0200 Subject: [PATCH 2/6] Refactor: Remove Vouchers code from aleph-client --- src/aleph_client/voucher.py | 213 ------------------ tests/unit/test_voucher.py | 423 ------------------------------------ 2 files changed, 636 deletions(-) delete mode 100644 src/aleph_client/voucher.py delete mode 100644 tests/unit/test_voucher.py diff --git a/src/aleph_client/voucher.py b/src/aleph_client/voucher.py deleted file mode 100644 index 6fb4ed1d..00000000 --- a/src/aleph_client/voucher.py +++ /dev/null @@ -1,213 +0,0 @@ -import json -import logging -from decimal import Decimal -from typing import Optional, Union - -import aiohttp -from aleph.sdk.client.http import AlephHttpClient -from aleph.sdk.conf import settings -from aleph.sdk.query.filters import PostFilter -from aleph.sdk.query.responses import Post, PostsResponse -from aleph.sdk.types import Account -from aleph_message.models import Chain -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - - -VOUCHER_METDATA_TEMPLATE_URL = "https://claim.twentysix.cloud/sbt/metadata/{}.json" -VOUCHER_SOL_REGISTRY = "https://api.claim.twentysix.cloud/v1/registry/sol" -VOUCHER_SENDER = "0xB34f25f2c935bCA437C061547eA12851d719dEFb" - - -class VoucherAttribute(BaseModel): - value: Union[str, Decimal] - trait_type: str = Field(..., alias="trait_type") - display_type: Optional[str] = Field(None, alias="display_type") - - -class VoucherMetadata(BaseModel): - name: str - description: str - external_url: str = Field(..., alias="external_url") - image: str - icon: str - attributes: list[VoucherAttribute] - - -class Voucher(BaseModel): - id: str - metadata_id: str = Field(..., alias="metadata_id") - name: str - description: str - external_url: str = Field(..., alias="external_url") - image: str - icon: str - attributes: list[VoucherAttribute] - - -class VoucherManager: - def __init__(self, account: Optional[Account], chain: Optional[Chain]): - self.account = account or None - self.chain = chain or None - - def _resolve_address(self, address: Optional[str] = None) -> str: - """ - Resolve the address to use. Prefer the provided address, fallback to account. - """ - if address: - return address - if self.account: - return self.account.get_address() - error_msg = "No address provided and no account available to resolve address." - raise ValueError(error_msg) - - async def _fetch_voucher_update(self): - """ - Fetch the latest EVM voucher update (unfiltered). - """ - async with AlephHttpClient(api_server=settings.API_HOST) as client: - post_filter = PostFilter(types=["vouchers-update"], addresses=[VOUCHER_SENDER]) - vouchers_post: PostsResponse = await client.get_posts(post_filter=post_filter, page_size=1) - if not vouchers_post.posts: - return [] - - message_post: Post = vouchers_post.posts[0] - nft_vouchers = message_post.content.get("nft_vouchers", {}) - return list(nft_vouchers.items()) # [(voucher_id, voucher_data)] - - async def _fetch_solana_voucher(self): - """ - Fetch full Solana voucher registry (unfiltered). - """ - try: - async with aiohttp.ClientSession() as session: - try: - async with session.get(VOUCHER_SOL_REGISTRY) as resp: - if resp.status != 200: - return {} - - try: - return await resp.json() - except aiohttp.client_exceptions.ContentTypeError: - text_data = await resp.text() - try: - return json.loads(text_data) - except json.JSONDecodeError: - return {} - except Exception: - return {} - except Exception: - return {} - - async def get_all(self, address: Optional[str] = None) -> list[Voucher]: - """ - Retrieve all vouchers for the account / specific adress, across EVM and Solana chains. - """ - vouchers = [] - - # Get EVM vouchers - evm_vouchers = await self.get_evm_voucher(address=address) - vouchers.extend(evm_vouchers) - - # Get Solana vouchers - solana_vouchers = await self.fetch_solana_vouchers(address=address) - vouchers.extend(solana_vouchers) - - return vouchers - - async def fetch_vouchers_by_chain(self, chain: Chain): - if chain == Chain.SOL: - return await self.fetch_solana_vouchers() - else: - return await self.get_evm_voucher() - - async def get_evm_voucher(self, address: Optional[str] = None) -> list[Voucher]: - """ - Retrieve vouchers specific to EVM chains for a specific address. - """ - resolved_address = self._resolve_address(address=address) - vouchers: list[Voucher] = [] - - nft_vouchers = await self._fetch_voucher_update() - for voucher_id, voucher_data in nft_vouchers: - if voucher_data.get("claimer") != resolved_address: - continue - - metadata_id = voucher_data.get("metadata_id") - metadata = await self.fetch_metadata(metadata_id) - if not metadata: - continue - - voucher = Voucher( - id=voucher_id, - metadata_id=metadata_id, - name=metadata.name, - description=metadata.description, - external_url=metadata.external_url, - image=metadata.image, - icon=metadata.icon, - attributes=metadata.attributes, - ) - vouchers.append(voucher) - return vouchers - - async def fetch_solana_vouchers(self, address: Optional[str] = None) -> list[Voucher]: - """ - Fetch Solana vouchers for a specific address. - """ - resolved_address = self._resolve_address(address=address) - vouchers: list[Voucher] = [] - - registry_data = await self._fetch_solana_voucher() - - claimed_tickets = registry_data.get("claimed_tickets", {}) - batches = registry_data.get("batches", {}) - - for ticket_hash, ticket_data in claimed_tickets.items(): - claimer = ticket_data.get("claimer") - if claimer != resolved_address: - continue - - batch_id = ticket_data.get("batch_id") - metadata_id = None - - if str(batch_id) in batches: - metadata_id = batches[str(batch_id)].get("metadata_id") - - if metadata_id: - metadata = await self.fetch_metadata(metadata_id) - if metadata: - voucher = Voucher( - id=ticket_hash, - metadata_id=metadata_id, - name=metadata.name, - description=metadata.description, - external_url=metadata.external_url, - image=metadata.image, - icon=metadata.icon, - attributes=metadata.attributes, - ) - vouchers.append(voucher) - - return vouchers - - async def fetch_metadata(self, metadata_id: str) -> Optional[VoucherMetadata]: - """ - Fetch metadata for a given voucher. - """ - url = f"https://claim.twentysix.cloud/sbt/metadata/{metadata_id}.json" - try: - async with aiohttp.ClientSession() as session: - try: - async with session.get(url) as resp: - if resp.status != 200: - return None - data = await resp.json() - return VoucherMetadata.model_validate(data) - except Exception as e: - logger.error(f"Error fetching metadata: {e}") - return None - except Exception as e: - logger.error(f"Error creating session: {e}") - return None diff --git a/tests/unit/test_voucher.py b/tests/unit/test_voucher.py deleted file mode 100644 index 084b2436..00000000 --- a/tests/unit/test_voucher.py +++ /dev/null @@ -1,423 +0,0 @@ -from decimal import Decimal -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from aleph.sdk.chains.ethereum import get_fallback_account as eth_fallback -from aleph.sdk.chains.solana import get_fallback_account as sol_fallback -from aleph.sdk.query.responses import Post, PostsResponse -from aleph_message.models import Chain - -from aleph_client.voucher import ( - Voucher, - VoucherAttribute, - VoucherManager, - VoucherMetadata, -) - -# Test data -MOCK_ADDRESS = "0x1234567890123456789012345678901234567890" -MOCK_SOLANA_ADDRESS = "abcdefghijklmnopqrstuvwxyz123456789" - -MOCK_METADATA_ID = "metadata123" -MOCK_VOUCHER_ID = "voucher123" - -MOCK_METADATA = { - "name": "Test Voucher", - "description": "A test voucher", - "external_url": "https://example.com", - "image": "https://example.com/image.png", - "icon": "https://example.com/icon.png", - "attributes": [ - {"trait_type": "Test Trait", "value": "Test Value"}, - {"trait_type": "Numeric Trait", "value": "123", "display_type": "number"}, - ], -} - -MOCK_EVM_VOUCHER_DATA = [(MOCK_VOUCHER_ID, {"claimer": MOCK_ADDRESS, "metadata_id": MOCK_METADATA_ID})] - -MOCK_SOLANA_REGISTRY = { - "claimed_tickets": {"solticket123": {"claimer": MOCK_SOLANA_ADDRESS, "batch_id": "batch123"}}, - "batches": {"batch123": {"metadata_id": MOCK_METADATA_ID}}, -} - - -@pytest.fixture -def mock_account(): - account = eth_fallback() - with patch.object(account, "get_address", return_value=MOCK_ADDRESS): - yield account - - -@pytest.fixture -def mock_solana_account(): - account = sol_fallback() - with patch.object(account, "get_address", return_value=MOCK_SOLANA_ADDRESS): - yield account - - -@pytest.fixture -def voucher_manager(mock_account): - return VoucherManager(mock_account, Chain.ETH) - - -@pytest.fixture -def solana_voucher_manager(mock_solana_account): - return VoucherManager(mock_solana_account, Chain.SOL) - - -class TestVoucherAttribute: - def test_voucher_attribute_creation(self): - attr = VoucherAttribute(trait_type="Test Trait", value="Test Value") - assert attr.trait_type == "Test Trait" - assert attr.value == "Test Value" - assert attr.display_type is None - - attr = VoucherAttribute(trait_type="Test Trait", value="Test Value", display_type="number") - assert attr.trait_type == "Test Trait" - assert attr.value == "Test Value" - assert attr.display_type == "number" - - attr = VoucherAttribute(trait_type="Test Trait", value=Decimal("123")) - assert attr.trait_type == "Test Trait" - assert attr.value == Decimal("123") - - -class TestVoucherMetadata: - def test_voucher_metadata_creation(self): - metadata = VoucherMetadata( - name="Test Voucher", - description="A test voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[VoucherAttribute(trait_type="Test Trait", value="Test Value")], - ) - - assert metadata.name == "Test Voucher" - assert metadata.description == "A test voucher" - assert metadata.external_url == "https://example.com" - assert metadata.image == "https://example.com/image.png" - assert metadata.icon == "https://example.com/icon.png" - assert len(metadata.attributes) == 1 - assert metadata.attributes[0].trait_type == "Test Trait" - assert metadata.attributes[0].value == "Test Value" - - -class TestVoucher: - def test_voucher_creation(self): - voucher = Voucher( - id=MOCK_VOUCHER_ID, - metadata_id=MOCK_METADATA_ID, - name="Test Voucher", - description="A test voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[VoucherAttribute(trait_type="Test Trait", value="Test Value")], - ) - - assert voucher.id == MOCK_VOUCHER_ID - assert voucher.metadata_id == MOCK_METADATA_ID - assert voucher.name == "Test Voucher" - assert voucher.description == "A test voucher" - assert voucher.external_url == "https://example.com" - assert voucher.image == "https://example.com/image.png" - assert voucher.icon == "https://example.com/icon.png" - assert len(voucher.attributes) == 1 - assert voucher.attributes[0].trait_type == "Test Trait" - assert voucher.attributes[0].value == "Test Value" - - -class TestVoucherManager: - def test_init(self, mock_account): - # Test with account and chain - vm = VoucherManager(mock_account, Chain.ETH) - assert vm.account is mock_account - assert vm.chain is Chain.ETH - - # Test with None values - vm = VoucherManager(None, None) - assert vm.account is None - assert vm.chain is None - - def test_resolve_address(self, voucher_manager): - # Test with provided address - address = voucher_manager._resolve_address("0xabcdef") - assert address == "0xabcdef" - - # Test with account address - address = voucher_manager._resolve_address() - assert address == MOCK_ADDRESS - - # Test with no address and no account - vm = VoucherManager(None, None) - with pytest.raises(ValueError): - vm._resolve_address() - - @pytest.mark.asyncio - async def test_fetch_voucher_update(self, voucher_manager): - mock_posts_response = AsyncMock() - mock_post = MagicMock(spec=Post) - mock_post.content = { - "nft_vouchers": {MOCK_VOUCHER_ID: {"claimer": MOCK_ADDRESS, "metadata_id": MOCK_METADATA_ID}} - } - mock_posts_response.posts = [mock_post] - - mock_client = AsyncMock() - mock_client.get_posts = AsyncMock(return_value=mock_posts_response) - - with patch( - "aleph_client.voucher.AlephHttpClient", - return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_client), __aexit__=AsyncMock()), - ): - result = await voucher_manager._fetch_voucher_update() - - assert len(result) == 1 - assert result[0][0] == MOCK_VOUCHER_ID - assert result[0][1]["claimer"] == MOCK_ADDRESS - assert result[0][1]["metadata_id"] == MOCK_METADATA_ID - - @pytest.mark.asyncio - async def test_fetch_voucher_update_empty(self, voucher_manager): - mock_posts_response = AsyncMock(spec=PostsResponse) - mock_posts_response.posts = [] - - mock_client = AsyncMock() - mock_client.get_posts = AsyncMock(return_value=mock_posts_response) - - with patch( - "aleph_client.voucher.AlephHttpClient", - return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_client), __aexit__=AsyncMock()), - ): - result = await voucher_manager._fetch_voucher_update() - - assert result == [] - - @pytest.mark.asyncio - async def test_fetch_solana_voucher(self, voucher_manager): - # Override the original method with a direct mock - voucher_manager._fetch_solana_voucher = AsyncMock(return_value=MOCK_SOLANA_REGISTRY) - - result = await voucher_manager._fetch_solana_voucher() - - assert result == MOCK_SOLANA_REGISTRY - - @pytest.mark.asyncio - async def test_fetch_solana_voucher_error_status(self, voucher_manager): - # Override the original method with a direct mock - voucher_manager._fetch_solana_voucher = AsyncMock(return_value={}) - - result = await voucher_manager._fetch_solana_voucher() - - assert result == {} - - @pytest.mark.asyncio - async def test_fetch_solana_voucher_content_type_error(self, voucher_manager): - # Override the original method with a direct mock - voucher_manager._fetch_solana_voucher = AsyncMock(return_value=MOCK_SOLANA_REGISTRY) - - result = await voucher_manager._fetch_solana_voucher() - - assert result == MOCK_SOLANA_REGISTRY - - @pytest.mark.asyncio - async def test_fetch_solana_voucher_json_decode_error(self, voucher_manager): - # Override the original method with a direct mock - voucher_manager._fetch_solana_voucher = AsyncMock(return_value={}) - - result = await voucher_manager._fetch_solana_voucher() - - assert result == {} - - @pytest.mark.asyncio - async def test_fetch_metadata(self, voucher_manager): - # Create a VoucherMetadata instance directly from the mock data - mock_metadata = VoucherMetadata( - name="Test Voucher", - description="A test voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[ - VoucherAttribute(trait_type="Test Trait", value="Test Value"), - VoucherAttribute(trait_type="Numeric Trait", value="123", display_type="number"), - ], - ) - - # Override the original method with a direct mock - voucher_manager.fetch_metadata = AsyncMock(return_value=mock_metadata) - - result = await voucher_manager.fetch_metadata(MOCK_METADATA_ID) - - assert isinstance(result, VoucherMetadata) - assert result.name == "Test Voucher" - assert result.description == "A test voucher" - assert result.external_url == "https://example.com" - assert result.image == "https://example.com/image.png" - assert result.icon == "https://example.com/icon.png" - assert len(result.attributes) == 2 - - @pytest.mark.asyncio - async def test_fetch_metadata_error(self, voucher_manager): - # Override the original method with a direct mock - voucher_manager.fetch_metadata = AsyncMock(return_value=None) - - result = await voucher_manager.fetch_metadata(MOCK_METADATA_ID) - - assert result is None - - @pytest.mark.asyncio - async def test_get_evm_voucher(self, voucher_manager): - # Mock _fetch_voucher_update - voucher_manager._fetch_voucher_update = AsyncMock(return_value=MOCK_EVM_VOUCHER_DATA) - - # Mock fetch_metadata - mock_metadata = VoucherMetadata( - name="Test Voucher", - description="A test voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[VoucherAttribute(trait_type="Test Trait", value="Test Value")], - ) - voucher_manager.fetch_metadata = AsyncMock(return_value=mock_metadata) - - result = await voucher_manager.get_evm_voucher() - - assert len(result) == 1 - assert isinstance(result[0], Voucher) - assert result[0].id == MOCK_VOUCHER_ID - assert result[0].metadata_id == MOCK_METADATA_ID - assert result[0].name == "Test Voucher" - - # Test with specific address - original_resolve = voucher_manager._resolve_address - voucher_manager._resolve_address = MagicMock(return_value="0xspecific") - result = await voucher_manager.get_evm_voucher("0xspecific") - voucher_manager._resolve_address.assert_called_with(address="0xspecific") - voucher_manager._resolve_address = original_resolve - - @pytest.mark.asyncio - async def test_get_evm_voucher_no_match(self, voucher_manager): - # Mock _fetch_voucher_update with non-matching claimer - voucher_manager._fetch_voucher_update = AsyncMock( - return_value=[(MOCK_VOUCHER_ID, {"claimer": "0xdifferent", "metadata_id": MOCK_METADATA_ID})] - ) - - result = await voucher_manager.get_evm_voucher() - - assert len(result) == 0 - - @pytest.mark.asyncio - async def test_get_evm_voucher_no_metadata(self, voucher_manager): - # Mock _fetch_voucher_update - voucher_manager._fetch_voucher_update = AsyncMock(return_value=MOCK_EVM_VOUCHER_DATA) - - # Mock fetch_metadata to return None - voucher_manager.fetch_metadata = AsyncMock(return_value=None) - - result = await voucher_manager.get_evm_voucher() - - assert len(result) == 0 - - @pytest.mark.asyncio - async def test_fetch_solana_vouchers(self, solana_voucher_manager): - # Mock _fetch_solana_voucher - solana_voucher_manager._fetch_solana_voucher = AsyncMock(return_value=MOCK_SOLANA_REGISTRY) - - # Mock fetch_metadata - mock_metadata = VoucherMetadata( - name="Test Voucher", - description="A test voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[VoucherAttribute(trait_type="Test Trait", value="Test Value")], - ) - solana_voucher_manager.fetch_metadata = AsyncMock(return_value=mock_metadata) - - result = await solana_voucher_manager.fetch_solana_vouchers() - - assert len(result) == 1 - assert isinstance(result[0], Voucher) - assert result[0].id == "solticket123" - assert result[0].metadata_id == MOCK_METADATA_ID - assert result[0].name == "Test Voucher" - - original_resolve = solana_voucher_manager._resolve_address - solana_voucher_manager._resolve_address = MagicMock(return_value="specificsolana") - result = await solana_voucher_manager.fetch_solana_vouchers("specificsolana") - solana_voucher_manager._resolve_address.assert_called_with(address="specificsolana") - solana_voucher_manager._resolve_address = original_resolve - - @pytest.mark.asyncio - async def test_fetch_solana_vouchers_no_match(self, solana_voucher_manager): - # Mock _fetch_solana_voucher with non-matching claimer - mock_registry = { - "claimed_tickets": {"solticket123": {"claimer": "differentsolana", "batch_id": "batch123"}}, - "batches": {"batch123": {"metadata_id": MOCK_METADATA_ID}}, - } - solana_voucher_manager._fetch_solana_voucher = AsyncMock(return_value=mock_registry) - - result = await solana_voucher_manager.fetch_solana_vouchers() - - assert len(result) == 0 - - @pytest.mark.asyncio - async def test_fetch_solana_vouchers_no_batch(self, solana_voucher_manager): - # Mock _fetch_solana_voucher with no matching batch - mock_registry = { - "claimed_tickets": {"solticket123": {"claimer": MOCK_SOLANA_ADDRESS, "batch_id": "nonexistent"}}, - "batches": {}, - } - solana_voucher_manager._fetch_solana_voucher = AsyncMock(return_value=mock_registry) - - result = await solana_voucher_manager.fetch_solana_vouchers() - - assert len(result) == 0 - - @pytest.mark.asyncio - async def test_fetch_solana_vouchers_no_metadata(self, solana_voucher_manager): - # Mock _fetch_solana_voucher - solana_voucher_manager._fetch_solana_voucher = AsyncMock(return_value=MOCK_SOLANA_REGISTRY) - - # Mock fetch_metadata to return None - solana_voucher_manager.fetch_metadata = AsyncMock(return_value=None) - - result = await solana_voucher_manager.fetch_solana_vouchers() - - assert len(result) == 0 - - @pytest.mark.asyncio - async def test_get_all(self, voucher_manager): - # Mock get_evm_voucher - evm_voucher = Voucher( - id="evm123", - metadata_id=MOCK_METADATA_ID, - name="EVM Voucher", - description="An EVM voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[VoucherAttribute(trait_type="Test Trait", value="Test Value")], - ) - voucher_manager.get_evm_voucher = AsyncMock(return_value=[evm_voucher]) - - # Mock fetch_solana_vouchers - solana_voucher = Voucher( - id="solana123", - metadata_id=MOCK_METADATA_ID, - name="Solana Voucher", - description="A Solana voucher", - external_url="https://example.com", - image="https://example.com/image.png", - icon="https://example.com/icon.png", - attributes=[VoucherAttribute(trait_type="Test Trait", value="Test Value")], - ) - voucher_manager.fetch_solana_vouchers = AsyncMock(return_value=[solana_voucher]) - - result = await voucher_manager.get_all() - - assert len(result) == 2 - assert result[0] == evm_voucher - assert result[1] == solana_voucher From 140bbe51094966e2bd1dd7d30ba45bd79109b6b5 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 5 Aug 2025 13:19:20 +0200 Subject: [PATCH 3/6] Fix: replace voucher Manager from cli to use new client service 'vouchers' --- src/aleph_client/commands/account.py | 12 +++++------- src/aleph_client/commands/instance/__init__.py | 13 +++++-------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index d12e1b24..b3b9d3f0 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -9,6 +9,7 @@ import aiohttp import typer +from aleph.sdk import AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.chains.common import generate_key from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key @@ -42,7 +43,6 @@ yes_no_input, ) from aleph_client.utils import AsyncTyper, list_unlinked_keys -from aleph_client.voucher import VoucherManager logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -301,8 +301,6 @@ async def balance( if account and not address: address = account.get_address() - voucher_manager = VoucherManager(account=account, chain=chain) - if address: try: balance_data = await get_balance(address) @@ -335,7 +333,8 @@ async def balance( ] # Get vouchers and add them to Account Info panel - vouchers = await voucher_manager.get_all(address=address) + async with AuthenticatedAlephHttpClient(account=account) as client: + vouchers = await client.voucher.get_vouchers(address=address) if vouchers: voucher_names = [voucher.name for voucher in vouchers] infos += [ @@ -422,11 +421,10 @@ async def vouchers( if account and not address: address = account.get_address() - voucher_manager = VoucherManager(account=account, chain=chain) - if address: try: - vouchers = await voucher_manager.get_all(address=address) + async with AuthenticatedAlephHttpClient(account=account) as client: + vouchers = await client.voucher.get_vouchers(address=address) if vouchers: voucher_table = Table(title="", show_header=True, box=box.ROUNDED) voucher_table.add_column("Name", style="bright_cyan") diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index f43b4434..adae3c92 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -85,7 +85,6 @@ yes_no_input, ) from aleph_client.utils import AsyncTyper, sanitize_url -from aleph_client.voucher import VoucherManager logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -193,9 +192,7 @@ async def create( # Force-switches if NFT payment-type nft_chains = [Chain.AVAX, Chain.BASE, Chain.SOL] if payment_type == "nft": - voucher_manager = VoucherManager(account=account, chain=Chain(account.CHAIN)) payment_type = PaymentType.hold - if payment_chain is None or payment_chain not in nft_chains: if payment_chain: console.print( @@ -210,11 +207,11 @@ async def create( default=Chain.AVAX.value, ) ) - - vouchers = await voucher_manager.fetch_vouchers_by_chain(payment_chain) - if len(vouchers) == 0: - console.print("No NFT vouchers find on this account") - raise typer.Exit(code=1) + async with AuthenticatedAlephHttpClient(account=account) as client: + vouchers = await client.voucher.fetch_vouchers_by_chain(chain=Chain(account.CHAIN)) + if len(vouchers) == 0: + console.print("No NFT vouchers find on this account") + raise typer.Exit(code=1) elif payment_type in [ptype.value for ptype in PaymentType]: payment_type = PaymentType(payment_type) From 2b4d553a1a58366f3001f8bc856a981d4c086b8b Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 5 Aug 2025 13:24:45 +0200 Subject: [PATCH 4/6] fix: linting issue on pyproject.toml --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d035a067..dbcd2c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,16 +32,16 @@ dependencies = [ "aiohttp==3.11.13", "aleph-message>=1.0.1", "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@1yam-voucher-service", - "base58==2.1.1", # Needed now as default with _load_account changement + "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", - "py-sr25519-bindings==0.2", # Needed for DOT signatures + "py-sr25519-bindings==0.2", # Needed for DOT signatures "pydantic>=2", "pygments==2.19.1", - "pynacl==1.5", # Needed now as default with _load_account changement + "pynacl==1.5", # Needed now as default with _load_account changement "python-magic==0.4.27", "rich==13.9.*", "setuptools>=65.5", - "substrate-interface==1.7.11", # Needed for DOT signatures + "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", ] From d542076bb65e5e3725af57ece0598b3bd0259224 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 14:27:32 +0200 Subject: [PATCH 5/6] fix: mypy issue --- src/aleph_client/commands/instance/display.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index 5f04c5e3..f401e671 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -344,10 +344,15 @@ def _prepare_allocation_column(self): color_allocation = "magenta3" crn_hash = safe_getattr(self.message.content.requirements, "node.node_hash") or "" else: - crn_url = self.allocation.allocations.node.url + allocation_node = ( + self.allocation.allocations.node + if self.allocation.allocations and self.allocation.allocations.node + else None + ) + crn_url = allocation_node.url if allocation_node else "" allocation_str = ALLOCATION_AUTO color_allocation = "deep_sky_blue1" - crn_hash = self.allocation.allocations.node.node_id + crn_hash = allocation_node.node_id if allocation_node else "" # Assemble the complete allocation column self.allocation_column = cast( From 1b0eecb8a9ca08a1cf91fefcd41060361dc58cfd Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 2 Sep 2025 14:28:13 +0200 Subject: [PATCH 6/6] Feature: proper unit test case for voucher --- tests/unit/conftest.py | 62 ++++++++++++++++++++++++++++- tests/unit/test_commands.py | 9 ++++- tests/unit/test_instance.py | 79 ++++++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 6 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1e759857..30441935 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -13,12 +13,12 @@ from datetime import datetime, timezone from pathlib import Path from tempfile import NamedTemporaryFile -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from aleph.sdk.chains.common import generate_key from aleph.sdk.chains.ethereum import ETHAccount, get_fallback_private_key -from aleph.sdk.types import StoredContent +from aleph.sdk.types import StoredContent, Voucher, VoucherAttribute from aleph_message.models import Chain, ItemHash, ItemType, StoreContent, StoreMessage from aleph_message.models.base import MessageType @@ -37,6 +37,12 @@ FAKE_STORE_HASH, ) +# Constants for voucher testing +MOCK_ADDRESS = "0x1234567890123456789012345678901234567890" +MOCK_SOLANA_ADDRESS = "abcdefghijklmnopqrstuvwxyz123456789" +MOCK_METADATA_ID = "metadata123" +MOCK_VOUCHER_ID = "voucher123" + @pytest.fixture def new_config_file() -> Generator[Path, None, None]: @@ -466,3 +472,55 @@ async def mock_json(): instance.get = AsyncMock(return_value=mock_response) yield mock_session + + +@pytest.fixture +def mock_vouchers(): + """Create mock vouchers for testing.""" + # Create EVM voucher + evm_voucher = Voucher( + id=MOCK_VOUCHER_ID, + metadata_id=MOCK_METADATA_ID, + name="EVM Test Voucher", + description="A test voucher for EVM chains", + external_url="https://example.com", + image="https://example.com/image.png", + icon="https://example.com/icon.png", + attributes=[ + VoucherAttribute(trait_type="Duration", value="30 days", display_type="string"), + VoucherAttribute(trait_type="Compute Units", value="4", display_type="number"), + VoucherAttribute(trait_type="Type", value="instance", display_type="string"), + ], + ) + + # Create Solana voucher + solana_voucher = Voucher( + id="solticket123", + metadata_id=MOCK_METADATA_ID, + name="Solana Test Voucher", + description="A test voucher for Solana", + external_url="https://example.com", + image="https://example.com/image.png", + icon="https://example.com/icon.png", + attributes=[ + VoucherAttribute(trait_type="Duration", value="60 days", display_type="string"), + VoucherAttribute(trait_type="Compute Units", value="8", display_type="number"), + VoucherAttribute(trait_type="Type", value="instance", display_type="string"), + ], + ) + + return evm_voucher, solana_voucher + + +@pytest.fixture +def mock_voucher_service(mock_vouchers): + """Create a mock voucher service with pre-configured responses.""" + evm_voucher, solana_voucher = mock_vouchers + + mock_service = MagicMock() + mock_service.fetch_vouchers_by_chain = AsyncMock(return_value=[evm_voucher]) + mock_service.get_vouchers = AsyncMock(return_value=[evm_voucher, solana_voucher]) + mock_service.get_evm_vouchers = AsyncMock(return_value=[evm_voucher]) + mock_service.get_solana_vouchers = AsyncMock(return_value=[solana_voucher]) + + return mock_service diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 8ca47f7f..8a2c7386 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -244,7 +244,7 @@ def test_account_sign_bytes(env_files): assert result.stdout.startswith("\nSignature:") -def test_account_balance(mocker, env_files): +def test_account_balance(mocker, env_files, mock_voucher_service): settings.CONFIG_FILE = env_files[1] balance_response = { "address": "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe", @@ -255,7 +255,10 @@ def test_account_balance(mocker, env_files): } mocker.patch("aleph_client.commands.account.get_balance", return_value=balance_response) - mocker.patch("aleph_client.voucher.VoucherManager.get_all", return_value=[]) + # TODO: used the mocked_client fixture instead (also need to move get_balance to the SDK) + mock_client = mocker.AsyncMock() + mock_client.voucher = mock_voucher_service + mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient.__aenter__", return_value=mock_client) result = runner.invoke( app, ["account", "balance", "--address", "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe", "--chain", "ETH"] @@ -263,6 +266,8 @@ def test_account_balance(mocker, env_files): assert result.exit_code == 0 assert result.stdout.startswith("╭─ Account Infos") assert "Available: 20189.67" in result.stdout + assert "Vouchers:" in result.stdout + assert "EVM Test Voucher" in result.stdout def test_account_balance_error(mocker, env_files): diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index a43b828e..fbd3c8d9 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -47,6 +47,7 @@ ) from aleph_client.utils import FORBIDDEN_HOSTS, sanitize_url +from .conftest import MOCK_METADATA_ID, MOCK_VOUCHER_ID from .mocks import ( FAKE_ADDRESS_EVM, FAKE_CRN_BASIC_HASH, @@ -254,7 +255,7 @@ def create_mock_shutil(): return MagicMock(which=MagicMock(return_value="/root/.cargo/bin/sevctl", move=MagicMock(return_value="/fake/path"))) -def create_mock_client(mock_crn_list, payment_type="superfluid"): +def create_mock_client(mock_crn_list, payment_type="superfluid", mock_voucher_service=None): # Create a proper mock for the crn service mock_crn_service = MagicMock() mock_crn_service.get_crns_list = AsyncMock(return_value={"crns": mock_crn_list}) @@ -271,11 +272,13 @@ def create_mock_client(mock_crn_list, payment_type="superfluid"): ) ), ) - # Set the crn attribute to the properly mocked service + # Set the service attributes mock_client.crn = mock_crn_service + mock_client.voucher = mock_voucher_service mock_client_class = MagicMock() mock_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_client) + return mock_client_class, mock_client @@ -291,6 +294,48 @@ def response_get_program_price(ptype): mock_crn_service = MagicMock() mock_crn_service.get_crns_list = AsyncMock(return_value={"crns": mock_crn_list or []}) + # Create voucher attributes using the proper types + from aleph.sdk.types import Voucher, VoucherAttribute + + # Create EVM voucher + evm_voucher = Voucher( + id=MOCK_VOUCHER_ID, + metadata_id=MOCK_METADATA_ID, + name="EVM Test Voucher", + description="A test voucher for EVM chains", + external_url="https://example.com", + image="https://example.com/image.png", + icon="https://example.com/icon.png", + attributes=[ + VoucherAttribute(trait_type="Duration", value="30 days", display_type="string"), + VoucherAttribute(trait_type="Compute Units", value="4", display_type="number"), + VoucherAttribute(trait_type="Type", value="instance", display_type="string"), + ], + ) + + # Create Solana voucher + solana_voucher = Voucher( + id="solticket123", + metadata_id=MOCK_METADATA_ID, + name="Solana Test Voucher", + description="A test voucher for Solana", + external_url="https://example.com", + image="https://example.com/image.png", + icon="https://example.com/icon.png", + attributes=[ + VoucherAttribute(trait_type="Duration", value="60 days", display_type="string"), + VoucherAttribute(trait_type="Compute Units", value="8", display_type="number"), + VoucherAttribute(trait_type="Type", value="instance", display_type="string"), + ], + ) + + # Create a proper mock for voucher service + mock_voucher_service = MagicMock() + mock_voucher_service.fetch_vouchers_by_chain = AsyncMock(return_value=[evm_voucher]) + mock_voucher_service.get_vouchers = AsyncMock(return_value=[evm_voucher, solana_voucher]) + mock_voucher_service.get_evm_vouchers = AsyncMock(return_value=[evm_voucher]) + mock_voucher_service.get_solana_vouchers = AsyncMock(return_value=[solana_voucher]) + mock_response_get_message = create_mock_instance_message(mock_account, payg=True) mock_response_create_instance = MagicMock(item_hash=FAKE_VM_HASH) @@ -312,6 +357,7 @@ def response_get_program_price(ptype): # Set the service attributes mock_auth_client.crn = mock_crn_service mock_auth_client.port_forwarder = mock_port_forwarder + mock_auth_client.voucher = mock_voucher_service if payment_types: mock_auth_client.get_program_price = AsyncMock( @@ -374,6 +420,9 @@ def create_mock_vm_coco_client(): "coco_hold_evm", "coco_superfluid_evm", "gpu_superfluid_evm", + "nft_payment_avax", + "nft_payment_base", + "nft_payment_sol", ], argnames="args, expected", argvalues=[ @@ -445,6 +494,32 @@ def create_mock_vm_coco_client(): }, (FAKE_VM_HASH, FAKE_CRN_GPU_URL, "BASE"), ), + ( # nft_payment_avax + { + "payment_type": "nft", + "payment_chain": "AVAX", + "rootfs": "debian12", + }, + (FAKE_VM_HASH, None, "AVAX"), + ), + ( # nft_payment_base + { + "payment_type": "nft", + "payment_chain": "BASE", + "rootfs": "debian12", + "crn_url": FAKE_CRN_BASIC_URL, + }, + (FAKE_VM_HASH, None, "BASE"), + ), + ( # nft_payment_sol + { + "payment_type": "nft", + "payment_chain": "SOL", + "rootfs": "debian12", + "crn_url": FAKE_CRN_BASIC_URL, + }, + (FAKE_VM_HASH, None, "SOL"), + ), ], ) @pytest.mark.asyncio