Skip to content

CodeWithShamim/Diverge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

38 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

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. Assert β€” assert_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. Challenge β€” challenge(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. Resolve β€” Diverge.resolve(id) (anyone). Runs consensus adjudication over the pinned snapshots; state β†’ RESOLVING β†’ RESOLVED 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 β†’ APPEALED β†’ RESOLVING β†’ RESOLVED, round = 2, final).
  5. Finalize β€” finalize(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-NEITHER β†’ UNRESOLVED), 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.

DisputeRegistry β€” dispute_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

StakeVault β€” stake_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

ResolutionLog β€” resolution_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

AppealManager β€” appeal_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.example β†’ app/.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

No releases published

Packages

 
 
 

Contributors