Status: approved 2026-06-12 · Scope: ClaimVerifier (GenLayer) + web reason gate · Goal: the 6th and final reason goes live.
_decide currently returns FAILED_TRANSIENT for reason 4 ("Objective reasons not yet implemented"). The intended native path is broken upstream (docs/GENLAYER-FEEDBACK.md): the gl.evm external-contract view path fails in the runner (EthCall), and EthCall has no chain selector, so even a working call couldn't target Base Sepolia specifically.
Workaround (this design): validators read chain state over HTTP JSON-RPC — eth_call via gl.nondet.web.post to an allowlisted RPC. This is the same nondet-web pattern the search proxy proved, keeps the same trust model (each validator reads independently; consensus compares verdicts), and swaps back to native gl.evm mechanically when the runner is fixed.
- Claim format: free text + LLM extraction (same UX as all other reasons). The contract address travels in the claim or
sources. - Metric scope: two tiers. Chain-readable metrics settle by real
eth_call; indexer-shaped metrics (holders, TVL, rankings) route to the existing subjective web pipeline with an onchain hint. - Not-crossed-yet: terminal
FALSE(claim asserts something IS true; submitting early is user error). The reject reason includes the actual read value.
_decide(reason=4) → _decide_onchain_metric(...)
├─ claimId guard (same _expected_claim_id check as the other reasons)
└─ nondet gen():
1. LLM extracts spec from claim+sources:
{tier: "chain"|"web", contract, function_sig, args, comparator, threshold}
2. tier "chain":
calldata = selector(function_sig) + abi_encode(args) # in-contract helpers
for rpc in CHAIN_RPCS["base-sepolia"]: # Tenderly, sepolia.base.org
eth_call via gl.nondet.web.post (JSON-RPC, body as JSON string)
value = decode uint256 → VERDICT = comparator(value, threshold)
3. tier "web": delegate to _judge_claim(claim, sources, ONCHAIN_HINT)
equivalence: gl.eq_principle.prompt_comparative on the VERDICT label
(consistent with _judge_claim; robust to validators reading ±1 block)
LLM prompt returns strict JSON. Tier is "chain" only when ALL hold: a 0x40-hex contract address is present; the metric maps to a uint-returning view with zero args or one address arg (totalSupply(), balanceOf(address), or an explicitly named custom view); the claim states a numeric threshold and direction. Otherwise "web". Decimals: the LLM never does magnitude arithmetic. It extracts the human-scale number ("1M" → 1000000) plus one boolean, token_denominated. When true, the contract makes one extra deterministic eth_call to decimals() on the same token and scales threshold × 10^decimals in code. If decimals() reverts while token_denominated is true (not an ERC-20), the spec is ambiguous → tier "web". Raw-count metrics (counters, IDs, block numbers) skip scaling.
- Selector:
Keccak256(function_sig)[:4]— same primitive already used for_expected_claim_id. - ABI encoding: zero-arg or single-address arg (left-padded 32 bytes). Nothing more in v1 (YAGNI).
- Native ETH balance claims use
eth_getBalanceinstead ofeth_call. - RPC allowlist is a module-level dict
{"base-sepolia": [TENDERLY, BASE_ORG]}; claims about other chains →UNVERIFIABLE(terminal reject) in v1. New spokes are one entry. - First RPC returning a well-formed result wins; malformed/non-200 falls through; all unreachable →
NOT_YET.
| Outcome | Status | On Base |
|---|---|---|
| comparator satisfied | VERIFIED + identity from claim text (_claim_identity, overrides win) |
token mints |
| not satisfied | REJECTED_EXTERNAL, reason includes read value |
terminal reject |
| no RPC reachable | FAILED_TRANSIENT |
Retryable (≤5) |
| no address / unparseable / other-chain | REJECTED_EXTERNAL (unverifiable) |
terminal reject |
ONCHAIN_HINT added to _SUBJECTIVE_HINTS style: prefer block explorers (basescan.org/sepolia.basescan.org) and official analytics dashboards; the claim may not exceed what the page shows; absolute numbers must appear in evidence.
reasons.ts: onchain_metric → live: true. Wizard is already generic (claim + sources + per-reason validation); isLaunchClaimValid already requires a source URL for non-ViralPost reasons, which doubles as the natural place to carry the contract address (an explorer link to the contract satisfies both).
- Deploy new ClaimVerifier to studionet (constructor: bridge_receiver, bridge_sender, claim_launcher — unchanged).
set_phase2(true)+ verify config.- Base:
launcher.setClaimVerifier(newVerifier)(owner) — no launcher redeploy, relay config untouched. - Record in
packages/contracts-evm/deployments/base-sepolia.json(prev →claimVerifier_prev3).
- Direct-mode tests (contracts-genlayer/tests): selector/abi-encode helpers (pure); extraction prompt on fixture claims (chain tier, web tier, garbage); eth_call against the live Base Sepolia RPC reading a real token's
totalSupply. - Live studionet proof pair (the bar every other reason met):
- TRUE: "token 0x…(an existing tokenpost token) on Base Sepolia has totalSupply of at least 1" → must mint.
- FALSE: same contract, absurd threshold (e.g. ≥ 10^40) → must terminally reject with the read value in the reason.
- Web tier smoke: a holders-style claim routes through
_judge_claimand returns a sane label.
Multi-arg view functions, non-uint returns, historical/block-pinned reads, chains beyond Base Sepolia, price-denominated TVL math, native gl.evm (blocked upstream; revisit when the runner fix ships).