This commit is contained in:
aaron 2026-05-21 17:48:54 +08:00
parent 2f682cfd0d
commit a22fb1e775
5 changed files with 203 additions and 3 deletions

View File

@ -55,6 +55,8 @@ ALPHAX_NODEREAL_MAX_LOGS_PER_TOKEN=25
ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED=1
ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK=1
ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN=30
ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED=1
ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE=82
ALPHAX_ONCHAIN_CANDIDATE_ENABLED=1
ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE=70
ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE=70

View File

@ -84,6 +84,8 @@ def default_onchain_config(default_chains=("ethereum", "bsc")):
"nodereal_raw_transfer_enabled": _env_bool("ALPHAX_NODEREAL_RAW_TRANSFER_ENABLED", True),
"nodereal_raw_block_lookback": _env_int("ALPHAX_NODEREAL_RAW_BLOCK_LOOKBACK", 1),
"nodereal_raw_max_logs_per_chain": _env_int("ALPHAX_NODEREAL_RAW_MAX_LOGS_PER_CHAIN", 30),
"nodereal_auto_mapping_enabled": _env_bool("ALPHAX_NODEREAL_AUTO_MAPPING_ENABLED", True),
"nodereal_auto_mapping_confidence": _env_int("ALPHAX_NODEREAL_AUTO_MAPPING_CONFIDENCE", 82),
"candidate_enabled": _env_bool("ALPHAX_ONCHAIN_CANDIDATE_ENABLED", True),
"candidate_min_score": _env_float("ALPHAX_ONCHAIN_CANDIDATE_MIN_SCORE", 70),
"candidate_min_confidence": _env_int("ALPHAX_ONCHAIN_CANDIDATE_MIN_CONFIDENCE", 70),

View File

@ -64,6 +64,10 @@ class NodeRealClient:
result = self.call(chain, "eth_getLogs", [log_filter])
return result if isinstance(result, list) else []
def eth_call(self, chain: str, to_address: str, data: str, block: str = "latest") -> str:
result = self.call(chain, "eth_call", [{"to": to_address, "data": data}, block])
return str(result or "")
def token_holder_count(self, chain: str, contract_address: str) -> int:
return _hex_to_int(self.call(chain, "nr_getTokenHolderCount", [contract_address]))
@ -87,4 +91,3 @@ def _hex_to_int(value: Any) -> int:
return int(text, 16) if text.startswith("0x") else int(text)
except Exception:
return 0

View File

@ -73,6 +73,9 @@ DEXSCREENER_RAW_ENDPOINTS = (
("token_boost_top", "https://api.dexscreener.com/token-boosts/top/v1"),
)
TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
ERC20_SYMBOL_SELECTOR = "0x95d89b41"
ERC20_NAME_SELECTOR = "0x06fdde03"
ERC20_DECIMALS_SELECTOR = "0x313ce567"
def _env_bool(name, default=False):
@ -129,6 +132,8 @@ def get_onchain_params():
"nodereal_raw_transfer_enabled": bool(cfg.get("nodereal_raw_transfer_enabled", True)),
"nodereal_raw_block_lookback": int(cfg.get("nodereal_raw_block_lookback") or 1),
"nodereal_raw_max_logs_per_chain": int(cfg.get("nodereal_raw_max_logs_per_chain") or 30),
"nodereal_auto_mapping_enabled": bool(cfg.get("nodereal_auto_mapping_enabled", True)),
"nodereal_auto_mapping_confidence": int(cfg.get("nodereal_auto_mapping_confidence") or 82),
"candidate_enabled": bool(cfg.get("candidate_enabled", True)),
"candidate_min_score": float(cfg.get("candidate_min_score") or 70),
"candidate_min_confidence": int(cfg.get("candidate_min_confidence") or 70),
@ -795,6 +800,107 @@ def _topic_to_address(topic):
return ""
def _decode_abi_string(value):
text = str(value or "").strip()
if not text or text == "0x":
return ""
payload = text[2:] if text.startswith("0x") else text
try:
raw = bytes.fromhex(payload)
except Exception:
return ""
if not raw:
return ""
try:
if len(raw) >= 96:
offset = int.from_bytes(raw[:32], "big")
if 0 <= offset + 32 <= len(raw):
length = int.from_bytes(raw[offset:offset + 32], "big")
body = raw[offset + 32:offset + 32 + length]
return body.decode("utf-8", errors="ignore").strip("\x00 ").strip()
return raw.rstrip(b"\x00").decode("utf-8", errors="ignore").strip()
except Exception:
return ""
def _clean_erc20_symbol(value):
symbol = str(value or "").upper().strip().replace("$", "")
symbol = "".join(ch for ch in symbol if ch.isalnum())
if symbol.endswith("USDT") and len(symbol) > 4:
symbol = symbol[:-4]
return symbol[:20]
def _is_auto_mapping_symbol_allowed(base, token_name=""):
base = _clean_erc20_symbol(base)
if not base:
return False
symbol = f"{base}/USDT"
if not _tradable_symbol(symbol):
return False
if base in NON_TARGET_NATIVE_BASES:
return False
text = f"{base} {token_name or ''}".lower()
if any(marker in text for marker in BRIDGED_TOKEN_MARKERS):
return False
return True
def _read_erc20_metadata(client, chain, contract):
metadata = {"symbol": "", "name": "", "decimals": 18}
try:
metadata["symbol"] = _decode_abi_string(client.eth_call(chain, contract, ERC20_SYMBOL_SELECTOR))
except Exception:
metadata["symbol"] = ""
try:
metadata["name"] = _decode_abi_string(client.eth_call(chain, contract, ERC20_NAME_SELECTOR))
except Exception:
metadata["name"] = ""
try:
decimals = _hex_to_int(client.eth_call(chain, contract, ERC20_DECIMALS_SELECTOR))
if 0 <= decimals <= 36:
metadata["decimals"] = decimals
except Exception:
pass
metadata["symbol"] = _clean_erc20_symbol(metadata.get("symbol"))
return metadata
def _auto_map_nodereal_contract(client, chain, contract, cfg=None):
cfg = cfg or get_onchain_params()
if not cfg.get("nodereal_auto_mapping_enabled", True):
return None
existing = find_mapping_by_contract(chain, contract)
if existing:
return existing
metadata = _read_erc20_metadata(client, chain, contract)
base = metadata.get("symbol") or ""
if not _is_auto_mapping_symbol_allowed(base, metadata.get("name")):
return None
symbol = normalize_symbol(base)
confidence = max(1, min(95, int(cfg.get("nodereal_auto_mapping_confidence") or 82)))
mapping_id = onchain_db.upsert_token_mapping(
symbol=symbol,
chain=chain,
contract_address=contract,
source="nodereal_erc20_metadata",
confidence=confidence,
raw=metadata,
is_active=True,
)
if not mapping_id:
return None
return {
"id": mapping_id,
"symbol": symbol,
"chain": str(chain or "").lower(),
"contract_address": contract,
"source": "nodereal_erc20_metadata",
"confidence": confidence,
"raw_json": json.dumps(metadata, ensure_ascii=False),
}
def _nodereal_client(cfg=None):
cfg = cfg or get_onchain_params()
return NodeRealClient(
@ -880,6 +986,30 @@ def _raw_event_from_nodereal_transfer(log, chain):
}
def _apply_raw_event_mapping(raw_event, client=None, cfg=None):
item = dict(raw_event or {})
chain = str(item.get("chain") or "").lower()
contract = str(item.get("token_address") or "").strip()
if not chain or not contract:
return item
mapping = find_mapping_by_contract(chain, contract)
if not mapping and client:
mapping = _auto_map_nodereal_contract(client, chain, contract, cfg=cfg)
if mapping:
item["mapped_symbol"] = normalize_symbol(mapping.get("symbol"))
item["mapping_status"] = "mapped"
item["symbol_guess"] = item.get("symbol_guess") or item["mapped_symbol"].split("/")[0]
item["raw"] = {
**(item.get("raw") or {}),
"mapping": {
"symbol": item["mapped_symbol"],
"source": mapping.get("source") or "",
"confidence": mapping.get("confidence") or 0,
},
}
return item
def _metric_from_nodereal_holder_count(holder_count, mapping):
symbol = normalize_symbol(mapping.get("symbol"))
chain = str(mapping.get("chain") or "").lower()
@ -1039,7 +1169,7 @@ def fetch_nodereal_raw_events(client=None, cfg=None, limit=60):
continue
item = _raw_event_from_nodereal_transfer(log, chain)
if item:
raw_items.append(item)
raw_items.append(_apply_raw_event_mapping(item, client=client, cfg=cfg))
raw_items.sort(key=lambda item: item.get("amount") or 0, reverse=True)
for item in raw_items[:per_chain]:
if insert_onchain_raw_event(item):

View File

@ -388,8 +388,12 @@ def test_nodereal_records_raw_events_without_strategy_mappings(monkeypatch, tmp_
def block_number(self, chain):
return 1000
def token_holder_count(self, chain, contract):
return 0
def get_logs(self, chain, log_filter):
assert "address" not in log_filter
if "address" in log_filter:
return []
return [
{
"address": "0xabc",
@ -418,6 +422,65 @@ def test_nodereal_records_raw_events_without_strategy_mappings(monkeypatch, tmp_
assert raw["items"][0]["event_type"] == "evm_transfer"
def test_nodereal_auto_maps_raw_event_from_erc20_metadata(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")
monkeypatch.setenv("ALPHAX_NODEREAL_CHAINS", "ethereum")
def abi_string(value):
body = value.encode()
padded = body + (b"\x00" * ((32 - len(body) % 32) % 32))
return "0x" + (32).to_bytes(32, "big").hex() + len(body).to_bytes(32, "big").hex() + padded.hex()
class MetadataNodeRealClient:
def supports_chain(self, chain):
return chain == "ethereum"
def block_number(self, chain):
return 1000
def token_holder_count(self, chain, contract):
return 0
def get_logs(self, chain, log_filter):
if "address" in log_filter:
return []
return [
{
"address": "0xstorj",
"transactionHash": "0xrawtx",
"data": hex(1000 * 10**8),
"topics": [
onchain_monitor.TRANSFER_TOPIC,
"0x0000000000000000000000001111111111111111111111111111111111111111",
"0x0000000000000000000000002222222222222222222222222222222222222222",
],
}
]
def eth_call(self, chain, to_address, data, block="latest"):
if data == onchain_monitor.ERC20_SYMBOL_SELECTOR:
return abi_string("STORJ")
if data == onchain_monitor.ERC20_NAME_SELECTOR:
return abi_string("Storj")
if data == onchain_monitor.ERC20_DECIMALS_SELECTOR:
return hex(8)
return "0x"
monkeypatch.setattr(onchain_monitor, "_nodereal_client", lambda cfg=None: MetadataNodeRealClient())
result = onchain_monitor.fetch_nodereal_events(limit=10)
assert result["errors"] == []
assert len(result["raw_events"]) == 1
raw = onchain_db.list_onchain_raw_events(hours=50000)
assert raw["items"][0]["mapping_status"] == "mapped"
assert raw["items"][0]["mapped_symbol"] == "STORJ/USDT"
mappings = onchain_db.get_token_mappings("STORJ/USDT")
assert len(mappings) == 1
assert mappings[0]["source"] == "nodereal_erc20_metadata"
def test_nodereal_seeds_configured_token_mappings_from_env(monkeypatch, tmp_path):
_temp_db(monkeypatch, tmp_path)
monkeypatch.setenv("ALPHAX_NODEREAL_API_KEY", "test-key")