Metadata-Version: 2.5
Name: evix-verify
Version: 0.3.1
Summary: Independent, offline verifier and commitment format for Evix attestations (EAS + OpenTimestamps)
Author: Lelimo Capital sp. z o.o.
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: attestation,eas,merkle,opentimestamps,selective-disclosure,verifiable
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.10
Provides-Extra: test
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# evix-verify

Check an Evix attestation yourself — offline, with nothing but Python, and
without trusting Evix's servers.

```
pip install evix-verify     # or copy one file: src/evix_verify/verify.py
evix-verify attestation.json --disclosure disclosure.json \
    --attester did:pkh:eip155:42161:0x… --authority authority.json
matches
```

This repository holds the **format** (a specification, a reference library and
test vectors) and an **independent verifier** for attestations published by
[Evix](https://evixlab.com), a KYB/KYC platform. Python 3.10+, no dependencies.

## What an Evix attestation is

When Evix derives a fact from documents — say, that a person's effective
ownership of a company is between 25 and 50 % — it can publish a commitment to
that fact on a public ledger, so that the fact can later be proven to a bank,
an auditor or a regulator without anyone having to trust Evix's word or its
servers being up.

What goes on chain is deliberately almost nothing: an
[Ethereum Attestation Service](https://attest.org) record holding five
32-byte values —

| Field | What it is |
|---|---|
| `root` | the root of a Merkle tree of salted commitments, one per field of the fact |
| `method` | a hash of the rule and engine versions the fact was computed with |
| `inputs` | the root of a tree over the hashes of the documents it was computed from |
| `subjectRef` | a per-case salted pseudonym of the person — unlinkable across cases |
| `level` | who asserts it: 1 self-asserted, 2 machine-derived, 3 adopted by a regulated person |

No names, no values, not even the kind of fact: those live in the tree, under
per-field secret salts. The holder of the fact (the company, the person, or the
obliged entity they gave it to) can then **disclose one field** — say the
share range — by handing over a small JSON *disclosure package* with the value,
its salt and the Merkle path. Everything else in the tree stays hidden.

Each publication batch is also timestamped in **Bitcoin** through
[OpenTimestamps](https://opentimestamps.org), so "when" survives even if the
L2 chain stops.

## What is attested

Three kinds of derived fact, and only these — screening findings (sanctions,
PEP status, adverse media) are never attested:

| Kind | Fields in its tree |
|---|---|
| `effective_share` — a person's effective share in a company along the whole ownership chain | `subject`, `company`, `basis_points`, `as_of` |
| `ownership_path` — the chain that share was computed along | `subject`, `company`, `path`, `as_of` |
| `identity_resolution` — whether two spellings of a name are the same person | `subject`, `alias`, `same`, `as_of` |

Each field has one published value format, whatever kind it is in:

| Field | Value |
|---|---|
| `subject`, `company`, `alias` | a node key — a non-empty string, opaque (do not parse it) |
| `as_of` | the date of the data, `"YYYY-MM-DD"` |
| `basis_points` | `{"min_bp": 2500, "max_bp": 5000, "fully_traced": true}` — a range in hundredths of a percent, `0 ≤ min_bp ≤ max_bp ≤ 10000`; `fully_traced: false` means part of the chain was not traced |
| `path` | every chain the share was computed along, each from the company out to the person: `[[{"holder", "company", "min_bp", "max_bp", "kind": "known" \| "inferred" \| "unknown"}, …], …]` |
| `same` | `true` (one person) or `false` (different people) |

The list and the formats are in
[`src/evix_ledger/catalogue.py`](src/evix_ledger/catalogue.py) —
`validate(kind, values)` returns what is wrong with a fact — and in
[SPEC §4.1](SPEC.md#41-kinds-of-fact-and-their-fields), with examples. The
commitment format itself does not depend on the catalogue; the verifier checks
a disclosed value against its field's format once the cryptography holds.

## The commitment and tree, in brief

```
commit(field, value, salt) = sha256( u32 len ‖ 00"kyc/commit/v1"
                                   ‖ u32 len ‖ field
                                   ‖ u32 len ‖ salt (16 random bytes)
                                   ‖ u32 len ‖ canonical JSON of value )   # RFC 8785 subset, no floats
node(a, b)                 = sha256( 01"kyc/node/v1" ‖ a ‖ b )
filler(i)                  = sha256( 02"kyc/filler/v1" ‖ tree_salt ‖ u32 i )
```

Leaves are sorted by commitment value (so a leaf's position does not reveal
which field it is) and padded with fillers to 32, so the root does not reveal
how many fields there are and every path has exactly five steps. The
**[SPEC.md](SPEC.md)** gives every byte, and **[vectors/v2/](vectors/v2)** has
test vectors for each step, with one tree per kind of fact.

## How to verify without Evix

You need three things, none of which come from Evix's servers:

1. **The disclosure package** — from whoever is showing you the fact.
2. **The attestation record** — from the chain, fetched by you with any
   Ethereum client. With Foundry's `cast`:

   ```
   cast call 0xbD75f629A22Dc1ceD33dDA0b68c546A1c035c458 \
       'getAttestation(bytes32)((bytes32,bytes32,uint64,uint64,uint64,bytes32,address,address,bool,bytes))' \
       <uid> --rpc-url <your Arbitrum One node>
   ```

   and save it as JSON (`uid`, `schema`, `time`, `attester`, `revocationTime`,
   `refUID`, `data`). Or let the verifier ask a node you name:

   ```
   evix-verify <uid> --rpc https://your-node --disclosure disclosure.json --save record.json
   ```

   `--rpc` is **off by default**; without it the verifier makes no network
   request at all. A node's answer is that node's word — use your own, or ask two.
   The verifier checks that the record is under the Evix attestation schema
   (`0x9c5330df…e64bc5`, see SPEC §6.1) and refuses anything else.
3. **Who the signer should be** — the address or DID the issuer publishes as
   its attester (Evix lists current and retired signers, with dates, at
   `/.well-known/attester.json` on its API host), *as of the attestation's time*.

Then:

```
evix-verify record.json --disclosure disclosure.json --attester did:pkh:eip155:42161:0x…
```

| Answer | Exit | Meaning |
|---|---|---|
| `matches` | 0 | the disclosed value is in the tree, and the attestation stands |
| `mismatch` | 1 | it is not — wrong value, or the salt was destroyed (erasure) |
| `superseded` | 2 | it matches, but a later attestation replaced it |
| `revoked` | 3 | it matches, but the attestation was revoked |
| `unusable` | 4 | wrong format, wrong schema, unreadable input |
| `foreign` | 5 | it matches, but someone other than the expected signer signed it |
| `unauthorised` | 8 | the signer was not authorised at that moment (with `--authority`) |
| `malformed` | 9 | it matches and stands, but the disclosed value is not in its field's published format |

**Is the value what its field says?** A root proves which bytes were committed
to, not that they make sense: an inverted range reaches the root as well as a
sane one. So when everything else holds, the verifier checks the disclosed
value against [SPEC §4.1](SPEC.md#41-kinds-of-fact-and-their-fields) and
answers `malformed` (exit 9) if it is out of format, listing the problems on
stderr. A stronger answer (`revoked`, `foreign`, …) is never replaced by it;
the problems are still listed. `--no-check-values` skips the check and says so.

**Was the signer authorised then?** Pass the signer-authority records (a second
EAS schema, SPEC §9) with `--authority file.json` or, with `--rpc`,
`--authority-uid <uid>`. The verifier answers for the attestation's own
`time`, prints who stated each authority, and leaves the trust decision to you.
Without `--authority` it says on stderr that this question was not asked.

**When was it published?** Pass the batch's `.ots` file and either the 32-byte
batch root or the list of attestation roots in the batch:

```
evix-verify record.json --timestamp batch.ots --batch batch-roots.json \
    --bitcoin-header <80-byte header hex from your Bitcoin node>
```

This checks that the attestation is in the batch, that the proof is about that
batch, and — with `--bitcoin-header` — that the block header holds the merkle
root the proof leads to. For a fuller OpenTimestamps tool (stamp, upgrade,
Esplora lookups) see the companion project `otskit`.

Try it on the worked example — an `effective_share` fact (Jane Doe holds
25–50 % of Example Trading Ltd through Example Holdings Ltd, chain fully
traced) whose `basis_points` field was disclosed to a bank:

```
evix-verify vectors/v2/example/attestation.json --disclosure vectors/v2/example/disclosure.json \
    --attester "$(cat vectors/v2/example/attester.txt)" --authority vectors/v2/example/authority.json
matches
```

The package (`vectors/v2/example/disclosure.json`) carries the one field:

```json
{
  "version": "kyc/disclosure/v1",
  "uid": "0x9a50…c56f",
  "field": "basis_points",
  "value": {"fully_traced": true, "max_bp": 5000, "min_bp": 2500},
  "salt": "297df04ccf0dbbf20badccfae6589c9f",
  "path": ["1407…1235", "… 5 entries …"],
  "directions": [true, false, false, false, false],
  "recipient": "Example Bank",
  "reason": ""
}
```

The other three fields of the fact (`subject`, `company`, `as_of`) stay hidden
in the tree; the bank learns the range, that it is fully traced, and that it
is what was attested.

## Two implementations, on purpose

* `src/evix_ledger/` — the reference library: canonical form, commitments,
  trees, payload, disclosure packages, authority records, DIDs, batch roots.
  It is what an issuer uses to *produce* attestations.
* `src/evix_verify/verify.py` — the verifier. It imports nothing from
  `evix_ledger`, only the standard library, and re-implements the format
  from the specification. The test suite checks that the two agree on every
  value and on every vector. If they agree, the format is reproducible from its
  description — which is the claim that matters.

Both include plain-Python Keccak-256 (and the verifier RIPEMD-160), so they
work on Python builds whose OpenSSL lacks those hashes.

## Limits — what this does not prove

* **The chain is only as trustworthy as your node.** With `--rpc`, the record
  is whatever that node returns. Use your own node or cross-check two.
* **Supersession needs an indexer.** EAS keeps no index of which attestations
  point at a given one, so a node cannot be asked. With `--rpc` the verifier
  says "superseded: not checked"; pass `--superseded-by <uid>` (from the EAS
  GraphQL API or your own scan of `Attested` events), or a record file with
  `superseded_by`.
* **Authority records are the publisher's statement.** They add a timestamp and
  immutability, not an independent source.
* **The Bitcoin check is not SPV.** The verifier compares the proof with a
  header you supply; it does not check that the header is in the Bitcoin chain.
* **Disclosed means disclosed.** A package can be forwarded; the `recipient`
  field records whom it was given to, but nothing enforces it.
* **Deployment status (2026-10-20):** the attestation schema is registered on
  Arbitrum Sepolia (a testnet — reset, not evidence); it is not yet registered
  on Arbitrum One, and the authority schema is not yet registered on either.

## Tests

```
pip install pytest && pytest        # offline, ~5 seconds
pytest -m network                   # also asks a public Arbitrum Sepolia node
python tools/make_vectors.py --check
```

`tests/vectors/ots/` holds example proofs from the OpenTimestamps repositories
and real Bitcoin block headers (see the PROVENANCE file there).

## Licence

Copyright 2026 Lelimo Capital sp. z o.o. Licensed under the
[Apache License, Version 2.0](LICENSE). Contributions are accepted under the
same licence.
