TruthBeacon is a GenLayer Intelligent Contract for decentralized fact-checking. Anyone submits a claim together with candidate source URLs; GenLayer's validators independently fetch and judge each source, and reach Optimistic Democracy consensus on one deterministic final verdict, stored permanently on-chain with a full, auditable evidence trail.
This is a from-scratch redesign of a previously rejected version of this contract (see Reviewer Feedback Addressed below), and has since been deployed and tested live on GenLayer Studio (see Live Deployment).
v2.8 update: this Accepted submission's steward left a specific improvement suggestion — a stronger source-authority and freshness policy (see Source-Authority & Freshness Policy (v2.8) below). That mechanism is implemented, 111/111 offline-tested, and was deployed and exercised live on GenLayer Studio — see CHANGELOG.md § v2.8 for both transactions.
v2.9 update: the v2.8 submission's steward left a further suggestion — "a governance-controlled authority or credibility registry so deployments can evolve source policy without relying on the small built-in list." That mechanism is implemented (see Governance-Controlled Credibility Registry (v2.9) below), 146/146 offline-tested, and deployed and exercised live on GenLayer Studio at a new address — see Live Deployment below. (The first deployment attempt hit a real bug,
Addressdouble-wrapping in_require_governor, caught by that live transaction rather than the offline suite; it's fixed, the offline stub hardened so this bug class is now catchable offline too, and the corrected redeployment below is what's actually live — see CHANGELOG.md § v2.9.1 for the full story.)
This contract does not determine absolute truth. It deterministically evaluates whether multiple independent sources corroborate or refute a claim under GenLayer consensus rules. A
Verifiedverdict means "enough independent, credible, reachable sources agreed" — not an infallible statement of objective fact.
| Document | What's in it |
|---|---|
| README.md (this file) | Quick start, interface reference, aggregation rule, reviewer feedback mapping |
| ARCHITECTURE.md | Component diagram, execution flow, storage/consensus model, why prompt_comparative |
| SECURITY.md | Full threat model — prompt injection, fake news, Sybil domains, fetch failures, known limitations |
| DESIGN_DECISIONS.md | Every design choice: problem → solution → alternative considered → trade-offs |
| TESTING.md | Offline tests, the unexecuted integration example, and live deployment evidence tiers |
| CHANGELOG.md | Full version history from the v1 rejection through every review round |
| ROADMAP.md | What's intentionally out of scope, and why, for each future direction |
| CONTRIBUTING.md | How to contribute, and what not to change casually |
| REVIEWER_GUIDE.md | Where every claim in this repo can be independently verified |
| PROJECT_OVERVIEW.md | 5-minute executive summary |
| SUBMISSION_CHECKLIST.md | Pre-submission checklist |
| RELEASE_NOTES_v2.md | What v2 achieved, summarized |
| tests/README.md | Test-file-by-test-file coverage index |
Current live address (v2.9.1 — governance-controlled credibility registry, bug-fixed): 0x2Db6a24f519611155046A72355752cF869A067Bb
Public explorer (all transactions): https://explorer-studio.genlayer.com/address/0x2Db6a24f519611155046A72355752cF869A067Bb
Deployed with a single governor (0xBD767E1958928ff1CAaE1218dbCb4b2f2ada35F6, quorum = 1) via deploy tx 0xe27e4fc363758b07e76862c3f97df9bd6e9c7765828d80e3d2a40e5e6b6e42ec (FINALIZED). This is a redeployment of the corrected source — an earlier deployment at 0x859dF1aEAC8C45F36B72C22535B9f83139bf49c8 hit a real bug (Address double-wrapping in _require_governor) on its first live governance call; that bug is fixed in this deployment's source, and the fix is itself verified live below, not just offline. See CHANGELOG.md § v2.9.1 for the full story.
Transaction A — propose_registry_change, the corrected mechanism working live (tx 0x6f20f47acfa5aa06b90a0f4c83a3b865bdad7debe0ea90f5c4b9a2aefa59ae01, FINALIZED, SUCCESS): propose_registry_change("scam-example-test.example", "add"), called from the governor address. Result: Output: "0" — a proposal_id, returned without error (unlike the pre-fix deployment, which raised TypeError: cannot convert 'Address' object to bytes at this exact call). Since quorum is 1, the proposal finalized atomically in this same transaction.
Confirmation — get_registry_status: a follow-up read call for "scam-example-test.example" returned "low_credibility", confirming the proposal genuinely mutated on-chain state, not just that the call didn't error.
Proves: the entire governance mechanism — authorization via gl.message.sender_address, proposal creation, quorum-based auto-finalization, and the resulting credibility_registry mutation — works correctly end-to-end on real GenVM infrastructure, and the v2.9.1 bug fix specifically resolved the exact failure mode observed on the prior deployment.
Prior live address (v2.8 — source-authority + freshness policy): 0x93F0F657a008FC99a41149E444AA37a604A14580
Public explorer (all transactions): https://explorer-studio.genlayer.com/address/0x93F0F657a008FC99a41149E444AA37a604A14580
This is the v2.8 redeployment (see CHANGELOG.md) — a new address, since GenLayer Intelligent Contract source changes require redeployment. Both v2.8 mechanisms have been exercised live, not just offline-tested:
Transaction 1 — expected_domains source-authority policy (tx 0x1891eb4645f426774c0301e3e9c7069d6fc253747381ed7672d7ef710afb5296, FINALIZED): submit_claim("The Eiffel Tower is located in Paris, France.", [wikipedia.org, britannica.com, history.com URLs], expected_domains=["wikipedia.org", "britannica.com"]). Result (claim_id "0"): history.com was fetched and judged Supported exactly like the other sources, but — because it was not in the declared expected_domains — it was correctly flagged is_authorized_domain: false and excluded from corroboration (unauthorized_domain_count: 1). britannica.com independently failed to fetch (fetch_status: inaccessible, a real network failure, correctly recorded rather than silently dropped). With only 1 authorized, reachable, credible source left (independent_domain_count: 1), the contract correctly returned final_verdict: InsufficientEvidence rather than letting the unauthorized source pad the count.
Transaction 2 — freshness / staleness signal (tx 0x760cdaa2fefec430d5b2896643b546255d500c9028c9bad01d759e4826e98a54, FINALIZED): submit_claim("The James Webb Space Telescope was launched in December 2021.", [a live Wikipedia article, a NASA page, and a 2018 Wayback Machine snapshot of the pre-launch Wikipedia article], expected_domains=[]). Result (claim_id "1"): the live Wikipedia source was judged Supported / freshness: "Current". The 2018 archived snapshot — describing JWST as a future, not-yet-launched project — was correctly judged freshness: "Stale", set is_stale: true, and excluded from corroboration (stale_source_count: 1), independently of the fact that its verdict also came back NotSupported. The NASA page failed to fetch (inaccessible, a real network failure). With only 1 eligible source, the result was again the conservative final_verdict: InsufficientEvidence.
Both transactions reached multi-validator consensus (ACCEPTED → FINALIZED) via gl.eq_principle.prompt_comparative, not exact string matching — transaction 2 in particular shows the comparator in action: several validators' raw outputs were marked Disagree against the leader's proposal before the round finalized, which is expected prompt_comparative behavior (validators judge equivalence under EQUIVALENCE_PRINCIPLE, not byte-identical output) and not an error.
The prior (pre-v2.8) deployment at 0xE30A0F67Da4a3F58F2E31C82dfbc50e8B8F588A5 remains as historical evidence for the base fetch/aggregate/consensus pipeline (clean Verified result, duplicate-domain detection, unanimous rejection, etc.) — full detail in REVIEWER_GUIDE.md and TESTING.md § Tier 3. Those transactions are not re-cited as v2.8 or v2.9 evidence; they remain valid only for the base pipeline they demonstrated.
"The contract checks whether one caller-selected page supports a claim, but it cannot establish that the source or claim is trustworthy. Add provenance checks or independent corroboration, retain auditable evidence, and test failure and adversarial-source cases."
| Reviewer comment | How this version addresses it |
|---|---|
| Checks only one caller-selected page | submit_claim requires 3–6 candidate URLs (MIN_SOURCES_SUBMITTED/MAX_SOURCES_SUBMITTED). A single-URL submission is not a valid call. |
| Cannot establish source/claim trustworthiness | Every source is scored independently by an LLM (Supported/NotSupported/Unclear); a fixed, on-chain denylist (LOW_CREDIBILITY_DOMAINS) flags known low-credibility domains, recorded but excluded from corroboration. |
| Add independent corroboration | Verified/Refuted require at least 2 distinct-domain, successfully-fetched, non-denylisted sources to agree, with agreement strictly outnumbering disagreement. See Aggregation Logic below. |
| Add provenance checks | _annotate_sources computes domain, validity, duplicate status, and denylist status for every URL before any network access, and this metadata is persisted on-chain in full. |
| Detect duplicate domains | Domains are reduced to an approximate registrable form (_registrable_domain), so news.example.com, www.example.com, and mirror.example.com are recognized as the same source. Duplicates are recorded but excluded from corroboration. Verified live — see REVIEWER_GUIDE.md. |
| Retain auditable evidence | get_claim(claim_id) returns the full record: claim text, final verdict, corroboration stats, and a per-source array with URL, domain, provenance flags, fetch status, and verdict for every submitted URL — nothing discarded. |
| Test failure cases | Fetch failures are explicitly classified (timeout/inaccessible/empty/malformed), never silently treated as evidence. See TESTING.md. |
| Test adversarial-source cases | Fake-news denylisting, duplicate/Sybil domains, conflicting sources, prompt injection (both claim text and source content), quoted/opinion/speculative content, and garbage pages are all covered. Full mapping in SECURITY.md. |
For the complete history of how this was reached — including two critical self-review rounds and an SDK compatibility audit — see CHANGELOG.md.
Steward's review note on the Accepted submission: "TruthBeacon is a substantive reusable fact-checking contract... A valuable next improvement would be a stronger source-authority and freshness policy so distinct domains provide more assurance of genuine independent corroboration."
| Suggestion | How this version addresses it | Verified by |
|---|---|---|
| Source-authority policy locked at submission time, not resolution time | New optional expected_domains: list[str] parameter on submit_claim. When provided, it's normalized and validated before any source is fetched, and only submitted sources whose domain is a member of that pre-declared, submission-time-locked set are eligible to count toward corroboration (is_authorized_domain in _aggregate). Every source is still fetched and recorded either way — nothing is silently dropped. Left empty (the default), behavior is identical to pre-v2.8: every domain is authorized. |
test_expected_domains_restricts_corroboration_to_declared_set, test_expected_domains_accepts_full_urls_as_entries, test_omitting_expected_domains_is_fully_backward_compatible, test_expected_domains_with_no_matching_sources_is_rejected_upfront (all in test_end_to_end.py); test_unauthorized_domain_not_counted_as_corroboration, test_missing_authorized_key_defaults_to_authorized (in test_aggregation.py) |
Freshness signal, gating eligibility like is_duplicate_domain/is_low_credibility |
The per-source LLM prompt now asks for a second, independent line: a Current/Stale/Undated freshness judgment for the fetched content relative to the claim. _aggregate excludes any source flagged is_stale (i.e. not Current) from corroboration, exactly the same way it already excludes duplicates and denylisted domains. |
test_stale_source_excluded_from_corroboration_end_to_end, test_undated_source_excluded_from_corroboration_end_to_end, test_failed_fetch_freshness_is_not_applicable (in test_end_to_end.py); test_stale_source_not_counted_as_corroboration, test_undated_source_excluded_same_as_stale, test_two_fresh_supports_still_verify_with_a_stale_third, test_missing_freshness_key_defaults_to_not_stale (in test_aggregation.py); test_contains_freshness_guardrail (in test_prompt_and_consensus.py) |
Backward compatibility: both new eligibility flags (is_stale, is_authorized_domain) are read in _aggregate with .get(key, safe_default) rather than direct indexing, so any pre-existing caller or hand-built record — including every test written before v2.8 — behaves exactly as it did before. submit_claim without expected_domains is unchanged in every other respect.
Verified live on GenLayer Studio (see Live Deployment above for both transactions, addresses, and full result breakdowns) — no longer offline-only.
Steward's review note on the Accepted v2.8 submission: "TruthBeacon provides a substantial reusable fact-checking primitive... A useful next refinement would be a governance-controlled authority or credibility registry so deployments can evolve source policy without relying on the small built-in list."
| Suggestion | How this version addresses it | Verified by |
|---|---|---|
| Governance-controlled credibility registry, evolvable without redeployment | New credibility_registry: TreeMap[str, str], an on-chain, mutable extension of the existing LOW_CREDIBILITY_DOMAINS baseline. A deploy-time-fixed committee of governors can propose (propose_registry_change) and vote (vote_on_proposal) to add or remove domains, with changes taking effect immediately once a strict-majority quorum is reached — no contract redeployment needed to evolve the denylist going forward. |
test_single_governor_deployment_auto_finalizes_on_propose, test_second_vote_reaches_quorum_and_finalizes, test_governance_added_domain_flagged_in_annotate_sources, test_governance_added_domain_excluded_from_corroboration_end_to_end (in test_governance.py and test_end_to_end.py) |
| The built-in list shouldn't itself become a single point of failure/control | Governance can only ever extend the denylist, never weaken the permanent LOW_CREDIBILITY_DOMAINS baseline — propose_registry_change explicitly rejects any attempt to add or remove a baseline domain, so even a fully compromised governor committee can't un-flag theonion.com or similar. |
test_cannot_add_baseline_domain, test_cannot_remove_baseline_domain (in test_governance.py) |
| Not a single admin's unilateral decision | Every registry change requires a strict-majority vote among the deploy-time-fixed governor set (quorum = floor(n/2) + 1), computed automatically and not caller-configurable. A single-governor deployment is still supported (quorum of 1) as a reasonable, simple starting point — but the same mechanism scales to a multi-governor committee without any code change. |
test_three_governors_has_quorum_two, test_two_governors_has_quorum_two, test_non_governor_cannot_propose, test_non_governor_cannot_vote (in test_governance.py) |
Backward compatibility: _is_low_credibility(domain) — the single function both _annotate_sources and the persisted is_low_credibility flag now flow through — returns exactly what the old domain in self.LOW_CREDIBILITY_DOMAINS check did for any domain with no governance history. _aggregate itself required zero changes. Every pre-v2.9 offline test (all 111) passes completely unchanged.
Not backward compatible at the deploy layer, by necessity: the constructor now requires governors: list[Address] (previously took no arguments) — governance needs to know who's authorized from the moment the contract exists, so this genuinely requires a new deployment, exactly like every prior version bump. See Live Deployment above for the address and live transaction evidence.
Contract deployment: TruthBeacon(governors=[...]) # v2.9
│ deploy-time-fixed governor committee, quorum = floor(n/2)+1
│
submit_claim(claim_text, source_urls, expected_domains=[])
│
├─ 1. Deterministic input validation (cheap, fails fast, no gl.* calls)
│ claim_text ≤ MAX_CLAIM_TEXT_CHARS · 3 ≤ len(source_urls) ≤ 6
│ ≥ 2 distinct, non-denylisted domains among submitted URLs
│ expected_domains normalized + validated (v2.8, optional)
│
├─ 2. Deterministic provenance annotation (_annotate_sources)
│ registrable domain per URL · duplicate flag
│ denylist flag, now via _is_low_credibility (v2.9: baseline
│ OR governance-added) · is_authorized_domain (v2.8)
│
├─ 3. ONE non-deterministic closure (gl.eq_principle.prompt_comparative)
│ per source: fetch → classify → LLM judge → fixed-vocabulary
│ verdict + freshness (v2.8) → deterministic aggregation
│ (gated on staleness + authorization + credibility) → one
│ final verdict + stats
│
└─ 4. Persist claim + verdict + full evidence trail to on-chain storage
separately: propose_registry_change / vote_on_proposal (v2.9) # governor-only
│ pure deterministic state logic - no gl.nondet.*/eq_principle.*
│ needed, same as ordinary state-changing writes
└─ mutates credibility_registry once quorum is reached
Full component diagrams, sequence diagrams, and the "why prompt_comparative not strict_eq" rationale: ARCHITECTURE.md.
_aggregate only considers eligible records: fetch_status == "ok", not a duplicate domain, not low-credibility (baseline OR governance-flagged, v2.9), not stale/undated (v2.8), and authorized under the claim's declared source-authority policy if one was set (v2.8). Let support/oppose be the count of eligible Supported/NotSupported verdicts, and independent_total be the total eligible count.
| Final verdict | Exact condition |
|---|---|
| InsufficientEvidence | independent_total < 2 — not enough independent, credible, fresh, authorized, reachable sources to say anything |
| Verified | support >= 2 and support > oppose |
| Refuted | oppose >= 2 and oppose > support |
| Disputed | Neither above, but support > 0 and oppose > 0 (a tie or near-tie) |
| Unverified | Everything else — enough sources exist but they're inconclusive |
Tested nuance: Verified/Refuted require a strict majority, not unanimity — a 2-vs-1 split is still Verified, not Disputed (test_majority_with_dissent_still_verifies in tests/test_aggregation.py). Full rationale in DESIGN_DECISIONS.md § 6.
# Constructor (v2.9)
TruthBeacon(governors: list[Address]) # at least 1 required; duplicates deduped
# Fact-checking (unchanged public signature since v2.8)
submit_claim(claim_text: str, source_urls: list[str], expected_domains: list[str] = []) -> str # returns claim_id
get_claim(claim_id: str) -> str # full JSON evidence record
get_verdict(claim_id: str) -> str # just the final verdict word
total_claims() -> int
# Governance (new in v2.9)
propose_registry_change(domain: str, action: str) -> str # governor-only; action = "add" | "remove"; returns proposal_id
vote_on_proposal(proposal_id: str) -> str # governor-only; returns "pending" or "finalized"
get_proposal(proposal_id: str) -> str # full JSON proposal record
total_proposals() -> int
get_registry_status(domain: str) -> str # "low_credibility" | "not_flagged"
is_governor(address: str) -> bool
total_governors() -> intexpected_domains (v2.8, optional): a bare domain ("reuters.com") or full URL per entry; see Source-Authority & Freshness Policy (v2.8) above.
governors (v2.9, required at deploy time): a list of addresses forming the credibility-registry governance committee; see Governance-Controlled Credibility Registry (v2.9) above.
Example get_claim result:
{
"claim_id": "0",
"claim_text": "The Eiffel Tower is located in Paris, France.",
"final_verdict": "Verified",
"total_sources_submitted": 3,
"independent_domain_count": 3,
"duplicate_domain_count": 0,
"failed_source_count": 0,
"stale_source_count": 0,
"unauthorized_domain_count": 0,
"expected_domains": [],
"sources": [
{
"url": "https://reuters.example/eiffel-tower",
"domain": "reuters.example",
"is_duplicate_domain": false,
"is_low_credibility": false,
"is_authorized_domain": true,
"fetch_status": "ok",
"verdict": "Supported",
"freshness": "Current",
"is_stale": false
}
]
}As of this revision, domain is the approximate registrable domain (see DESIGN_DECISIONS.md § 8), not necessarily the exact fetched hostname — the exact URL is always available in the url field. stale_source_count, unauthorized_domain_count, expected_domains, is_authorized_domain, freshness, and is_stale are from v2.8; is_low_credibility now reflects both the permanent baseline AND any governance-added registry entries (v2.9, see _is_low_credibility) but its field name and position in the schema are unchanged.
Prompt injection (both via fetched content and via the claim text itself), fake-news domains, Sybil-style duplicate-domain stuffing, fetch failures, weak/speculative evidence, and governance-mechanism abuse (v2.9) are all explicitly mitigated with tests. Full threat model, evidence, and residual risks: SECURITY.md.
No full Public Suffix List, no cross-domain content-similarity detection, no spam/staking defense, consensus reliability that inherently scales with source count, and a governance committee that is deploy-time-fixed (no on-chain mechanism yet to add/remove governors themselves without redeployment, v2.9). Every limitation is disclosed with its specific trade-off reasoning, not hidden: SECURITY.md § 8 and DESIGN_DECISIONS.md. What a future version could do about each: ROADMAP.md.
146/146 offline tests passing, organized into 9 files by function under test:
python3 -m unittest discover -s tests -p "test_*.py" -v| File | Tests | Covers |
|---|---|---|
test_domain_extraction.py |
26 | Registrable-domain extraction, subdomains, IPv6, trailing dots, expected_domains normalization (v2.8) |
test_content_classification.py |
11 | Malformed/empty/ok content detection |
test_aggregation.py |
15 | Final-verdict decision rule, incl. staleness + authorization gating (v2.8) |
test_parser.py |
8 | Raw LLM response → fixed vocabulary |
test_prompt_and_consensus.py |
12 | Prompt guardrails (incl. freshness, v2.8), equivalence principle |
test_input_validation.py |
9 | Pre-fetch validation, gl.vm.UserError |
test_end_to_end.py |
23 | Full pipeline, adversarial scenarios, expected_domains + freshness (v2.8), governance-added registry gating (v2.9) |
test_governance.py |
34 | Constructor validation, proposal/voting lifecycle, baseline protection, _is_low_credibility integration (v2.9); incl. a regression test for a real live-caught Address-double-wrapping bug (v2.9.1, see CHANGELOG.md) |
test_storage.py |
8 | On-chain persistence, multi-claim isolation |
Plus an unexecuted gltest integration example (tests/gltest_integration_example.py — explicitly marked as not-yet-validated) and real live deployment evidence covering the base pipeline, the v2.8 source-authority/freshness policy, and (v2.9.1) the governance registry's core add/finalize path (see Live Deployment above). Full three-tier explanation: TESTING.md.
Single-file deployment, same as any GenLayer Intelligent Contract — deploy contract.py via the GenLayer Studio "Create New Contract" UI. As of v2.9, the constructor requires one argument: governors, a JSON array of at least one address (e.g. ["0xYourAddressHere"]) — the initial credibility-registry governance committee. Pick this deliberately; while more governors can be represented by redeploying, there is currently no way to add/remove individual governors from an already-deployed contract without a new deployment (see SECURITY.md and ROADMAP.md).
Both the v2.9.1 and v2.8 addresses are deployed and live — see Live Deployment above for both, including live governance-registry transaction evidence for v2.9.1.
truthbeacon/
├── contract.py # the Intelligent Contract (single deployable file)
├── README.md # this file
├── ARCHITECTURE.md
├── SECURITY.md
├── DESIGN_DECISIONS.md
├── TESTING.md
├── CONTRIBUTING.md
├── CHANGELOG.md
├── ROADMAP.md
├── REVIEWER_GUIDE.md
├── PROJECT_OVERVIEW.md
├── SUBMISSION_CHECKLIST.md
├── RELEASE_NOTES_v2.md
└── tests/
├── README.md # test coverage index
├── _bootstrap.py # shared offline-stub wiring
├── genlayer_stub/ # minimal offline genlayer SDK stub
├── test_domain_extraction.py
├── test_content_classification.py
├── test_aggregation.py
├── test_parser.py
├── test_prompt_and_consensus.py
├── test_input_validation.py
├── test_end_to_end.py
├── test_governance.py
├── test_storage.py
└── gltest_integration_example.py # unexecuted, see TESTING.md