448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""
|
|
SPL Token Metadata Decoder
|
|
===========================
|
|
Parses raw Solana account data to definitively extract hidden metadata,
|
|
mint authorities, and freeze flags, bypassing unreliable third-party APIs.
|
|
|
|
This is a LOCAL/FREE_API provider that reads directly from public Solana RPCs
|
|
and decodes the binary SPL Token mint layout without relying on external indexers.
|
|
|
|
SPL Token Mint Layout (76 bytes base):
|
|
Byte 0: mint_authority_option (1 = Some, 0 = None)
|
|
Bytes 1-32: mint_authority pubkey (if Some, else 32 zeros)
|
|
Bytes 33-40: supply (u64, little-endian)
|
|
Byte 41: decimals (u8)
|
|
Byte 42: is_initialized (bool, 1 byte)
|
|
Byte 43: freeze_authority_option (1 = Some, 0 = None)
|
|
Bytes 44-75: freeze_authority pubkey (if Some, else 32 zeros)
|
|
|
|
Token-2022 Extensions:
|
|
If account data > 76 bytes, parse extension headers to detect:
|
|
- MintCloseAuthority
|
|
- PermanentDelegate
|
|
- TransferFee
|
|
- ConfidentialTransfer
|
|
- DefaultAccountState (frozen by default)
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger("databus.spl_metadata")
|
|
|
|
# Free public Solana RPCs (fallback chain)
|
|
PUBLIC_RPC_ENDPOINTS = [
|
|
"https://api.mainnet-beta.solana.com",
|
|
"https://solana-rpc.publicnode.com",
|
|
"https://solana.drpc.org",
|
|
]
|
|
|
|
# Metaplex Token Metadata Program ID
|
|
METAPLEX_METADATA_PROGRAM = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s"
|
|
TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
|
|
TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
|
|
|
# Known malicious/frozen token flags
|
|
HIGH_RISK_FLAGS = [
|
|
"mint_authority_present",
|
|
"freeze_authority_present",
|
|
"default_account_state_frozen",
|
|
"transfer_fee_present",
|
|
"permanent_delegate_present",
|
|
]
|
|
|
|
|
|
def decode_pubkey(data: bytes, offset: int) -> str | None:
|
|
"""Decode a 32-byte Solana pubkey from bytes."""
|
|
if len(data) < offset + 32:
|
|
return None
|
|
pubkey_bytes = data[offset : offset + 32]
|
|
# Check if it's all zeros (None)
|
|
if all(b == 0 for b in pubkey_bytes):
|
|
return None
|
|
# Encode to base58
|
|
return _bytes_to_base58(pubkey_bytes)
|
|
|
|
|
|
def _bytes_to_base58(data: bytes) -> str:
|
|
"""Convert bytes to base58 string (Solana pubkey format)."""
|
|
alphabet = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
num = int.from_bytes(data, "big")
|
|
result = bytearray()
|
|
while num > 0:
|
|
num, mod = divmod(num, 58)
|
|
result.append(alphabet[mod])
|
|
# Add leading '1's for each leading zero byte
|
|
leading_zeros = len(data) - len(data.lstrip(b"\x00"))
|
|
result.extend([alphabet[0]] * leading_zeros)
|
|
return result[::-1].decode("ascii")
|
|
|
|
|
|
def parse_spl_mint_data(raw_data: bytes) -> dict[str, Any]:
|
|
"""
|
|
Parse raw SPL Token mint account data.
|
|
Returns decoded metadata including authorities, supply, decimals, and flags.
|
|
|
|
SPL Token Mint Layout (82 bytes base):
|
|
Bytes 0-3: mint_authority_option (u32, 1 = Some, 0 = None)
|
|
Bytes 4-35: mint_authority pubkey (32 bytes)
|
|
Bytes 36-43: supply (u64, little-endian)
|
|
Byte 44: decimals (u8)
|
|
Byte 45: is_initialized (bool)
|
|
Bytes 46-49: freeze_authority_option (u32)
|
|
Bytes 50-81: freeze_authority pubkey (32 bytes)
|
|
"""
|
|
result = {
|
|
"is_valid": False,
|
|
"decimals": 0,
|
|
"supply": 0,
|
|
"is_initialized": False,
|
|
"mint_authority": None,
|
|
"mint_authority_revoked": True,
|
|
"freeze_authority": None,
|
|
"freeze_authority_revoked": True,
|
|
"risk_flags": [],
|
|
"extensions": [],
|
|
"raw_size": len(raw_data),
|
|
}
|
|
|
|
if len(raw_data) < 82:
|
|
result["error"] = f"Data too short for SPL mint: {len(raw_data)} bytes (expected >= 82)"
|
|
return result
|
|
|
|
try:
|
|
# Bytes 0-3: mint_authority_option (u32)
|
|
mint_auth_option = int.from_bytes(raw_data[0:4], "little")
|
|
if mint_auth_option == 1:
|
|
result["mint_authority"] = decode_pubkey(raw_data, 4)
|
|
result["mint_authority_revoked"] = False
|
|
result["risk_flags"].append("mint_authority_present")
|
|
|
|
# Bytes 36-43: supply (u64 little-endian)
|
|
supply = int.from_bytes(raw_data[36:44], "little")
|
|
result["supply"] = supply
|
|
|
|
# Byte 44: decimals
|
|
result["decimals"] = raw_data[44]
|
|
|
|
# Byte 45: is_initialized
|
|
result["is_initialized"] = raw_data[45] == 1
|
|
|
|
# Bytes 46-49: freeze_authority_option (u32)
|
|
freeze_auth_option = int.from_bytes(raw_data[46:50], "little")
|
|
if freeze_auth_option == 1:
|
|
result["freeze_authority"] = decode_pubkey(raw_data, 50)
|
|
result["freeze_authority_revoked"] = False
|
|
result["risk_flags"].append("freeze_authority_present")
|
|
|
|
result["is_valid"] = result["is_initialized"]
|
|
|
|
# Parse Token-2022 extensions if data is larger than base 82 bytes
|
|
if len(raw_data) > 82:
|
|
result["extensions"] = _parse_token_extensions(raw_data[82:], result)
|
|
|
|
except Exception as e:
|
|
result["error"] = f"Failed to parse mint data: {e!s}"
|
|
|
|
return result
|
|
|
|
|
|
def _parse_token_extensions(extension_data: bytes, base_result: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""
|
|
Parse Token-2022 extension data.
|
|
Extension layout: [extension_type (u16), length (u16), data (variable)]
|
|
"""
|
|
extensions = []
|
|
offset = 0
|
|
|
|
# Extension type constants (from spl-token-2022)
|
|
EXTENSION_TYPES = {
|
|
1: "Uninitialized",
|
|
2: "TransferFeeConfig",
|
|
3: "TransferFeeAmount",
|
|
4: "MintCloseAuthority",
|
|
5: "ConfidentialTransferMint",
|
|
6: "ConfidentialTransferAccount",
|
|
7: "DefaultAccountState",
|
|
8: "ImmutableOwner",
|
|
9: "MemoTransfer",
|
|
10: "NonTransferable",
|
|
11: "InterestBearingConfig",
|
|
12: "CpiGuard",
|
|
13: "PermanentDelegate",
|
|
14: "NonTransferableAccount",
|
|
15: "TransferHook",
|
|
16: "TransferHookAccount",
|
|
17: "ConfidentialTransferFeeConfig",
|
|
18: "ConfidentialTransferFeeAmount",
|
|
19: "MetadataPointer",
|
|
20: "TokenMetadata",
|
|
21: "GroupPointer",
|
|
22: "GroupMemberPointer",
|
|
}
|
|
|
|
while offset + 4 <= len(extension_data):
|
|
try:
|
|
ext_type = int.from_bytes(extension_data[offset : offset + 2], "little")
|
|
ext_length = int.from_bytes(extension_data[offset + 2 : offset + 4], "little")
|
|
|
|
ext_name = EXTENSION_TYPES.get(ext_type, f"Unknown({ext_type})")
|
|
ext_info = {"type": ext_type, "name": ext_name, "length": ext_length}
|
|
|
|
# Check for high-risk extensions
|
|
if ext_type == 4: # MintCloseAuthority
|
|
ext_info["risk"] = "high"
|
|
ext_info["description"] = "Mint can be closed by authority"
|
|
elif ext_type == 7: # DefaultAccountState
|
|
# Check if state is Frozen (2)
|
|
if offset + 4 + ext_length <= len(extension_data):
|
|
state = extension_data[offset + 4]
|
|
if state == 2:
|
|
ext_info["risk"] = "critical"
|
|
ext_info["description"] = "Accounts are frozen by default"
|
|
base_result["risk_flags"].append("default_account_state_frozen")
|
|
elif ext_type == 13: # PermanentDelegate
|
|
ext_info["risk"] = "critical"
|
|
ext_info["description"] = "Permanent delegate can transfer any tokens"
|
|
base_result["risk_flags"].append("permanent_delegate_present")
|
|
elif ext_type == 2: # TransferFeeConfig
|
|
ext_info["risk"] = "medium"
|
|
ext_info["description"] = "Transfer fees can be modified by authority"
|
|
base_result["risk_flags"].append("transfer_fee_present")
|
|
|
|
extensions.append(ext_info)
|
|
offset += 4 + ext_length
|
|
|
|
except Exception:
|
|
break # Stop parsing if extension data is malformed
|
|
|
|
return extensions
|
|
|
|
|
|
async def fetch_metaplex_metadata(mint: str, rpc_url: str) -> dict[str, Any] | None:
|
|
"""
|
|
Fetch Metaplex Token Metadata for a given mint.
|
|
Uses getProgramAccounts to find the metadata PDA.
|
|
"""
|
|
# Metaplex metadata PDA derivation:
|
|
# seeds = ["metadata", METAPLEX_METADATA_PROGRAM, mint]
|
|
# For simplicity, we'll use a known RPC method or derive it
|
|
|
|
# Actually, the easiest way is to use the getAccountInfo on the known metadata address
|
|
# But deriving it requires sha256. Let's use a simpler approach:
|
|
# query getProgramAccounts with dataSize filter for the metadata program.
|
|
|
|
# Better: use the standard metadata PDA derivation
|
|
# seeds: b"metadata", metaplex_program_bytes, mint_bytes
|
|
|
|
metaplex_bytes = bytes.fromhex("".join(f"{ord(c):02x}" for c in METAPLEX_METADATA_PROGRAM))
|
|
mint_bytes = bytes.fromhex("".join(f"{ord(c):02x}" for c in mint))
|
|
|
|
# Actually, let's use the standard Solana PDA derivation
|
|
# We'll compute it using hashlib.sha256
|
|
seed1 = b"metadata"
|
|
|
|
# Create the seed bytes
|
|
seeds = seed1 + metaplex_bytes + mint_bytes
|
|
|
|
# PDA derivation: sha256(seeds + bump_seed)
|
|
# We'll try bump seeds 255 down to 0
|
|
for bump in range(255, -1, -1):
|
|
test_seeds = seeds + bytes([bump])
|
|
hashlib.sha256(test_seeds).digest()
|
|
# Check if it's off the ed25519 curve (valid PDA)
|
|
# For simplicity, we'll just use the first one that doesn't have the high bit set
|
|
# Actually, proper PDA derivation is complex. Let's use a known endpoint or skip if too complex.
|
|
|
|
# Simpler approach: use getProgramAccounts with filters
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "getProgramAccounts",
|
|
"params": [
|
|
METAPLEX_METADATA_PROGRAM,
|
|
{
|
|
"encoding": "base64",
|
|
"filters": [
|
|
{"dataSize": 679}, # Standard metadata size
|
|
{"memcmp": {"offset": 1, "bytes": mint}}, # Mint address at offset 1
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
response = await client.post(rpc_url, json=payload)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
accounts = data.get("result", [])
|
|
if accounts:
|
|
account = accounts[0]
|
|
pubkey = account.get("pubkey")
|
|
account_data = account.get("account", {}).get("data", [""])[0]
|
|
raw_bytes = base64.b64decode(account_data)
|
|
|
|
# Parse basic metadata (name, symbol, uri)
|
|
# Offset 1: mint (32 bytes)
|
|
# Offset 33: update_authority (32 bytes)
|
|
# Offset 65: name length (4 bytes) + name
|
|
# This is complex, so we'll return raw for now or parse simply
|
|
|
|
return {
|
|
"address": pubkey,
|
|
"raw_size": len(raw_bytes),
|
|
"note": "Full metadata parsing requires borsh deserialization",
|
|
}
|
|
except Exception as e:
|
|
logger.debug(f"Metaplex metadata fetch failed: {e}")
|
|
|
|
return None
|
|
|
|
|
|
async def decode_spl_token_metadata(mint: str, rpc_urls: list[str] | None = None) -> dict[str, Any]:
|
|
"""
|
|
Main decoder function. Fetches and parses SPL token metadata from public RPCs.
|
|
|
|
Args:
|
|
mint: Solana token mint address (base58)
|
|
rpc_urls: List of RPC URLs to try (defaults to PUBLIC_RPC_ENDPOINTS)
|
|
|
|
Returns:
|
|
Dict containing decoded metadata, authorities, and risk flags.
|
|
"""
|
|
if rpc_urls is None:
|
|
rpc_urls = PUBLIC_RPC_ENDPOINTS
|
|
|
|
result = {
|
|
"mint": mint,
|
|
"success": False,
|
|
"rpc_used": None,
|
|
"account_exists": False,
|
|
"decoded": {},
|
|
"metadata": None,
|
|
"risk_score": 0,
|
|
"risk_flags": [],
|
|
"error": None,
|
|
}
|
|
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "getAccountInfo",
|
|
"params": [mint, {"encoding": "base64"}],
|
|
}
|
|
|
|
for rpc_url in rpc_urls:
|
|
try:
|
|
async with httpx.AsyncClient(timeout=8) as client:
|
|
response = await client.post(rpc_url, json=payload)
|
|
|
|
if response.status_code != 200:
|
|
continue
|
|
|
|
data = response.json()
|
|
error = data.get("error")
|
|
if error:
|
|
if "not found" in str(error.get("message", "")).lower():
|
|
result["error"] = "Mint account not found"
|
|
return result
|
|
continue
|
|
|
|
value = data.get("result", {}).get("value")
|
|
if value is None:
|
|
result["error"] = "Mint account not found"
|
|
return result
|
|
|
|
result["account_exists"] = True
|
|
result["rpc_used"] = rpc_url
|
|
|
|
# Get owner program
|
|
owner = value.get("owner", "")
|
|
result["owner_program"] = owner
|
|
|
|
# Get raw data
|
|
account_data = value.get("data", [""])[0]
|
|
raw_bytes = base64.b64decode(account_data)
|
|
|
|
# Parse the mint data
|
|
decoded = parse_spl_mint_data(raw_bytes)
|
|
result["decoded"] = decoded
|
|
result["risk_flags"] = decoded.get("risk_flags", [])
|
|
|
|
# Calculate risk score
|
|
risk_score = 0
|
|
for flag in result["risk_flags"]:
|
|
if flag == "mint_authority_present":
|
|
risk_score += 30
|
|
elif flag == "freeze_authority_present":
|
|
risk_score += 40
|
|
elif flag == "default_account_state_frozen":
|
|
risk_score += 50
|
|
elif flag == "permanent_delegate_present":
|
|
risk_score += 60
|
|
elif flag == "transfer_fee_present":
|
|
risk_score += 20
|
|
|
|
# Check extensions for additional risk
|
|
for ext in decoded.get("extensions", []):
|
|
if ext.get("risk") == "critical":
|
|
risk_score += 40
|
|
elif ext.get("risk") == "high":
|
|
risk_score += 25
|
|
elif ext.get("risk") == "medium":
|
|
risk_score += 15
|
|
|
|
result["risk_score"] = min(100, risk_score)
|
|
result["success"] = True
|
|
|
|
# Try to fetch Metaplex metadata (non-blocking, best effort)
|
|
try:
|
|
meta = await fetch_metaplex_metadata(mint, rpc_url)
|
|
if meta:
|
|
result["metadata"] = meta
|
|
except Exception:
|
|
pass # Metadata is optional
|
|
|
|
return result
|
|
|
|
except httpx.TimeoutException:
|
|
continue
|
|
except Exception as e:
|
|
logger.debug(f"RPC {rpc_url} failed: {e}")
|
|
continue
|
|
|
|
result["error"] = "All RPC endpoints failed or timed out"
|
|
return result
|
|
|
|
|
|
# ── DataBus Provider Integration ───────────────────────────────────
|
|
|
|
|
|
async def _spl_metadata_decoder_provider(mint: str = "", **kwargs) -> dict[str, Any] | None:
|
|
"""
|
|
DataBus provider function for SPL Token Metadata Decoder.
|
|
"""
|
|
if not mint:
|
|
return {"error": "mint address is required"}
|
|
|
|
# Validate base58 format (basic check)
|
|
if len(mint) < 32 or len(mint) > 44:
|
|
return {"error": "Invalid mint address format"}
|
|
|
|
result = await decode_spl_token_metadata(mint)
|
|
|
|
if result["success"]:
|
|
return {
|
|
"mint": mint,
|
|
"source": "spl_metadata_decoder",
|
|
"tier": "free_api",
|
|
"is_local": False,
|
|
"data": result,
|
|
}
|
|
|
|
return {"error": result.get("error", "Failed to decode SPL token metadata")}
|