Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lazer/cardano/solarchain/.env.example
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions lazer/cardano/solarchain/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules
dist
.next
coverage
.env
.env.local
build
*.tsbuildinfo
.DS_Store
78 changes: 78 additions & 0 deletions lazer/cardano/solarchain/README.md
Original file line number Diff line number Diff line change
@@ -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
```
20 changes: 20 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-api/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
10 changes: 10 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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; }
5 changes: 5 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-api/src/demo-data.ts
Original file line number Diff line number Diff line change
@@ -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 }] };
5 changes: 5 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> { 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); }); }
Original file line number Diff line number Diff line change
@@ -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<SolarSnapshotRepository> { 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";
Original file line number Diff line number Diff line change
@@ -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<void> { return; } async create(snapshot: SolarSnapshotRecord): Promise<SolarSnapshotRecord> { this.#snapshots.unshift(snapshot); return snapshot; } async list(limit: number): Promise<SolarSnapshotRecord[]> { return this.#snapshots.slice(0,limit); } async getById(snapshotId: string): Promise<SolarSnapshotRecord | null> { return this.#snapshots.find((i)=>i.snapshotId===snapshotId) ?? null; } }
Original file line number Diff line number Diff line change
@@ -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<void> { const schema = await readFile(schemaPath,"utf8"); await this.#pool.query(schema); } async create(snapshot: SolarSnapshotRecord): Promise<SolarSnapshotRecord> { 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<SolarSnapshotRecord[]> { const result = await this.#pool.query<SnapshotRow>(`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<SolarSnapshotRecord | null> { const result = await this.#pool.query<SnapshotRow>(`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; } }
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import type { RepositoryMode, SolarSnapshotRecord } from "@packages/shared-types";
export interface SolarSnapshotRepository { readonly mode: RepositoryMode; ensureReady(): Promise<void>; create(snapshot: SolarSnapshotRecord): Promise<SolarSnapshotRecord>; list(limit: number): Promise<SolarSnapshotRecord[]>; getById(snapshotId: string): Promise<SolarSnapshotRecord | null>; }
12 changes: 12 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-api/src/sql/schema.sql
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 7 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions lazer/cardano/solarchain/apps/solarchain-web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { ReactNode } from "react";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) { return (<html lang="es"><body>{children}</body></html>); }
Loading