Skip to content

About

Tamper-evident evidence layer + on-chain AI settlement for agent-to-agent deals on GenLayer

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Flight Recorder

A tamper-evident evidence layer + on-chain settlement for agent-to-agent deals.

Built for the Agent Tank Hackathon — Onchain Justice track. Review Submission

"GenEscrow is the judge. Flight Recorder is the black box no one can deny."

The Problem

When two agents do business together, disputes happen. But without neutral evidence, resolution depends on who's louder — not who's right.

Flight Recorder solves this by recording every event (requests, responses, deliveries, validations) into a hash-chained log that can't be tampered with. When a dispute happens, the case file becomes evidence for on-chain adjudication.

Settlement is a GenLayer Intelligent Contract that acts as the judge: it reads the case file, verifies its integrity, and uses AI validators to reach a verdict.

Architecture

Off-Chain (Flight Recorder):

  • FastAPI + SQLite
  • Hash chain (tamper-evident)
  • Case file export (GenLayer-ready JSON)

On-Chain (Settlement):

  • open_deal: lock escrow with agreement hash
  • dispute: file dispute with case file hash
  • resolve: AI validators adjudicate
  • finalize: pay winner based on verdict

Flow: Agent creates deal → records events → files dispute → exports case file → Settlement contract reads case file → AI validators reach verdict → payment/refund executed.

Key Principles

Layer Role Source of Truth
Flight Recorder (off-chain) Evidence witness Hash chain + case file
Settlement (on-chain) Judge + escrow Contract state + validator consensus

Why off-chain evidence? Raw payloads are too large for on-chain storage. The hash chain provides tamper evidence; on-chain anchors provide public proof.

Why on-chain settlement? Payment requires trustless execution. GenLayer's AI validators provide neutral adjudication without trusting any single party.

Features

Off-Chain (Flight Recorder API)

  • Deal creation with definition-of-done and agreement hash
  • Signed event recording into an append-only hash chain: every new event carries an EIP-191 signature by the actor that wrote it
  • Identity binding: the server requires that the recovered signer is the claimed actor, that the actor is a party to the deal, and that the actor owns the session token
  • Hash verification (tamper detection) and a separate signature status verdict
  • Dispute filing by deal parties, authenticated like every other write
  • Case file export (GenLayer-ready JSON) carrying each event's signature and the fields it covers, so an adjudicator can re-derive the signer without trusting this server

On-Chain (Settlement Contract)

  • Escrow: open_deal locks funds with agreement hash; the escrow is exactly the attached value, and a zero-value open is refused
  • Milestone anchoring: anchor_milestone records a chain head on-chain, and only the first one for a milestone stands
  • Dispute filing: dispute submits the case file hash AND the sealed chain head, and writes that head as the delivery anchor in the same transaction. If an anchor already exists the head must equal it, or the whole call reverts.
  • AI adjudication: resolve uses GenLayer validators to reach a verdict; evidence verdicts (mismatch, tampering, anchor mismatch) are decided from the fetched bytes with exact consensus, and the anchor gate applies to every disputed deal
  • Appeal process: appeal allows the loser to contest, optionally with a new case file URL
  • Finalization: finalize pays the winner based on the verdict
  • Timeout release: timeout_release auto-pays the worker if no dispute was filed, and refunds the client once an unresolvable deal's appeal window has closed

Quick Start

1. Off-Chain Service

pip install -r requirements.txt
uvicorn src.main:app --reload

API docs: http://localhost:8000/docs

2. Run Tests

pytest tests/ -v

Expected: 173 passed, 12 skipped (93 off-chain + 80 direct-mode). The direct-mode suite is tests/direct/test_settlement.py (55) plus tests/direct/test_chain_recompute.py (25), which drives all 23 golden hash-chain vectors through the deployed contract source plus the size-ceiling and missing-anchor guards. The 12 skips are the off-chain chain-vector checks that only apply to a self-consistent chain; the adversarial vectors intentionally have no head to match and are asserted as mismatches instead. Direct mode needs Linux or macOS (gltest cannot inject its fd-0 message on Windows), so on Windows the off-chain half runs locally and CI on Ubuntu is the authoritative full run.

3. Run Demos

With the off-chain service from step 1 still running:

python demo/scraper_dispute.py
python demo/code_quality_dispute.py

Each script logs in with a throwaway key per party, records a full deal lifecycle (signed events → seal → dispute → verify) against the local API, and prints the sealed chain head that the dispute anchors. FLIGHT_RECORDER_URL points them at another recorder if needed.

On-Chain Settlement (studio-dev)

The Settlement contract is deployed on GenLayer Studio Next, whose network is studio-dev (chain ID 61997, RPC https://studio-dev.genlayer.com/api). Throughout this repository the environment is called studio-dev.

  • Contract address: 0x51A54D74e86159482F2E57b8d4744c7Ba44D7043 (v6 / D6, deployed 2026-10-12)
  • Explorer: https://explorer-studio-dev.genlayer.com/address/0x51A54D74e86159482F2E57b8d4744c7Ba44D7043
  • Previous deployments, all still on-chain and verifiable: 0xc93007ac5f62E6f9785B425A612bdAc1E2b245F1 (v5 / D5), 0xfD91f7eDCa653702ACe1F040F4d56d35e674c750 (v4 / D4), 0x223323CE1b755313212FaD13016543C0E4E63E12 (audited v3 / D3), 0x8BC572Bec7EAA3C6662a9da3E38b4233a35bF97D (v2 / D2) and 0x4bA38e58f0d413405C0c4F079328ff7C5848Fa35 (v1 / D1). The transaction hashes documented in scripts/e2e_results.md and on the site belong to the deployment they were run against, and each row says which.

Run the end-to-end flow from the repository root:

npm install
node scripts/e2e_demo.js

Run the direct Settlement tests:

pytest tests/direct/test_settlement.py -v

Deployment history

Version Address What it is
v1 0x4bA38e58f0d413405C0c4F079328ff7C5848Fa35 Demo-video lifecycle: the first end-to-end run used in the recorded walkthrough.
v2 0x8BC572Bec7EAA3C6662a9da3E38b4233a35bF97D Superseded by 0x223323CE… on 2026-09-24; see Deployment history. Remains on-chain as historical evidence and carries the four live scenarios, including the APPROVED happy path. See scripts/e2e_results.md for the raw run output.
v3-audit (D3) 0x223323CE1b755313212FaD13016543C0E4E63E12 Superseded by v4 / D4 on 2026-09-26; remains on-chain and verifiable. Repo-HEAD hardening: payout atomicity, agreement and anchor verification, deterministic time, exception-path retry counter, and a fail-closed timeout — plus audit patches F4 (anchor is immutable once written) and F6 (agreement digest accepted in both Python and browser canonical forms). Findings F1, F2 and F3 were still open here; they are fixed in v4. Validated with scripts/e2e_demo.js: open_deal → dispute → resolve = AGREEMENT_MISMATCH refund → payout.
v4 (D4) 0xfD91f7eDCa653702ACe1F040F4d56d35e674c750 Superseded by v5 / D5 on 2026-10-10; remains on-chain and verifiable. Everything in v3-audit plus the three findings that survived it: F1 (escrow is exactly gl.message.value, never caller-declared), F2 (an unresolvable deal is refunded by timeout_release once the appeal window closes) and F3 (only a party can resolve), plus validator payload depth (resolve sees the last ≤5 event payloads, not just counters) and appeal-with-new-evidence. Validated with scripts/e2e_demo.js: open_deal → dispute → resolve → finalize, all four FINALIZED.
v5 (D5) 0xc93007ac5f62E6f9785B425A612bdAc1E2b245F1 Superseded by v6 / D6 on 2026-10-12; remains on-chain and verifiable. The atomic head anchor: dispute(deal_id, case_file_url, case_file_hash, chain_head) takes the sealed chain head as a required fourth argument (64 lowercase hex) and writes it as the delivery anchor in the same transaction, or reverts if a different head is already on record. The anchor can never be overwritten, ANCHOR_MISMATCH now gates every disputed deal instead of only pre-anchored ones, and a missing anchor fails closed. Fund-moving verdicts still require exact consensus on the discrete labels. Deployed with node scripts/deploy_contract.js (deploy tx 0xee4f5955ecded5fd6d7ea493fb21dc79e2a6ba8aa5a853d6df457cb2ee10d500). Validated with scripts/e2e_demo.js on deal 4 and with ten adversarial cases in scripts/adversarial_live.js (10/10, every hash verified against the explorer JSON API); see scripts/e2e_results.md.
v6 (D6) 0x51A54D74e86159482F2E57b8d4744c7Ba44D7043 Current on-chain deployment (2026-10-12). The contract now recomputes the event hash chain itself. resolve walks the chain from genesis with the same canonical serialization the recorder uses, re-derives every payload_hash and event_hash, and pays only when its own recomputed head equals the delivery head anchored on-chain at dispute. It reads no verdict-like field from the fetched case file: chain_integrity, its verification flag and its declared last_hash are ignored and no longer appear in the validator prompt. A mismatch, a malformed structure, an over-size case file or an unknown hash version returns the new discrete label CHAIN_MISMATCH (same refund outcome as ANCHOR_MISMATCH, exact validator consensus required); the TAMPERED verdict is removed. This closes audit findings F5 and F23. Deployed with node scripts/deploy_contract.js (deploy tx 0x2edb7e3e78f45b50866fc0a1b41326a7091a4033889fc9c126616687a59edb9a). Validated by 23 golden chain vectors through the contract source in direct mode (tests/direct/test_chain_recompute.py), twelve adversarial live cases including a CHAIN_MISMATCH refund (scripts/adversarial_live.js, scripts/e2e_results.md), all hashes verified against the explorer JSON API. Residual, documented: the GenVM runtime has no ecrecover, so per-event signature verification stays off-chain.

Current status: the v6 contract is live at 0x51A54D74e86159482F2E57b8d4744c7Ba44D7043 (D6). The earlier contracts are not deleted or forked. D1 to D5 stay on-chain, so every transaction hash referenced in this README, JUDGING.md, scripts/e2e_results.md and index.html still resolves to a real record. What changed is only which address new writes go to. Test coverage is 173 (93 off-chain + 80 direct-mode); a full security and correctness audit of the contract is in AUDIT.md. Findings F1, F2 and F3 were fixed in v4 and are still fixed in D6; D5 added F16 to F24 from the API and caller audit plus the immutable head anchor, and D6 closes F5 and F23 by making resolve recompute the hash chain itself instead of trusting the case file's self-reported chain.

Versioning

On-chain deployments are numbered D1 (0x4bA3…), D2 (0x8BC5…, superseded), D3 (0x2233…, superseded), D4 (0xfD91…, superseded), D5 (0xc930…, superseded) and D6 (0x51A5…, live). Code generations are numbered v3 (the audited hardening deployed as D3), v4 (F1/F2/F3 fixes, validator payload depth and appeal evidence, deployed as D4), v5 (signed events off-chain plus the atomic head anchor on-chain, deployed as D5) and v6 (the contract recomputes the hash chain itself and adds CHAIN_MISMATCH, deployed as D6). A code generation is not a deployment until it appears in Deployment history, and a deployment is not "current" until its address is in the line above.

API Reference

Off-Chain Endpoints

"Session" means the Authorization: Bearer <token> header from the two /api/auth endpoints. Every write that changes the evidence record requires one, and the identity it names must be the session's own address.

Method Endpoint Auth Description
POST /api/auth/nonce none Get a login nonce (expires after 5 minutes, single use)
POST /api/auth/verify signature Prove a key and get a session token (valid 7 days)
POST /deals none Create a deal; parties must be addresses, see limits below
GET /deals/{deal_id} none Get deal details
POST /events session Record a signed event into the hash chain
GET /deals/{deal_id}/events none Get all events for a deal
GET /deals/{deal_id}/verify none Verify the chain, and report signature status separately
POST /deals/{deal_id}/seal session Freeze the evidence log, return the chain head
POST /deals/{deal_id}/dispute session File a dispute (the named party must be the session)
GET /deals/{deal_id}/disputes none Get all disputes
GET /deals/{deal_id}/case-file none Export case file for adjudication (this is what validators fetch)

POST /deals/{deal_id}/seal and POST /deals/{deal_id}/dispute apply the same rule as an event write: the actor or party named in the body must equal the authenticated session address, so a shared or stolen session token cannot freeze or accuse on someone else's record.

Event request fields

Field Required Notes
deal_id yes up to 100 characters, the same ceiling the contract enforces
actor yes an Ethereum address (0x + 40 hex); it must be a party and must be the session
event_type yes up to 64 characters, see Event Types
payload no dict, at most 32 KiB measured on the canonical encoding that is hashed
metadata no dict, at most 8 KiB
signature yes EIP-191 personal_sign over the canonical event message, below
client_timestamp yes ISO-8601 with a UTC offset; must be within the window

The timestamp window has both a floor and a ceiling, as named constants in src/main.py: EVENT_TIMESTAMP_MAX_PAST_SECONDS = 900 and EVENT_TIMESTAMP_MAX_FUTURE_SECONDS = 300. A window with only one bound is not a window: without the floor a signed event from last month could be replayed today, and without the ceiling a party could date its own evidence ahead of everything else, forever.

Event signing

Every event written from here on carries a signature. The signed message is plain text, joined by newlines, and it binds the write to one position in one deal's chain:

flight-recorder-event-v1      # domain tag, so a login signature can never be an event signature
<deal_id>
<previous_event_hash>         # GENESIS for the first event
<event_type>
<sha256 of the canonical payload>
<client_timestamp>

The server recovers the signer with eth_account and requires three things at once: recovered address equals the claimed actor, the actor is a party to the deal, and the actor is the authenticated session. Because the deal id and the previous hash are inside the signed text, replaying a signature into another deal or another position changes what is recovered and fails the first check rather than succeeding quietly.

The signature is stored and folded into the event hash, which is why the hash formula has two versions. sig_version 1 is the original six-field formula and 2 adds the signature and client timestamp. Rows written before this change keep version 1, are verified with the formula they were written under, and are never rewritten: an existing log's head hash does not move when the migration adds its columns. GET /deals/{id}/verify reports the two things separately, verification for the chain and signature_status as PASS, FAIL or UNSIGNED_LEGACY, and the case file carries each event's signature, client_timestamp, sig_version and the exact signed_fields, so anybody can re-derive the signer without trusting this server.

Clients that implement the same message: demo/signed_client.py (Python) and scripts/event_signing.js (Node), plus the browser helpers in index.html. They produce byte-identical digests: canonical JSON is sorted-key, separator-compacted and ASCII-escaped on all three sides.

Event Types

Type Description
REQUEST Client sends request
RESPONSE Worker responds
DELIVERY Worker delivers
VALIDATION Client validates
STATE_CHANGE Status change
WARNING Warning event
ERROR Error event
PAYMENT_INTENT Payment intent
CUSTOM Custom event

On-Chain Contract Methods

Method Type Description
open_deal write.payable Lock escrow with agreement hash; the escrow is exactly the attached value
anchor_milestone write Record a chain head on-chain, first one for a milestone only
dispute write File a dispute with the case-file hash and the sealed chain head, which becomes the immutable delivery anchor in the same transaction
resolve write AI validators adjudicate; only a party may call it
appeal write Loser contests, optionally pointing at a new case file URL
finalize write Pay the winner of an adjudicated deal
timeout_release write Auto-pay the worker after 7 days undisputed, or refund an unresolvable deal once its appeal window has closed
get_deal view Get deal state
get_payouts view Get payout history

Verdict Types

Verdict Meaning Winner
APPROVED Worker fulfilled agreement Worker gets paid
REFUNDED Worker failed to deliver Client gets refund
EVIDENCE_MISMATCH Case file was tampered Client gets refund
AGREEMENT_MISMATCH ¹ Case-file terms are not the anchored agreement hash Client gets refund
ANCHOR_MISMATCH ¹ Case-file chain head is not the anchored head Client gets refund
UNRESOLVABLE Too many failed attempts No payout
TIMEOUT No dispute filed Worker gets paid

¹ Deployed since D3 (0x223323CE…, 2026-09-24) and carried into the current D4 (0xfD91f7…, 2026-09-26). These two verdicts come from the hardening work and are covered by the direct-mode test suite; they execute on-chain. The earlier D2 (0x8BC5…) deployment does not have them, which is why the older on-chain evidence stays a continuous D2 record. Concretely: a deal whose anchored case file carries terms other than the deal's own agreement is adjudicated on the case file's terms by D2, but comes back AGREEMENT_MISMATCH and is refunded by D3/D4 — the D3 validation run exercised exactly that path (scripts/e2e_results.md). The D4 browser demo instead anchors the repo happy_path case file with a matching agreement, so it clears this gate and is adjudicated on the merits.

Hash Chain

Each event is linked to the previous one:

Event 1: previous = GENESIS
Event 2: previous = Event 1's hash
Event 3: previous = Event 2's hash

Tamper detection:

  • Modify any payload → hash chain breaks → verify returns FAIL
  • Delete any event → chain has gap → verify returns FAIL

Evidence seal

The chain is append-only while a deal is live. The moment a dispute is filed the log freezes: POST /deals/{deal_id}/seal sets a sealed flag on the deal and returns the current chain head hash. After that, POST /events for the deal is rejected with HTTP 409 Evidence log sealed after dispute.

Note precisely what reaches the chain now. dispute() anchors two things in one transaction: the case-file hash, and the sealed chain head passed as its fourth argument, which becomes the deal's delivery milestone. That anchor is written once and never moved: if a head is already on record the new one must equal it or the entire call reverts, so neither party can pick the anchor after seeing the other's. anchor_milestone() remains for staking a head before a dispute exists, under the same write-once rule.

The head being anchored is the head that /seal returned, and the API refuses any further event for a sealed deal, so the anchored value cannot describe a log that grew later. One consequence is worth stating: because a head must be 64 lowercase hex, an evidence log with no events at all (whose head is the literal GENESIS) cannot be disputed on-chain.

Why it matters: the contract adjudicates against both the case-file hash and the anchored head. If events could still be appended after anchoring, a party could pad the log with favourable entries and leave the on-chain hash describing a record that no longer exists off-chain. Sealing makes the anchored hash final, and the atomic anchor makes the head mutual and immutable.

Sealing is idempotent — calling it twice returns the same head hash. It freezes writes only: GET /deals/{deal_id}/verify and GET /deals/{deal_id}/case-file keep working and the chain still verifies PASS.

Both live-demo scripts (scripts/run_live_scenarios.js, scripts/e2e_demo.js) and the browser flow call /seal immediately before sending the on-chain dispute transaction, so sealing is never a step anyone has to remember.

Case File Format

This is the real shape exported today, from the published demo/case_files/happy_path.json (regenerate it with python demo/make_case_file.py against a recorder, never by hand):

{
  "deal_id": "demo_happy_002",
  "definition_of_done": {"success_criteria": "...", "acceptance": "..."},
  "parties": ["0x62F4e023b1d1e68a86E153829704610C007175Fb", "0x..."],
  "agreement_hash": "056b9e2e...",
  "events": [
    {
      "id": 1,
      "actor": "0x...",
      "event_type": "REQUEST",
      "payload": {"seq": 0, "request": "..."},
      "payload_hash": "sha256...",
      "previous_event_hash": "GENESIS",
      "event_hash": "sha256...",
      "timestamp": "2026-10-10T19:13:00.782712+00:00",
      "signature": "0x... (EIP-191 personal_sign by the actor)",
      "client_timestamp": "2026-10-10T19:12:54.582009+00:00",
      "sig_version": 2,
      "signature_status": "PASS",
      "signed_fields": {
        "domain": "flight-recorder-event-v1",
        "deal_id": "demo_happy_002",
        "previous_event_hash": "GENESIS",
        "event_type": "REQUEST",
        "payload_hash": "sha256...",
        "client_timestamp": "2026-10-10T19:12:54.582009+00:00"
      }
    }
  ],
  "disputes": [],
  "chain_integrity": {
    "verification": "PASS",
    "signature_status": "PASS",
    "signatures": {"events": 4, "signed": 4, "legacy": 0, "failed": 0},
    "hash_version": 2,
    "events": 4,
    "genesis_hash": "sha256...",
    "last_hash": "33d588cb..."
  }
}

chain_integrity.last_hash is the head an honest dispute anchors, and verification and signature_status are deliberately two answers about the exported log: a chain can be internally consistent and still contain unsigned legacy rows, or carry valid signatures over an edited link. Since D6 the contract does not read these fields to decide a verdict: it recomputes the chain itself from the events and compares its own digest to the anchored delivery head, so a self-reported PASS binds nothing.

Project Structure

flight-recorder/
├── src/                      # Off-chain evidence service
│   ├── main.py               # FastAPI app
│   ├── models.py             # Pydantic models
│   ├── hash_chain.py         # SHA-256 hash chain logic
│   └── db.py                 # SQLite database
├── contracts/
│   └── settlement.py         # GenLayer Intelligent Contract
├── tests/
│   ├── test_flight_recorder.py  # Off-chain tests (14 tests)
│   ├── test_signed_events.py    # Signature and replay tests (18 tests)
│   ├── test_api_hardening.py    # API guard tests, findings F16-F19 (15 tests)
│   ├── test_agreement_hash_canonicalization.py  # Cross-runtime hash tests (3 tests)
│   ├── test_chain_vectors.py    # Golden hash-chain vectors (48 tests)
│   ├── test_case_files.py       # Published case files recompute to their head (4 tests)
│   ├── test_session_logout.py   # Session revocation hardening (3 tests)
│   ├── golden/                  # chain_vectors.json and its generator
│   └── direct/
│       ├── test_settlement.py   # Direct-mode contract tests (55 tests)
│       └── test_chain_recompute.py # 23 golden vectors + bounds driven through the contract (25 tests)
├── demo/
│   ├── signed_client.py         # Python client that logs in and signs every event
│   ├── make_case_file.py        # Regenerates the published case file from a real run
│   ├── scraper_dispute.py
│   └── code_quality_dispute.py
├── scripts/
│   ├── event_signing.js         # Node equivalent of signed_client.py
│   ├── wallet_key.js            # Reads the throwaway key from .env, never prints it
│   ├── explorer_verify.js       # Verifies a tx hash against the explorer JSON API
│   ├── adversarial_live.js      # Twelve adversarial cases against the live contract
│   ├── settle_open_deals.js     # Settles deals a run left behind
│   └── deploy_contract.js, e2e_demo.js, run_live_scenarios.js
└── README.md

Threat model & known limitations

  • Unsigned events: closed for new events, still true for old ones. The hash chain proves integrity (no later edit), not provenance (who wrote it). Since D5 every new event carries an EIP-191 personal_sign signature by its actor over a message that binds the domain tag, the deal id, the previous event hash, the event type, the payload digest and a client timestamp, and the server requires that the recovered signer is the claimed actor, that the actor is a party, and that the actor owns the session token. Rows written before the change have no signature: GET /deals/{id}/verify reports them as UNSIGNED_LEGACY instead of pretending they are proven, and the case file says so per event. A party that controls the logging service still cannot fabricate an event in another party's name without their key.
  • Actor binding: closed. Writes used to trust the actor / party / X-Actor string the caller typed. Every write that touches the evidence record (POST /events, POST /deals/{id}/seal, POST /deals/{id}/dispute) now requires a session and rejects a body that names anybody else, so a shared or stolen token cannot log onto someone else's record or freeze or accuse on their behalf.
  • Omission: the chain cannot prove an event was NOT skipped. The seal plus the head anchored by the dispute pin the log at one point in time and make it immutable afterwards; periodic auto-anchoring while a deal is still live is on the roadmap.
  • Self-reported chain: CLOSED in D6 (was F5 and F23). Before D6 the contract compared the anchored head with the head the case file declared for itself, and read the chain_integrity.verification flag straight out of the untrusted JSON. D6 removes both trusts: resolve recomputes the event chain from genesis with the recorder's own canonical serialization, re-derives every payload_hash and event_hash, and pays only when its own recomputed head equals the delivery head anchored on-chain at dispute. chain_integrity, its verification flag and its declared head are ignored and no longer reach the validator prompt; a mismatch, a malformed structure, an over-size case file or an unknown hash version is the discrete CHAIN_MISMATCH refund. Residual, documented: the GenVM runtime has no ecrecover, so per-event signatures are embedded in the hash but not verified on-chain, and a party that fabricates a fully self-consistent chain anchored to its own fabricated head is not caught by recomputation alone. See AUDIT.md F5 and F23.
  • Timestamps: the client timestamp is now bounded in both directions (900s of past drift, 300s into the future) and is inside the signed message, so it cannot be re-dated after the fact. Server receipt time is still the ordering the chain records; the on-chain anchor is what makes the head authoritative.
  • Concurrency: POST /events is serialized per deal (threading lock + BEGIN IMMEDIATE), so concurrent writes cannot fork the chain; regression-tested by test_concurrent_writes_cannot_fork_chain.
  • Input limits: every caller-supplied field is bounded before it is hashed, recovered or stored, and deal_id shares the contract's own 100-character ceiling so the API never accepts evidence the contract will refuse. Bounds apply to input; stored rows keep their old shape, so history written before a limit is still readable.
  • CORS is allow_origins=["*"] with no credential support, and the API authenticates by bearer token rather than cookie: a browser on another origin can read public evidence (which is the point, validators must fetch it too) but cannot act as a party without that party's key.
  • Prompt injection: validators receive evidence inside data tags with instructions to ignore embedded commands; adversarial case files are regression-tested by test_prompt_injection_does_not_change_verdict.
  • Live on-chain instance is D6 (0x51A54D74e86159482F2E57b8d4744c7Ba44D7043): the contract recomputes the event hash chain itself and pays only on its own digest matching the anchored delivery head. D5 (0xc930…) added the atomic head anchor in dispute; D4 (0xfD91f7…) carries the audited v3 hardening plus F4 and F6 and the F1, F2 and F3 fixes; D2 (0x8BC5…) and D3 (0x223323CE…) remain on-chain as historical evidence.
  • Escrow amount is caller-declared, not value-derived (audit CRITICAL) — FIXED in D4. Previously open_deal took an amount and only fell back to gl.message.value when it was zero, so a caller could record more escrow than it sent and later withdraw that larger figure from the contract's shared balance. The deployed v4 contract ignores any caller-declared amount and sets escrow to exactly gl.message.value (a zero-value open reverts with open_deal requires payable value). Fixed in D4 (0xfD91f7…); the D2/D3 contracts still on-chain carry the old behaviour. See AUDIT.md F1.
  • unresolvable dead state + unauthenticated resolve (audit HIGH) — FIXED in D4. Previously, after three failed resolve attempts the escrow could no longer be moved by any method (dispute, resolve, appeal, finalize and timeout_release all gated on other statuses), so funds locked instead of settling; and resolve was unauthenticated, letting a third party spend a deal's retry allowance deliberately. In v4, timeout_release refunds an unresolvable deal once the appeal window closes, and resolve requires the sender to be the client or the worker. Fixed in D4 (0xfD91f7…). See AUDIT.md F2/F3.
  • anchor_milestone overwrites an existing anchor (audit HIGH) — FIXED since D3. A re-posted milestone with a different head is now rejected, so the anchored chain head a case file is checked against is immutable once written. Fixed in D3 (0x223323CE…) and carried into D4; the D2 contract still on-chain overwrites. See AUDIT.md F4.
  • chain_integrity.verification in a case file: no longer trusted (was audit MEDIUM, F5). Before D6 the contract read the PASS/FAIL flag out of the same untrusted JSON whose hash it was given, so that check proved the file claims integrity, not that it has any. D6 removed the reliance: resolve recomputes the chain from the events themselves and ignores chain_integrity entirely. The on-chain anchor and the agreement hash remain the binding checks. See AUDIT.md F5.
  • Direct-mode tests need Linux/macOS: gltest's loader replaces fd 0 with a temp file and unlinks it while still open, which Windows rejects (WinError 32). Run them in the codespace or CI.

Roadmap & open questions

Known gaps, stated plainly rather than discovered later:

  • Ambiguous-dispute benchmark. Add partial-delivery, conflicting-validation and missing-evidence case files with their expected verdicts, and execute them in direct mode so the equivalence principle is measured against cases where the right answer is genuinely contested, not only the clear-cut ones.
  • Actor attribution: DONE (D5). POST /events, /seal and /dispute require a session token from a wallet-signed login, and every event body now carries an EIP-191 signature by its actor over the deal id, the previous hash, the event type, the payload digest and a bounded client timestamp. A session alone is no longer enough: the identity named in the body has to be the session, and the signature has to recover to it.
  • Signed events as the write path: DONE (D5). New writes are refused without a signature (HTTP 400), and pre-existing unsigned rows are reported as UNSIGNED_LEGACY rather than silently trusted or rewritten.
  • Recompute the chain inside the contract (F5, F23): DONE (D6). The step that turned the evidence layer from self-attested into independently verified is shipped: resolve now hashes each link of the exported log itself and pays only when its own head matches the anchored delivery head, instead of reading chain_integrity out of the file it is judging.
  • Appeal with new evidence — DONE (D4). appeal(deal_id, new_case_file_url="") now accepts an optional new URL; when supplied it updates case_file_url, clears case_file_hash so the next resolve re-fetches and re-anchors, and returns the deal to disputed. The agreement/anchor/chain gates still apply, so "new evidence" cannot mean "new terms".
  • Redeploy v3 to Studio Next — DONE (D3, 0x223323CE…, 2026-09-24; since superseded by D4). The audited v3 hardening plus patches F4 and F6 was deployed and validated by a full scripts/e2e_demo.js lifecycle whose resolve returned AGREEMENT_MISMATCH and refunded the client (scripts/e2e_results.md).
  • Deploy F1, F2 and F3 as D4 — DONE (0xfD91f7…, 2026-09-26). The three findings that survived D3 are now fixed on-chain: escrow is exactly gl.message.value (F1), an unresolvable deal is refunded by timeout_release once the appeal window closes (F2), and resolve requires a party (F3). Validated by a full scripts/e2e_demo.js lifecycle. See AUDIT.md Task E.
  • Deploy the head anchor as D5: DONE (0xc93007ac…, 2026-10-10). dispute requires the sealed chain head and writes it as the immutable delivery anchor in the same transaction; ANCHOR_MISMATCH gates every disputed deal and fails closed when no anchor exists. Validated by a full scripts/e2e_demo.js lifecycle and ten adversarial live cases. See AUDIT.md and scripts/e2e_results.md.
  • Deploy the chain recompute as D6: DONE (0x51A54D…, 2026-10-12). resolve recomputes the event hash chain from genesis and pays only when its own digest equals the anchored delivery head, so chain_integrity.verification and the declared head no longer gate a verdict; a mismatch or a malformed/over-size case file is the discrete CHAIN_MISMATCH refund and the TAMPERED verdict is gone. Closes F5 and F23. Validated by 23 golden chain vectors in direct mode, twelve adversarial live cases including a CHAIN_MISMATCH refund, and a full scripts/e2e_demo.js run, all verified against the explorer JSON API. Session logout / token revocation (POST /api/auth/logout) shipped alongside it. See AUDIT.md and scripts/e2e_results.md.
  • Hosted evidence API — DONE. The off-chain recorder now runs on PythonAnywhere (https://hreicher.pythonanywhere.com), which index.html targets for github.io hosts, so visitors can complete the whole lifecycle without cloning the repo. The local path (uvicorn src.main:app) remains for offline use and for networks PythonAnywhere geo-blocks (HTTP 451).
  • Integrated faucet. Visitors currently fund their own test transactions from the Studio Next faucet, which is why every on-chain row is a real independently verifiable run. Wiring a faucet into the page would lower the barrier for judges without changing that property.

Runtime compatibility

The reference runtime is Python 3.12 (what CI runs); the supported range is 3.10 – 3.14. Dependencies stay pinned to what works across that range: no major dependency upgrades and no Python version change as part of a feature round. The GenVM runner the contract names is pinned in CI through GENVM_VERSION (see .github/workflows/ci.yml), because an unpinned "latest" release breaks the direct suite for a reason that has nothing to do with the contract.

Environment Python Status
Codespace 3.14 tested-green
CI (GitHub Actions) 3.12 reference runtime
PythonAnywhere web worker 3.10 deployed-live
Supported range 3.10 – 3.14 pytest tests/ green across the range

Hosting the evidence API (PythonAnywhere)

The off-chain recorder is served on PythonAnywhere's free tier through an a2wsgi WSGI shim.

  • Multi-worker safe (single worker no longer required). Nonces and sessions are persisted in SQLite (nonces, sessions tables in src/db.py) rather than in-process dictionaries, so a token survives a restart and is honoured by any worker sharing the same database file. Regression-tested by test_session_survives_app_restart.
  • Session model. POST /api/auth/nonce issues a single-use nonce (deleted on a successful verify); POST /api/auth/verify recovers the signer with eth_account and mints a Bearer token valid for 7 days. The browser keeps the token in localStorage, so any XSS on the page could read it — there is no refresh or server-side revocation.
  • CORS. allow_origins=["*"] with headers echoing authorization, content-type; a preflight OPTIONS returns 200 with those headers (verified against the app). The wildcard is safe here only because the token is not a cookie and is not sent automatically cross-site — it is read from the page's own localStorage.
  • Actor binding (known limitation). A valid Bearer token lets its holder post any actor string to /events, and seal /deals/{id}/seal with any actor that is a deal party: the session proves an address logged in but does not bind the event's actor to that address. Per-event signatures (roadmap) are the fix.
  • Storage durability. SQLite lives at /home/<user>/flight_recorder.db. That path survives web-app reloads and code deploys, but not a PythonAnywhere account reset, which clears the home directory.
  • Geo-restriction. PythonAnywhere's free tier returns HTTP 451 to some client networks. Visitors who see 451 from the hosted API can still run the whole flow through the local Quick Start (uvicorn src.main:app).

Redeploy after a push

On the PythonAnywhere bash console:

cd flight-recorder && git pull

then Web tab → Reload. The Web-tab reload is what restarts the WSGI process.

Tech Stack

Component Technology
Off-chain API FastAPI + SQLite
On-chain contract GenLayer SDK v0.19.0rc2 (consensus v0.6)
Testing pytest + genlayer-test (Direct Mode)
CI GitHub Actions

License

MIT © 2026 hoveiser

About

Tamper-evident evidence layer + on-chain AI settlement for agent-to-agent deals on GenLayer

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages