Skip to content

Commit 513c09d

Browse files
committed
Feature: Voucher Manager and unit test
1 parent 671ea0e commit 513c09d

File tree

2 files changed

+668
-0
lines changed

2 files changed

+668
-0
lines changed

src/aleph_client/voucher.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import json
2+
import logging
3+
from decimal import Decimal
4+
from typing import Optional, Union
5+
6+
import aiohttp
7+
from aleph.sdk.client.http import AlephHttpClient
8+
from aleph.sdk.conf import settings
9+
from aleph.sdk.query.filters import PostFilter
10+
from aleph.sdk.query.responses import Post, PostsResponse
11+
from aleph.sdk.types import Account
12+
from aleph_message.models import Chain
13+
from pydantic import BaseModel, Field
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
VOUCHER_METDATA_TEMPLATE_URL = "https://claim.twentysix.cloud/sbt/metadata/{}.json"
19+
VOUCHER_SOL_REGISTRY = "https://api.claim.twentysix.cloud/v1/registry/sol"
20+
VOUCHER_SENDER = "0xB34f25f2c935bCA437C061547eA12851d719dEFb"
21+
22+
23+
class VoucherAttribute(BaseModel):
24+
value: Union[str, Decimal]
25+
trait_type: str = Field(..., alias="trait_type")
26+
display_type: Optional[str] = Field(None, alias="display_type")
27+
28+
29+
class VoucherMetadata(BaseModel):
30+
name: str
31+
description: str
32+
external_url: str = Field(..., alias="external_url")
33+
image: str
34+
icon: str
35+
attributes: list[VoucherAttribute]
36+
37+
38+
class Voucher(BaseModel):
39+
id: str
40+
metadata_id: str = Field(..., alias="metadata_id")
41+
name: str
42+
description: str
43+
external_url: str = Field(..., alias="external_url")
44+
image: str
45+
icon: str
46+
attributes: list[VoucherAttribute]
47+
48+
49+
class VoucherManager:
50+
def __init__(self, account: Optional[Account], chain: Optional[Chain]):
51+
self.account = account or None
52+
self.chain = chain or None
53+
54+
def _resolve_address(self, address: Optional[str] = None) -> str:
55+
"""
56+
Resolve the address to use. Prefer the provided address, fallback to account.
57+
"""
58+
if address:
59+
return address
60+
if self.account:
61+
return self.account.get_address()
62+
error_msg = "No address provided and no account available to resolve address."
63+
raise ValueError(error_msg)
64+
65+
async def _fetch_voucher_update(self):
66+
"""
67+
Fetch the latest EVM voucher update (unfiltered).
68+
"""
69+
async with AlephHttpClient(api_server=settings.API_HOST) as client:
70+
post_filter = PostFilter(types=["vouchers-update"], addresses=[VOUCHER_SENDER])
71+
vouchers_post: PostsResponse = await client.get_posts(post_filter=post_filter, page_size=1)
72+
if not vouchers_post.posts:
73+
return []
74+
75+
message_post: Post = vouchers_post.posts[0]
76+
nft_vouchers = message_post.content.get("nft_vouchers", {})
77+
return list(nft_vouchers.items()) # [(voucher_id, voucher_data)]
78+
79+
async def _fetch_solana_voucher(self):
80+
"""
81+
Fetch full Solana voucher registry (unfiltered).
82+
"""
83+
try:
84+
async with aiohttp.ClientSession() as session:
85+
try:
86+
async with session.get(VOUCHER_SOL_REGISTRY) as resp:
87+
if resp.status != 200:
88+
return {}
89+
90+
try:
91+
return await resp.json()
92+
except aiohttp.client_exceptions.ContentTypeError:
93+
text_data = await resp.text()
94+
try:
95+
return json.loads(text_data)
96+
except json.JSONDecodeError:
97+
return {}
98+
except Exception:
99+
# This makes sure we catch any exception in case of AsyncMock issues
100+
return {}
101+
except Exception:
102+
# This makes sure we catch any exception in case of AsyncMock issues
103+
return {}
104+
105+
async def get_all(self, address: Optional[str] = None) -> list[Voucher]:
106+
"""
107+
Retrieve all vouchers for the account / specific adress, across EVM and Solana chains.
108+
"""
109+
vouchers = []
110+
111+
# Get EVM vouchers
112+
evm_vouchers = await self.get_evm_voucher(address=address)
113+
vouchers.extend(evm_vouchers)
114+
115+
# Get Solana vouchers
116+
solana_vouchers = await self.fetch_solana_vouchers(address=address)
117+
vouchers.extend(solana_vouchers)
118+
119+
return vouchers
120+
121+
async def fetch_vouchers_by_chain(self, chain: Chain):
122+
if chain == Chain.SOL:
123+
return await self.fetch_solana_vouchers()
124+
else:
125+
return await self.get_evm_voucher()
126+
127+
async def get_evm_voucher(self, address: Optional[str] = None) -> list[Voucher]:
128+
"""
129+
Retrieve vouchers specific to EVM chains for a specific address.
130+
"""
131+
resolved_address = self._resolve_address(address=address)
132+
vouchers: list[Voucher] = []
133+
134+
nft_vouchers = await self._fetch_voucher_update()
135+
for voucher_id, voucher_data in nft_vouchers:
136+
if voucher_data.get("claimer") != resolved_address:
137+
continue
138+
139+
metadata_id = voucher_data.get("metadata_id")
140+
metadata = await self.fetch_metadata(metadata_id)
141+
if not metadata:
142+
continue
143+
144+
voucher = Voucher(
145+
id=voucher_id,
146+
metadata_id=metadata_id,
147+
name=metadata.name,
148+
description=metadata.description,
149+
external_url=metadata.external_url,
150+
image=metadata.image,
151+
icon=metadata.icon,
152+
attributes=metadata.attributes,
153+
)
154+
vouchers.append(voucher)
155+
return vouchers
156+
157+
async def fetch_solana_vouchers(self, address: Optional[str] = None) -> list[Voucher]:
158+
"""
159+
Fetch Solana vouchers for a specific address.
160+
"""
161+
resolved_address = self._resolve_address(address=address)
162+
vouchers: list[Voucher] = []
163+
164+
registry_data = await self._fetch_solana_voucher()
165+
166+
claimed_tickets = registry_data.get("claimed_tickets", {})
167+
batches = registry_data.get("batches", {})
168+
169+
for ticket_hash, ticket_data in claimed_tickets.items():
170+
claimer = ticket_data.get("claimer")
171+
if claimer != resolved_address:
172+
continue
173+
174+
batch_id = ticket_data.get("batch_id")
175+
metadata_id = None
176+
177+
if str(batch_id) in batches:
178+
metadata_id = batches[str(batch_id)].get("metadata_id")
179+
180+
if metadata_id:
181+
metadata = await self.fetch_metadata(metadata_id)
182+
if metadata:
183+
voucher = Voucher(
184+
id=ticket_hash,
185+
metadata_id=metadata_id,
186+
name=metadata.name,
187+
description=metadata.description,
188+
external_url=metadata.external_url,
189+
image=metadata.image,
190+
icon=metadata.icon,
191+
attributes=metadata.attributes,
192+
)
193+
vouchers.append(voucher)
194+
195+
return vouchers
196+
197+
async def fetch_metadata(self, metadata_id: str) -> Optional[VoucherMetadata]:
198+
"""
199+
Fetch metadata for a given voucher.
200+
"""
201+
url = f"https://claim.twentysix.cloud/sbt/metadata/{metadata_id}.json"
202+
try:
203+
async with aiohttp.ClientSession() as session:
204+
try:
205+
async with session.get(url) as resp:
206+
if resp.status != 200:
207+
return None
208+
data = await resp.json()
209+
return VoucherMetadata.model_validate(data)
210+
except Exception as e:
211+
logger.error(f"Error fetching metadata: {e}")
212+
return None
213+
except Exception as e:
214+
logger.error(f"Error creating session: {e}")
215+
return None

0 commit comments

Comments
 (0)