diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..50358a1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +Repository Agent Guide — BlockEnergy Mesh (idea85-blockenergy-mesh-verifiable) +============================================================= + +Purpose +- This repository holds a protocol skeleton and a focused Phase-0 implementation for the BlockEnergy Mesh idea. Agents must preserve the registry, primitives, adapters, and the toy proof mechanism. Future agents will extend with solvers, ZK proofs, and ledger integrations. + +Tech stack +- Python 3.8+ +- pydantic for canonical models and schema validation +- cryptography for signature-based attestations +- numpy for small numerical helpers +- pytest for tests + +Key paths +- src/idea85_blockenergy_mesh_verifiable/: package root + - primitives.py: LocalProblem, SharedVariables, PlanDelta, AuditLog + - registry.py: GraphOfContracts (in-memory registry) + - adapters.py: sample adapters and adapter base class + - proofs.py: toy proof generation and verification + +Testing +- Run `bash test.sh`. The script executes `pytest` and `python3 -m build` to validate packaging. + +Git / CI notes for agents +- Do not run git commands from the agent unless the user explicitly requests commits or pushes. +- When extending interfaces or changing persisted schema, update registry tests and include a migration plan. + +Contribution rules +- Keep changes minimal and focused. Follow the repo's dataclass/pydantic patterns. +- Add unit tests for any new behavior. Ensure `pytest` remains green. diff --git a/README.md b/README.md index 99c4fb9..3799dbe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ -# idea85-blockenergy-mesh-verifiable +BlockEnergy Mesh — idea85-blockenergy-mesh-verifiable +===================================================== -Source logic for Idea #85 \ No newline at end of file +This repository contains a focused, production-oriented chunk of the BlockEnergy Mesh project: a protocol skeleton, canonical primitives, a Graph-of-Contracts registry, two sample adapters, and a toy on-chain proof mechanism (signature-based attestation of constraint satisfaction). + +What is included (MVP slice) +- Canonical primitives: LocalProblem, SharedVariables, PlanDelta, AuditLog (pydantic models) +- Graph-of-Contracts: simple registry to version contract schemas and adapters +- Two starter adapters: SubstationMeterAdapter and DerAggregatorAdapter that map device data to LocalProblem and produce PlanDelta +- Toy proof mechanism: cryptographic commitment (SHA-256) + ECDSA signature (via cryptography) as a verifiable attestation +- Tests and packaging: pytest tests and pyproject.toml for packaging + +Run tests +1. Create a virtualenv (recommended) +2. Install test deps (optional): `pip install -r requirements.txt` (not required) +3. Run the test script: `bash test.sh` + +This will run `pytest` and `python3 -m build` to validate packaging metadata. + +Roadmap +- This patch implements Phase 0 skeleton and a toy proof scheme. Next steps by the swarm: integrate ZK proofs (snarkjs/circom or zksnark bindings), off-chain solver (ADMM-lite), delta-sync reconciliation, governance ledger integration, and adapter conformance suite. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f94332b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea85-blockenergy-mesh-verifiable" +version = "0.1.0" +description = "BlockEnergy Mesh: Verifiable Cross-Utility Microgrid Data Exchange (toy MVP)" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "cryptography>=3.4", + "pydantic>=1.8", + "numpy>=1.21", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..50fe8df --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +cryptography>=3.4 +pydantic>=1.8 +numpy>=1.21 +pytest diff --git a/src/idea85_blockenergy_mesh_verifiable/__init__.py b/src/idea85_blockenergy_mesh_verifiable/__init__.py new file mode 100644 index 0000000..25e5528 --- /dev/null +++ b/src/idea85_blockenergy_mesh_verifiable/__init__.py @@ -0,0 +1,7 @@ +"""idea85_blockenergy_mesh_verifiable package.""" +__all__ = [ + "primitives", + "registry", + "adapters", + "proofs", +] diff --git a/src/idea85_blockenergy_mesh_verifiable/adapters.py b/src/idea85_blockenergy_mesh_verifiable/adapters.py new file mode 100644 index 0000000..16cbd57 --- /dev/null +++ b/src/idea85_blockenergy_mesh_verifiable/adapters.py @@ -0,0 +1,79 @@ +from typing import Dict, Any +from datetime import datetime +from .primitives import LocalProblem, PlanDelta +from .proofs import generate_proof, generate_ec_keypair, ToyProof + + +class AdapterBase: + def __init__(self, name: str): + self.name = name + # each adapter owns a keypair for signing proofs in this MVP + self._priv = generate_ec_keypair() + + def private_key(self): + return self._priv + + +class SubstationMeterAdapter(AdapterBase): + """Maps a substation meter reading to a LocalProblem.""" + + def to_local_problem(self, meter_payload: Dict[str, Any]) -> LocalProblem: + # Expect payload with node_id, timestamp, loads list, capacity + return LocalProblem( + schema_version="localproblem-v1", + node_id=meter_payload["node_id"], + timestamp=datetime.fromisoformat(meter_payload["timestamp"]), + horizon_minutes=int(meter_payload.get("horizon_minutes", 60)), + net_load_kw=meter_payload["net_load_kw"], + capacity_kw=float(meter_payload["capacity_kw"]), + metadata=meter_payload.get("metadata"), + ) + + def produce_plan_and_proof(self, meter_payload: Dict[str, Any]) -> Dict[str, Any]: + lp = self.to_local_problem(meter_payload) + + # naive deterministic plan: zero-out anything above capacity by scaling + avg = sum(lp.net_load_kw) / max(1, len(lp.net_load_kw)) + scale = min(1.0, lp.capacity_kw / max(1e-6, avg)) + plan = [v * scale for v in lp.net_load_kw] + + delta = [p - v for p, v in zip(plan, lp.net_load_kw)] + + pd = PlanDelta(schema_version="plandelta-v1", node_id=lp.node_id, delta_schedule_kw=delta, reason="scale_to_capacity") + + # Create claims (toy): capacity respected if max(plan) <= capacity + claims = {"capacity_ok": max(plan) <= lp.capacity_kw} + + proof = generate_proof(lp.dict(), claims, self._priv) + + return {"plan_delta": pd, "proof": proof} + + +class DerAggregatorAdapter(AdapterBase): + """Maps an aggregator's API to LocalProblem; slightly different heuristic.""" + + def to_local_problem(self, agg_payload: Dict[str, Any]) -> LocalProblem: + return LocalProblem( + schema_version="localproblem-v1", + node_id=agg_payload["id"], + timestamp=datetime.fromisoformat(agg_payload.get("ts")), + horizon_minutes=int(agg_payload.get("horizon", 60)), + net_load_kw=agg_payload["profile"], + capacity_kw=float(agg_payload.get("cap", 0.0)), + metadata=agg_payload.get("meta"), + ) + + def produce_plan_and_proof(self, agg_payload: Dict[str, Any]) -> Dict[str, Any]: + lp = self.to_local_problem(agg_payload) + + # simple heuristic: shift down high peaks by fixed margin 10% + plan = [v * 0.9 if v > 0.8 * lp.capacity_kw else v for v in lp.net_load_kw] + delta = [p - v for p, v in zip(plan, lp.net_load_kw)] + + pd = PlanDelta(schema_version="plandelta-v1", node_id=lp.node_id, delta_schedule_kw=delta, reason="peak_shave_10pct") + + claims = {"capacity_ok": max(plan) <= lp.capacity_kw} + + proof = generate_proof(lp.dict(), claims, self._priv) + + return {"plan_delta": pd, "proof": proof} diff --git a/src/idea85_blockenergy_mesh_verifiable/primitives.py b/src/idea85_blockenergy_mesh_verifiable/primitives.py new file mode 100644 index 0000000..560719b --- /dev/null +++ b/src/idea85_blockenergy_mesh_verifiable/primitives.py @@ -0,0 +1,41 @@ +from typing import List, Dict, Optional +from pydantic import BaseModel, Field +from datetime import datetime + + +class VersionedModel(BaseModel): + schema_version: str = Field(..., description="Schema version identifier") + + +class LocalProblem(VersionedModel): + """Canonical representation of a local optimization problem for a DER domain.""" + node_id: str + timestamp: datetime + horizon_minutes: int + net_load_kw: List[float] + capacity_kw: float + metadata: Optional[Dict] = None + + +class SharedVariables(VersionedModel): + """Variables shared across domains (morphisms) for coordinated optimization.""" + node_id: str + schedule_kw: List[float] + price_signal: Optional[List[float]] = None + + +class PlanDelta(VersionedModel): + """Incremental plan updates produced by a local solver or adapter.""" + node_id: str + delta_schedule_kw: List[float] + reason: Optional[str] = None + + +class AuditLog(VersionedModel): + """Audit entry tying objects and proofs together for long-term verifiability.""" + entry_id: str + created_at: datetime + author: str + object_hash: str + proof_ref: Optional[str] = None + metadata: Optional[Dict] = None diff --git a/src/idea85_blockenergy_mesh_verifiable/proofs.py b/src/idea85_blockenergy_mesh_verifiable/proofs.py new file mode 100644 index 0000000..c895c2a --- /dev/null +++ b/src/idea85_blockenergy_mesh_verifiable/proofs.py @@ -0,0 +1,79 @@ +import json +from hashlib import sha256 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature, decode_dss_signature +from cryptography.hazmat.primitives import serialization +from cryptography.exceptions import InvalidSignature + + +def _canonical_json(obj) -> bytes: + # Use json.dumps with a default serializer for datetimes and other non-serializable + # types. Keep keys sorted for deterministic commitments. + return json.dumps( + obj, + sort_keys=True, + separators=(",", ":"), + default=lambda o: o.isoformat() if hasattr(o, "isoformat") else str(o), + ).encode("utf-8") + + +class ToyProof: + """Toy proof: commitment (sha256 of object) + ECDSA signature by prover key. + + The proof object carries a minimal claim set (e.g., capacity_ok) and a signature + over (commitment || claims) so verifiers can check the attestation without raw data. + """ + + def __init__(self, commitment_hex: str, claims: dict, signature: bytes, pubkey_bytes: bytes): + self.commitment = commitment_hex + self.claims = claims + self.signature = signature + self.pubkey = pubkey_bytes + + def to_dict(self): + return { + "commitment": self.commitment, + "claims": self.claims, + "signature": self.signature.hex(), + "pubkey": self.pubkey.hex(), + } + + +def generate_proof(obj: dict, claims: dict, private_key: ec.EllipticCurvePrivateKey) -> ToyProof: + # Commitment: hash of canonical JSON of the object + commitment = sha256(_canonical_json(obj)).hexdigest() + + payload = {"commitment": commitment, "claims": claims} + payload_b = _canonical_json(payload) + + signature = private_key.sign(payload_b, ec.ECDSA(hashes.SHA256())) + + pubkey = private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + return ToyProof(commitment, claims, signature, pubkey) + + +def verify_proof(obj: dict, proof: ToyProof) -> bool: + # Recompute commitment + commitment = sha256(_canonical_json(obj)).hexdigest() + if commitment != proof.commitment: + return False + + payload = {"commitment": proof.commitment, "claims": proof.claims} + payload_b = _canonical_json(payload) + + # load pubkey + pubkey = serialization.load_der_public_key(proof.pubkey) + try: + pubkey.verify(proof.signature, payload_b, ec.ECDSA(hashes.SHA256())) + return True + except InvalidSignature: + return False + + +def generate_ec_keypair() -> ec.EllipticCurvePrivateKey: + return ec.generate_private_key(ec.SECP256R1()) diff --git a/src/idea85_blockenergy_mesh_verifiable/registry.py b/src/idea85_blockenergy_mesh_verifiable/registry.py new file mode 100644 index 0000000..2f90711 --- /dev/null +++ b/src/idea85_blockenergy_mesh_verifiable/registry.py @@ -0,0 +1,30 @@ +from typing import Dict, Any, List +from copy import deepcopy + +class GraphOfContracts: + """Simple in-memory registry mapping contract names to versioned schemas and adapters. + + This is intentionally lightweight: it stores schema metadata and adapter references. + """ + + def __init__(self): + # structure: { contract_name: { version: {"schema": ..., "adapters": [names] } } } + self._store = {} + + def register_schema(self, contract_name: str, version: str, schema: Any): + self._store.setdefault(contract_name, {}) + self._store[contract_name][version] = {"schema": deepcopy(schema), "adapters": []} + + def list_versions(self, contract_name: str) -> List[str]: + return list(self._store.get(contract_name, {}).keys()) + + def add_adapter(self, contract_name: str, version: str, adapter_name: str): + if contract_name not in self._store or version not in self._store[contract_name]: + raise KeyError("Schema version not found") + self._store[contract_name][version]["adapters"].append(adapter_name) + + def get_schema(self, contract_name: str, version: str): + return self._store[contract_name][version]["schema"] + + def adapters_for(self, contract_name: str, version: str): + return list(self._store[contract_name][version]["adapters"]) diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..12cf4e3 --- /dev/null +++ b/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + + +echo "Installing package in editable mode..." +pip install -e . + +echo "Running pytest..." +pytest -q + +echo "Building package (python3 -m build)..." +python3 -m build + +echo "All tests and build completed successfully." diff --git a/tests/test_primitives.py b/tests/test_primitives.py new file mode 100644 index 0000000..4440765 --- /dev/null +++ b/tests/test_primitives.py @@ -0,0 +1,46 @@ +import pytest +from idea85_blockenergy_mesh_verifiable.primitives import LocalProblem +from idea85_blockenergy_mesh_verifiable.adapters import SubstationMeterAdapter, DerAggregatorAdapter +from idea85_blockenergy_mesh_verifiable.proofs import verify_proof +from datetime import datetime + + +def sample_meter_payload(): + return { + "node_id": "substation-1", + "timestamp": datetime.utcnow().isoformat(), + "horizon_minutes": 60, + "net_load_kw": [10.0, 12.0, 11.0, 9.0], + "capacity_kw": 12.0, + } + + +def test_substation_adapter_plan_and_proof(): + adapter = SubstationMeterAdapter("submeter") + payload = sample_meter_payload() + out = adapter.produce_plan_and_proof(payload) + + assert "plan_delta" in out and "proof" in out + plan_delta = out["plan_delta"] + proof = out["proof"] + + # Plan delta is a PlanDelta model + assert plan_delta.node_id == payload["node_id"] + # Proof verifies against original LocalProblem + lp = adapter.to_local_problem(payload) + assert verify_proof(lp.dict(), proof) + + +def test_der_adapter_plan_and_proof(): + adapter = DerAggregatorAdapter("deragg") + payload = { + "id": "der-1", + "ts": datetime.utcnow().isoformat(), + "horizon": 60, + "profile": [2.0, 3.0, 5.0, 1.0], + "cap": 6.0, + } + out = adapter.produce_plan_and_proof(payload) + assert "plan_delta" in out and "proof" in out + lp = adapter.to_local_problem(payload) + assert verify_proof(lp.dict(), out["proof"]) is True