|
#!/usr/bin/env python3 |
|
"""Explainable sybil-likelihood scorer for Stacks/AIBTC agent addresses. |
|
|
|
The script intentionally favors transparent heuristics over black-box scoring. |
|
It uses public Hiro endpoints and optional AIBTC public endpoints only. |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import json |
|
import sys |
|
import time |
|
import urllib.error |
|
import urllib.parse |
|
import urllib.request |
|
from dataclasses import dataclass, field |
|
from datetime import datetime, timezone |
|
from typing import Any, Iterable |
|
|
|
HIRO = "https://api.hiro.so" |
|
AIBTC = "https://aibtc.com" |
|
IDENTITY_CONTRACT = "SP1NMR7MY0TJ1QA7WQBZ6504KC79PZNTRQH4YGFJD.identity-registry-v2" |
|
|
|
|
|
def get_json(url: str, timeout: int = 15) -> tuple[Any | None, str | None]: |
|
req = urllib.request.Request(url, headers={"User-Agent": "stacks-sybil-score/0.1"}) |
|
try: |
|
with urllib.request.urlopen(req, timeout=timeout) as res: |
|
return json.loads(res.read().decode("utf-8")), None |
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as exc: |
|
return None, str(exc) |
|
|
|
|
|
def parse_ts(value: str | None) -> float | None: |
|
if not value: |
|
return None |
|
try: |
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() |
|
except ValueError: |
|
return None |
|
|
|
|
|
@dataclass |
|
class Evidence: |
|
address: str |
|
tx_count: int | None = None |
|
first_seen_ts: float | None = None |
|
first_funder: str | None = None |
|
inbound_count: int = 0 |
|
outbound_count: int = 0 |
|
identity_observed: bool | None = None |
|
inbox_edges: int | None = None |
|
api_errors: list[str] = field(default_factory=list) |
|
|
|
|
|
def fetch_address_evidence(address: str) -> Evidence: |
|
ev = Evidence(address=address) |
|
q = urllib.parse.urlencode({"limit": 50, "offset": 0}) |
|
data, err = get_json(f"{HIRO}/extended/v1/address/{address}/transactions?{q}") |
|
if err: |
|
ev.api_errors.append(f"hiro address txs: {err}") |
|
else: |
|
results = data.get("results", []) if isinstance(data, dict) else [] |
|
ev.tx_count = data.get("total") if isinstance(data, dict) else len(results) |
|
oldest = None |
|
for tx in results: |
|
ts = parse_ts(tx.get("burn_block_time_iso") or tx.get("block_time_iso")) |
|
if ts and (oldest is None or ts < oldest): |
|
oldest = ts |
|
sender = tx.get("sender_address") or tx.get("tx_sender") |
|
if sender == address: |
|
ev.outbound_count += 1 |
|
else: |
|
ev.inbound_count += 1 |
|
if not ev.first_funder and sender: |
|
ev.first_funder = sender |
|
ev.first_seen_ts = oldest |
|
|
|
nft_url = f"{HIRO}/extended/v1/tokens/nft/holdings?principal={urllib.parse.quote(address)}&asset_identifiers={urllib.parse.quote(IDENTITY_CONTRACT + '::agent-identity')}" |
|
data, err = get_json(nft_url) |
|
if err: |
|
ev.api_errors.append(f"hiro identity nft: {err}") |
|
ev.identity_observed = None |
|
else: |
|
total = data.get("total", 0) if isinstance(data, dict) else 0 |
|
ev.identity_observed = total > 0 |
|
|
|
inbox_url = f"{AIBTC}/api/inbox?address={urllib.parse.quote(address)}" |
|
data, err = get_json(inbox_url) |
|
if err: |
|
ev.api_errors.append(f"aibtc inbox: {err}") |
|
else: |
|
if isinstance(data, dict): |
|
items = data.get("messages") or data.get("items") or data.get("inbox") or [] |
|
ev.inbox_edges = len(items) if isinstance(items, list) else None |
|
return ev |
|
|
|
|
|
def score_one(ev: Evidence, batch_funders: dict[str, int], seed_funders: set[str]) -> dict[str, Any]: |
|
score = 0 |
|
signals: list[tuple[int, str]] = [] |
|
now = time.time() |
|
|
|
if ev.first_seen_ts: |
|
age_days = (now - ev.first_seen_ts) / 86400 |
|
if age_days < 7: |
|
signals.append((25, "first seen less than 7 days ago")) |
|
elif age_days < 30: |
|
signals.append((15, "first seen less than 30 days ago")) |
|
elif age_days > 180: |
|
signals.append((-10, "address age over 180 days")) |
|
else: |
|
signals.append((8, "first-seen timestamp unavailable")) |
|
|
|
if ev.tx_count is not None: |
|
if ev.tx_count <= 2: |
|
signals.append((20, "two or fewer public transactions observed")) |
|
elif ev.tx_count <= 8: |
|
signals.append((10, "sparse transaction history")) |
|
elif ev.tx_count >= 50: |
|
signals.append((-10, "substantial transaction history")) |
|
|
|
if ev.first_funder: |
|
peers = batch_funders.get(ev.first_funder, 0) |
|
if peers >= 4: |
|
signals.append((25, f"shares initial funder with {peers - 1} other batch addresses")) |
|
elif peers >= 2: |
|
signals.append((15, f"shares initial funder with {peers - 1} other batch address(es)")) |
|
if ev.first_funder in seed_funders: |
|
signals.append((30, "initial funder matches supplied sybil seed funding source")) |
|
|
|
if ev.identity_observed is False: |
|
signals.append((10, "no Agent Identity v2 NFT observed")) |
|
elif ev.identity_observed is True: |
|
signals.append((-20, "Agent Identity v2 NFT observed")) |
|
|
|
if ev.inbox_edges is not None: |
|
if ev.inbox_edges == 0: |
|
signals.append((5, "no public AIBTC inbox edges observed")) |
|
elif ev.inbox_edges >= 5: |
|
signals.append((-10, "multiple public AIBTC inbox edges observed")) |
|
|
|
if ev.inbound_count > 0 and ev.outbound_count == 0: |
|
signals.append((10, "inbound-funded address with no outbound history in sample")) |
|
|
|
for points, _ in signals: |
|
score += points |
|
score = max(0, min(100, score)) |
|
risk = "low" if score < 35 else "medium" if score < 70 else "high" |
|
ranked = sorted(signals, key=lambda x: abs(x[0]), reverse=True) |
|
return { |
|
"address": ev.address, |
|
"score": score, |
|
"risk": risk, |
|
"top_signals": [f"{text} ({points:+d})" for points, text in ranked[:3]], |
|
"all_signals": [f"{text} ({points:+d})" for points, text in ranked], |
|
"evidence": { |
|
"tx_count": ev.tx_count, |
|
"first_seen_iso": datetime.fromtimestamp(ev.first_seen_ts, timezone.utc).isoformat() if ev.first_seen_ts else None, |
|
"first_funder": ev.first_funder, |
|
"inbound_count_sample": ev.inbound_count, |
|
"outbound_count_sample": ev.outbound_count, |
|
"agent_identity_v2_observed": ev.identity_observed, |
|
"inbox_edges": ev.inbox_edges, |
|
"api_errors": ev.api_errors, |
|
}, |
|
} |
|
|
|
|
|
def read_lines(path: str | None) -> list[str]: |
|
if not path: |
|
return [] |
|
with open(path, "r", encoding="utf-8") as fh: |
|
return [ln.strip() for ln in fh if ln.strip() and not ln.startswith("#")] |
|
|
|
|
|
def main(argv: Iterable[str]) -> int: |
|
parser = argparse.ArgumentParser(description="Score Stacks addresses for sybil-likelihood from public signals") |
|
parser.add_argument("addresses", nargs="*", help="STX addresses to score") |
|
parser.add_argument("--addresses", dest="address_file", help="file containing one STX address per line") |
|
parser.add_argument("--seeds", help="optional file of known sybil seed STX addresses") |
|
parser.add_argument("--json", action="store_true", help="emit JSON only") |
|
args = parser.parse_args(list(argv)) |
|
|
|
addresses = args.addresses + read_lines(args.address_file) |
|
if not addresses: |
|
parser.error("provide at least one address") |
|
|
|
seed_addresses = read_lines(args.seeds) |
|
seed_evidence = [fetch_address_evidence(a) for a in seed_addresses] |
|
seed_funders = {ev.first_funder for ev in seed_evidence if ev.first_funder} |
|
|
|
evidence = [fetch_address_evidence(a) for a in addresses] |
|
funders: dict[str, int] = {} |
|
for ev in evidence: |
|
if ev.first_funder: |
|
funders[ev.first_funder] = funders.get(ev.first_funder, 0) + 1 |
|
|
|
results = [score_one(ev, funders, seed_funders) for ev in evidence] |
|
print(json.dumps({"results": results}, indent=2, sort_keys=True)) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
raise SystemExit(main(sys.argv[1:])) |