Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion src/aleph_client/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from aleph.sdk.utils import bytes_from_hex, displayable_amount
from aleph_message.models import Chain
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt
Expand All @@ -41,6 +42,7 @@
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)
Expand Down Expand Up @@ -293,12 +295,14 @@ async def balance(
] = settings.PRIVATE_KEY_FILE,
chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None,
):
"""Display your ALEPH balance."""
"""Display your ALEPH balance and basic voucher information."""
account = _load_account(private_key, private_key_file, chain=chain)

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)
Expand Down Expand Up @@ -329,6 +333,16 @@ async def balance(
f"[/{available_color}]"
),
]

# Get vouchers and add them to Account Info panel
vouchers = await voucher_manager.get_all(address=address)
if vouchers:
voucher_names = [voucher.name for voucher in vouchers]
infos += [
Text("\n\nVouchers:"),
Text.from_markup(f"\n [bright_cyan]{', '.join(voucher_names)}[/bright_cyan]"),
]

console.print(
Panel(
Text.assemble(*infos),
Expand All @@ -338,6 +352,7 @@ async def balance(
title_align="left",
)
)

except Exception as e:
typer.echo(e)
else:
Expand Down Expand Up @@ -392,6 +407,64 @@ async def list_accounts():
console.print(table)


@app.command(name="vouchers")
async def vouchers(
address: Annotated[Optional[str], typer.Option(help="Address")] = None,
private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING,
private_key_file: Annotated[
Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)
] = settings.PRIVATE_KEY_FILE,
chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None,
):
"""Display detailed information about your vouchers."""
account = _load_account(private_key, private_key_file, chain=chain)

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)
if vouchers:
voucher_table = Table(title="", show_header=True, box=box.ROUNDED)
voucher_table.add_column("Name", style="bright_cyan")
voucher_table.add_column("Description", style="green")
voucher_table.add_column("Attributes", style="magenta")

for voucher in vouchers:
attr_text = ""
for attr in voucher.attributes:
attr_text += f"{attr.trait_type}: {attr.value}\n"

voucher_table.add_row(voucher.name, voucher.description, attr_text.strip())

console.print(
Panel(
voucher_table,
title="Vouchers",
border_style="bright_cyan",
expand=False,
title_align="left",
)
)
else:
console.print(
Panel(
"No vouchers found for this address",
title="Vouchers",
border_style="bright_cyan",
expand=False,
title_align="left",
)
)
except Exception as e:
typer.echo(e)
else:
typer.echo("Error: Please provide either a private key, private key file, or an address.")


@app.command(name="config")
async def configure(
private_key_file: Annotated[Optional[Path], typer.Option(help="New path to the private key file")] = None,
Expand Down
10 changes: 10 additions & 0 deletions src/aleph_client/commands/instance/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
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)
Expand Down Expand Up @@ -190,20 +191,29 @@ 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(
f"[red]{safe_getattr(payment_chain, 'value') or payment_chain}[/red]"
" incompatible with NFT vouchers."
)

payment_chain = Chain(
Prompt.ask(
"On which chain did you claim your NFT voucher?",
choices=[nft_chain.value for nft_chain in nft_chains],
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)

elif payment_type in [ptype.value for ptype in PaymentType]:
payment_type = PaymentType(payment_type)
else:
Expand Down
213 changes: 213 additions & 0 deletions src/aleph_client/voucher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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
Loading
Loading