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 +45 -0
- avm_mesh/__main__.py +3 -0
- avm_mesh/a2a.py +150 -0
- avm_mesh/attestation.py +283 -0
- avm_mesh/bond.py +199 -0
- avm_mesh/cli.py +138 -0
- avm_mesh/effects.py +162 -0
- avm_mesh/produce.py +120 -0
- avm_mesh/rails/__init__.py +16 -0
- avm_mesh/rails/__main__.py +25 -0
- avm_mesh/rails/verifier.py +142 -0
- avm_mesh/receipt.py +283 -0
- avm_mesh/sealed.py +161 -0
- avm_mesh/service.py +277 -0
- avm_mesh/trust.py +141 -0
- avm_mesh-0.4.2.dist-info/METADATA +333 -0
- avm_mesh-0.4.2.dist-info/RECORD +19 -0
- avm_mesh-0.4.2.dist-info/WHEEL +5 -0
- avm_mesh-0.4.2.dist-info/top_level.txt +1 -0
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
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
|
+
)
|
avm_mesh/attestation.py
ADDED
|
@@ -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)
|