Source code for pyrxd.glyph.scanner

"""GlyphScanner: resolve a Radiant address to its Glyph inventory.

Wires together GlyphInspector (pure parser), ElectrumXClient (network),
and the GlyphNft / GlyphFt types into a single async API.
"""

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING

from ..network.electrumx import script_hash_for_address
from ..security.errors import NetworkError
from ..security.types import Hex32
from .inspector import GlyphInspector
from .script import (
    extract_owner_pkh_from_ft_script,
    extract_owner_pkh_from_nft_script,
)
from .types import GlyphFt, GlyphNft

if TYPE_CHECKING:
    from ..network.electrumx import ElectrumXClient

logger = logging.getLogger(__name__)

GlyphItem = GlyphNft | GlyphFt


[docs] class GlyphScanner: """Scan a Radiant address or script_hash for Glyph outputs. Parameters ---------- client: An *already-connected* ElectrumXClient. The scanner does not own the connection lifecycle; callers should use the client as a context manager and pass it in. """
[docs] def __init__(self, client: ElectrumXClient) -> None: self._client = client self._inspector = GlyphInspector()
[docs] async def scan_address(self, address: str) -> list[GlyphItem]: """Return all Glyph outputs currently owned at *address*. Parameters ---------- address: Base58Check-encoded P2PKH address. Returns ------- List[GlyphNft | GlyphFt] Typed Glyph objects. ``metadata`` is ``None`` for transfer outputs (no reveal scriptSig in the origin transaction). """ sh = script_hash_for_address(address) return await self.scan_script_hash(sh)
[docs] async def scan_script_hash(self, script_hash: Hex32 | bytes | str) -> list[GlyphItem]: """Return all Glyph outputs for *script_hash*. Fetches UTXOs, raw transactions, and (where available) reveal transaction metadata, then constructs typed GlyphNft / GlyphFt objects. Concurrency: UTXO raw-tx fetches and reveal-metadata fetches both run in parallel via ``asyncio.gather``. Pre-fix (closes ultrareview re-review N17) the reveal-metadata path was inside the per-utxo loop and serialised one round-trip per glyph; for a 100-glyph wallet that meant ~100x the latency of the now- batched version. """ from ..transaction.transaction import Transaction utxos = await self._client.get_utxos(script_hash) if not utxos: return [] # Fetch all UTXO raw txs concurrently. raw_txs = await asyncio.gather( *[self._client.get_transaction(utxo.tx_hash) for utxo in utxos], return_exceptions=True, ) # First pass: parse each UTXO's source tx, run the glyph inspector, # collect every (utxo, glyph) pair we'd want metadata for. # Two-pass split lets us issue all reveal-metadata fetches as a # single gather() instead of one-await-per-glyph. pending: list[tuple] = [] # (utxo, glyph) for utxo, raw in zip(utxos, raw_txs): if isinstance(raw, Exception): logger.warning("Failed to fetch tx %s: %s", utxo.tx_hash, raw) continue tx = Transaction.from_hex(bytes(raw)) if tx is None: logger.warning("Failed to parse tx %s", utxo.tx_hash) continue output_pairs = [(out.satoshis, out.locking_script.serialize()) for out in tx.outputs] glyphs = self._inspector.find_glyphs(output_pairs) for g in glyphs: if g.vout != utxo.tx_pos: continue pending.append((utxo, g)) if not pending: return [] # Reveal-metadata fetches batched concurrently (N17 fix). Each # entry's index maps 1:1 back to ``pending[i]`` so we can pair # them up below without sorting. metadatas = await asyncio.gather( *[self._fetch_reveal_metadata(g.ref.txid) for (_, g) in pending], return_exceptions=True, ) results: list[GlyphItem] = [] for (utxo, g), meta in zip(pending, metadatas): # _fetch_reveal_metadata catches its own exceptions and # returns None — but gather(return_exceptions=True) means a # truly unexpected error (TypeError, MemoryError) still # surfaces here as an Exception object instead of crashing # the whole scan. metadata = None if isinstance(meta, BaseException) else meta script = g.script try: if g.glyph_type == "nft": pkh = extract_owner_pkh_from_nft_script(script) results.append(GlyphNft(ref=g.ref, owner_pkh=pkh, metadata=metadata)) elif g.glyph_type == "ft": pkh = extract_owner_pkh_from_ft_script(script) results.append( GlyphFt( ref=g.ref, owner_pkh=pkh, amount=utxo.value, metadata=metadata, ) ) except Exception as exc: logger.warning( "Could not construct Glyph for %s vout %d: %s", utxo.tx_hash, utxo.tx_pos, exc, ) return results
async def _fetch_reveal_metadata(self, origin_txid: str): # type: ignore[return] """Fetch the origin tx and extract metadata from input[0] scriptSig. Returns None if this is a transfer (no GLY marker) or on any error. """ from ..transaction.transaction import Transaction try: raw = await self._client.get_transaction(origin_txid) except (NetworkError, Exception): return None tx = Transaction.from_hex(bytes(raw)) if tx is None or not tx.inputs: return None inp = tx.inputs[0] scriptsig = inp.unlocking_script.serialize() if inp.unlocking_script else b"" return self._inspector.extract_reveal_metadata(scriptsig)