avm-mesh 0.4.2__py3-none-any.whl

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.
avm_mesh/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """avm-mesh -- settlement receipts.
2
+
3
+ A receipt is what moves between meshes. It is deliberately small: a digest,
4
+ a pointer to a proofpack, and the trust tier that proofpack actually earned.
5
+ The bundle itself is stored once per attested interval, never per transaction.
6
+ """
7
+ from .attestation import ConsensusAttestation, attest, verify_attestation
8
+ from .effects import verify_settlement_amount
9
+ from . import a2a, rails, produce, service, trust
10
+ from .produce import produce as produce_receipt
11
+ from .service import Store, WitnessInfo
12
+ from .trust import TrustPolicy, load_policy, verify_with_policy
13
+ from .sealed import seal, verify_sealed
14
+ from .receipt import (
15
+ MeshError,
16
+ ProofpackPointer,
17
+ SettlementReceipt,
18
+ create_receipt,
19
+ verify_receipt,
20
+ )
21
+
22
+ __all__ = [
23
+ "produce",
24
+ "service",
25
+ "trust",
26
+ "produce_receipt",
27
+ "Store",
28
+ "WitnessInfo",
29
+ "TrustPolicy",
30
+ "load_policy",
31
+ "verify_with_policy",
32
+ "a2a",
33
+ "seal",
34
+ "verify_sealed",
35
+ "ConsensusAttestation",
36
+ "attest",
37
+ "verify_attestation",
38
+ "verify_settlement_amount",
39
+ "MeshError",
40
+ "ProofpackPointer",
41
+ "SettlementReceipt",
42
+ "create_receipt",
43
+ "verify_receipt",
44
+ ]
45
+ __version__ = "0.4.2"
avm_mesh/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
avm_mesh/a2a.py ADDED
@@ -0,0 +1,150 @@
1
+ """A2A settlement-evidence extension.
2
+
3
+ A2A is the transport agents already use to talk to each other. This
4
+ extension lets one agent hand another a settlement receipt as evidence, and
5
+ lets the receiver check it against chain consensus instead of taking the
6
+ sender's word.
7
+
8
+ Design follows the A2A extension mechanism rather than inventing a parallel
9
+ one: the extension is declared on the agent card, negotiated per request, and
10
+ the evidence travels as an ordinary Artifact with a `data` part. Nothing in
11
+ the protocol changes; a peer that does not implement the extension simply
12
+ ignores it and falls back to whatever it trusted before.
13
+
14
+ The extension URI is the versioning boundary. Never change the semantics
15
+ behind a published URI -- publish a v2.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Callable
20
+
21
+ from .receipt import MeshError
22
+ from .sealed import verify_sealed
23
+
24
+ EXTENSION_URI = "https://algovoi.co.uk/a2a/extensions/settlement-evidence/v1"
25
+ ARTIFACT_NAME = "settlement-receipt"
26
+
27
+
28
+ def extension_descriptor() -> dict:
29
+ """The object to place in an agent card's capabilities.extensions."""
30
+ return {
31
+ "uri": EXTENSION_URI,
32
+ "description": (
33
+ "Carries a sealed settlement receipt whose settlement is attested by "
34
+ "Algorand or Voi consensus state proofs (Falcon-1024). Receivers verify "
35
+ "offline against a pinned genesis hash; no operator key is required in "
36
+ "the trust base."
37
+ ),
38
+ "required": False,
39
+ "params": {
40
+ "tiers": ["stake_attested", "structural"],
41
+ "chains": ["mainnet-v1.0", "voimain-v1.0"],
42
+ "proofpack_delivery": "by-reference",
43
+ },
44
+ }
45
+
46
+
47
+ def advertise(card: dict) -> dict:
48
+ """Return the card with this extension declared. Does not mutate the input."""
49
+ if not isinstance(card, dict):
50
+ raise MeshError("agent card must be an object")
51
+ out = dict(card)
52
+ caps = dict(out.get("capabilities") or {})
53
+ existing = list(caps.get("extensions") or ())
54
+ if not any(e.get("uri") == EXTENSION_URI for e in existing if isinstance(e, dict)):
55
+ existing.append(extension_descriptor())
56
+ caps["extensions"] = existing
57
+ out["capabilities"] = caps
58
+ return out
59
+
60
+
61
+ def wants_extension(message_or_params: Any) -> bool:
62
+ """True if a peer asked for this extension in a request.
63
+
64
+ A2A puts requested extensions on the message params as `extensions`, and
65
+ older peers omit it entirely, so absence means "not requested" rather
66
+ than "refused".
67
+ """
68
+ if not isinstance(message_or_params, dict):
69
+ return False
70
+ for container in (message_or_params, message_or_params.get("params") or {}):
71
+ if isinstance(container, dict):
72
+ exts = container.get("extensions")
73
+ if isinstance(exts, (list, tuple)) and EXTENSION_URI in exts:
74
+ return True
75
+ return False
76
+
77
+
78
+ def as_artifact(sealed: dict, *, artifact_id: str = "settlement-receipt") -> dict:
79
+ """Wrap a sealed receipt as an A2A Artifact.
80
+
81
+ Returns the receipt itself in a ``data`` part rather than as text, so a
82
+ receiver never has to parse prose or trust a base64 blob's framing.
83
+
84
+ EMITS THE v1.0 FORM. A2A v1.0 removed the inline ``kind`` discriminator as a
85
+ breaking change (spec appendix A.2.1): a DataPart is now identified by the
86
+ presence of the ``data`` member, not by a tag. The legacy v0.3.x form was
87
+ ``{"kind": "data", "data": {...}}``; emitting it against a v1.0 peer makes the
88
+ part unparseable, because there is no ``kind`` field to read and the extra
89
+ member is unexpected.
90
+ """
91
+ if not isinstance(sealed, dict):
92
+ raise MeshError("sealed receipt must be an object")
93
+ for key in ("canonical_bytes_b64", "content_hash"):
94
+ if key not in sealed:
95
+ raise MeshError(f"sealed receipt missing {key}")
96
+ return {
97
+ "artifact_id": artifact_id,
98
+ "name": ARTIFACT_NAME,
99
+ "parts": [{
100
+ "data": {"avm_mesh_receipt": sealed, "extension": EXTENSION_URI},
101
+ "mediaType": "application/json",
102
+ }],
103
+ }
104
+
105
+
106
+ def verify_artifact(artifact: dict, resolve: Callable[[str], dict],
107
+ *, trusted_voters: Any = None,
108
+ expected_genesis_hash_b64: str | None = None,
109
+ verify_signature: Callable[[bytes, dict], bool] | None = None) -> dict:
110
+ """Verify settlement evidence received from a peer.
111
+
112
+ Fail-closed: an artifact that does not carry this extension's data is an
113
+ error, not an empty success. Returning "no evidence" as success is how
114
+ receivers end up accepting nothing and thinking they verified something.
115
+ """
116
+ if not isinstance(artifact, dict):
117
+ raise MeshError("artifact must be an object")
118
+ parts = artifact.get("parts")
119
+ if not isinstance(parts, list):
120
+ raise MeshError("artifact has no parts")
121
+
122
+ payload = None
123
+ for part in parts:
124
+ if not isinstance(part, dict):
125
+ continue
126
+ # v1.0: the presence of "data" identifies a DataPart; the inline `kind`
127
+ # discriminator was removed (spec appendix A.2.1). v0.3.x peers still send
128
+ # it, so accept both -- the spec tells implementors to dual-handle during
129
+ # the overlap period.
130
+ if "data" not in part:
131
+ continue
132
+ if "kind" in part and part.get("kind") != "data":
133
+ continue
134
+ data = part.get("data")
135
+ if isinstance(data, dict) and data.get("extension") == EXTENSION_URI:
136
+ payload = data.get("avm_mesh_receipt")
137
+ break
138
+
139
+ if payload is None:
140
+ raise MeshError("artifact carries no settlement-evidence payload")
141
+ if not isinstance(payload, dict):
142
+ raise MeshError("settlement-evidence payload is not an object")
143
+
144
+ return verify_sealed(
145
+ payload,
146
+ resolve,
147
+ trusted_voters=trusted_voters,
148
+ expected_genesis_hash_b64=expected_genesis_hash_b64,
149
+ verify_signature=verify_signature,
150
+ )
@@ -0,0 +1,283 @@
1
+ """LEVEL 3: consensus attestation for a settlement receipt.
2
+
3
+ The rails receipt has two levels:
4
+
5
+ LEVEL 1 integrity sha256(canonical_bytes) == content_hash
6
+ LEVEL 2 authenticity the OPERATOR's Falcon-1024 signature over that hash
7
+
8
+ LEVEL 2 is signed by AlgoVoi, so it proves "the operator issued this receipt".
9
+ It cannot prove the settlement happened as described, because the operator
10
+ could sign anything. LEVEL 3 closes that: evidence that the transaction was
11
+ final, authenticated against the chain's own consensus -- and against a
12
+ *trusted voters anchor the verifier supplies*, never one the bundle asserts.
13
+
14
+ WHY THE ANCHOR IS MANDATORY (scheme v2). An earlier version of this module
15
+ derived the signing set from the bundle's own `previous_state_proof_message`.
16
+ That was forgeable: a bundle author chooses that field, so they could name
17
+ themselves as the only signer and mint a valid-looking stake_attested tier.
18
+ avm-proofpack 0.4.1 fixed this by requiring a caller-supplied
19
+ `TrustedVoters` anchor; v2 here follows. The scheme string is bumped because
20
+ v1 attestations must not be accepted as if they meant the same thing.
21
+
22
+ Obtain the anchor independently: walk the chain of state proofs from a pinned
23
+ genesis bootstrap (avm_proofpack.consensus.chain), or take it from a node you
24
+ already trust at a known round. Never read it out of the bundle you are
25
+ verifying.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ from dataclasses import dataclass
31
+ from typing import Any, Callable
32
+
33
+ from .receipt import MeshError, bundle_digest
34
+
35
+ SCHEME = "avm-stateproof-falcon1024-v2"
36
+ SUPPORTED_CHAINS = ("mainnet-v1.0", "voimain-v1.0")
37
+
38
+
39
+ def _b64(b: bytes) -> str:
40
+ return base64.b64encode(b).decode("ascii")
41
+
42
+
43
+ def _unb64(s: str) -> bytes:
44
+ return base64.b64decode(s.encode("ascii"), validate=True)
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ConsensusAttestation:
49
+ """Evidence that a settlement was final, anchored in chain consensus.
50
+
51
+ `anchor_voters_commitment_b64` and `anchor_ln_proven_weight` name the
52
+ anchor the attestation was built against. The verifier must supply the
53
+ same anchor independently and it must match; carrying it here makes the
54
+ claim auditable, not self-certifying.
55
+ """
56
+ scheme: str
57
+ chain: str
58
+ genesis_hash_b64: str
59
+ txid: str
60
+ round: int
61
+ trust_root: str
62
+ evidence_uri: str
63
+ evidence_digest: str
64
+ first_attested_round: int
65
+ last_attested_round: int
66
+ anchor_voters_commitment_b64: str
67
+ anchor_ln_proven_weight: int
68
+
69
+ def __post_init__(self) -> None:
70
+ if self.scheme != SCHEME:
71
+ raise MeshError(
72
+ f"unsupported attestation scheme {self.scheme!r}; "
73
+ f"v1 attestations were forgeable and are refused"
74
+ )
75
+ if self.chain not in SUPPORTED_CHAINS:
76
+ raise MeshError(
77
+ f"chain {self.chain!r} has no state-proof consensus; "
78
+ f"supported: {', '.join(SUPPORTED_CHAINS)}"
79
+ )
80
+ if self.trust_root != "stake_attested":
81
+ raise MeshError(
82
+ f"a consensus attestation must be stake_attested; got {self.trust_root!r}"
83
+ )
84
+ try:
85
+ gh = _unb64(self.genesis_hash_b64)
86
+ vc = _unb64(self.anchor_voters_commitment_b64)
87
+ except Exception as exc:
88
+ raise MeshError("genesis hash or voters commitment is not valid base64") from exc
89
+ if len(gh) != 32:
90
+ raise MeshError("genesis hash must be 32 bytes")
91
+ if len(vc) != 64:
92
+ raise MeshError("voters commitment must be 64 bytes")
93
+ if self.anchor_ln_proven_weight < 0:
94
+ raise MeshError("anchor proven weight cannot be negative")
95
+
96
+ def to_dict(self) -> dict:
97
+ return {
98
+ "scheme": self.scheme,
99
+ "chain": self.chain,
100
+ "genesis_hash_b64": self.genesis_hash_b64,
101
+ "txid": self.txid,
102
+ "round": self.round,
103
+ "trust_root": self.trust_root,
104
+ "evidence_uri": self.evidence_uri,
105
+ "evidence_digest": self.evidence_digest,
106
+ "first_attested_round": self.first_attested_round,
107
+ "last_attested_round": self.last_attested_round,
108
+ "anchor_voters_commitment_b64": self.anchor_voters_commitment_b64,
109
+ "anchor_ln_proven_weight": self.anchor_ln_proven_weight,
110
+ }
111
+
112
+ @classmethod
113
+ def from_dict(cls, data: Any) -> "ConsensusAttestation":
114
+ if not isinstance(data, dict):
115
+ raise MeshError("consensus_attestation must be an object")
116
+ try:
117
+ return cls(
118
+ scheme=data["scheme"],
119
+ chain=data["chain"],
120
+ genesis_hash_b64=data["genesis_hash_b64"],
121
+ txid=data["txid"],
122
+ round=int(data["round"]),
123
+ trust_root=data["trust_root"],
124
+ evidence_uri=data["evidence_uri"],
125
+ evidence_digest=data["evidence_digest"],
126
+ first_attested_round=int(data["first_attested_round"]),
127
+ last_attested_round=int(data["last_attested_round"]),
128
+ anchor_voters_commitment_b64=data["anchor_voters_commitment_b64"],
129
+ anchor_ln_proven_weight=int(data["anchor_ln_proven_weight"]),
130
+ )
131
+ except KeyError as exc:
132
+ raise MeshError(f"attestation missing field {exc}") from exc
133
+ except (TypeError, ValueError) as exc:
134
+ raise MeshError(f"attestation has an unusable field: {exc}") from exc
135
+
136
+
137
+ def attest(bundle: dict, uri: str, *, trusted_voters: Any) -> ConsensusAttestation:
138
+ """Build a LEVEL 3 block from a proofpack, or refuse.
139
+
140
+ `trusted_voters` is the caller's anchor (avm_proofpack.TrustedVoters).
141
+ It is required: without it the evidence cannot reach stake_attested, and
142
+ emitting a weaker block would let a receipt *look* consensus-backed while
143
+ carrying an operator's word.
144
+ """
145
+ from avm_proofpack.bundle import Bundle
146
+ from avm_proofpack.verify import verify_bundle
147
+
148
+ if trusted_voters is None:
149
+ raise MeshError(
150
+ "a trusted voters anchor is required for LEVEL 3; derive one from a "
151
+ "pinned genesis bootstrap or a chain walk, never from this bundle"
152
+ )
153
+ if not isinstance(bundle, dict):
154
+ raise MeshError("bundle must be an object")
155
+
156
+ try:
157
+ report = verify_bundle(Bundle.from_dict(bundle), trusted_voters=trusted_voters)
158
+ except Exception as exc:
159
+ raise MeshError(f"evidence failed verification: {exc}") from exc
160
+ if report.trust_root != "stake_attested":
161
+ raise MeshError(
162
+ f"evidence earns {report.trust_root!r}, not stake_attested"
163
+ )
164
+ return ConsensusAttestation(
165
+ scheme=SCHEME,
166
+ chain=report.genesis_id,
167
+ genesis_hash_b64=report.genesis_hash_b64,
168
+ txid=report.txid,
169
+ round=report.round,
170
+ trust_root=report.trust_root,
171
+ evidence_uri=uri,
172
+ evidence_digest=bundle_digest(bundle),
173
+ first_attested_round=report.first_attested_round,
174
+ last_attested_round=report.last_attested_round,
175
+ anchor_voters_commitment_b64=_b64(bytes(trusted_voters.voters_commitment)),
176
+ anchor_ln_proven_weight=int(trusted_voters.ln_proven_weight),
177
+ )
178
+
179
+
180
+ def verify_attestation(attestation: ConsensusAttestation | dict,
181
+ resolve: Callable[[str], dict],
182
+ *, trusted_voters: Any,
183
+ expected_genesis_hash_b64: str | None = None) -> Any:
184
+ """Check LEVEL 3 against a caller-supplied anchor. Returns the report.
185
+
186
+ The anchor is required and must match the one recorded in the
187
+ attestation. Passing the attestation's own anchor back to it would verify
188
+ nothing: the claim would certify itself.
189
+ """
190
+ if isinstance(attestation, dict):
191
+ attestation = ConsensusAttestation.from_dict(attestation)
192
+ if not isinstance(attestation, ConsensusAttestation):
193
+ raise MeshError("attestation must be a ConsensusAttestation")
194
+ if trusted_voters is None:
195
+ raise MeshError("a trusted voters anchor is required to check LEVEL 3")
196
+
197
+ recorded_vc = _b64(bytes(trusted_voters.voters_commitment))
198
+ if recorded_vc != attestation.anchor_voters_commitment_b64:
199
+ raise MeshError(
200
+ "anchor mismatch: the attestation was built against a different "
201
+ "voters commitment than the one you trust"
202
+ )
203
+ if int(trusted_voters.ln_proven_weight) != attestation.anchor_ln_proven_weight:
204
+ raise MeshError("anchor mismatch: proven weight differs from the attestation")
205
+
206
+ expected_gh_bytes: bytes | None = None
207
+ if expected_genesis_hash_b64 is not None:
208
+ if attestation.genesis_hash_b64 != expected_genesis_hash_b64:
209
+ raise MeshError(
210
+ "genesis hash mismatch: attestation is anchored to a different chain"
211
+ )
212
+ try:
213
+ expected_gh_bytes = _unb64(expected_genesis_hash_b64)
214
+ except Exception as exc:
215
+ raise MeshError("expected genesis hash is not valid base64") from exc
216
+
217
+ # evidence_uri is a LOCATOR, not a security binding: it only tells the
218
+ # caller's resolver where to look. It is deliberately not checked against
219
+ # any verified value -- there is none to check it against -- because the
220
+ # integrity binding is the digest below. Whatever the URI points at, only a
221
+ # bundle hashing to evidence_digest passes, so the URI cannot substitute
222
+ # evidence. URI policy (which schemes/hosts are acceptable) belongs to the
223
+ # resolver the caller supplies.
224
+ try:
225
+ bundle = resolve(attestation.evidence_uri)
226
+ except MeshError:
227
+ raise
228
+ except Exception as exc:
229
+ raise MeshError(f"could not resolve evidence {attestation.evidence_uri!r}: {exc}") from exc
230
+ if not isinstance(bundle, dict):
231
+ raise MeshError("resolver returned something that is not a proofpack")
232
+
233
+ actual = bundle_digest(bundle)
234
+ if actual != attestation.evidence_digest:
235
+ raise MeshError(
236
+ f"evidence digest mismatch: attestation commits to "
237
+ f"{attestation.evidence_digest[:16]}..., evidence hashes to {actual[:16]}..."
238
+ )
239
+
240
+ from avm_proofpack.bundle import Bundle
241
+ from avm_proofpack.verify import verify_bundle
242
+
243
+ # Pass the pin down so avm-proofpack enforces it against the *authenticated*
244
+ # block header ('gh' is inside the signed header), not against a field the
245
+ # evidence author chose. Without this the pin would only ever be compared to
246
+ # the attestation's own self-asserted genesis_hash_b64 above.
247
+ try:
248
+ report = verify_bundle(
249
+ Bundle.from_dict(bundle),
250
+ trusted_voters=trusted_voters,
251
+ expected_genesis_hash=expected_gh_bytes,
252
+ )
253
+ except Exception as exc:
254
+ raise MeshError(f"evidence failed verification: {exc}") from exc
255
+
256
+ if report.trust_root != "stake_attested":
257
+ raise MeshError(f"evidence earns {report.trust_root!r}, not stake_attested")
258
+ if report.txid != attestation.txid or report.round != attestation.round:
259
+ raise MeshError("evidence attests a different settlement")
260
+ if report.genesis_id != attestation.chain:
261
+ raise MeshError("evidence is for a different chain")
262
+ # Bind the recorded genesis hash to the one the evidence actually
263
+ # authenticates to. This ties the attestation's genesis_hash_b64 field (and
264
+ # therefore any pin checked against it) to verified data rather than to
265
+ # metadata the bundle author supplied.
266
+ if report.genesis_hash_b64 != attestation.genesis_hash_b64:
267
+ raise MeshError(
268
+ "genesis hash mismatch: attestation records a genesis hash the "
269
+ "evidence does not authenticate to"
270
+ )
271
+ # Bind the claimed attested interval to the one the evidence actually
272
+ # attests. LEVEL 3 exists precisely so the operator who sealed this is NOT
273
+ # trusted; without this check that operator could advertise an interval the
274
+ # evidence does not cover and still verify -- the same gap verify_receipt
275
+ # already closes on its pointer interval.
276
+ if (report.first_attested_round, report.last_attested_round) != (
277
+ attestation.first_attested_round, attestation.last_attested_round):
278
+ raise MeshError(
279
+ f"attested interval mismatch: attestation says "
280
+ f"[{attestation.first_attested_round}, {attestation.last_attested_round}], "
281
+ f"evidence attests [{report.first_attested_round}, {report.last_attested_round}]"
282
+ )
283
+ return report
avm_mesh/bond.py ADDED
@@ -0,0 +1,199 @@
1
+ """Witness bonding: why a witness would tell the truth.
2
+
3
+ A state proof answers "what did the chain do". A witness answers "here is the
4
+ proof". Without a bond, a witness can withhold, delay or selectively serve
5
+ evidence at zero cost, and "decentralised because node operators run it" is a
6
+ centralised service with a bigger logo. The bond is the only thing that makes
7
+ a false or withholding attestation cost money.
8
+
9
+ TWO DISTINCT CLAIMS, kept apart on purpose:
10
+
11
+ bond -- an economic stake that can be slashed. IMPORTANT: this module
12
+ reasons over DECLARED bond values (escrow_txid, bond_microalgo,
13
+ active); it does NOT read the chain. "bonded" here means "claims
14
+ a stake at or above the floor", not "a stake proven on-chain to
15
+ exist, be unspent and be active". On-chain verification of the
16
+ escrow is deliberately out of scope (Track 3, decisions pending),
17
+ so until it lands everything downstream of a bond is a model, not
18
+ a guarantee. The economic argument below is the design intent, not
19
+ something this code enforces.
20
+ operator -- who runs the witness. NOT a trust source; only used to decide
21
+ independence, because two endpoints of one operator are one
22
+ witness wearing two hats.
23
+
24
+ A witness can be perfectly bonded and still be irrelevant to corroboration if
25
+ it shares an operator with the rest of the quorum.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from typing import Iterable
31
+
32
+ MIN_BOND_MICROALGO = 1_000_000
33
+
34
+
35
+ class BondError(Exception):
36
+ """Raised when a witness set cannot support the claim being made."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Witness:
41
+ witness_id: str
42
+ operator_id: str
43
+ chain: str
44
+ bond_microalgo: int
45
+ escrow_txid: str
46
+ active: bool = True
47
+
48
+ def __post_init__(self) -> None:
49
+ # Type, not truthiness: 42 is truthy and would become a "witness id" that
50
+ # no other record could ever match.
51
+ for name, value in (("witness_id", self.witness_id),
52
+ ("operator_id", self.operator_id),
53
+ ("chain", self.chain),
54
+ ("escrow_txid", self.escrow_txid)):
55
+ if not isinstance(value, str) or not value:
56
+ raise BondError(f"{name} must be a non-empty string")
57
+ if not isinstance(self.bond_microalgo, int) or isinstance(self.bond_microalgo, bool):
58
+ raise BondError("bond_microalgo must be an integer")
59
+ if self.bond_microalgo < 0:
60
+ raise BondError("bond cannot be negative")
61
+
62
+ @property
63
+ def bonded(self) -> bool:
64
+ return self.active and self.bond_microalgo >= MIN_BOND_MICROALGO
65
+
66
+ @dataclass(frozen=True)
67
+ class WitnessSet:
68
+ witnesses: tuple[Witness, ...]
69
+
70
+ def __post_init__(self) -> None:
71
+ seen: set[str] = set()
72
+ for w in self.witnesses:
73
+ if w.witness_id in seen:
74
+ raise BondError(f"duplicate witness_id {w.witness_id!r}")
75
+ seen.add(w.witness_id)
76
+
77
+ def bonded(self) -> tuple[Witness, ...]:
78
+ return tuple(w for w in self.witnesses if w.bonded)
79
+
80
+ def independent_corroborators(self, chain: str) -> tuple[Witness, ...]:
81
+ """Bonded witnesses on `chain`, at most ONE per operator and per escrow.
82
+
83
+ Two endpoints of the same operator are not a quorum, and neither are
84
+ two witnesses pointing at one escrow -- that is one bond counted
85
+ twice, which is the cheapest way to fake corroboration.
86
+ """
87
+ chosen: list[Witness] = []
88
+ seen_operators: set[str] = set()
89
+ seen_escrows: set[str] = set()
90
+ for w in sorted(self.bonded(), key=lambda x: (-x.bond_microalgo, x.witness_id)):
91
+ if w.chain != chain:
92
+ continue
93
+ if w.operator_id in seen_operators or w.escrow_txid in seen_escrows:
94
+ continue
95
+ seen_operators.add(w.operator_id)
96
+ seen_escrows.add(w.escrow_txid)
97
+ chosen.append(w)
98
+ return tuple(chosen)
99
+
100
+ def require_corroboration(self, chain: str, threshold: int) -> tuple[Witness, ...]:
101
+ """Return the independent corroborators, or refuse.
102
+
103
+ Refusing is the point: a caller that cannot reach the threshold must
104
+ not be handed a shorter list and left to interpret it.
105
+ """
106
+ if threshold < 1:
107
+ raise BondError("threshold must be at least 1")
108
+ got = self.independent_corroborators(chain)
109
+ if len(got) < threshold:
110
+ raise BondError(
111
+ f"insufficient independent corroboration on {chain}: "
112
+ f"{len(got)} of {threshold} required "
113
+ f"({len(self.bonded())} bonded, "
114
+ f"{len({w.operator_id for w in self.bonded()})} distinct operators)"
115
+ )
116
+ return got[:threshold]
117
+
118
+ def slashable_stake(self, chain: str) -> int:
119
+ """Total DECLARED microalgo a false attestation could put at risk.
120
+
121
+ Counted over independent operators only, because that is the amount
122
+ that could actually be taken for one lie. This is arithmetic over the
123
+ declared bond figures, not a chain-verified sum: it is only as real as
124
+ the bonds themselves, which this module does not verify on-chain.
125
+ """
126
+ return sum(w.bond_microalgo for w in self.independent_corroborators(chain))
127
+
128
+
129
+ def witnesses(*items: Witness | dict) -> WitnessSet:
130
+ """Convenience constructor, tolerating dicts from config or JSON."""
131
+ out = []
132
+ for item in items:
133
+ if isinstance(item, Witness):
134
+ out.append(item)
135
+ elif isinstance(item, dict):
136
+ try:
137
+ out.append(Witness(**item))
138
+ except TypeError as exc:
139
+ raise BondError(f"unusable witness record: {exc}") from exc
140
+ else:
141
+ raise BondError(f"unusable witness entry: {item!r}")
142
+ return WitnessSet(tuple(out))
143
+
144
+ def validate_corroboration(claimed, witness_set: "WitnessSet", chain: str,
145
+ threshold: int) -> tuple["Witness", ...]:
146
+ """Check a receipt's claimed corroboration against the bonded witness set.
147
+
148
+ A receipt records who observed the capture. That claim is worthless unless it
149
+ is checked against bonds, so this refuses on four distinct failures:
150
+
151
+ unknown a witness id nobody has a bond record for
152
+ unbonded a real witness whose stake is absent, inactive or too small
153
+ duplicated the same operator or the same escrow counted twice
154
+ insufficient fewer independent corroborators than the threshold demands
155
+
156
+ Returning a short list would let a caller read a failed quorum as a pass.
157
+ """
158
+ if threshold < 1:
159
+ raise BondError("threshold must be at least 1")
160
+ if claimed is None:
161
+ claimed = ()
162
+ if not isinstance(claimed, (list, tuple)):
163
+ raise BondError("corroboration must be a list of witness ids")
164
+
165
+ by_id = {w.witness_id: w for w in witness_set.witnesses}
166
+ chosen: list[Witness] = []
167
+ seen_operators: set[str] = set()
168
+ seen_escrows: set[str] = set()
169
+
170
+ for wid in claimed:
171
+ if not isinstance(wid, str) or not wid:
172
+ raise BondError(f"corroboration entries must be witness ids, got {wid!r}")
173
+ w = by_id.get(wid)
174
+ if w is None:
175
+ raise BondError(f"claimed corroborator {wid!r} has no bond record")
176
+ if not w.bonded:
177
+ raise BondError(f"claimed corroborator {wid!r} is not bonded")
178
+ if w.chain != chain:
179
+ raise BondError(f"claimed corroborator {wid!r} is bonded on {w.chain!r}, not {chain!r}")
180
+ if w.operator_id in seen_operators:
181
+ raise BondError(
182
+ f"claimed corroborators share operator {w.operator_id!r}: "
183
+ "one operator is one witness"
184
+ )
185
+ if w.escrow_txid in seen_escrows:
186
+ raise BondError(
187
+ f"claimed corroborators share escrow {w.escrow_txid!r}: "
188
+ "one bond is one witness"
189
+ )
190
+ seen_operators.add(w.operator_id)
191
+ seen_escrows.add(w.escrow_txid)
192
+ chosen.append(w)
193
+
194
+ if len(chosen) < threshold:
195
+ raise BondError(
196
+ f"insufficient corroboration on {chain}: claimed {len(chosen)} independent "
197
+ f"witnesses, threshold is {threshold}"
198
+ )
199
+ return tuple(chosen)