Ethereum: de historia a labs

Artículo 23 de 48

48%

completado al llegar aquí

Sistemas distribuidosNota de investigación17 min de lectura

Approvals, permit y phishing de firmas

Qué delega approve, cómo convierte EIP-2612 una firma fuera de cadena en allowance, por qué revocar no anula un permit pendiente y qué revisar en custodia.

Ruta Ethereum: 0/48

Una pregunta concreta para un equipo de custodia: un usuario firmó ayer un mensaje en una página web, sin pagar gas y sin enviar ninguna transacción. Hoy revocó todas sus aprobaciones con una herramienta de revocación. ¿Sus tokens están a salvo? La respuesta puede ser no, y entender por qué exige separar tres cosas que la interfaz de una wallet suele mezclar: una transacción approve, una firma permit y una aprobación a un contrato intermediario como Permit2.

Este capítulo trata cada una como una capacidad: un permiso, guardado en algún lugar, que deja a otra dirección mover tus tokens más tarde. Para cada capacidad preguntamos dónde vive, quién puede usarla, cuándo vence y cómo se anula. Los comportamientos de approve y permit descritos aquí se verificaron con tests de Foundry 1.5.1 sobre OpenZeppelin Contracts 5.4; los de Permit2 se toman de su código fuente.

Brújula de lectura

Actor, evidencia y estado

Antes de avanzar, ubica quién actúa, qué prueba queda y qué parte del sistema cambia.

Actor

Spender

La dirección autorizada a llamar transferFrom sobre los tokens de otro.

Evidencia

Firma EIP-712 y evento Approval

La firma puede existir fuera de cadena sin dejar rastro hasta que alguien la envía.

Estado

allowance y nonces del token

allowance[owner][spender] y el contador de firmas de cada owner.

Preguntas guía

  • ¿Qué cambia en cadena cuando un usuario aprueba a un spender, y qué no cambia?
  • ¿Por qué una aprobación "infinita" no baja cuando el spender gasta?
  • Si una firma permit no es una transacción, ¿quién puede enviarla y cuándo?
  • ¿Qué anula de verdad una firma permit que todavía no se usó?
  • ¿Qué campos de un mensaje firmado debe mostrar y validar una wallet antes de pedir la firma?

Modelo de capacidad: qué entrega approve y qué no

En ERC-20, approve(spender, value) permite que spender retire de tu cuenta varias veces, hasta value en total, usando transferFrom. Llamarla de nuevo sobrescribe el valor anterior; no lo suma. allowance(owner, spender) devuelve cuánto le queda por retirar, y cada approve exitoso debe emitir el evento Approval.

Aprobar no mueve tokens. Entrega una capacidad que queda guardada en el storage del token y que el spender puede ejercer cuando quiera, sin pedir nada más al dueño. Si el spender es un contrato, la capacidad la ejerce su código; si ese contrato es actualizable o tiene un admin, también la ejerce, en la práctica, quien controle ese admin.

El estándar incluye una advertencia antigua: cambiar un allowance de N a M con una sola llamada permite una carrera, en la que el spender gasta N justo antes del cambio y luego M. Por eso recomienda que las interfaces pongan el allowance en 0 antes de fijar otro valor para el mismo spender.

Dos detalles de la implementación de OpenZeppelin importan para custodia. Primero, si el allowance es el máximo uint256, transferFrom no lo descuenta: es, semánticamente, una aprobación infinita. Segundo, transferFrom actualiza el allowance sin emitir Approval. Un monitor que reconstruye allowances solo con eventos queda desactualizado; la fuente de verdad es llamar allowance(owner, spender).

Los NFTs tienen su propia versión, más amplia. En ERC-721, setApprovalForAll(operator, true) autoriza a un operador a gestionar todos los NFTs del dueño en ese contrato. Como la autorización no está atada a un tokenId, alcanza también a los que el dueño reciba después.

Traza de una firma permit: de mensaje EIP-712 a allowance en cadena

El problema que resuelve permit es de experiencia de usuario. Con ERC-20 puro, usar un token en una aplicación exige dos transacciones, approve y luego la acción, y la primera exige tener ETH para gas. ERC-2612 agrega permit, que modifica el allowance a partir de un mensaje firmado en lugar de msg.sender.

La interfaz agrega tres funciones al token: permit(owner, spender, value, deadline, v, r, s), nonces(owner) y DOMAIN_SEPARATOR(). Una llamada a permit fija allowance[owner][spender] = value, incrementa nonces[owner] en 1 y emite Approval, si y solo si se cumplen cuatro condiciones: el tiempo del bloque es menor o igual a deadline, owner no es la dirección cero, el nonce firmado es el nonce actual del owner y la firma es válida. Si alguna falla, la llamada revierte.

El mensaje firmado sigue EIP-712, un estándar para firmar datos estructurados con tipos en lugar de bytes opacos. Incluye un separador de dominio, que en la forma habitual contiene el nombre del token, una versión, el chainId y la dirección del contrato verificador, para que una firma no sirva en otro contrato ni en otra cadena. EIP-712 aclara que no incluye protección contra replay: eso lo pone la aplicación, aquí con el nonce. Esto es lo que una wallet recibe para firmar, en forma resumida:

JSON
{
  "primaryType": "Permit",
  "domain": {
    "name": "Lab Token",
    "version": "1",
    "chainId": 1,
    "verifyingContract": "0x…token"
  },
  "message": {
    "owner": "0x…usuario",
    "spender": "0x…quien podrá gastar",
    "value": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
    "nonce": 0,
    "deadline": "115792089237316195423570985008687907853269984665640564039457584007913129639935"
  }
}

Ese ejemplo es ilustrativo y muestra el caso peligroso: value y deadline iguales al máximo uint256, es decir, aprobación infinita que no vence. ERC-2612 permite ese deadline explícitamente. Lo decisivo es que la definición no menciona msg.sender: cualquier dirección puede enviar la firma a la cadena. Quien tiene la firma tiene la capacidad, aunque no exista todavía ningún rastro en cadena.

Invariante que se olvida: revocar no anula una firma pendiente

Volvamos a la pregunta inicial. Una herramienta de revocación llama approve(spender, 0). Eso pone el allowance en cero, pero no toca nonces[owner]. Las cuatro condiciones de permit no miran el allowance actual: miran tiempo, nonce y firma. Por lo tanto, una firma emitida antes de la revocación sigue siendo válida después, mientras no venza y su nonce no se use. Es una consecuencia directa de la especificación, y la comprobamos con un test.

Figura 1

La brecha entre revocar un allowance y anular una firma

Fuentes:

[3]
La brecha entre revocar un allowance y anular una firma
Cómo leer esta figura

Línea de tiempo horizontal con cuatro eventos, t0 a t3, dentro de una franja sombreada que representa la ventana de validez de la firma, delimitada a la derecha por una línea vertical marcada deadline. La franja indica que la firma es válida mientras block.timestamp sea menor o igual a deadline y el nonce firmado todavía no se haya usado. En t0, el dueño firma Permit con value 100 fuera de cadena. En t1, el dueño revoca con approve(s, 0) y el allowance queda en 0. En t2, alguien envía permit y el allowance vuelve a 100. En t3, el spender llama transferFrom y el saldo queda en 0. Nota inferior: approve(s, 0) cambia el allowance, no el nonce, así que la firma de t0 sigue siendo válida en t2. Para anularla antes del deadline hay que consumir ese nonce con otro permit enviado por el dueño.

Visual original de TrautsLab. El dueño firma un permit en t0 y revoca con approve(s, 0) en t1. En t2 cualquiera envía la firma original y el allowance vuelve a 100; en t3 el spender retira. La firma solo deja de servir al vencer el deadline o al consumirse su nonce.
solidity
// Fragmento de test (Foundry + OpenZeppelin ERC20Permit 5.4). Pasa.
// _signPermit arma el digest EIP-712 del struct Permit y firma con vm.sign.
function test_RevokeDoesNotKillPendingPermit() public {
    uint256 deadline = block.timestamp + 1 days;
    (uint8 v, bytes32 r, bytes32 s) = _signPermit(100 ether, token.nonces(owner), deadline);
 
    vm.prank(owner);
    token.approve(spender, 0); // el usuario "revoca"
 
    token.permit(owner, spender, 100 ether, deadline, v, r, s); // cualquiera la envía
    vm.prank(spender);
    token.transferFrom(owner, spender, 100 ether);
    assertEq(token.balanceOf(owner), 0);
}

¿Qué anula de verdad la firma? Hay dos caminos. Esperar a que pase deadline, que no sirve si el deadline es lejano. O consumir el nonce: como los nonces de ERC-2612 son secuenciales, cualquier permit ejecutado con ese nonce invalida toda otra firma sobre el mismo nonce. ERC-2612 lo menciona: si el firmante tiene ETH, puede enviar él mismo un permit y dejar inválidos los firmados antes. En nuestro test, el dueño firmó y envió un permit de valor 0 con el mismo nonce, y la firma vieja pasó a revertir. Es una carrera: si el atacante envía primero, gana.

El estándar agrega dos riesgos más. Un relayer que recibe la firma tiene una "opción gratuita" de enviarla o retenerla, y el deadline es la mitigación. Y la carrera clásica de approve también aplica a permit. Para quien integra permit, OpenZeppelin recomienda envolver la llamada en try/catch y usar msg.sender como owner: alguien puede enviar la firma primero, consumir el nonce y hacer revertir la transacción original.

No todos los permit son iguales. ERC-2612 documenta que el permit de DAI es anterior y distinto: recibe un bool allowed que fija el allowance en 0 o en el máximo, y su campo se llama expiry, lo que cambia el contenido del mensaje firmado. Una wallet que decodifica solo la variante estándar puede mostrar mal la otra.

Permit2: una aprobación al intermediario, permisos con vencimiento

Muchos tokens no implementan permit. Permit2, de Uniswap, generaliza la idea: el usuario aprueba una vez al contrato Permit2 desde cada token, y a partir de ahí las aplicaciones piden firmas a Permit2. Tiene dos módulos. AllowanceTransfer guarda permisos por token y spender con monto, expiración y nonce. SignatureTransfer usa firmas de un solo uso, válidas solo durante la transacción que las consume, sin allowance persistente.

Para custodia, Permit2 mueve el riesgo de lugar. La aprobación en cadena al contrato Permit2 puede ser grande y duradera, y las capacidades reales se otorgan después por firma. Revisar solo los allowances del token muestra "Permit2 aprobado" y nada más; los permisos por spender viven dentro de Permit2. El contrato ofrece herramientas para anularlos: lockdown revoca en lote pares token y spender, invalidateNonces invalida firmas de AllowanceTransfer e invalidateUnorderedNonces hace lo propio en SignatureTransfer.

Comparación

Tres formas de delegar el gasto de tokens

approve (ERC-20)

Transacción en cadena; el allowance vive en el token y es visible con allowance().

Ideal para
Montos exactos para una operación inmediata.
Ojo con
Sin vencimiento; con uint256 máximo no se descuenta. Se anula con approve(spender, 0).
Precaución

permit (ERC-2612)

Firma fuera de cadena; se vuelve allowance cuando cualquiera la envía.

Ideal para
Flujos de una sola transacción con deadline corto y monto exacto.
Ojo con
Invisible hasta que se usa; approve(spender, 0) no la anula, solo el deadline o consumir el nonce.
Recomendado

Permit2

Aprobación en cadena a Permit2; permisos por spender firmados, con expiración o de un solo uso.

Ideal para
Tokens sin permit y permisos con vencimiento explícito.
Ojo con
La aprobación base a Permit2 concentra el riesgo; revocar exige lockdown o invalidar nonces en Permit2.
Cada fila responde dónde vive la capacidad y cómo se anula.

Riesgo operacional en custodia: qué mostrar, qué bloquear, qué monitorear

El phishing de firmas explota una asimetría: firmar parece inocuo porque no cuesta gas ni aparece en el historial, pero puede entregar la misma capacidad que una transacción. EIP-712 existe para que la wallet muestre datos estructurados en vez de un hash opaco; eso solo protege si la wallet y el operador leen los campos correctos.

Hay un matiz útil para quien diseña la arquitectura de claves. OpenZeppelin señala que las wallets de contrato, como Safe, no pueden producir firmas permit de ERC-2612, porque el estándar verifica una firma secp256k1 de una clave. Permit2, en cambio, soporta verificación EIP-1271 para contratos. La superficie de firma depende de qué tipo de cuenta custodia los fondos.

Política de firma

Controles para un equipo de custodia

0 de 6 revisados

Cada punto se puede implementar como regla automática antes de que una clave firme.

Una última regla, más cultural que técnica: en revisión de incidentes, "el usuario no hizo ninguna transacción" no descarta una delegación. Pregunta siempre qué firmó, con qué dominio y qué nonce, y si ese nonce ya se consumió.

Conexiones dentro de la serie

Referencias

Bibliografía académica

Formato IEEENumerado para ingeniería, sistemas y protocolos.
  1. [1]

    F. Vogelsteller and V. Buterin, "ERC-20: Token Standard," Ethereum Improvement Proposals, no. 20, Nov. 2015.

    approve sobrescribe el allowance; transferFrom para flujos de retiro; nota sobre poner el allowance en 0 antes de cambiarlo; evento Approval.

  2. [2]

    OpenZeppelin, "ERC-20," OpenZeppelin Contracts 5.x API Reference.

    Documentation siteID: OZ-ERC20-API-5XAbrir fuente (abre en otra pestaña)

    approve con uint256 máximo como aprobación infinita; _spendAllowance sin evento Approval; consideraciones de seguridad de ERC20Permit sobre frontrunning y wallets de contrato.

  3. [3]

    M. Lundfall, "ERC-2612: Permit Extension for EIP-20 Signed Approvals," Ethereum Improvement Proposals, no. 2612, Apr. 2020.

    Firma de permit, nonces y DOMAIN_SEPARATOR; condiciones de validez; deadline; envío por cualquier dirección; consideraciones de frontrunning, censura y SWC-114.

  4. [4]

    R. Bloemen, L. Logvinov, and J. Evans, "EIP-712: Typed structured data hashing and signing," Ethereum Improvement Proposals, no. 712, Sep. 2017.

    Firma de datos estructurados, separador de dominio, eth_signTypedData; no incluye protección contra replay; consideraciones de frontrunning.

  5. [5]

    W. Entriken, D. Shirley, J. Evans, and N. Sachs, "ERC-721: Non-Fungible Token Standard," Ethereum Improvement Proposals, no. 721, Jan. 2018.

    setApprovalForAll habilita a un operador sobre todos los activos del owner en ese contrato.

  6. [6]

    Uniswap Labs, "Permit2," GitHub repository Uniswap/permit2, README and interfaces IAllowanceTransfer and ISignatureTransfer.

    Source codeID: UNISWAP-PERMIT2Abrir fuente (abre en otra pestaña)

    AllowanceTransfer con amount, expiration y nonce; SignatureTransfer de un solo uso; lockdown, invalidateNonces e invalidateUnorderedNonces; soporte EIP-1271.

Fuentes primarias, papers seminales y especificaciones técnicas citadas en esta investigación.

Rutas vecinas

Si este tema te abrió hambre, sigue por aquí.

Series y labs que comparten área o dependen de esta ruta. Pocas opciones, para no convertir la curiosidad en menú infinito.

Sigue leyendo

Ideas conectadas con este artículo

Una ruta corta para continuar sin abrir veinte pestañas y perder el hilo.

Los comentarios se cargan al final de la lectura para no meter peso antes de tiempo.