oris-kya-verify 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ node_modules/
2
+ .next/
3
+ dist/
4
+ .turbo/
5
+ *.log
6
+ .env
7
+ .env.local
8
+ .env.production
9
+ __pycache__/
10
+ *.pyc
11
+ .venv/
12
+ *.egg-info/
13
+ .mypy_cache/
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+ celerybeat-schedule
18
+
19
+ # Docker
20
+ docker/certs/
21
+ docker/data/
22
+ docker/secrets/*.txt
23
+
24
+ # Vault
25
+ .vault-backup/
26
+
27
+ # IDE
28
+ .idea/
29
+ .vscode/
30
+ *.swp
31
+ *.swo
32
+ .DS_Store
33
+
34
+ # Ruff
35
+ .ruff_cache/
36
+ tsconfig.tsbuildinfo
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: oris-kya-verify
3
+ Version: 0.1.0
4
+ Summary: Standalone Know-Your-Agent verifier. Reads on-chain EAS attestations + verifies EIP-712 signatures with zero Oris API dependency.
5
+ Project-URL: Homepage, https://useoris.xyz
6
+ Project-URL: Documentation, https://docs.useoris.xyz/kya-verify
7
+ Project-URL: Repository, https://github.com/fluxaventures/oris
8
+ Project-URL: Bug Tracker, https://github.com/fluxaventures/oris/issues
9
+ Author-email: Fluxa Ventures <engineering@fluxa.ventures>
10
+ License: Apache-2.0
11
+ Keywords: agent-payments,attestation,compliance,eas,ethereum,kya
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: coincurve>=19
24
+ Requires-Dist: eth-abi>=5
25
+ Requires-Dist: httpx>=0.27
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Requires-Dist: respx>=0.21; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # oris-kya-verify
33
+
34
+ > Standalone Know-Your-Agent verifier. Reads on-chain EAS
35
+ > attestations and verifies EIP-712 signatures with **zero Oris API
36
+ > dependency**. Drop into any payment gateway, wallet provider, or
37
+ > compliance pipeline to verify the agent on the other side of a
38
+ > transaction.
39
+
40
+ The package implements the public `KYABundle` schema published at
41
+ [`docs.useoris.xyz/spec/kya-bundle-v1.json`](https://docs.useoris.xyz/spec/kya-bundle-v1.json).
42
+ Any KYA attestation issued by Oris on Base, Ethereum, Arbitrum, or
43
+ Optimism mainnet can be verified by this library without ever
44
+ hitting an Oris service.
45
+
46
+ ## Why this exists
47
+
48
+ Oris issues two parallel attestations for every Know-Your-Agent
49
+ state transition (promote, demote, suspend):
50
+
51
+ 1. An off-chain **EIP-712** signed message recoverable to the
52
+ Oris MPC key.
53
+ 2. An on-chain **EAS** attestation on Base + Ethereum (with optional
54
+ replication to Arbitrum / Optimism).
55
+
56
+ A third party can verify either independently. This library wraps
57
+ both checks behind a single `KYAVerifier.verify()` call.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ pip install oris-kya-verify
63
+ ```
64
+
65
+ Dependencies: `httpx`, `coincurve`, `eth-abi`. No web3.py, no
66
+ Postgres, no Oris SDK.
67
+
68
+ ## Usage
69
+
70
+ ```python
71
+ import asyncio
72
+ from oris_kya import KYAVerifier
73
+
74
+ async def main():
75
+ verifier = KYAVerifier(chain="base")
76
+ bundle = await verifier.verify("did:ethr:8453:0x7f3a9c2d1b4e5f6a7b8c9d0e1f2a3b4c5d6e7c2d")
77
+
78
+ print(f"KYA level: {bundle.kya_level}")
79
+ print(f"Risk score: {bundle.risk_score}/100")
80
+ print(f"Status: {bundle.kya_status}")
81
+ print(f"Attestation UID: {bundle.attestation_uid}")
82
+ print(f"Signed by: {bundle.signer_address}") # Should match the Oris MPC key
83
+ print(f"Issued at: {bundle.issued_at}")
84
+ print(f"Expires at: {bundle.expires_at}")
85
+ print(f"Expired? {bundle.is_expired}")
86
+
87
+ if bundle.kya_level >= 3 and not bundle.is_expired:
88
+ # Authorize the payment.
89
+ pass
90
+
91
+ asyncio.run(main())
92
+ ```
93
+
94
+ ## Supported chains
95
+
96
+ | Chain | EAS contract | Default RPC |
97
+ |---|---|---|
98
+ | `base` | `0x4200000000000000000000000000000000000021` | `https://mainnet.base.org` |
99
+ | `ethereum` | `0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587` | `https://eth.llamarpc.com` |
100
+ | `arbitrum` | `0xbD75f629A22Dc1ceD33dDA0b68c546A1c035c458` | `https://arb1.arbitrum.io/rpc` |
101
+ | `optimism` | `0x4200000000000000000000000000000000000021` | `https://mainnet.optimism.io` |
102
+
103
+ Override `rpc_url` in the `KYAVerifier` constructor to point at
104
+ your own RPC (recommended for production: Alchemy, Infura, Helius,
105
+ etc.).
106
+
107
+ ## Error handling
108
+
109
+ The library raises typed exceptions you can catch by category:
110
+
111
+ - `KYANotAttestedError` — no on-chain attestation exists for this agent.
112
+ - `KYAExpiredError` — the latest attestation has passed its `expires_at`.
113
+ - `KYAInvalidSignatureError` — the EIP-712 signature does not recover
114
+ to the expected Oris MPC signer.
115
+ - `KYAVerificationError` — base class for any of the above.
116
+
117
+ Network failures bubble up as `httpx.HTTPError`. The library never
118
+ silently fails: every "yes" answer is cryptographically grounded.
119
+
120
+ ## Schema
121
+
122
+ The `KYABundle` returned by `verify()` is a frozen dataclass that
123
+ mirrors the public JSON schema at
124
+ [`docs.useoris.xyz/spec/kya-bundle-v1.json`](https://docs.useoris.xyz/spec/kya-bundle-v1.json).
125
+ Use the schema directly if you are implementing your own verifier
126
+ in a different language.
127
+
128
+ ## License
129
+
130
+ Apache-2.0. See [LICENSE](LICENSE).
@@ -0,0 +1,99 @@
1
+ # oris-kya-verify
2
+
3
+ > Standalone Know-Your-Agent verifier. Reads on-chain EAS
4
+ > attestations and verifies EIP-712 signatures with **zero Oris API
5
+ > dependency**. Drop into any payment gateway, wallet provider, or
6
+ > compliance pipeline to verify the agent on the other side of a
7
+ > transaction.
8
+
9
+ The package implements the public `KYABundle` schema published at
10
+ [`docs.useoris.xyz/spec/kya-bundle-v1.json`](https://docs.useoris.xyz/spec/kya-bundle-v1.json).
11
+ Any KYA attestation issued by Oris on Base, Ethereum, Arbitrum, or
12
+ Optimism mainnet can be verified by this library without ever
13
+ hitting an Oris service.
14
+
15
+ ## Why this exists
16
+
17
+ Oris issues two parallel attestations for every Know-Your-Agent
18
+ state transition (promote, demote, suspend):
19
+
20
+ 1. An off-chain **EIP-712** signed message recoverable to the
21
+ Oris MPC key.
22
+ 2. An on-chain **EAS** attestation on Base + Ethereum (with optional
23
+ replication to Arbitrum / Optimism).
24
+
25
+ A third party can verify either independently. This library wraps
26
+ both checks behind a single `KYAVerifier.verify()` call.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install oris-kya-verify
32
+ ```
33
+
34
+ Dependencies: `httpx`, `coincurve`, `eth-abi`. No web3.py, no
35
+ Postgres, no Oris SDK.
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ import asyncio
41
+ from oris_kya import KYAVerifier
42
+
43
+ async def main():
44
+ verifier = KYAVerifier(chain="base")
45
+ bundle = await verifier.verify("did:ethr:8453:0x7f3a9c2d1b4e5f6a7b8c9d0e1f2a3b4c5d6e7c2d")
46
+
47
+ print(f"KYA level: {bundle.kya_level}")
48
+ print(f"Risk score: {bundle.risk_score}/100")
49
+ print(f"Status: {bundle.kya_status}")
50
+ print(f"Attestation UID: {bundle.attestation_uid}")
51
+ print(f"Signed by: {bundle.signer_address}") # Should match the Oris MPC key
52
+ print(f"Issued at: {bundle.issued_at}")
53
+ print(f"Expires at: {bundle.expires_at}")
54
+ print(f"Expired? {bundle.is_expired}")
55
+
56
+ if bundle.kya_level >= 3 and not bundle.is_expired:
57
+ # Authorize the payment.
58
+ pass
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ ## Supported chains
64
+
65
+ | Chain | EAS contract | Default RPC |
66
+ |---|---|---|
67
+ | `base` | `0x4200000000000000000000000000000000000021` | `https://mainnet.base.org` |
68
+ | `ethereum` | `0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587` | `https://eth.llamarpc.com` |
69
+ | `arbitrum` | `0xbD75f629A22Dc1ceD33dDA0b68c546A1c035c458` | `https://arb1.arbitrum.io/rpc` |
70
+ | `optimism` | `0x4200000000000000000000000000000000000021` | `https://mainnet.optimism.io` |
71
+
72
+ Override `rpc_url` in the `KYAVerifier` constructor to point at
73
+ your own RPC (recommended for production: Alchemy, Infura, Helius,
74
+ etc.).
75
+
76
+ ## Error handling
77
+
78
+ The library raises typed exceptions you can catch by category:
79
+
80
+ - `KYANotAttestedError` — no on-chain attestation exists for this agent.
81
+ - `KYAExpiredError` — the latest attestation has passed its `expires_at`.
82
+ - `KYAInvalidSignatureError` — the EIP-712 signature does not recover
83
+ to the expected Oris MPC signer.
84
+ - `KYAVerificationError` — base class for any of the above.
85
+
86
+ Network failures bubble up as `httpx.HTTPError`. The library never
87
+ silently fails: every "yes" answer is cryptographically grounded.
88
+
89
+ ## Schema
90
+
91
+ The `KYABundle` returned by `verify()` is a frozen dataclass that
92
+ mirrors the public JSON schema at
93
+ [`docs.useoris.xyz/spec/kya-bundle-v1.json`](https://docs.useoris.xyz/spec/kya-bundle-v1.json).
94
+ Use the schema directly if you are implementing your own verifier
95
+ in a different language.
96
+
97
+ ## License
98
+
99
+ Apache-2.0. See [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ """Public API of the oris-kya-verify package.
2
+
3
+ Import surface:
4
+
5
+ from oris_kya import (
6
+ KYAVerifier,
7
+ KYABundle,
8
+ KYAVerificationError,
9
+ KYAExpiredError,
10
+ KYAInvalidSignatureError,
11
+ KYANotAttestedError,
12
+ SCHEMA_VERSION,
13
+ )
14
+ """
15
+
16
+ from oris_kya.bundle import KYABundle
17
+ from oris_kya.errors import (
18
+ KYAExpiredError,
19
+ KYAInvalidSignatureError,
20
+ KYANotAttestedError,
21
+ KYAVerificationError,
22
+ )
23
+ from oris_kya.verifier import KYAVerifier
24
+
25
+ SCHEMA_VERSION = "kya-bundle-v1"
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "KYAVerifier",
31
+ "KYABundle",
32
+ "KYAVerificationError",
33
+ "KYAExpiredError",
34
+ "KYAInvalidSignatureError",
35
+ "KYANotAttestedError",
36
+ "SCHEMA_VERSION",
37
+ "__version__",
38
+ ]
@@ -0,0 +1,89 @@
1
+ """KYABundle dataclass mirroring the public JSON schema.
2
+
3
+ Schema URL: https://docs.useoris.xyz/spec/kya-bundle-v1.json
4
+ Schema version: kya-bundle-v1
5
+
6
+ A bundle is the result of a successful verification. It contains
7
+ everything a downstream caller needs to decide whether to allow a
8
+ payment: the agent's KYA level (0-4), the latest composite risk
9
+ score (0-100), the validity window, the on-chain attestation UID
10
+ that proves provenance, and the recovered signer address that
11
+ proves authenticity.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class KYABundle:
23
+ """Verified Know-Your-Agent state for one agent.
24
+
25
+ Frozen + slotted: cheap to pass around, deterministic equality,
26
+ safe to use as a dict key. Construct via KYAVerifier.verify().
27
+ """
28
+
29
+ # ── Identity ────────────────────────────────────────────────────
30
+ did: str # did:ethr:{chain_id}:{address}
31
+ agent_id_hex: str # 0x-prefixed 64-char hex of the agent UID
32
+ chain: str # base | ethereum | arbitrum | optimism
33
+
34
+ # ── KYA verdict ─────────────────────────────────────────────────
35
+ kya_level: int # 0..4
36
+ kya_status: int # 0=pending, 1=verified, 2=suspended, 3=revoked
37
+ risk_score: int # 0..100
38
+
39
+ # ── Provenance ──────────────────────────────────────────────────
40
+ attestation_uid: str # on-chain EAS UID (0x-prefixed 32-byte hex)
41
+ signer_address: str # recovered from the EIP-712 signature
42
+ issued_at: datetime
43
+ expires_at: datetime
44
+
45
+ # ── Schema versioning ───────────────────────────────────────────
46
+ schema_version: str = "kya-bundle-v1"
47
+
48
+ @property
49
+ def is_expired(self) -> bool:
50
+ """True if the attestation's expires_at is in the past.
51
+
52
+ Comparison is in UTC. Callers should still gate by both
53
+ is_expired and their own business window.
54
+ """
55
+ return datetime.now(timezone.utc) >= self.expires_at
56
+
57
+ @property
58
+ def kya_status_label(self) -> str:
59
+ """Human-readable status name (verified / pending / etc.)."""
60
+ return _STATUS_LABELS.get(self.kya_status, "unknown")
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ """JSON-serialisable form.
64
+
65
+ Conforms to kya-bundle-v1.json. Datetimes are ISO-8601 UTC.
66
+ """
67
+ return {
68
+ "schema_version": self.schema_version,
69
+ "did": self.did,
70
+ "agent_id_hex": self.agent_id_hex,
71
+ "chain": self.chain,
72
+ "kya_level": self.kya_level,
73
+ "kya_status": self.kya_status,
74
+ "kya_status_label": self.kya_status_label,
75
+ "risk_score": self.risk_score,
76
+ "attestation_uid": self.attestation_uid,
77
+ "signer_address": self.signer_address,
78
+ "issued_at": self.issued_at.astimezone(timezone.utc).isoformat(),
79
+ "expires_at": self.expires_at.astimezone(timezone.utc).isoformat(),
80
+ "is_expired": self.is_expired,
81
+ }
82
+
83
+
84
+ _STATUS_LABELS = {
85
+ 0: "pending",
86
+ 1: "verified",
87
+ 2: "suspended",
88
+ 3: "revoked",
89
+ }
@@ -0,0 +1,250 @@
1
+ """Read EAS attestations over JSON-RPC.
2
+
3
+ Pure-httpx, pure-eth-abi. No web3.py dependency. One `eth_call` to
4
+ the EAS contract's `getAttestation(bytes32 uid)` method, plus the
5
+ attestation lookup by recipient + schema if uid is not known.
6
+
7
+ The encoded data field on the attestation carries the Oris KYA
8
+ schema: `bytes32 agentId, uint8 kyaLevel, uint8 kyaStatus,
9
+ uint32 riskScore, uint64 issuedAt, uint64 expiresAt`.
10
+
11
+ Per-chain EAS contract addresses are pinned to mainnet values
12
+ verified against https://docs.attest.org/docs/quick--start/contracts.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import httpx
20
+ from eth_abi import decode as abi_decode
21
+ from eth_abi import encode as abi_encode
22
+
23
+
24
+ # Canonical mainnet EAS contracts. These are the official deployments.
25
+ EAS_CONTRACTS: dict[str, str] = {
26
+ "base": "0x4200000000000000000000000000000000000021",
27
+ "ethereum": "0xA1207F3BBa224E2c9c3c6D5aF63D0eb1582Ce587",
28
+ "arbitrum": "0xbD75f629A22Dc1ceD33dDA0b68c546A1c035c458",
29
+ "optimism": "0x4200000000000000000000000000000000000021",
30
+ }
31
+
32
+ # Default public RPCs. Production users should override via the
33
+ # KYAVerifier(rpc_url=...) constructor for rate-limit headroom.
34
+ DEFAULT_RPC: dict[str, str] = {
35
+ "base": "https://mainnet.base.org",
36
+ "ethereum": "https://eth.llamarpc.com",
37
+ "arbitrum": "https://arb1.arbitrum.io/rpc",
38
+ "optimism": "https://mainnet.optimism.io",
39
+ }
40
+
41
+ # EAS chain IDs, used to validate did:ethr:{chain_id}:{address} inputs.
42
+ EAS_CHAIN_IDS: dict[str, int] = {
43
+ "base": 8453,
44
+ "ethereum": 1,
45
+ "arbitrum": 42161,
46
+ "optimism": 10,
47
+ }
48
+
49
+
50
+ # Function selectors. Keccak256 of the canonical signature, first 4 bytes.
51
+ # getAttestation(bytes32) -> 0xa3112a64
52
+ # getAttestationUIDs(address,address,bytes32,bool,uint256,uint256) -> see Oris docs
53
+ #
54
+ # We use the simple getAttestation entry point and require the
55
+ # caller to know the UID. UID lookup by (recipient, schema) is
56
+ # left for a future revision; today the Oris API itself surfaces
57
+ # the latest UID per agent, so external verifiers receive it as
58
+ # part of the agent's published bundle metadata.
59
+ _GET_ATTESTATION_SELECTOR = "0xa3112a64"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class RawAttestation:
64
+ """Decoded EAS Attestation struct + parsed Oris schema data."""
65
+
66
+ uid: str # 0x-prefixed 32-byte hex
67
+ schema_uid: str
68
+ time: int # unix seconds when attested
69
+ expiration_time: int
70
+ revocation_time: int # 0 if not revoked
71
+ recipient: str
72
+ attester: str
73
+
74
+ # ── Decoded schema data ──
75
+ agent_id_hex: str
76
+ kya_level: int
77
+ kya_status: int
78
+ risk_score: int
79
+ issued_at: int
80
+ expires_at: int
81
+
82
+ @property
83
+ def is_revoked(self) -> bool:
84
+ return self.revocation_time != 0
85
+
86
+
87
+ async def fetch_attestation(
88
+ *,
89
+ uid: str,
90
+ chain: str,
91
+ rpc_url: str | None = None,
92
+ client: httpx.AsyncClient | None = None,
93
+ timeout: float = 8.0,
94
+ ) -> RawAttestation | None:
95
+ """Return the EAS attestation for `uid`, or None if not found.
96
+
97
+ `uid` must be a 0x-prefixed 64-char hex (the 32-byte EAS UID).
98
+ `chain` must be one of `EAS_CONTRACTS.keys()`. `rpc_url` defaults
99
+ to a public RPC; production callers should override.
100
+
101
+ Returns None if the call returns the empty struct (UID never
102
+ attested). Raises httpx.HTTPError on network failure -- the
103
+ caller decides whether to retry.
104
+ """
105
+ if chain not in EAS_CONTRACTS:
106
+ raise ValueError(f"unsupported chain: {chain}")
107
+ rpc = rpc_url or DEFAULT_RPC[chain]
108
+ contract = EAS_CONTRACTS[chain]
109
+
110
+ # Strip 0x and pad to 32 bytes if needed.
111
+ uid_clean = uid.removeprefix("0x").rjust(64, "0").lower()
112
+ call_data = _GET_ATTESTATION_SELECTOR + uid_clean
113
+
114
+ payload = {
115
+ "jsonrpc": "2.0",
116
+ "id": 1,
117
+ "method": "eth_call",
118
+ "params": [
119
+ {"to": contract, "data": call_data},
120
+ "latest",
121
+ ],
122
+ }
123
+
124
+ own_client = client is None
125
+ if own_client:
126
+ client = httpx.AsyncClient(timeout=timeout)
127
+
128
+ try:
129
+ response = await client.post(rpc, json=payload)
130
+ response.raise_for_status()
131
+ body = response.json()
132
+ finally:
133
+ if own_client:
134
+ await client.aclose()
135
+
136
+ if "error" in body:
137
+ raise RuntimeError(f"RPC error: {body['error']}")
138
+
139
+ hex_result = body.get("result", "0x")
140
+ if hex_result == "0x" or not hex_result:
141
+ return None
142
+
143
+ return _decode_attestation(uid_clean, hex_result)
144
+
145
+
146
+ # ABI types for `Attestation` struct returned by getAttestation.
147
+ # Per https://github.com/ethereum-attestation-service/eas-contracts:
148
+ # struct Attestation {
149
+ # bytes32 uid;
150
+ # bytes32 schema;
151
+ # uint64 time;
152
+ # uint64 expirationTime;
153
+ # uint64 revocationTime;
154
+ # bytes32 refUID;
155
+ # address recipient;
156
+ # address attester;
157
+ # bool revocable;
158
+ # bytes data;
159
+ # }
160
+ _ATTESTATION_TUPLE_TYPES = (
161
+ "bytes32", # uid
162
+ "bytes32", # schema
163
+ "uint64", # time
164
+ "uint64", # expirationTime
165
+ "uint64", # revocationTime
166
+ "bytes32", # refUID
167
+ "address", # recipient
168
+ "address", # attester
169
+ "bool", # revocable
170
+ "bytes", # data
171
+ )
172
+
173
+ # ABI types of the Oris KYA schema (encoded in `data`).
174
+ _ORIS_KYA_SCHEMA_TYPES = (
175
+ "bytes32", # agentId
176
+ "uint8", # kyaLevel
177
+ "uint8", # kyaStatus
178
+ "uint32", # riskScore
179
+ "uint64", # issuedAt
180
+ "uint64", # expiresAt
181
+ )
182
+
183
+
184
+ def _decode_attestation(uid_clean: str, hex_result: str) -> RawAttestation | None:
185
+ """Decode the eth_call return blob into a RawAttestation.
186
+
187
+ Returns None when the EAS contract returns a zero-uid struct
188
+ (UID not found).
189
+ """
190
+ raw_bytes = bytes.fromhex(hex_result.removeprefix("0x"))
191
+
192
+ # The returned struct is a single tuple wrapped in ABI encoding.
193
+ # eth_abi.decode with a tuple type extracts the fields in order.
194
+ try:
195
+ decoded = abi_decode(
196
+ ["(" + ",".join(_ATTESTATION_TUPLE_TYPES) + ")"],
197
+ raw_bytes,
198
+ )
199
+ except Exception:
200
+ return None
201
+
202
+ if not decoded:
203
+ return None
204
+ tup = decoded[0]
205
+ (uid_b, schema_b, time, expiration_time, revocation_time,
206
+ ref_uid_b, recipient, attester, revocable, data_bytes) = tup
207
+
208
+ # Empty attestation: uid is all zeros.
209
+ if int.from_bytes(uid_b, "big") == 0:
210
+ return None
211
+
212
+ # Decode the schema-specific data payload.
213
+ try:
214
+ (agent_id_b, kya_level, kya_status, risk_score,
215
+ issued_at, expires_at) = abi_decode(_ORIS_KYA_SCHEMA_TYPES, data_bytes)
216
+ except Exception:
217
+ return None
218
+
219
+ return RawAttestation(
220
+ uid="0x" + uid_b.hex(),
221
+ schema_uid="0x" + schema_b.hex(),
222
+ time=int(time),
223
+ expiration_time=int(expiration_time),
224
+ revocation_time=int(revocation_time),
225
+ recipient=_address_lower(recipient),
226
+ attester=_address_lower(attester),
227
+ agent_id_hex="0x" + agent_id_b.hex(),
228
+ kya_level=int(kya_level),
229
+ kya_status=int(kya_status),
230
+ risk_score=int(risk_score),
231
+ issued_at=int(issued_at),
232
+ expires_at=int(expires_at),
233
+ )
234
+
235
+
236
+ def _address_lower(addr: str | bytes) -> str:
237
+ """Normalise an EVM address to 0x-prefixed lowercase hex."""
238
+ if isinstance(addr, bytes):
239
+ return "0x" + addr.hex()
240
+ return addr.lower()
241
+
242
+
243
+ # Exported for caller-side schema validation if needed.
244
+ __all__ = [
245
+ "EAS_CONTRACTS",
246
+ "DEFAULT_RPC",
247
+ "EAS_CHAIN_IDS",
248
+ "RawAttestation",
249
+ "fetch_attestation",
250
+ ]