Skip to content

Commit 20c62fc

Browse files
committed
Feature: Voucher Manager and unit test
1 parent 671ea0e commit 20c62fc

File tree

2 files changed

+680
-0
lines changed

2 files changed

+680
-0
lines changed

src/aleph_client/voucher.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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+
return {}
100+
except Exception:
101+
return {}
102+
103+
async def get_all(self, address: Optional[str] = None) -> list[Voucher]:
104+
"""
105+
Retrieve all vouchers for the account / specific adress, across EVM and Solana chains.
106+
"""
107+
vouchers = []
108+
109+
# Get EVM vouchers
110+
evm_vouchers = await self.get_evm_voucher(address=address)
111+
vouchers.extend(evm_vouchers)
112+
113+
# Get Solana vouchers
114+
solana_vouchers = await self.fetch_solana_vouchers(address=address)
115+
vouchers.extend(solana_vouchers)
116+
117+
return vouchers
118+
119+
async def fetch_vouchers_by_chain(self, chain: Chain):
120+
if chain == Chain.SOL:
121+
return await self.fetch_solana_vouchers()
122+
else:
123+
return await self.get_evm_voucher()
124+
125+
async def get_evm_voucher(self, address: Optional[str] = None) -> list[Voucher]:
126+
"""
127+
Retrieve vouchers specific to EVM chains for a specific address.
128+
"""
129+
resolved_address = self._resolve_address(address=address)
130+
vouchers: list[Voucher] = []
131+
132+
nft_vouchers = await self._fetch_voucher_update()
133+
for voucher_id, voucher_data in nft_vouchers:
134+
if voucher_data.get("claimer") != resolved_address:
135+
continue
136+
137+
metadata_id = voucher_data.get("metadata_id")
138+
metadata = await self.fetch_metadata(metadata_id)
139+
if not metadata:
140+
continue
141+
142+
voucher = Voucher(
143+
id=voucher_id,
144+
metadata_id=metadata_id,
145+
name=metadata.name,
146+
description=metadata.description,
147+
external_url=metadata.external_url,
148+
image=metadata.image,
149+
icon=metadata.icon,
150+
attributes=metadata.attributes,
151+
)
152+
vouchers.append(voucher)
153+
return vouchers
154+
155+
async def fetch_solana_vouchers(self, address: Optional[str] = None) -> list[Voucher]:
156+
"""
157+
Fetch Solana vouchers for a specific address.
158+
"""
159+
resolved_address = self._resolve_address(address=address)
160+
vouchers: list[Voucher] = []
161+
162+
registry_data = await self._fetch_solana_voucher()
163+
164+
claimed_tickets = registry_data.get("claimed_tickets", {})
165+
batches = registry_data.get("batches", {})
166+
167+
for ticket_hash, ticket_data in claimed_tickets.items():
168+
claimer = ticket_data.get("claimer")
169+
if claimer != resolved_address:
170+
continue
171+
172+
batch_id = ticket_data.get("batch_id")
173+
metadata_id = None
174+
175+
if str(batch_id) in batches:
176+
metadata_id = batches[str(batch_id)].get("metadata_id")
177+
178+
if metadata_id:
179+
metadata = await self.fetch_metadata(metadata_id)
180+
if metadata:
181+
voucher = Voucher(
182+
id=ticket_hash,
183+
metadata_id=metadata_id,
184+
name=metadata.name,
185+
description=metadata.description,
186+
external_url=metadata.external_url,
187+
image=metadata.image,
188+
icon=metadata.icon,
189+
attributes=metadata.attributes,
190+
)
191+
vouchers.append(voucher)
192+
193+
return vouchers
194+
195+
async def fetch_metadata(self, metadata_id: str) -> Optional[VoucherMetadata]:
196+
"""
197+
Fetch metadata for a given voucher.
198+
"""
199+
url = f"https://claim.twentysix.cloud/sbt/metadata/{metadata_id}.json"
200+
try:
201+
async with aiohttp.ClientSession() as session:
202+
try:
203+
async with session.get(url) as resp:
204+
if resp.status != 200:
205+
return None
206+
data = await resp.json()
207+
return VoucherMetadata.model_validate(data)
208+
except Exception as e:
209+
logger.error(f"Error fetching metadata: {e}")
210+
return None
211+
except Exception as e:
212+
logger.error(f"Error creating session: {e}")
213+
return None

0 commit comments

Comments
 (0)