Wallet de Ethereum como extensión de Chrome (MV3), escrita desde cero para el Máster CodeCrypto. Inyecta un provider EIP-1193 en cualquier página, se anuncia por EIP-6963, firma transacciones y mensajes EIP-712, y gestiona redes y permisos por origen. Sin smart contracts en el alcance.
| Qué | Dónde | Cómo se usa |
|---|---|---|
| dApp de prueba | https://chrome-wallet.vercel.app | Se abre en el navegador. Es la página que consume la wallet desde fuera, como lo haría cualquier web |
| La extensión | No se despliega | Se carga en Chrome como unpacked desde extension/dist/. Ver instalación |
La dApp necesita la extensión cargada para hacer algo. Si la abrís sin ella, dice "No wallet announced itself" — y no es un error de la página: es lo que tiene que decir. La dApp descubre wallets por EIP-6963, o sea que espera a que una extensión instalada se anuncie. Sin ninguna instalada no se anuncia nadie.
Dos proyectos independientes, cada uno con su package.json y su lockfile:
| Carpeta | Qué es |
|---|---|
extension/ |
La wallet: service worker, popup y provider inyectado |
dapp/ |
Una dApp Next.js que consume el provider desde fuera |
No comparten código a propósito: la dApp conoce EIP-1193 y EIP-6963 —que son estándares públicos— y una constante con el rdns, nada más. Igual que si estuviera escrita contra MetaMask.
- Node ≥ 20.19 (probado en 24.x)
- pnpm 11
- Foundry para
anvil, el nodo local
cd extension
pnpm install
pnpm buildLuego, en Chrome:
- Abrir
chrome://extensions - Activar Developer mode (arriba a la derecha)
- Load unpacked → seleccionar
extension/dist/
Después de cada
pnpm buildhay que pulsar recargar (↻) en la tarjeta de la extensión. El service worker no se actualiza solo.
Navegadores. Verificada en Chrome y en Microsoft Edge. Brave no está soportado: gestiona los permisos y los scripts en iframes de otra manera, y trae su propia wallet compitiendo por los mismos eventos, así que da falsos negativos — un provider que sí está inyectado puede parecer ausente. No es teoría: pasó dos veces en este proyecto, y está medido en las comprobaciones 79 y 17.
Para tener saldos con los que jugar, levantar el nodo local en otra terminal:
anvil # http://localhost:8545 · chainId 31337 (0x7a69)La frase de desarrollo de Anvil —pública y solo para pruebas— es:
test test test test test test test test test test test junk
El popup la ofrece con un botón, así que no hace falta escribirla.
cd dapp
pnpm install
pnpm dev # http://localhost:3000Con la extensión cargada, la dApp la descubre por EIP-6963 y aparece en la lista junto a cualquier otra wallet instalada.
La regla de oro: el mnemonic y las claves privadas viven ÚNICAMENTE en el
service worker. El popup, connect.html y notification.html son solo UI:
mandan mensajes y muestran resultados. Nunca importan ethers, nunca derivan una
wallet, nunca firman.
flowchart LR
subgraph PAGE["🌐 Página web · mundo de la dApp"]
DAPP["dApp<br/><i>cualquier sitio</i>"]
INJECT["inject.ts<br/>window.codecrypto<br/><i>EIP-1193 · EIP-6963</i>"]
end
subgraph ISO["Mundo aislado de la extensión"]
CS["content-script.ts<br/><i>relay + cerrojo de origen</i>"]
end
subgraph SW["🔒 Service worker · background.ts"]
DISPATCH["dispatch.ts<br/><i>permisos y despacho</i>"]
KEYS[("cc:mnemonic<br/>claves privadas<br/><b>nunca salen de aquí</b>")]
ETHERS["ethers.js v6"]
end
subgraph WINDOWS["Ventanas propias · solo UI"]
CONNECT["connect.html<br/><i>¿qué cuenta ve este sitio?</i>"]
NOTIF["notification.html<br/><i>¿firmo esto?</i>"]
POPUP["popup<br/><i>cuentas, redes, registro</i>"]
end
NODE[("Nodo RPC<br/>Anvil · Sepolia")]
DAPP -->|"request()"| INJECT
INJECT <-->|"postMessage"| CS
CS <-->|"chrome.runtime"| DISPATCH
DISPATCH --> KEYS
DISPATCH --> ETHERS
ETHERS <-->|"JSON-RPC"| NODE
DISPATCH -.->|"abre"| CONNECT
DISPATCH -.->|"abre"| NOTIF
CONNECT -->|"decisión"| DISPATCH
NOTIF -->|"decisión"| DISPATCH
POPUP <-->|"chrome.runtime"| DISPATCH
style KEYS fill:#7c5cff,color:#fff
style SW fill:#1a1a2e,color:#fff
Lo que el diagrama hace evidente: hay tres saltos de proceso entre la página y
una clave privada, y ninguno de ellos transporta la clave. La página pide por
nombre (eth_sendTransaction), el content script traduce el sobre, y el service
worker decide, deriva y firma. Lo que vuelve es un hash o una firma.
Modelo de cuenta: por origen. Cada dApp conectada ve su propia cuenta;
accountsChanged se emite solo al origen afectado, chainChanged a todos.
¿Y el mixed content? La dApp está en HTTPS y Anvil en http://localhost:8545
El navegador lo bloquearía si la página hiciera esa petición. No la hace, y nunca toca el RPC.
La página le pide a la wallet por window.codecrypto; quien habla con el nodo es
el service worker, que corre bajo chrome-extension:// y no está sujeto a la
política de contenido mixto de la página. Por eso http://localhost:8545 está en
host_permissions del manifest y no en el código de la dApp.
Sobre la numeración. El README oficial del enunciado numera con solapes —hay dos ítems "20" y la Parte 5 vuelve a arrancar en 20—, y por eso la lista se cita habitualmente como "36 specs". Acá va con numeración corregida, 1 a 37, respetando el orden y el agrupamiento del original.
| # | Spec | Dónde | |
|---|---|---|---|
| 1 | Mnemonic BIP-39: generar e importar 12 palabras | ✅ | lib/hd-wallet.ts · wallet_createMnemonic / wallet_importMnemonic |
| 2 | Carga sin contraseña (solo desarrollo) | ✅ | No hay contraseña en ninguna parte: la frase se guarda y se relee |
| 3 | Provider window.codecrypto inyectado |
✅ | inject.ts, en el mundo de la página |
| 4 | Solo ethers.js v6 | ✅ | ethers ^6.17.0, y solo en background.ts — lo verifica pnpm check:bundles |
| 5 | React 19 + TypeScript | ✅ | React ^19.2.8, TS ~5.9.3 en modo strict |
| 6 | RPC por defecto localhost:8545, chainId 0x7a69 |
✅ | lib/networks.ts → DEFAULT_CHAIN_ID |
| # | Spec | Dónde | |
|---|---|---|---|
| 7 | eth_sendTransaction — firmar y enviar |
✅ | lib/signer.ts · lib/tx.ts |
| 8 | eth_signTypedData_v4 — EIP-712 |
✅ | lib/typed-data.ts |
| 9 | Inyección global (todas las páginas y frames) | ✅ | <all_urls> + all_frames + match_about_blank |
| 10 | Evento accountsChanged |
✅ | lib/events.ts, dirigido solo al origen afectado |
| # | Spec | Dónde | |
|---|---|---|---|
| 11 | Polling de saldos cada 5 s | ✅ | ui/hooks/useBalances.ts → BALANCE_POLL_INTERVAL_MS = 5_000 |
| 12 | Compatibilidad Chrome y Edge (MV3) | ✅ | Probado en los dos. Ver la nota de abajo |
| 13 | Logs de llamadas al provider | ✅ | lib/logs.ts, nivel call |
| 14 | Logs de eventos emitidos | ✅ | nivel event, escrito en el punto común de emisión |
| 15 | Logs de errores (rojo) | ✅ | nivel error — y el rojo es solo de ese nivel |
| 16 | Logs de operaciones en tiempo real | ✅ | nivel operation; el panel se actualiza sin cerrar el popup |
La 12, con su medición. Verificado en Microsoft Edge el 18 de agosto de 2026: extensión cargada con Carga desempaquetada desde
extension/dist, service worker activo, mnemonic de Anvil importado y las cinco direcciones derivadas correctas. Con la dApp enlocalhost:3000, EIP-6963 la anuncia ("1 wallet announced"), la conexión funciona, aparece el selector de cuenta por origen, y el panel de actividad registraeth_chainId,eth_accountsyaccountsChangedcon su origen. Cubre las cinco superficies: popup, background, content script, inject y el anuncio 6963.El único error en la pestaña de errores de Edge fue
[codecrypto] an rpc call failed on Anvil Local: TypeError: Failed to fetch— el sondeo de saldos con Anvil apagado, que la extensión maneja enseñando el banner "Cannot reach Anvil Local". No es específico de Edge.
| # | Spec | Dónde | |
|---|---|---|---|
| 17 | EIP-1559: maxFeePerGas + maxPriorityFeePerGas |
✅ | lib/tx.ts, con fallback a legacy si la red no lo soporta |
| 18 | EIP-6963: anuncio de provider | ✅ | lib/page-protocol.ts, con uuid estable entre recargas |
| 19 | Cambio de redes (wallet_switchEthereumChain) |
✅ | lib/network-rpc.ts |
| 20 | Gestión de redes (wallet_addEthereumChain) |
✅ | Con verificación del chainId contra el nodo propuesto |
| # | Spec | Dónde | |
|---|---|---|---|
| 21 | Modal de confirmación en página independiente | ✅ | notification.html, ventana propia y no un modal del popup |
| 22 | Reset wallet | ✅ | Dos pasos a propósito: un clic no puede borrar la frase |
| 23 | Hint interactivo: mnemonic de prueba clickeable | ✅ | ui/components/ImportWalletFlow.tsx |
| 24 | Historial de logs persistente entre resets | ✅ | cc:logs no está en RESET_CLEARED_KEYS, y el reset se anota a sí mismo |
| 25 | Transferencias internas entre cuentas | ✅ | lib/transfer.ts, por el mismo pipeline de firma |
| 26 | Validación de formularios | ✅ | lib/validators.ts, con el error junto al campo |
| # | Spec | Dónde | |
|---|---|---|---|
| 27 | chrome.storage.local: mnemonic, cuentas, configuración |
✅ | lib/storage.ts, tipado contra StorageSchema |
| 28 | Auto-carga si la wallet ya existe | ✅ | isLoaded en wallet_getState |
| 29 | Restaurar cuenta activa y red al reabrir | ✅ | cc:defaultAccountIndex y cc:chainId |
| # | Spec | Dónde | |
|---|---|---|---|
| 30 | notification.html independiente para firmas |
✅ | Sobrevive a que el popup pierda el foco |
| 31 | connect.html independiente para elegir cuenta |
✅ | Ventana propia |
| 32 | Badge contador de solicitudes pendientes | ✅ | lib/badge.ts, derivado de storage y no de un contador en memoria |
| 33 | Notificaciones de Chrome | ✅ | lib/notifications.ts, incluida la de transacción minada |
| 34 | Inyección robusta (todas las páginas y frames) | ✅ | run_at: document_start, nada lanza sin capturar, y el iframe de origen real medido en Chrome contra la dApp |
| 35 | Sincronización accountsChanged a todas las pestañas |
✅ | lib/events.ts — todas las pestañas del origen, no la primera |
| 36 | Sincronización chainChanged a todas las pestañas |
✅ | Global, a todos los orígenes conectados |
| 37 | Selección de cuenta por dApp (permisos por origen) | ✅ | lib/sites.ts · cc:connectedSites |
37 de 37, sin parciales. Cada fila se comprobó contra el código, y la 12 —la única que dependía de un navegador que nadie había abierto— se cerró midiéndola en Edge.
Tres capas, y cada una cubre lo que la de arriba no puede.
cd extension && pnpm test:run # 933 tests · lógica, en Node
cd dapp && pnpm test:run # 113 tests · la dApp
cd extension && pnpm test:e2e # 22 tests · Chrome de verdad, con la extensión cargada| Capa | Qué cubre | Por qué no llega más lejos |
|---|---|---|
| Unitarios (933) | src/lib y src/types: derivación de claves, despachador, storage, redes, firma, registro |
Corren en Node. No hay navegador, así que no ven el cableado ni la UI |
| E2E (22) | El cableado y los píxeles: content script, provider inyectado, ventanas de aprobación, badge, alarmas, popup renderizado | No alcanza los diálogos nativos de Chrome ni las notificaciones de escritorio |
| Manuales | Lo que ninguna API expone: permisos de host, avisos del sistema, chrome://extensions |
Las ejecuta una persona |
Los e2e no son una segunda copia de los unitarios: cubren 3.859 líneas que
vitest no ejecuta jamás — background.ts, content-script.ts, inject.ts y
todos los .tsx. Medido sabotéandolo: quitar el rechazo al cerrar una ventana de
aprobación, o el rearme de la alarma del worker, deja los 933 unitarios en
verde y solo se cae el e2e.
La primera vez, los e2e necesitan el navegador que Playwright usa:
cd extension && pnpm exec playwright install chromiumpnpm test:e2e se ocupa de su propio mundo: reconstruye la extensión, levanta un
Anvil limpio y lo apaga al terminar. Si el puerto 8545 ya está ocupado se para y
lo dice, en vez de reutilizar un nodo con estado de ayer.
Playwright crudo y no Synpress. La regla habitual —"frontend-heavy → Synpress"— asume que la app bajo prueba consume una wallet externa. Acá el proyecto es la wallet y los roles se invierten: lo que hay que conducir es la extensión, no una página que la usa.
Lo que sigue necesitando una persona delante está en
manual-checks.md, que abre con un mapa de
qué quedó automatizado y qué no.
Las claves nunca cruzan un límite de proceso. El popup y las ventanas de
aprobación no importan ethers ni derivan nada — y no es una convención: lo
verifica pnpm check:bundles contra lo que el build emitió, siguiendo el
grafo de imports desde cada .html.
El registro funciona por allowlist, no por denylist. Nada que venga de la
página se reenvía al log: solo escalares planos que construye la wallet. Una
denylist tendría que anticipar nombres de campo que elige la dApp, y no puede.
Ver lib/logs.ts.
Sepolia va contra un endpoint público sin API key (publicnode). Una
extensión es un zip: cualquiera la descomprime y lee la clave del bundle, así que
no hay equivalente a la fricción de un NEXT_PUBLIC_* en un servidor. El de
serie era dRPC hasta que metió la cadena detrás de su plan de pago —0 de 9
métodos— y se midió el reemplazo antes de elegirlo. Ver
lib/networks.ts.
Tres límites conocidos, anotados junto al código que los produce, no en una
lista aparte: el badge se queda obsoleto cuando una solicitud caduca
(lib/badge.ts); cerrar la ventana de aprobación
con el service worker muerto no la rechaza y deja la solicitud hasta que caduque
(background.ts); y el timeout responde 4001,
que significa "el usuario rechazó" cuando nadie rechazó
(lib/approvals.ts). Los dos primeros tienen
un test que se pondrá rojo el día que se arreglen.
| Documento | Qué hay dentro |
|---|---|
CLAUDE.md |
Arquitectura, reglas del proyecto, stack fijado y las lecciones de cada fase |
extension/docs/manual-checks.md |
Las comprobaciones que necesitan una persona, con el mapa de qué está automatizado |
extension/docs/e2e-backlog.md |
Qué cubre el e2e, qué no es automatizable y por qué, y lo que se dejó fuera |
docs/DEPLOY.md |
Despliegue de la dApp a Vercel |