Skip to content

Latest commit

 

History

History
580 lines (461 loc) · 26.7 KB

File metadata and controls

580 lines (461 loc) · 26.7 KB

Verified-Claim Phase 2 — Flagship Subjective Verifier — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the launchpad verify whether a claim is actually true (not just "is this post real"): add a RealWorldEvent verifier (reason 1) that takes a free-text claim + sources, has validators independently search the web, and reaches consensus on a TRUE / FALSE / NOT_YET verdict — minting only on TRUE.

Architecture: A self-run search proxy (Supabase edge function, holds the search-API key server-side, exposes a keyless endpoint) gives GenLayer validators real web search via gl.nondet.web.request. A new _decide_realworld handler in ClaimVerifier.py gathers evidence (user sources + independent search), then judges the claim under gl.eq_principle.prompt_comparative so validators agree on the verdict label, not exact wording. Built on the generalized Phase-1 rails — no new contract changes on the Base side.

Tech Stack: Supabase edge function (Deno/TypeScript) + a search provider (Tavily by default), GenLayer Python intelligent contract (genlayer-test direct mode + studionet), Next.js/wagmi web app.

Reference: docs/design/2026-06-03-verified-claim-consensus.md — §2 principles, §6 the subjective pipeline, §9 the search proxy. Phase-1 rails: docs/design/2026-06-03-verified-claim-phase1-plan.md.


Prerequisites (one-time, before Part A)

  • A search-provider API key. Default: Tavily (https://api.tavily.com/search, AI-search, clean JSON, free tier). Swappable behind the proxy. The user provides TAVILY_API_KEY.
  • Supabase Management token already in gitignored .env as SUPABASE_ACCESS_TOKEN (project peeqjjqpomjpdmnpogfs).
  • GenLayer CLI on studionet, account default funded (as in Phase 1).

Key design decisions locked for this plan

  • Verdict labels (from the LLM judge): TRUE, FALSE, UNVERIFIABLE, NOT_YET. Map to the existing verdict status: TRUE→VERIFIED(0), FALSE/UNVERIFIABLE→REJECTED_EXTERNAL(1), NOT_YET→FAILED_TRANSIENT(2). Gate A (reject vague/future/opinion) is folded into the judge as the UNVERIFIABLE label — one consensus call, not two.
  • Consensus mechanism: gl.eq_principle.prompt_comparative. The generation function does the nondet web work (fetch user sources + call the search proxy) and asks the LLM for a strict LABEL|one-sentence-summary. The comparative criteria makes validators agree on the LABEL and that the summary is faithful — tolerating wording differences. If validators can't agree, the principle fails → caught → NOT_YET (honest "no consensus").
  • Evidence: user sources (fetched with gl.nondet.web.get, canonical URL only, no shorteners) PLUS the proxy search results. The user's link is never trusted alone.
  • First-party provenance: handled inside the judge prompt — the LLM is told that an official first-party source can stand alone only if the search corroborates the domain is the entity's official channel.
  • Reason 1 only. ViralPost (reason 0) keeps its own handler unchanged. The other subjective reasons (GitHubRelease, SportsResult) are later plans reusing this pipeline.

File structure

File Responsibility Action
supabase/functions/search/index.ts keyless search proxy over the provider key Create
contracts-genlayer/ClaimVerifier.py _decide_realworld, _judge_claim, search-proxy call, identity Modify
contracts-genlayer/tests/direct/test_claim_verifier.py direct tests for reason-1 verdicts Modify
apps/web/src/lib/reasons.ts flip real_event to live: true Modify
apps/web/src/components/launch/LaunchWizard.tsx claim + sources inputs for non-ViralPost reasons Modify
apps/web/src/lib/launch.ts wizard validation for the general claim flow Modify

No Base-contract changes (the generalized launcher already carries reason, claim, sources).


Part A — Search-proxy spike (GO/NO-GO gate) — ✅ DONE, GO (2026-06-03)

RESULT: GO. The proxy (supabase/functions/search) is deployed; a throwaway spike contract on studionet called it via gl.nondet.web.post and a validator stored 5 real source URLs, with consensus holding (ACCEPTED/AGREE) via prompt_comparative. The spike contract was deleted; the proxy is committed. Verified API: gl.nondet.web.post(url, *, body: str|bytes, headers: dict)body MUST be a JSON string (not a dict), response has .status + .body (bytes). The web.request(method=..., body=...) form also works but body is str/bytes either way.

Purpose (kept for reference): prove a GenLayer validator can reach an external endpoint and get results stable enough for consensus before building the pipeline.

Task 1: Minimal search proxy deployed

Files:

  • Create: supabase/functions/search/index.ts

  • Step 1: Write the minimal proxy

// Keyless search proxy. Validators POST {query}; we call the provider with the server-side key
// and return a compact, normalized result list. Holds TAVILY_API_KEY as a Supabase secret.
Deno.serve(async (req) => {
  if (req.method !== "POST") return new Response("POST only", { status: 405 });
  const { query } = await req.json().catch(() => ({ query: "" }));
  if (!query || typeof query !== "string") return new Response(JSON.stringify({ results: [] }), { status: 400 });

  const key = Deno.env.get("TAVILY_API_KEY");
  if (!key) return new Response(JSON.stringify({ error: "no key" }), { status: 500 });

  const r = await fetch("https://api.tavily.com/search", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ api_key: key, query, max_results: 5, search_depth: "basic" }),
  });
  const data = await r.json().catch(() => ({}));
  // Normalize to a tiny, stable shape (title + url + snippet), capped, so validators converge.
  const results = (data.results ?? []).slice(0, 5).map((x: Record<string, unknown>) => ({
    title: String(x.title ?? "").slice(0, 200),
    url: String(x.url ?? ""),
    snippet: String(x.content ?? "").slice(0, 500),
  }));
  return new Response(JSON.stringify({ query, results }), {
    headers: { "Content-Type": "application/json" },
  });
});
  • Step 2: Set the provider key as a Supabase secret

Run (token + ref from .env):

set -a; source .env; set +a
curl -s -X POST "https://api.supabase.com/v1/projects/$SUPABASE_PROJECT_REF/secrets" \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '[{"name":"TAVILY_API_KEY","value":"<USER_PROVIDED_KEY>"}]' -w "\nHTTP %{http_code}\n"

Expected: HTTP 201.

  • Step 3: Deploy the function

Use the Supabase MCP deploy_edge_function (slug search, the file above), or the Supabase CLI: supabase functions deploy search --project-ref $SUPABASE_PROJECT_REF. Record the function URL: https://<ref>.supabase.co/functions/v1/search.

  • Step 4: Smoke-test the proxy directly (HTTP)

Run:

curl -s -X POST "https://$SUPABASE_PROJECT_REF.supabase.co/functions/v1/search" \
  -H "Content-Type: application/json" -d '{"query":"Anthropic confidential S-1 SEC filing"}' | head -c 600

Expected: JSON { "query": ..., "results": [ {title,url,snippet}, ... ] } with ≥1 result.

Task 2: Validator-reachability spike (the real gate)

Files:

  • Create (throwaway): contracts-genlayer/spike_search.py

  • Step 1: Write a minimal IC that calls the proxy from a nondet block

# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" }
from genlayer import *
import json

PROXY = "https://<ref>.supabase.co/functions/v1/search"

class SpikeSearch(gl.Contract):
    last: str
    def __init__(self): self.last = ""

    @gl.public.write
    def search(self, query: str) -> str:
        def gen() -> str:
            resp = gl.nondet.web.request("POST", PROXY,
                headers={"Content-Type": "application/json"},
                body=json.dumps({"query": query}))
            data = json.loads(resp.body.decode("utf-8"))
            # Return only the result URLs, sorted — a stable surface for consensus.
            urls = sorted([r.get("url", "") for r in data.get("results", [])])
            return json.dumps(urls)
        out = gl.eq_principle.prompt_comparative(
            gen, "Two lists of source URLs are equivalent if they overlap substantially."
        )
        self.last = out.get() if hasattr(out, "get") else out
        return self.last

NOTE: confirm the exact gl.nondet.web.request signature against the write-contract skill before running — load genlayer-dev:write-contract. If the signature differs (arg order, headers kwarg, .body vs .text), adjust here; that is the point of the spike.

  • Step 2: Deploy to studionet and call it

Run:

genlayer network set studionet
genlayer deploy --contract contracts-genlayer/spike_search.py --args
# then, with the deployed address:
genlayer write <ADDR> search --args "Anthropic confidential S-1 SEC filing"
genlayer receipt <tx> --stderr
  • Step 3: GO/NO-GO decision

  • GO if: validators reached consensus (ACCEPTED, no stderr fault) and last holds a non-empty URL list. Delete spike_search.py, proceed to Part B.

  • NO-GO if: web.request is blocked, times out, or validators can't agree on results. STOP. Record the failure mode and revisit §9 (options: SearXNG self-host returning a deterministic top-k; or pass search results in from the relayer instead of in-contract). Do not build Part C.

  • Step 4: Commit the proxy (only after GO)

git add supabase/functions/search/index.ts
git commit -m "feat(search): keyless search proxy for validator web-search (spike GO)"

Part B — Harden the proxy

Task 3: Determinism + safety on the proxy

Files:

  • Modify: supabase/functions/search/index.ts

  • Step 1: Make results stable and bounded (validators converge better on stable output)

Add to the function: sort results by url before returning; lowercase/trim the query server-side; cap snippet to 500 chars (already), cap to 5 results (already); return { query, results } only (no provider-specific fields). Add a RELAY_SECRET-style optional shared header check is not needed (the endpoint is intentionally public/keyless), but add a basic query.length <= 300 guard.

  • Step 2: Redeploy + re-smoke-test

Redeploy via MCP/CLI; re-run the Task 1 Step 4 curl. Expected: results sorted by url, ≤5.

  • Step 3: Commit
git add supabase/functions/search/index.ts
git commit -m "feat(search): stable sorted bounded results for validator consensus"

Part C — The RealWorldEvent verifier

Task 4: _judge_claim — gather evidence + comparative-consensus verdict

Files:

  • Modify: contracts-genlayer/ClaimVerifier.py

  • Test: contracts-genlayer/tests/direct/test_claim_verifier.py

  • Step 1: Write failing direct tests (append; use the harness web/LLM injection)

REAL_CLAIM = "Anthropic confidentially filed a draft S-1 with the SEC"
REAL_SRC = "https://www.anthropic.com/news/sec-filing"

def _rwe(claim=REAL_CLAIM, sources=REAL_SRC, **kw):
    return _request(reason=1, claim=claim, sources=sources, **kw)

def test_rwe_true_claim_mints(direct_vm, direct_deploy, direct_owner):
    c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
    direct_vm.mock_web(r".*anthropic\.com.*", {"status": 200, "body": "Anthropic has confidentially submitted a draft registration statement (S-1) to the SEC."})
    direct_vm.mock_web(r".*functions/v1/search.*", {"status": 200, "body": '{"results":[{"title":"Anthropic files S-1","url":"https://reuters.com/x","snippet":"Anthropic confidentially filed a draft S-1 with the SEC."}]}'})
    direct_vm.mock_llm(r".*VERDICT.*", "TRUE|Anthropic confidentially filed a draft S-1 with the SEC.")
    d = c.decode_verdict(c.preview_verdict(_rwe()))
    assert d["status"] == 0  # VERIFIED
    assert d["name"] != "" and d["symbol"].startswith("$")

def test_rwe_false_claim_rejected(direct_vm, direct_deploy, direct_owner):
    c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
    direct_vm.mock_web(r".*", {"status": 200, "body": "no such event"})
    direct_vm.mock_web(r".*functions/v1/search.*", {"status": 200, "body": '{"results":[]}'})
    direct_vm.mock_llm(r".*VERDICT.*", "FALSE|No credible source supports this claim.")
    d = c.decode_verdict(c.preview_verdict(_rwe(claim="SpaceX landed humans on the moon yesterday")))
    assert d["status"] == 1  # REJECTED_EXTERNAL

def test_rwe_vague_claim_unverifiable(direct_vm, direct_deploy, direct_owner):
    c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
    direct_vm.mock_web(r".*", {"status": 200, "body": ""})
    direct_vm.mock_web(r".*functions/v1/search.*", {"status": 200, "body": '{"results":[]}'})
    direct_vm.mock_llm(r".*VERDICT.*", "UNVERIFIABLE|This is an opinion, not a checkable fact.")
    d = c.decode_verdict(c.preview_verdict(_rwe(claim="This project is going to be huge")))
    assert d["status"] == 1  # REJECTED_EXTERNAL (gate A)

def test_rwe_unreachable_is_not_yet(direct_vm, direct_deploy, direct_owner):
    c = _deploy_phase2(direct_vm, direct_deploy, direct_owner)
    direct_vm.mock_web(r".*", {"status": 503, "body": ""})
    direct_vm.mock_web(r".*functions/v1/search.*", {"status": 503, "body": ""})
    direct_vm.mock_llm(r".*VERDICT.*", "NOT_YET|Sources were unreachable.")
    d = c.decode_verdict(c.preview_verdict(_rwe()))
    assert d["status"] == 2  # FAILED_TRANSIENT
  • Step 2: Run to verify they fail

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -k rwe -v Expected: FAIL — reason 1 currently returns FAILED_TRANSIENT for all.

  • Step 3: Implement the handler in ClaimVerifier.py

Add the proxy URL constant near the top:

SEARCH_PROXY = "https://peeqjjqpomjpdmnpogfs.supabase.co/functions/v1/search"
REASON_REALWORLD = 1

Add the verdict-label map and handler:

def _decide_realworld(self, claim_id: bytes, reason: int, claim: str, sources: str,
                      name_ovr: str, sym_ovr: str, desc_ovr: str):
    # dedup-bypass guard (same generalized formula as ViralPost)
    if claim_id != self._expected_claim_id(reason, _primary(claim, sources)):
        return (REJECTED_EXTERNAL, "", "", "")
    label, summary = self._judge_claim(claim, sources)
    if label == "TRUE":
        identity = _claim_identity(claim, summary)
        name = name_ovr if name_ovr != "" else identity["name"]
        symbol = sym_ovr if sym_ovr != "" else identity["ticker"]
        description = desc_ovr if desc_ovr != "" else summary[:280]
        return (VERIFIED, name, symbol, description)
    if label == "NOT_YET":
        return (FAILED_TRANSIENT, "", "", "")
    return (REJECTED_EXTERNAL, "", "", "")  # FALSE or UNVERIFIABLE

def _judge_claim(self, claim: str, sources: str):
    """Comparative-consensus verdict over {claim, fetched user sources, independent search}."""
    src_urls = [u for u in (sources.split("\n") if sources else []) if u != ""]

    def gen() -> str:
        # 1. fetch user-supplied sources (canonical only)
        evidence = []
        for u in src_urls[:3]:
            try:
                r = gl.nondet.web.get(_canonical(u))
                if int(getattr(r, "status", 200) or 200) == 200:
                    evidence.append("SOURCE " + _canonical(u) + ": " + r.body.decode("utf-8")[:1200])
            except Exception:
                pass
        # 2. independent web search via the proxy
        # VERIFIED API (spike, 2026-06-03): gl.nondet.web.post(url, *, body: str|bytes, headers).
        # `body` MUST be a str/bytes (json.dumps), NOT a dict; response has `.status` and `.body`.
        try:
            sr = gl.nondet.web.post(SEARCH_PROXY,
                body=json.dumps({"query": claim}),
                headers={"Content-Type": "application/json"})
            for item in json.loads(sr.body.decode("utf-8")).get("results", [])[:5]:
                evidence.append("WEB " + str(item.get("url", "")) + ": " + str(item.get("snippet", "")))
        except Exception:
            pass
        bundle = "\n".join(evidence) if evidence else "(no evidence retrieved)"
        prompt = (
            "You verify whether a CLAIM is true using only the EVIDENCE.\n"
            "Reply EXACTLY 'VERDICT=LABEL|<one factual sentence>' where LABEL is one of:\n"
            "TRUE (evidence clearly supports the claim as written),\n"
            "FALSE (evidence contradicts it, or no credible source supports a checkable claim),\n"
            "UNVERIFIABLE (vague, opinion, prediction/future, or not objectively checkable),\n"
            "NOT_YET (evidence sources were unreachable right now).\n"
            "A claim may not exceed its evidence: a teaser is not a confirmation. "
            "An official first-party source counts alone only if the search corroborates the domain "
            "is that entity's official channel. Do not assume; judge only from EVIDENCE.\n\n"
            "CLAIM: " + claim + "\n\nEVIDENCE:\n" + bundle + "\n"
        )
        out = str(gl.nondet.exec_prompt(prompt)).strip()
        return out

    try:
        res = gl.eq_principle.prompt_comparative(
            gen,
            "Equivalent if the VERDICT label (TRUE/FALSE/UNVERIFIABLE/NOT_YET) matches and the "
            "one-sentence reason is consistent with the evidence; ignore wording differences.",
        )
        raw = res.get() if hasattr(res, "get") else res
    except Exception:
        return ("NOT_YET", "")
    return _parse_verdict(str(raw))

Add module-level pure helpers:

def _parse_verdict(raw: str):
    body = raw.split("VERDICT=", 1)[-1].strip() if "VERDICT=" in raw else raw.strip()
    label, _, summary = body.partition("|")
    label = label.strip().upper()
    if label not in ("TRUE", "FALSE", "UNVERIFIABLE", "NOT_YET"):
        label = "NOT_YET"
    return (label, summary.strip())

def _claim_identity(claim: str, summary: str) -> dict:
    base = (summary or claim)
    words = [_clean_title_word(w) for w in base.replace("\n", " ").split(" ")]
    words = [w for w in words if w != "" and w.lower() not in _STOP_WORDS][:3]
    name = " ".join(words) if words else "Verified Claim"
    return {"name": name[:42], "ticker": _ticker_from_name(name)[:8]}

Wire the dispatch (in _decide, add the branch before the transient fallback):

    if reason == REASON_REALWORLD:
        return self._decide_realworld(claim_id, reason, claim, sources, name_ovr, sym_ovr, desc_ovr)
  • Step 4: Run to verify they pass

Run: .venv/bin/pytest contracts-genlayer/tests/direct/test_claim_verifier.py -k rwe -v Expected: PASS (4 tests). Then run the full direct suite: .venv/bin/pytest contracts-genlayer/tests/direct -v → all green (ViralPost untouched).

  • Step 5: Lint + commit

Run: ~/.local/bin/genvm-lint check contracts-genlayer/ClaimVerifier.py → checks pass.

git add contracts-genlayer/ClaimVerifier.py contracts-genlayer/tests/direct/test_claim_verifier.py
git commit -m "feat(verifier): RealWorldEvent (reason 1) — evidence + comparative-consensus verdict"

Part D — Enable the reason in the web app

Task 5: Wizard supports the general claim flow

Files:

  • Modify: apps/web/src/lib/reasons.ts, apps/web/src/components/launch/LaunchWizard.tsx, apps/web/src/lib/launch.ts

  • Step 1: Write/extend the validation test

In apps/web/src/lib/__tests__/launch.test.ts:

it("real_event flow requires a claim and at least one source", () => {
  expect(isLaunchClaimValid({ reason: "real_event", claim: "", sources: "" })).toBe(false);
  expect(isLaunchClaimValid({ reason: "real_event", claim: "Base teased an airdrop", sources: "" })).toBe(false);
  expect(isLaunchClaimValid({ reason: "real_event", claim: "Base teased an airdrop", sources: "https://docs.base.org/x" })).toBe(true);
});
it("viral_post flow still validates the post URL only", () => {
  expect(isLaunchClaimValid({ reason: "viral_post", claim: "https://x.com/a/status/1", sources: "" })).toBe(true);
  expect(isLaunchClaimValid({ reason: "viral_post", claim: "not a url", sources: "" })).toBe(false);
});
  • Step 2: Run to verify it fails

Run: cd apps/web && pnpm vitest run src/lib/__tests__/launch.test.ts Expected: FAIL — isLaunchClaimValid not defined.

  • Step 3: Implement validation + flip the reason live

launch.ts — add a per-reason validator:

export function isLaunchClaimValid(s: { reason: string; claim: string; sources: string }): boolean {
  if (s.reason === "viral_post") return isValidTweetUrl(s.claim);
  // general subjective claim: need a non-trivial claim + at least one http(s) source
  const hasSource = s.sources.split("\n").map((l) => l.trim()).some((l) => /^https?:\/\//i.test(l));
  return s.claim.trim().length >= 8 && hasSource;
}

reasons.ts — flip real_event to live:

{ key: "real_event", label: "Real-world event", blurb: "A public event that actually happened.", live: true },

LaunchWizard.tsx — step 1 renders the post-URL input for viral_post and a claim textarea + sources textarea for other live reasons; canNext for step 1 uses isLaunchClaimValid(s); the claimId query uses s.sources for non-viral reasons. Concretely:

{step === 1 && s.reason === "viral_post" && (/* existing X post URL input bound to s.claim */)}
{step === 1 && s.reason !== "viral_post" && (
  <div className="space-y-3">
    <div className="space-y-2">
      <Label htmlFor="claim">The claim</Label>
      <Textarea id="claim" rows={2} value={s.claim} onChange={(e) => set({ claim: e.target.value })}
        placeholder="e.g. Base announced an airdrop on their official docs" />
    </div>
    <div className="space-y-2">
      <Label htmlFor="sources">Source URLs <span className="text-ink-faint">(one per line)</span></Label>
      <Textarea id="sources" rows={3} value={s.sources} onChange={(e) => set({ sources: e.target.value })}
        placeholder="https://docs.base.org/…" />
    </div>
    <p className="text-xs text-ink-faint">Validators check these AND search the web independently before agreeing it’s true.</p>
  </div>
)}

And replace the step-1 canNext branch with return isLaunchClaimValid(s);.

  • Step 4: Run tests + typecheck

Run: cd apps/web && pnpm vitest run && pnpm tsc --noEmit Expected: all green.

  • Step 5: Commit
git add apps/web/src
git commit -m "feat(web): enable RealWorldEvent flow — claim + sources inputs, per-reason validation"

Part E — Deploy & prove live

Task 6: Redeploy the verifier and re-wire

  • Step 1: Redeploy ClaimVerifier.py (now with the reason-1 handler) to studionet

Run:

genlayer network set studionet
genlayer deploy --contract contracts-genlayer/ClaimVerifier.py \
  --args 0xce87655D60dCa6CA76183DEDc8582766e5DE4e57 \
         0xcfBD25E80e075e7F34E79C7DaCbA2EAbD6Aca22f \
         0xe008003cD89852149e6e28BCAe7d372a71B13e8C

Verify genlayer schema <addr> + genlayer receipt <tx> --stderr. Record the new verifier address.

  • Step 2: Re-point the launcher + flip Phase 2
set -a; source .env; set +a
PK="$BASE_SEPOLIA_PRIVATE_KEY"; case "$PK" in 0x*) ;; *) PK="0x$PK";; esac
cast send 0xe008003cD89852149e6e28BCAe7d372a71B13e8C "setClaimVerifier(address)" <NEW_VERIFIER> \
  --rpc-url https://sepolia.base.org --private-key "$PK"
genlayer write <NEW_VERIFIER> set_phase2 --args true

Verify cast call 0xe008…3e8C "claimVerifier()(address)" == new verifier.

  • Step 3: Record + commit addresses

Update deployments/base-sepolia.json claimLoop.claimVerifier (+ _prev). Commit.

Task 7: Live proof — a true claim mints, a false claim is rejected

  • Step 1: Submit a TRUE claim (reason 1, real claim + real source)
cast send 0xe008003cD89852149e6e28BCAe7d372a71B13e8C \
  "submitClaim(uint8,string,string,string,string,string,uint256)" \
  1 "Anthropic confidentially filed a draft S-1 with the SEC" \
  "https://www.anthropic.com/news" "" "" "" 1000000000000000000 \
  --value 0.02ether --rpc-url https://sepolia.base.org --private-key "$PK"

Record claimId = cast call … "claimIdForClaim(uint8,string,string)(bytes32)" 1 "<claim>" "<source>".

  • Step 2: Watch to mint (~10–15 min; poll claims(claimId) for status 2 and tokenForClaim)

Expected: status Launched, a token whose reason() == "RealWorldEvent" and whose name/description came from the verified claim. This is the flagship working live.

  • Step 3: Submit a FALSE claim and confirm rejection
cast send 0xe008003cD89852149e6e28BCAe7d372a71B13e8C \
  "submitClaim(uint8,string,string,string,string,string,uint256)" \
  1 "SpaceX landed humans on the Moon yesterday" \
  "https://example.com/fake" "" "" "" 1000000000000000000 \
  --value 0.02ether --rpc-url https://sepolia.base.org --private-key "$PK"

Watch: status should become Rejected (3), no token minted. This proves the verifier says NO — the anti-spam core. (A Retryable (4) is acceptable if sources were transiently unreachable.)

  • Step 4: Record both proofs + push

Append a realWorldEventProven block to deployments/base-sepolia.json (true→token, false→rejected, claimIds, txs). Commit and git push.


Self-review against the design

  • §9 search proxy → Part A (spike) + Part B (harden). The GO/NO-GO gate is explicit. ✓
  • §6.2 pipeline (fetch user sources + independent search + judge) → Task 4 _judge_claim. ✓
  • §2 principles: sources required + gate A (UNVERIFIABLE), independent search, three verdicts, reasonable-reader, claim-can’t-outrun-evidence, first-party provenance → all in the judge prompt + label mapping (Task 4). ✓
  • §6.3 consensus on a judgmentgl.eq_principle.prompt_comparative agreeing on the LABEL. ✓
  • §8 dedup → reused generalized guard (_expected_claim_id) in _decide_realworld. ✓
  • Built on Phase-1 rails, no Base contract change → confirmed; only the verifier + web change. ✓
  • Proven live, both directions → Part E (true mints, false rejected). ✓

Out of scope (later plans): GitHubRelease + SportsResult (reuse this pipeline, new prompts/sources), and the Objective family (Onchain gl.eth spike, Price feed, first real DEX graduation).

Hard-risk note for the implementer: the make-or-break is the judge prompt resisting LLM agreement-bias — it must confidently return FALSE/UNVERIFIABLE. The four direct tests pin the label mapping, but before trusting it live, run a handful of adversarial claims (opinion, future, unsupported, true-but-fresh) through preview_verdict on studionet and eyeball the labels.