Skip to content

About

Ethereum wallet as a Chrome MV3 extension, built from scratch: BIP-39/44, EIP-1193, EIP-6963, EIP-712. Keys never leave the service worker.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

121 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CodeCrypto Wallet

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.

Video


Dónde está desplegado

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.

Estructura

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.

Requisitos

  • Node ≥ 20.19 (probado en 24.x)
  • pnpm 11
  • Foundry para anvil, el nodo local

Instalar la extensión

cd extension
pnpm install
pnpm build

Luego, en Chrome:

  1. Abrir chrome://extensions
  2. Activar Developer mode (arriba a la derecha)
  3. Load unpacked → seleccionar extension/dist/

Después de cada pnpm build hay 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.

Levantar la dApp

cd dapp
pnpm install
pnpm dev        # http://localhost:3000

Con la extensión cargada, la dApp la descubre por EIP-6963 y aparece en la lista junto a cualquier otra wallet instalada.

Arquitectura

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
Loading

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.

Las 37 specs

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.

Parte 1 · Core Wallet

# 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

Parte 2 · Operaciones blockchain

# 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

Parte 3 · UX y logging

# 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 en localhost:3000, EIP-6963 la anuncia ("1 wallet announced"), la conexión funciona, aparece el selector de cuenta por origen, y el panel de actividad registra eth_chainId, eth_accounts y accountsChanged con 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.

Parte 4 · Estándares EIP

# 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

Parte 5 · UI avanzada

# 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

Parte 6 · Persistencia

# 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

Parte 7 · Chrome Extension avanzado

# 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.

Los tests

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 chromium

pnpm 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.

Decisiones y límites conocidos

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.

Documentación

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

About

Ethereum wallet as a Chrome MV3 extension, built from scratch: BIP-39/44, EIP-1193, EIP-6963, EIP-712. Keys never leave the service worker.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages