Skip to content

Latest commit

 

History

History
81 lines (57 loc) · 5.99 KB

File metadata and controls

81 lines (57 loc) · 5.99 KB

OnchainMetric verification (reason 4) — design

Status: approved 2026-06-12 · Scope: ClaimVerifier (GenLayer) + web reason gate · Goal: the 6th and final reason goes live.

Context & binding constraint

_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.

Decisions (made 2026-06-12)

  1. Claim format: free text + LLM extraction (same UX as all other reasons). The contract address travels in the claim or sources.
  2. 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.
  3. Not-crossed-yet: terminal FALSE (claim asserts something IS true; submitting early is user error). The reject reason includes the actual read value.

Architecture

_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)

Extraction contract (step 1)

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.

Chain read (step 2)

  • 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_getBalance instead of eth_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.

Verdict mapping (unchanged loop semantics)

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

Web tier (step 3)

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.

Web app change

reasons.ts: onchain_metriclive: 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

  1. Deploy new ClaimVerifier to studionet (constructor: bridge_receiver, bridge_sender, claim_launcher — unchanged).
  2. set_phase2(true) + verify config.
  3. Base: launcher.setClaimVerifier(newVerifier) (owner) — no launcher redeploy, relay config untouched.
  4. Record in packages/contracts-evm/deployments/base-sepolia.json (prev → claimVerifier_prev3).

Testing (no mocks — real RPC, real loop)

  1. 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.
  2. 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.
  3. Web tier smoke: a holders-style claim routes through _judge_claim and returns a sane label.

Out of scope (v1)

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).