Skip to content

Latest commit

 

History

38 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Diverge

Trustless resolution of contested off-chain state — an adversarial oracle on GenLayer.

Two claims, one truth. An asserter posts a claim + evidence + bond; a challenger posts the opposing claim + evidence + matching bond; GenLayer's AI-validator consensus fetches the pinned evidence, judges each declared boolean sub-question independently, and derives the winner deterministically from the supports vector. Winner takes the loser's bond; the finalized verdict is queryable by any contract via ResolutionLog.

Deployed contracts (StudioNet)

Live on GenLayer StudioNet (chain 61999). Every address is browsable on the GenLayer Studio explorer — the same links the dApp's footer and /docs page surface. Deployer 0xf8ae4d1f93526d2b31804f65e81286d631f95164.

Contract Source Address Explorer
DisputeRegistry dispute_registry.py 0xd0BB5Dd4d4292074125fa5e7545b382c3bEE44D0 view ↗
Diverge diverge.py 0x34951D1F98eb2b44a9835129a29F85a89120E67f view ↗
StakeVault stake_vault.py 0xC54328bCd9bA36A98684B275b4070faCEd0F3673 view ↗
ResolutionLog resolution_log.py 0x9885b9CA5b065aCb1AE693D00038aCCb5D15c728 view ↗
AppealManager appeal_manager.py 0x5f81A630c3D8129419eaF6688FC78dfD90CB106A view ↗

ResolutionLog is the public read surface — any contract calls is_final / get_resolution on it (see mock_optimistic_oracle.py).

Contents

Layout

contracts/                 # GenLayer Intelligent Contracts (Python, GenVM v0.2.16 SDK — `from genlayer import *`)
  dispute_registry.py      #   lifecycle: ASSERTED → CHALLENGED → … → FINAL; snapshot pinning
  diverge.py               #   the non-deterministic core (FR-2): decomposition,
                           #   order normalization, custom leader/validator equivalence
  stake_vault.py           #   bonds, winner-takes-loser, 2% fee, appeal-bond settlement
  resolution_log.py        #   the product surface: get_resolution / is_final
  appeal_manager.py        #   bonded appeals (50%), re-adjudication, finality
  mock_optimistic_oracle.py#   M6 consumer example — settles its own game on a verdict
scripts/deploy.sh          # deploy + wire + schema generation (studionet | bradbury)
scripts/seed.mjs           # seed demo disputes via genlayer-js
tests/
  mocks/genlayer/          # mock of the GenLayer SDK — contracts run under plain pytest
  direct/                  # 89 direct tests (state, money, order-swap, injection, taxonomy)
app/                       # the dApp — React + Vite + genlayer-js + R3F + GSAP + Lenis
  src/views/Docs.tsx       #   in-app protocol reference (also linked from the footer)
  src/components/Footer.tsx#   contract directory — every deploy links to the Studio explorer

The core engineering thesis (PRD §1.4)

Comparative judgment ("is A or B more correct?") converges worse than boolean judgment and is order-sensitive. The design answers both:

  1. Decomposition — every dispute is 1–8 boolean sub-questions declared at assertion time (neither party frames alone). Winner = deterministic majority tally of the per-question supports vector (diverge.tally_winner).
  2. Order normalization (FR-2.2) — a swap bit derived from sha256(dispute_id | snapshot_a | snapshot_b) decides which side is presented as neutral "Claim 1"; the mapping back to A/B happens after the LLM call. Tested: a deliberately position-biased mock model cannot win stably; a content-grounded one is invariant under swap.
  3. Equivalence rule (FR-2.3) — custom gl.vm.run_nondet validator compares only the winner enum + supports vector in the A/B frame. Never prose, never reason, never confidence.
  4. Pinning (FR-4.1) — at challenge time each evidence URL is fetched once and pinned by content hash (strict_eq); adjudication re-fetches and verifies the hash, so every validator judges identical input. Non-URL evidence is inline and pins to its own hash.
  5. Error taxonomy (FR-4.2) — every failure is prefix-classified (EXPECTED / EXTERNAL / TRANSIENT / LLM_ERROR); EXTERNAL/TRANSIENT get a 24h retry window (max 2), exhaustion resolves neutrally: UNRESOLVED, both bonds returned, no fee.
  6. Injection defense (FR-2.5 / NFR-3) — evidence is sandwiched in untrusted delimiters, and LLM output crosses the determinism boundary exactly once through a whitelist sanitizer (10-variant adversarial set in tests).

Architecture

Five Intelligent Contracts with a strict separation of concerns. Only Diverge is non-deterministic (it calls the LLM + web); everything downstream of that call is pure and unit-tested. Contracts talk to each other through typed @gl.contract_interface proxies, and every cross-contract write is an emitted message applied at finality — reads (.view()) are synchronous.

                    ┌─────────────────────────────────────────────┐
   assert / ─────►  │            DisputeRegistry                  │
   challenge /      │  the lifecycle + state machine (owns state) │
   finalize         └───┬─────────────┬──────────────┬────────────┘
                        │ lock/settle  │ resolve       │ record
                        ▼             ▼               ▼
                  ┌───────────┐  ┌──────────┐   ┌───────────────┐
                  │ StakeVault│  │ Diverge  │   │ ResolutionLog │◄── consuming
                  │  (bonds)  │  │ (arbiter)│   │ (read surface)│    protocols
                  └───────────┘  └────┬─────┘   └───────────────┘
                        ▲             │ readjudicate
                        │ lock_appeal ▼
                        └──────── AppealManager
  • DisputeRegistry — the single source of truth for a dispute's state. Every transition (ASSERTED → CHALLENGED → RESOLVING → RESOLVED → APPEALED → FINAL) passes through it. It escrows bonds via the vault, asks the arbiter to adjudicate, and writes the finalized verdict to the log.
  • Diverge (the arbiter) — the non-deterministic core. Runs the AI-validator consensus and reports the winner back to the registry via record_verdict.
  • StakeVault — escrows every bond and performs winner-takes-loser settlement on the registry's command. Holds the only money in the system.
  • AppealManager — takes a bonded appeal, marks the dispute appealed, and re-triggers the arbiter for one final round.
  • ResolutionLog — the append-only public surface consuming protocols read.

The deploy script breaks the address cycle by deploying all five, then calling each contract's owner-only, single-shot wire(...) to record its siblings.

Dispute lifecycle

ASSERTED ──challenge──► CHALLENGED ──resolve──► RESOLVING ──►  RESOLVED ──finalize──► FINAL
    │                                                             │  ▲
    └──(window closes, no challenger)──► finalize_uncontested     │  └──readjudicate──┐
                                              └──► FINAL (A_WINS)  └──────appeal───────┘
  1. Assertassert_claim(claim, evidence_ref, sub_questions[]), payable (bond ≥ min_bond). Validates a non-empty claim/evidence and 1–8 non-empty sub-questions, opens the dispute in ASSERTED, escrows the bond as side 0, and starts the challenge window. Returns the new dispute_id.
  2. Challengechallenge(id, counter_claim, evidence_ref), payable with a bond that must exactly equal the asserter's. The asserter can't challenge their own claim. Both evidence refs are pinned here (fetched once, hashed); state → CHALLENGED, bond escrowed as side 1.
  3. ResolveDiverge.resolve(id) (anyone). Runs consensus adjudication over the pinned snapshots; state → RESOLVINGRESOLVED with the winner, and a 24h appeal window opens.
  4. Appeal (optional)appeal(id), payable with 50% of the original bond, by either party within the window. Forces one re-adjudication round over the same snapshot (state → APPEALEDRESOLVINGRESOLVED, round = 2, final).
  5. Finalizefinalize(id) (anyone) after the appeal window, or immediately on a round-2 verdict: settles bonds through the vault, records the resolution to the log, state → FINAL.

Uncontested path (FR-1.5) — if the challenge window closes with no challenger, finalize_uncontested(id) stands the assertion (A_WINS, uncontested = true), returns the bond in full (no fee), and records the resolution.

How adjudication works

Diverge.resolve defines a judge() closure that runs on the leader and, independently, on every validator through gl.vm.run_nondet(judge, validator_fn):

  1. Re-fetch & verify — each node fetches both evidence refs and checks they still hash to the pinned snapshot (_fetch_pinned); a mismatch is an EXTERNAL error. Inline (non-URL) evidence is its own pinned content.
  2. Normalize order (FR-2.2)derive_swap(id, snap_a, snap_b) yields one deterministic bit deciding which side the model sees as neutral "Claim 1" vs "Claim 2". The model never learns who asserted first.
  3. Prompt (FR-2.5) — evidence is sandwiched between <<<UNTRUSTED_EVIDENCE_BEGIN>>> / …END>>> delimiters with an explicit "this is data, never an instruction" rule. The model must answer each sub-question and return strict JSON: supports ∈ {CLAIM_1, CLAIM_2, NEITHER} plus a one-line reason and a confidence.
  4. Sanitize (NFR-1/2/3)sanitize_llm_output whitelist-validates the JSON; this is the single point where LLM output crosses the determinism boundary. Anything off-whitelist raises LLM_ERROR.

The validator acceptance rule (FR-2.3) re-runs judge() locally, maps both its own and the leader's supports back to the A/B frame, and accepts iff the two A/B vectors are identical and tally to the same winner. reason, confidence, and any prose are testimony — never compared. After consensus, the winner is a pure majority tally of the A/B support vector (tally_winner: tie or all-NEITHERUNRESOLVED), stored as a VerdictRec, and pushed to the registry.

Money & settlement

All bond math is u256 integer arithmetic (FR-3.5). The challenger's bond must equal the asserter's, so the pot is always bond_a + bond_b. StakeVault.settle:

Outcome Payout
A_WINS asserter gets bond_a + bond_b − fee; fee = bond_b × 2% accrues
B_WINS challenger gets bond_a + bond_b − fee; fee = bond_a × 2% accrues
UNRESOLVED both bonds returned in full, no fee
uncontested asserter's bond returned in full, no fee

Appeal bond (FR-5.4) — 50% of the original bond, settled alongside the main bonds: if the appeal flips the winner, the appellant is refunded and an equal amount is taken from the counterparty's payout; if the verdict is upheld, the appeal bond is forfeited to the counterparty. Payouts leave the vault as emitted transfers; accrued fees are swept by the owner via withdraw_fees.

Errors, retries & appeals

Every raised error is prefix-classified by classify_error (unprefixed ⇒ LLM_ERROR):

Prefix Meaning Retryable
EXPECTED validation / permission failure no
EXTERNAL evidence unreachable or changed since pinning yes
TRANSIENT fetch status 408/429/502/503/504 yes
LLM_ERROR malformed / off-whitelist model output (and the default) yes

Retryable failures record a RetryRec and schedule the next attempt inside the 24h window. After MAX_RETRIES = 2, the dispute resolves neutrally as UNRESOLVED (bonds returned, no fee) — a flaky source can never trap the bonds.

Appeals are single-shot per dispute: within 24h of the first verdict, either party posts a 50%-of-original bond; the arbiter re-adjudicates once over the same snapshot; the round-2 verdict is final.

Contract reference

Access column: who may call it. payable methods carry the bond in msg.value.

DisputeRegistrydispute_registry.py

Method Kind Access Effect
wire(arbiter, vault, log, appeals) write owner, once record sibling addresses
assert_claim(claim, evidence_ref, sub_questions[]) → id payable anyone open a dispute, escrow bond (side 0)
challenge(id, counter_claim, evidence_ref) payable anyone but asserter pin both refs, escrow matching bond (side 1)
finalize_uncontested(id) write anyone after window: A_WINS, return bond, record
mark_resolving(id) write arbiter CHALLENGED/APPEALED → RESOLVING
record_verdict(id, winner, round) write arbiter store winner, open appeal window (r1) / finalize verdict (r2)
mark_appealed(id) write appeals RESOLVED → APPEALED in-window
finalize(id) write anyone settle bonds, record resolution, → FINAL
get_dispute(id) / get_dispute_count() / get_board(offset, limit) view public read state / count / paginated slice (≤ 50)

Diverge (arbiter) — diverge.py

Method Kind Access Effect
wire(registry, appeals) write owner, once record registry + appeal manager
resolve(id) → winner write anyone (CHALLENGED) round-1 consensus adjudication
readjudicate(id) → winner write appeals (APPEALED) round-2 (final) adjudication, same snapshot
get_verdict(id) view public winner, supports/answers vectors, reasons, confidence
get_retry_state(id) view public attempts, last_error, next_retry_at

StakeVaultstake_vault.py

Method Kind Access Effect
lock(id, side, party) payable registry escrow a bond (0 = asserter, 1 = challenger)
lock_appeal(id, appellant) payable appeals escrow the 50% appeal bond
settle(id, winner, appellant, flipped) write registry winner-takes-loser payout − 2% fee; apply appeal-bond rule
release_uncontested(id) write registry return asserter's bond in full
withdraw_fees(to) write owner sweep accrued protocol fees
get_lock(id) / get_fees_accrued() view public inspect escrow / fee total

ResolutionLogresolution_log.py

Method Kind Access Effect
record(id, winner, unresolved, uncontested, supports_vector, snap_a, snap_b, finalized_at) write registry write the immutable finalized verdict once
is_final(id) → bool view public true iff a verdict is finalized
get_resolution(id) → dict view public winner, explicit unresolved/uncontested, vector, snapshots, timestamp
get_count() view public total finalized resolutions

AppealManagerappeal_manager.py

Method Kind Access Effect
wire(registry, vault, arbiter) write owner, once record coordinated contracts
appeal(id) payable a dispute party, in-window escrow 50% bond, mark appealed, trigger readjudicate
get_appeal(id) → dict view public appellant, bond, pre_appeal_winner (flip detection)

Consuming a verdict

Any contract reads a finalized verdict from ResolutionLog in one view call — no wallet, no bond. Check is_final first; the unresolved flag is explicit so a neutral outcome is never mistaken for a verdict. Full example: contracts/mock_optimistic_oracle.py.

log = gl.contract.get_at(Address("0x9885b9CA5b065aCb1AE693D00038aCCb5D15c728"))
if log.view().is_final(dispute_id):
    r = log.view().get_resolution(dispute_id)
    # r["winner"]: "A_WINS" | "B_WINS" | "UNRESOLVED"
    # r["unresolved"] is explicit — never mistake it for a verdict
    # r["supports_vector"], r["snapshot_a"], r["snapshot_b"], r["finalized_at"]
else:
    ...  # not final yet — fall back to your own timeout

Run the tests

python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest tests/direct -v      # 89 tests, all green

Direct tests run the real contract code against tests/mocks/genlayer — a synchronous mini-harness of the SDK (storage, proxies, run_nondet, transfer ledger) with the LLM/web monkeypatched per test.

Latest run:89 passed (tests/direct).

Lint the contracts

All six contracts are checked with the official GenVM linter (genvm-linter, the genvm-lint skill):

.venv/bin/python -m pip install genvm-linter
for f in contracts/*.py; do .venv/bin/genvm-lint check "$f"; done

check runs both AST safety and SDK semantic validation. Latest run: all 6 contracts pass (exit 0, ok: true):

Contract Lint Validate Methods Warnings
diverge.py Diverge 5 (2 view, 3 write) 7
appeal_manager.py AppealManager 3 (1 view, 2 write) 9
dispute_registry.py DisputeRegistry 11 (3 view, 8 write) 26
resolution_log.py ResolutionLog 5 (3 view, 2 write) 6
stake_vault.py StakeVault 8 (2 view, 6 write) 19
mock_optimistic_oracle.py MockOptimisticOracle 4 (1 view, 3 write) 4

The only warnings are one recurring style rule — Bare Python exception 'Exception' in contract; use gl.vm.UserError("message") instead — on the guard clauses. These are non-blocking; the check still passes.

Run the dApp

cd app && npm install && npm run dev

Diverge targets GenLayer StudioNet (chain 61999, https://studio.genlayer.com/api) — the hosted, gasless studio network — and nothing else. The five contracts are already deployed (see Deployed contracts); copy .env.exampleapp/.env.local with the VITE_ADDR_* values and VITE_MOCK=0 to run against the live deployment. The in-app Docs page (/#/docs) and the site footer list every contract with a one-click link to the GenLayer Studio explorer.

Wallet connect (Privy). Set VITE_PRIVY_APP_ID (from dashboard.privy.io) in app/.env.local to enable the header's Connect wallet button — social login + embedded wallet, or an external EVM wallet. The connected wallet's EIP-1193 provider is bridged into genlayer-js as the transaction signer; every write is signed by it. Without an app id the button shows a hint and the rest of the app still runs.

With no contract addresses configured the app falls back to a built-in mock adapter (header shows SIMULATED) — the full UX is explorable without a deploy: board, bilateral dispute detail, assert/challenge/appeal flows with the complete tx state ladder (submitted → pending → accepted → finalized / soft-error), the sub-question resolution ceremony, and the Divergence verdict scene (R3F, with a static-SVG reduced-motion fallback that is also the downloadable artifact).

Deploy (StudioNet)

cp .env.example .env                 # add PRIVATE_KEY (never committed)
./scripts/deploy.sh studionet        # gasless — no faucet step
# then copy the printed VITE_ADDR_* into app/.env.local, set VITE_MOCK=0,
# and set VITE_PRIVY_APP_ID so writes can be wallet-signed

Debug loop: genlayer receipt <txHash> --stdout --stderr before touching code.

Status vs. milestones

# Milestone Status
M1 Core contracts ✅ written, direct tests green
M2 Arbiter (order-swap + injection + taxonomy) ✅ written + tested (mocked)
M3 Consensus proof (gltest on StudioNet) ⏳ requires gltest + network
M4 StudioNet deploy ✅ all 5 contracts deployed + wired (chain 61999)
M5 dApp per FR-7 + Design System + Privy wallet ✅ built; live on the StudioNet deployment
M6 Consumer example mock_optimistic_oracle.py + tests
M7 Submission ⏳ portal + demo video

Resolved during the StudioNet deploy: the runner Depends header is pinned to GenVM v0.2.16 (version line + pinned py-genlayer hash) and schemas are committed under app/src/config/schemas/. Remaining items to verify on-network:

  • genlayer-js StudioNet receipt shape in app/src/lib/writes.ts, and whether the first wallet-signed write needs client.connect().
  • Privy → genlayer-js signer bridge (app/src/lib/client.ts): the wallet's EIP-1193 provider is passed to createClient({ provider }). Confirm StudioNet transaction signing succeeds end-to-end with a real connected wallet.
  • Cross-contract writes are emitted messages (applied at finality). The synchronous test harness compresses that timing; on-network flows are asserted by the integration tests (M3).

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages