Architecture#
System design document for the Mergeproof staked PR review protocol.
High-Level System Diagram#
┌─────────────────────────────┐
│ Users / Agents │
│ (Bounty owners, Submitters, │
│ Bug hunters, Attestors) │
└──────┬──────────────┬────────┘
│ │
TX 1 (stake) TX 2 (action)
│ │
┌─────────────▼──┐ ┌───────▼────────────┐
│ Base (EVM) │ │ GenLayer │
│ │ │ │
│ Escrow.sol │◄──│ BountyRegistry.py │
│ ├─ deposits │ │ ├─ state machine │
│ ├─ stakes │RPC│ ├─ GitHub API reads│
│ └─ settle() │ │ ├─ AI arbiter (v2) │
│ │ │ └─ payout calc │
│ BridgeRx.sol │ │ │
│ └─ lzReceive │ │ BridgeSender │
│ └─ relay msg │ │ └─ send_message() │
└──────▲─────────┘ └──────┬──────────────┘
│ │
│ ONE bridge │
│ message per │
│ bounty │
│ │
┌──────┴────────────────────▼──────────┐
│ Bridge Layer │
│ │
│ Path A: LayerZero V2 (production) │
│ Path B: Relay service (dev/testnet) │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Client Layer │
│ │
│ CLI — mutations, two-TX orchestr. │
│ Web — read-only dashboard + CTAs │
└──────────────────────────────────────┘
Design Principles#
- Funds never leave Base until settlement — simpler security model
- One bridge message per bounty — gas-efficient, atomic payouts
- GenLayer is arbiter, not custodian — decides outcomes, doesn't hold funds
- Stake-first model — users deposit on Base before calling GenLayer
Dual-Chain Design Rationale#
GenLayer: Logic and Arbitration#
GenLayer is an AI-native blockchain where intelligent contracts (Python) can:
- Read external APIs — fetch GitHub CI status, PR state, merge status, and user profiles directly from contract code.
- Use the equivalence principle — leader nodes fetch data, validator nodes independently verify. This gives decentralized consensus over external state.
- Run AI evaluation (v2+) — LLMs evaluate bug validity, spec compliance, and severity as on-chain operations.
GenLayer is the state machine and arbiter. It tracks bounty lifecycle, decides outcomes, but never holds funds.
Base (EVM): Custody and Settlement#
Base is the EVM chain where:
- Funds live permanently until settlement. The Escrow contract holds bounty deposits and stakes.
- Settlement is atomic — one
settle()call distributes all payouts for a bounty in a single transaction. - DeFi composability — ERC-20 tokens, battle-tested OpenZeppelin contracts, Foundry tooling.
Base is the custodian. It holds money and executes payouts, but never makes decisions about outcomes.
Why Two Chains?#
| Concern | Single-chain | Dual-chain (Mergeproof) |
|---|---|---|
| GitHub API access | Requires oracle | Native in GenLayer contracts |
| AI arbitration | External service + trust | On-chain via GenLayer validators |
| Fund security | Logic + custody coupled | Custody isolated on battle-tested EVM |
| Bridge complexity | None | One message per bounty conclusion |
| Token support | Depends on chain | Any ERC-20 on Base |
The trade-off is bridge complexity, but the protocol minimizes this: only one bridge message per bounty lifetime (at conclusion).
The Stake-First Model#
Every action requiring economic commitment follows a two-transaction pattern:
Step 1: User deposits on Base (EVM) ────────────────────────────────────── Escrow.depositBounty() or Escrow.depositStake() → Funds pulled from user, recorded in Escrow state Step 2: User calls GenLayer ────────────────────────────────────── BountyRegistry.create_bounty() or BountyRegistry.submit_pr() or BountyRegistry.report_bug() or BountyRegistry.attest() → Contract reads Base via eth_call RPC → Verifies deposit/stake exists and meets requirements → Only then proceeds with state change
Two-Transaction Pattern (all staked actions):
| Action | TX 1 (Base) | TX 2 (GenLayer) |
|---|---|---|
| Create bounty | depositBounty(id, amount, pool, token) | create_bounty(...) verifies deposit |
| Submit PR | depositStake(bountyId, stakeRatio%) | submit_pr(...) verifies stake |
| Report bug | depositStake(bountyId, 0.25%) | report_bug(...) verifies stake |
| Attest | depositStake(bountyId, 1%) | attest(...) verifies stake |
Single-Transaction Actions (no stake required): validate_bug, claim, abandon, cancel_bounty.
Why Stake-First?#
- Funds never leave Base until settlement. GenLayer never has custody.
- No trust in GenLayer for fund safety. Even if GenLayer has a bug, funds on Base are safe until a valid bridge message arrives.
- Proven pattern. Same
eth_callRPC verification used in Rally for gas payments. - Atomic from the user's perspective. The CLI handles both transactions in sequence.
Contract Architecture#
BountyRegistry.py (GenLayer)#
The core intelligent contract managing all protocol state.
State machine:
create_bounty()
┌───────────────────────────┐
│ ▼
┌─┴──┐ submit_pr() ┌─────────┐
│OPEN│──────────────────►│IN_REVIEW│
└─┬──┘ └────┬────┘
│ │
│ cancel_bounty() ├── report_bug()
│ → CANCELLED ├── validate_bug()
│ ├── attest()
│ │
│ ┌────┴────────┐
│ │ │
│ claim()│ auto_reject()
│ │ abandon()
│ ▼ │
│ ┌───────────┐ │
│ │COMPLETED │ │
│ └───────────┘ │
│ │
└────────────────────────────────────┘
bounty reopens (OPEN)
Key responsibilities:
| Area | What BountyRegistry Does |
|---|---|
| Identity | GitHub-to-wallet linking via challenge-response |
| Deposits | Verifies Base escrow deposits via RPC (eth_call) |
| Submissions | Tracks PRs, commit hashes, review windows, attempt counts |
| Bug reports | Records reports, manages validation status (pending/valid/invalid) |
| Attestations | Enforces 24h window, pool capacity limits, slashing |
| Settlement | Builds decision struct, sends ONE bridge message |
| GitHub reads | CI status, PR author, merge status, issue existence |
Storage layout (TreeMap-based):
# Identity identities: TreeMap[Address, Identity] # wallet → GitHub mapping github_to_wallet: TreeMap[str, Address] # github_user_id → wallet pending_challenges: TreeMap[Address, str] # wallet → challenge string # Bounties bounties: TreeMap[str, Bounty] # bounty_id → Bounty bounty_order: DynArray[str] # creation-order index (append-only) status_counts: TreeMap[str, u256] # O(1) status queries # Submissions submissions: TreeMap[str, Submission] # submission_id → Submission submitter_attempts: TreeMap[str, u256] # "bountyId:githubId" → count # Bug reports & attestations bug_reports: TreeMap[str, BugReport] submission_bugs: TreeMap[str, DynArray[str]] # submission_id → bug IDs attestations: TreeMap[str, Attestation] submission_attestations: TreeMap[str, DynArray[str]] # submission_id → att IDs
RPC stake verification:
def verify_stake(self, bounty_id, staker, required):
# DEV MODE: skip when bridge_sender is zero address
if self.bridge_sender == Address("0x00...00"):
return True
# Manual ABI encoding (genvm_eth doesn't support bytes32)
bounty_id_bytes = bounty_id.encode().ljust(32, b'\x00')[:32]
staker_padded = bytes.fromhex(staker.as_hex[2:]).rjust(32, b'\x00')
selector = bytes.fromhex("a7b9828f") # stakes(bytes32,address)
call_data = selector + bounty_id_bytes + staker_padded
# Both leader and validators independently call Base RPC
result_bytes = gl.eq_principle.strict_eq(
lambda: self.request_to_rpc(base_rpc_url, escrow_address, call_data)
)
return int.from_bytes(result_bytes[:32], "big") >= requiredSource: contracts/genlayer/BountyRegistry.py
Escrow.sol (Base / EVM)#
The source of truth for all funds. Holds deposits and stakes. Calculates and executes payouts.
Key state:
mapping(bytes32 => BountyDeposit) public bountyDeposits;
mapping(bytes32 => mapping(address => uint256)) public stakes;Settlement decision struct (received from GenLayer):
struct SettlementDecision {
bytes32 bountyId;
Outcome outcome; // CLAIM | REJECT | ABANDON | AUTO_REJECT
address submitter;
ValidBug[] validBugs; // [{reporter, severity}]
address[] invalidBugReporters;
address[] validAttestors;
address[] slashedAttestors;
}Payout handlers by outcome:
| Outcome | Submitter Stake | Valid Bug Reporters | Invalid Reporters | Attestors (no bugs) | Attestors (bugs found) | Bounty |
|---|---|---|---|---|---|---|
| CLAIM | Returned | Reward + stake back | Slashed → treasury | Reward + stake back | Slashed → treasury | Minus reductions → submitter |
| REJECT | Forfeited | Reward + stake back | Slashed → treasury | — | All slashed | Floor → owner |
| ABANDON | Forfeited | Reward + stake back | Slashed → treasury | — | All slashed | Pool → owner; main stays |
| AUTO_REJECT | Forfeited | Reward + stake back | Slashed → treasury | — | All slashed | Floor + pool → owner |
All payout math runs on-chain. GenLayer sends only the decision; Escrow calculates exact amounts from stored deposits, stakes, and protocol constants.
Source: contracts/evm/src/Escrow.sol
BridgeReceiver.sol (Base / EVM)#
Receives settlement messages on Base and routes them to Escrow. Supports two delivery paths:
- LayerZero V2 (
lzReceive) — production path. Decodes LayerZero envelope, verifies trusted forwarder, extracts inner message. - Relay service (
processBridgeMessage) — dev/testnet path. Accepts messages directly from a trusted relayer address.
Format detection: Uses a 0x44454349 ("DECI") magic prefix to distinguish:
- Decision format — new struct-based settlement (stripped prefix → ABI decode →
Escrow.settle(decision)) - Legacy format — pre-calculated payout arrays (used for cancellations:
Escrow.settle(bountyId, payouts[]))
Source: contracts/evm/src/BridgeReceiver.sol
Cross-Chain Communication#
Settlement Flow#
1. Trigger: claim(), abandon(), auto_reject(), or cancel() 2. GenLayer builds SettlementDecision: ├─ bountyId ├─ outcome (CLAIM | REJECT | ABANDON | AUTO_REJECT) ├─ submitter address ├─ validBugs[] with severities ├─ invalidBugReporters[] (to slash) ├─ validAttestors[] (to reward) └─ slashedAttestors[] 3. BountyRegistry → BridgeSender.send_message() 4. Bridge delivers to Base: Production: LayerZero V2 → BridgeReceiver.lzReceive() Dev: Relay polls → BridgeReceiver.processBridgeMessage() 5. BridgeReceiver detects "DECI" prefix → decodes → Escrow.settle(decision) 6. Escrow executes batch payouts: ├─ Submitter: bounty (minus reductions) + stake return ├─ Valid bug reporters: severity-based reward + stake return ├─ Invalid reporters: stake slashed → treasury ├─ Valid attestors: fixed reward (0.5% of bounty) + stake return ├─ Slashed attestors: stake → treasury ├─ Owner: unused attestation pool └─ Treasury: protocol fees (10%) + slashed stakes
Bridge Delivery Paths#
Path 1: LayerZero V2 (production) GenLayer → BridgeSender → LayerZero DVNs → BridgeReceiver.lzReceive() → Escrow Path 2: Relay Service (dev/testnet) GenLayer → BridgeSender → Relay polls get_message_hashes() → Relay translates JSON → ABI → BridgeReceiver.processBridgeMessage() → Escrow
Data Flow Diagrams#
Create Bounty#
Owner Base (Escrow) GenLayer (BountyRegistry)
│ │ │
│ approve(escrow, amt) │ │
├────────────────────────►│ │
│ │ │
│ depositBounty(id, │ │
│ amt, pool, token) │ │
├────────────────────────►│ │
│ │ records BountyDeposit │
│ │ │
│ create_bounty(id, repo, issue, ...) │
├───────────────────────────────────────────────────────►│
│ │ │
│ │◄── eth_call: getBountyDepositFull(id)
│ │──► returns (amt, pool, token, owner)
│ │ │
│ │ fetch GitHub issue │
│ │ create Bounty{open} │
│◄─────────────────────────────────────── return bountyId│
Submit PR#
Submitter Base (Escrow) GenLayer (BountyRegistry)
│ │ │
│ depositStake(id, amt) │ │
├────────────────────────►│ │
│ │ stakes[id][sender] += amt │
│ │ │
│ submit_pr(id, pr#, commit) │
├───────────────────────────────────────────────────────►│
│ │ │
│ │◄── eth_call: stakes(id, sender)
│ │──► returns stake amount │
│ │ │
│ │ verify PR author = identity│
│ │ verify CI green (GitHub) │
│ │ create Submission{active} │
│ │ bounty → "in_review" │
│◄───────────────────────────────── return submission_id │
Claim (Settlement)#
Submitter GenLayer Bridge Base (Escrow) │ │ │ │ │ claim(id) │ │ │ ├────────────────►│ │ │ │ │ │ │ │ verify window ended │ │ │ verify no pending bugs │ │ │ verify PR merged (GitHub)│ │ │ │ │ │ │ build SettlementDecision │ │ │ │ send_message() │ │ │ ├────────────────►│ │ │ │ │ settle(decision) │ │ │ ├──────────────────►│ │ │ │ │ │ │ │ batch transfers: │ │ │ │ submitter │ │ │ │ bug reporters │ │ │ │ attestors │ │ │ │ treasury │ │ │ │ owner (unused) │ │ bounty.status = "completed" │ │ │◄────────────────┤ │ │
Report Bug#
Hunter Base (Escrow) GenLayer (BountyRegistry)
│ │ │
│ depositStake(id, │ │
│ 0.25% of bounty) │ │
├────────────────────────►│ │
│ │ stakes[id][hunter] += amt │
│ │ │
│ report_bug(id, commit, severity, description) │
├───────────────────────────────────────────────────────►│
│ │ │
│ │◄── eth_call: stakes(id, hunter)
│ │──► returns stake amount │
│ │ │
│ │ verify commit matches │
│ │ verify window open │
│ │ create BugReport{pending} │
│◄─────────────────────────────────────── return bug_id │
GitHub API Integration#
GenLayer's equivalence principle enables decentralized verification of external data.
Consensus Patterns#
strict_eq — Deterministic data. Used when fetched data IS the value and must match exactly between leader and validators:
- Base RPC calls (
eth_callfor stake/deposit verification) - Profile README content (checking for challenge string)
run_nondet — Derived data. Used when raw API responses vary (timestamps, avatars) but derived facts are stable:
- GitHub user lookup → extracts:
id,login,bio - GitHub issue data → extracts:
id,number,title,state - GitHub PR data → extracts:
number,title,user.id,merged,state - CI check runs → compares derived pass/fail status (not raw arrays)
Stable Field Extraction#
GitHub API responses contain volatile fields that differ between requests. The contract extracts only stable fields:
def leader_fn():
response = gl.nondet.web.get(url)
data = json.loads(response.body.decode("utf-8"))
# Only stable fields — no timestamps, avatars, etc.
return {
"number": data["number"],
"title": data["title"],
"user": {"id": data["user"]["id"]},
"merged": data.get("merged", False),
}
def validator_fn(leaders_result):
my_result = leader_fn()
return my_result == leaders_result.calldataCI Status Special Case#
CI check runs have an additional complication: new CI runs may start between leader/validator fetches. The contract compares derived status rather than raw arrays:
def derive_status(checks):
if not checks:
return "pending"
for c in checks:
if c["status"] != "completed":
return "pending"
if c["conclusion"] != "success":
return c["conclusion"]
return "success"
# Validator compares: derive_status(leader_checks) == derive_status(my_checks)Dev Mode#
When bridge_sender is the zero address (0x0000000000000000000000000000000000000000), the GenLayer contract enters dev mode:
| Check | Production | Dev Mode |
|---|---|---|
| GitHub issue fetch | Fetches via API | Returns mock data |
| PR author verification | Fetches via API | Returns caller's identity |
| CI status check | Fetches via API | Returns "success" |
| Bounty deposit verification | eth_call to Base | Skips, returns true |
| Stake verification | eth_call to Base | Skips, returns true |
| Identity registration | Challenge-response via GitHub | dev_register_identity() — no GitHub |
| Review window minimum | 24 hours | Configurable (can be 0) |
Dev mode enables local development and testing against the GenLayer simulator without real GitHub repos, Base deposits, or LayerZero infrastructure.
Relay Service Architecture#
The relay (apps/relay/) bridges messages from GenLayer to Base for dev/testnet environments.
apps/relay/src/ ├── index.ts # Entry point: health server (port 8080), cron scheduler ├── relay.ts # BridgeRelayService: polling loop, message delivery ├── decoder.ts # Message translation: JSON → ABI encoding └── config.ts # Environment configuration loader
Polling loop (every 10 seconds):
1. Poll GenLayer: get_message_hashes()
2. Filter out already-processed hashes (in-memory Set)
3. For each new hash:
a. Fetch message data from GenLayer
b. Decode ABI envelope (srcChainId, srcSender, localContract, innerMessage)
c. Parse JSON inner payload
d. Re-encode as ABI for BridgeReceiver
e. Call BridgeReceiver.processBridgeMessage()
f. Mark hash as processedDesign choices:
- Stateless — no database. Tracks processed messages in-memory via
usedHashesSet. On restart, re-checks all messages (idempotent because Escrow rejects double-settlement viasettledflag). - Translation layer — GenLayer emits JSON payloads; Base expects ABI-encoded data. The decoder handles the conversion including the "DECI" magic prefix for decision-based messages.
- Health endpoint —
/healthreports sync status, message counters, and staleness for monitoring.
Web Dashboard Architecture#
The web app (apps/web/) is a Next.js 15 application using the App Router.
apps/web/src/
├── app/ # File-based routing
│ ├── layout.tsx # Root layout, dark mode, header/footer
│ ├── page.tsx # Landing page (marketing)
│ ├── bounties/page.tsx # Bounty list with filters
│ ├── bounty/[id]/ # Bounty detail + state-aware CTAs
│ ├── submission/[id]/ # Submission with bug reports, attestations
│ ├── create/page.tsx # Bounty creation guide (CLI commands)
│ └── how-it-works/ # Protocol explainer
├── components/ # Shared UI components
└── lib/
├── api.ts # GenLayer read-only client, mock mode
└── format.ts # Display formatting helpers
Key design decisions:
- Read-only — no wallet connection, no write operations. All mutations via CLI. Reduces attack surface.
- Client-side data — contract reads happen in browser via genlayer-js. No server-side data fetching.
- Mock mode —
NEXT_PUBLIC_MOCK_MODE=truereturns synthetic data for development without deployed contracts. - State-aware CTAs — each bounty status shows different call-to-action panels with the exact CLI command to run.
- Map/BigInt normalization — GenLayer returns JavaScript
MapandBigInttypes. Theapi.tslayer converts to plain objects for React.
Styling: Tailwind CSS with CSS variable theming. Light/dark mode (system-aware, localStorage persisted). Responsive grid layouts.
CLI Architecture#
The CLI (apps/cli/) is the primary interface for all protocol mutations.
apps/cli/src/
├── index.ts # Commander entry point, global options
├── commands/
│ ├── bounty/index.ts # create, list, info, configure
│ ├── pr/index.ts # submit, retry, claim, abandon, status
│ ├── bug/index.ts # report, validate, list, info
│ ├── attest/index.ts # submit, list
│ ├── identity/index.ts # start, verify, info
│ ├── wallet/index.ts # balance, faucet
│ └── config/index.ts # get, set, list
└── lib/
├── genlayer.ts # GenLayer client wrapper (callView/callWrite)
├── evm.ts # EVM client, escrow interactions
├── github.ts # GitHub API integration
├── output.ts # Dual-mode output (TTY + JSON)
├── wallet.ts # Key loading (env var, keystore)
└── errors.ts # Error classification + exit codes
Two-TX orchestration. The CLI handles the stake-first model transparently. Example for mergeproof bounty create:
1. Parse options, validate parameters
2. Get GenLayer + EVM clients
3. Check token balance
4. Confirm transfer (interactive or --yes)
5. ERC-20 approve → Escrow.depositBounty()
6. Wait for Base confirmation
7. BountyRegistry.create_bounty() → wait for GenLayer receipt
8. Post to GitHub issue (optional, with GITHUB_TOKEN)
9. Output result + next stepsDual output mode:
- TTY — colored output (chalk), spinners (ora), interactive confirmations, arrow-prefixed next-step hints.
- JSON (
--json) — structured output with{ success, message, data, next_steps[] }for AI agent consumption.
Error handling: Classified error codes (INSUFFICIENT_FUNDS, NETWORK_ERROR, BOUNTY_NOT_FOUND) with semantic exit codes and retry hints.
Protocol Constants#
Defined identically in BountyRegistry (Python) and Escrow (Solidity):
| Constant | Value | Description |
|---|---|---|
PROTOCOL_FEE_BPS | 1000 (10%) | Fee on bug rewards and attestation payouts |
BOUNTY_FLOOR_BPS | 5000 (50%) | Minimum bounty; triggers auto-reject if reached |
MAX_ATTEMPTS | 3 | Maximum submission attempts per bounty per submitter |
BUG_REPORT_STAKE_BPS | 25 (0.25%) | Bug reporter stake as % of bounty |
ATTESTATION_STAKE_BPS | 100 (1%) | Attestor stake as % of bounty |
ATTESTATION_REWARD_BPS | 50 (0.5%) | Fixed reward per attestor (50% of stake) |
ATTESTATION_WINDOW_HOURS | 24 | Attestation opens in last 24h of review window |
SEVERITY_MINOR_BPS | 100 (1%) | Bounty reduction for minor bug |
SEVERITY_MAJOR_BPS | 300 (3%) | Bounty reduction for major bug |
SEVERITY_CRITICAL_BPS | 1000 (10%) | Bounty reduction for critical bug |
Monorepo Structure#
mergeproof/
├── apps/
│ ├── cli/ # TypeScript CLI (@mergeproof/cli)
│ ├── web/ # Next.js 15 dashboard
│ └── relay/ # Bridge relay service
├── contracts/
│ ├── genlayer/ # Python intelligent contracts
│ │ ├── BountyRegistry.py # Core protocol logic
│ │ └── BridgeSender.py # Outbound bridge messages
│ └── evm/ # Solidity contracts (Foundry)
│ └── src/
│ ├── Escrow.sol # Fund custody + settlement
│ ├── BridgeReceiver.sol # Inbound bridge messages
│ └── TestToken.sol # ERC-20 for testing
├── packages/
│ └── contracts/ # Shared ABIs and type exports
├── tests/
│ ├── direct/ # GenLayer direct-mode tests (fast, ~4s)
│ └── intelligent_contracts/ # GenLayer simulator-mode tests
├── scripts/ # Deployment scripts
└── docs/
├── SPEC.md # Full protocol specification
└── ARCHITECTURE.md # This document
Build system: Turborepo with pnpm workspaces. TypeScript throughout (except Python contracts and Solidity).
Security Model#
Trust Boundaries#
| Component | Trusts | Notes |
|---|---|---|
| Escrow.sol | Bridge contract only | onlyBridge modifier on settle() |
| BridgeReceiver.sol | LayerZero endpoint or trusted relayer | Verifies forwarder or relayer address |
| BountyRegistry.py | GenLayer consensus | Nondeterministic reads validated by multiple nodes |
| Bug validation (v1) | Bounty owner | Owner validates bug reports |
| Bug validation (v2+) | AI arbiter | GenLayer LLM evaluates bugs decentrally |
Access Control#
- Escrow.settle() — callable only by the bridge contract (
onlyBridge) - BridgeReceiver.lzReceive() — callable only by LayerZero endpoint with trusted forwarder check
- BridgeReceiver.processBridgeMessage() — callable only by trusted relayer
- BountyRegistry.validate_bug() — callable only by the bounty owner (v1)
- Admin functions —
onlyOwnerfor Escrow config (bridge, treasury, chain ID) and BridgeReceiver config (forwarders, escrow, relayer)
Key Invariants#
- Zero-sum — no funds created or destroyed; every deposited token accounted for in payouts
- One settlement per bounty —
settledboolean prevents replay - Stake-first ordering — GenLayer actions revert if Base deposits missing
- Bug reward = bounty reduction — rewards come from bounty reduction, preventing collusion
- Bounty floor (50%) — hitting floor triggers automatic rejection
- Collusion unprofitable — protocol fee (10%) makes coordinated attacks net-negative