Un marketplace e-commerce Web3 multi-vendor construido alrededor de una stablecoin anclada al euro (EURT). Un cliente compra EURT con una tarjeta de crédito real (Stripe), y luego gasta ese EURT on-chain para comprar productos a distintos comercios. El catálogo, los carritos, las facturas y los pagos viven en la blockchain — las apps web son clientes ligeros sobre el estado on-chain.
El pago final se liquida a través de una pasarela de pago cripto (approve + processPayment)
firmada con MetaMask, y un único checkout puede pagar a varios vendedores de forma atómica.
Construido como Módulo 8 del Máster CodeCrypto en Blockchain & AI Systems Engineering. Filosofía: aprendizaje profundo sobre velocidad — Stripe real, IPFS real (Pinata), 110 tests y 100% de cobertura del código de los contratos.
- Comprar EURT — el cliente paga con tarjeta en
compra-stablecoin(Stripe). Tras confirmarse el pago, el backend mintea EURT a la wallet del cliente. - Catálogo (admin) — un comercio registrado publica productos en
web-admin; las imágenes se suben a IPFS (Pinata) y on-chain solo se guarda el CID. - Carrito y checkout — el cliente añade productos en
web-customery hace checkout, que crea las facturas on-chain (fase 1) y redirige a la pasarela. - Pago cripto — en
payment-gatewayel cliente firma con MetaMask unapprovepor el importe exacto y unprocessPayment/processBatchPaymentsque liquida las facturas (fase 2). - Verificación — el cliente vuelve a
web-customery ve su orden marcada como Paid.
sequenceDiagram
actor C as Cliente (MetaMask)
participant B as compra-stablecoin (6001)
participant S as Stripe
participant ET as EuroToken (EURT)
participant A as web-admin (6003)
participant W as web-customer (6004)
participant G as payment-gateway (6002)
participant E as Ecommerce
C->>B: Paga con tarjeta (test)
B->>S: create-payment-intent
S-->>B: paymentIntent.succeeded
B->>ET: mint(cliente, importe)
Note over ET: EURT acuñado en la wallet del cliente
A->>E: addProduct (imagen → IPFS, on-chain solo CID)
C->>W: Añade productos al carrito
W->>E: checkout → crea facturas (fase 1)
W-->>C: window.location.href → pasarela (?invoices=...&redirect=...)
C->>G: Abre la pasarela
G->>ET: approve(Ecommerce, total exacto)
G->>E: processBatchPayments([ids]) (fase 2, atómico)
E->>ET: transferFrom(cliente → vendedores)
Note over E: Facturas marcadas como Paid
G-->>W: redirect de vuelta a /orders
W-->>C: Orden = Paid ✅
ecommerce-web3/
├── apps/
│ ├── compra-stablecoin/ # 6001 · Comprar EURT con tarjeta (Stripe) → mint
│ ├── payment-gateway/ # 6002 · MetaMask: approve + processPayment
│ ├── web-admin/ # 6003 · Empresas, productos (IPFS), facturas, clientes
│ └── web-customer/ # 6004 · Catálogo, carrito, checkout, historial de órdenes
├── contracts/
│ ├── euro-token/ # Proyecto Foundry · EuroToken.sol (EURT)
│ └── ecommerce/ # Proyecto Foundry · Ecommerce.sol + 6 librerías
├── packages/ # Paquetes compartidos (planificados; ver nota abajo)
│ ├── shared-abis/ # ABIs de los contratos
│ ├── shared-types/ # Tipos TypeScript compartidos
│ └── shared-config/ # Direcciones de contratos y config de red
├── restart-all.sh # Orquestación local (Anvil → deploy → seed → 4 apps)
├── scripts/ # Scripts auxiliares (ver scripts/README.md)
├── docs/ # ARCHITECTURE.md
├── CLAUDE.md # Guía profunda para desarrolladores
├── pnpm-workspace.yaml turbo.json .nvmrc
└── README.md # (este archivo)
🇪🇸 NOTA sobre
packages/: son paquetes compartidos planificados como futura fuente única de verdad (ABIs/tipos/config). Hoy no están implementados: cada app lleva sus propios ABIs, tipos y config. Se documentan aquí para reflejar la estructura prevista del monorepo.
- Librerías
internal+using for. La lógica deEcommercese reparte en 6 librerías (Company / Product / Customer / Cart / Invoice / Payment) enganchadas conusing X for Y. Esto mantiene el contrato principal pequeño (límite de 24 KB), mejora la legibilidad y el gas. - Multi-vendor atómico. Un checkout genera una factura por vendedor;
processBatchPaymentslas liquida todas en una transacción — o pasan todas, o revierte ninguna. - Checkout en 2 fases. Fase 1:
checkout()crea las facturas on-chain (estado pendiente). Fase 2: en la pasarela,approve+processPayment(s)mueve el EURT y marca las facturas como pagadas. Separar creación de pago permite redirigir entre apps de distinto origen sin perder estado. - Access control en 2 niveles.
EuroTokenusa Ownable (solo el owner mintea).Ecommerceusa AccessControl:DEFAULT_ADMIN_ROLE(plataforma) puede registrar empresas, y un modifieronlyCompanyOwnerrestringe el CRUD de productos al dueño de cada empresa.
| Capa | Tecnologías |
|---|---|
| On-chain | Solidity 0.8.28 · Foundry (Forge/Anvil/Cast) · OpenZeppelin (ERC20, Ownable, AccessControl, ReentrancyGuard) |
| Off-chain | Next.js 15 (App Router) · TypeScript (strict) · Tailwind CSS · ethers.js v6 |
| Integraciones | Stripe (Payment Intents, test mode) · IPFS vía Pinata · MetaMask (EIP-1193) |
| Tooling | pnpm workspaces · Turborepo · Node 20 LTS |
| Red local | Anvil (chainId 31337, RPC http://localhost:8545) |
- Node 20 LTS + pnpm vía corepack (
corepack enable). - Foundry (
foundryup) — Forge, Anvil, Cast. - MetaMask configurado para la red local Anvil (chainId
31337, RPChttp://localhost:8545). - Una cuenta de Stripe en modo test (publishable + secret key) — para
compra-stablecoin. - Un JWT de Pinata (IPFS) — para subir imágenes de producto en
web-admin.
git clone <repo-url> ecommerce-web3
cd ecommerce-web3
corepack enable # habilita pnpm vía corepack
pnpm install # instala dependencias de todo el workspace
# Compila ambos proyectos Foundry
forge build --root contracts/euro-token
forge build --root contracts/ecommerce
# Copia y rellena las variables de entorno de cada app (ver la sección 10)
cp apps/compra-stablecoin/.env.example apps/compra-stablecoin/.env
cp apps/payment-gateway/.env.example apps/payment-gateway/.env
cp apps/web-admin/.env.example apps/web-admin/.env
cp apps/web-customer/.env.example apps/web-customer/.env🇪🇸 NOTA: las direcciones de los contratos que van en los
.envson deterministas en Anvil (mismas en cada arranque). Están listadas en Direcciones deterministas y las usarestart-all.sh.
Un único comando levanta todo el sistema local de cero:
./restart-all.shEs idempotente (seguro re-ejecutar): mata los procesos previos antes de arrancar. Los logs se
escriben en /tmp/ (/tmp/anvil.log, /tmp/app-6001.log, …).
| Paso | Qué hace |
|---|---|
| 1 | Mata procesos previos de Anvil y de las apps, y libera los puertos 8545 / 6001-6004. |
| 2 | Arranca Anvil (chainId 31337) y espera a que el RPC responda. |
| 3 | Ejecuta forge script DeployEcommerce.s.sol → despliega EuroToken + Ecommerce en una pasada. |
| 4 | Valida que las direcciones desplegadas coinciden con las deterministas esperadas (ver Direcciones deterministas). |
| 5 | Siembra datos: empresa TechShop (id 1, owner = acct1) + 3 productos (Smartphone, Laptop, Bicicleta) + mintea 1000 EURT a acct1. |
| 6 | Arranca las 4 apps con pnpm --filter <app> dev y espera HTTP 200 en cada puerto. |
🇪🇸 NOTA: el script no registra clientes ni wirea los
.envautomáticamente — los.envde las apps ya llevan preconfiguradas las direcciones deterministas. El registro de cliente ocurre en la app la primera vez que se interactúa (carrito/checkout).
| Servicio | URL | Puerto |
|---|---|---|
| Anvil (RPC) | http://localhost:8545 | 8545 |
| compra-stablecoin | http://localhost:6001 | 6001 |
| payment-gateway | http://localhost:6002 | 6002 |
| web-admin | http://localhost:6003 | 6003 |
| web-customer | http://localhost:6004 | 6004 |
⚠️ SOLO PARA DESARROLLO LOCAL. Estas son las claves públicas y bien conocidas del mnemónico por defecto de Anvil (test test test … junk). Nunca las uses en una red real ni les envíes fondos con valor.
| Cuenta | Dirección | Clave privada | Rol |
|---|---|---|---|
| acct0 | 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 |
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 |
Owner / deployer (owner de EuroToken, admin de la plataforma) |
| acct1 | 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 |
0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d |
Owner de TechShop + dueño de los 1000 EURT sembrados |
| acct2 | 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC |
0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a |
Cliente (sin fondos iniciales; se fondea con EURT vía el flujo Stripe) |
🇪🇸 NOTA importante para la demo: acct1 es a la vez vendedor y tesorero (tiene los 1000 EURT). Para una prueba E2E coherente conviene usar acct2 como cliente y fondearla con EURT en el paso 1 — así no te "compras a ti mismo".
🇪🇸 NOTA: las
NEXT_PUBLIC_*se exponen al navegador (no son secretas). El resto son solo de servidor (Stripe secret key, clave privada del minter, Pinata JWT) y nunca llevan prefijo público. Plantillas reales en cadaapps/<app>/.env.example.
| Variable | ¿Secreta? | Propósito |
|---|---|---|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY |
no | Clave cliente de Stripe |
NEXT_PUBLIC_EUROTOKEN_ADDRESS |
no | Dirección de EuroToken (ver aviso de naming abajo) |
NEXT_PUBLIC_RPC_URL |
no | RPC de Anvil |
NEXT_PUBLIC_CHAIN_ID |
no | 31337 |
NEXT_PUBLIC_PAYMENT_GATEWAY_URL |
no | URL de la pasarela (para el flujo demo legacy) |
STRIPE_SECRET_KEY |
sí | Clave servidor de Stripe (crear Payment Intent) |
WALLET_PRIVATE_KEY |
sí | Wallet owner que llama a mint() |
RPC_URL |
sí | RPC usado por las API routes del servidor |
| Variable | ¿Secreta? | Propósito |
|---|---|---|
NEXT_PUBLIC_RPC_URL |
no | RPC de Anvil |
NEXT_PUBLIC_CHAIN_ID |
no | 31337 |
NEXT_PUBLIC_ECOMMERCE_ADDRESS |
no | Para processPayment / processBatchPayments |
NEXT_PUBLIC_EURO_TOKEN_ADDRESS |
no | Para approve() |
| Variable | ¿Secreta? | Propósito |
|---|---|---|
NEXT_PUBLIC_RPC_URL |
no | RPC de Anvil |
NEXT_PUBLIC_CHAIN_ID |
no | 31337 |
NEXT_PUBLIC_ECOMMERCE_ADDRESS |
no | Contrato Ecommerce |
NEXT_PUBLIC_EURO_TOKEN_ADDRESS |
no | Contrato EuroToken |
NEXT_PUBLIC_IPFS_GATEWAY |
no | Gateway público para leer imágenes IPFS |
PINATA_JWT |
sí | Subida de imágenes a IPFS (Pinata) |
| Variable | ¿Secreta? | Propósito |
|---|---|---|
NEXT_PUBLIC_RPC_URL |
no | RPC de Anvil |
NEXT_PUBLIC_CHAIN_ID |
no | 31337 |
NEXT_PUBLIC_ECOMMERCE_ADDRESS |
no | Contrato Ecommerce |
NEXT_PUBLIC_EURO_TOKEN_ADDRESS |
no | Contrato EuroToken |
NEXT_PUBLIC_IPFS_GATEWAY |
no | Leer imágenes de producto |
NEXT_PUBLIC_PAYMENT_GATEWAY_URL |
no | Destino del redirect del checkout a la pasarela |
⚠️ Aviso de naming (inconsistencia real del repo):compra-stablecoinnombra la dirección de EuroToken comoNEXT_PUBLIC_EUROTOKEN_ADDRESS(sin guion bajo), mientras que las otras tres apps usanNEXT_PUBLIC_EURO_TOKEN_ADDRESS. Respétalo tal cual al rellenar cada.env.
EuroToken (EURT) = 0x5FbDB2315678afecb367f032d93F642f64180aa3
Ecommerce = 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Prerrequisito:
./restart-all.shcorriendo, y MetaMask conectado a Anvil (chainId31337).
-
Comprar EURT (cliente = acct2).
- Importa acct2 en MetaMask.
- Abre
http://localhost:6001, introduce un importe y paga con una tarjeta de test de Stripe (p.ej.4242 4242 4242 4242, fecha futura, CVC cualquiera). - Tras
paymentIntent.succeeded, el backend mintea EURT a acct2. Verifica el saldo (en MetaMask o concast).
-
Catálogo (admin = acct1).
- Los 3 productos de TechShop ya están sembrados. Opcionalmente, abre
http://localhost:6003con acct1 y añade un producto nuevo (sube una imagen → IPFS, se guarda solo el CID).
- Los 3 productos de TechShop ya están sembrados. Opcionalmente, abre
-
Carrito y checkout (cliente = acct2).
- En
http://localhost:6004añade uno o varios productos al carrito y pulsa checkout. - Se crean las facturas on-chain (fase 1) y la app redirige a la pasarela
(
?invoices=...&redirect=.../orders).
- En
-
Pago en la pasarela (cliente = acct2).
- En
http://localhost:6002confirma: firma elapprove(por el importe exacto) y luego elprocessPayment/processBatchPayments. - El EURT se transfiere del cliente al/los vendedor(es) y las facturas pasan a Paid.
- En
-
Verificar la orden.
- Vuelves automáticamente a
http://localhost:6004/orders. La orden aparece como Paid con los nombres de producto resueltos. ✅
- Vuelves automáticamente a
- ERC20 con 6 decimales (como USDC: 1 EUR = 1 000 000 unidades base; los decimales son cosméticos, on-chain todo son enteros).
- Ownable (OpenZeppelin v5): solo el
ownerpuedemint(address,uint256). Emite eventoMint.
- Orquestador con AccessControl y ReentrancyGuard, compuesto por 6 librerías vía
using for:CompanyLib · ProductLib · CustomerLib · CartLib · InvoiceLib · PaymentLib. - Funciones de pago:
processPayment(uint256)(una factura) yprocessBatchPayments(uint256[])(varias, atómico; revierte con array vacío). GuardProductCompanyMismatch(companyId, productId)que evita pagar al vendedor equivocado si elcompanyIddenormalizado del carrito no coincide con el real del producto.
# EuroToken
forge build --root contracts/euro-token
forge test --root contracts/euro-token -vvv
forge coverage --root contracts/euro-token
# Ecommerce
forge build --root contracts/ecommerce
forge test --root contracts/ecommerce -vvv
forge coverage --root contracts/ecommerce
# Desplegar localmente (ambos contratos en una pasada)
forge script script/DeployEcommerce.s.sol \
--root contracts/ecommerce \
--rpc-url http://localhost:8545 --broadcast \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80110 tests en total, todos pasando = 8 (euro-token) + 102 (ecommerce).
| Proyecto | Tests | Cobertura del código fuente (medida con forge coverage) |
|---|---|---|
| euro-token | 8 | EuroToken.sol: 100% (líneas 5/5, statements 3/3, ramas, funcs 2/2) |
| ecommerce | 102 | Ecommerce.sol: 100% (112/112 líneas, 127/127 statements, 13/13 ramas, 28/28 funcs) · las 6 librerías: 100% en todo |
🇪🇸 NOTA: la cobertura del código de los contratos (EuroToken + Ecommerce + 6 librerías) es del 100% en líneas, statements, ramas y funciones. Los
Totalagregados que imprimeforge coveragebajan solo porque incluyen los scripts de despliegue (script/*.s.sol), que no se testean por convención.
Patrones de test: vm.prank y vm.startPrank / vm.stopPrank para cambiar de actor,
vm.expectRevert para asertar errores, vm.expectEmit para eventos, fuzz testing en EuroToken, y
un test E2E en ecommerce que invoca el propio DeployEcommerce script como harness de despliegue.
| Decisión | Razón |
|---|---|
approve por importe exacto (no MaxUint256) |
Mínima superficie de riesgo: la pasarela solo aprueba lo que se va a pagar, en vez de un allowance infinito. |
Helper parseEurt (src/lib/format.ts) |
Convierte un string en euros a bigint con 6 decimales de forma centralizada y consistente. |
Diff sobre getCustomerInvoices |
El checkout calcula los IDs de factura nuevos comparando la lista antes/después, sin depender de devolver IDs desde la transacción. |
window.location.href para el redirect |
El checkout salta de web-customer (:6004) a payment-gateway (:6002): distinto origen ⇒ navegación de página completa, no router.push. |
Guard ProductCompanyMismatch |
Valida que el companyId denormalizado del carrito coincide con el real del producto, evitando pagar al vendedor incorrecto. |
CID almacenado como string (≥46 chars) |
On-chain solo se guarda el identificador de IPFS, no los bytes de la imagen (almacenar bytes on-chain sería prohibitivo). |
El backend mintea solo tras paymentIntent.succeeded |
Nunca se confía en el navegador para confirmar el pago: el EURT se acuña únicamente cuando Stripe confirma el cobro de la tarjeta. |
Este proyecto se construyó usando Claude Code (Anthropic) como compañero de desarrollo.
Ver CLAUDE.md — la guía maestra leída tanto por la IA como por humanos.
Filosofía de colaboración: la IA acelera el boilerplate y la explicación; el humano es dueño de la arquitectura y las decisiones. Cada decisión técnica de este repo fue entendida por el autor antes de ser commiteada.
MIT © 2026 alebeta06