A standalone GenLayer Intelligent Contract that reaches decentralized validator consensus on whether a described credential is listed on a pre-registered public issuer page, then lets that VERIFIED status decay over time (FRESH -> STALE -> EXPIRED) unless it is renewed by a fresh consensus round. Anyone can trigger a re-check; a credential the issuer no longer lists becomes REVOKED, permanently.
| Network | GenLayer Studio network (studionet, Chain ID 61999, RPC https://studio.genlayer.com/api) |
| Contract | 0x3Af7cF11A402c4c8c23974c18a057183ca8b6735 |
| Deployer | 0x6775E11e08FB78b5Ae7B0EE40E5ADa037775a171 |
| Deploy tx | 0xa06080e50de8715c922594cdf345ca8e1ba6a206db5001eef022db0b70574c80 - FINALIZED, execution SUCCESS |
| Purpose | Method | Tx hash | Validator agreement | On-chain result |
|---|---|---|---|---|
| Register positive claim | register_claim(...) |
0x80706588d55e731f312bb34aea2f75d1fc609aa85491074978d180d234b6711b |
deterministic write | claim "0" REGISTERED |
| Register negative claim | register_claim(...) |
0xf1335b2b7b44e45c1ad9fe9123f006af6832a3af16d25716552bb410ba6b6af4 |
deterministic write | claim "1" REGISTERED |
| Positive: real RFC on issuer page | verify_claim("0") |
0xa3f3c7d93844ec97a6a60ced003f23ca1913f0fd1a5d0594cbc1bf2753da548d |
3/5 AGREE, 0 DISAGREE | status VERIFIED, last_band 2, freshness FRESH, is_verified true |
| Negative: fictitious RFC, same page | verify_claim("1") |
0x736796b3ef97eafb499aa40b377ce836a81a357cde5e17b8a3026c012af9ee65 |
3/5 AGREE, 0 DISAGREE | status REJECTED, last_outcome NOT_LISTED, last_band 2 |
The exact inputs sent on-chain for both demos are committed verbatim in samples/ (same descriptors, same window parameters, same claim ids). Anyone can re-run them against the live contract or a fresh deployment.
Bradbury note. The contract was also deployed twice to the Bradbury testnet (Chain ID 4221) on 2026-07-26/27 - deploy txs
0xcf4f132b1d6ae590c7e9011c7ab477385ceb7e310ba9b214fa78558abf72b4c8and0xd1fbdc38f09c175c3367f4634e35c7120ba86258e984a9c042200ba0e38fb342, bothFINALIZEDwith executionFINISHED_WITH_RETURN, plus tworegister_claimand oneverify_claimthat also finalized successfully. During that window Bradbury wiped GenVM contract state and itsgen_callread path began returning "contract not found" network-wide (chain still producing blocks, balances intact), making contract state unverifiable there. The studionet deployment above was therefore chosen as the verifiable public evidence.
Credentials live on issuer web pages: professional registries, certification directories, membership rosters, publication indexes. Ordinary smart contracts cannot read those pages, and a single off-chain checker becomes the trusted party the whole design was trying to remove. Worse, credential verification is not a one-shot fact: certifications lapse, issuers revoke, pages change. A verification from six months ago proves nothing today.
GenLayer is used for exactly the irreducible part: multiple independent validators fetch the same pre-registered issuer page, run their own LLM evaluation of one natural-language question ("is this credential listed, and in what state?"), and must agree on minimal decision fields before anything is stored. Everything else - status transitions, freshness decay, revocation - is plain deterministic code.
When NOT to use GenLayer: if the issuer offers a signed API or on-chain attestation, use that directly - no consensus needed. If verification is needed only once, at one point in time, by one party that trusts its own scraper, a normal backend is sufficient. This primitive earns its consensus cost only when the answer must be trust-minimized, ongoing, and consumable by other contracts.
register_claim verify_claim / reverify_claim (permissionless, cooldown-limited)
| |
v v
claim stored with immutable leader fetches issuer_url (7000-char cap)
issuer_url + descriptor -> LLM returns ONLY {listed, issuer_state, confidence, reason}
-> each validator independently re-fetches and re-evaluates,
then checks: exact listed match, exact issuer_state match,
equal confidence band, cross-field invariants
|
v (consensus reached)
deterministic derivation in contract code:
band -> outcome -> status transition -> timestamps
|
v
oracle views: is_verified / get_freshness / get_claim
(freshness derived at READ time from chain-pinned tx timestamps)
Claim status (stored): REGISTERED -> VERIFIED | REJECTED, REJECTED -> VERIFIED (issuer page may change), VERIFIED -> VERIFIED (renewal), VERIFIED -> REVOKED (terminal).
Freshness (never stored, derived at read time from last_verified_at): FRESH (age <= fresh_window) -> STALE (age <= expiry_window) -> EXPIRED. Windows are per-claim, bounded (fresh 1h..365d, expiry <= 730d), fixed at registration.
Invariants:
is_verified == (status == VERIFIED and freshness == FRESH).- Every status change derives from a consensus-validated payload;
INCONCLUSIVEoutcomes never change status orlast_verified_at. REVOKEDis terminal and only reachable fromVERIFIED; a re-listed credential must be re-registered as a new claim, keeping revocation history immutable.- Evidence is pre-registered:
issuer_urlanddescriptorare immutable after registration, so challengers can never select evidence per dispute. - No admin role exists anywhere in the contract.
Writes:
register_claim(subject_label, issuer_url, descriptor, fresh_window_secs, expiry_window_secs, recheck_cooldown_secs)- permissionless; duplicate (url, descriptor) pairs rejected.verify_claim(claim_id)- first consensus verification (REGISTERED claims only).reverify_claim(claim_id)- permissionless renewal / revocation challenge, rate-limited by the per-claim cooldown. REVOKED claims reject all writes.
Oracle views (for integrators):
is_verified(claim_id) -> bool- the one-line integration surface: consensus-verified AND still fresh.get_freshness(claim_id) -> str-NONE | FRESH | STALE | EXPIREDfor consumers with lenient policies.get_claim(claim_id) -> str- full JSON decision record (status, outcome, band, timestamps, windows, counters).find_claim(issuer_url, descriptor) -> str- claim id lookup ("" when absent).get_claim_count() -> int,get_registry_version() -> str.
Claim ids are decimal-string sequence numbers ("0", "1", ...). SDK note: use genlayer-js or genlayer-py to pass claim ids; the genlayer CLI coerces numeric strings like "0" to integers, which the contract rejects with a clear UserError (fails closed, no state effect).
The LLM returns four fields only: listed: bool, issuer_state: ACTIVE|REVOKED|NOT_FOUND|UNCLEAR, confidence: 0-100, reason (display-only). Calibration bands are published inside the prompt: 0-39 low, 40-79 medium, 80-100 high, and the prompt states that anything below 80 is treated as inconclusive by the protocol.
Each validator independently re-fetches the page and re-evaluates, then accepts the leader only if: payload is a well-formed gl.vm.Return, schema is valid (types, enum, range, bool-vs-int guard), cross-field invariants hold (ACTIVE/REVOKED => listed, NOT_FOUND => not listed), listed and issuer_state match exactly, and confidence bands are equal.
Deterministic derivation after consensus - every definitive outcome requires the high band, because a negative outcome can irreversibly revoke:
| listed | issuer_state | band | outcome | effect |
|---|---|---|---|---|
| true | ACTIVE | 2 | VERIFIED | status VERIFIED, freshness reset |
| true | REVOKED | 2 | ISSUER_REVOKED | REJECTED, or REVOKED (terminal) if previously VERIFIED |
| false | NOT_FOUND | 2 | NOT_LISTED | REJECTED, or REVOKED (terminal) if previously VERIFIED |
| any | any | 0-1 | INCONCLUSIVE | no state change, streak incremented |
| any | UNCLEAR | any | INCONCLUSIVE | no state change |
Failure behavior: unreachable/empty pages short-circuit to the canonical UNCLEAR payload without an LLM call; malformed or self-contradictory LLM output is canonicalized to UNCLEAR by the leader and independently rejected by validators via the invariant check; consensus failure aborts the transaction with no state write; the write path re-validates the payload deterministically as defense in depth.
| Field | Source | Stored? | Downstream effect | Validator check | Binding mode | Differential test |
|---|---|---|---|---|---|---|
listed |
LLM | No (feeds derivation) | outcome | exact bool + invariant | exact | true vs false cannot both pass |
issuer_state |
LLM | No (feeds derivation) | outcome | exact enum + invariant | exact | ACTIVE vs REVOKED cannot both pass |
confidence |
LLM | No | band only | band equality | band | 75 vs 85 rejected; 85 vs 95 both pass with byte-identical stored state |
last_band |
derived from consensused confidence | Yes | outcome gating | - | deterministic derive | test_differential_same_band_converges_to_identical_state |
last_outcome |
derived | Yes | status transition | - | pure function | outcome table + 79/80 boundary tests |
status |
derived | Yes | oracle views | - | transition table | lifecycle tests |
last_verified_at |
chain-pinned tx time | Yes | freshness decay | - | deterministic | warp decay tests |
| freshness | derived at read | No | is_verified |
- | pure function | FRESH/STALE/EXPIRED boundary tests |
last_reason |
LLM | Yes (display-only) | none | wording free | display-only | same-decision-different-wording test |
| invariant-violating payload | LLM | - | - | reject | invariant | listed=true+NOT_FOUND rejected though each field is valid |
Raw confidence is never stored: validators only band-check it, so storing the raw integer would let two different numbers pass consensus while producing different state.
- Issuer page content and the registrant-supplied descriptor are both treated as hostile. Evidence is JSON-encoded with
<,>,&escaped to</>/&, wrapped under a SECURITY DIRECTIVE that instructions inside the data cannot override; a test asserts the escaping on the actual prompt string. - URL validation: HTTPS only, credential-in-URL rejected, localhost/private/link-local/reserved IP targets rejected, private DNS suffixes rejected, 512-char cap.
- Page evidence capped at 7000 chars; descriptor 10-600 chars; reason truncated to 300 chars.
- Rate limiting is cooldown-only (10min..30d per claim). There is deliberately no lifetime attempt cap: with permissionless re-verification, a cap would let any caller exhaust attempts and permanently block future renewals, forcing expiry (griefing). A test proves 500 hostile prior attempts cannot stop a later renewal.
- All numeric decision values are integers; no floats anywhere in state or decisions.
pip install -r requirements.txt
genvm-lint check contracts/credential_freshness_registry.py
python -m pytest tests/ -qActual results for this working tree: lint passed (3 checks), 40 tests passed, 0 failed. Coverage: pure-function tables (band boundaries 39/40/79/80, outcome derivation, freshness edges), registration validation, full lifecycle including terminal REVOKED, warp-based decay and renewal, cooldown boundaries, consensus agreement/disagreement, per-field differential binding tests, invariant-violation rejection, error paths (unreachable page, malformed LLM output, UserError/VMError results), prompt-injection escaping, and griefing resistance.
On-chain evidence: the authoritative demo transactions are in the Section 1 table (studionet, real multi-validator consensus, real web fetch of the issuer page, real LLM evaluation). An earlier smoke-test instance on the same network reproduced the same positive/negative outcomes before the final deployment, and the observed INCONCLUSIVE behavior on unfetchable pages (PyPI blocks the GenVM fetcher) exercised the designed fail-closed path with zero state damage.
Lessons from building and shipping this specific contract:
- FINALIZED is not SUCCESS. A transaction can finalize while its GenVM execution errored (we produced exactly this with a mis-typed argument during testing). Always check the leader execution result before treating a receipt as evidence.
- Store the band, never the number. Validators agree on confidence bands, not raw confidences - so the contract stores only the derived band. Storing the raw integer would silently break validator-state binding (85 vs 95 both pass, different state).
- Terminal transitions demand the highest evidence bar. Revocation is irreversible, so NOT_LISTED / ISSUER_REVOKED require the same high band as VERIFIED; confidence 79 changes nothing, 80 is definitive - and there are boundary tests for both sides.
- Never put a lifetime cap on a permissionless path. An attempt cap looked like prudent rate limiting and was actually a griefing vector (exhaust the cap, force expiry). Cooldown limits rate; caps create permanent denial.
- Evidence is hostile and must be capped. Web pages can inject instructions, so evidence is escaped, boxed under a security directive, and truncated - and unreachable pages resolve to a canonical inconclusive payload without ever calling the LLM.
- Time belongs to the VM, not the caller. Freshness decay derives from GenVM-pinned transaction timestamps and is computed at read time; the contract never trusts caller-supplied clocks and never stores a freshness label that could go stale.
The oracle surface is designed for other contracts (see examples/credential_gate_consumer.py for a working pattern using @gl.contract_interface):
- DAO / role gating - grant proposal or voting rights only while
is_verified(claim_id)is true; members renew via permissionlessreverify_claimbefore the freshness window lapses. - Escrow / hiring milestones - release payment only when the contractor's professional credential is VERIFIED and FRESH at release time, not merely at contract signing.
- Marketplace trust badges - map
get_freshnessto UI states (FRESH badge, STALE warning, EXPIRED removal) without running any off-chain verification infrastructure.
- Truth source is the registered issuer page as rendered text: if an issuer page is JavaScript-only, paywalled, bot-blocked, or lists the credential beyond the 7000-char evidence cap, claims resolve INCONCLUSIVE (fail-closed, never a false verdict).
- The registry does not prove the identity of the claim registrant - it proves what the issuer page says about the described credential. Identity binding belongs to the consumer contract.
- GenLayer test networks may reset state periodically (Bradbury did exactly this during this project's deployment window - see the note in Section 1); the samples/ inputs allow anyone to reproduce the demos against a fresh deployment on any GenLayer network.
- One evidence URL per claim by design (pre-registered, immutable); multi-source aggregation is a different primitive.
contracts/credential_freshness_registry.py # the contract (single Contract class)
tests/test_credential_freshness_registry.py # 40 direct tests (mocked LLM/web + warp time)
samples/positive_rfc2606.json # exact on-chain input of the positive demo
samples/negative_rfc99999.json # exact on-chain input of the negative demo
examples/credential_gate_consumer.py # integration example consuming the oracle views
docs/consensus.md # consensus design specification
README.md # this document
gltest.config.yaml # test runner configuration
requirements.txt # pinned toolchain
LICENSE # MIT
.gitignore # cache/artifact exclusions
MIT - see LICENSE.