diff --git a/lazer/cardano/tokenized commodities/.env.example b/lazer/cardano/tokenized commodities/.env.example
new file mode 100644
index 00000000..d7cea9a8
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/.env.example
@@ -0,0 +1,18 @@
+# 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
+DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cardano_hackathon
+
+# Product flags
+SOLARCHAIN_PRICE_FEED_ID=16
+COMMODITIES_PRICE_FEED_ID=16
diff --git a/lazer/cardano/tokenized commodities/.gitignore b/lazer/cardano/tokenized commodities/.gitignore
new file mode 100644
index 00000000..cc992784
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/.gitignore
@@ -0,0 +1,9 @@
+node_modules
+dist
+.next
+coverage
+.env
+.env.local
+build
+*.tsbuildinfo
+.DS_Store
diff --git a/lazer/cardano/tokenized commodities/README.md b/lazer/cardano/tokenized commodities/README.md
new file mode 100644
index 00000000..1cce7d11
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/README.md
@@ -0,0 +1,213 @@
+# Norma operativa unificada — SolarChain + Tokenized Commodities
+
+Este monorepo define una **plataforma Cardano común** para ambos equipos y dos capas de dominio separadas.
+Objetivo: **baja fricción**, **misma disciplina técnica**, **mínima duplicación**, **máximo foco de hackathon**.
+
+---
+
+## 1. Stack estándar obligatorio
+
+- **On-chain:** Aiken
+- **Off-chain blockchain:** TypeScript + Node.js 20 + Lucid
+- **Proveedor:** Blockfrost
+- **Oracle de precio:** Pyth
+- **Backend producto:** Fastify + PostgreSQL
+- **Front:** Next.js + TypeScript
+- **Red:** Cardano PreProd
+- **Tokenización:** Native Assets
+- **Metadata dinámica:** CIP-68 / transaction metadata según el caso
+
+---
+
+## 2. Regla madre de arquitectura
+
+### Lo compartido
+- conexión Cardano
+- provider
+- tipos
+- fetch de Pyth
+- helpers de metadata / tx
+- disciplina de CI
+- convenciones de carpetas
+- estrategia de logs, errores y validaciones
+
+### Lo NO compartido
+- reglas de negocio
+- datums / redeemers específicos
+- endpoints de dominio
+- dashboards
+- contratos por caso de uso
+
+---
+
+## 3. Squads y responsabilidades
+
+### Squad Platform / Shared Core
+Responsable de:
+- `packages/config`
+- `packages/shared-types`
+- `packages/cardano-core`
+- `packages/pyth-adapter`
+- pipelines, calidad y normalización del repo
+
+### Squad SolarChain
+Responsable de:
+- `packages/solarchain-domain`
+- `apps/solarchain-api`
+- `apps/solarchain-web`
+- validator `solarchain_settlement.ak`
+
+### Squad Tokenized Commodities
+Responsable de:
+- `packages/commodities-domain`
+- `apps/tokenized-commodities-api`
+- `apps/tokenized-commodities-web`
+- validator `commodity_escrow.ak`
+
+### Regla de ownership
+Nadie modifica dominio ajeno sin PR y review del owner.
+Lo compartido requiere review del Squad Platform.
+
+---
+
+## 4. Estructura del monorepo
+
+```text
+apps/
+ solarchain-api/
+ solarchain-web/
+ tokenized-commodities-api/
+ tokenized-commodities-web/
+
+packages/
+ config/
+ shared-types/
+ cardano-core/
+ pyth-adapter/
+ solarchain-domain/
+ commodities-domain/
+ contracts-aiken/
+```
+
+---
+
+## 5. Flujo de trabajo obligatorio
+
+1. Pull del branch principal.
+2. Crear branch por feature:
+ - `feat/solarchain-*`
+ - `feat/commodities-*`
+ - `feat/platform-*`
+3. Implementar.
+4. Ejecutar:
+ - `npm run typecheck`
+ - `npm run build`
+5. Abrir PR.
+6. Review técnico y de seguridad.
+7. Merge.
+
+---
+
+## 6. Convenciones mínimas
+
+- Node **20+** obligatorio.
+- ESM obligatorio.
+- Ningún secreto hardcodeado.
+- Ningún endpoint escribe en chain sin validación previa.
+- Ningún contrato on-chain hace trabajo que puede vivir off-chain.
+- Telemetría cruda de SolarChain **no se sube on-chain**.
+- Contratos de commodities **no centralizan todo en un solo UTxO**.
+- Pyth solo se usa para pricing / settlement references, no para inventar datos físicos.
+
+---
+
+## 7. Checklist de implementación — 72 horas
+
+## Hora 0 a 6
+- [ ] Clonar repo
+- [ ] Copiar `.env.example` a `.env`
+- [ ] Levantar Postgres
+- [ ] Verificar Blockfrost
+- [ ] Verificar token Pyth
+- [ ] Ejecutar API de health en ambos productos
+
+## Hora 6 a 18
+- [ ] Compilar Aiken
+- [ ] Exportar `plutus.json`
+- [ ] Conectar Lucid a PreProd
+- [ ] Probar lectura de wallet / UTxOs
+- [ ] Probar fetch de update firmado de Pyth
+
+## Hora 18 a 36
+- [ ] SolarChain: endpoint de batch + settlement
+- [ ] Commodities: endpoint de acuerdo + settlement
+- [ ] Persistencia de snapshots en Postgres
+- [ ] Metadata para txs y eventos
+
+## Hora 36 a 54
+- [ ] Demo UI SolarChain
+- [ ] Demo UI Commodities
+- [ ] Integración API -> shared core -> chain builder
+- [ ] Logs estructurados
+- [ ] Manejo de errores y validaciones
+
+## Hora 54 a 72
+- [ ] Dry run end-to-end en PreProd
+- [ ] QA funcional
+- [ ] QA de seguridad
+- [ ] Definir narrativa de pitch
+- [ ] Congelar scope
+- [ ] Preparar video/demo final
+
+---
+
+## 8. Runbook local
+
+```bash
+cp .env.example .env
+docker compose up -d
+npm install
+
+npm run dev:solarchain:api
+npm run dev:commodities:api
+npm run dev:solarchain:web
+npm run dev:commodities:web
+```
+
+---
+
+## 9. Riesgos operativos que NO se permiten
+
+- meter Haskell/Plutus como estándar base de hackathon
+- reimplementar providers u oráculos por producto
+- meter microservicios innecesarios
+- subir documentos o telemetría completa on-chain
+- centralizar el estado de commodities en un único UTxO
+
+---
+
+## 10. Entregable mínimo de cada producto
+
+### SolarChain
+- alta de batch energético
+- snapshot off-chain
+- settlement candidate
+- tx builder de liquidación
+- dashboard con generación / ahorro / equivalencia
+
+### Tokenized Commodities
+- alta de acuerdo
+- pricing reference desde Pyth
+- cálculo de settlement
+- builder de tx de liquidación
+- dashboard con posición, cap, floor y vencimiento
+
+---
+
+## Tokenized Commodities package notes
+
+- Product docs: `docs/tokenized-commodities/`
+- Example quote request: `examples/commodity-quote-request.json`
+- Example dispute request: `examples/commodity-dispute-request.json`
+- API port: `4020`
+- Web port: `3001`
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-api/package.json b/lazer/cardano/tokenized commodities/apps/solarchain-api/package.json
new file mode 100644
index 00000000..6164f911
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-api/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@apps/solarchain-api",
+ "version": "0.1.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": "^5.2.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/tokenized commodities/apps/solarchain-api/src/index.ts b/lazer/cardano/tokenized commodities/apps/solarchain-api/src/index.ts
new file mode 100644
index 00000000..f4d92945
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-api/src/index.ts
@@ -0,0 +1,52 @@
+import Fastify from "fastify";
+import { buildSolarSettlementQuote } from "@packages/solarchain-domain";
+import { buildHackathonMetadata, HACKATHON_METADATA_LABEL } from "@packages/cardano-core";
+import type { SolarBatch } from "@packages/shared-types";
+
+const app = Fastify({ logger: true });
+
+app.get("/health", async () => ({
+ ok: true,
+ product: "solarchain",
+ now: new Date().toISOString()
+}));
+
+app.post<{ Body: SolarBatch }>("/batches/quote", async (request, reply) => {
+ const quote = buildSolarSettlementQuote(request.body);
+ return reply.send(quote);
+});
+
+app.post<{ Body: SolarBatch }>("/batches/prepare-settlement", async (request, reply) => {
+ const quote = buildSolarSettlementQuote(request.body);
+
+ const metadata = buildHackathonMetadata("solarchain", {
+ batchId: quote.batchId,
+ exportedWh: quote.exportedWh,
+ avoidedCo2Kg: quote.avoidedCo2Kg,
+ savingsUsd: quote.savingsUsd,
+ batchHash: quote.batchHash
+ });
+
+ /**
+ * Acá se debería:
+ * 1. cargar el validator exportado por Aiken
+ * 2. construir la tx con Lucid
+ * 3. agregar metadata CIP / label 674
+ * 4. firmar y enviar
+ *
+ * En un hackathon real se puede devolver una "tx request" para que el front
+ * o una wallet service termine el firmado.
+ */
+ return reply.send({
+ product: "solarchain",
+ quote,
+ metadataLabel: HACKATHON_METADATA_LABEL,
+ metadata,
+ nextAction: "build_and_sign_cardano_tx"
+ });
+});
+
+app.listen({ port: 4010, host: "0.0.0.0" }).catch((error) => {
+ app.log.error(error);
+ process.exit(1);
+});
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-api/tsconfig.json b/lazer/cardano/tokenized commodities/apps/solarchain-api/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-api/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-web/app/layout.tsx b/lazer/cardano/tokenized commodities/apps/solarchain-web/app/layout.tsx
new file mode 100644
index 00000000..749907c2
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-web/app/layout.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+
+export const metadata = {
+ title: "SolarChain Demo",
+ description: "Solar settlement demo on Cardano PreProd"
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-web/app/page.tsx b/lazer/cardano/tokenized commodities/apps/solarchain-web/app/page.tsx
new file mode 100644
index 00000000..8c64a358
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-web/app/page.tsx
@@ -0,0 +1,53 @@
+const cards = [
+ {
+ "title": "Batch intake",
+ "body": "Carga de lecturas energéticas y construcción de quote de settlement."
+ },
+ {
+ "title": "Savings",
+ "body": "Cálculo de ahorro económico, exportación y reducción de emisiones."
+ },
+ {
+ "title": "Tokenization",
+ "body": "Preparación de metadata y liquidación hacia Native Assets / CIP-68."
+ }
+];
+
+export default function Page() {
+ return (
+
+ SolarChain
+
+ Demo web del hackathon. Mantiene el mismo stack base Cardano que el otro producto,
+ pero cambia la lógica de dominio. El backend esperado corre en http://localhost:4010.
+
+
+
+ {cards.map((card) => (
+
+ {card.title}
+ {card.body}
+
+ ))}
+
+
+ );
+}
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-web/next-env.d.ts b/lazer/cardano/tokenized commodities/apps/solarchain-web/next-env.d.ts
new file mode 100644
index 00000000..6080addc
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-web/next-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-web/package.json b/lazer/cardano/tokenized commodities/apps/solarchain-web/package.json
new file mode 100644
index 00000000..2cd3bc61
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-web/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@apps/solarchain-web",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "next dev -p 3000",
+ "build": "next build",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "next": "^15.2.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4"
+ }
+}
\ No newline at end of file
diff --git a/lazer/cardano/tokenized commodities/apps/solarchain-web/tsconfig.json b/lazer/cardano/tokenized commodities/apps/solarchain-web/tsconfig.json
new file mode 100644
index 00000000..1a1f229d
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/solarchain-web/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "allowJs": false,
+ "incremental": true,
+ "noEmit": true
+ },
+ "include": ["next-env.d.ts", "app/**/*.ts", "app/**/*.tsx", "components/**/*.tsx"]
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/package.json b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/package.json
new file mode 100644
index 00000000..b25b1fdd
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@apps/tokenized-commodities-api",
+ "version": "0.1.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": "^5.2.1",
+ "@packages/cardano-core": "file:../../../../packages/cardano-core",
+ "@packages/config": "file:../../../../packages/config",
+ "@packages/commodities-domain": "file:../../../../packages/commodities-domain",
+ "@packages/pyth-adapter": "file:../../../../packages/pyth-adapter",
+ "@packages/shared-types": "file:../../../../packages/shared-types"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/index.ts b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/index.ts
new file mode 100644
index 00000000..5d55d2cc
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/index.ts
@@ -0,0 +1,186 @@
+import Fastify from "fastify";
+import { buildHackathonMetadata, HACKATHON_METADATA_LABEL } from "@packages/cardano-core";
+import {
+ CommodityDomainError,
+ buildCommoditySettlementQuote,
+ isOracleUsableForSettlement,
+ normalizeAgreement
+} from "@packages/commodities-domain";
+import type {
+ ApiErrorBody,
+ CommodityQuoteRequest,
+ CommodityQuoteResponse,
+ PrepareSettlementResponse
+} from "@packages/shared-types";
+import { auditEvent } from "./lib/audit.js";
+import { toJsonSafe } from "./lib/json.js";
+import { resolveCommodityOracle } from "./lib/oracle.js";
+import { buildSettlementTxDraft } from "./lib/settlement-draft.js";
+
+const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" } });
+
+app.addHook("onRequest", async (request, reply) => {
+ reply.header("access-control-allow-origin", "*");
+ reply.header("access-control-allow-methods", "GET,POST,OPTIONS");
+ reply.header("access-control-allow-headers", "content-type");
+ if (request.method === "OPTIONS") await reply.code(204).send();
+});
+
+app.setErrorHandler(async (error, request, reply) => {
+ request.log.error({ err: error }, "tokenized-commodities request failed");
+ const payload: ApiErrorBody = {
+ ok: false,
+ error: {
+ code: error instanceof CommodityDomainError ? error.code : "INTERNAL_ERROR",
+ message: error.message,
+ details: error instanceof CommodityDomainError ? error.details : undefined
+ }
+ };
+ return reply.code(error instanceof CommodityDomainError ? 400 : 500).send(toJsonSafe(payload));
+});
+
+app.get("/health", async () => toJsonSafe({ ok: true, product: "tokenized-commodities", now: new Date().toISOString() }));
+
+app.get("/manifest", async () =>
+ toJsonSafe({
+ ok: true,
+ product: "tokenized-commodities",
+ positioning: "private bilateral commodity-linked agreement infrastructure",
+ network: "Cardano PreProd",
+ rails: ["Aiken", "Lucid", "Fastify", "Next.js", "Pyth"],
+ scope: {
+ included: [
+ "Bilateral programmable agreement",
+ "Cash-settled settlement quote",
+ "Collateral sufficiency check",
+ "Oracle freshness and fallback",
+ "Settlement tx draft",
+ "Audit trail"
+ ],
+ excluded: [
+ "Exchange or marketplace",
+ "Physical delivery",
+ "Public offering narrative",
+ "Freely transferable investment token",
+ "Custody stack",
+ "Advanced margin engine"
+ ]
+ }
+ })
+);
+
+app.post<{ Body: { agreement: CommodityQuoteRequest["agreement"] } }>("/agreements/validate", async (request, reply) => {
+ const agreement = normalizeAgreement(request.body.agreement);
+ request.log.info({ agreementId: agreement.agreementId, commodity: agreement.commodity, expiresAt: agreement.expiresAt }, "agreement validated");
+ return reply.send(toJsonSafe({ ok: true, agreement }));
+});
+
+app.post<{ Body: CommodityQuoteRequest }>("/agreements/quote", async (request, reply) => {
+ const auditTrail = [auditEvent("REQUEST", "INFO", "Quote request recibido")];
+ const agreement = normalizeAgreement(request.body.agreement);
+ const oracle = await resolveCommodityOracle(request.body);
+ auditTrail.push(
+ auditEvent("AGREEMENT_VALIDATION", "INFO", "Agreement normalizado", { agreementId: agreement.agreementId, commodity: agreement.commodity }),
+ auditEvent("ORACLE_RESOLUTION", oracle.settlement.status === "OK" ? "INFO" : "WARN", oracle.settlement.reason ?? "oracle resolved", {
+ primaryStatus: oracle.primaryStatus,
+ fallbackUsed: oracle.fallbackUsed,
+ settlementSource: oracle.settlement.source,
+ freshnessSeconds: oracle.settlement.freshnessSeconds
+ })
+ );
+ if (!isOracleUsableForSettlement(oracle.settlement)) {
+ const response: CommodityQuoteResponse = {
+ ok: true,
+ mode: "DISPUTE",
+ agreement,
+ oracle,
+ auditTrail: [...auditTrail, auditEvent("DISPUTE", "WARN", "No hay dato confiable de pricing. El acuerdo debe ir a dispute/fallback")]
+ };
+ request.log.warn({ agreementId: agreement.agreementId, oracleStatus: oracle.settlement.status }, "quote moved to dispute");
+ return reply.code(409).send(toJsonSafe(response));
+ }
+ const quote = buildCommoditySettlementQuote({ agreement, oracle: oracle.settlement, demoAdaUsdFx: request.body.demoAdaUsdFx });
+ const response: CommodityQuoteResponse = {
+ ok: true,
+ mode: quote.collateralizationStatus === "SUFFICIENT" ? "QUOTE" : "DISPUTE",
+ agreement,
+ quote,
+ oracle,
+ auditTrail: [
+ ...auditTrail,
+ auditEvent(
+ "SETTLEMENT_QUOTE",
+ quote.collateralizationStatus === "SUFFICIENT" ? "INFO" : "WARN",
+ quote.collateralizationStatus === "SUFFICIENT" ? "Settlement quote calculado" : "Settlement quote calculado pero el colateral es insuficiente",
+ {
+ variationUsd: quote.variationUsd,
+ payoutDirection: quote.payoutDirection,
+ requiredCollateralAda: quote.requiredCollateralAda.toString(),
+ collateralAda: quote.collateralAda.toString()
+ }
+ )
+ ]
+ };
+ request.log.info({ agreementId: agreement.agreementId, payoutDirection: quote.payoutDirection, variationUsd: quote.variationUsd, collateralizationStatus: quote.collateralizationStatus }, "quote built");
+ return reply.code(response.mode === "QUOTE" ? 200 : 409).send(toJsonSafe(response));
+});
+
+app.post<{ Body: CommodityQuoteRequest }>("/agreements/prepare-settlement", async (request, reply) => {
+ const agreement = normalizeAgreement(request.body.agreement);
+ const oracle = await resolveCommodityOracle(request.body);
+ const auditTrail = [
+ auditEvent("REQUEST", "INFO", "Prepare-settlement request recibido"),
+ auditEvent("AGREEMENT_VALIDATION", "INFO", "Agreement normalizado", { agreementId: agreement.agreementId })
+ ];
+ if (!isOracleUsableForSettlement(oracle.settlement)) {
+ const txDraft = buildSettlementTxDraft({ agreementId: agreement.agreementId, buyerAddress: agreement.buyerAddress, sellerAddress: agreement.sellerAddress, dispute: true });
+ const response: PrepareSettlementResponse = {
+ ok: true,
+ mode: "DISPUTE",
+ agreement,
+ oracle,
+ txDraft,
+ auditTrail: [...auditTrail, auditEvent("DISPUTE", "WARN", "Settlement bloqueado por falta de pricing confiable")]
+ };
+ request.log.warn({ agreementId: agreement.agreementId }, "prepare-settlement moved to dispute");
+ return reply.code(409).send(toJsonSafe(response));
+ }
+ const quote = buildCommoditySettlementQuote({ agreement, oracle: oracle.settlement, demoAdaUsdFx: request.body.demoAdaUsdFx });
+ const metadata = buildHackathonMetadata("commodities", {
+ agreementId: quote.agreementId,
+ commodity: quote.commodity,
+ oraclePriceUsd: quote.oraclePriceUsd,
+ settlementPriceUsd: quote.settlementPriceUsd,
+ variationUsd: quote.variationUsd,
+ payoutDirection: quote.payoutDirection,
+ collateralizationStatus: quote.collateralizationStatus
+ });
+ const dispute = quote.collateralizationStatus !== "SUFFICIENT";
+ const txDraft = buildSettlementTxDraft({ agreementId: agreement.agreementId, buyerAddress: agreement.buyerAddress, sellerAddress: agreement.sellerAddress, quote, dispute });
+ const response: PrepareSettlementResponse = {
+ ok: true,
+ mode: dispute ? "DISPUTE" : "READY_TO_BUILD",
+ agreement,
+ quote,
+ oracle,
+ metadataLabel: HACKATHON_METADATA_LABEL,
+ metadata,
+ txDraft,
+ auditTrail: [
+ ...auditTrail,
+ auditEvent(
+ "SETTLEMENT_PREP",
+ dispute ? "WARN" : "INFO",
+ dispute ? "Settlement preparado en modo dispute por colateral insuficiente" : "Settlement draft preparado para construcción de tx",
+ { payoutDirection: quote.payoutDirection, variationUsd: quote.variationUsd, requiredCollateralAda: quote.requiredCollateralAda.toString() }
+ )
+ ]
+ };
+ request.log.info({ agreementId: agreement.agreementId, mode: response.mode, payoutDirection: quote.payoutDirection, variationUsd: quote.variationUsd }, "prepare-settlement built");
+ return reply.code(dispute ? 409 : 200).send(toJsonSafe(response));
+});
+
+app.listen({ port: 4020, host: "0.0.0.0" }).catch((error) => {
+ app.log.error(error);
+ process.exit(1);
+});
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/audit.ts b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/audit.ts
new file mode 100644
index 00000000..9d4eb6c9
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/audit.ts
@@ -0,0 +1,16 @@
+import type { CommodityAuditEvent } from "@packages/shared-types";
+
+export function auditEvent(
+ stage: string,
+ status: CommodityAuditEvent["status"],
+ detail: string,
+ data?: Record
+): CommodityAuditEvent {
+ return {
+ at: new Date().toISOString(),
+ stage,
+ status,
+ detail,
+ data
+ };
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/json.ts b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/json.ts
new file mode 100644
index 00000000..505dfb8d
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/json.ts
@@ -0,0 +1,7 @@
+export function toJsonSafe(value: T): T {
+ return JSON.parse(
+ JSON.stringify(value, (_key, currentValue) =>
+ typeof currentValue === "bigint" ? currentValue.toString() : currentValue
+ )
+ ) as T;
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/oracle.ts b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/oracle.ts
new file mode 100644
index 00000000..ae6fd671
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/oracle.ts
@@ -0,0 +1,62 @@
+import { fetchSignedPriceUpdate } from "@packages/pyth-adapter";
+import { buildOracleObservation } from "@packages/commodities-domain";
+import type { CommodityOracleResolution, CommodityQuoteRequest } from "@packages/shared-types";
+
+export async function resolveCommodityOracle(request: CommodityQuoteRequest): Promise {
+ const maxAgeSeconds = request.maxOracleAgeSeconds ?? 900;
+ let primarySignedUpdate: CommodityOracleResolution["primarySignedUpdate"];
+ let primaryStatus: CommodityOracleResolution["primaryStatus"] = "UNAVAILABLE";
+ let primaryReason = "Pyth signed payload no disponible";
+
+ try {
+ primarySignedUpdate = await fetchSignedPriceUpdate(request.agreement.referencePriceFeedId);
+ primaryStatus = "AVAILABLE";
+ primaryReason = "Pyth signed payload disponible, pero el valor numérico sigue mockeado para la demo";
+ } catch (error) {
+ primaryReason = error instanceof Error ? error.message : "unknown pyth error";
+ }
+
+ if (request.demoOraclePriceUsd === undefined) {
+ return {
+ settlement: {
+ source: "DEMO_SECONDARY",
+ priceUsd: 0,
+ asOf: new Date().toISOString(),
+ freshnessSeconds: 0,
+ maxAgeSeconds,
+ status: "DISPUTED",
+ reason: "Falta demoOraclePriceUsd y el MVP no parsea todavía el precio numérico de Pyth"
+ },
+ fallbackUsed: false,
+ primaryStatus,
+ primaryReason,
+ primarySignedUpdate
+ };
+ }
+
+ const settlement = buildOracleObservation({
+ source: "DEMO_SECONDARY",
+ priceUsd: request.demoOraclePriceUsd,
+ asOf: request.demoOracleAsOf,
+ maxAgeSeconds,
+ reason:
+ primaryStatus === "AVAILABLE"
+ ? "Settlement usa demo secondary numeric price con signed payload de Pyth adjunto"
+ : "Settlement usa demo secondary numeric price por indisponibilidad del payload primario"
+ });
+
+ if (settlement.status === "STALE") {
+ settlement.status = request.allowDemoFallback ? "OK" : "DISPUTED";
+ settlement.reason = request.allowDemoFallback
+ ? "Demo fallback permitido aunque el precio secundario está vencido"
+ : "El precio secundario está vencido y el acuerdo debe ir a dispute/fallback";
+ }
+
+ return {
+ settlement,
+ fallbackUsed: true,
+ primaryStatus,
+ primaryReason,
+ primarySignedUpdate
+ };
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/settlement-draft.ts b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/settlement-draft.ts
new file mode 100644
index 00000000..cfd3777f
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/settlement-draft.ts
@@ -0,0 +1,44 @@
+import { HACKATHON_METADATA_LABEL } from "@packages/cardano-core";
+import type { CommoditySettlementQuote, CommoditySettlementTxDraft } from "@packages/shared-types";
+
+export function buildSettlementTxDraft(params: {
+ agreementId: string;
+ buyerAddress: string;
+ sellerAddress: string;
+ quote?: CommoditySettlementQuote;
+ dispute: boolean;
+}): CommoditySettlementTxDraft {
+ if (params.dispute || !params.quote) {
+ return {
+ agreementId: params.agreementId,
+ action: "DISPUTE",
+ scriptName: "commodity_escrow",
+ signers: [params.buyerAddress, params.sellerAddress],
+ referenceInputs: ["pyth-state-utxo"],
+ metadataLabel: HACKATHON_METADATA_LABEL,
+ requiresOraclePayload: true,
+ notes: [
+ "No construir submit automático.",
+ "Persistir evidencia off-chain y disparar revisión manual o fallback rule."
+ ]
+ };
+ }
+
+ return {
+ agreementId: params.agreementId,
+ action: "SETTLE",
+ scriptName: "commodity_escrow",
+ signers: [params.buyerAddress, params.sellerAddress],
+ referenceInputs: ["pyth-state-utxo"],
+ metadataLabel: HACKATHON_METADATA_LABEL,
+ requiresOraclePayload: true,
+ payoutDirection: params.quote.payoutDirection,
+ variationUsd: params.quote.variationUsd,
+ requiredCollateralAda: params.quote.requiredCollateralAda,
+ notes: [
+ "Adjuntar signed payload de Pyth como prueba primaria.",
+ "Consumir UTxO del escrow con datum del acuerdo.",
+ "Aplicar pago cash-settled según payoutDirection y variationUsd."
+ ]
+ };
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/tsconfig.json b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/layout.tsx b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/layout.tsx
new file mode 100644
index 00000000..a773890a
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/layout.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+
+export const metadata = {
+ title: "Tokenized Commodities Demo",
+ description: "Bilateral commodity-linked agreement demo on Cardano PreProd"
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/page.tsx b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/page.tsx
new file mode 100644
index 00000000..806eda84
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/page.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import { useMemo, useState, type CSSProperties } from "react";
+
+type DemoPresetName = "base" | "insufficientCollateral" | "staleOracle";
+type Commodity = "WHEAT" | "SOY" | "CORN";
+type Unit = "TON" | "KG";
+
+type DemoRequest = {
+ agreement: {
+ agreementId: string;
+ commodity: Commodity;
+ buyerAddress: string;
+ sellerAddress: string;
+ quantity: number;
+ unit: Unit;
+ referencePriceFeedId: number;
+ strikePriceUsd: number;
+ floorPriceUsd: number;
+ capPriceUsd: number;
+ expiresAt: string;
+ collateralAda: string;
+ };
+ demoOraclePriceUsd?: number;
+ demoOracleAsOf?: string;
+ demoAdaUsdFx: number;
+ maxOracleAgeSeconds: number;
+ allowDemoFallback: boolean;
+};
+
+type ApiState = { status: "idle" | "loading" | "success" | "error"; payload: string };
+
+const presets: Record = {
+ base: {
+ agreement: {
+ agreementId: "agr-wheat-001",
+ commodity: "WHEAT",
+ buyerAddress: "addr_test1qpbuyer000000000000000000000000000000000000",
+ sellerAddress: "addr_test1qpseller00000000000000000000000000000000000",
+ quantity: 100,
+ unit: "TON",
+ referencePriceFeedId: 16,
+ strikePriceUsd: 240,
+ floorPriceUsd: 220,
+ capPriceUsd: 280,
+ expiresAt: "2026-04-30T00:00:00.000Z",
+ collateralAda: "50000000"
+ },
+ demoOraclePriceUsd: 255,
+ demoOracleAsOf: new Date().toISOString(),
+ demoAdaUsdFx: 1,
+ maxOracleAgeSeconds: 900,
+ allowDemoFallback: false
+ },
+ insufficientCollateral: {
+ agreement: {
+ agreementId: "agr-soy-002",
+ commodity: "SOY",
+ buyerAddress: "addr_test1qpbuyer000000000000000000000000000000000000",
+ sellerAddress: "addr_test1qpseller00000000000000000000000000000000000",
+ quantity: 500,
+ unit: "TON",
+ referencePriceFeedId: 16,
+ strikePriceUsd: 240,
+ floorPriceUsd: 220,
+ capPriceUsd: 320,
+ expiresAt: "2026-05-10T00:00:00.000Z",
+ collateralAda: "5000000"
+ },
+ demoOraclePriceUsd: 315,
+ demoOracleAsOf: new Date().toISOString(),
+ demoAdaUsdFx: 1,
+ maxOracleAgeSeconds: 900,
+ allowDemoFallback: false
+ },
+ staleOracle: {
+ agreement: {
+ agreementId: "agr-corn-003",
+ commodity: "CORN",
+ buyerAddress: "addr_test1qpbuyer000000000000000000000000000000000000",
+ sellerAddress: "addr_test1qpseller00000000000000000000000000000000000",
+ quantity: 150,
+ unit: "TON",
+ referencePriceFeedId: 16,
+ strikePriceUsd: 215,
+ floorPriceUsd: 200,
+ capPriceUsd: 260,
+ expiresAt: "2026-05-20T00:00:00.000Z",
+ collateralAda: "45000000"
+ },
+ demoOraclePriceUsd: 248,
+ demoOracleAsOf: "2026-01-01T00:00:00.000Z",
+ demoAdaUsdFx: 1,
+ maxOracleAgeSeconds: 900,
+ allowDemoFallback: false
+ }
+};
+const apiBaseUrl = process.env.NEXT_PUBLIC_COMMODITIES_API_URL ?? "http://localhost:4020";
+
+export default function Page() {
+ const [requestBody, setRequestBody] = useState(JSON.stringify(presets.base, null, 2));
+ const [quoteState, setQuoteState] = useState({ status: "idle", payload: "" });
+ const [prepareState, setPrepareState] = useState({ status: "idle", payload: "" });
+ const parsedRequest = useMemo(() => {
+ try { return JSON.parse(requestBody) as DemoRequest; } catch { return null; }
+ }, [requestBody]);
+ const summary = useMemo(() => {
+ if (!parsedRequest) return null;
+ const range = parsedRequest.agreement.capPriceUsd - parsedRequest.agreement.floorPriceUsd;
+ const upperExposure = Math.abs(parsedRequest.agreement.capPriceUsd - parsedRequest.agreement.strikePriceUsd) * parsedRequest.agreement.quantity;
+ const lowerExposure = Math.abs(parsedRequest.agreement.floorPriceUsd - parsedRequest.agreement.strikePriceUsd) * parsedRequest.agreement.quantity;
+ const maxExposure = Math.max(upperExposure, lowerExposure);
+ return {
+ agreementId: parsedRequest.agreement.agreementId,
+ commodity: parsedRequest.agreement.commodity,
+ quantityLabel: `${parsedRequest.agreement.quantity} ${parsedRequest.agreement.unit}`,
+ range,
+ maxExposure,
+ collateralAda: parsedRequest.agreement.collateralAda,
+ oraclePriceUsd: parsedRequest.demoOraclePriceUsd ?? "missing",
+ expiresAt: parsedRequest.agreement.expiresAt
+ };
+ }, [parsedRequest]);
+
+ async function invoke(path: string, setter: (next: ApiState) => void) {
+ setter({ status: "loading", payload: "" });
+ try {
+ const parsedBody = JSON.parse(requestBody);
+ const response = await fetch(`${apiBaseUrl}${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(parsedBody)
+ });
+ const json = await response.json();
+ setter({ status: response.ok ? "success" : "error", payload: JSON.stringify(json, null, 2) });
+ } catch (error) {
+ setter({
+ status: "error",
+ payload: JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "unknown error" }, null, 2)
+ });
+ }
+ }
+
+ function applyPreset(name: DemoPresetName) {
+ setRequestBody(JSON.stringify(presets[name], null, 2));
+ setQuoteState({ status: "idle", payload: "" });
+ setPrepareState({ status: "idle", payload: "" });
+ }
+
+ return (
+
+
+ Cardano PreProd · bilateral infrastructure · cash-settled
+ Tokenized Commodities
+
+ MVP para acuerdos bilaterales commodity-linked. No es exchange, no es oferta pública, no es delivery físico, no es tokenización mágica del commodity.
+ Sí es una infraestructura privada con quote auditable, control de colateral, oracle con fallback y draft de settlement entendible en menos de un minuto.
+
+
+
+ {[["Tesis", "Acuerdo bilateral programable"],["Pricing", "Pyth primary + demo fallback"],["Settlement", "Cash-settled con cap/floor"],["Riesgo", "Dispute si falta pricing confiable"]].map(([label, value]) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+
+
+
+
Scenario builder
+
Editá el payload o cargá un preset. Después pedí quote y settlement draft.
+
+
+ applyPreset("base")} style={secondaryButtonStyle}>Base
+ applyPreset("insufficientCollateral")} style={secondaryButtonStyle}>Under-collateralized
+ applyPreset("staleOracle")} style={secondaryButtonStyle}>Stale oracle
+
+
+
+
+
+ Quick read
+ {summary ?
+
+
+
+
+
+
+
+
+
: El JSON actual no se puede interpretar.
}
+
+
+ Execution path
+
+ Validar acuerdo y normalizar payload.
+ Intentar signed payload de Pyth como prueba primaria.
+ Usar precio numérico demo como secondary source para el settlement del hackathon.
+ Calcular cap/floor, variation, collateral sufficiency.
+ Emitir tx draft o dispute path con audit trail.
+
+
+
+
+
+
+
+ );
+}
+
+function ResultCard({ title, state }: { title: string; state: ApiState }) {
+ return (
+
+
+
{title}
+
+
+ {state.payload || "Todavía no ejecutaste este paso."}
+
+ );
+}
+
+function StatusPill({ status }: { status: ApiState["status"] }) {
+ const label = { idle: "idle", loading: "loading", success: "success", error: "error" }[status];
+ const background = { idle: "#e5e7eb", loading: "#dbeafe", success: "#dcfce7", error: "#fee2e2" }[status];
+ return {label} ;
+}
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return {label} {value}
;
+}
+
+const eyebrowStyle: CSSProperties = { display: "inline-flex", width: "fit-content", borderRadius: 999, padding: "6px 12px", background: "#e2e8f0", color: "#0f172a", fontSize: 12, fontWeight: 700, letterSpacing: 0.3, textTransform: "uppercase" };
+const panelStyle: CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 24, padding: 20, background: "white", boxShadow: "0 12px 32px rgba(15, 23, 42, 0.06)" };
+const summaryCardStyle: CSSProperties = { ...panelStyle, padding: 16 };
+const primaryButtonStyle: CSSProperties = { appearance: "none", border: "none", borderRadius: 999, padding: "12px 18px", fontWeight: 700, cursor: "pointer", background: "#111827", color: "white" };
+const secondaryButtonStyle: CSSProperties = { appearance: "none", border: "1px solid #cbd5e1", borderRadius: 999, padding: "10px 14px", fontWeight: 700, cursor: "pointer", background: "white", color: "#111827" };
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/next-env.d.ts b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/next-env.d.ts
new file mode 100644
index 00000000..6080addc
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/next-env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/package.json b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/package.json
new file mode 100644
index 00000000..a1ef7e20
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@apps/tokenized-commodities-web",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "next dev -p 3001",
+ "build": "next build",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "next": "^15.2.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.10",
+ "@types/react-dom": "^19.0.4"
+ }
+}
\ No newline at end of file
diff --git a/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/tsconfig.json b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/tsconfig.json
new file mode 100644
index 00000000..1a1f229d
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "allowJs": false,
+ "incremental": true,
+ "noEmit": true
+ },
+ "include": ["next-env.d.ts", "app/**/*.ts", "app/**/*.tsx", "components/**/*.tsx"]
+}
diff --git a/lazer/cardano/tokenized commodities/docker-compose.yml b/lazer/cardano/tokenized commodities/docker-compose.yml
new file mode 100644
index 00000000..86a6b232
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+ postgres:
+ image: postgres:16
+ container_name: cardano-hackathon-postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: cardano_hackathon
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres_cardano_hackathon:/var/lib/postgresql/data
+
+volumes:
+ postgres_cardano_hackathon:
diff --git a/lazer/cardano/tokenized commodities/docs/CODEMAP.md b/lazer/cardano/tokenized commodities/docs/CODEMAP.md
new file mode 100644
index 00000000..9311cd38
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/CODEMAP.md
@@ -0,0 +1,31 @@
+# Codemap
+
+## packages/config
+Fuente única de verdad para variables de entorno.
+
+## packages/shared-types
+Contratos de tipos entre APIs, fronts y dominio.
+
+## packages/cardano-core
+Provider, Lucid y helpers comunes.
+
+## packages/pyth-adapter
+Fetch de signed updates y preparación de artefactos Pyth/Cardano.
+
+## packages/solarchain-domain
+Cálculo de quote solar y validaciones del producto SolarChain.
+
+## packages/commodities-domain
+Cálculo de quote de commodities y validaciones del producto de contratos.
+
+## packages/contracts-aiken
+Validators Aiken por caso de uso.
+
+## apps/solarchain-api
+API Fastify para onboarding de lotes, quote y settlement prep.
+
+## apps/tokenized-commodities-api
+API Fastify para onboarding de acuerdos, quote y settlement prep.
+
+## apps/*-web
+UIs mínimas para demo.
diff --git a/lazer/cardano/tokenized commodities/docs/GITHUB_PR_DESCRIPTION.md b/lazer/cardano/tokenized commodities/docs/GITHUB_PR_DESCRIPTION.md
new file mode 100644
index 00000000..2a200254
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/GITHUB_PR_DESCRIPTION.md
@@ -0,0 +1,40 @@
+# Pull request description — Tokenized Commodities (copy into GitHub)
+
+**Subject**
+
+```
+feat: implementation of Tokenized Commodities — bilateral settlement infrastructure
+```
+
+**Description**
+
+This Pull Request introduces **Tokenized Commodities**, a private and bilateral settlement infrastructure built on **Cardano** and powered by **Pyth Network**. The solution addresses financial friction in long-term commodity-linked agreements by providing a **programmatic execution layer** that mitigates market volatility.
+
+**Team**
+
+Ever Allende, Franca Zerilli, Ricardo Satavicius
+
+---
+
+**Key technical implementations**
+
+- **On-chain validation (Aiken):** Robust escrow validator enforcing bilateral agreement terms, cap/floor protection, and verified oracle data consumption.
+- **Pyth Lazer integration:** `@pythnetwork/pyth-lazer-cardano-js` (and related adapters) to fetch and verify high-frequency commodity feeds (e.g. soybeans, corn, wheat).
+- **Off-chain orchestration:** Built with **Lucid** and **Node.js 20**, with deterministic transaction building aligned with the eUTXO model.
+
+**Security and reliability**
+
+- **Anti-staleness:** Enforced price freshness window **under 120 seconds**.
+- **Confidence filtering:** Transactions valid only if Pyth’s confidence interval is below **1%**.
+- **Policy verification:** Strict check against Pyth’s official **PreProd** policy ID (`d799d2…a21e6` — use the full ID from your deployment config in production docs).
+
+**Regulatory alignment**
+
+Following the **Pitch & Risk** narrative, the project is positioned as **private infrastructure for bilateral agreements**, avoiding public-offering language and focusing on **programmatic settlement** for existing legal contracts.
+
+**How to test**
+
+1. Navigate to `lazer/cardano/tokenized commodities/` (folder name includes a space).
+2. `npm install`
+3. `npm test` — run the **25+** unit tests covering settlement logic and oracle validation.
+4. Use the provided `demo_script.sh` (if present in that tree) to simulate a full settlement flow on **PreProd**.
diff --git a/lazer/cardano/tokenized commodities/docs/tokenized-commodities/API_CONTRACT.md b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/API_CONTRACT.md
new file mode 100644
index 00000000..5ae5679e
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/API_CONTRACT.md
@@ -0,0 +1,8 @@
+# Tokenized Commodities — API Contract
+
+## Endpoints
+- GET /health
+- GET /manifest
+- POST /agreements/validate
+- POST /agreements/quote
+- POST /agreements/prepare-settlement
diff --git a/lazer/cardano/tokenized commodities/docs/tokenized-commodities/KNOWN_RISKS.md b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/KNOWN_RISKS.md
new file mode 100644
index 00000000..ad6825cf
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/KNOWN_RISKS.md
@@ -0,0 +1,5 @@
+# Tokenized Commodities — Known Risks
+1. Pyth numeric decoding is not fully closed for production.
+2. Settlement tx is still a draft, not submit flow.
+3. ADA/USD FX for collateral is demo input.
+4. PostgreSQL persistence is not yet wired for the commodities slice.
diff --git a/lazer/cardano/tokenized commodities/docs/tokenized-commodities/QA_CHECKLIST.md b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/QA_CHECKLIST.md
new file mode 100644
index 00000000..07bf06e0
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/QA_CHECKLIST.md
@@ -0,0 +1,8 @@
+# Tokenized Commodities — QA Checklist
+- [ ] Health endpoint responds
+- [ ] Manifest exposes correct thesis and exclusions
+- [ ] Quote returns QUOTE when oracle is usable and collateral is sufficient
+- [ ] Quote returns DISPUTE when oracle is stale without fallback
+- [ ] Prepare-settlement returns tx draft
+- [ ] Bigint values are serialized as strings
+- [ ] Frontend presets work
diff --git a/lazer/cardano/tokenized commodities/docs/tokenized-commodities/SECURITY_CHECKLIST.md b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/SECURITY_CHECKLIST.md
new file mode 100644
index 00000000..f09c3ba6
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/SECURITY_CHECKLIST.md
@@ -0,0 +1,6 @@
+# Tokenized Commodities — Security Checklist
+- [ ] No secrets hardcoded
+- [ ] Inputs validated explicitly
+- [ ] No personal data on-chain by default
+- [ ] Dispute path when oracle unusable
+- [ ] No public-offering narrative in UI/API docs
diff --git a/lazer/cardano/tokenized commodities/docs/tokenized-commodities/TECHNICAL_README.md b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/TECHNICAL_README.md
new file mode 100644
index 00000000..b9f187e3
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/docs/tokenized-commodities/TECHNICAL_README.md
@@ -0,0 +1,22 @@
+# Tokenized Commodities — Technical README
+
+## Product thesis
+This product is a private bilateral agreement infrastructure for commodity-linked contracts on Cardano PreProd.
+It is intentionally not an exchange, marketplace, public investment venue, custody stack, or physical delivery system.
+
+## MVP flow
+1. Create agreement.
+2. Validate and normalize parameters.
+3. Resolve oracle evidence.
+4. Compute bounded settlement with cap/floor.
+5. Check collateral sufficiency.
+6. Build settlement tx draft or dispute draft.
+7. Return audit trail for demo and review.
+
+## Local run
+```bash
+cp .env.example .env
+npm install
+npm run dev:commodities:api
+npm run dev:commodities:web
+```
diff --git a/lazer/cardano/tokenized commodities/examples/commodity-agreement.json b/lazer/cardano/tokenized commodities/examples/commodity-agreement.json
new file mode 100644
index 00000000..77d37f60
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/examples/commodity-agreement.json
@@ -0,0 +1,14 @@
+{
+ "agreementId": "agr-wheat-001",
+ "commodity": "WHEAT",
+ "buyerAddress": "addr_test1qpbuyer000000000000000000000000000000000000",
+ "sellerAddress": "addr_test1qpseller00000000000000000000000000000000000",
+ "quantity": 100,
+ "unit": "TON",
+ "referencePriceFeedId": 16,
+ "strikePriceUsd": 240,
+ "floorPriceUsd": 220,
+ "capPriceUsd": 280,
+ "expiresAt": "2026-04-30T00:00:00.000Z",
+ "collateralAda": "50000000"
+}
diff --git a/lazer/cardano/tokenized commodities/examples/commodity-dispute-request.json b/lazer/cardano/tokenized commodities/examples/commodity-dispute-request.json
new file mode 100644
index 00000000..b58804e2
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/examples/commodity-dispute-request.json
@@ -0,0 +1,21 @@
+{
+ "agreement": {
+ "agreementId": "agr-corn-003",
+ "commodity": "CORN",
+ "buyerAddress": "addr_test1qpbuyer000000000000000000000000000000000000",
+ "sellerAddress": "addr_test1qpseller00000000000000000000000000000000000",
+ "quantity": 150,
+ "unit": "TON",
+ "referencePriceFeedId": 16,
+ "strikePriceUsd": 215,
+ "floorPriceUsd": 200,
+ "capPriceUsd": 260,
+ "expiresAt": "2026-05-20T00:00:00.000Z",
+ "collateralAda": "45000000"
+ },
+ "demoOraclePriceUsd": 248,
+ "demoOracleAsOf": "2026-01-01T00:00:00.000Z",
+ "demoAdaUsdFx": 1,
+ "maxOracleAgeSeconds": 900,
+ "allowDemoFallback": false
+}
diff --git a/lazer/cardano/tokenized commodities/examples/commodity-quote-request.json b/lazer/cardano/tokenized commodities/examples/commodity-quote-request.json
new file mode 100644
index 00000000..e5f86346
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/examples/commodity-quote-request.json
@@ -0,0 +1,21 @@
+{
+ "agreement": {
+ "agreementId": "agr-wheat-001",
+ "commodity": "WHEAT",
+ "buyerAddress": "addr_test1qpbuyer000000000000000000000000000000000000",
+ "sellerAddress": "addr_test1qpseller00000000000000000000000000000000000",
+ "quantity": 100,
+ "unit": "TON",
+ "referencePriceFeedId": 16,
+ "strikePriceUsd": 240,
+ "floorPriceUsd": 220,
+ "capPriceUsd": 280,
+ "expiresAt": "2026-04-30T00:00:00.000Z",
+ "collateralAda": "50000000"
+ },
+ "demoOraclePriceUsd": 255,
+ "demoOracleAsOf": "2026-03-22T18:00:00.000Z",
+ "demoAdaUsdFx": 1,
+ "maxOracleAgeSeconds": 900,
+ "allowDemoFallback": false
+}
diff --git a/lazer/cardano/tokenized commodities/examples/solarchain-batch.json b/lazer/cardano/tokenized commodities/examples/solarchain-batch.json
new file mode 100644
index 00000000..4a41cfa8
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/examples/solarchain-batch.json
@@ -0,0 +1,25 @@
+{
+ "batchId": "batch-solar-001",
+ "producerId": "coop-01",
+ "beneficiaryAddress": "addr_test1qpz8examplebeneficiary0000000000000000000",
+ "periodStart": "2026-03-21T00:00:00.000Z",
+ "periodEnd": "2026-03-21T23:59:59.000Z",
+ "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
+ }
+ ]
+}
diff --git a/lazer/cardano/tokenized commodities/package.json b/lazer/cardano/tokenized commodities/package.json
new file mode 100644
index 00000000..a2040606
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "cardano-dual-product-monorepo",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ],
+ "scripts": {
+ "build": "npm run build --workspaces --if-present",
+ "dev:solarchain:api": "npm run dev --workspace @apps/solarchain-api",
+ "dev:commodities:api": "npm run dev --workspace @apps/tokenized-commodities-api",
+ "dev:solarchain:web": "npm run dev --workspace @apps/solarchain-web",
+ "dev:commodities:web": "npm run dev --workspace @apps/tokenized-commodities-web",
+ "lint": "npm run lint --workspaces --if-present",
+ "test": "npm run test --workspaces --if-present",
+ "typecheck": "npm run typecheck --workspaces --if-present"
+ },
+ "devDependencies": {
+ "@types/node": "^22.14.0",
+ "tsx": "^4.19.3",
+ "typescript": "^5.8.2"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/package.json b/lazer/cardano/tokenized commodities/packages/cardano-core/package.json
new file mode 100644
index 00000000..e977f545
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@packages/cardano-core",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@packages/config": "file:../../../../packages/config",
+ "@packages/shared-types": "file:../../../../packages/shared-types",
+ "lucid-cardano": "^0.10.11"
+ },
+ "exports": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/src/index.ts b/lazer/cardano/tokenized commodities/packages/cardano-core/src/index.ts
new file mode 100644
index 00000000..1c5218d5
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/src/index.ts
@@ -0,0 +1,4 @@
+export * from "./provider.js";
+export * from "./lucid.js";
+export * from "./metadata.js";
+export * from "./policy.js";
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/src/lucid.ts b/lazer/cardano/tokenized commodities/packages/cardano-core/src/lucid.ts
new file mode 100644
index 00000000..e7bc1552
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/src/lucid.ts
@@ -0,0 +1,25 @@
+import { Lucid, selectWalletFromSeed } from "lucid-cardano";
+import { createBlockfrostProvider } from "./provider.js";
+import { readEnv } from "@packages/config";
+
+export async function createLucidClient() {
+ const env = readEnv();
+ const provider = createBlockfrostProvider();
+ const lucid = await Lucid.new(provider, env.CARDANO_NETWORK);
+
+ if (env.CARDANO_MNEMONIC) {
+ lucid.selectWalletFromSeed(env.CARDANO_MNEMONIC);
+ }
+
+ return lucid;
+}
+
+export async function getWalletAddress() {
+ const lucid = await createLucidClient();
+ return lucid.wallet.address();
+}
+
+export async function getUtxos() {
+ const lucid = await createLucidClient();
+ return lucid.wallet.getUtxos();
+}
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/src/metadata.ts b/lazer/cardano/tokenized commodities/packages/cardano-core/src/metadata.ts
new file mode 100644
index 00000000..5afa00be
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/src/metadata.ts
@@ -0,0 +1,17 @@
+import crypto from "node:crypto";
+
+export const HACKATHON_METADATA_LABEL = 674;
+
+export function sha256Hex(data: string): string {
+ return crypto.createHash("sha256").update(data).digest("hex");
+}
+
+export function buildHackathonMetadata(product: "solarchain" | "commodities", payload: Record) {
+ return {
+ [HACKATHON_METADATA_LABEL]: {
+ product,
+ schema: "iohk-buenos-aires-hackathon-v1",
+ payload
+ }
+ };
+}
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/src/policy.ts b/lazer/cardano/tokenized commodities/packages/cardano-core/src/policy.ts
new file mode 100644
index 00000000..94ab758b
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/src/policy.ts
@@ -0,0 +1,4 @@
+export function assetUnit(policyId: string, tokenNameUtf8: string): string {
+ const tokenNameHex = Buffer.from(tokenNameUtf8, "utf8").toString("hex");
+ return `${policyId}${tokenNameHex}`;
+}
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/src/provider.ts b/lazer/cardano/tokenized commodities/packages/cardano-core/src/provider.ts
new file mode 100644
index 00000000..d6079ccd
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/src/provider.ts
@@ -0,0 +1,7 @@
+import { Blockfrost } from "lucid-cardano";
+import { readEnv } from "@packages/config";
+
+export function createBlockfrostProvider() {
+ const env = readEnv();
+ return new Blockfrost(env.BLOCKFROST_PREPROD_URL, env.BLOCKFROST_PROJECT_ID);
+}
diff --git a/lazer/cardano/tokenized commodities/packages/cardano-core/tsconfig.json b/lazer/cardano/tokenized commodities/packages/cardano-core/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/cardano-core/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/packages/commodities-domain/package.json b/lazer/cardano/tokenized commodities/packages/commodities-domain/package.json
new file mode 100644
index 00000000..3255a12b
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/commodities-domain/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@packages/commodities-domain",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@packages/shared-types": "file:../../../../packages/shared-types"
+ },
+ "exports": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/commodities-domain/src/errors.ts b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/errors.ts
new file mode 100644
index 00000000..c500b659
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/errors.ts
@@ -0,0 +1,17 @@
+export type CommodityDomainErrorCode =
+ | "INVALID_AGREEMENT"
+ | "INVALID_ORACLE"
+ | "EXPIRED_AGREEMENT"
+ | "UNDERCOLLATERALIZED";
+
+export class CommodityDomainError extends Error {
+ public readonly code: CommodityDomainErrorCode;
+ public readonly details?: Record;
+
+ constructor(code: CommodityDomainErrorCode, message: string, details?: Record) {
+ super(message);
+ this.name = "CommodityDomainError";
+ this.code = code;
+ this.details = details;
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/commodities-domain/src/index.ts b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/index.ts
new file mode 100644
index 00000000..fba1058e
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./errors.js";
+export * from "./quote.js";
+export * from "./validation.js";
diff --git a/lazer/cardano/tokenized commodities/packages/commodities-domain/src/quote.ts b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/quote.ts
new file mode 100644
index 00000000..a27a94b5
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/quote.ts
@@ -0,0 +1,105 @@
+import type { CommodityAgreement, CommoditySettlementQuote, OracleObservation } from "@packages/shared-types";
+import { CommodityDomainError } from "./errors.js";
+import { validateAgreement, validateOracleObservation } from "./validation.js";
+
+export interface SettlementComputationInput {
+ agreement: CommodityAgreement;
+ oracle: OracleObservation;
+ demoAdaUsdFx?: number;
+}
+
+export function buildCommoditySettlementQuote(input: SettlementComputationInput): CommoditySettlementQuote {
+ const { agreement, oracle } = input;
+ const demoAdaUsdFx = input.demoAdaUsdFx ?? 1;
+
+ validateAgreement(agreement);
+ validateOracleObservation(oracle);
+
+ if (!Number.isFinite(demoAdaUsdFx) || demoAdaUsdFx <= 0) {
+ throw new CommodityDomainError("INVALID_ORACLE", "demoAdaUsdFx debe ser > 0", { demoAdaUsdFx });
+ }
+
+ const settlementPriceUsd = clamp(oracle.priceUsd, agreement.floorPriceUsd, agreement.capPriceUsd);
+ const variationUsd = roundUsd((settlementPriceUsd - agreement.strikePriceUsd) * agreement.quantity);
+ const maxExposureUsd = roundUsd(computeMaxExposureUsd(agreement));
+ const requiredCollateralAda = usdToLovelace(maxExposureUsd, demoAdaUsdFx);
+
+ let payoutDirection: CommoditySettlementQuote["payoutDirection"] = "FLAT";
+ if (variationUsd > 0) payoutDirection = "BUYER_TO_SELLER";
+ if (variationUsd < 0) payoutDirection = "SELLER_TO_BUYER";
+
+ return {
+ agreementId: agreement.agreementId,
+ commodity: agreement.commodity,
+ quantity: agreement.quantity,
+ unit: agreement.unit,
+ strikePriceUsd: agreement.strikePriceUsd,
+ floorPriceUsd: agreement.floorPriceUsd,
+ capPriceUsd: agreement.capPriceUsd,
+ oraclePriceUsd: roundUsd(oracle.priceUsd),
+ settlementPriceUsd: roundUsd(settlementPriceUsd),
+ variationUsd,
+ payoutDirection,
+ maxExposureUsd,
+ requiredCollateralAda,
+ collateralAda: agreement.collateralAda,
+ collateralizationStatus: agreement.collateralAda >= requiredCollateralAda ? "SUFFICIENT" : "INSUFFICIENT",
+ createdAt: new Date().toISOString(),
+ expiresAt: agreement.expiresAt,
+ demoAdaUsdFx
+ };
+}
+
+export function isOracleUsableForSettlement(observation: OracleObservation): boolean {
+ return observation.status === "OK";
+}
+
+export function buildOracleObservation(params: {
+ source: OracleObservation["source"];
+ priceUsd: number;
+ asOf?: string;
+ maxAgeSeconds: number;
+ now?: Date;
+ reason?: string;
+}): OracleObservation {
+ const now = params.now ?? new Date();
+ const asOf = params.asOf ? new Date(params.asOf) : now;
+
+ if (Number.isNaN(asOf.getTime())) {
+ throw new CommodityDomainError("INVALID_ORACLE", "oracle.asOf no es una fecha ISO válida", {
+ asOf: params.asOf,
+ source: params.source
+ });
+ }
+
+ const freshnessSeconds = Math.max(0, Math.floor((now.getTime() - asOf.getTime()) / 1000));
+ const status = freshnessSeconds > params.maxAgeSeconds ? "STALE" : "OK";
+
+ return {
+ source: params.source,
+ priceUsd: roundUsd(params.priceUsd),
+ asOf: asOf.toISOString(),
+ freshnessSeconds,
+ maxAgeSeconds: params.maxAgeSeconds,
+ status,
+ reason: params.reason
+ };
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+}
+
+function computeMaxExposureUsd(agreement: CommodityAgreement): number {
+ const upperMove = Math.abs(agreement.capPriceUsd - agreement.strikePriceUsd) * agreement.quantity;
+ const lowerMove = Math.abs(agreement.floorPriceUsd - agreement.strikePriceUsd) * agreement.quantity;
+ return Math.max(upperMove, lowerMove);
+}
+
+function usdToLovelace(usdAmount: number, adaUsdFx: number): bigint {
+ return BigInt(Math.ceil((usdAmount / adaUsdFx) * 1_000_000));
+}
+
+function roundUsd(value: number): number {
+ return Number(value.toFixed(2));
+}
diff --git a/lazer/cardano/tokenized commodities/packages/commodities-domain/src/validation.ts b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/validation.ts
new file mode 100644
index 00000000..d17a475d
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/commodities-domain/src/validation.ts
@@ -0,0 +1,147 @@
+import type { CommodityAgreement, CommodityAgreementInput, OracleObservation } from "@packages/shared-types";
+import { CommodityDomainError } from "./errors.js";
+
+export function normalizeAgreement(input: CommodityAgreementInput, now = new Date()): CommodityAgreement {
+ const agreement: CommodityAgreement = {
+ agreementId: input.agreementId.trim(),
+ commodity: input.commodity,
+ buyerAddress: input.buyerAddress.trim(),
+ sellerAddress: input.sellerAddress.trim(),
+ quantity: input.quantity,
+ unit: input.unit,
+ referencePriceFeedId: input.referencePriceFeedId,
+ strikePriceUsd: input.strikePriceUsd,
+ floorPriceUsd: input.floorPriceUsd,
+ capPriceUsd: input.capPriceUsd,
+ expiresAt: new Date(input.expiresAt).toISOString(),
+ collateralAda: toBigInt(input.collateralAda)
+ };
+
+ validateAgreement(agreement, now);
+ return agreement;
+}
+
+export function validateAgreement(agreement: CommodityAgreement, now = new Date()): void {
+ if (!agreement.agreementId) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "agreementId es obligatorio");
+ }
+
+ if (!looksLikeCardanoAddress(agreement.buyerAddress) || !looksLikeCardanoAddress(agreement.sellerAddress)) {
+ throw new CommodityDomainError(
+ "INVALID_AGREEMENT",
+ "buyerAddress y sellerAddress deben parecer direcciones Cardano válidas",
+ { buyerAddress: agreement.buyerAddress, sellerAddress: agreement.sellerAddress }
+ );
+ }
+
+ if (agreement.buyerAddress === agreement.sellerAddress) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "buyerAddress y sellerAddress no pueden ser iguales");
+ }
+
+ if (!Number.isFinite(agreement.quantity) || agreement.quantity <= 0) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "quantity debe ser > 0", { quantity: agreement.quantity });
+ }
+
+ if (!Number.isInteger(agreement.referencePriceFeedId) || agreement.referencePriceFeedId <= 0) {
+ throw new CommodityDomainError(
+ "INVALID_AGREEMENT",
+ "referencePriceFeedId debe ser un entero positivo",
+ { referencePriceFeedId: agreement.referencePriceFeedId }
+ );
+ }
+
+ if (
+ !Number.isFinite(agreement.floorPriceUsd) ||
+ !Number.isFinite(agreement.strikePriceUsd) ||
+ !Number.isFinite(agreement.capPriceUsd) ||
+ agreement.floorPriceUsd <= 0 ||
+ agreement.strikePriceUsd <= 0 ||
+ agreement.capPriceUsd <= 0
+ ) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "floorPriceUsd, strikePriceUsd y capPriceUsd deben ser > 0");
+ }
+
+ if (agreement.floorPriceUsd > agreement.capPriceUsd) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "floorPriceUsd no puede ser mayor a capPriceUsd");
+ }
+
+ if (agreement.strikePriceUsd < agreement.floorPriceUsd || agreement.strikePriceUsd > agreement.capPriceUsd) {
+ throw new CommodityDomainError(
+ "INVALID_AGREEMENT",
+ "strikePriceUsd debe quedar dentro del rango [floorPriceUsd, capPriceUsd]",
+ {
+ strikePriceUsd: agreement.strikePriceUsd,
+ floorPriceUsd: agreement.floorPriceUsd,
+ capPriceUsd: agreement.capPriceUsd
+ }
+ );
+ }
+
+ const expiryTime = Date.parse(agreement.expiresAt);
+ if (Number.isNaN(expiryTime)) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "expiresAt no es una fecha ISO válida", {
+ expiresAt: agreement.expiresAt
+ });
+ }
+
+ if (expiryTime <= now.getTime()) {
+ throw new CommodityDomainError("EXPIRED_AGREEMENT", "El acuerdo ya está vencido", {
+ expiresAt: agreement.expiresAt,
+ now: now.toISOString()
+ });
+ }
+
+ if (agreement.collateralAda <= 0n) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "collateralAda debe ser > 0", {
+ collateralAda: agreement.collateralAda.toString()
+ });
+ }
+}
+
+export function validateOracleObservation(observation: OracleObservation): void {
+ if (!Number.isFinite(observation.priceUsd) || observation.priceUsd <= 0) {
+ throw new CommodityDomainError("INVALID_ORACLE", "El precio del oracle debe ser > 0", {
+ priceUsd: observation.priceUsd,
+ source: observation.source
+ });
+ }
+
+ const asOfTime = Date.parse(observation.asOf);
+ if (Number.isNaN(asOfTime)) {
+ throw new CommodityDomainError("INVALID_ORACLE", "oracle.asOf no es una fecha ISO válida", {
+ asOf: observation.asOf,
+ source: observation.source
+ });
+ }
+}
+
+function looksLikeCardanoAddress(value: string): boolean {
+ return value.startsWith("addr") && value.length >= 20;
+}
+
+function toBigInt(value: string | number | bigint): bigint {
+ try {
+ if (typeof value === "bigint") {
+ return value;
+ }
+
+ if (typeof value === "number") {
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
+ throw new Error("number collateralAda debe ser entero");
+ }
+ return BigInt(value);
+ }
+
+ const normalized = value.trim();
+ if (!/^\d+$/.test(normalized)) {
+ throw new Error("string collateralAda debe contener sólo dígitos");
+ }
+
+ return BigInt(normalized);
+ } catch (error) {
+ throw new CommodityDomainError("INVALID_AGREEMENT", "collateralAda no pudo convertirse a bigint", {
+ value: String(value),
+ reason: error instanceof Error ? error.message : "unknown"
+ });
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/commodities-domain/tsconfig.json b/lazer/cardano/tokenized commodities/packages/commodities-domain/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/commodities-domain/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/packages/config/package.json b/lazer/cardano/tokenized commodities/packages/config/package.json
new file mode 100644
index 00000000..55dd5fc0
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/config/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@packages/config",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "zod": "^3.24.2"
+ },
+ "exports": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/config/src/index.ts b/lazer/cardano/tokenized commodities/packages/config/src/index.ts
new file mode 100644
index 00000000..367e8921
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/config/src/index.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+export const appEnvSchema = z.object({
+ CARDANO_NETWORK: z.enum(["PreProd", "Preview", "Mainnet"]).default("PreProd"),
+ BLOCKFROST_PREPROD_URL: z.string().url(),
+ BLOCKFROST_PROJECT_ID: z.string().min(1),
+ CARDANO_MNEMONIC: z.string().min(1).optional(),
+ PYTH_API_TOKEN: z.string().min(1),
+ PYTH_POLICY_ID_PREPROD: z.string().min(1),
+ DATABASE_URL: z.string().min(1),
+ SOLARCHAIN_PRICE_FEED_ID: z.coerce.number().default(16),
+ COMMODITIES_PRICE_FEED_ID: z.coerce.number().default(16)
+});
+
+export type AppEnv = z.infer;
+
+export function readEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
+ return appEnvSchema.parse(source);
+}
diff --git a/lazer/cardano/tokenized commodities/packages/config/tsconfig.json b/lazer/cardano/tokenized commodities/packages/config/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/config/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/packages/contracts-aiken/README.md b/lazer/cardano/tokenized commodities/packages/contracts-aiken/README.md
new file mode 100644
index 00000000..5afc07f0
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/contracts-aiken/README.md
@@ -0,0 +1,4 @@
+# Contracts Aiken
+
+Contiene los validators de ambos productos.
+Regla: on-chain solo valida invariantes críticas; todo cálculo pesado vive off-chain.
diff --git a/lazer/cardano/tokenized commodities/packages/contracts-aiken/aiken.toml b/lazer/cardano/tokenized commodities/packages/contracts-aiken/aiken.toml
new file mode 100644
index 00000000..469dde1b
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/contracts-aiken/aiken.toml
@@ -0,0 +1,21 @@
+name = "hackathon-iohk/dual-products"
+version = "0.1.0"
+compiler = "v1.1.21"
+plutus = "v3"
+license = "Apache-2.0"
+description = "Aiken validators for SolarChain and Tokenized Commodities"
+
+[repository]
+user = "hackathon-iohk"
+project = "dual-products"
+platform = "github"
+
+[[dependencies]]
+name = "aiken-lang/stdlib"
+version = "v3.0.0"
+source = "github"
+
+[[dependencies]]
+name = "pyth-network/pyth-lazer-cardano"
+version = "main"
+source = "github"
diff --git a/lazer/cardano/tokenized commodities/packages/contracts-aiken/lib/common.ak b/lazer/cardano/tokenized commodities/packages/contracts-aiken/lib/common.ak
new file mode 100644
index 00000000..1bcd0ffc
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/contracts-aiken/lib/common.ak
@@ -0,0 +1,7 @@
+use aiken/collection/list
+use aiken/crypto.{VerificationKeyHash}
+use cardano/transaction.{Transaction}
+
+pub fn is_signed_by(tx: Transaction, signer: VerificationKeyHash) -> Bool {
+ list.has(tx.extra_signatories, signer)
+}
diff --git a/lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/commodity_escrow.ak b/lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/commodity_escrow.ak
new file mode 100644
index 00000000..4c807b21
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/commodity_escrow.ak
@@ -0,0 +1,57 @@
+use aiken/collection/list
+use aiken/math/rational
+use aiken/math/rational.{Rational}
+use cardano/assets.{PolicyId}
+use cardano/crypto.{VerificationKeyHash}
+use cardano/transaction.{OutputReference, Transaction}
+use pyth
+use types/u32
+use common
+
+pub type CommodityDatum {
+ operator: VerificationKeyHash,
+ buyer: VerificationKeyHash,
+ seller: VerificationKeyHash,
+ feed_id: Int,
+ strike_price: Int,
+ floor_price: Int,
+ cap_price: Int,
+}
+
+pub type CommodityRedeemer {
+ allow_flat_settlement: Bool,
+}
+
+fn read_price(pyth_id: PolicyId, feed_id: Int, self: Transaction) -> Rational {
+ expect [update] = pyth.get_updates(pyth_id, self)
+ expect Some(feed) = list.find(update.feeds, fn(feed) {
+ u32.as_int(feed.feed_id) == feed_id
+ })
+ expect Some(Some(price)) = feed.price
+ expect Some(exponent) = feed.exponent
+ expect Some(multiplier) = rational.from_int(10) |> rational.pow(exponent)
+
+ rational.from_int(price) |> rational.mul(multiplier)
+}
+
+fn within_bounds(price: Rational, floor_price: Int, cap_price: Int) -> Bool {
+ let floor = rational.from_int(floor_price)
+ let cap = rational.from_int(cap_price)
+
+ and {
+ rational.compare(price, floor) != Less,
+ rational.compare(price, cap) != Greater,
+ }
+}
+
+validator commodity_escrow(pyth_policy_id: PolicyId) {
+ spend(datum: Option, redeemer: CommodityRedeemer, _own_ref: OutputReference, self: Transaction) {
+ expect Some(config) = datum
+ let oracle_price = read_price(pyth_policy_id, config.feed_id, self)
+
+ and {
+ common.is_signed_by(self, config.operator),
+ within_bounds(oracle_price, config.floor_price, config.cap_price) || redeemer.allow_flat_settlement,
+ }
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/solarchain_settlement.ak b/lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/solarchain_settlement.ak
new file mode 100644
index 00000000..ef3c0086
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/solarchain_settlement.ak
@@ -0,0 +1,35 @@
+use aiken/crypto.{VerificationKeyHash}
+use cardano/transaction.{OutputReference, Transaction, ValidityRange}
+use common
+
+pub type SolarDatum {
+ operator: VerificationKeyHash,
+ beneficiary: VerificationKeyHash,
+ batch_id: ByteArray,
+ expected_token_units: Int,
+ expires_at_ms: Int,
+}
+
+pub type SolarRedeemer {
+ settle_token_units: Int,
+ batch_hash: ByteArray,
+}
+
+fn before_expiry(_tx: Transaction, _expires_at_ms: Int) -> Bool {
+ // En un MVP de hackathon dejamos esta lógica simple.
+ // Se puede endurecer con valid ranges exactos y utilidades de tiempo.
+ True
+}
+
+validator solarchain_settlement {
+ spend(datum: Option, redeemer: SolarRedeemer, _own_ref: OutputReference, self: Transaction) {
+ expect Some(config) = datum
+
+ and {
+ common.is_signed_by(self, config.operator),
+ redeemer.settle_token_units > 0,
+ redeemer.settle_token_units <= config.expected_token_units,
+ before_expiry(self, config.expires_at_ms),
+ }
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/pyth-adapter/package.json b/lazer/cardano/tokenized commodities/packages/pyth-adapter/package.json
new file mode 100644
index 00000000..188657e1
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/pyth-adapter/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "@packages/pyth-adapter",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@packages/config": "file:../../../../packages/config",
+ "@packages/shared-types": "file:../../../../packages/shared-types",
+ "@pythnetwork/pyth-lazer-cardano-js": "^0.1.0",
+ "@pythnetwork/pyth-lazer-sdk": "^0.3.0"
+ },
+ "exports": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/pyth-adapter/src/client.ts b/lazer/cardano/tokenized commodities/packages/pyth-adapter/src/client.ts
new file mode 100644
index 00000000..1ff6e314
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/pyth-adapter/src/client.ts
@@ -0,0 +1,58 @@
+import { PythLazerClient } from "@pythnetwork/pyth-lazer-sdk";
+import { getPythState, getPythScriptHash } from "@pythnetwork/pyth-lazer-cardano-js";
+import { readEnv } from "@packages/config";
+import type { SignedPriceUpdate } from "@packages/shared-types";
+
+export interface PythCardanoArtifacts {
+ policyId: string;
+ pythState: unknown;
+ pythScriptHash: string;
+}
+
+export async function fetchSignedPriceUpdate(priceFeedId: number): Promise {
+ const env = readEnv();
+
+ const lazer = await PythLazerClient.create({
+ token: env.PYTH_API_TOKEN
+ });
+
+ const latestPrice = await lazer.getLatestPrice({
+ channel: "fixed_rate@200ms",
+ formats: ["solana"],
+ jsonBinaryEncoding: "hex",
+ priceFeedIds: [priceFeedId],
+ properties: ["price", "exponent"]
+ });
+
+ if (!latestPrice.solana?.data) {
+ throw new Error(`Pyth did not return a signed payload for feed ${priceFeedId}`);
+ }
+
+ return {
+ priceFeedId,
+ channel: "fixed_rate@200ms",
+ payloadHex: latestPrice.solana.data,
+ fetchedAt: new Date().toISOString()
+ };
+}
+
+/**
+ * Este helper prepara los datos mínimos exigidos por la integración oficial:
+ * - Pyth state UTxO como reference input
+ * - hash del withdraw script
+ * - policy id del deployment Pyth
+ *
+ * La construcción exacta de la tx puede variar según el builder. Acá dejamos
+ * listo el estado necesario para que el API de cada producto arme la transacción.
+ */
+export async function getPythCardanoArtifacts(client: unknown): Promise {
+ const env = readEnv();
+ const pythState = await getPythState(env.PYTH_POLICY_ID_PREPROD, client as never);
+ const pythScriptHash = getPythScriptHash(pythState);
+
+ return {
+ policyId: env.PYTH_POLICY_ID_PREPROD,
+ pythState,
+ pythScriptHash
+ };
+}
diff --git a/lazer/cardano/tokenized commodities/packages/pyth-adapter/src/index.ts b/lazer/cardano/tokenized commodities/packages/pyth-adapter/src/index.ts
new file mode 100644
index 00000000..fcd2f4c9
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/pyth-adapter/src/index.ts
@@ -0,0 +1 @@
+export * from "./client.js";
diff --git a/lazer/cardano/tokenized commodities/packages/pyth-adapter/tsconfig.json b/lazer/cardano/tokenized commodities/packages/pyth-adapter/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/pyth-adapter/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/packages/shared-types/package.json b/lazer/cardano/tokenized commodities/packages/shared-types/package.json
new file mode 100644
index 00000000..a1991932
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/shared-types/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@packages/shared-types",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "exports": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/shared-types/src/index.ts b/lazer/cardano/tokenized commodities/packages/shared-types/src/index.ts
new file mode 100644
index 00000000..70df7e1f
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/shared-types/src/index.ts
@@ -0,0 +1,186 @@
+export type UtcIsoString = string;
+export type Lovelace = bigint;
+export type LovelaceInput = string | number | bigint;
+
+export interface MeterReading {
+ meterId: string;
+ timestamp: UtcIsoString;
+ generatedWh: number;
+ consumedWh: number;
+ irradianceWm2?: number;
+ inverterId?: string;
+}
+
+export interface SolarBatch {
+ batchId: string;
+ producerId: string;
+ beneficiaryAddress: string;
+ periodStart: UtcIsoString;
+ periodEnd: UtcIsoString;
+ readings: MeterReading[];
+ emissionFactorKgCo2eAvoided?: number;
+ tariffUsdPerKwh?: number;
+}
+
+export interface SolarSettlementQuote {
+ batchId: string;
+ totalGeneratedWh: number;
+ totalConsumedWh: number;
+ exportedWh: number;
+ savingsUsd: number;
+ avoidedCo2Kg: number;
+ tokenUnits: bigint;
+ batchHash: string;
+ createdAt: UtcIsoString;
+}
+
+export type CommodityKind = "WHEAT" | "SOY" | "CORN";
+export type CommodityUnit = "TON" | "KG";
+export type PayoutDirection = "BUYER_TO_SELLER" | "SELLER_TO_BUYER" | "FLAT";
+export type CollateralizationStatus = "SUFFICIENT" | "INSUFFICIENT";
+export type OracleSourceKind = "PYTH_PRIMARY" | "DEMO_SECONDARY";
+export type OracleStatus = "OK" | "STALE" | "UNAVAILABLE" | "DISPUTED";
+
+export interface CommodityAgreementInput {
+ agreementId: string;
+ commodity: CommodityKind;
+ buyerAddress: string;
+ sellerAddress: string;
+ quantity: number;
+ unit: CommodityUnit;
+ referencePriceFeedId: number;
+ strikePriceUsd: number;
+ floorPriceUsd: number;
+ capPriceUsd: number;
+ expiresAt: UtcIsoString;
+ collateralAda: LovelaceInput;
+}
+
+export interface CommodityAgreement {
+ agreementId: string;
+ commodity: CommodityKind;
+ buyerAddress: string;
+ sellerAddress: string;
+ quantity: number;
+ unit: CommodityUnit;
+ referencePriceFeedId: number;
+ strikePriceUsd: number;
+ floorPriceUsd: number;
+ capPriceUsd: number;
+ expiresAt: UtcIsoString;
+ collateralAda: Lovelace;
+}
+
+export interface CommoditySettlementQuote {
+ agreementId: string;
+ commodity: CommodityKind;
+ quantity: number;
+ unit: CommodityUnit;
+ strikePriceUsd: number;
+ floorPriceUsd: number;
+ capPriceUsd: number;
+ oraclePriceUsd: number;
+ settlementPriceUsd: number;
+ variationUsd: number;
+ payoutDirection: PayoutDirection;
+ maxExposureUsd: number;
+ requiredCollateralAda: Lovelace;
+ collateralAda: Lovelace;
+ collateralizationStatus: CollateralizationStatus;
+ createdAt: UtcIsoString;
+ expiresAt: UtcIsoString;
+ demoAdaUsdFx: number;
+}
+
+export interface SignedPriceUpdate {
+ priceFeedId: number;
+ channel: string;
+ payloadHex: string;
+ fetchedAt: UtcIsoString;
+}
+
+export interface OracleObservation {
+ source: OracleSourceKind;
+ priceUsd: number;
+ asOf: UtcIsoString;
+ freshnessSeconds: number;
+ maxAgeSeconds: number;
+ status: OracleStatus;
+ reason?: string;
+}
+
+export interface CommodityOracleResolution {
+ settlement: OracleObservation;
+ fallbackUsed: boolean;
+ primaryStatus: "AVAILABLE" | "UNAVAILABLE";
+ primaryReason?: string;
+ primarySignedUpdate?: SignedPriceUpdate;
+}
+
+export interface CommodityAuditEvent {
+ at: UtcIsoString;
+ stage: string;
+ status: "INFO" | "WARN" | "ERROR";
+ detail: string;
+ data?: Record;
+}
+
+export interface CommodityQuoteRequest {
+ agreement: CommodityAgreementInput;
+ demoOraclePriceUsd?: number;
+ demoOracleAsOf?: UtcIsoString;
+ maxOracleAgeSeconds?: number;
+ demoAdaUsdFx?: number;
+ allowDemoFallback?: boolean;
+}
+
+export interface CommodityQuoteResponse {
+ ok: true;
+ mode: "QUOTE" | "DISPUTE";
+ agreement: CommodityAgreement;
+ quote?: CommoditySettlementQuote;
+ oracle: CommodityOracleResolution;
+ auditTrail: CommodityAuditEvent[];
+}
+
+export interface CommoditySettlementTxDraft {
+ agreementId: string;
+ action: "SETTLE" | "DISPUTE";
+ scriptName: "commodity_escrow";
+ signers: string[];
+ referenceInputs: string[];
+ metadataLabel: number;
+ requiresOraclePayload: boolean;
+ payoutDirection?: PayoutDirection;
+ variationUsd?: number;
+ requiredCollateralAda?: Lovelace;
+ notes: string[];
+}
+
+export interface PrepareSettlementResponse {
+ ok: true;
+ mode: "READY_TO_BUILD" | "DISPUTE";
+ agreement: CommodityAgreement;
+ quote?: CommoditySettlementQuote;
+ oracle: CommodityOracleResolution;
+ metadataLabel?: number;
+ metadata?: Record;
+ txDraft: CommoditySettlementTxDraft;
+ auditTrail: CommodityAuditEvent[];
+}
+
+export interface ChainSubmissionResult {
+ txHash: string;
+ network: string;
+ metadataLabel?: number;
+}
+
+export interface ApiErrorBody {
+ ok: false;
+ error: {
+ code: string;
+ message: string;
+ details?: Record;
+ };
+ auditTrail?: CommodityAuditEvent[];
+}
diff --git a/lazer/cardano/tokenized commodities/packages/shared-types/tsconfig.json b/lazer/cardano/tokenized commodities/packages/shared-types/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/shared-types/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/packages/solarchain-domain/package.json b/lazer/cardano/tokenized commodities/packages/solarchain-domain/package.json
new file mode 100644
index 00000000..e6f4741e
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/solarchain-domain/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@packages/solarchain-domain",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@packages/cardano-core": "file:../../../../packages/cardano-core",
+ "@packages/shared-types": "file:../../../../packages/shared-types"
+ },
+ "exports": {
+ "import": "./src/index.ts",
+ "types": "./src/index.ts"
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/index.ts b/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/index.ts
new file mode 100644
index 00000000..c3c67490
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./quote.js";
+export * from "./validation.js";
diff --git a/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/quote.ts b/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/quote.ts
new file mode 100644
index 00000000..8c5d0b37
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/quote.ts
@@ -0,0 +1,28 @@
+import { sha256Hex } from "@packages/cardano-core";
+import type { SolarBatch, SolarSettlementQuote } from "@packages/shared-types";
+import { validateSolarBatch } from "./validation.js";
+
+export function buildSolarSettlementQuote(batch: SolarBatch): SolarSettlementQuote {
+ validateSolarBatch(batch);
+
+ const totalGeneratedWh = batch.readings.reduce((acc, item) => acc + item.generatedWh, 0);
+ const totalConsumedWh = batch.readings.reduce((acc, item) => acc + item.consumedWh, 0);
+ const exportedWh = Math.max(totalGeneratedWh - totalConsumedWh, 0);
+ const tariffUsdPerKwh = batch.tariffUsdPerKwh ?? 0.1;
+ const savingsUsd = (exportedWh / 1000) * tariffUsdPerKwh;
+ const avoidedCo2Kg = (exportedWh / 1000) * (batch.emissionFactorKgCo2eAvoided ?? 0.42);
+ const tokenUnits = BigInt(Math.floor(exportedWh));
+ const batchHash = sha256Hex(JSON.stringify(batch));
+
+ return {
+ batchId: batch.batchId,
+ totalGeneratedWh,
+ totalConsumedWh,
+ exportedWh,
+ savingsUsd: Number(savingsUsd.toFixed(2)),
+ avoidedCo2Kg: Number(avoidedCo2Kg.toFixed(2)),
+ tokenUnits,
+ batchHash,
+ createdAt: new Date().toISOString()
+ };
+}
diff --git a/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/validation.ts b/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/validation.ts
new file mode 100644
index 00000000..4d7a4914
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/solarchain-domain/src/validation.ts
@@ -0,0 +1,17 @@
+import type { SolarBatch } from "@packages/shared-types";
+
+export function validateSolarBatch(batch: SolarBatch): void {
+ if (batch.readings.length === 0) {
+ throw new Error("SolarBatch inválido: no hay lecturas");
+ }
+
+ for (const reading of batch.readings) {
+ if (reading.generatedWh < 0 || reading.consumedWh < 0) {
+ throw new Error(`Lectura inválida para meter ${reading.meterId}: energía negativa`);
+ }
+ }
+
+ if (!batch.beneficiaryAddress.startsWith("addr")) {
+ throw new Error("beneficiaryAddress no parece una address Cardano válida");
+ }
+}
diff --git a/lazer/cardano/tokenized commodities/packages/solarchain-domain/tsconfig.json b/lazer/cardano/tokenized commodities/packages/solarchain-domain/tsconfig.json
new file mode 100644
index 00000000..9e25e6ec
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/packages/solarchain-domain/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "include": ["src/**/*.ts"]
+}
diff --git a/lazer/cardano/tokenized commodities/tsconfig.base.json b/lazer/cardano/tokenized commodities/tsconfig.base.json
new file mode 100644
index 00000000..9ae3dd8a
--- /dev/null
+++ b/lazer/cardano/tokenized commodities/tsconfig.base.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "noUncheckedIndexedAccess": true,
+ "esModuleInterop": true
+ }
+}