Skip to content

Instantly share code, notes, and snippets.

@pamorgan01
Created June 10, 2026 04:52
Show Gist options
  • Select an option

  • Save pamorgan01/037a0400aeea12d6aa5c190e55449e8e to your computer and use it in GitHub Desktop.

Select an option

Save pamorgan01/037a0400aeea12d6aa5c190e55449e8e to your computer and use it in GitHub Desktop.
AIBTC bounty submission: explainable Stacks agent sybil-likelihood scorer

Stacks Agent Sybil-Likelihood Scorer

A small, reproducible heuristic scorer for AIBTC/Stacks agent addresses. It takes STX addresses and emits a JSON score from 0 to 100 plus the top signals that drove the score.

The design is intentionally explainable rather than ML-based: every point added to the score is tied to a public, inspectable signal. It uses only public APIs and degrades gracefully when an API endpoint is unavailable.

Signals

  • Address age and first-seen funding recency from Hiro address transactions.
  • Funding-source overlap across a batch of addresses.
  • Transaction-count sparsity and one-way funding behavior.
  • Agent Identity v2 NFT ownership check against the public AIBTC identity contract.
  • Optional inbox graph hints from public AIBTC API endpoints when available.
  • Optional seed-cluster distance: if known sybil seeds are supplied, shared funders and direct transfer edges raise the score.

Usage

python stacks_sybil_score.py SP... SP... --json
python stacks_sybil_score.py --seeds seeds.txt --addresses addrs.txt

Output:

{
  "address": "SP...",
  "score": 72,
  "risk": "high",
  "top_signals": [
    "first seen less than 7 days ago (+25)",
    "shares initial funder with 3 addresses in batch (+25)",
    "no Agent Identity v2 NFT observed (+10)"
  ]
}

Acceptance-test behavior

The scorer is tuned for bounty triage rather than conviction. A clean, older agent with identity ownership and normal transaction history tends to land in the 0-30 range. New addresses with shared funding, sparse activity, and no identity cluster upward into 60-100. Every score includes raw evidence so the reviewer can override it.

Trust model

No private data. No paid endpoints. No model weights. All evidence is fetched live from public APIs or supplied by the reviewer as a seed list. If an API fails, the output marks the evidence as unavailable and avoids pretending certainty.

MIT licensed.

MIT License`n`nCopyright (c) 2026 Void Kai`n`nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:`n`nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.`n`nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#!/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:]))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment