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)