Skip to content

Commit bdc8019

Browse files
authored
Feature: Voucher integrations (#225)
* feat: new field in conf for voucher * feat: Vouchers BaseModel # Conflicts: # src/aleph/sdk/types.py * Feat: voucher integrations * Feat: AuthenticatedVoucher integrations * fix: move fixture / metdata for voucher to conftest.py * fix: add vouchers and authenticated voucher to client * fix: remove debug print * Fix: only mock the get_posts from client and use real client * Fix: linting issue * Fix: import sort issue * refactor: using with patch.objects instead of direcly assign method for mypy * Fix: rename VOUCHER_SENDER to VOUCHER_ORIGIN_ADDRESS * fix: Vouchers import * fix: types.py lint issue * fix: get_stored_content should fetch data when removing
1 parent 4eb207c commit bdc8019

File tree

9 files changed

+557
-1
lines changed

9 files changed

+557
-1
lines changed

src/aleph/sdk/client/authenticated_http.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .abstract import AuthenticatedAlephClient
4040
from .http import AlephHttpClient
4141
from .services.authenticated_port_forwarder import AuthenticatedPortForwarder
42+
from .services.authenticated_voucher import AuthenticatedVoucher
4243

4344
logger = logging.getLogger(__name__)
4445

@@ -86,7 +87,7 @@ async def __aenter__(self):
8687
await super().__aenter__()
8788
# Override services with authenticated versions
8889
self.port_forwarder = AuthenticatedPortForwarder(self)
89-
90+
self.voucher = AuthenticatedVoucher(self)
9091
return self
9192

9293
async def ipfs_push(self, content: Mapping) -> str:

src/aleph/sdk/client/http.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from aleph.sdk.client.services.port_forwarder import PortForwarder
4040
from aleph.sdk.client.services.pricing import Pricing
4141
from aleph.sdk.client.services.scheduler import Scheduler
42+
from aleph.sdk.client.services.voucher import Vouchers
4243

4344
from ..conf import settings
4445
from ..exceptions import (
@@ -137,6 +138,8 @@ async def __aenter__(self):
137138
self.scheduler = Scheduler(self)
138139
self.instance = Instance(self)
139140
self.pricing = Pricing(self)
141+
self.voucher = Vouchers(self)
142+
140143
return self
141144

142145
async def __aexit__(self, exc_type, exc_val, exc_tb):
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import TYPE_CHECKING, Optional, overload
2+
3+
from typing_extensions import override
4+
5+
from aleph.sdk.types import Voucher
6+
7+
from .voucher import Vouchers
8+
9+
if TYPE_CHECKING:
10+
from aleph.sdk.client.abstract import AuthenticatedAlephClient
11+
12+
13+
class AuthenticatedVoucher(Vouchers):
14+
"""
15+
This service is same logic than Vouchers but allow to don't pass address
16+
to use account address
17+
"""
18+
19+
def __init__(self, client: "AuthenticatedAlephClient"):
20+
super().__init__(client)
21+
22+
@overload
23+
def _resolve_address(self, address: str) -> str: ...
24+
25+
@overload
26+
def _resolve_address(self, address: None) -> str: ...
27+
28+
@override
29+
def _resolve_address(self, address: Optional[str] = None) -> str:
30+
"""
31+
Resolve the address to use. Prefer the provided address, fallback to account.
32+
"""
33+
if address:
34+
return address
35+
if self._client.account:
36+
return self._client.account.get_address()
37+
38+
raise ValueError("No address provided and no account configured")
39+
40+
@override
41+
async def get_vouchers(self, address: Optional[str] = None) -> list[Voucher]:
42+
"""
43+
Retrieve all vouchers for the account / specific address, across EVM and Solana chains.
44+
"""
45+
address = address or self._client.account.get_address()
46+
return await super().get_vouchers(address=address)
47+
48+
@override
49+
async def get_evm_vouchers(self, address: Optional[str] = None) -> list[Voucher]:
50+
"""
51+
Retrieve vouchers specific to EVM chains for a specific address.
52+
"""
53+
address = address or self._client.account.get_address()
54+
return await super().get_evm_vouchers(address=address)
55+
56+
@override
57+
async def get_solana_vouchers(self, address: Optional[str] = None) -> list[Voucher]:
58+
"""
59+
Fetch Solana vouchers for a specific address.
60+
"""
61+
address = address or self._client.account.get_address()
62+
return await super().get_solana_vouchers(address=address)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from typing import Optional
2+
3+
import aiohttp
4+
from aiohttp import ClientResponseError
5+
from aleph_message.models import Chain
6+
7+
from aleph.sdk.conf import settings
8+
from aleph.sdk.query.filters import PostFilter
9+
from aleph.sdk.query.responses import Post, PostsResponse
10+
from aleph.sdk.types import Voucher, VoucherMetadata
11+
12+
13+
class Vouchers:
14+
"""
15+
This service is made to fetch voucher (SOL / EVM)
16+
"""
17+
18+
def __init__(self, client):
19+
self._client = client
20+
21+
# Utils
22+
def _resolve_address(self, address: str) -> str:
23+
return address # Not Authenticated client so address need to be given
24+
25+
async def _fetch_voucher_update(self):
26+
"""
27+
Fetch the latest EVM voucher update (unfiltered).
28+
"""
29+
30+
post_filter = PostFilter(
31+
types=["vouchers-update"], addresses=[settings.VOUCHER_ORIGIN_ADDRESS]
32+
)
33+
vouchers_post: PostsResponse = await self._client.get_posts(
34+
post_filter=post_filter, page_size=1
35+
)
36+
37+
if not vouchers_post.posts:
38+
return []
39+
40+
message_post: Post = vouchers_post.posts[0]
41+
42+
nft_vouchers = message_post.content.get("nft_vouchers", {})
43+
return list(nft_vouchers.items()) # [(voucher_id, voucher_data)]
44+
45+
async def _fetch_solana_voucher_list(self):
46+
"""
47+
Fetch full Solana voucher registry (unfiltered).
48+
"""
49+
try:
50+
async with aiohttp.ClientSession() as session:
51+
async with session.get(settings.VOUCHER_SOL_REGISTRY) as resp:
52+
resp.raise_for_status()
53+
return await resp.json()
54+
except ClientResponseError:
55+
return {}
56+
57+
async def fetch_voucher_metadata(
58+
self, metadata_id: str
59+
) -> Optional[VoucherMetadata]:
60+
"""
61+
Fetch metadata for a given voucher.
62+
"""
63+
url = f"https://claim.twentysix.cloud/sbt/metadata/{metadata_id}.json"
64+
try:
65+
async with aiohttp.ClientSession() as session:
66+
async with session.get(url) as resp:
67+
resp.raise_for_status()
68+
data = await resp.json()
69+
return VoucherMetadata.model_validate(data)
70+
except ClientResponseError:
71+
return None
72+
73+
async def get_solana_vouchers(self, address: str) -> list[Voucher]:
74+
"""
75+
Fetch Solana vouchers for a specific address.
76+
"""
77+
resolved_address = self._resolve_address(address=address)
78+
vouchers: list[Voucher] = []
79+
80+
registry_data = await self._fetch_solana_voucher_list()
81+
82+
claimed_tickets = registry_data.get("claimed_tickets", {})
83+
batches = registry_data.get("batches", {})
84+
85+
for ticket_hash, ticket_data in claimed_tickets.items():
86+
claimer = ticket_data.get("claimer")
87+
if claimer != resolved_address:
88+
continue
89+
90+
batch_id = ticket_data.get("batch_id")
91+
metadata_id = None
92+
93+
if str(batch_id) in batches:
94+
metadata_id = batches[str(batch_id)].get("metadata_id")
95+
96+
if metadata_id:
97+
metadata = await self.fetch_voucher_metadata(metadata_id)
98+
if metadata:
99+
voucher = Voucher(
100+
id=ticket_hash,
101+
metadata_id=metadata_id,
102+
name=metadata.name,
103+
description=metadata.description,
104+
external_url=metadata.external_url,
105+
image=metadata.image,
106+
icon=metadata.icon,
107+
attributes=metadata.attributes,
108+
)
109+
vouchers.append(voucher)
110+
111+
return vouchers
112+
113+
async def get_evm_vouchers(self, address: str) -> list[Voucher]:
114+
"""
115+
Retrieve vouchers specific to EVM chains for a specific address.
116+
"""
117+
resolved_address = self._resolve_address(address=address)
118+
vouchers: list[Voucher] = []
119+
120+
nft_vouchers = await self._fetch_voucher_update()
121+
for voucher_id, voucher_data in nft_vouchers:
122+
if voucher_data.get("claimer") != resolved_address:
123+
continue
124+
125+
metadata_id = voucher_data.get("metadata_id")
126+
metadata = await self.fetch_voucher_metadata(metadata_id)
127+
if not metadata:
128+
continue
129+
130+
voucher = Voucher(
131+
id=voucher_id,
132+
metadata_id=metadata_id,
133+
name=metadata.name,
134+
description=metadata.description,
135+
external_url=metadata.external_url,
136+
image=metadata.image,
137+
icon=metadata.icon,
138+
attributes=metadata.attributes,
139+
)
140+
vouchers.append(voucher)
141+
return vouchers
142+
143+
async def fetch_vouchers_by_chain(self, chain: Chain, address: str):
144+
if chain == Chain.SOL:
145+
return await self.get_solana_vouchers(address=address)
146+
else:
147+
return await self.get_evm_vouchers(address=address)
148+
149+
async def get_vouchers(self, address: str) -> list[Voucher]:
150+
"""
151+
Retrieve all vouchers for the account / specific adress, across EVM and Solana chains.
152+
"""
153+
vouchers = []
154+
155+
# Get EVM vouchers
156+
if address.startswith("0x") and len(address) == 42:
157+
evm_vouchers = await self.get_evm_vouchers(address=address)
158+
vouchers.extend(evm_vouchers)
159+
else:
160+
# Get Solana vouchers
161+
solana_vouchers = await self.get_solana_vouchers(address=address)
162+
vouchers.extend(solana_vouchers)
163+
164+
return vouchers

src/aleph/sdk/conf.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ class Settings(BaseSettings):
9292
)
9393
SCHEDULER_URL: ClassVar[str] = "https://scheduler.api.aleph.cloud/"
9494

95+
VOUCHER_METDATA_TEMPLATE_URL: str = (
96+
"https://claim.twentysix.cloud/sbt/metadata/{}.json"
97+
)
98+
VOUCHER_SOL_REGISTRY: str = "https://api.claim.twentysix.cloud/v1/registry/sol"
99+
VOUCHER_ORIGIN_ADDRESS: str = "0xB34f25f2c935bCA437C061547eA12851d719dEFb"
100+
95101
# Web3Provider settings
96102
TOKEN_DECIMALS: ClassVar[int] = 18
97103
TX_TIMEOUT: ClassVar[int] = 60 * 3

src/aleph/sdk/types.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from abc import abstractmethod
22
from datetime import datetime
3+
from decimal import Decimal
34
from enum import Enum
45
from typing import (
56
Any,
@@ -341,3 +342,29 @@ def items(self):
341342

342343
def get(self, key: str, default=None):
343344
return getattr(self, key, default)
345+
346+
347+
class VoucherAttribute(BaseModel):
348+
value: Union[str, Decimal]
349+
trait_type: str = Field(..., alias="trait_type")
350+
display_type: Optional[str] = Field(None, alias="display_type")
351+
352+
353+
class VoucherMetadata(BaseModel):
354+
name: str
355+
description: str
356+
external_url: str
357+
image: str
358+
icon: str
359+
attributes: list[VoucherAttribute]
360+
361+
362+
class Voucher(BaseModel):
363+
id: str
364+
metadata_id: str
365+
name: str
366+
description: str
367+
external_url: str
368+
image: str
369+
icon: str
370+
attributes: list[VoucherAttribute]

tests/unit/conftest.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,65 @@ def post(self, *_args, **_kwargs):
306306
client._http_session = http_session
307307

308308
return client
309+
310+
311+
@pytest.fixture
312+
def make_mock_aiohttp_session():
313+
def _make(mocked_json_response):
314+
mock_response = AsyncMock()
315+
mock_response.json.return_value = mocked_json_response
316+
mock_response.raise_for_status.return_value = None
317+
318+
session = MagicMock()
319+
320+
get_cm = AsyncMock()
321+
get_cm.__aenter__.return_value = mock_response
322+
session.get.return_value = get_cm
323+
324+
session_cm = AsyncMock()
325+
session_cm.__aenter__.return_value = session
326+
return session_cm
327+
328+
return _make
329+
330+
331+
# Constants needed for voucher tests
332+
MOCK_ADDRESS = "0x1234567890123456789012345678901234567890"
333+
MOCK_SOLANA_ADDRESS = "abcdefghijklmnopqrstuvwxyz123456789"
334+
MOCK_METADATA_ID = "metadata123"
335+
MOCK_VOUCHER_ID = "voucher123"
336+
MOCK_METADATA = {
337+
"name": "Test Voucher",
338+
"description": "A test voucher",
339+
"external_url": "https://example.com",
340+
"image": "https://example.com/image.png",
341+
"icon": "https://example.com/icon.png",
342+
"attributes": [
343+
{"trait_type": "Test Trait", "value": "Test Value"},
344+
{"trait_type": "Numeric Trait", "value": "123", "display_type": "number"},
345+
],
346+
}
347+
348+
MOCK_EVM_VOUCHER_DATA = [
349+
(MOCK_VOUCHER_ID, {"claimer": MOCK_ADDRESS, "metadata_id": MOCK_METADATA_ID})
350+
]
351+
352+
MOCK_SOLANA_REGISTRY = {
353+
"claimed_tickets": {
354+
"solticket123": {"claimer": MOCK_SOLANA_ADDRESS, "batch_id": "batch123"}
355+
},
356+
"batches": {"batch123": {"metadata_id": MOCK_METADATA_ID}},
357+
}
358+
359+
360+
@pytest.fixture
361+
def mock_post_response():
362+
mock_post = MagicMock()
363+
mock_post.content = {
364+
"nft_vouchers": {
365+
MOCK_VOUCHER_ID: {"claimer": MOCK_ADDRESS, "metadata_id": MOCK_METADATA_ID}
366+
}
367+
}
368+
posts_response = MagicMock()
369+
posts_response.posts = [mock_post]
370+
return posts_response

0 commit comments

Comments
 (0)