diff --git a/lazer/cardano/solarchain/.env.example b/lazer/cardano/solarchain/.env.example new file mode 100644 index 00000000..3ac2d9a3 --- /dev/null +++ b/lazer/cardano/solarchain/.env.example @@ -0,0 +1,22 @@ +# Cardano / Blockfrost +CARDANO_NETWORK=PreProd +BLOCKFROST_PREPROD_URL=https://cardano-preprod.blockfrost.io/api/v0 +BLOCKFROST_PROJECT_ID=replace_me + +# Wallet seed only for local demo / test wallets +CARDANO_MNEMONIC=replace_me + +# Pyth +PYTH_API_TOKEN=replace_me +PYTH_POLICY_ID_PREPROD=d799d287105dea9377cdf9ea8502a83d2b9eb2d2050a8aea800a21e6 + +# Database (optional local fallback to memory if unavailable) +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/solarchain + +# Product flags +SOLARCHAIN_PRICE_FEED_ID=16 +COMMODITIES_PRICE_FEED_ID=16 + +# Frontend +SOLARCHAIN_API_URL=http://127.0.0.1:4010 +NEXT_PUBLIC_SOLARCHAIN_API_URL=http://127.0.0.1:4010 diff --git a/lazer/cardano/solarchain/.gitignore b/lazer/cardano/solarchain/.gitignore new file mode 100644 index 00000000..cc992784 --- /dev/null +++ b/lazer/cardano/solarchain/.gitignore @@ -0,0 +1,9 @@ +node_modules +dist +.next +coverage +.env +.env.local +build +*.tsbuildinfo +.DS_Store diff --git a/lazer/cardano/solarchain/README.md b/lazer/cardano/solarchain/README.md new file mode 100644 index 00000000..85963e09 --- /dev/null +++ b/lazer/cardano/solarchain/README.md @@ -0,0 +1,78 @@ +# SolarChain Cardano MVP + +SolarChain es una capa de trazabilidad y settlement para beneficio solar verificable. +No es un trading system. No es un carbon market. No es un bono verde. No es un sistema de subsidios. + +## Corrección aplicada respecto del mock inicial + +Se removió la narrativa incorrecta de: + +- dividendos, +- tokenomics de dashboard, +- supply/holders, +- métricas que harían parecer a SolarChain un activo de trading. + +El dashboard ahora muestra: + +- evidencia energética, +- referencias climáticas y económicas, +- settlement preview, +- snapshots persistidos, +- export de evidencia. + +## Unidad canónica del MVP + +La unidad de settlement del MVP es **sKWh**. + +- `1 sKWh = 1 kWh solar verificable asignable según regla del sitio` +- `Wh` queda como evidencia cruda off-chain +- el settlement usa solo **sKWh enteros** + +## Regla de asignación activa + +- `EXPORTED_ENERGY_ONLY` + +La energía asignable se calcula como: + +```text +assignableWh = max(totalGeneratedWh - totalConsumedWh, 0) +settlementSKwh = floor(assignableWh / 1000) +``` + +## Estructura + +```text +packages/ + config/ + shared-types/ + cardano-core/ + pyth-adapter/ + solarchain-domain/ + contracts-aiken/ + +apps/ + solarchain-api/ + solarchain-web/ +``` + +## Endpoints + +- `GET /health` +- `GET /site` +- `GET /dashboard/summary` +- `GET /snapshots?limit=8` +- `GET /snapshots/:snapshotId/evidence` +- `POST /batches/quote` +- `POST /batches/prepare-settlement` +- `POST /snapshots/ingest` + +## Run local + +```bash +cp .env.example .env +npm install +npm run test +npm run typecheck +npm run dev:api +npm run dev:web +``` diff --git a/lazer/cardano/solarchain/apps/solarchain-api/package.json b/lazer/cardano/solarchain/apps/solarchain-api/package.json new file mode 100644 index 00000000..094bb5a1 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "@apps/solarchain-api", + "version": "0.2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@fastify/cors": "^10.0.2", + "fastify": "^5.2.1", + "pg": "^8.14.1", + "@packages/cardano-core": "file:../../packages/cardano-core", + "@packages/solarchain-domain": "file:../../packages/solarchain-domain", + "@packages/shared-types": "file:../../packages/shared-types" + } +} diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/app.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/app.ts new file mode 100644 index 00000000..5125b7c3 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/app.ts @@ -0,0 +1,10 @@ +import crypto from "node:crypto"; +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import { buildSolarDashboardSummary, buildSolarEvidenceExport, buildSolarSettlementQuote, validateSolarClimateSnapshot, validateSolarReferencePriceSnapshot } from "@packages/solarchain-domain"; +import { buildHackathonMetadata, HACKATHON_METADATA_LABEL } from "@packages/cardano-core"; +import type { SolarBatch, SolarIngestSnapshotInput, SolarSnapshotRecord } from "@packages/shared-types"; +import { solarDemoSite } from "./demo-data.js"; +import type { SolarSnapshotRepository } from "./repositories/index.js"; +function normalizeLimit(rawLimit: unknown): number { const parsed = Number(rawLimit); if (!Number.isFinite(parsed) || parsed <= 0) return 10; return Math.min(Math.trunc(parsed),50); } +export async function buildApp(repository: SolarSnapshotRepository) { const app = Fastify({ logger: true }); await app.register(cors,{ origin:true, methods:["GET","POST"] }); app.setErrorHandler((error, request, reply) => { request.log.error(error); const statusCode = typeof (error as { statusCode?: unknown }).statusCode === "number" ? (error as { statusCode: number }).statusCode : 400; const isClientError = statusCode >= 400 && statusCode < 500; const errorMessage = error instanceof Error ? error.message : "Unexpected error"; return reply.status(isClientError ? statusCode : 500).send({ ok:false, error:isClientError ? errorMessage : "Internal Server Error" }); }); app.get("/health", async () => ({ ok:true, product:"solarchain", repositoryMode:repository.mode, now:new Date().toISOString() })); app.get("/site", async () => solarDemoSite); app.get("/dashboard/summary", async () => { const snapshots = await repository.list(12); return buildSolarDashboardSummary(solarDemoSite, repository.mode, snapshots); }); app.get<{ Querystring: { limit?: string } }>("/snapshots", async (request) => repository.list(normalizeLimit(request.query.limit))); app.get<{ Params: { snapshotId: string } }>("/snapshots/:snapshotId/evidence", async (request, reply) => { const snapshot = await repository.getById(request.params.snapshotId); if (!snapshot) return reply.status(404).send({ ok:false, error:"Snapshot no encontrado" }); return buildSolarEvidenceExport(snapshot); }); app.post<{ Body: SolarBatch }>("/batches/quote", async (request, reply) => reply.send(buildSolarSettlementQuote(request.body))); app.post<{ Body: SolarBatch }>("/batches/prepare-settlement", async (request, reply) => { const quote = buildSolarSettlementQuote(request.body); const metadata = buildHackathonMetadata("solarchain", { batchId: quote.batchId, allocationRuleApplied: quote.allocationRuleApplied, exportedWh: quote.exportedWh, assignableWh: quote.assignableWh, settlementSKwh: quote.settlementSKwh, unit: quote.unit, avoidedCo2Kg: quote.avoidedCo2Kg, savingsUsd: quote.savingsUsd, batchHash: quote.batchHash }); return reply.send({ product:"solarchain", quote, metadataLabel:HACKATHON_METADATA_LABEL, metadata, nextAction:"build_and_sign_cardano_tx" }); }); app.post<{ Body: SolarIngestSnapshotInput }>("/snapshots/ingest", async (request, reply) => { validateSolarClimateSnapshot(request.body.climate); validateSolarReferencePriceSnapshot(request.body.referencePrice); const batch = { ...request.body.batch, allocationRule: request.body.batch.allocationRule ?? solarDemoSite.allocationRule }; const quote = buildSolarSettlementQuote(batch); const snapshot: SolarSnapshotRecord = { snapshotId: crypto.randomUUID(), siteId: request.body.siteId ?? solarDemoSite.siteId, batch, quote, climate: request.body.climate, referencePrice: request.body.referencePrice, createdAt: new Date().toISOString() }; const created = await repository.create(snapshot); return reply.status(201).send({ snapshot: created, evidenceExportUrl: `/snapshots/${created.snapshotId}/evidence` }); }); return app; } diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/demo-data.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/demo-data.ts new file mode 100644 index 00000000..9f245a77 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/demo-data.ts @@ -0,0 +1,5 @@ +import type { SolarBatch, SolarClimateSnapshot, SolarReferencePriceSnapshot, SolarSiteProfile } from "@packages/shared-types"; +export const solarDemoSite: SolarSiteProfile = { siteId: "site-la-torre-palermo", displayName: 'Edificio "La Torre"', locationLabel: "Palermo, CABA", systemCapacityKw: 15, status: "LIVE", allocationRule: "EXPORTED_ENERGY_ONLY", settlementUnit: "sKWh" }; +export const solarDemoClimate: SolarClimateSnapshot = { temperatureC: 28.4, cloudCoverPct: 35, irradianceWm2: 760, source: "demo", capturedAt: "2026-03-22T16:00:00.000Z" }; +export const solarDemoReferencePrice: SolarReferencePriceSnapshot = { arsUsd: 22.28, electricityArsPerKwh: 319, source: "demo", capturedAt: "2026-03-22T16:00:00.000Z" }; +export const solarDemoBatch: SolarBatch = { batchId: "batch-solar-001", producerId: "coop-01", beneficiaryAddress: "addr_test1qpz8examplebeneficiary0000000000000000000", periodStart: "2026-03-21T00:00:00.000Z", periodEnd: "2026-03-21T23:59:59.000Z", allocationRule: "EXPORTED_ENERGY_ONLY", tariffUsdPerKwh: 0.12, emissionFactorKgCo2eAvoided: 0.42, readings: [{ meterId: "m-001", timestamp: "2026-03-21T10:00:00.000Z", generatedWh: 2500, consumedWh: 1800, irradianceWm2: 730 }, { meterId: "m-001", timestamp: "2026-03-21T11:00:00.000Z", generatedWh: 2700, consumedWh: 1900, irradianceWm2: 760 }, { meterId: "m-001", timestamp: "2026-03-21T12:00:00.000Z", generatedWh: 3100, consumedWh: 2100, irradianceWm2: 810 }] }; diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/index.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/index.ts new file mode 100644 index 00000000..7ae8980a --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/index.ts @@ -0,0 +1,5 @@ +import { pathToFileURL } from "node:url"; +import { buildApp } from "./app.js"; +import { createSolarSnapshotRepository } from "./repositories/index.js"; +export async function start(): Promise { const repository = await createSolarSnapshotRepository(); const app = await buildApp(repository); await app.listen({ port: 4010, host: "0.0.0.0" }); } +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { start().catch((error)=>{ console.error(error); process.exit(1); }); } diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/index.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/index.ts new file mode 100644 index 00000000..67a137db --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/index.ts @@ -0,0 +1,10 @@ +import crypto from "node:crypto"; +import { buildSolarSettlementQuote } from "@packages/solarchain-domain"; +import type { SolarSnapshotRecord } from "@packages/shared-types"; +import { solarDemoBatch, solarDemoClimate, solarDemoReferencePrice, solarDemoSite } from "../demo-data.js"; +import { MemorySolarSnapshotRepository } from "./memory.js"; +import { PostgresSolarSnapshotRepository } from "./postgres.js"; +import type { SolarSnapshotRepository } from "./types.js"; +function buildSeedSnapshot(): SolarSnapshotRecord { const quote = buildSolarSettlementQuote(solarDemoBatch); return { snapshotId: crypto.randomUUID(), siteId: solarDemoSite.siteId, batch: solarDemoBatch, quote, climate: solarDemoClimate, referencePrice: solarDemoReferencePrice, createdAt: quote.createdAt }; } +export async function createSolarSnapshotRepository(): Promise { const databaseUrl = process.env.DATABASE_URL?.trim(); if (!databaseUrl) return new MemorySolarSnapshotRepository([buildSeedSnapshot()]); try { const repository = new PostgresSolarSnapshotRepository(databaseUrl); await repository.ensureReady(); const existing = await repository.list(1); if (existing.length === 0) await repository.create(buildSeedSnapshot()); return repository; } catch (error) { console.error("PostgreSQL no disponible. Se usa fallback en memoria para la demo.", error); return new MemorySolarSnapshotRepository([buildSeedSnapshot()]); } } +export type { SolarSnapshotRepository } from "./types.js"; diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/memory.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/memory.ts new file mode 100644 index 00000000..0a447123 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/memory.ts @@ -0,0 +1,3 @@ +import type { SolarSnapshotRecord } from "@packages/shared-types"; +import type { SolarSnapshotRepository } from "./types.js"; +export class MemorySolarSnapshotRepository implements SolarSnapshotRepository { readonly mode = "memory" as const; readonly #snapshots: SolarSnapshotRecord[]; constructor(seed: SolarSnapshotRecord[] = []) { this.#snapshots = [...seed].sort((a,b)=>Date.parse(b.createdAt)-Date.parse(a.createdAt)); } async ensureReady(): Promise { return; } async create(snapshot: SolarSnapshotRecord): Promise { this.#snapshots.unshift(snapshot); return snapshot; } async list(limit: number): Promise { return this.#snapshots.slice(0,limit); } async getById(snapshotId: string): Promise { return this.#snapshots.find((i)=>i.snapshotId===snapshotId) ?? null; } } diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/postgres.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/postgres.ts new file mode 100644 index 00000000..4d7d65d1 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/postgres.ts @@ -0,0 +1,10 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { Pool } from "pg"; +import type { SolarSnapshotRecord } from "@packages/shared-types"; +import type { SolarSnapshotRepository } from "./types.js"; +const currentDir = dirname(fileURLToPath(import.meta.url)); const schemaPath = join(currentDir,"..","sql","schema.sql"); +interface SnapshotRow { snapshot_id: string; site_id: string; batch: SolarSnapshotRecord["batch"]; quote: SolarSnapshotRecord["quote"]; climate: SolarSnapshotRecord["climate"]; reference_price: SolarSnapshotRecord["referencePrice"]; created_at: string | Date; } +function mapRowToSnapshot(row: SnapshotRow): SolarSnapshotRecord { return { snapshotId: row.snapshot_id, siteId: row.site_id, batch: row.batch, quote: row.quote, climate: row.climate, referencePrice: row.reference_price, createdAt: new Date(row.created_at).toISOString() }; } +export class PostgresSolarSnapshotRepository implements SolarSnapshotRepository { readonly mode = "postgres" as const; readonly #pool: Pool; constructor(databaseUrl: string) { this.#pool = new Pool({ connectionString: databaseUrl }); } async ensureReady(): Promise { const schema = await readFile(schemaPath,"utf8"); await this.#pool.query(schema); } async create(snapshot: SolarSnapshotRecord): Promise { await this.#pool.query(`INSERT INTO solarchain_snapshots (snapshot_id,site_id,batch,quote,climate,reference_price,created_at) VALUES ($1,$2,$3::jsonb,$4::jsonb,$5::jsonb,$6::jsonb,$7)`, [snapshot.snapshotId,snapshot.siteId,JSON.stringify(snapshot.batch),JSON.stringify(snapshot.quote),JSON.stringify(snapshot.climate),JSON.stringify(snapshot.referencePrice),snapshot.createdAt]); return snapshot; } async list(limit: number): Promise { const result = await this.#pool.query(`SELECT snapshot_id, site_id, batch, quote, climate, reference_price, created_at FROM solarchain_snapshots ORDER BY created_at DESC LIMIT $1`, [limit]); return result.rows.map(mapRowToSnapshot); } async getById(snapshotId: string): Promise { const result = await this.#pool.query(`SELECT snapshot_id, site_id, batch, quote, climate, reference_price, created_at FROM solarchain_snapshots WHERE snapshot_id = $1 LIMIT 1`, [snapshotId]); const row = result.rows[0]; return row ? mapRowToSnapshot(row) : null; } } diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/types.ts b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/types.ts new file mode 100644 index 00000000..18e7e81f --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/repositories/types.ts @@ -0,0 +1,2 @@ +import type { RepositoryMode, SolarSnapshotRecord } from "@packages/shared-types"; +export interface SolarSnapshotRepository { readonly mode: RepositoryMode; ensureReady(): Promise; create(snapshot: SolarSnapshotRecord): Promise; list(limit: number): Promise; getById(snapshotId: string): Promise; } diff --git a/lazer/cardano/solarchain/apps/solarchain-api/src/sql/schema.sql b/lazer/cardano/solarchain/apps/solarchain-api/src/sql/schema.sql new file mode 100644 index 00000000..5e21733d --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/src/sql/schema.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS solarchain_snapshots ( + snapshot_id TEXT PRIMARY KEY, + site_id TEXT NOT NULL, + batch JSONB NOT NULL, + quote JSONB NOT NULL, + climate JSONB NOT NULL, + reference_price JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_solarchain_snapshots_created_at + ON solarchain_snapshots (created_at DESC); diff --git a/lazer/cardano/solarchain/apps/solarchain-api/tsconfig.json b/lazer/cardano/solarchain/apps/solarchain-api/tsconfig.json new file mode 100644 index 00000000..513b49d9 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/lazer/cardano/solarchain/apps/solarchain-web/app/globals.css b/lazer/cardano/solarchain/apps/solarchain-web/app/globals.css new file mode 100644 index 00000000..4e9d074b --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-web/app/globals.css @@ -0,0 +1 @@ +:root { color-scheme: light; --bg:#f4f6f8; --panel:#fff; --panel-soft:#f7faf8; --text:#1f2937; --muted:#6b7280; --border:#d7dde4; --accent:#159947; --accent-soft:#e8f8ee; --shadow:0 14px 32px rgba(15,23,42,.06);}*{box-sizing:border-box}html,body{margin:0;min-height:100%;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--text)}a{color:inherit}main.shell{max-width:1240px;margin:0 auto;padding:32px}.header{display:flex;flex-direction:column;gap:10px;margin-bottom:24px}.header h1{margin:0;font-size:2.2rem}.header p{margin:0;color:var(--muted)}.badge-row{display:flex;flex-wrap:wrap;gap:10px}.badge{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:.88rem;font-weight:600;border:1px solid rgba(21,153,71,.18);background:var(--accent-soft);color:var(--accent)}.grid{display:grid;gap:16px}.grid-top{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-main{margin-top:16px;grid-template-columns:1.35fr 1fr 1fr}.grid-bottom{margin-top:16px;grid-template-columns:1.4fr 1fr}.card{background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px;box-shadow:var(--shadow)}.card.soft{background:var(--panel-soft)}.card h2,.card h3,.card h4,.card p{margin-top:0}.eyebrow{margin:0 0 8px;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.04em}.metric{font-size:2rem;font-weight:700;line-height:1.1}.metric-sub{margin-top:8px;color:var(--muted);font-size:.92rem}.metric-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.metric-stack{display:flex;flex-direction:column;gap:14px}.kpi-box{border:1px solid #c2d8ff;background:#eff5ff;border-radius:14px;padding:14px}.kpi-box strong{display:block;font-size:1.4rem;margin-top:4px}.system-list{display:grid;gap:10px}.system-line{display:flex;justify-content:space-between;gap:12px;font-size:.95rem}.system-line span:first-child{color:var(--muted)}.sparkline-wrap{margin-top:14px;border-radius:14px;background:linear-gradient(180deg,#f2f5f7 0%,#eef7f0 100%);border:1px solid var(--border);padding:12px}.sparkline-svg{width:100%;height:180px}.sparkline-caption{margin-top:10px;color:var(--muted);font-size:.88rem}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--border);font-size:.95rem}th{color:var(--muted);font-weight:600}textarea{width:100%;min-height:240px;border-radius:14px;border:1px solid var(--border);padding:14px;font:.92rem/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;resize:vertical;background:#fbfcfd}button{appearance:none;border:0;border-radius:12px;padding:12px 16px;font-weight:700;cursor:pointer}button.primary{background:var(--accent);color:#fff}button.secondary{background:#111827;color:#fff}button.ghost{background:#eef2f7;color:#111827}.button-row{display:flex;flex-wrap:wrap;gap:12px;margin-top:14px}.notice,.error-box,.result-box{border-radius:14px;padding:14px;margin-top:14px;font-size:.95rem}.notice{background:#f8fafc;border:1px solid var(--border)}.error-box{background:#fff1f2;border:1px solid #fecdd3;color:#9f1239}.result-box{background:#effcf4;border:1px solid #bbf7d0}.result-grid{display:grid;gap:8px;margin-top:10px}.footer-note{margin-top:16px;color:var(--muted);font-size:.9rem}@media (max-width:1100px){.grid-top,.grid-main,.grid-bottom{grid-template-columns:1fr 1fr}}@media (max-width:720px){main.shell{padding:20px}.grid-top,.grid-main,.grid-bottom,.metric-grid{grid-template-columns:1fr}.system-line{flex-direction:column}} diff --git a/lazer/cardano/solarchain/apps/solarchain-web/app/layout.tsx b/lazer/cardano/solarchain/apps/solarchain-web/app/layout.tsx new file mode 100644 index 00000000..4ffc43c6 --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-web/app/layout.tsx @@ -0,0 +1,3 @@ +import type { ReactNode } from "react"; +import "./globals.css"; +export default function RootLayout({ children }: { children: ReactNode }) { return ({children}); } diff --git a/lazer/cardano/solarchain/apps/solarchain-web/app/page.tsx b/lazer/cardano/solarchain/apps/solarchain-web/app/page.tsx new file mode 100644 index 00000000..0471d26f --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-web/app/page.tsx @@ -0,0 +1,11 @@ +export const dynamic = "force-dynamic"; +import type { SolarBatch, SolarDashboardSummary, SolarSnapshotRecord } from "@packages/shared-types"; +import { IngestBatchPanel } from "../components/ingest-batch-panel"; +import { SettlementSparkline } from "../components/sparkline"; +const apiBaseUrl = process.env.SOLARCHAIN_API_URL ?? process.env.NEXT_PUBLIC_SOLARCHAIN_API_URL ?? "http://127.0.0.1:4010"; +const exampleBatch: SolarBatch = { batchId:"batch-solar-001", producerId:"coop-01", beneficiaryAddress:"addr_test1qpz8examplebeneficiary0000000000000000000", periodStart:"2026-03-21T00:00:00.000Z", periodEnd:"2026-03-21T23:59:59.000Z", allocationRule:"EXPORTED_ENERGY_ONLY", tariffUsdPerKwh:0.12, emissionFactorKgCo2eAvoided:0.42, readings:[{ meterId:"m-001", timestamp:"2026-03-21T10:00:00.000Z", generatedWh:2500, consumedWh:1800, irradianceWm2:730 },{ meterId:"m-001", timestamp:"2026-03-21T11:00:00.000Z", generatedWh:2700, consumedWh:1900, irradianceWm2:760 },{ meterId:"m-001", timestamp:"2026-03-21T12:00:00.000Z", generatedWh:3100, consumedWh:2100, irradianceWm2:810 }] }; +async function fetchJson(path: string): Promise { try { const response = await fetch(`${apiBaseUrl}${path}`,{ cache:"no-store" }); if(!response.ok) return null; return (await response.json()) as T; } catch { return null; } } +function formatDate(date:string): string { return new Intl.DateTimeFormat("es-AR",{ dateStyle:"short", timeStyle:"short" }).format(new Date(date)); } +function formatCurrency(value:number, currency:"USD"|"ARS"="USD"): string { return new Intl.NumberFormat("es-AR",{ style:"currency", currency, maximumFractionDigits:2 }).format(value); } +function shortenHash(hash:string): string { return `${hash.slice(0,12)}…${hash.slice(-10)}`; } +export default async function Page(){ const [summary,snapshots]=await Promise.all([fetchJson("/dashboard/summary"), fetchJson("/snapshots?limit=8")]); const latestSnapshot = summary?.latestSnapshot ?? null; const exampleBatchText = JSON.stringify(exampleBatch,null,2); return (

⚡ SolarChain Dashboard

{summary?.site.displayName ?? 'Edificio "La Torre"'} · {summary?.site.locationLabel ?? "Palermo, CABA"} · {summary?.site.systemCapacityKw ?? 15} kW sistema solar

● EN VIVOUnidad canónica: sKWhRegla: {summary?.site.allocationRule ?? "EXPORTED_ENERGY_ONLY"}

Última energía exportable

{latestSnapshot?.quote.assignableWh ?? 0} Wh
Último lote verificado · Settlement equivalente: {latestSnapshot?.quote.settlementSKwh ?? 0} sKWh

Climate reference

{latestSnapshot?.climate.temperatureC ?? 0}°C
Temperatura
{latestSnapshot?.climate.cloudCoverPct ?? 0}%
Nubosidad

Reference inputs

{formatCurrency(latestSnapshot?.referencePrice.arsUsd ?? 0,"USD")}
ARS/USD
{formatCurrency(latestSnapshot?.referencePrice.electricityArsPerKwh ?? 0,"ARS")}
Electricidad por kWh

Settlement preview

{formatCurrency(latestSnapshot?.quote.savingsUsd ?? 0)}
Ahorro estimado del último lote. No se exponen dividendos, supply ni tokenomics ficticia.

Serie reciente de settlement

El mock original hablaba de token stats y dividendos. Eso estaba mal. SolarChain demuestra trazabilidad y settlement verificable.

Impacto acumulado

sKWh asentables

{summary?.totals.totalSettlementSKwh ?? 0}

Ahorro estimado

{formatCurrency(summary?.totals.totalSavingsUsd ?? 0)}
CO₂ evitado acumulado{(summary?.totals.totalAvoidedCo2Kg ?? 0).toFixed(2)} kg

Trazabilidad

Snapshots persistidos{summary?.totals.snapshotCount ?? 0}
Batch actual{latestSnapshot?.batch.batchId ?? "Sin datos"}
Batch hash{latestSnapshot ? shortenHash(latestSnapshot.quote.batchHash) : "Sin datos"}
Última actualización{latestSnapshot ? formatDate(latestSnapshot.createdAt) : "Sin datos"}

Snapshots recientes

{(snapshots ?? []).map((snapshot)=>())}
FechaBatchAsignableSettlementAhorro
{formatDate(snapshot.createdAt)}{snapshot.batch.batchId}{snapshot.quote.assignableWh} Wh{snapshot.quote.settlementSKwh} sKWh{formatCurrency(snapshot.quote.savingsUsd)}
Evidence export disponible por API en /snapshots/:snapshotId/evidence.

Sistema operativo

API backend{apiBaseUrl}
Repository mode{summary?.repositoryMode ?? "Sin conexión"}
Contrato de settlementsKWh enteros
Oracle referencesPyth + climate snapshot
Cardano queda para metadata y settlement. No se sube telemetría cruda on-chain porque eso sería una mala idea con esteroides.
); } diff --git a/lazer/cardano/solarchain/apps/solarchain-web/components/ingest-batch-panel.tsx b/lazer/cardano/solarchain/apps/solarchain-web/components/ingest-batch-panel.tsx new file mode 100644 index 00000000..c1f7e48b --- /dev/null +++ b/lazer/cardano/solarchain/apps/solarchain-web/components/ingest-batch-panel.tsx @@ -0,0 +1,6 @@ +"use client"; +import { useMemo, useState } from "react"; +import type { SolarClimateSnapshot, SolarIngestSnapshotInput, SolarReferencePriceSnapshot, SolarSettlementQuote } from "@packages/shared-types"; +interface IngestBatchPanelProps { apiBaseUrl: string; exampleBatchText: string; defaultClimate: SolarClimateSnapshot; defaultReferencePrice: SolarReferencePriceSnapshot; siteId: string; } +interface SnapshotResponse { snapshot: { snapshotId: string; quote: SolarSettlementQuote; }; evidenceExportUrl: string; } +export function IngestBatchPanel({ apiBaseUrl, exampleBatchText, defaultClimate, defaultReferencePrice, siteId }: IngestBatchPanelProps) { const [jsonText,setJsonText]=useState(exampleBatchText); const [loading,setLoading]=useState<"idle"|"preview"|"persist">("idle"); const [error,setError]=useState(null); const [quote,setQuote]=useState(null); const [createdSnapshot,setCreatedSnapshot]=useState(null); const defaultPayload = useMemo(()=>({ siteId, batch: JSON.parse(exampleBatchText), climate: defaultClimate, referencePrice: defaultReferencePrice }),[defaultClimate,defaultReferencePrice,exampleBatchText,siteId]); function parseBatchPayload(){ const parsed = JSON.parse(jsonText) as SolarIngestSnapshotInput["batch"]; return { ...defaultPayload, batch: parsed } satisfies SolarIngestSnapshotInput; } async function handlePreview(){ setLoading("preview"); setError(null); setCreatedSnapshot(null); try { const payload=parseBatchPayload(); const response=await fetch(`${apiBaseUrl}/batches/quote`,{ method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify(payload.batch)}); const data=await response.json(); if(!response.ok) throw new Error(data.error ?? "No se pudo cotizar el lote"); setQuote(data as SolarSettlementQuote); } catch(cause){ setError(cause instanceof Error ? cause.message : "Error desconocido"); setQuote(null); } finally { setLoading("idle"); } } async function handlePersist(){ setLoading("persist"); setError(null); try { const payload=parseBatchPayload(); const response=await fetch(`${apiBaseUrl}/snapshots/ingest`,{ method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify(payload)}); const data=await response.json(); if(!response.ok) throw new Error(data.error ?? "No se pudo persistir el snapshot"); setCreatedSnapshot(data as SnapshotResponse); setQuote((data as SnapshotResponse).snapshot.quote); window.location.reload(); } catch(cause){ setError(cause instanceof Error ? cause.message : "Error desconocido"); } finally { setLoading("idle"); } } return (

Batch intake

Ingresar lote de lecturas

Esta consola reemplaza la narrativa incorrecta de dividendos/tokens del mock original. Aquí solo cotizamos y persistimos evidencia de settlement SolarChain.