An on-chain public good for transparent energy reporting, built on the Ethereum Attestation Service (EAS). Any energy project can attest production or consumption data publicly and permanently on-chain, creating a verifiable record that anyone can audit.
Project site: attest.energy
With the goal of becoming an open standard for on-chain energy reporting.
- Core concepts
- Protocol architecture
- Subgraph
- Operations
- Security
Critical terms used consistently throughout this document:
| Term | Definition | Code Format | JSON Format |
|---|---|---|---|
| Period | A time range for an attestation, bounded by fromTimestamp and toTimestamp |
(fromTimestamp, toTimestamp) |
from_timestamp, to_timestamp |
| Attestation | A signed energy report stored on-chain via EAS, containing readings for a period | Noun (singular/plural) | Entity in JSON reports |
| Reporting period | The time range covered by readings (equivalent to "period") | Used interchangeably with "period" | — |
| Energy | Watt-hours (Wh) accumulated over a period | uint256 energyWh |
readings[] (array) |
| Power | Instantaneous rate (W, kW, kWp) — different from energy | Not on-chain | In project metadata only |
| Readings | Array of per-interval energy values in Wh | uint256[] readings |
readings array in JSON |
| Reading interval | Time between consecutive readings (in minutes) | readingIntervalMinutes |
reading_interval |
| Collection method | How data was gathered: "manual", "iot", "estimated" |
method field |
method field |
| Metadata URI | Off-chain JSON document with proof/audit context | metadataURI (camelCase) |
metadata_uri (snake_case) |
| Replacement | Correcting an attestation by submitting new data with refUID pointing to old UID |
Via refUID |
ref_uid in JSON |
| Chain/Chain tip | Sequence of gapless attestations; chain tip = toTimestamp of the last one |
getProjectLastTimestamp() |
— |
Code vs JSON convention: Solidity and TypeScript use camelCase (
fromTimestamp,metadataURI). JSON reports from off-chain systems use snake_case (from_timestamp,metadata_uri). Scripts automatically convert between them.
Ethereum Attestation Service (EAS)
The Ethereum Attestation Service is a permissionless protocol for making and verifying on-chain statements ("attestations") about anything. An attestation is a signed, structured piece of data stored on-chain, linked to a schema that defines its fields.
Key components used in this project:
- SchemaRegistry — registers the data structure that all energy attestations follow
- EAS — accepts attestation submissions and routes them through a resolver contract for validation
- SchemaResolver — a custom contract (this repo) that EAS calls on every attest/revoke, where business logic lives
This means EAS handles storage, indexing, and revocation — and this contract handles who can attest and what data is valid.
uint64 projectId, uint32 readingCount, uint32 readingIntervalMinutes, uint256[] readings, uint64 fromTimestamp, string method, string metadataURI
| Field | Type | Description |
|---|---|---|
projectId |
uint64 |
ID of the registered energy project |
readingCount |
uint32 |
Number of interval readings in this report (must equal readings.length) |
readingIntervalMinutes |
uint32 |
Interval length in minutes between readings |
readings |
uint256[] |
Per-interval energy in Wh (array length must equal readingCount) |
fromTimestamp |
uint64 |
Start of the reporting period (Unix seconds) |
method |
string |
Collection method: "manual", "iot", "estimated", etc. |
metadataURI |
string |
Optional URI pointing to supporting evidence (pass "" for none) |
Note:
energyTypeis no longer a per-attestation field. It is set once at project registration and stored on the project. The resolver reads it from the registry on every attestation — attesters never need to re-declare it.
The resolver derives:
toTimestamp = fromTimestamp + readingCount * readingIntervalMinutes * 60energyWh = sum(readings)(may be0for maintenance/offline periods)
Each reading is stored as watt-hours (Wh) as a uint256 integer to avoid floating-point precision issues on-chain.
| Conversion | Example |
|---|---|
| 1 kWh = 1,000 Wh | 1000 |
| 1 MWh = 1,000,000 Wh | 1000000 |
| 1 GWh = 1,000,000,000 Wh | 1000000000 |
Wh vs W vs kW: The schema reports energy (Wh — watt-hours, accumulated over a period). Do not confuse this with power (W or kW — the instantaneous rate of generation). A 100 kW solar installation running for one hour produces 100,000 Wh (100 kWh). Project metadata can separately describe installed power capacity (kW), but all on-chain attestation data is always in Wh.
The energyType is stored on the project (set once at registration, never changes). It serves a dual purpose: it identifies the generation source and determines whether the project is a generator or consumer.
Reserved value: 0 = consumer. Any project registered with energyType = 0 is treated as a consumer — its attestations flow into the consumed accumulator. All other IDs must be registered in the on-chain energy type registry and indicate a generator project.
| ID | Name | Notes |
|---|---|---|
| 0 | consumer |
Reserved — consumption project (no generation source) |
| 1 | solar_pv |
Photovoltaic solar |
| 2 | wind_onshore |
Land-based wind turbines |
| 3 | wind_offshore |
Offshore wind turbines |
| 4 | hydro |
Hydroelectric (run-of-river or reservoir) |
| 5 | biomass |
Biomass combustion or biogas |
| 6 | geothermal |
Geothermal heat or steam |
| 7 | ocean_tidal |
Tidal or wave energy |
| 8 | nuclear |
Nuclear fission |
| 9 | natural_gas |
Natural gas combustion |
| 10 | coal |
Coal combustion |
| 11 | oil |
Oil/diesel combustion |
| 12 | storage_discharge |
Battery or other storage system discharge |
| 13 | hydrogen_fuel_cell |
Hydrogen fuel cell |
IDs 1–13 are pre-registered at deployment. A dedicated energy type admin role (set to the deployer at construction, transferable) can add or remove types without redeployment.
- Add a type:
registry.registerEnergyType(id, name)— energy type admin only - Remove a type:
registry.removeEnergyType(id)— energy type admin only - Transfer the admin role:
registry.transferEnergyTypeAdmin(newAdmin)— current admin only
A project's category is determined by its energyType, set once at registration and permanent:
energyType = 0→ consumer — energy consumed by this project (operational load, carbon accounting, etc.)energyType = 1–13+→ generator — energy produced by this project; the ID specifies the generation source (solar, wind, hydro, etc.)
getProjectType(projectId) returns the conventional 0/1 flag (0 = generator, 1 = consumer) derived from energyType. getProjectEnergyType(projectId) returns the raw stored value.
This prevents misclassification: a solar farm is registered with energyType = 1 and will only ever accumulate generated energy — its attesters cannot accidentally submit consumption readings.
The registry maintains separate accumulators for each direction:
getTotalGeneratedEnergy(projectId)/getTotalGeneratedEnergyByWatcher(watcherId)getTotalConsumedEnergy(projectId)/getTotalConsumedEnergyByWatcher(watcherId)
Replacements update the correct accumulator automatically, derived from the project's energyType.
The metadataURI field is optional (pass "" to omit). When populated, it points to a JSON document with any supporting evidence or context for that specific attestation — audit reports, raw IoT readings, certifications, or anything else the attester wants to anchor on-chain.
This makes the schema forward-compatible: future use cases can attach richer data without changing the schema or creating a new schema UID.
Recommended format (all fields optional):
{
"proof": "ipfs://QmAuditReportHash",
"certifier": "0xAuditorWalletAddress",
"device_id": "sensor-42",
"notes": "Monthly aggregate for March 2026. Raw readings available at proof URI."
}| Field | Description |
|---|---|
proof |
URI to the primary evidence document (PDF, CSV, JSON) |
certifier |
Address or identifier of the third-party that verified the reading |
device_id |
IoT device or meter identifier |
notes |
Free-text context for the attester |
Add any custom fields your use case requires — indexers that don't recognise them will ignore them.
URI conventions:
- Use
ipfs://Qm...for immutable evidence snapshots (audit PDFs, signed meter exports) - Use
https://...for live endpoints (dashboards, APIs)
Example — attaching an audit PDF:
const metadataURI = "ipfs://QmXyZ..."; // IPFS CID of the uploaded PDF
const data = AbiCoder.defaultAbiCoder().encode(
["uint64", "uint32", "uint32", "uint256[]", "uint64", "string", "string"],
[projectId, readingCount, readingIntervalMinutes, readings, fromTimestamp, "manual", metadataURI]
);Batch reporting is supported via EAS's multiAttest() — useful for submitting multiple hourly readings in a single transaction.
Attestations for each project form a gapless, sequential chain: the toTimestamp of attestation N must equal the fromTimestamp of attestation N+1. This is enforced on-chain with O(1) cost via a single mapping -- no overlap scans required.
- The first attestation for a project can start at any timestamp and establishes the chain's starting point.
- Every subsequent attestation must start exactly where the previous one ended.
- Call
getProjectLastTimestamp(projectId)to determine where the next attestation must begin. Returns0if the project has no attestations yet.
Disclaimer: The first attestation should represent the oldest available data for the project. There is no mechanism to retroactively fill gaps before the first attestation's fromTimestamp.
After the first attestation, the contract enforces strict continuity:
- First attestation: Can start at any timestamp (establishes the chain origin)
- Subsequent attestations: Must start exactly at the previous attestation's
toTimestamp
Example flow:
- Attest period 1000–2000 → chain tip is now
2000 - Next attestation must start at
2000→ can be2000–3000,2000–4000, etc. - If you submit starting at 1500 (gap backward) → reverts with
NonSequentialAttestation(projectId, 2000, 1500) - If you submit starting at 2100 (gap forward) → reverts with
NonSequentialAttestation(projectId, 2000, 2100) - If you submit starting at 2000 → succeeds, chain extends
This is O(1) enforcement: the contract checks one value (getProjectLastTimestamp) per attestation, not scanning all prior periods.
To correct a previously submitted attestation, submit a new EAS attestation with refUID set to the UID of the attestation being replaced.
Rules:
- The replacement must cover the exact same time period -- same
fromTimestampand same derivedtoTimestamp. Only the readings, method, and metadataURI can change. - Any currently-authorized attester for the project can perform the replacement (not restricted to the original attester).
- After the replacement is recorded, the old EAS attestation can be revoked by calling
EAS.revoke()on it — the resolver allows this only for attestations that have already been replaced. The SDK'soverwriteAttestation()handles this automatically (two transactions: replace, then revoke). On EAS explorer the old attestation will appear as revoked. - Use
getReplacementUID(uid)to follow the replacement chain: it returns the new UID if the attestation was replaced, orbytes32(0)if it is still current.
Example flow:
const correctedData = AbiCoder.defaultAbiCoder().encode(
["uint64", "uint32", "uint32", "uint256[]", "uint64", "string", "string"],
[projectId, readingCount, readingIntervalMinutes, correctedReadings, fromTimestamp, "manual", metadataURI]
);
await eas.attest({
schema: schemaUID,
data: {
recipient: ethers.ZeroAddress,
expirationTime: NO_EXPIRATION_TIME,
revocable: true,
refUID: oldAttestationUid, // the UID being replaced
data: correctedData,
value: 0,
},
});- Chain tip unchanged — Replacing any attestation in the chain does not change
getProjectLastTimestamp()- Example: Project has 3 attestations: A (1000–2000), B (2000–3000), C (3000–4000). If you replace B with B', the chain tip stays at 4000. Future attestations must still start at 4000.
- Follow the replacement trail — Use
getReplacementUID(oldUid)to track corrections:- Returns new UID if the attestation was replaced,
bytes32(0)if current - Allows auditors to trace the full history: A → B → C (where C supersedes B in the data, but both exist on EAS)
- Returns new UID if the attestation was replaced,
- Cannot replace an already-replaced attestation — If you replaced attestation X with Y, you cannot later submit a replacement for X; submit a new replacement for Y instead
- Reverts with
AttestationAlreadyReplaced(uid)if you try to replace twice
- Reverts with
- Authorization is permissionless within the attester whitelist — The IoT device that originally attested can be replaced by a manual backup attester
- Any wallet authorized for that project (via
isProjectAttesterorisWatcherAttester) can submit a replacement - Useful for handling sensor failures or data corrections from different sources
- Any wallet authorized for that project (via
Replacements atomically adjust the correct accumulator (based on project type: generator vs. consumer):
| Scenario | Example | Result |
|---|---|---|
| Increase energy | Replace 5,000 Wh with 7,000 Wh | Accumulator += 2,000 Wh |
| Decrease energy | Replace 5,000 Wh with 3,000 Wh | Accumulator -= 2,000 Wh |
| Zero reading (maintenance) | Replace 5,000 Wh with 0 Wh | Accumulator -= 5,000 Wh (valid: represents offline/maintenance period) |
| Project type mismatch | Try to replace generator attestation for a consumer project | Reverts: ReplacementProjectMismatch() |
All adjustments are atomic in a single transaction — old value subtracted, new value added, no intermediate state.
EAS.revoke() is blocked at the resolver level for active attestations -- any attempt to directly revoke a current attestation will revert with DirectRevocationBlocked. The only way to correct data is the replacement mechanism described above.
Exception: once an attestation has been replaced (i.e. getReplacementUID(uid) != bytes32(0)), it can be revoked on EAS. The SDK's overwriteAttestation() does this automatically after recording the replacement, so the old attestation appears as revoked on EAS explorer while the full replacement chain remains traceable via the registry.
The contract is fully permissionless and multi-tenant. There is no central gatekeeper — any watcher can join and operate independently.
1. Watchers self-register
Any wallet can call registerWatcher(name) and become a watcher owner. No approval needed. The caller's wallet is permanently the watcher owner (until transferred).
2. Watchers manage their own projects
The watcher owner registers projects under their watcher via registerProject(watcherId, name, energyType). Each project gets a sequential projectId and is declared as either a consumer (energyType = 0) or a generator (energyType = 1–N, where the ID specifies the generation source type) at creation time — this cannot be changed later. Only the watcher owner can register, deregister, or manage attesters for their own projects.
3. Attesters are whitelisted per project (or watcher-wide)
Watcher owners decide who can attest:
addAttester(projectId, wallet)— authorizes a wallet for a specific project onlyaddAttesters(projectId, wallets[])— batch version; adds multiple attesters in one transactionremoveAttester(projectId, wallet)/removeAttesters(projectId, wallets[])— revoke per-project accessaddWatcherAttester(watcherId, wallet)— authorizes a wallet across all projects under the watcherremoveWatcherAttester(watcherId, wallet)— revokes watcher-wide access
This covers the IoT failover case: add both the device wallet and a manual backup wallet. If the device fails, attest manually.
4. Strong tenant isolation
- Watcher A's attesters cannot attest to Watcher B's projects
- Watcher A's owner cannot modify Watcher B's projects or attesters
- The contract enforces this at the EVM level — there is no admin override
5. Energy accumulators
Every attestation updates four running totals, split by project type:
- Per-project generated:
getTotalGeneratedEnergy(projectId) - Per-project consumed:
getTotalConsumedEnergy(projectId) - Per-watcher generated:
getTotalGeneratedEnergyByWatcher(watcherId) - Per-watcher consumed:
getTotalConsumedEnergyByWatcher(watcherId)
Consumer project attestations never inflate generation totals, and vice versa. Replacements adjust the correct accumulator automatically.
6. No duplicate periods
The registry prevents the same (projectId, fromTimestamp, toTimestamp) combination from being attested twice. To correct a period, use the replacement mechanism -- submit a new attestation with refUID pointing to the original.
7. Watcher ownership transfer
Watcher owners can hand off control via transferWatcherOwnership(watcherId, newOwner) — useful for key rotation or team changes.
8. Project transfer
The current watcher owner can move a project to another watcher via transferProject(projectId, toWatcherId). All accumulated energy totals are migrated to the new watcher automatically. Useful when an external monitoring service takes over or a project is sold to another organization.
| Role | Who | Can do |
|---|---|---|
| Contract owner | Deployer wallet | Authorize/deauthorize resolvers, emergency pause/unpause |
| Energy type admin | Deployer wallet (transferable) | Register and remove generation energy types |
| Watcher owner | Wallet that called registerWatcher |
Register/deregister/transfer projects, manage attesters, transfer watcher ownership |
| Attester | Wallet whitelisted by watcher owner | Submit energy attestations for authorized projects |
| Anyone | Any wallet | Register a new watcher, read all public data |
EnergyRegistry (deployed once per network)
├── Watcher 1: "XYZ Solar" (owner: 0xAlice)
│ ├── Project 1: "Farm A"
│ │ ├── Attester: 0xIoTDevice1
│ │ └── Attester: 0xAlice (manual backup)
│ └── Project 2: "Farm B"
│ └── Watcher-wide attester: 0xAuditor (authorized for all projects)
└── Watcher 2: "Wind Co" (owner: 0xBob)
└── Project 3: "Turbine Park"
└── Attester: 0xIoTDevice2
0xIoTDevice1 can only attest to Project 1. 0xAuditor can attest to both Project 1 and 2 (watcher-wide). Neither can touch Watcher 2's projects.
Each project can store a URI pointing to a JSON document with off-chain metadata. Call setProjectMetadataURI(projectId, uri) as the watcher owner to set or update it at any time — no contract upgrade needed. A ProjectMetadataURISet event is emitted on every update so indexers and explorers know to refresh.
The URI can point to IPFS (ipfs://Qm...) for immutable snapshots or HTTPS for a live endpoint. Use IPFS when pinning a finalized certificate, and HTTPS when you want the document to evolve (adding awards, certifications, etc.) without emitting new transactions.
Click to expand: JSON Schema, Fields, and Design Principles (reference only, not required for basic use)
The format is intentionally based on the ERC-721 metadata standard so existing explorers and indexers already understand the base fields. Energy-specific data lives in the properties namespace.
Unit reminder: on-chain attestations always report energy in Wh (watt-hours accumulated over a reporting period). The metadata JSON may additionally describe the installation's rated power in kWp (kilowatts-peak — the maximum rate of generation). These are different physical quantities: a 100 kWp solar installation running for one hour produces 100,000 Wh.
{
"name": "Solar Farm Alpha",
"description": "Rooftop solar installation in Nairobi, Kenya. 100 kWp rated power. IoT-monitored.",
"image": "ipfs://QmImageHash",
"external_url": "https://myenergyproject.com",
"attributes": [
{ "trait_type": "Energy Type", "value": "solar" },
{ "trait_type": "Project Type", "value": "generator" },
{ "trait_type": "Installed Power (kWp)", "value": 100 },
{ "trait_type": "Country", "value": "Kenya" },
{ "trait_type": "Country Code", "value": "KE" },
{ "trait_type": "Data Collection", "value": "IoT" },
{ "trait_type": "Commissioned", "value": "2023-06-01" }
],
"properties": {
"fuel_type": "solar",
"project_type": "generator",
"installed_power_kwp": 100,
"location": {
"country": "KE",
"region": "Nairobi",
"lat": -1.286,
"lon": 36.817
},
"certifications": [
{
"name": "REC",
"issuer": "Gold Standard",
"valid_until": "2027-01-01",
"uri": "ipfs://QmCertificateHash"
}
],
"files": [
{
"uri": "ipfs://QmInstallationReport",
"type": "application/pdf",
"role": "installation_certificate"
},
{
"uri": "https://dashboard.myenergyproject.com",
"type": "text/html",
"role": "live_dashboard"
}
]
},
"version": "1",
"updated_at": "2026-03-13T00:00:00Z"
}Top-level (ERC-721 compatible — indexed by most explorers)
| Field | Required | Description |
|---|---|---|
name |
Yes | Human-readable project name |
description |
No | Free-text description |
image |
No | Cover image URI (IPFS or HTTPS) |
external_url |
No | Project website or dashboard |
attributes |
No | Array of { trait_type, value } — used for filtering in explorers |
**attributes[] — recommended trait types**
trait_type |
Example values |
|---|---|
Energy Type |
"solar", "wind", "hydro", "biomass", "grid" |
Project Type |
"generator", "consumer" |
Installed Power (kWp) |
500 (rated power in kilowatts-peak; not the same as energy in Wh) |
Country |
"Kenya" |
Country Code |
"KE" (ISO 3166-1 alpha-2) |
Data Collection |
"IoT", "Manual", "Estimated" |
Commissioned |
"2023-06-01" (ISO 8601) |
Grid Zone |
"ERCOT", "EU-DE" |
**properties — structured energy data (parsed by EAS-aware indexers)**
| Field | Description |
|---|---|
fuel_type |
"solar", "wind", "hydro", "biomass", "nuclear", "natural_gas" |
project_type |
"generator", "consumer" |
installed_power_kwp |
Rated/peak power of the installation in kilowatts (kWp). This is power (rate), not energy (Wh). |
location.country |
ISO 3166-1 alpha-2 country code |
location.region |
State, province, or region name |
location.lat / location.lon |
GPS coordinates (decimal degrees) |
certifications[] |
Array of certification objects (see below) |
files[] |
Supporting documents: certificates, reports, dashboards |
**certifications[] object**
| Field | Description |
|---|---|
name |
Certificate name ("REC", "I-REC", "REGO", etc.) |
issuer |
Issuing organization |
valid_until |
Expiry date (ISO 8601) |
uri |
Link to the certificate document |
- Fully extensible — add any fields to
propertieswithout touching the contract. Indexers that don't understand custom fields simply ignore them. - Backwards compatible — the base ERC-721 fields (
name,description,attributes) are enough. All other fields are optional. - Update freely — call
setProjectMetadataURIagain with a new URI whenever something changes (new certification, updated capacity, new contact). Each update emitsProjectMetadataURISet. - IPFS for permanence, HTTPS for liveness — pin a snapshot to IPFS when you receive a certification; use an HTTPS endpoint for a live dashboard that always reflects the current state.
The watcher.owner address is the single key that controls all projects and attesters for a watcher. By default this is the wallet that called registerWatcher, which is typically a single externally-owned account (EOA). For any production deployment, strongly consider using a multisig wallet instead.
Click to expand: Why multisig matters and how to set it up
A single EOA is a single point of failure:
- Lost private key = permanent loss of watcher admin access (projects become unmanageable)
- Compromised private key = attacker can add fraudulent attesters or deregister all projects
The contract treats watcher.owner as a plain address — it calls msg.sender == watcher.owner with no other constraints. A Gnosis Safe (or any smart contract wallet) works natively as the owner because Safe transactions appear as msg.sender = safeAddress.
Steps:
- Deploy a Gnosis Safe with your desired signers and threshold (e.g. 2-of-3)
- Call
registerWatcher(name)from the Safe — the Safe address becomes the watcher owner - All subsequent management calls (
registerProject,addAttester, etc.) are executed as Safe transactions
If you already registered with an EOA:
- Call
transferWatcherOwnership(watcherId, safeAddress)from the current EOA to transfer control to the Safe
The contract owner (deployer) address should also be a multisig for the same reasons — it controls resolver authorization and emergency pause.
The system is built around a permanent state layer and a replaceable logic layer, each with a distinct upgrade mechanism.
┌─────────────────────────────────────────────────────────────────────────────┐
│ USERS / SDK │
│ (watchers, IoT devices, project operators) │
└───────────────────────────┬─────────────────────────────────────────────────┘
│ EAS.attest() / EAS.revoke()
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ EAS (Ethereum Attestation Service) │
│ — immutable, deployed by EAS team — │
│ │
│ Stores attestations on-chain. On every attest/revoke calls the │
│ resolver's onAttest() / onRevoke() hook before accepting the tx. │
└───────────────────────────────────┬───────────────────────────────────────┘
│ onAttest() / onRevoke() (hook)
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ EnergyAttestationResolver (replaceable logic) │
│ — deployed once per schema version — │
│ │
│ • Validates: attester authorized, project active, data format valid │
│ • Stateless: only stores the registry address (immutable) │
│ • Can be paused by owner to block new attestations │
└────────┬──────────────────────────────────────────────────────────────────┘
│ read: isProjectRegistered(), isProjectAttester(), getProjectType()
│ write: recordAttestation(), recordReplacement()
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ EnergyRegistry PROXY ◄── permanent address, never changes │
│ ───────────────────────────────────────────────────────────── │
│ EnergyRegistry IMPL ◄── swappable via UUPS upgrade │
│ │
│ • Owns all state: watchers, projects, attesters, energy accumulators │
│ • Emits all events from one address → subgraph never needs repointing │
│ • Multiple resolvers can be authorized simultaneously (migration window) │
│ • Upgradeable via UUPS: impl can change, proxy address is permanent │
└───────────────────────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ The Graph Subgraph │
│ indexes EnergyRegistry events at the proxy address │
└───────────────────────────────────────────────────────────────────────────┘
Deployed once per network. Holds all watcher, project, attester, and energy data. Because all events are emitted here, the subgraph always indexes from the same address regardless of how many resolver or registry versions have been deployed.
Deployed as a UUPS upgradeable proxy (EIP-1967): the proxy address never changes; the implementation behind it can be upgraded to fix bugs or add features while preserving all on-chain state.
Ownership: contract owner. Only the owner can authorize/deauthorize resolvers and upgrade the implementation.
The EAS hook contract. EAS calls it on every attest/revoke. It validates the data (project registered, attester authorized, data well-formed) and delegates all state reads/writes to the registry.
The resolver is stateless except for the immutable registry address. It can be replaced without migrating any data. Multiple versions can run simultaneously during a migration window.
Ownership: contract owner. Only the owner can pause/unpause.
All state is publicly readable from EnergyRegistry. For historical queries, time-series data, and dashboard use cases, see the Subgraph section.
| Function | Returns | Notes |
|---|---|---|
getWatcher(watcherId) |
Watcher struct |
id, name, owner |
isWatcherRegistered(watcherId) |
bool |
|
getNextWatcherId() |
uint64 |
Iterate 1..nextWatcherId-1 to enumerate all watchers |
getProject(projectId) |
Project struct |
Fields: watcherId (uint64), registered (bool), energyType (uint8), name (string) |
getProjectEnergyType(projectId) |
uint8 |
Raw stored value: 0 = consumer, 1–N = generator energy source |
getProjectType(projectId) |
uint8 |
Derived: 0 = generator (energyType≠0), 1 = consumer (energyType=0) |
isProjectRegistered(projectId) |
bool |
false for deregistered projects |
getProjectWatcherId(projectId) |
uint64 |
Which watcher owns this project |
getNextProjectId() |
uint64 |
Iterate 1..nextProjectId-1 to enumerate all projects |
getWatcherProjects(watcherId) |
uint64[] |
All project IDs ever registered under the watcher (including deregistered) |
getProjectMetadataURI(projectId) |
string |
Off-chain metadata URI; "" if not set |
| Function | Returns | Notes |
|---|---|---|
isProjectAttester(projectId, wallet) |
bool |
True if wallet is on the per-project whitelist |
isWatcherAttester(watcherId, wallet) |
bool |
True if wallet has watcher-wide attester access |
| Function | Returns |
|---|---|
getTotalGeneratedEnergy(projectId) |
uint256 Wh — lifetime generation for the project |
getTotalConsumedEnergy(projectId) |
uint256 Wh — lifetime consumption for the project |
getTotalGeneratedEnergyByWatcher(watcherId) |
uint256 Wh — sum across all watcher projects |
getTotalConsumedEnergyByWatcher(watcherId) |
uint256 Wh — sum across all watcher projects |
| Function | Returns | Notes |
|---|---|---|
getAttestedPeriodUID(projectId, fromTimestamp, toTimestamp) |
bytes32 |
EAS attestation UID for the given period; bytes32(0) if none |
getAttestedPeriodStartUID(projectId, fromTimestamp) |
bytes32 |
UID that occupies the start timestamp; bytes32(0) if free (strict-start debugging) |
getProjectLastTimestamp(projectId) |
uint64 |
toTimestamp of the last attestation in the chain; 0 if no attestations yet |
getReplacementUID(uid) |
bytes32 |
New UID if the attestation was replaced; bytes32(0) if still current |
getProjectLastTimestamp tells you where the next attestation must start (sequential chain). getReplacementUID lets you follow the replacement chain for corrected attestations.
| Function | Returns |
|---|---|
isEnergyTypeRegistered(id) |
bool |
getEnergyTypeName(id) |
string |
| Function | Returns | Notes |
|---|---|---|
isAuthorizedResolver(resolver) |
bool |
True if the resolver can write data |
All events are emitted by EnergyRegistry so they persist across resolver upgrades and can always be indexed from one address.
| Event | Indexed fields | Other fields | Notes |
|---|---|---|---|
WatcherRegistered(watcherId, name, owner) |
watcherId, owner |
name |
|
WatcherOwnershipTransferred(watcherId, previousOwner, newOwner) |
watcherId, previousOwner, newOwner |
||
ProjectRegistered(projectId, watcherId, name, energyType) |
projectId, watcherId |
name, energyType |
energyType: 0=consumer, 1–N=generator |
ProjectDeregistered(projectId) |
projectId |
||
ProjectTransferred(projectId, fromWatcherId, toWatcherId) |
projectId, fromWatcherId, toWatcherId |
||
AttesterAdded(projectId, attester) |
projectId, attester |
projectId=0 means watcher-wide scope |
|
AttesterRemoved(projectId, attester) |
projectId, attester |
projectId=0 means watcher-wide scope |
|
EnergyAttested(projectId, fromTimestamp, toTimestamp, energyWh, attester, energyType, metadataURI) |
projectId, attester |
all others | energyType is read from project (0=consumer, 1–N=generator) |
EnergyReplaced(projectId, oldUid, newUid, oldEnergyWh, newEnergyWh, attester, metadataURI, newReadings) |
projectId, oldUid, newUid |
all others | Emitted when an attestation is replaced via refUID; newReadings carries the replacement readings for indexers |
ProjectMetadataURISet(projectId, uri) |
projectId |
uri |
|
EnergyTypeRegistered(id, name) |
id |
name |
|
EnergyTypeRemoved(id, name) |
id |
name |
|
EnergyTypeAdminTransferred(previousAdmin, newAdmin) |
previousAdmin, newAdmin |
||
ResolverAuthorized(resolver) |
resolver |
||
ResolverDeauthorized(resolver) |
resolver |
Errors are reverted by the contracts. Integrations and off-chain tooling should handle these.
**EnergyRegistry**
| Error | Thrown when |
|---|---|
WatcherNotRegistered(watcherId) |
Watcher ID does not exist |
UnauthorizedWatcherOwner(caller, watcherId) |
msg.sender is not the watcher owner |
ProjectNotRegistered(projectId) |
Project ID does not exist or was deregistered |
AttesterAlreadyAuthorized(attester, projectId) |
Adding an attester that is already whitelisted |
AttesterNotAuthorized(attester, projectId) |
Removing an attester that is not whitelisted |
EmptyAttesterArray() |
Batch add/remove called with empty array |
UnauthorizedResolver(caller) |
Resolver not in authorized list tried to write state |
PeriodAlreadyAttested(projectId, fromTimestamp, toTimestamp) |
Exact period already has an active attestation |
PeriodStartAlreadyAttested(projectId, fromTimestamp) |
A different attestation already starts at this timestamp |
InvalidEnergyType(energyType) |
registerProject called with energyType that is neither 0 nor a registered type |
UnauthorizedEnergyTypeAdmin(caller) |
Caller is not the energy type admin |
EnergyTypeNotRegistered(id) |
Tried to remove a type that is not registered |
NonSequentialAttestation(projectId, expectedFrom, actualFrom) |
Attestation fromTimestamp does not match the chain tip |
DirectRevocationBlocked(projectId) |
Direct EAS revocations are blocked |
ReplacementPeriodMismatch(uint64, uint64, uint64, uint64) |
Replacement period does not match the original |
AttestationNotFound(uid) |
Old UID not found at the expected period |
AttestationAlreadyReplaced(uid) |
Attestation was already replaced |
**EnergyAttestationResolver**
| Error | Thrown when |
|---|---|
UnauthorizedAttester(attester) |
Submitting wallet is not on the project or watcher-wide whitelist |
ProjectNotRegistered(projectId) |
Project ID not registered (checked before registry call) |
InvalidTimestamps() |
Derived toTimestamp <= fromTimestamp |
InvalidReadingCount() |
readingCount == 0 |
InvalidReadingInterval() |
readingIntervalMinutes == 0 |
InvalidReadingsLength(readingCount, readingsLength) |
readings.length != readingCount |
TimestampOverflow() |
Computed toTimestamp exceeds uint64.max |
InvalidMethod() |
method field is an empty string |
DirectRevocationBlocked() |
Direct EAS revocations are blocked |
ReplacementPeriodMismatch() |
Replacement period does not match the original |
ReplacementProjectMismatch() |
Replacement targets a different project |
Quick reference for developers encountering failures:
| Scenario | Error | Root Cause | Fix |
|---|---|---|---|
| Submit second attestation with gap | NonSequentialAttestation(projectId, expectedFrom, actualFrom) |
fromTimestamp doesn't match previous toTimestamp |
Call getProjectLastTimestamp(projectId) to find the correct starting timestamp; pass --replace <uid> flag if correcting existing data |
| Try to directly revoke an attestation | DirectRevocationBlocked(projectId) |
Direct EAS revocation is forbidden to preserve chain integrity | Use the replacement mechanism instead: submit a new attestation with refUID = oldUid |
| Replace attestation with different time period | ReplacementPeriodMismatch(...) |
Replacement must use same fromTimestamp and toTimestamp |
Keep the period identical; only change readings, method, or metadataURI |
| Replace attestation for wrong project | ReplacementProjectMismatch() |
The refUID attestation belongs to a different project |
Ensure the old UID and new data are for the same project |
| Unauthorized wallet submits attestation | UnauthorizedAttester(wallet) |
Wallet is not on project or watcher-wide whitelist | Add the wallet via addAttester(projectId, wallet) or addWatcherAttester(watcherId, wallet) |
| Submit attestation with zero readings | Succeeds, energy = 0 Wh | Zero energy is valid | This is intentional — represents maintenance/offline period with no production |
| Try to replace an already-replaced attestation | AttestationAlreadyReplaced(uid) |
Attestation X was already replaced with Y; trying to replace X again | Replace Y instead (the current UID), not the superseded X |
| Period already attested | PeriodAlreadyAttested(projectId, from, to) |
Same exact period exists as active attestation | Use replacement mechanism (with refUID) to correct it |
| Attestation timestamps overflow | TimestampOverflow() |
Computed toTimestamp exceeds uint64.max |
Use smaller readingCount or readingIntervalMinutes |
| Empty readings array | InvalidReadingsLength(readingCount, 0) |
Provided 0 readings but readingCount > 0 |
Ensure readings.length == readingCount |
There are three types of protocol changes, each with a different scope, cost, and impact:
| Type | What changes | Subgraph update | Watchers re-register | State lost |
|---|---|---|---|---|
| Energy type | runtime call, no deploy | No | No | No |
| Resolver upgrade | new resolver contract + new schema | No | Schema UID only | No |
| Registry upgrade | new registry implementation (UUPS) | No | No | No |
Energy types (solar, wind, hydro, etc.) are stored in the registry at runtime. Adding a new type is a single on-chain call — no contract deployment, no subgraph change, no watcher action needed.
owner wallet
│
└─► registry.registerEnergyType(14, "offshore_solar")
│
└─► writes to _energyTypeNames[14] and _energyTypeRegistered[14]
emits EnergyTypeRegistered(14, "offshore_solar")
When to use: new energy source needs to be tracked. The uint8 key supports IDs 0–255 (0 is reserved as consumer sentinel; 1–13 pre-registered at deploy time; 14–255 available).
Use this when attestation validation logic needs to change (new field, new rule, bug fix in the hook), or when the EAS schema definition itself changes. Each resolver version is tied to exactly one schema UID on EAS.
No state is ever migrated. All watchers, projects, attesters, and energy totals stay in the registry unchanged. Only the schema UID used when submitting attestations changes.
┌─────────────┐ ┌─────────────────────────────────────┐
│ Users │ │ EAS │
│ (watchers) │ │ │
└──────┬──────┘ └──────────┬──────────────────────────┘
│ │
│ attest with old schemaUID │ onAttest()
├────────────────────────────►│ ─────────────────────────► ResolverV1 ──► Registry
│ │ (authorized)
│ attest with new schemaUID │ onAttest()
└────────────────────────────►│ ─────────────────────────► ResolverV2 ──► Registry
│ (authorized)
Both resolvers write to the same registry. All attestations go to the same state, emit the same events, and appear in the same subgraph — regardless of which resolver version processed them.
1. Deploy new EnergyAttestationResolver(easAddress, registryProxyAddress)
│
└─► resolverV2Address
2. registry.authorizeResolver(resolverV2Address)
│
└─► both V1 and V2 are now active simultaneously
3. eas.schemaRegistry.register(newSchema, resolverV2Address, revocable)
│
└─► newSchemaUID ← share this with all watchers
4. Notify watchers: update your SDK/scripts to use newSchemaUID
SDK config before: { schemaUID: "0xOLD..." }
SDK config after: { schemaUID: "0xNEW..." }
That is the only change watchers make. Watcher IDs, project IDs, attester whitelists, and all accumulated energy totals carry over automatically.
1. resolverV1.pause()
│
└─► blocks new attestations through V1 schema (reverts onAttest)
2. registry.deauthorizeResolver(resolverV1Address)
│
└─► removes V1 write access to the registry
3. old schemaUID is retired — existing attestations under it remain on EAS forever
When to use: changing attestation validation rules, adding new schema fields, fixing a bug in onAttest/onRevoke, or any change to the EAS schema string.
Use this only when the registry's own storage or logic needs to change — new data structures, new on-chain aggregation, bug in registry state management. The proxy address never changes, so the subgraph data source, all resolver contracts, and all SDK configs require zero updates.
BEFORE AFTER
EAS ──► ResolverV2 ──► [Proxy: 0xABCD] EAS ──► ResolverV2 ──► [Proxy: 0xABCD]
│ │
[Impl V1] [Impl V2]
(old code) (new code)
same storage ✓
same proxy address ✓
1. Write and audit RegistryV2.sol
│
└─► must inherit from RegistryV1 layout:
- never reorder or remove existing state variables
- append new variables BEFORE __gap
- reduce __gap by N for each N new uint256-equivalent variable added
2. Deploy bare RegistryV2 implementation (initialize() is blocked by _disableInitializers)
│
└─► implV2Address
3. registry.upgradeToAndCall(implV2Address, "0x") ← called from owner wallet
│
├─► proxy now delegates to implV2
├─► all storage (watchers, projects, energy totals) is untouched
└─► subgraph continues indexing from the same proxy address, no reindex needed
4. Verify: registry.getNextWatcherId() still returns expected value
registry.getWatcher(1) still returns correct data
// V1 state variables (DO NOT TOUCH):
mapping(address => bool) private _authorizedResolvers; // slot 2
mapping(uint64 => Watcher) private _watchers; // slot 3
uint64 private _nextWatcherId; // slot 4
// ... (slots 5–21) ...
address private _energyTypeAdmin; // slot 21
uint256[50] private __gap; // slots 22–71
// V2: adding 2 new variables → reduce gap from 50 to 48
address private _energyTypeAdmin; // slot 21 ← unchanged
address private _newFeatureAddress; // slot 22 ← new variable
uint256 private _newFeatureConfig; // slot 23 ← new variable
uint256[48] private __gap; // slots 24–71 ← 50 - 2 = 48
Rule: consumed slots + remaining gap must always equal 50.
When to use: adding new on-chain aggregation fields, changing registry data structures, fixing a state-management bug in recordAttestation/recordReplacement. Do not use for attestation validation changes — that is a resolver upgrade.
Energy type Resolver upgrade Registry upgrade
(runtime call) (new deploy) (UUPS)
─────────────────────────────────────────────────────────────────────────
Proxy address unchanged unchanged unchanged
Registry state unchanged unchanged unchanged
Subgraph reindex no no no
Watchers re-register no no no
Watchers update SDK no schemaUID only no
EAS schema unchanged new schema + UID unchanged
Resolver address unchanged new address unchanged
The Energy Attestation Service subgraph indexes all EnergyRegistry events and exposes them via a GraphQL API, enabling dashboards and analytics without reading directly from the chain.
Endpoint (Polygon Amoy):
https://api.studio.thegraph.com/query/119110/energy-attestation-service/version/latest
| Entity | Description |
|---|---|
Protocol |
Global counters — total watchers, projects, attestations, and energy |
Watcher |
Registered watcher with owner, project count, and energy totals |
Project |
Energy project with type, accumulated Wh, and attestation count |
EnergyAttestation |
Individual attestation with readings array, timestamps, and attester |
DailyEnergySnapshot |
Per-project daily aggregates for time-series charts |
EnergyType |
Registered energy generation types (solar_pv, wind_onshore, etc.) |
ProjectAttester |
Per-project attester whitelist entries |
WatcherAttester |
Watcher-wide attester whitelist entries |
WatcherOwnershipTransfer |
Historical ownership transfer log |
Protocol overview:
{
protocol(id: "protocol") {
totalWatchers
totalProjects
totalAttestations
totalGeneratedWh
}
}Attestations for a project:
{
energyAttestations(
where: { project: "1" }
orderBy: fromTimestamp
orderDirection: asc
) {
id
fromTimestamp
toTimestamp
energyWh
readings
energyType { name }
attester
replaced
}
}Daily time-series for a project:
{
dailyEnergySnapshots(
where: { project: "1" }
orderBy: timestamp
orderDirection: asc
) {
date
generatedWh
attestationCount
}
}The subgraph source lives in subgraph/. After any contract change that modifies events:
npm run compile # recompile contracts
npm run copy-abi # copy fresh ABI to subgraph/abis/
cd subgraph
npm run codegen # regenerate AssemblyScript types
npm run build # compile to WASM
npm run deploy:amoy # deploy to The Graph StudioReference (contract deployment addresses)
| Network | EAS | SchemaRegistry |
|---|---|---|
| Polygon Mainnet | 0x5E634ef5355f45A855d02D66eCD687b1502AF790 |
0x7876EEF51A891E737AF8ba5A5E0f0Fd29073D5a7 |
| Polygon Amoy (testnet) | 0xb101275a60d8bfb14529C421899aD7CA1Ae5B5Fc |
0x23c5701A1BDa89C61d181BD79E5203c730708AE7 |
| Celo Mainnet | 0x72E1d8ccf5299fb36fEfD8CC4394B8ef7e98Af92 |
0x5ece93bE4BDCF293Ed61FA78698B594F2135AF34 |
Deployed contracts
| Network | EnergyRegistry | EnergyAttestationResolver | Schema UID |
|---|---|---|---|
| Celo Mainnet | 0xA5B5f895091d79d1f099531cDB8cb896F17ec4C1 |
0x5d2f202cAE2D321C5f595baaE359956c3a9Ff02D |
0xb9c136082a935b39c6e276ea137ac489bdc090aac17a116347c7ea90442ef7e0 |
| Polygon Mainnet | 0xA5B5f895091d79d1f099531cDB8cb896F17ec4C1 |
0x5d2f202cAE2D321C5f595baaE359956c3a9Ff02D |
0xb9c136082a935b39c6e276ea137ac489bdc090aac17a116347c7ea90442ef7e0 |
| Polygon Amoy (testnet) | 0x059D4655941204cf6aaC1cF578Aa9dc5D3ed6B39 |
0x7DF77a7EA812c731Df67559D0277CCdF7A9eEbc3 |
0x4673141c77c3d54962edf6ef7f25a0c62656f9bd08138b4c4f9561413c235435 |
- Node.js 22.10+ (LTS recommended)
- A wallet private key with testnet/mainnet funds for gas
- For Polygon Amoy: get free MATIC from the Polygon Amoy faucet
npm installCopy the environment template and fill in your values:
cp .env.example .env.env fields:
PRIVATE_KEY_DEPLOYER=0x... # deployer / contract owner wallet private key
PRIVATE_KEY_WATCHER=0x... # watcher owner / attester wallet private key
REGISTRY_ADDRESS=0x... # filled after step 2
RESOLVER_ADDRESS=0x... # filled after step 2
SCHEMA_UID=0x... # filled after step 3
Use this flow if you are deploying the contracts yourself (e.g. as an operator or protocol owner). The example below uses Polygon Amoy testnet; replace amoy with polygon or celo for production.
npx hardhat compilenpx hardhat run scripts/deploy.ts --network amoyThis deploys the EnergyRegistry implementation + ERC1967Proxy (calling initialize in the same transaction), then deploys EnergyAttestationResolver and authorizes it on the registry. Everything is saved to deployments/amoy.json.
The script prints two registry addresses:
- implementation — the bare logic contract (needed for Etherscan verification only)
- proxy — the permanent address that everything else uses
Copy the proxy address into REGISTRY_ADDRESS and the resolver address into RESOLVER_ADDRESS in your .env.
Wallet roles: On real networks, Hardhat is configured to use:
PRIVATE_KEY_DEPLOYERas the deployer/owner wallet (used byscripts/deploy.ts)PRIVATE_KEY_WATCHERas the watcher owner / attester wallet (used byscripts/setup.ts,scripts/attest.ts,scripts/query-watcher.ts)This separation lets you exercise realistic permission levels in tests and scripts (owner vs watcher vs attester).
Set RESOLVER_ADDRESS and REGISTRY_ADDRESS in .env first, then:
npx hardhat run scripts/register-schema.ts --network amoyThis registers the following schema on EAS:
uint64 projectId, uint32 readingCount, uint32 readingIntervalMinutes, uint256[] readings, uint64 fromTimestamp, string method, string metadataURI
Copy the printed Schema UID into SCHEMA_UID in your .env. The schema UID is deterministic based on the schema string + resolver address — registering the same schema twice on the same network will return the same UID without creating a duplicate.
npx hardhat run scripts/setup.ts --network amoyThe script is interactive. It first asks what you want to do:
What would you like to do?
1) Register a new watcher
2) Register a new project
Option 1 — Register a watcher
Watcher name: My Energy Co
✓ Watcher registered!
Watcher ID : 1
Name : My Energy Co
Note the watcher ID — you'll need it to register projects.
Option 2 — Register a project
Watcher ID: 1
Project name: Solar Farm Alpha
Project energy type:
0 consumer (grid import, operational load, etc.)
1 solar_pv
2 wind_onshore
...
13 hydrogen_fuel_cell
Enter type ID: 1
Attester wallet address (leave blank to use your wallet 0xAbc...):
✓ Project registered!
Project ID : 1
Name : Solar Farm Alpha
Energy type : solar_pv (1)
Watcher ID : 1
Attester : 0xAbc...
The attester defaults to your watcher wallet if left blank. Note the project ID — you'll need it to submit attestations.
Security tip: Consider using a Gnosis Safe as the watcher owner instead of a plain EOA — see the Security: Watcher Ownership section.
Use this flow if the contracts are already deployed and you just want to:
- point your
.envto an existingEnergyRegistry/EnergyAttestationResolver, - use your own watcher wallet to register a watcher and projects,
- submit a few example attestations.
Ask the deployment owner / protocol operator for:
REGISTRY_ADDRESSRESOLVER_ADDRESSSCHEMA_UID
Then update your .env:
PRIVATE_KEY_WATCHER=0x... # your watcher / attester wallet
REGISTRY_ADDRESS=0x... # provided by operator
RESOLVER_ADDRESS=0x... # provided by operator
SCHEMA_UID=0x... # provided by operatorYou do not need
PRIVATE_KEY_DEPLOYERif you are only acting as a watcher/tester against an existing deployment.
Run the same setup script, but only with PRIVATE_KEY_WATCHER configured:
npx hardhat run scripts/setup.ts --network amoyThe prompts and outputs are identical to the deployment flow: first register a watcher (Option 1), then a project (Option 2). Note the watcher ID and project ID for later.
The attester defaults to your watcher wallet when you leave the attester address blank.
Make sure SCHEMA_UID is set in .env, then:
npx hardhat run scripts/attest.ts --network amoyThe script is fully interactive and guides you through each field:
Network : amoy
Attester : 0xAbc...
Energy report JSON path [default: examples/energy_report.json]:
→ Project: generator (solar_pv)
─────────────────────────────────────────────
Attestation summary
Project ID : 1
Type : generator (solar_pv)
Period : 1741996800 → 1742000400 (derived)
Readings : 60 × 1 min
Energy : 5,000 Wh
Method : manual
─────────────────────────────────────────────
Submit? (y/N): y
Submitting...
✓ Attestation submitted!
Tx hash: 0x...
Notes:
energyTypeis no longer prompted — it is read from the project registration and shown as context only- The script loads readings from a JSON report file; a manual entry mode is also available
- Reporting period and energy are derived from
fromTimestamp,readingCount, andreadingIntervalMinutes - Collection method defaults to
manualif not set in the report file - Metadata URI is optional — paste an IPFS CID or HTTPS URL to attach supporting evidence, or leave blank
- The script calls
getProjectLastTimestamp(projectId)before submitting and displays the expectedfromTimestamp - Pass
--replace <uid>to correct an existing attestation instead of creating a new one - The summary shows
Mode: NEWorMode: REPLACEaccordingly
To verify the attestation was recorded and check energy totals for your watcher:
npx hardhat run scripts/query-watcher.ts --network amoyThe script will prompt for a watcher ID and print a full summary:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Watcher #1: My Energy Co
Owner: 0xAbc...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Watcher totals
Generated : 5.000 kWh (5,000 Wh)
Consumed : 0 Wh
Projects (1)
────────────────────────────────────────────────
Project #1: Solar Farm Alpha [active] [generator]
Generated : 5.000 kWh (5,000 Wh)
To skip the prompt, pass the watcher ID via env var:
WATCHER_ID=1 npx hardhat run scripts/query-watcher.ts --network amoyAfter attesting, search by your wallet address or schema UID to see all attestations.
Polygon Amoy (testnet)
- Attestations: polygon-amoy.easscan.org
- Transactions: amoy.polygonscan.com
Polygon Mainnet
- Attestations: polygon.easscan.org
- Transactions: polygonscan.com
Celo Mainnet
- Attestations: celo.easscan.org
- Transactions: celoscan.io
The project has two linters configured — one for Solidity and one for TypeScript.
npm run lintnpm run lint:solChecks all contracts in contracts/**/*.sol. Rules enforced:
- Compiler version pinned to
0.8.28 - Immutable variables must be
SNAKE_CASE - Struct fields ordered for storage slot packing
++iincrement style in loops (gas saving)
npm run lint:tsChecks all files in scripts/ and test/. Uses typescript-eslint with unused variable warnings. Generated artifacts (artifacts/, cache/, typechain-types/) are ignored.
npx hardhat test186 tests covering:
Core Attestation & Chain Enforcement:
- Watcher registration, ownership transfer, and project transfer
- Attester management (per-project and watcher-wide)
- Project registration and deregistration — including
energyTypevalidation at registration time - Project type (generator vs consumer) — routing via
energyType,getProjectType,getProjectEnergyType - Attestation validation (energy, timestamps, method, access control)
Sequential Attestation Enforcement:
- Linear chain validation —
toTimestamp(N) == fromTimestamp(N+1)gapless enforcement - First attestation can start at any timestamp (chain origin)
- Subsequent attestations must start at chain tip (
getProjectLastTimestamp) - Rejections for gaps:
NonSequentialAttestationwhenfromTimestamp != expectedFrom - Chain independence: Project A's chain does not affect Project B
Replacement Mechanism:
- Corrections via replacement — same period,
refUIDlinkage, accumulator adjustment - Atomic accumulator updates (subtract old, add new in one transaction)
- Replacement chain tracking via
getReplacementUID— follow the audit trail - Period enforcement: replacement period must match original exactly
- Authorization: any project/watcher-wide attester can replace (not just original)
- Edge cases: cannot replace already-replaced attestations, replacements don't change chain tip
- Chained replacements: A→B→C via successive
refUIDlinks
No Direct Revocation:
EAS.revoke()blocked at resolver level withDirectRevocationBlockederror- All corrections must use replacement mechanism (preserves chain integrity)
Additional Coverage:
- Energy type registry — admin role, register, remove, transfer admin
- Tenant isolation (Watcher A cannot interfere with Watcher B)
- Watcher and project energy accumulators (accumulation + replacement adjustment)
- Duplicate period detection (same project/period rejected) + strict start-time uniqueness
getWatcherProjects(returns all project IDs, including deregistered)- Project metadata URI (set/update/access control, isolated per project)
- Attestation metadata URI (optional IPFS/HTTPS URI per attestation, emitted in event)
- Resolver authorization (only authorized resolvers can write to the registry)
- Emergency pause (gates attestations and replacements)
- Batch operations and boundary values
- Deregistered project behavior (blocking new attestations and replacements)
Scripts Reference (quick lookup of available deployment and interaction scripts)
| Script | Purpose |
|---|---|
scripts/deploy.ts |
Deploy EnergyRegistry + EnergyAttestationResolver, authorize resolver |
scripts/register-schema.ts |
Register the energy schema on EAS SchemaRegistry |
scripts/setup.ts |
Interactive — register a watcher or a project with attester |
scripts/attest.ts |
Interactive — submit an energy attestation with guided prompts |
scripts/query-watcher.ts |
Print a full energy summary for a watcher (totals + per-project breakdown) |
- Per-project attester whitelisting — a compromised IoT device can only affect its specific project, not the whole watcher
- Duplicate period detection — prevents double-counting; same period can only be attested once per project; use the replacement mechanism to correct. Additionally, the registry enforces strict start-time uniqueness: only one attestation per
(projectId, fromTimestamp)even if durations differ. - No direct revocation —
EAS.revoke()is blocked at the resolver level; corrections are only possible via the replacement mechanism, preserving sequential chain integrity - Metadata URI — only the watcher owner can update the metadata URI; use IPFS for immutable certificate snapshots
- Emergency pause — call
pause()on the resolver from the contract owner wallet if a bug is discovered; all attestations and replacements are blocked while paused - Registry is permanent — never replace or upgrade the registry; all historical data, accumulators, and event history live there
- Resolver is replaceable — deploy a new resolver, authorize it, register a new schema, notify watchers of the new schema UID
- Contract ownership — use
transferOwnership()(two-step viaOwnable2Step) to hand off the global pause role;renounceOwnershipis permanently disabled on both contracts - **Watcher ownership — use
transferWatcherOwnership()for key rotation; there is no recovery if the watcher owner key is lost, so use a hardware wallet or multisig
MIT