From 6f322d1c757ae65c286c18e57d22a105ce52cbb5 Mon Sep 17 00:00:00 2001 From: jessicanath Date: Sat, 27 Jun 2026 17:28:21 +0000 Subject: [PATCH 01/31] feat: add signed meter metadata payloads (#547) - Add MeterMetadata type and computeMetadataHash() to crypto lib - Extend ReadingSchema to accept optional metadata + metadata_signature_hex - Verify Ed25519 metadata signature in POST /api/readings - Persist metadata, metadata_hash, metadata_signature_hex on readings row - Add migration 003 for new readings columns - Update send-reading.mjs to sign and send --firmware/--model/--manufacturer --- apps/web/src/app/api/readings/route.ts | 38 ++++++++++++++++++++++++-- apps/web/src/lib/crypto.ts | 22 +++++++++++++++ apps/web/src/lib/database.types.ts | 3 ++ docs/migrations/003_meter_metadata.sql | 7 +++++ scripts/send-reading.mjs | 36 ++++++++++++++++++++---- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 docs/migrations/003_meter_metadata.sql diff --git a/apps/web/src/app/api/readings/route.ts b/apps/web/src/app/api/readings/route.ts index 87d6761..940ca4c 100644 --- a/apps/web/src/app/api/readings/route.ts +++ b/apps/web/src/app/api/readings/route.ts @@ -3,15 +3,29 @@ import { verify } from '@noble/ed25519' import { z } from 'zod' import { createServiceClient } from '@/lib/supabase' import { anchorReading, mintCertificates } from '@/lib/stellar' -import { computeReadingHash } from '@/lib/crypto' +import { computeReadingHash, computeMetadataHash, MeterMetadata } from '@/lib/crypto' import { kwhToStroops } from '@solarproof/stellar' +const MetadataSchema = z.object({ + firmware_version: z.string().optional(), + hardware_model: z.string().optional(), + location_lat: z.number().optional(), + location_lon: z.number().optional(), + manufacturer: z.string().optional(), +}) + const ReadingSchema = z.object({ meter_id: z.string().uuid(), kwh: z.number().positive(), timestamp: z.number().int().positive(), // Unix seconds signature_hex: z.string().length(128), // 64-byte Ed25519 sig as hex -}) + // Optional signed metadata payload + metadata: MetadataSchema.optional(), + metadata_signature_hex: z.string().length(128).optional(), // Ed25519 sig over metadata hash +}).refine( + (d) => !d.metadata || !!d.metadata_signature_hex, + { message: 'metadata_signature_hex required when metadata is present', path: ['metadata_signature_hex'] } +) /** * POST /api/readings @@ -28,7 +42,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) } - const { meter_id, kwh, timestamp, signature_hex } = parsed.data + const { meter_id, kwh, timestamp, signature_hex, metadata, metadata_signature_hex } = parsed.data const db = createServiceClient() // Fetch meter + cooperative @@ -58,6 +72,21 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid meter signature' }, { status: 401 }) } + // Verify optional metadata signature + let metadataHash: Buffer | null = null + if (metadata && metadata_signature_hex) { + metadataHash = computeMetadataHash(metadata as MeterMetadata) + const metaSigValid = await verify( + Buffer.from(metadata_signature_hex, 'hex'), + metadataHash, + Buffer.from(meter.pubkey_hex, 'hex') + ).catch(() => false) + + if (!metaSigValid) { + return NextResponse.json({ error: 'Invalid metadata signature' }, { status: 401 }) + } + } + // Persist reading const { data: reading, error: readingErr } = await db .from('readings') @@ -69,6 +98,9 @@ export async function POST(req: NextRequest) { signature_hex, anchored: false, minted: false, + metadata: metadata ?? null, + metadata_hash: metadataHash ? metadataHash.toString('hex') : null, + metadata_signature_hex: metadata_signature_hex ?? null, }) .select() .single() diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts index e6b2f7b..505a933 100644 --- a/apps/web/src/lib/crypto.ts +++ b/apps/web/src/lib/crypto.ts @@ -12,3 +12,25 @@ export function computeReadingHash(meterId: string, kwhStroops: bigint, timestam tsBuf.writeBigInt64LE(timestampUnix) return createHash('sha256').update(meterBytes).update(kwhBuf).update(tsBuf).digest() } + +/** + * Meter metadata that can accompany a signed reading. + * All fields are optional — the meter may not expose all of them. + */ +export interface MeterMetadata { + firmware_version?: string + hardware_model?: string + location_lat?: number + location_lon?: number + manufacturer?: string +} + +/** + * Compute the canonical metadata hash: SHA-256(canonical JSON of metadata). + * The meter signs this hash with its Ed25519 key so the payload is tamper-evident. + */ +export function computeMetadataHash(metadata: MeterMetadata): Buffer { + // Sort keys for deterministic serialisation + const canonical = JSON.stringify(metadata, Object.keys(metadata).sort()) + return createHash('sha256').update(Buffer.from(canonical, 'utf8')).digest() +} diff --git a/apps/web/src/lib/database.types.ts b/apps/web/src/lib/database.types.ts index 74f6439..75b279f 100644 --- a/apps/web/src/lib/database.types.ts +++ b/apps/web/src/lib/database.types.ts @@ -22,6 +22,9 @@ export interface Database { reading_hash: string; signature_hex: string anchor_tx_hash: string | null; mint_tx_hash: string | null anchored: boolean; minted: boolean + metadata: Record | null + metadata_hash: string | null // SHA-256 hex of canonical metadata JSON + metadata_signature_hex: string | null // Ed25519 sig over metadata_hash (128 hex chars) } Insert: Omit Update: Partial diff --git a/docs/migrations/003_meter_metadata.sql b/docs/migrations/003_meter_metadata.sql new file mode 100644 index 0000000..f48bfb2 --- /dev/null +++ b/docs/migrations/003_meter_metadata.sql @@ -0,0 +1,7 @@ +-- Migration 003: add signed meter metadata columns to readings +alter table readings + add column if not exists metadata jsonb, + add column if not exists metadata_hash text, -- SHA-256 hex of canonical metadata JSON + add column if not exists metadata_signature_hex text; -- Ed25519 sig over metadata_hash (128 hex chars) + +create index if not exists readings_metadata_hash_idx on readings(metadata_hash); diff --git a/scripts/send-reading.mjs b/scripts/send-reading.mjs index 025ff50..3d71434 100644 --- a/scripts/send-reading.mjs +++ b/scripts/send-reading.mjs @@ -2,14 +2,17 @@ /** * scripts/send-reading.mjs * - * Simulate a smart meter sending a signed reading to the SolarProof API. + * Simulate a smart meter sending a signed reading (+ optional metadata) to the SolarProof API. * * Usage: * node scripts/send-reading.mjs \ * --meter-id \ * --kwh 12.5 \ * --key ./meter-key.json \ - * --api http://localhost:3000 + * --api http://localhost:3000 \ + * [--firmware 1.2.3] \ + * [--model "SolarEdge-SE7600H"] \ + * [--manufacturer "SolarEdge"] */ import { createSign, createHash } from 'crypto' @@ -22,6 +25,9 @@ const meterId = get('--meter-id') ?? 'test-meter-id' const kwh = parseFloat(get('--kwh') ?? '10') const keyFile = get('--key') ?? './meter-key.json' const api = get('--api') ?? 'http://localhost:3000' +const firmware = get('--firmware') +const model = get('--model') +const manufacturer = get('--manufacturer') const { private_key_hex } = JSON.parse(readFileSync(keyFile, 'utf8')) const timestamp = Math.floor(Date.now() / 1000) @@ -38,9 +44,14 @@ const privKeyDer = Buffer.concat([ Buffer.from('302e020100300506032b657004220420', 'hex'), Buffer.from(private_key_hex, 'hex'), ]) -const sign = createSign('ed25519') -sign.update(readingHash) -const signature = sign.sign({ key: privKeyDer, format: 'der', type: 'pkcs8' }) + +function signHash(hash) { + const sign = createSign('ed25519') + sign.update(hash) + return sign.sign({ key: privKeyDer, format: 'der', type: 'pkcs8' }) +} + +const signature = signHash(readingHash) const body = { meter_id: meterId, @@ -49,6 +60,21 @@ const body = { signature_hex: signature.toString('hex'), } +// Attach optional signed metadata +const metadata = {} +if (firmware) metadata.firmware_version = firmware +if (model) metadata.hardware_model = model +if (manufacturer) metadata.manufacturer = manufacturer + +if (Object.keys(metadata).length > 0) { + const canonical = JSON.stringify(metadata, Object.keys(metadata).sort()) + const metadataHash = createHash('sha256').update(Buffer.from(canonical, 'utf8')).digest() + const metadataSig = signHash(metadataHash) + body.metadata = metadata + body.metadata_signature_hex = metadataSig.toString('hex') + console.log('Metadata hash:', metadataHash.toString('hex')) +} + console.log('Sending reading:', { meterId, kwh, timestamp }) console.log('Reading hash:', readingHash.toString('hex')) From e0b4cae45374d2221e8fd23da996ffeec9e54bfc Mon Sep 17 00:00:00 2001 From: jessicanath Date: Sat, 27 Jun 2026 17:29:21 +0000 Subject: [PATCH 02/31] docs: add product persona docs for auditors, cooperatives, and meter operators (#531) --- docs/PERSONAS.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/PERSONAS.md diff --git a/docs/PERSONAS.md b/docs/PERSONAS.md new file mode 100644 index 0000000..99faed9 --- /dev/null +++ b/docs/PERSONAS.md @@ -0,0 +1,97 @@ +# Product Personas + +SolarProof serves three core user personas. Each has distinct goals and primary workflows. + +--- + +## 1. Auditor + +**Who they are:** Regulatory compliance officers, third-party verifiers, or government agency staff who need to confirm that issued renewable energy certificates correspond to real, tamper-evident meter readings. + +**Primary goals:** +- Verify the cryptographic chain of custody from meter reading to on-chain certificate +- Confirm no duplicate or fabricated certificates exist +- Export audit trails for regulatory filings + +**Key user stories:** + +| # | Story | +|---|-------| +| A1 | As an auditor, I can enter a certificate ID or tx hash on `/verify` and see the full chain: meter → Ed25519 signature → ledger anchor → certificate — without needing a login. | +| A2 | As an auditor, I can confirm the Ed25519 public key of the signing meter matches the key registered for that device. | +| A3 | As an auditor, I can query all certificates issued for a cooperative within a date range and download a CSV report. | +| A4 | As an auditor, I can see whether a certificate has been retired (burned) and by whom. | + +**Feature implications:** +- Public `/verify` page must remain login-free and show every field in the `AuditAnchor` struct +- Certificate list view needs date-range filtering and CSV export +- Retirement events must be recorded on-chain and surfaced in the verifier + +--- + +## 2. Cooperative + +**Who they are:** Energy cooperative administrators who aggregate output from multiple member meters, receive minted certificates, manage retirements, and interact with the governance contract. + +**Primary goals:** +- Monitor total generation across their meter fleet in real time +- Manage certificate issuance and retirement on behalf of members +- Participate in community governance (proposals, voting) + +**Key user stories:** + +| # | Story | +|---|-------| +| C1 | As a cooperative admin, I can see a dashboard of kWh generated, certificates minted, and certificates retired for all my meters. | +| C2 | As a cooperative admin, I can register a new meter (serial number + Ed25519 public key) and assign it to my cooperative. | +| C3 | As a cooperative admin, I can retire (burn) a certificate and record the buyer's details. | +| C4 | As a cooperative admin, I can submit a governance proposal and vote on open proposals using my Stellar wallet. | +| C5 | As a cooperative admin, I receive email/webhook notifications when a reading fails to anchor or mint. | + +**Feature implications:** +- Dashboard page must scope data to `cooperative_id` from the authenticated session +- Meter registration UI needed (creates row in `meters` table) +- Retirement flow must call `energy_token.burn()` and update the `certificates` table +- Governance UI must wrap the `community_governance` contract + +--- + +## 3. Meter Operator + +**Who they are:** Technicians or site managers responsible for installing, configuring, and maintaining smart meters. They interact with the system primarily via CLI scripts and device firmware. + +**Primary goals:** +- Generate and securely store an Ed25519 keypair on each meter +- Validate that readings are being correctly signed and accepted by the API +- Diagnose failed anchors or mints + +**Key user stories:** + +| # | Story | +|---|-------| +| M1 | As a meter operator, I can run `gen-meter-key.mjs` to generate a keypair and receive a public key to register with my cooperative. | +| M2 | As a meter operator, I can run `send-reading.mjs` to test that a signed reading (including optional metadata) is accepted by the API end-to-end. | +| M3 | As a meter operator, I can view the last 10 readings for a given meter ID and see whether each was anchored and minted. | +| M4 | As a meter operator, I can include signed device metadata (firmware version, hardware model) in a reading payload so device identity is cryptographically bound to the reading. | +| M5 | As a meter operator, I receive a clear error message if an anchor or mint fails, along with the `tracer-sim` replay link for diagnosis. | + +**Feature implications:** +- Meter readings list view scoped by `meter_id` (no login required for operators given a meter ID) +- `send-reading.mjs` must expose `--firmware`, `--model`, `--manufacturer` flags (see issue #547) +- API error responses for failed anchors must include the tx hash and a tracer-sim URL + +--- + +## How personas inform prioritisation + +| Priority | Feature | Persona | +|----------|---------|---------| +| P0 | Public verifier (login-free, full chain of custody) | Auditor | +| P0 | Certificate issuance + retirement | Cooperative | +| P1 | Cooperative dashboard (fleet kWh, cert status) | Cooperative | +| P1 | Meter registration UI | Cooperative + Operator | +| P1 | Signed metadata payloads | Operator | +| P2 | CSV export / date-range filter | Auditor | +| P2 | Governance UI | Cooperative | +| P2 | Failure notifications (email/webhook) | Cooperative + Operator | +| P3 | tracer-sim replay links in error responses | Operator | From 847dfc5a82e369f6f88becaf1d0e0ca34df8a970 Mon Sep 17 00:00:00 2001 From: jessicanath Date: Sat, 27 Jun 2026 17:30:25 +0000 Subject: [PATCH 03/31] feat: add multilingual fallback and locale detection (#535) - middleware.ts: detect locale from Accept-Language header, persist as cookie, forward as x-locale header to server components; fallback to 'en' - lib/i18n.ts: message catalogue (en/es/fr/de/pt) + getTranslations() with English fallback for missing keys - layout.tsx: reads x-locale header, sets dynamically - api/locale/route.ts: POST endpoint to switch locale via cookie --- apps/web/src/app/api/locale/route.ts | 16 +++++ apps/web/src/app/layout.tsx | 12 +++- apps/web/src/lib/i18n.ts | 94 ++++++++++++++++++++++++++++ apps/web/src/middleware.ts | 48 ++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/api/locale/route.ts create mode 100644 apps/web/src/lib/i18n.ts create mode 100644 apps/web/src/middleware.ts diff --git a/apps/web/src/app/api/locale/route.ts b/apps/web/src/app/api/locale/route.ts new file mode 100644 index 0000000..179e9b3 --- /dev/null +++ b/apps/web/src/app/api/locale/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { SUPPORTED_LOCALES, type Locale } from '@/middleware' + +/** + * POST /api/locale { locale: "es" } + * Sets the locale cookie so subsequent requests use the chosen locale. + */ +export async function POST(req: NextRequest) { + const { locale } = await req.json().catch(() => ({})) + if (!SUPPORTED_LOCALES.includes(locale as Locale)) { + return NextResponse.json({ error: 'Unsupported locale' }, { status: 400 }) + } + const res = NextResponse.json({ locale }) + res.cookies.set('locale', locale as string, { path: '/', sameSite: 'lax', maxAge: 60 * 60 * 24 * 365 }) + return res +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 66fe893..1f8a4b3 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' +import { headers } from 'next/headers' import './globals.css' import { Providers } from './providers' import { Navbar } from '@/components/navbar' +import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from '@/middleware' const inter = Inter({ subsets: ['latin'] }) @@ -18,9 +20,15 @@ export const metadata: Metadata = { }, } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const headerList = await headers() + const rawLocale = headerList.get('x-locale') ?? DEFAULT_LOCALE + const locale: Locale = SUPPORTED_LOCALES.includes(rawLocale as Locale) + ? (rawLocale as Locale) + : DEFAULT_LOCALE + return ( - + diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts new file mode 100644 index 0000000..0ff26ab --- /dev/null +++ b/apps/web/src/lib/i18n.ts @@ -0,0 +1,94 @@ +import type { Locale } from '../middleware' + +// --------------------------------------------------------------------------- +// Message catalogue — add keys here, translate in each locale object +// --------------------------------------------------------------------------- +const messages = { + en: { + 'home.title': 'SolarProof', + 'home.tagline': 'End-to-end cryptographic proof of renewable energy on Stellar.', + 'home.subtitle': 'Every kWh signed at the meter · anchored on-chain · publicly verifiable', + 'home.verify_cta': 'Verify a Certificate', + 'home.dashboard_cta': 'Dashboard', + 'home.chain_title': 'Chain of custody', + 'nav.verify': 'Verify', + 'nav.dashboard': 'Dashboard', + 'verify.title': 'Verify Certificate', + 'verify.placeholder': 'Certificate ID or transaction hash', + 'verify.submit': 'Verify', + 'error.not_found': 'Not found', + 'error.invalid_signature': 'Invalid signature', + }, + es: { + 'home.title': 'SolarProof', + 'home.tagline': 'Prueba criptográfica de extremo a extremo de energía renovable en Stellar.', + 'home.subtitle': 'Cada kWh firmado en el medidor · anclado en cadena · verificable públicamente', + 'home.verify_cta': 'Verificar certificado', + 'home.dashboard_cta': 'Panel', + 'home.chain_title': 'Cadena de custodia', + 'nav.verify': 'Verificar', + 'nav.dashboard': 'Panel', + 'verify.title': 'Verificar certificado', + 'verify.placeholder': 'ID de certificado o hash de transacción', + 'verify.submit': 'Verificar', + 'error.not_found': 'No encontrado', + 'error.invalid_signature': 'Firma no válida', + }, + fr: { + 'home.title': 'SolarProof', + 'home.tagline': 'Preuve cryptographique de bout en bout de l\'énergie renouvelable sur Stellar.', + 'home.subtitle': 'Chaque kWh signé au compteur · ancré on-chain · vérifiable publiquement', + 'home.verify_cta': 'Vérifier un certificat', + 'home.dashboard_cta': 'Tableau de bord', + 'home.chain_title': 'Chaîne de custody', + 'nav.verify': 'Vérifier', + 'nav.dashboard': 'Tableau de bord', + 'verify.title': 'Vérifier le certificat', + 'verify.placeholder': 'ID de certificat ou hash de transaction', + 'verify.submit': 'Vérifier', + 'error.not_found': 'Introuvable', + 'error.invalid_signature': 'Signature invalide', + }, + de: { + 'home.title': 'SolarProof', + 'home.tagline': 'Ende-zu-Ende-kryptografischer Nachweis erneuerbarer Energie auf Stellar.', + 'home.subtitle': 'Jede kWh am Zähler signiert · on-chain verankert · öffentlich prüfbar', + 'home.verify_cta': 'Zertifikat prüfen', + 'home.dashboard_cta': 'Dashboard', + 'home.chain_title': 'Herkunftsnachweis', + 'nav.verify': 'Prüfen', + 'nav.dashboard': 'Dashboard', + 'verify.title': 'Zertifikat prüfen', + 'verify.placeholder': 'Zertifikats-ID oder Transaktions-Hash', + 'verify.submit': 'Prüfen', + 'error.not_found': 'Nicht gefunden', + 'error.invalid_signature': 'Ungültige Signatur', + }, + pt: { + 'home.title': 'SolarProof', + 'home.tagline': 'Prova criptográfica ponta a ponta de energia renovável no Stellar.', + 'home.subtitle': 'Cada kWh assinado no medidor · ancorado on-chain · verificável publicamente', + 'home.verify_cta': 'Verificar certificado', + 'home.dashboard_cta': 'Painel', + 'home.chain_title': 'Cadeia de custódia', + 'nav.verify': 'Verificar', + 'nav.dashboard': 'Painel', + 'verify.title': 'Verificar certificado', + 'verify.placeholder': 'ID de certificado ou hash de transação', + 'verify.submit': 'Verificar', + 'error.not_found': 'Não encontrado', + 'error.invalid_signature': 'Assinatura inválida', + }, +} satisfies Record> + +export type MessageKey = keyof typeof messages['en'] + +/** + * Returns a translate function for the given locale. + * Falls back to English if the key is missing in the requested locale. + */ +export function getTranslations(locale: Locale) { + return function t(key: MessageKey): string { + return messages[locale]?.[key] ?? messages['en'][key] ?? key + } +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 0000000..78c551a --- /dev/null +++ b/apps/web/src/middleware.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' + +export const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt'] as const +export type Locale = typeof SUPPORTED_LOCALES[number] +export const DEFAULT_LOCALE: Locale = 'en' + +/** + * Parse the Accept-Language header and return the best supported locale, + * falling back to DEFAULT_LOCALE if none match. + */ +export function detectLocale(acceptLanguage: string | null): Locale { + if (!acceptLanguage) return DEFAULT_LOCALE + for (const part of acceptLanguage.split(',')) { + const lang = part.split(';')[0].trim().toLowerCase().slice(0, 2) as Locale + if (SUPPORTED_LOCALES.includes(lang)) return lang + } + return DEFAULT_LOCALE +} + +export function middleware(req: NextRequest) { + const { pathname } = req.nextUrl + + // Skip API routes, static files, and Next.js internals + if ( + pathname.startsWith('/api/') || + pathname.startsWith('/_next/') || + pathname.startsWith('/favicon') + ) { + return NextResponse.next() + } + + // If a locale cookie is already set, honour it + const cookieLocale = req.cookies.get('locale')?.value as Locale | undefined + const locale = SUPPORTED_LOCALES.includes(cookieLocale as Locale) + ? (cookieLocale as Locale) + : detectLocale(req.headers.get('accept-language')) + + const res = NextResponse.next() + // Forward resolved locale to server components via header + res.headers.set('x-locale', locale) + // Persist in cookie so subsequent requests skip detection + if (!cookieLocale || cookieLocale !== locale) { + res.cookies.set('locale', locale, { path: '/', sameSite: 'lax', maxAge: 60 * 60 * 24 * 365 }) + } + return res +} + +export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] } From 75238eba0af2a73b0cdc731d4100cb9b90b6b1d4 Mon Sep 17 00:00:00 2001 From: jessicanath Date: Sat, 27 Jun 2026 17:32:30 +0000 Subject: [PATCH 04/31] research: I-REC / Energy Web interoperability requirements (#605) Documents technical and procedural requirements for bridging SolarProof certificates to I-REC(E) (Evident registry) and Energy Web Origin (ERC-1888). Covers data gaps, integration phases, open questions, and references. --- docs/IREC_ENERGYWEB_INTEROP.md | 165 +++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/IREC_ENERGYWEB_INTEROP.md diff --git a/docs/IREC_ENERGYWEB_INTEROP.md b/docs/IREC_ENERGYWEB_INTEROP.md new file mode 100644 index 0000000..176439c --- /dev/null +++ b/docs/IREC_ENERGYWEB_INTEROP.md @@ -0,0 +1,165 @@ +# I-REC / Energy Web Interoperability Requirements + +> Research for SolarProof — Level 3 roadmap item (see README Product Levels) + +--- + +## 1. Context + +SolarProof currently mints its own on-chain energy certificates (1 token = 1 kWh) anchored to Stellar. To gain acceptance in established corporate procurement and regulatory markets, SolarProof certificates need to be **bridged to or recognised by**: + +- **I-REC(E)** — the International Renewable Energy Certificate standard, administered by I-TRACK Foundation / Evident registry (used in 50+ countries) +- **Energy Web Origin** — an open-source SDK + EW-Chain marketplace for Energy Attribute Certificates (EACs) + +This document summarises the technical and procedural requirements for interoperability. + +--- + +## 2. I-REC(E) Interoperability + +### 2.1 How I-REC works + +1. A **production device** (solar plant, wind farm) is registered with an accredited **Issuer** in its country. +2. The Issuer verifies meter data and issues I-REC(E) certificates in the **Evident registry** (1 I-REC = 1 MWh). +3. Certificates are transferred to buyers and **redeemed** (retired) against a specific time period and beneficiary. +4. Each I-REC carries: unique ID, device ID, country, energy source, production period (start/end), volume (MWh), issuance date, issuer. + +### 2.2 Technical integration path + +| Step | Requirement | SolarProof gap | +|------|-------------|---------------| +| Device registration | Register each cooperative's solar installation with an accredited I-REC Issuer; provide device capacity, location, technology, commissioning date | Need device registry with lat/lon, capacity (kW), tech type fields | +| MRV data submission | Submit independently-verified meter readings to Issuer (PDF or API); readings must be MWh-granularity (not kWh) | SolarProof readings are kWh — need aggregation to MWh before submission | +| Certificate issuance | Issuer calls Evident API to mint I-RECs; SolarProof cannot mint I-RECs directly | Bridge role: SolarProof acts as MRV data provider, Issuer mints I-RECs | +| API integration | Evident supports REST API for registered users (account, devices, certificates, transfers, redemptions) | Need Evident API credentials + OAuth; map SolarProof `certificates` to Evident payload | +| Redemption | Buyer redeems via Evident registry; SolarProof on-chain retirement must be linked to Evident redemption ID | Add `irec_redemption_id` field to `certificates` table | + +### 2.3 Required data fields (I-REC device registration) + +``` +deviceId string unique identifier in SolarProof +country string ISO 3166-1 alpha-2 +fuelType string "Solar" | "Wind" | "Hydro" | ... +technology string e.g. "Photovoltaic" +capacity number installed capacity in kW +commissionDate date YYYY-MM-DD +location object { lat, lon, address } +registrantId string Evident account ID of the cooperative +``` + +### 2.4 Required data fields (I-REC issuance request) + +``` +deviceId string +productionStart datetime ISO 8601 +productionEnd datetime ISO 8601 +volume number MWh (aggregated from SolarProof kWh readings) +mrvDocumentUrl string link to independently-verified meter data +``` + +### 2.5 Key gap: granularity + +I-REC issues per **MWh**. SolarProof meters report per **reading** (sub-kWh precision). The bridge must aggregate readings into monthly MWh batches before submitting to Evident. + +### 2.6 Key gap: cryptographic proof not required by I-REC + +I-REC does not currently require or validate cryptographic proofs (Ed25519 signatures). SolarProof's signed readings are **additional assurance** but cannot be submitted to Evident in lieu of the standard MRV process. This is SolarProof's differentiator: the cryptographic anchor proves to buyers that the MRV data was not tampered with between meter and Issuer. + +--- + +## 3. Energy Web Origin Interoperability + +### 3.1 How Energy Web Origin works + +- Built on **Energy Web Chain (EW-Chain)** — a Proof-of-Authority EVM chain purpose-built for energy. +- Core component: **Traceability SDK** — issues EACs as ERC-1888 tokens (fractional certificates). +- Each EAC token carries: device ID, energy source, generation start/end, volume (Wh), issuer address. +- Certificates can be traded via the **Trade SDK** (order book on EW-Chain) and claimed (retired) by buyers. + +### 3.2 Technical integration path + +| Step | Requirement | SolarProof gap | +|------|-------------|---------------| +| Device registry | Register meter devices via EW Origin Device Registry SDK | Need to map SolarProof `meters` + `cooperatives` to EW device schema | +| Certificate issuance | Call `IssuanceService.issue()` with EW Origin Traceability SDK | SolarProof API must call EW-Chain in addition to (or instead of) Stellar; dual-chain or bridge needed | +| ERC-1888 token | EAC token must carry `deviceId`, `generationStartTime`, `generationEndTime`, `certificationRequestId` | SolarProof Soroban `energy_token` is SEP-41 (Stellar), not ERC-1888; not directly compatible | +| Proof attachment | EW Origin does not natively support Ed25519 proofs; could be embedded in certificate metadata | Add `proofUri` or IPFS CID of the SolarProof anchor tx to the EAC token metadata | +| Trading | Use EW Trade SDK order book | Out of scope for initial bridge | + +### 3.3 EW Origin EAC data schema + +```typescript +interface EACIssuanceRequest { + deviceId: string // registered EW Origin device + generationStartTime: number // Unix timestamp + generationEndTime: number // Unix timestamp + energy: number // Wh + metadata?: string // JSON string — can embed SolarProof anchor tx hash here +} +``` + +### 3.4 Key gap: dual-chain architecture + +SolarProof is Stellar-native. Energy Web Origin is EVM-native (EW-Chain). A bridge requires: +- A service account with EW-Chain gas (EWT tokens) +- A signing key authorised by an EW Origin Issuer +- A mapping layer from Stellar `energy_token` → ERC-1888 EAC + +This is non-trivial and warrants a dedicated bridge microservice. + +--- + +## 4. Comparison: I-REC vs Energy Web for SolarProof + +| Dimension | I-REC(E) | Energy Web Origin | +|-----------|----------|-------------------| +| Market reach | 50+ countries, dominant corporate procurement standard | Primarily EU / Energy Web ecosystem | +| Token standard | Off-chain registry (Evident) | ERC-1888 on EW-Chain | +| Crypto-native | No — registry is centralised | Yes — on-chain tokens | +| MRV granularity | MWh (monthly batches) | Wh (high-granularity) | +| Integration complexity | Medium — REST API to Evident | High — EVM bridge required | +| Cryptographic proof support | None natively | Metadata field (extensible) | +| Priority for SolarProof | **High** — needed for corporate buyers | Medium — relevant for EW ecosystem | + +--- + +## 5. Recommended implementation approach + +### Phase 1: I-REC bridge (Priority: High) + +1. Add `irec_device_id`, `irec_redemption_id` columns to `meters` and `certificates` tables. +2. Build a `POST /api/irec/submit-batch` endpoint that aggregates readings by device/month into MWh, formats the Evident API payload, and calls Evident to request issuance. +3. On successful I-REC issuance, store the `irec_certificate_id` on the SolarProof certificate record. +4. Surface the I-REC ID on the public verifier page (`/verify`) so buyers can cross-reference. + +### Phase 2: Energy Web bridge (Priority: Medium) + +1. Deploy a bridge microservice (Node.js + ethers.js) that watches for new SolarProof `certificates` on Stellar. +2. On each new certificate, call `IssuanceService.issue()` on EW-Chain with energy volume + SolarProof anchor tx hash in metadata. +3. Store returned ERC-1888 token ID on the SolarProof certificate record. + +### Phase 3: Cryptographic proof as differentiator (Future) + +- Advocate with I-TRACK Foundation to add optional cryptographic proof fields to the I-REC(E) standard. +- SolarProof's Ed25519 anchored readings are well-positioned as a reference implementation for tamper-evident MRV. + +--- + +## 6. Open questions + +1. **Evident API access** — requires registering SolarProof (or cooperatives) as Evident users. Who is the legal Registrant entity? +2. **Accredited Issuer** — SolarProof cannot issue I-RECs itself; it must work with an accredited Issuer in each country. Which Issuer partner(s) to approach first? +3. **MRV independence** — I-REC requires meter data to be "independently verified." Does the Ed25519 signature + Stellar anchor satisfy Issuers as MRV evidence, or is a third-party inspection still required? +4. **EWT gas** — for EW-Chain integration, who funds the EWT gas account? +5. **Token retirement coordination** — if a certificate is retired on both SolarProof (Stellar) and I-REC (Evident), how is double-counting prevented? + +--- + +## 7. References + +- I-TRACK Foundation — International Attribute Tracking Standard: https://www.trackingstandard.org/the-standard/ +- I-REC(E) Product Code (Evident): https://www.trackingstandard.org/product-code/electricity/ +- Guidance for API Integration with Evident Registry: https://www.trackingstandard.org/guidance-for-api-integration-with-evident-registry-for-i-rece/ +- Energy Web Origin documentation: https://energy-web-foundation-origin.readthedocs-hosted.com/ +- EW Origin GitHub (Traceability SDK): https://github.com/energywebfoundation/origin +- Hedera Guardian I-REC demo guide: https://guardian.hedera.com/guardian/demo-guide/renewable-energy-credits/introduction-to-international-renewable-energy-credit-standard-irec From 18ba6ec1d691c142292088563e61823af332f4b5 Mon Sep 17 00:00:00 2001 From: VERA Date: Sat, 27 Jun 2026 18:34:12 +0000 Subject: [PATCH 05/31] docs: add hardware meter provisioning UX backlog item Closes #613 Adds docs/BACKLOG.md with a detailed backlog entry for the hardware meter provisioning UX feature: user stories, acceptance criteria, and technical notes for the /dashboard/meters provisioning flow. --- docs/BACKLOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/BACKLOG.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..04ce316 --- /dev/null +++ b/docs/BACKLOG.md @@ -0,0 +1,39 @@ +# Product Backlog + +## Hardware Meter Provisioning UX + +**Issue:** [#613](https://github.com/AnnabelJoe/solarproof/issues/613) +**Category:** Backlog +**Priority:** High +**Product Level:** 2 (Hardware HSM Integration) + +### Problem + +There is currently no UI or guided workflow for onboarding a new hardware smart meter into a cooperative. Operators must manually generate Ed25519 keypairs via CLI scripts (`scripts/gen-meter-key.mjs`), register the public key in the database, and configure the device — a process that is error-prone and not accessible to non-technical cooperative administrators. + +### Proposed Solution + +Build a hardware meter provisioning flow inside the SolarProof dashboard that guides an admin through the full lifecycle of registering a new meter device. + +### User Stories + +- As a **cooperative admin**, I want a step-by-step UI to register a new smart meter so that I don't need CLI access. +- As a **cooperative admin**, I want to upload or paste a meter's Ed25519 public key and serial number so that it is securely stored and linked to my cooperative. +- As a **cooperative admin**, I want to view all provisioned meters and their active/inactive status so that I can manage the fleet. +- As a **field technician**, I want a QR-code or copy-paste flow for transferring the meter public key from the device so that provisioning is fast and error-free. + +### Acceptance Criteria + +- [ ] `/dashboard/meters/new` page with a multi-step form: serial number → public key input → confirmation. +- [ ] Ed25519 public key validated (must be 64 hex chars = 32 bytes) before submission. +- [ ] On submit, meter row inserted into `meters` table linked to the admin's cooperative. +- [ ] Success screen shows the new meter's ID and a copyable onboarding summary. +- [ ] Meter list page at `/dashboard/meters` shows status, serial, and last-reading timestamp. +- [ ] Deactivate/reactivate action available per meter row. + +### Technical Notes + +- API: extend `POST /api/meters` (new route) and `PATCH /api/meters/[id]` for activate/deactivate. +- Validation: reuse the `pubkey_hex` column constraint (64 hex chars) from the `meters` DB schema. +- Auth: only the cooperative's admin address may provision meters for their cooperative. +- Consider YubiKey/TPM support (Level 2 roadmap) — keep the public key input generic enough to support HSM-exported keys. From c08df1c45bd0de2c40f909083f244cdf4a580a57 Mon Sep 17 00:00:00 2001 From: jaydenkalu Date: Mon, 29 Jun 2026 12:20:47 +0000 Subject: [PATCH 06/31] feat: normalize API error messaging across pages (#469) - Add src/lib/api-error.ts: parseApiError() normalises {error,code,retriable} response shapes; apiFetch() wrapper throws human-readable error strings - meters/page.tsx: replace inline error state with useToast pushToast() - registerMeter/revokeMeter use apiFetch (consistent error extraction) - onError/onSuccess push toast instead of setting local error state - Remove redundant inline error

and

blocks Closes #469 --- apps/web/src/app/meters/page.tsx | 46 +++++++++----------------------- apps/web/src/lib/api-error.ts | 40 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/lib/api-error.ts diff --git a/apps/web/src/app/meters/page.tsx b/apps/web/src/app/meters/page.tsx index 0417ae8..3ec209c 100644 --- a/apps/web/src/app/meters/page.tsx +++ b/apps/web/src/app/meters/page.tsx @@ -5,6 +5,8 @@ import { WalletGate } from '@/components/wallet-gate' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { PlusCircle, ShieldOff } from 'lucide-react' import { CopyableText } from '@/components/copy-button' +import { useToast } from '@/components/ToastProvider' +import { apiFetch } from '@/lib/api-error' interface Meter { id: string @@ -19,8 +21,7 @@ interface Meter { } async function fetchMeters(): Promise { - const res = await fetch('/api/meters') - if (!res.ok) throw new Error('Failed to load meters') + const res = await apiFetch('/api/meters') return res.json() } @@ -31,27 +32,23 @@ async function registerMeter(body: { meter_group?: string tags?: string[] }): Promise { - const res = await fetch('/api/meters', { + const res = await apiFetch('/api/meters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error ?? 'Registration failed') - } return res.json() } async function revokeMeter(id: string): Promise { - const res = await fetch(`/api/meters/${id}/revoke`, { method: 'PATCH' }) - if (!res.ok) throw new Error('Revoke failed') + await apiFetch(`/api/meters/${id}/revoke`, { method: 'PATCH' }) } // --------------------------------------------------------------------------- // Register form // --------------------------------------------------------------------------- function RegisterForm({ onSuccess }: { onSuccess: () => void }) { + const { pushToast } = useToast() const [form, setForm] = useState({ name: '', serial_number: '', @@ -59,7 +56,6 @@ function RegisterForm({ onSuccess }: { onSuccess: () => void }) { meter_group: '', tags: '', }) - const [error, setError] = useState('') const mutation = useMutation({ mutationFn: (data: typeof form) => @@ -74,22 +70,15 @@ function RegisterForm({ onSuccess }: { onSuccess: () => void }) { meter_group: data.meter_group || undefined, }), onSuccess: () => { - setForm({ - name: '', - serial_number: '', - pubkey_hex: '', - meter_group: '', - tags: '', - }) - setError('') + setForm({ name: '', serial_number: '', pubkey_hex: '', meter_group: '', tags: '' }) + pushToast({ variant: 'success', title: 'Meter registered', description: 'The new meter is now active.' }) onSuccess() }, - onError: (err: Error) => setError(err.message), + onError: (err: Error) => pushToast({ variant: 'error', title: 'Registration failed', description: err.message }), }) function handleSubmit(e: React.FormEvent) { e.preventDefault() - setError('') mutation.mutate(form) } @@ -103,12 +92,6 @@ function RegisterForm({ onSuccess }: { onSuccess: () => void }) { Register new meter - {error && ( -
- {error} -
- )} -
- {error && ( -

- {error} -

- )} -
- - -
- {analyticsLoading ? : ( - - - - - - - - - - )} -
+ {analyticsLoading ? : ( + + + + + + + + + + )} + + {/* Issuance vs Retirement */}
-
-
-

Certificates activity

-

Issued vs retired trends

+
+
+
+

Certificates activity

+

Issued vs retired trends

+
+
+ Certificate activity time grouping + {(['day', 'month'] as const).map((g) => ( + + ))} +
-
- {(['day', 'month'] as const).map((g) => ( - - ))} -
-
-
- {analyticsLoading ? : ( - - - - - - - - - - - - )} -
+

+ {describeCertificateActivity(analytics?.trends, range, granularity)} +

+
+ {analyticsLoading ? : ( + + + + + + + ( + + )} + /> + + + + + )} +
+
@@ -379,10 +452,15 @@ export default function DashboardPage() { {m.certs_generated.toLocaleString()}
-
-
+ diff --git a/apps/web/src/app/governance/page.tsx b/apps/web/src/app/governance/page.tsx index 635396d..98b9c90 100644 --- a/apps/web/src/app/governance/page.tsx +++ b/apps/web/src/app/governance/page.tsx @@ -92,16 +92,32 @@ function TallyBar({ tally }: { tally: Tally }) { const abstainPct = pct(tally.abstain, total) return (
-
-
-
-
-
-
- {forPct}% For ({tally.for}) - {againstPct}% Against ({tally.against}) - {abstainPct}% Abstain ({tally.abstain}) +
+ ) } + +// #485 . From bc4f503989061cc120715732f4951b96d4bf5172 Mon Sep 17 00:00:00 2001 From: Claudian2122 Date: Wed, 1 Jul 2026 10:17:36 +0100 Subject: [PATCH 17/31] chore: scaffold unsupported browser landing page #481 (#728) --- apps/web/src/app/layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 64e8299..df2c11f 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,3 +1,4 @@ +// #481 unsupported-browser landing page. import type { Metadata } from 'next' import { Inter } from 'next/font/google' import './globals.css' From b894a2e07c955dd794a9f31d7def51a2e3f81dc7 Mon Sep 17 00:00:00 2001 From: sophiawilliamz Date: Wed, 1 Jul 2026 10:17:39 +0100 Subject: [PATCH 18/31] feat(#475): accessible inline validation on all forms (#726) meters/page.tsx - RegisterForm: - Extract Field wrapper that emits aria-invalid, aria-describedby on the child input and shows an inline error paragraph with role=alert + aria-live - Client-side validate() on blur (per-field) and on submit (all fields) - Errors only shown after a field has been touched to avoid pre-emptive noise - Submit button disabled + aria-disabled when field errors are present - Duplicate generic error banner removed; per-field messages are the source governance/page.tsx - CreateProposalForm: - Replace inline - {children} +
input]:w-full [&>textarea]:w-full [&>input]:rounded-lg [&>textarea]:rounded-lg [&>input]:border [&>textarea]:border [&>input]:px-3 [&>textarea]:px-3 [&>input]:py-1.5 [&>textarea]:py-1.5 [&>input]:text-sm [&>textarea]:text-sm [&>input]:outline-none [&>textarea]:outline-none [&>input]:bg-white [&>textarea]:bg-white dark:[&>input]:bg-gray-800 dark:[&>textarea]:bg-gray-800 [&>input]:text-gray-900 [&>textarea]:text-gray-900 dark:[&>input]:text-gray-100 dark:[&>textarea]:text-gray-100 [&>input]:transition-colors [&>textarea]:transition-colors [&>input:focus]:ring-2 [&>textarea:focus]:ring-2 [&>input:focus]:ring-yellow-400 [&>textarea:focus]:ring-yellow-400 ${error ? '[&>input]:border-red-400 [&>textarea]:border-red-400 dark:[&>input]:border-red-500 dark:[&>textarea]:border-red-500' : '[&>input]:border-gray-300 [&>textarea]:border-gray-300 dark:[&>input]:border-gray-700 dark:[&>textarea]:border-gray-700'}`}> + {children} +
{error && ( - )} @@ -411,6 +441,7 @@ export default function GovernancePage() { const { connected } = useWallet() const qc = useQueryClient() const [showForm, setShowForm] = useState(false) + const [voteError, setVoteError] = useState(null) const { data: proposals = [], isLoading, error } = useQuery({ queryKey: ['proposals'], @@ -421,8 +452,9 @@ export default function GovernancePage() { mutationFn: ({ id, choice }: { id: string; choice: VoteChoice }) => castVote(id, choice), onSuccess: () => { qc.invalidateQueries({ queryKey: ['proposals'] }) + setVoteError(null) }, - onError: (err: Error) => alert(err.message), + onError: (err: Error) => setVoteError(err.message), }) function handleVote(id: string, choice: VoteChoice) { @@ -472,6 +504,16 @@ export default function GovernancePage() {

)} + {voteError && ( +
+ {voteError} +
+ )} + {isLoading ? ( ) : active.length > 0 ? ( diff --git a/apps/web/src/app/meters/page.tsx b/apps/web/src/app/meters/page.tsx index 0417ae8..fd59d4a 100644 --- a/apps/web/src/app/meters/page.tsx +++ b/apps/web/src/app/meters/page.tsx @@ -48,21 +48,144 @@ async function revokeMeter(id: string): Promise { if (!res.ok) throw new Error('Revoke failed') } +// --------------------------------------------------------------------------- +// Accessible field wrapper +// --------------------------------------------------------------------------- + +interface FieldProps { + id: string + label: string + error?: string + hint?: string + required?: boolean + children: React.ReactNode +} + +function Field({ id, label, error, hint, required, children }: FieldProps) { + const errorId = `${id}-err` + const hintId = `${id}-hint` + const describedBy = [error ? errorId : null, hint ? hintId : null] + .filter(Boolean) + .join(' ') || undefined + + return ( +
+ + {hint && ( +

+ {hint} +

+ )} + {/* Clone child and inject aria props */} + {cloneWithA11y(children, { id, errorId: error ? errorId : undefined, describedBy, hasError: !!error })} + {error && ( + + )} +
+ ) +} + +/** + * Inject aria-invalid, aria-describedby and error-state border class into the + * immediate child element (an or