Skip to content

Instantly share code, notes, and snippets.

@g8rr5dg2p7-svg
Created May 31, 2026 18:14
Show Gist options
  • Select an option

  • Save g8rr5dg2p7-svg/402b084712f4d00f40177a0a1ad8c654 to your computer and use it in GitHub Desktop.

Select an option

Save g8rr5dg2p7-svg/402b084712f4d00f40177a0a1ad8c654 to your computer and use it in GitHub Desktop.
AIBTC Sybil Cluster Lens - explainable public-data sybil scoring for Stacks agent addresses

AIBTC Sybil Cluster Lens

Explainable sybil-likelihood scoring for Stacks/AIBTC agent addresses.

This tool scores one or more STX or BTC addresses from public signals only:

  • AIBTC agent directory registration cohorts
  • Profile-template similarity inside burst-created cohorts
  • ERC-8004 identity and AIBTC level
  • Public inbox economics
  • Hiro Stacks transaction history and first observed STX/token funder
  • mempool.space BTC anchor activity and first observed BTC funder
  • Optional seed-cluster proximity

It outputs a 0-100 score, a label, the top three signals, all signals, raw public facts, and endpoint errors.

Install

Python 3.10+ is enough. No packages are required.

python3 sybil_cluster_lens.py --self-test

Run

Score individual addresses:

python3 sybil_cluster_lens.py SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1 --pretty

Score a batch:

python3 sybil_cluster_lens.py --address-file addresses.txt --pretty

Use seed addresses:

python3 sybil_cluster_lens.py --address-file addresses.txt --seed-file seeds.txt --pretty

Evaluate a labeled CSV:

python3 sybil_cluster_lens.py --address-file addresses.txt --labeled labels.csv --pretty

labels.csv can use columns like:

address,label
SP...,sybil
SP...,clean

Run the live public demo:

python3 sybil_cluster_lens.py --demo --pretty

Output Labels

  • LIKELY_CLEAN: score under 38
  • MODERATE_RISK: score 38-67
  • HIGH_SYBIL_RISK: score 68+

Scores are triage signals, not accusations or proof of shared control.

Public Data Sources

  • https://aibtc.com/api/agents
  • https://aibtc.com/api/agents/:address
  • https://aibtc.com/api/inbox/:address
  • https://api.hiro.so/extended/v1/address/:address/transactions
  • https://api.hiro.so/extended/v1/address/:address/assets
  • https://mempool.space/api/address/:address

Trust Model

This is an explainable heuristic. It trusts AIBTC, Hiro, and mempool.space as public read-only mirrors. A reviewer can substitute self-hosted indexers for Hiro or mempool.space and rerun the same checks.

Cost

  • Direct verification cost: 0 sats
  • Paid APIs: none
  • On-chain transactions: none

License

MIT

{
"cost": {
"onchain_transactions": 0,
"paid_apis": 0,
"sats": 0
},
"directory": {
"agents_loaded": 985,
"largest_registration_cohorts_30s": [
{
"agent_count": 21,
"avg_description_similarity": 0.104,
"sample_stx_addresses": [
"SPRKVG9F9VEE3QQDM9HYJHF2A2ACD066RYJ6VGKQ",
"SP3SXE9AFDM45HBEYNXT6XN4Q5KHPVBGJ6CXJMZA4",
"SPTHZ7JPWWP7N7MBMWWDWYKBPPT1Q1G8C1RTDEBY",
"SP1X5V14J4YVW07WM3DQAQ22W72H97QWNV74QTW0X",
"SP1Z10V0EVVFBKDHGV5KHXYPKCZBMZCVETX30P6RK",
"SP2S4N22J0P7F9A9SV08HASFAHCW48YQ4PSFTQPWE"
],
"window_start": "2026-04-01T02:58:30.995000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.104,
"sample_stx_addresses": [
"SP3SXE9AFDM45HBEYNXT6XN4Q5KHPVBGJ6CXJMZA4",
"SPTHZ7JPWWP7N7MBMWWDWYKBPPT1Q1G8C1RTDEBY",
"SP1X5V14J4YVW07WM3DQAQ22W72H97QWNV74QTW0X",
"SP1Z10V0EVVFBKDHGV5KHXYPKCZBMZCVETX30P6RK",
"SP2S4N22J0P7F9A9SV08HASFAHCW48YQ4PSFTQPWE",
"SPVTBNYQ285CG39AV3MKR0A0ASWZ114MZ2M99WK8"
],
"window_start": "2026-04-01T02:58:32.447000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.103,
"sample_stx_addresses": [
"SP6XVR04K2CZJVTBASP0DWG7FJPS7414ABXNVY33",
"SPSQ2QCYMDWWNK45TB2HWH4NKHH5CXJ4D5K81XEN",
"SPRKVG9F9VEE3QQDM9HYJHF2A2ACD066RYJ6VGKQ",
"SP3SXE9AFDM45HBEYNXT6XN4Q5KHPVBGJ6CXJMZA4",
"SPTHZ7JPWWP7N7MBMWWDWYKBPPT1Q1G8C1RTDEBY",
"SP1X5V14J4YVW07WM3DQAQ22W72H97QWNV74QTW0X"
],
"window_start": "2026-04-01T02:58:27.877000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.103,
"sample_stx_addresses": [
"SPSQ2QCYMDWWNK45TB2HWH4NKHH5CXJ4D5K81XEN",
"SPRKVG9F9VEE3QQDM9HYJHF2A2ACD066RYJ6VGKQ",
"SP3SXE9AFDM45HBEYNXT6XN4Q5KHPVBGJ6CXJMZA4",
"SPTHZ7JPWWP7N7MBMWWDWYKBPPT1Q1G8C1RTDEBY",
"SP1X5V14J4YVW07WM3DQAQ22W72H97QWNV74QTW0X",
"SP1Z10V0EVVFBKDHGV5KHXYPKCZBMZCVETX30P6RK"
],
"window_start": "2026-04-01T02:58:29.486000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.102,
"sample_stx_addresses": [
"SP1XESHGF8NQWAS170EKSGG1JVRZ135PE7B1NSJEG",
"SP2GPNPSKFMZ10ZSR9SF4GMYA1QX0TP77QD9BKEB",
"SP6XVR04K2CZJVTBASP0DWG7FJPS7414ABXNVY33",
"SPSQ2QCYMDWWNK45TB2HWH4NKHH5CXJ4D5K81XEN",
"SPRKVG9F9VEE3QQDM9HYJHF2A2ACD066RYJ6VGKQ",
"SP3SXE9AFDM45HBEYNXT6XN4Q5KHPVBGJ6CXJMZA4"
],
"window_start": "2026-04-01T02:58:24.834000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.102,
"sample_stx_addresses": [
"SP2GPNPSKFMZ10ZSR9SF4GMYA1QX0TP77QD9BKEB",
"SP6XVR04K2CZJVTBASP0DWG7FJPS7414ABXNVY33",
"SPSQ2QCYMDWWNK45TB2HWH4NKHH5CXJ4D5K81XEN",
"SPRKVG9F9VEE3QQDM9HYJHF2A2ACD066RYJ6VGKQ",
"SP3SXE9AFDM45HBEYNXT6XN4Q5KHPVBGJ6CXJMZA4",
"SPTHZ7JPWWP7N7MBMWWDWYKBPPT1Q1G8C1RTDEBY"
],
"window_start": "2026-04-01T02:58:26.390000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.101,
"sample_stx_addresses": [
"SP64X0W5JWK6QA1PWA4Y9ASGXEH4SZWXCD4MHCN0",
"SP1XESHGF8NQWAS170EKSGG1JVRZ135PE7B1NSJEG",
"SP2GPNPSKFMZ10ZSR9SF4GMYA1QX0TP77QD9BKEB",
"SP6XVR04K2CZJVTBASP0DWG7FJPS7414ABXNVY33",
"SPSQ2QCYMDWWNK45TB2HWH4NKHH5CXJ4D5K81XEN",
"SPRKVG9F9VEE3QQDM9HYJHF2A2ACD066RYJ6VGKQ"
],
"window_start": "2026-04-01T02:58:23.401000+00:00"
},
{
"agent_count": 21,
"avg_description_similarity": 0.1,
"sample_stx_addresses": [
"SP2WNA8F1G5QXMKS97M2S6TB1XR89ZFRKQ0NY6R3A",
"SP64X0W5JWK6QA1PWA4Y9ASGXEH4SZWXCD4MHCN0",
"SP1XESHGF8NQWAS170EKSGG1JVRZ135PE7B1NSJEG",
"SP2GPNPSKFMZ10ZSR9SF4GMYA1QX0TP77QD9BKEB",
"SP6XVR04K2CZJVTBASP0DWG7FJPS7414ABXNVY33",
"SPSQ2QCYMDWWNK45TB2HWH4NKHH5CXJ4D5K81XEN"
],
"window_start": "2026-04-01T02:58:21.739000+00:00"
}
]
},
"generated_at": "2026-05-31T18:14:04.780271+00:00",
"inputs": {
"addresses": [
"SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"SP2YTGB7CDQP1E4T79CQMJ1DT7JB3VH4JMMEB4KEJ",
"SP1SC59Y3G1A0WNY5837R9HDCEPWRJSF852YM7GEW",
"SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS",
"SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1"
],
"seed_count": 2
},
"label_evaluation": {
"accuracy": 1.0,
"correct": 8,
"count": 8,
"rows": [
{
"address": "SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"correct": true,
"expected": "clean",
"predicted": "LIKELY_CLEAN",
"score": 0
},
{
"address": "SP2YTGB7CDQP1E4T79CQMJ1DT7JB3VH4JMMEB4KEJ",
"correct": true,
"expected": "clean",
"predicted": "LIKELY_CLEAN",
"score": 0
},
{
"address": "SP1SC59Y3G1A0WNY5837R9HDCEPWRJSF852YM7GEW",
"correct": true,
"expected": "clean",
"predicted": "LIKELY_CLEAN",
"score": 0
},
{
"address": "SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS",
"correct": true,
"expected": "sybil",
"predicted": "HIGH_SYBIL_RISK",
"score": 100
},
{
"address": "SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"correct": true,
"expected": "sybil",
"predicted": "HIGH_SYBIL_RISK",
"score": 100
},
{
"address": "SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"correct": true,
"expected": "sybil",
"predicted": "HIGH_SYBIL_RISK",
"score": 70
},
{
"address": "SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"correct": true,
"expected": "sybil",
"predicted": "HIGH_SYBIL_RISK",
"score": 70
},
{
"address": "SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"correct": true,
"expected": "sybil",
"predicted": "HIGH_SYBIL_RISK",
"score": 70
}
]
},
"results": [
{
"address": "SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"all_signals": [
{
"name": "mature_stx_history",
"points": -16,
"reason": "wallet has 44 days and 136 Stacks transactions"
},
{
"name": "inbox_economic_counter_signal",
"points": -14,
"reason": "inbox shows 19 received messages and 300 net sats"
},
{
"name": "onchain_identity_counter_signal",
"points": -10,
"reason": "agent has ERC-8004 identity #5"
},
{
"name": "older_active_agent_counter_signal",
"points": -8,
"reason": "registered 44 days ago with public activity"
}
],
"btc_address": "bc1qxhj8qdlw2yalqpdwka8en9h29m6h4n3kyw8vcm",
"btc_first_funder": "35Rv6zFxasfWRjBD8MRAi6wtNWcjtmGkqg",
"btc_funded_sats": 973789,
"btc_tx_count": 2,
"caip19": null,
"cohort_profile_similarity": 0.0,
"cohort_samples": [],
"cohort_size_30s": 1,
"directory_indexed": true,
"display_name": "Quasar Garuda",
"endpoint_errors": [],
"erc8004_agent_id": 5,
"first_stx_funder": "SP1EANQEQRHFYP4WHR1PHWDV25NAKGK143WV42ZN8",
"hiro_contract_count": 1,
"hiro_contracts_sample": [
"SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token"
],
"hiro_oldest_tx_iso": "2026-04-18T02:07:04.000Z",
"hiro_tx_total": 136,
"inbound_asset_events": 2,
"inbox_received": 19,
"inbox_sats_net": 300,
"inbox_sent": 16,
"inbox_total": 19,
"label": "LIKELY_CLEAN",
"last_active_at": "2026-05-31T12:44:34.000Z",
"level": 1,
"level_name": "Verified Agent",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": false,
"score": 0,
"stx_address": "SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1",
"top_signals": [
{
"name": "mature_stx_history",
"points": -16,
"reason": "wallet has 44 days and 136 Stacks transactions"
},
{
"name": "inbox_economic_counter_signal",
"points": -14,
"reason": "inbox shows 19 received messages and 300 net sats"
},
{
"name": "onchain_identity_counter_signal",
"points": -10,
"reason": "agent has ERC-8004 identity #5"
}
],
"verified_at": "2026-04-18T02:21:27.312Z"
},
{
"address": "SP2YTGB7CDQP1E4T79CQMJ1DT7JB3VH4JMMEB4KEJ",
"all_signals": [
{
"name": "mature_stx_history",
"points": -16,
"reason": "wallet has 44 days and 35 Stacks transactions"
},
{
"name": "inbox_economic_counter_signal",
"points": -14,
"reason": "inbox shows 4 received messages and 200 net sats"
},
{
"name": "onchain_identity_counter_signal",
"points": -10,
"reason": "agent has ERC-8004 identity #429"
},
{
"name": "genesis_counter_signal",
"points": -12,
"reason": "agent is Level 2 (Genesis)"
},
{
"name": "older_active_agent_counter_signal",
"points": -8,
"reason": "registered 44 days ago with public activity"
}
],
"btc_address": "bc1qy2gk8y973sxgy59h43jfy6lywuld738n4d0zth",
"btc_first_funder": "bc1qq2mvrp4g3ugd424dw4xv53rgsf8szkrv853jrc",
"btc_funded_sats": 58378,
"btc_tx_count": 7,
"caip19": null,
"cohort_profile_similarity": 0.0,
"cohort_samples": [],
"cohort_size_30s": 1,
"directory_indexed": true,
"display_name": "Celestial Shark",
"endpoint_errors": [],
"erc8004_agent_id": 429,
"first_stx_funder": "SP2P12FHKH6W2240S43G015BH3QVYB62Z91VF9SDG",
"hiro_contract_count": 5,
"hiro_contracts_sample": [
"SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.wrapper-velar-path-v-1-2",
"SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-swap-helper-v-1-3",
"SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token",
"SP1NMR7MY0TJ1QA7WQBZ6504KC79PZNTRQH4YGFJD.identity-registry-v2",
"SP35A2J9JBTPSS9WA9XZAPRX8FB3245XXG7CZ0ZM2.early-eagles-v2"
],
"hiro_oldest_tx_iso": "2026-04-17T14:30:48.000Z",
"hiro_tx_total": 35,
"inbound_asset_events": 9,
"inbox_received": 4,
"inbox_sats_net": 200,
"inbox_sent": 2,
"inbox_total": 4,
"label": "LIKELY_CLEAN",
"last_active_at": "2026-05-22T21:15:00.418Z",
"level": 2,
"level_name": "Genesis",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": true,
"score": 0,
"stx_address": "SP2YTGB7CDQP1E4T79CQMJ1DT7JB3VH4JMMEB4KEJ",
"top_signals": [
{
"name": "mature_stx_history",
"points": -16,
"reason": "wallet has 44 days and 35 Stacks transactions"
},
{
"name": "inbox_economic_counter_signal",
"points": -14,
"reason": "inbox shows 4 received messages and 200 net sats"
},
{
"name": "genesis_counter_signal",
"points": -12,
"reason": "agent is Level 2 (Genesis)"
}
],
"verified_at": "2026-04-17T12:37:22.888Z"
},
{
"address": "SP1SC59Y3G1A0WNY5837R9HDCEPWRJSF852YM7GEW",
"all_signals": [
{
"name": "onchain_identity_counter_signal",
"points": -10,
"reason": "agent has ERC-8004 identity #444"
},
{
"name": "genesis_counter_signal",
"points": -12,
"reason": "agent is Level 2 (Genesis)"
}
],
"btc_address": "bc1qx8x3tquc8zw9cwez2un4cre5elaeczeaens2xe",
"btc_first_funder": null,
"btc_funded_sats": 0,
"btc_tx_count": 0,
"caip19": null,
"cohort_profile_similarity": 0.0,
"cohort_samples": [],
"cohort_size_30s": 1,
"directory_indexed": true,
"display_name": "Coral Sable",
"endpoint_errors": [],
"erc8004_agent_id": 444,
"first_stx_funder": null,
"hiro_contract_count": 8,
"hiro_contracts_sample": [
"SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.router-stableswap-xyk-multihop-v-1-2",
"SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.stableswap-swap-helper-v-1-5",
"SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.wrapper-velar-path-v-1-2",
"SM1793C4R5PZ4NS4VQ4WMP7SKKYVH8JZEWSZ9HCCR.xyk-core-v-1-2",
"SM1FKXGNZJWSTWDWXQZJNF7B5TV5ZB235JTCXYXKD.dlmm-liquidity-router-v-1-1",
"SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token",
"SP1NMR7MY0TJ1QA7WQBZ6504KC79PZNTRQH4YGFJD.identity-registry-v2",
"SP1PFR4V08H1RAZXREBGFFQ59WB739XM8VVGTFSEA.dlmm-core-v-1-1"
],
"hiro_oldest_tx_iso": "2026-05-19T00:34:56.000Z",
"hiro_tx_total": 47,
"inbound_asset_events": 0,
"inbox_received": 1,
"inbox_sats_net": 0,
"inbox_sent": 1,
"inbox_total": 1,
"label": "LIKELY_CLEAN",
"last_active_at": "2026-05-31T15:23:17.000Z",
"level": 2,
"level_name": "Genesis",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": true,
"score": 0,
"stx_address": "SP1SC59Y3G1A0WNY5837R9HDCEPWRJSF852YM7GEW",
"top_signals": [
{
"name": "genesis_counter_signal",
"points": -12,
"reason": "agent is Level 2 (Genesis)"
},
{
"name": "onchain_identity_counter_signal",
"points": -10,
"reason": "agent has ERC-8004 identity #444"
}
],
"verified_at": "2026-05-19T01:43:36.824Z"
},
{
"address": "SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS",
"all_signals": [
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "profile_template",
"points": 10,
"reason": "profile text matches simple generated-template patterns inside a registration cohort"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
},
{
"name": "missing_onchain_identity",
"points": 6,
"reason": "agent profile has no ERC-8004 identity id"
},
{
"name": "explicit_seed_member",
"points": 30,
"reason": "address is explicitly present in the supplied sybil seed set"
}
],
"btc_address": "bc1qjj7dkqe20jdzqwp82vasz36swwsdeeduf65ql8",
"btc_first_funder": null,
"btc_funded_sats": 0,
"btc_tx_count": 0,
"caip19": null,
"cohort_profile_similarity": 0.429,
"cohort_samples": [
"SPGK7RYV7P27J05SV5AS8T8DMG8C9NCTQE45V0N8",
"SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP"
],
"cohort_size_30s": 6,
"directory_indexed": true,
"display_name": "Emerald Node",
"endpoint_errors": [],
"erc8004_agent_id": null,
"first_stx_funder": null,
"hiro_contract_count": 0,
"hiro_contracts_sample": [],
"hiro_oldest_tx_iso": null,
"hiro_tx_total": 0,
"inbound_asset_events": 0,
"inbox_received": 0,
"inbox_sats_net": 0,
"inbox_sent": 0,
"inbox_total": 0,
"label": "HIGH_SYBIL_RISK",
"last_active_at": null,
"level": 1,
"level_name": "Verified Agent",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": false,
"score": 100,
"stx_address": "SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS",
"top_signals": [
{
"name": "explicit_seed_member",
"points": 30,
"reason": "address is explicitly present in the supplied sybil seed set"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
}
],
"verified_at": "2026-04-18T06:38:20.427Z"
},
{
"address": "SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"all_signals": [
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "profile_template",
"points": 10,
"reason": "profile text matches simple generated-template patterns inside a registration cohort"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
},
{
"name": "missing_onchain_identity",
"points": 6,
"reason": "agent profile has no ERC-8004 identity id"
},
{
"name": "explicit_seed_member",
"points": 30,
"reason": "address is explicitly present in the supplied sybil seed set"
}
],
"btc_address": "bc1qtwee590z22ra58756lv8xhtdpl8qvft86p2u5p",
"btc_first_funder": null,
"btc_funded_sats": 0,
"btc_tx_count": 0,
"caip19": null,
"cohort_profile_similarity": 0.429,
"cohort_samples": [
"SPGK7RYV7P27J05SV5AS8T8DMG8C9NCTQE45V0N8",
"SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS"
],
"cohort_size_30s": 6,
"directory_indexed": true,
"display_name": "Crafty Gate",
"endpoint_errors": [],
"erc8004_agent_id": null,
"first_stx_funder": null,
"hiro_contract_count": 0,
"hiro_contracts_sample": [],
"hiro_oldest_tx_iso": null,
"hiro_tx_total": 0,
"inbound_asset_events": 0,
"inbox_received": 0,
"inbox_sats_net": 0,
"inbox_sent": 0,
"inbox_total": 0,
"label": "HIGH_SYBIL_RISK",
"last_active_at": null,
"level": 1,
"level_name": "Verified Agent",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": false,
"score": 100,
"stx_address": "SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"top_signals": [
{
"name": "explicit_seed_member",
"points": 30,
"reason": "address is explicitly present in the supplied sybil seed set"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
}
],
"verified_at": "2026-04-18T06:38:24.117Z"
},
{
"address": "SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"all_signals": [
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "profile_template",
"points": 10,
"reason": "profile text matches simple generated-template patterns inside a registration cohort"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
},
{
"name": "missing_onchain_identity",
"points": 6,
"reason": "agent profile has no ERC-8004 identity id"
}
],
"btc_address": "bc1qyfslkpw90s54kzz45utwh2394gv5mpd9hqrfh0",
"btc_first_funder": null,
"btc_funded_sats": 0,
"btc_tx_count": 0,
"caip19": null,
"cohort_profile_similarity": 0.429,
"cohort_samples": [
"SPGK7RYV7P27J05SV5AS8T8DMG8C9NCTQE45V0N8",
"SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS"
],
"cohort_size_30s": 6,
"directory_indexed": true,
"display_name": "Halcyon Jaguar",
"endpoint_errors": [],
"erc8004_agent_id": null,
"first_stx_funder": null,
"hiro_contract_count": 0,
"hiro_contracts_sample": [],
"hiro_oldest_tx_iso": null,
"hiro_tx_total": 0,
"inbound_asset_events": 0,
"inbox_received": 0,
"inbox_sats_net": 0,
"inbox_sent": 0,
"inbox_total": 0,
"label": "HIGH_SYBIL_RISK",
"last_active_at": null,
"level": 1,
"level_name": "Verified Agent",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": false,
"score": 70,
"stx_address": "SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"top_signals": [
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
}
],
"verified_at": "2026-04-18T06:38:27.721Z"
},
{
"address": "SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"all_signals": [
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "profile_template",
"points": 10,
"reason": "profile text matches simple generated-template patterns inside a registration cohort"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
},
{
"name": "missing_onchain_identity",
"points": 6,
"reason": "agent profile has no ERC-8004 identity id"
}
],
"btc_address": "bc1qglv704a7rq3n204wvy6fsc6kr3wpcgef4vjrge",
"btc_first_funder": null,
"btc_funded_sats": 0,
"btc_tx_count": 0,
"caip19": null,
"cohort_profile_similarity": 0.429,
"cohort_samples": [
"SPGK7RYV7P27J05SV5AS8T8DMG8C9NCTQE45V0N8",
"SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS"
],
"cohort_size_30s": 6,
"directory_indexed": true,
"display_name": "Veiled Stork",
"endpoint_errors": [],
"erc8004_agent_id": null,
"first_stx_funder": null,
"hiro_contract_count": 0,
"hiro_contracts_sample": [],
"hiro_oldest_tx_iso": null,
"hiro_tx_total": 0,
"inbound_asset_events": 0,
"inbox_received": 0,
"inbox_sats_net": 0,
"inbox_sent": 0,
"inbox_total": 0,
"label": "HIGH_SYBIL_RISK",
"last_active_at": null,
"level": 1,
"level_name": "Verified Agent",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": false,
"score": 70,
"stx_address": "SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"top_signals": [
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
}
],
"verified_at": "2026-04-18T06:38:31.228Z"
},
{
"address": "SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"all_signals": [
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "profile_template",
"points": 10,
"reason": "profile text matches simple generated-template patterns inside a registration cohort"
},
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
},
{
"name": "missing_onchain_identity",
"points": 6,
"reason": "agent profile has no ERC-8004 identity id"
}
],
"btc_address": "bc1q7xddxd6c69c3k4ylhe8nsjc2p24syfllk9utzz",
"btc_first_funder": null,
"btc_funded_sats": 0,
"btc_tx_count": 0,
"caip19": null,
"cohort_profile_similarity": 0.429,
"cohort_samples": [
"SPGK7RYV7P27J05SV5AS8T8DMG8C9NCTQE45V0N8",
"SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS"
],
"cohort_size_30s": 6,
"directory_indexed": true,
"display_name": "Violet Sable",
"endpoint_errors": [],
"erc8004_agent_id": null,
"first_stx_funder": null,
"hiro_contract_count": 0,
"hiro_contracts_sample": [],
"hiro_oldest_tx_iso": null,
"hiro_tx_total": 0,
"inbound_asset_events": 0,
"inbox_received": 0,
"inbox_sats_net": 0,
"inbox_sent": 0,
"inbox_total": 0,
"label": "HIGH_SYBIL_RISK",
"last_active_at": null,
"level": 1,
"level_name": "Verified Agent",
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
"owner_present": false,
"score": 70,
"stx_address": "SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
"top_signals": [
{
"name": "no_stx_history",
"points": 22,
"reason": "Hiro shows no Stacks transaction history"
},
{
"name": "burst_registration_cohort",
"points": 18,
"reason": "6 agents registered within 30 seconds"
},
{
"name": "no_economic_depth",
"points": 14,
"reason": "no public STX, BTC, or inbox economic activity found"
}
],
"verified_at": "2026-04-18T06:38:34.667Z"
}
],
"tool": "aibtc-sybil-cluster-lens",
"trust_model": [
"Uses public AIBTC, Hiro, and mempool.space APIs as read-only mirrors.",
"Scores are explainable heuristics for bounty triage, not cryptographic proof.",
"Endpoint failures are preserved per address so reviewers can rerun or substitute their own indexers."
],
"version": "1.0.0"
}
MIT License
Copyright (c) 2026 Mondo Fresca Produce
Permission 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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE 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
"""
AIBTC Sybil Cluster Lens.
Scores Stacks/AIBTC agent addresses with explainable public signals:
AIBTC directory cohorts, profile templates, inbox economics, Hiro chain history,
first funding source, BTC anchor activity, and optional seed-cluster proximity.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import json
import math
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Any
VERSION = "1.0.0"
AIBTC = "https://aibtc.com"
HIRO = "https://api.hiro.so"
MEMPOOL = "https://mempool.space"
DEMO_CLEAN = [
"SP20GPDS5RYB2DV03KG4W08EG6HD11KYPK6FQJE1", # long-lived bounty poster
"SP2YTGB7CDQP1E4T79CQMJ1DT7JB3VH4JMMEB4KEJ", # paid census winner
"SP1SC59Y3G1A0WNY5837R9HDCEPWRJSF852YM7GEW", # active Genesis agent
]
DEMO_SYBIL_COHORT = [
"SP3RN871AHRK44HYHSFJR21P4A0H3JZ2FV361TZPS",
"SPXVW3V29P59QD1MPM2KE1KZ1HR8FM2D2X9RN4QP",
"SPG6Q9EMPAJW2GEKHJKYHD2FD6DWHX7AT1A5KBR3",
"SP1S8M8QPRAQ4C7X9BKH4VZ1A75XQNPT822AAS1HN",
"SP166494293PXQW36G19ET6DSDQ38MD6CR2CZZ4P1",
]
class Fetcher:
def __init__(self, timeout: float = 15.0, sleep: float = 0.05):
self.timeout = timeout
self.sleep = sleep
self.cache: dict[str, Any] = {}
self.errors: list[dict[str, Any]] = []
self.ssl_context = self._ssl_context()
@staticmethod
def _ssl_context() -> ssl.SSLContext:
for cafile in (
os.environ.get("SSL_CERT_FILE"),
"/etc/ssl/cert.pem",
"/private/etc/ssl/cert.pem",
"/opt/homebrew/etc/openssl@3/cert.pem",
"/usr/local/etc/openssl@3/cert.pem",
"/etc/ssl/certs/ca-certificates.crt",
):
if cafile and os.path.exists(cafile):
return ssl.create_default_context(cafile=cafile)
return ssl.create_default_context()
def get_json(self, url: str, *, default: Any = None) -> Any:
if url in self.cache:
return self.cache[url]
if self.sleep:
time.sleep(self.sleep)
req = urllib.request.Request(url, headers={"user-agent": f"aibtc-sybil-cluster-lens/{VERSION}"})
try:
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_context) as resp:
data = json.loads(resp.read().decode("utf-8"))
self.cache[url] = data
return data
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
item = {"url": url, "error": type(exc).__name__, "detail": str(exc)[:300]}
if isinstance(exc, urllib.error.HTTPError):
item["status"] = exc.code
self.errors.append(item)
self.cache[url] = default
return default
def parse_time(value: Any) -> dt.datetime | None:
if not value or not isinstance(value, str):
return None
value = value.replace("Z", "+00:00")
if value.startswith("0000-"):
return None
try:
parsed = dt.datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt.timezone.utc)
return parsed.astimezone(dt.timezone.utc)
def now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def days_between(old: dt.datetime | None, new: dt.datetime | None = None) -> float | None:
if old is None:
return None
new = new or now_utc()
return max(0.0, (new - old).total_seconds() / 86400)
def norm_text(value: Any) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.lower().strip().replace("_", " ").replace("-", " ").split())
def jaccard(a: str, b: str) -> float:
aa = {x for x in norm_text(a).split() if len(x) > 2}
bb = {x for x in norm_text(b).split() if len(x) > 2}
if not aa and not bb:
return 1.0
if not aa or not bb:
return 0.0
return len(aa & bb) / len(aa | bb)
def address_kind(address: str) -> str:
if address.startswith(("SP", "SM")):
return "stx"
if address.startswith(("bc1", "1", "3")):
return "btc"
return "unknown"
def load_lines(path: str | None) -> list[str]:
if not path:
return []
out: list[str] = []
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#"):
out.append(line)
return out
def load_labeled(path: str | None) -> dict[str, str]:
if not path:
return {}
labels: dict[str, str] = {}
with open(path, "r", encoding="utf-8") as fh:
sample = fh.read(2048)
fh.seek(0)
dialect = csv.Sniffer().sniff(sample) if sample.strip() else csv.excel
reader = csv.DictReader(fh, dialect=dialect)
if reader.fieldnames:
lower = {name.lower(): name for name in reader.fieldnames}
addr_col = lower.get("address") or lower.get("stx_address") or reader.fieldnames[0]
label_col = lower.get("label") or lower.get("class") or lower.get("expected") or reader.fieldnames[-1]
for row in reader:
addr = (row.get(addr_col) or "").strip()
label = (row.get(label_col) or "").strip().lower()
if addr:
labels[addr] = label
return labels
@dataclass
class Signal:
name: str
points: float
reason: str
@dataclass
class AgentFacts:
address: str
stx_address: str | None = None
btc_address: str | None = None
display_name: str | None = None
description: str | None = None
owner: str | None = None
verified_at: str | None = None
level: int | None = None
level_name: str | None = None
erc8004_agent_id: int | None = None
caip19: str | None = None
last_active_at: str | None = None
directory_indexed: bool = False
cohort_size_30s: int = 0
cohort_profile_similarity: float = 0.0
cohort_samples: list[str] = field(default_factory=list)
hiro_tx_total: int | None = None
hiro_oldest_tx_iso: str | None = None
hiro_contracts: list[str] = field(default_factory=list)
first_stx_funder: str | None = None
inbound_asset_events: int = 0
inbox_total: int | None = None
inbox_received: int | None = None
inbox_sent: int | None = None
inbox_sats_net: int | None = None
btc_tx_count: int | None = None
btc_funded_sats: int | None = None
btc_first_funder: str | None = None
endpoint_errors: list[dict[str, Any]] = field(default_factory=list)
def fetch_directory(fetcher: Fetcher, limit: int = 100) -> list[dict[str, Any]]:
agents: list[dict[str, Any]] = []
offset = 0
while True:
url = f"{AIBTC}/api/agents?limit={limit}&offset={offset}"
data = fetcher.get_json(url, default={}) or {}
batch = data.get("agents") or []
agents.extend(batch)
pagination = data.get("pagination") or {}
if not pagination.get("hasMore") or not batch:
break
offset += limit
if offset > 5000:
break
return agents
def directory_by_stx(directory: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {a.get("stxAddress"): a for a in directory if a.get("stxAddress")}
def directory_by_btc(directory: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {a.get("btcAddress"): a for a in directory if a.get("btcAddress")}
def cohort_for(agent: dict[str, Any], directory: list[dict[str, Any]], seconds: int = 30) -> tuple[int, float, list[str]]:
target_time = parse_time(agent.get("verifiedAt"))
if not target_time:
return 0, 0.0, []
desc = agent.get("description") or ""
display = agent.get("displayName") or ""
sims: list[float] = []
samples: list[str] = []
for other in directory:
if other.get("stxAddress") == agent.get("stxAddress"):
continue
other_time = parse_time(other.get("verifiedAt"))
if not other_time:
continue
delta = abs((other_time - target_time).total_seconds())
if delta <= seconds:
samples.append(other.get("stxAddress") or other.get("btcAddress") or "")
sims.append(max(jaccard(display, other.get("displayName") or ""), jaccard(desc, other.get("description") or "")))
avg = sum(sims) / len(sims) if sims else 0.0
return len(samples) + 1, avg, samples[:8]
def fetch_hiro_facts(fetcher: Fetcher, stx: str) -> dict[str, Any]:
encoded = urllib.parse.quote(stx, safe="")
tx_url = f"{HIRO}/extended/v1/address/{encoded}/transactions?limit=50"
tx_data = fetcher.get_json(tx_url, default={}) or {}
total = tx_data.get("total")
txs = tx_data.get("results") or []
oldest = None
if isinstance(total, int) and total > 50:
old_data = fetcher.get_json(f"{HIRO}/extended/v1/address/{encoded}/transactions?limit=1&offset={total - 1}", default={}) or {}
old_results = old_data.get("results") or []
if old_results:
oldest = old_results[0].get("block_time_iso")
elif txs:
oldest = txs[-1].get("block_time_iso")
contracts = sorted(
{
(((tx.get("contract_call") or {}).get("contract_id")) or "")
for tx in txs
if tx.get("tx_type") == "contract_call" and (tx.get("contract_call") or {}).get("contract_id")
}
)
assets = fetcher.get_json(f"{HIRO}/extended/v1/address/{encoded}/assets?limit=50", default={}) or {}
events = assets.get("results") or []
first_funder = None
inbound = []
for ev in events:
asset = ev.get("asset") or {}
sender = asset.get("sender")
recipient = asset.get("recipient")
if sender and recipient == stx and sender != stx:
inbound.append(ev)
if inbound:
first_funder = (inbound[-1].get("asset") or {}).get("sender")
return {
"tx_total": total,
"oldest_tx_iso": oldest,
"contracts": contracts[:40],
"first_stx_funder": first_funder,
"inbound_asset_events": len(inbound),
}
def fetch_inbox_facts(fetcher: Fetcher, address: str) -> dict[str, Any]:
data = fetcher.get_json(f"{AIBTC}/api/inbox/{urllib.parse.quote(address, safe='')}", default={}) or {}
inbox = data.get("inbox") or {}
econ = inbox.get("economics") or {}
return {
"total": inbox.get("totalCount"),
"received": inbox.get("receivedCount"),
"sent": inbox.get("sentCount"),
"sats_net": econ.get("satsNet"),
}
def fetch_btc_facts(fetcher: Fetcher, btc: str | None) -> dict[str, Any]:
if not btc:
return {}
encoded = urllib.parse.quote(btc, safe="")
stats = fetcher.get_json(f"{MEMPOOL}/api/address/{encoded}", default={}) or {}
chain = stats.get("chain_stats") or {}
mempool = stats.get("mempool_stats") or {}
tx_count = int(chain.get("tx_count") or 0) + int(mempool.get("tx_count") or 0)
funded = int(chain.get("funded_txo_sum") or 0) + int(mempool.get("funded_txo_sum") or 0)
first_funder = None
if tx_count:
txs = fetcher.get_json(f"{MEMPOOL}/api/address/{encoded}/txs", default=[]) or []
if txs:
oldest = txs[-1]
for vin in oldest.get("vin") or []:
prevout = vin.get("prevout") or {}
addr = prevout.get("scriptpubkey_address")
if addr and addr != btc:
first_funder = addr
break
return {"tx_count": tx_count, "funded_sats": funded, "first_funder": first_funder}
def facts_for(fetcher: Fetcher, address: str, directory: list[dict[str, Any]], by_stx: dict[str, dict[str, Any]], by_btc: dict[str, dict[str, Any]]) -> AgentFacts:
before = len(fetcher.errors)
kind = address_kind(address)
agent = by_stx.get(address) if kind == "stx" else by_btc.get(address)
if not agent:
agent_data = fetcher.get_json(f"{AIBTC}/api/agents/{urllib.parse.quote(address, safe='')}", default={}) or {}
agent = agent_data.get("agent") or {}
facts = AgentFacts(address=address)
if agent:
facts.stx_address = agent.get("stxAddress")
facts.btc_address = agent.get("btcAddress")
facts.display_name = agent.get("displayName")
facts.description = agent.get("description")
facts.owner = agent.get("owner")
facts.verified_at = agent.get("verifiedAt")
facts.level = agent.get("level")
facts.level_name = agent.get("levelName")
facts.erc8004_agent_id = agent.get("erc8004AgentId")
facts.caip19 = agent.get("caip19")
facts.last_active_at = agent.get("lastActiveAt")
facts.directory_indexed = bool(facts.stx_address in by_stx or facts.btc_address in by_btc)
size, sim, samples = cohort_for(agent, directory)
facts.cohort_size_30s = size
facts.cohort_profile_similarity = round(sim, 3)
facts.cohort_samples = samples
elif kind == "stx":
facts.stx_address = address
elif kind == "btc":
facts.btc_address = address
if facts.stx_address:
hiro = fetch_hiro_facts(fetcher, facts.stx_address)
facts.hiro_tx_total = hiro.get("tx_total")
facts.hiro_oldest_tx_iso = hiro.get("oldest_tx_iso")
facts.hiro_contracts = hiro.get("contracts") or []
facts.first_stx_funder = hiro.get("first_stx_funder")
facts.inbound_asset_events = hiro.get("inbound_asset_events") or 0
inbox = fetch_inbox_facts(fetcher, facts.stx_address)
facts.inbox_total = inbox.get("total")
facts.inbox_received = inbox.get("received")
facts.inbox_sent = inbox.get("sent")
facts.inbox_sats_net = inbox.get("sats_net")
btc = fetch_btc_facts(fetcher, facts.btc_address)
facts.btc_tx_count = btc.get("tx_count")
facts.btc_funded_sats = btc.get("funded_sats")
facts.btc_first_funder = btc.get("first_funder")
facts.endpoint_errors = fetcher.errors[before:]
return facts
def profile_template_flag(f: AgentFacts) -> bool:
desc = norm_text(f.description)
name = norm_text(f.display_name)
if not desc:
return True
starts = (
"autonomous agent ",
"autonomous ai agent",
"automated ai operator",
"codex managed agent",
"codex built agent",
)
return any(desc.startswith(x) for x in starts) or any(word in name for word in ("node", "hub", "gate"))
def score_agent(f: AgentFacts, all_facts: list[AgentFacts], seed_facts: list[AgentFacts]) -> tuple[int, str, list[Signal]]:
signals: list[Signal] = []
tx_total = f.hiro_tx_total if isinstance(f.hiro_tx_total, int) else None
wallet_age = days_between(parse_time(f.hiro_oldest_tx_iso))
reg_age = days_between(parse_time(f.verified_at))
inbox_total = f.inbox_total if isinstance(f.inbox_total, int) else 0
btc_count = f.btc_tx_count if isinstance(f.btc_tx_count, int) else 0
if f.cohort_size_30s >= 5 and f.cohort_profile_similarity >= 0.45:
signals.append(Signal("burst_registration_cohort", 24, f"{f.cohort_size_30s} agents registered within 30 seconds with similar profiles"))
elif f.cohort_size_30s >= 5:
signals.append(Signal("burst_registration_cohort", 18, f"{f.cohort_size_30s} agents registered within 30 seconds"))
elif f.cohort_size_30s >= 3:
signals.append(Signal("registration_cohort", 14, f"{f.cohort_size_30s} agents registered within 30 seconds"))
if profile_template_flag(f) and f.cohort_size_30s >= 3:
signals.append(Signal("profile_template", 10, "profile text matches simple generated-template patterns inside a registration cohort"))
if tx_total == 0:
signals.append(Signal("no_stx_history", 22, "Hiro shows no Stacks transaction history"))
elif wallet_age is not None and wallet_age < 2 and tx_total <= 3:
signals.append(Signal("very_new_low_history_wallet", 16, f"wallet age is {wallet_age:.1f} days with only {tx_total} Stacks transactions"))
elif wallet_age is not None and wallet_age > 21 and tx_total >= 20:
signals.append(Signal("mature_stx_history", -16, f"wallet has {wallet_age:.0f} days and {tx_total} Stacks transactions"))
if (tx_total in (None, 0) or f.inbound_asset_events == 0) and btc_count == 0 and inbox_total == 0:
signals.append(Signal("no_economic_depth", 14, "no public STX, BTC, or inbox economic activity found"))
elif (f.inbox_received or 0) >= 3 or (f.inbox_sats_net or 0) > 0:
signals.append(Signal("inbox_economic_counter_signal", -14, f"inbox shows {f.inbox_received or 0} received messages and {f.inbox_sats_net or 0} net sats"))
if not f.erc8004_agent_id:
signals.append(Signal("missing_onchain_identity", 6, "agent profile has no ERC-8004 identity id"))
else:
signals.append(Signal("onchain_identity_counter_signal", -10, f"agent has ERC-8004 identity #{f.erc8004_agent_id}"))
if (f.level or 0) >= 2:
signals.append(Signal("genesis_counter_signal", -12, f"agent is Level {f.level} ({f.level_name})"))
same_funder_batch = [
x.stx_address
for x in all_facts
if x is not f and f.first_stx_funder and x.first_stx_funder == f.first_stx_funder
]
if same_funder_batch:
signals.append(Signal("shared_stx_funder_in_batch", 16, f"shares first observed STX/token funder with {len(same_funder_batch)} scored address(es)"))
same_btc_funder_batch = [
x.btc_address
for x in all_facts
if x is not f and f.btc_first_funder and x.btc_first_funder == f.btc_first_funder
]
if same_btc_funder_batch:
signals.append(Signal("shared_btc_funder_in_batch", 12, f"shares first observed BTC funder with {len(same_btc_funder_batch)} scored address(es)"))
for seed in seed_facts:
if f.stx_address and seed.stx_address == f.stx_address:
signals.append(Signal("explicit_seed_member", 30, "address is explicitly present in the supplied sybil seed set"))
break
if f.first_stx_funder and seed.first_stx_funder and f.first_stx_funder == seed.first_stx_funder:
signals.append(Signal("seed_shared_stx_funder", 24, "shares first observed STX/token funder with a seed address"))
break
if f.btc_first_funder and seed.btc_first_funder and f.btc_first_funder == seed.btc_first_funder:
signals.append(Signal("seed_shared_btc_funder", 18, "shares first observed BTC funder with a seed address"))
break
if parse_time(f.verified_at) and parse_time(seed.verified_at):
delta = abs((parse_time(f.verified_at) - parse_time(seed.verified_at)).total_seconds()) # type: ignore[operator]
sim = max(jaccard(f.description or "", seed.description or ""), jaccard(f.display_name or "", seed.display_name or ""))
if delta <= 30 and sim >= 0.45:
signals.append(Signal("seed_registration_template_match", 22, "registered within 30 seconds of a seed with similar profile text"))
break
shared_contracts = set(f.hiro_contracts) & set(seed.hiro_contracts)
if shared_contracts:
signals.append(Signal("seed_contract_overlap", 10, f"shares contract-call surface with seed: {sorted(shared_contracts)[0]}"))
break
if reg_age is not None and reg_age > 14 and tx_total and tx_total > 8 and inbox_total > 0:
signals.append(Signal("older_active_agent_counter_signal", -8, f"registered {reg_age:.0f} days ago with public activity"))
raw = sum(s.points for s in signals)
score = int(max(0, min(100, round(raw))))
if score >= 68:
label = "HIGH_SYBIL_RISK"
elif score >= 38:
label = "MODERATE_RISK"
else:
label = "LIKELY_CLEAN"
return score, label, signals
def facts_public_dict(f: AgentFacts) -> dict[str, Any]:
return {
"address": f.address,
"stx_address": f.stx_address,
"btc_address": f.btc_address,
"display_name": f.display_name,
"owner_present": bool(f.owner),
"verified_at": f.verified_at,
"last_active_at": f.last_active_at,
"level": f.level,
"level_name": f.level_name,
"erc8004_agent_id": f.erc8004_agent_id,
"caip19": f.caip19,
"directory_indexed": f.directory_indexed,
"cohort_size_30s": f.cohort_size_30s,
"cohort_profile_similarity": f.cohort_profile_similarity,
"cohort_samples": f.cohort_samples,
"hiro_tx_total": f.hiro_tx_total,
"hiro_oldest_tx_iso": f.hiro_oldest_tx_iso,
"hiro_contract_count": len(f.hiro_contracts),
"hiro_contracts_sample": f.hiro_contracts[:8],
"first_stx_funder": f.first_stx_funder,
"inbound_asset_events": f.inbound_asset_events,
"inbox_total": f.inbox_total,
"inbox_received": f.inbox_received,
"inbox_sent": f.inbox_sent,
"inbox_sats_net": f.inbox_sats_net,
"btc_tx_count": f.btc_tx_count,
"btc_funded_sats": f.btc_funded_sats,
"btc_first_funder": f.btc_first_funder,
"endpoint_errors": f.endpoint_errors,
}
def largest_cohorts(directory: list[dict[str, Any]], window_seconds: int = 30, topn: int = 8) -> list[dict[str, Any]]:
timed = []
for agent in directory:
ts = parse_time(agent.get("verifiedAt"))
if ts:
timed.append((ts, agent))
timed.sort(key=lambda x: x[0])
out = []
for i, (ts, agent) in enumerate(timed):
cohort = [agent]
j = i + 1
while j < len(timed) and (timed[j][0] - ts).total_seconds() <= window_seconds:
cohort.append(timed[j][1])
j += 1
if len(cohort) >= 3:
descriptions = [x.get("description") or "" for x in cohort]
sims = []
for a in range(len(descriptions)):
for b in range(a + 1, len(descriptions)):
sims.append(jaccard(descriptions[a], descriptions[b]))
out.append(
{
"window_start": ts.isoformat(),
"agent_count": len(cohort),
"avg_description_similarity": round(sum(sims) / len(sims), 3) if sims else 0.0,
"sample_stx_addresses": [x.get("stxAddress") for x in cohort[:6]],
}
)
out.sort(key=lambda x: (x["agent_count"], x["avg_description_similarity"]), reverse=True)
return out[:topn]
def evaluate(results: list[dict[str, Any]], labels: dict[str, str]) -> dict[str, Any] | None:
if not labels:
return None
rows = []
correct = 0
for item in results:
addr = item["address"]
expected = labels.get(addr) or labels.get(item.get("stx_address") or "") or labels.get(item.get("btc_address") or "")
if not expected:
continue
expected_sybil = expected in {"sybil", "high", "high_sybil_risk", "1", "true"}
predicted_sybil = item["score"] >= 68
ok = expected_sybil == predicted_sybil
correct += int(ok)
rows.append({"address": addr, "expected": expected, "predicted": item["label"], "score": item["score"], "correct": ok})
if not rows:
return None
return {"count": len(rows), "correct": correct, "accuracy": round(correct / len(rows), 3), "rows": rows}
def self_test() -> int:
clean = AgentFacts(
address="SPCLEAN",
stx_address="SPCLEAN",
btc_address="bc1clean",
verified_at=(now_utc() - dt.timedelta(days=40)).isoformat(),
level=2,
level_name="Genesis",
erc8004_agent_id=7,
hiro_tx_total=40,
hiro_oldest_tx_iso=(now_utc() - dt.timedelta(days=45)).isoformat(),
inbox_total=5,
inbox_received=4,
inbox_sats_net=300,
)
risky = AgentFacts(
address="SPRISK",
stx_address="SPRISK",
btc_address="bc1risk",
description="Autonomous agent cobalt-otter on AIBTC",
verified_at=now_utc().isoformat(),
cohort_size_30s=6,
cohort_profile_similarity=0.8,
level=1,
hiro_tx_total=0,
inbox_total=0,
btc_tx_count=0,
)
clean_score, clean_label, _ = score_agent(clean, [clean, risky], [])
risky_score, risky_label, _ = score_agent(risky, [clean, risky], [])
if not (clean_score < 38 and clean_label == "LIKELY_CLEAN"):
print(f"self-test failed: clean score={clean_score} label={clean_label}", file=sys.stderr)
return 1
if not (risky_score >= 68 and risky_label == "HIGH_SYBIL_RISK"):
print(f"self-test failed: risky score={risky_score} label={risky_label}", file=sys.stderr)
return 1
print("self-test passed")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Explainable AIBTC/Stx sybil-likelihood scorer.")
parser.add_argument("addresses", nargs="*", help="STX or BTC addresses to score")
parser.add_argument("--address-file", help="file with one address per line")
parser.add_argument("--seed", action="append", default=[], help="known/suspected sybil seed address; repeatable")
parser.add_argument("--seed-file", help="file with one seed address per line")
parser.add_argument("--labeled", help="CSV with address,label columns for private-set evaluation")
parser.add_argument("--demo", action="store_true", help="score public clean controls and a public burst cohort")
parser.add_argument("--pretty", action="store_true", help="pretty-print JSON")
parser.add_argument("--self-test", action="store_true", help="run offline scoring self-test")
args = parser.parse_args(argv)
if args.self_test:
return self_test()
addresses = list(args.addresses) + load_lines(args.address_file)
seeds = list(args.seed) + load_lines(args.seed_file)
labels = load_labeled(args.labeled)
if args.demo:
addresses.extend(DEMO_CLEAN + DEMO_SYBIL_COHORT)
seeds.extend(DEMO_SYBIL_COHORT[:2])
labels.update({addr: "clean" for addr in DEMO_CLEAN})
labels.update({addr: "sybil" for addr in DEMO_SYBIL_COHORT})
addresses = list(dict.fromkeys(a.strip() for a in addresses if a.strip()))
seeds = list(dict.fromkeys(a.strip() for a in seeds if a.strip()))
if not addresses:
parser.error("provide at least one address, --address-file, or --demo")
fetcher = Fetcher()
directory = fetch_directory(fetcher)
by_stx = directory_by_stx(directory)
by_btc = directory_by_btc(directory)
all_facts = [facts_for(fetcher, addr, directory, by_stx, by_btc) for addr in addresses]
seed_facts = [facts_for(fetcher, addr, directory, by_stx, by_btc) for addr in seeds]
results = []
for facts in all_facts:
score, label, signals = score_agent(facts, all_facts, seed_facts)
signal_rows = [{"name": s.name, "points": s.points, "reason": s.reason} for s in signals]
positives = sorted([s for s in signals if s.points > 0], key=lambda s: s.points, reverse=True)
top = positives[:3] if positives else sorted(signals, key=lambda s: abs(s.points), reverse=True)[:3]
row = facts_public_dict(facts)
row.update(
{
"score": score,
"label": label,
"top_signals": [{"name": s.name, "points": s.points, "reason": s.reason} for s in top],
"all_signals": signal_rows,
"note": "Heuristic triage only: high score is a review flag, not proof of shared control.",
}
)
results.append(row)
output = {
"tool": "aibtc-sybil-cluster-lens",
"version": VERSION,
"generated_at": now_utc().isoformat(),
"inputs": {"addresses": addresses, "seed_count": len(seeds)},
"directory": {
"agents_loaded": len(directory),
"largest_registration_cohorts_30s": largest_cohorts(directory),
},
"results": results,
"label_evaluation": evaluate(results, labels),
"trust_model": [
"Uses public AIBTC, Hiro, and mempool.space APIs as read-only mirrors.",
"Scores are explainable heuristics for bounty triage, not cryptographic proof.",
"Endpoint failures are preserved per address so reviewers can rerun or substitute their own indexers.",
],
"cost": {"sats": 0, "paid_apis": 0, "onchain_transactions": 0},
}
print(json.dumps(output, indent=2 if args.pretty else None, sort_keys=bool(args.pretty)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment