From 8c3594d535e3973a21e8f944d18be4c5f6253098 Mon Sep 17 00:00:00 2001 From: "Hern@N" Date: Sun, 22 Mar 2026 18:50:49 -0300 Subject: [PATCH 1/2] Add Cardano tokenized commodities example under lazer/cardano Made-with: Cursor --- .../tokenized commodities/.env.example | 18 ++ .../cardano/tokenized commodities/.gitignore | 9 + lazer/cardano/tokenized commodities/README.md | 213 +++++++++++++++ .../apps/solarchain-api/package.json | 18 ++ .../apps/solarchain-api/src/index.ts | 52 ++++ .../apps/solarchain-api/tsconfig.json | 4 + .../apps/solarchain-web/app/layout.tsx | 14 + .../apps/solarchain-web/app/page.tsx | 53 ++++ .../apps/solarchain-web/next-env.d.ts | 2 + .../apps/solarchain-web/package.json | 19 ++ .../apps/solarchain-web/tsconfig.json | 10 + .../tokenized-commodities-api/package.json | 20 ++ .../tokenized-commodities-api/src/index.ts | 186 +++++++++++++ .../src/lib/audit.ts | 16 ++ .../tokenized-commodities-api/src/lib/json.ts | 7 + .../src/lib/oracle.ts | 62 +++++ .../src/lib/settlement-draft.ts | 44 ++++ .../tokenized-commodities-api/tsconfig.json | 4 + .../tokenized-commodities-web/app/layout.tsx | 14 + .../tokenized-commodities-web/app/page.tsx | 246 ++++++++++++++++++ .../tokenized-commodities-web/next-env.d.ts | 2 + .../tokenized-commodities-web/package.json | 19 ++ .../tokenized-commodities-web/tsconfig.json | 10 + .../tokenized commodities/docker-compose.yml | 16 ++ .../tokenized commodities/docs/CODEMAP.md | 31 +++ .../tokenized-commodities/API_CONTRACT.md | 8 + .../docs/tokenized-commodities/KNOWN_RISKS.md | 5 + .../tokenized-commodities/QA_CHECKLIST.md | 8 + .../SECURITY_CHECKLIST.md | 6 + .../tokenized-commodities/TECHNICAL_README.md | 22 ++ .../examples/commodity-agreement.json | 14 + .../examples/commodity-dispute-request.json | 21 ++ .../examples/commodity-quote-request.json | 21 ++ .../examples/solarchain-batch.json | 25 ++ .../tokenized commodities/package.json | 28 ++ .../packages/cardano-core/package.json | 20 ++ .../packages/cardano-core/src/index.ts | 4 + .../packages/cardano-core/src/lucid.ts | 25 ++ .../packages/cardano-core/src/metadata.ts | 17 ++ .../packages/cardano-core/src/policy.ts | 4 + .../packages/cardano-core/src/provider.ts | 7 + .../packages/cardano-core/tsconfig.json | 4 + .../packages/commodities-domain/package.json | 18 ++ .../packages/commodities-domain/src/errors.ts | 17 ++ .../packages/commodities-domain/src/index.ts | 3 + .../packages/commodities-domain/src/quote.ts | 105 ++++++++ .../commodities-domain/src/validation.ts | 147 +++++++++++ .../packages/commodities-domain/tsconfig.json | 4 + .../packages/config/package.json | 18 ++ .../packages/config/src/index.ts | 19 ++ .../packages/config/tsconfig.json | 4 + .../packages/contracts-aiken/README.md | 4 + .../packages/contracts-aiken/aiken.toml | 21 ++ .../packages/contracts-aiken/lib/common.ak | 7 + .../validators/commodity_escrow.ak | 57 ++++ .../validators/solarchain_settlement.ak | 35 +++ .../packages/pyth-adapter/package.json | 21 ++ .../packages/pyth-adapter/src/client.ts | 58 +++++ .../packages/pyth-adapter/src/index.ts | 1 + .../packages/pyth-adapter/tsconfig.json | 4 + .../packages/shared-types/package.json | 15 ++ .../packages/shared-types/src/index.ts | 186 +++++++++++++ .../packages/shared-types/tsconfig.json | 4 + .../packages/solarchain-domain/package.json | 19 ++ .../packages/solarchain-domain/src/index.ts | 2 + .../packages/solarchain-domain/src/quote.ts | 28 ++ .../solarchain-domain/src/validation.ts | 17 ++ .../packages/solarchain-domain/tsconfig.json | 4 + .../tokenized commodities/tsconfig.base.json | 16 ++ 69 files changed, 2162 insertions(+) create mode 100644 lazer/cardano/tokenized commodities/.env.example create mode 100644 lazer/cardano/tokenized commodities/.gitignore create mode 100644 lazer/cardano/tokenized commodities/README.md create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-api/package.json create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-api/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-api/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-web/app/layout.tsx create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-web/app/page.tsx create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-web/next-env.d.ts create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-web/package.json create mode 100644 lazer/cardano/tokenized commodities/apps/solarchain-web/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/package.json create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/audit.ts create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/json.ts create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/oracle.ts create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/src/lib/settlement-draft.ts create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-api/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/layout.tsx create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/app/page.tsx create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/next-env.d.ts create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/package.json create mode 100644 lazer/cardano/tokenized commodities/apps/tokenized-commodities-web/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/docker-compose.yml create mode 100644 lazer/cardano/tokenized commodities/docs/CODEMAP.md create mode 100644 lazer/cardano/tokenized commodities/docs/tokenized-commodities/API_CONTRACT.md create mode 100644 lazer/cardano/tokenized commodities/docs/tokenized-commodities/KNOWN_RISKS.md create mode 100644 lazer/cardano/tokenized commodities/docs/tokenized-commodities/QA_CHECKLIST.md create mode 100644 lazer/cardano/tokenized commodities/docs/tokenized-commodities/SECURITY_CHECKLIST.md create mode 100644 lazer/cardano/tokenized commodities/docs/tokenized-commodities/TECHNICAL_README.md create mode 100644 lazer/cardano/tokenized commodities/examples/commodity-agreement.json create mode 100644 lazer/cardano/tokenized commodities/examples/commodity-dispute-request.json create mode 100644 lazer/cardano/tokenized commodities/examples/commodity-quote-request.json create mode 100644 lazer/cardano/tokenized commodities/examples/solarchain-batch.json create mode 100644 lazer/cardano/tokenized commodities/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/src/lucid.ts create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/src/metadata.ts create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/src/policy.ts create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/src/provider.ts create mode 100644 lazer/cardano/tokenized commodities/packages/cardano-core/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/packages/commodities-domain/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/commodities-domain/src/errors.ts create mode 100644 lazer/cardano/tokenized commodities/packages/commodities-domain/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/packages/commodities-domain/src/quote.ts create mode 100644 lazer/cardano/tokenized commodities/packages/commodities-domain/src/validation.ts create mode 100644 lazer/cardano/tokenized commodities/packages/commodities-domain/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/packages/config/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/config/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/packages/config/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/packages/contracts-aiken/README.md create mode 100644 lazer/cardano/tokenized commodities/packages/contracts-aiken/aiken.toml create mode 100644 lazer/cardano/tokenized commodities/packages/contracts-aiken/lib/common.ak create mode 100644 lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/commodity_escrow.ak create mode 100644 lazer/cardano/tokenized commodities/packages/contracts-aiken/validators/solarchain_settlement.ak create mode 100644 lazer/cardano/tokenized commodities/packages/pyth-adapter/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/pyth-adapter/src/client.ts create mode 100644 lazer/cardano/tokenized commodities/packages/pyth-adapter/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/packages/pyth-adapter/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/packages/shared-types/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/shared-types/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/packages/shared-types/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/packages/solarchain-domain/package.json create mode 100644 lazer/cardano/tokenized commodities/packages/solarchain-domain/src/index.ts create mode 100644 lazer/cardano/tokenized commodities/packages/solarchain-domain/src/quote.ts create mode 100644 lazer/cardano/tokenized commodities/packages/solarchain-domain/src/validation.ts create mode 100644 lazer/cardano/tokenized commodities/packages/solarchain-domain/tsconfig.json create mode 100644 lazer/cardano/tokenized commodities/tsconfig.base.json 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.

+
+
+ + + +
+
+