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."
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.
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.
| 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.
- 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
- 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
pip install -r requirements.txt
uvicorn src.main:app --reload
API docs: http://localhost:8000/docs
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.
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.
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) and0x4bA38e58f0d413405C0c4F079328ff7C5848Fa35(v1 / D1). The transaction hashes documented inscripts/e2e_results.mdand 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
| 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.
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.
"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.
| 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.
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.
| 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 |
| 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 | 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.
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
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.
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.
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
- 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_signsignature 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}/verifyreports them asUNSIGNED_LEGACYinstead 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-Actorstring 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.verificationflag straight out of the untrusted JSON. D6 removes both trusts:resolverecomputes the event chain from genesis with the recorder's own canonical serialization, re-derives everypayload_hashandevent_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 discreteCHAIN_MISMATCHrefund. 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 /eventsis serialized per deal (threading lock +BEGIN IMMEDIATE), so concurrent writes cannot fork the chain; regression-tested bytest_concurrent_writes_cannot_fork_chain. - Input limits: every caller-supplied field is bounded before it is hashed, recovered or
stored, and
deal_idshares 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 indispute; 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_dealtook anamountand only fell back togl.message.valuewhen 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 exactlygl.message.value(a zero-value open reverts withopen_deal requires payable value). Fixed in D4 (0xfD91f7…); the D2/D3 contracts still on-chain carry the old behaviour. See AUDIT.md F1. unresolvabledead state + unauthenticatedresolve(audit HIGH) — FIXED in D4. Previously, after three failed resolve attempts the escrow could no longer be moved by any method (dispute,resolve,appeal,finalizeandtimeout_releaseall gated on other statuses), so funds locked instead of settling; andresolvewas unauthenticated, letting a third party spend a deal's retry allowance deliberately. In v4,timeout_releaserefunds anunresolvabledeal once the appeal window closes, andresolverequires the sender to be the client or the worker. Fixed in D4 (0xfD91f7…). See AUDIT.md F2/F3.anchor_milestoneoverwrites 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.verificationin 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:resolverecomputes the chain from the events themselves and ignoreschain_integrityentirely. 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.
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,/sealand/disputerequire 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 asUNSIGNED_LEGACYrather 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:
resolvenow hashes each link of the exported log itself and pays only when its own head matches the anchored delivery head, instead of readingchain_integrityout 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 updatescase_file_url, clearscase_file_hashso the nextresolvere-fetches and re-anchors, and returns the deal todisputed. 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 fullscripts/e2e_demo.jslifecycle whoseresolvereturnedAGREEMENT_MISMATCHand 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 exactlygl.message.value(F1), anunresolvabledeal is refunded bytimeout_releaseonce the appeal window closes (F2), andresolverequires a party (F3). Validated by a fullscripts/e2e_demo.jslifecycle. See AUDIT.md Task E. - Deploy the head anchor as D5: DONE (
0xc93007ac…, 2026-10-10).disputerequires the sealed chain head and writes it as the immutabledeliveryanchor in the same transaction;ANCHOR_MISMATCHgates every disputed deal and fails closed when no anchor exists. Validated by a fullscripts/e2e_demo.jslifecycle and ten adversarial live cases. See AUDIT.md andscripts/e2e_results.md. - Deploy the chain recompute as D6: DONE (
0x51A54D…, 2026-10-12).resolverecomputes the event hash chain from genesis and pays only when its own digest equals the anchored delivery head, sochain_integrity.verificationand the declared head no longer gate a verdict; a mismatch or a malformed/over-size case file is the discreteCHAIN_MISMATCHrefund and theTAMPEREDverdict is gone. Closes F5 and F23. Validated by 23 golden chain vectors in direct mode, twelve adversarial live cases including aCHAIN_MISMATCHrefund, and a fullscripts/e2e_demo.jsrun, all verified against the explorer JSON API. Session logout / token revocation (POST /api/auth/logout) shipped alongside it. See AUDIT.md andscripts/e2e_results.md. - Hosted evidence API — DONE. The off-chain recorder now runs on
PythonAnywhere (
https://hreicher.pythonanywhere.com), whichindex.htmltargets forgithub.iohosts, 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.
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 |
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,sessionstables insrc/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 bytest_session_survives_app_restart. - Session model.
POST /api/auth/nonceissues a single-use nonce (deleted on a successfulverify);POST /api/auth/verifyrecovers the signer witheth_accountand mints a Bearer token valid for 7 days. The browser keeps the token inlocalStorage, so any XSS on the page could read it — there is no refresh or server-side revocation. - CORS.
allow_origins=["*"]with headers echoingauthorization, content-type; a preflightOPTIONSreturns 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 ownlocalStorage. - Actor binding (known limitation). A valid Bearer token lets its holder post
any
actorstring to/events, and seal/deals/{id}/sealwith any actor that is a deal party: the session proves an address logged in but does not bind the event'sactorto 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).
On the PythonAnywhere bash console:
cd flight-recorder && git pull
then Web tab → Reload. The Web-tab reload is what restarts the WSGI process.
| 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 |
MIT © 2026 hoveiser