Una transacción normal entra en el contrato A, y A usa DELEGATECALL para ceder la ejecución al contrato de implementación B. El bytecode que se ejecuta proviene de B, pero address(this) devuelve la dirección de A, msg.sender es la cuenta que inició la transacción y las escrituras de B en storage recaen en los slots de A. Si se cambia DELEGATECALL por CALL, todos estos valores cambian; si se cambia por STATICCALL, cualquier operación de escritura lanza directamente una excepción.
A nivel de bytecode, las cuatro instrucciones de llamada se diferencian en un solo número, pero sus diferencias semánticas atraviesan las actualizaciones de proxy, las llamadas a bibliotecas, las consultas de solo lectura y la protección contra reentrada. Este artículo compara una por una su comportamiento en cuanto a contexto de ejecución, propiedad del almacenamiento, campos del mensaje y gas, y explica por qué DELEGATECALL se convirtió en la base de los contratos proxy, por qué STATICCALL necesitó un EIP dedicado para introducirse y por qué CALLCODE pasó de ser miembro de Frontier a un legado obsoleto.
Número y parámetros de pila de las cuatro instrucciones
Empecemos por alinear la apariencia. CALL es 0xf1, CALLCODE es 0xf2, DELEGATECALL es 0xf4 y STATICCALL es 0xfa. Las cuatro empujan 0 a la pila cuando fallan y 1 cuando tienen éxito, describen los intervalos de entrada y salida con un desplazamiento y una longitud de memoria, están sujetas al límite de 1024 niveles de profundidad de llamada y fallan cuando no hay gas suficiente en lugar de truncar en silencio.
El número de parámetros de pila las divide en dos grupos. CALL y CALLCODE toman 7 operandos cada una: gas, la dirección destino, value, el desplazamiento de entrada, la longitud de entrada, el desplazamiento de salida y la longitud de salida. DELEGATECALL y STATICCALL toman 6, sin el elemento value: DELEGATECALL hereda el msg.value del ámbito padre y STATICCALL lo fija en 0. La diferencia en la tabla de parámetros es en sí misma la puerta de entrada para entender la semántica. De las dos instrucciones que permiten fijar value, una transfiere de verdad (CALL) y la otra no (CALLCODE); de las dos que no permiten fijar value, una lo hereda (DELEGATECALL) y la otra lo pone a cero (STATICCALL).
También vale la pena contrastar el tratamiento de los datos de retorno. Las cuatro escriben los datos devueltos por la subllamada en el intervalo de memoria que indique quien llama, pero la longitud escrita está limitada por el out_size que este proporcione. Tras la introducción de RETURNDATASIZE y RETURNDATACOPY en la actualización Byzantium (EIP-211), quien llama ya no tiene que adivinar de antemano el tamaño de los datos de retorno: puede leer primero la longitud y copiar según necesite, de modo que los proxies y los contratos de reenvío genéricos no tienen que reservar una zona de salida lo bastante grande.
Comparación una por una: de dónde viene el código, dónde se escribe el estado y de quién son los campos del mensaje
CALL crea un nuevo contexto de ejecución. El código proviene de la dirección destino, el storage también pertenece a la dirección destino, address(this) es el contrato destino y msg.sender es el contrato actual que inicia la llamada. El ether indicado en value se transfiere de verdad desde el contrato actual a la dirección destino, así que el saldo del contrato actual debe ser suficiente; de lo contrario, la llamada falla.
CALLCODE también toma el código de la dirección destino para ejecutarlo, pero la ejecución recae en el contexto de la cuenta actual: el storage es el del contrato actual y address(this) es la dirección del contrato actual. Tiene un punto más contraintuitivo que CALL: value puede ser cualquier valor que indique quien llama, de modo que msg.value puede reescribirse con un número distinto al del ámbito padre, mientras que la supuesta transferencia de valor es la cuenta actual enviándose a sí misma y no produce ningún cambio real de saldo. La comprobación de valor sigue siendo necesaria: la definición de 0xf2 en el libro amarillo exige que, como condición previa de la llamada, value no supere el saldo de la cuenta actual; de lo contrario no se entra en el código llamado y la pila recibe 0. El msg.sender de CALLCODE es el contrato actual que la ejecuta, igual que en CALL, y este es su punto de divergencia más importante con DELEGATECALL.
DELEGATECALL también ejecuta el código destino en el contexto de la cuenta actual, con el storage y address(this) apuntando al contrato actual, pero lleva al ámbito hijo el msg.sender y el msg.value del ámbito padre tal cual. La formulación de EIP-7 es: el emisor y el valor se propagan del ámbito padre al hijo, y el comportamiento de CALLER y VALUE en el código hijo es idéntico al del entorno padre. Esto aporta una ventaja directa: el contrato de implementación puede referenciar libremente msg.sender y msg.value, sin necesidad de recompilarse específicamente para ser compatible con llamadas de proxy. La sección de motivación de EIP-7 enumera además dos usos: dividir el código de implementación en varias partes para ejecutarlas por segmentos y así sortear el límite de aproximadamente 3 millones de gas por llamada de entonces, y guardar el origen del código en una dirección mutable para reenviar la llamada a esa dirección.
STATICCALL crea un contexto de ejecución similar al de CALL: el código y el storage pertenecen a la dirección destino, address(this) es el contrato destino, msg.sender es quien llama y msg.value toma el valor 0. La diferencia es que marca el ámbito hijo como estático y prohíbe cualquier modificación de estado durante la ejecución. Entre las prohibiciones que enumera EIP-214 están CREATE, CREATE2, LOG0 a LOG4, SSTORE, SELFDESTRUCT y las CALL con value distinto de cero; al encontrarse con estas operaciones se lanza una excepción directamente en lugar de ejecutar la modificación. La especificación conserva una excepción: CALLCODE no se considera modificación de estado aunque lleve un value distinto de cero, porque según su semántica ese valor no sale de la cuenta actual.
Tabla comparativa
En la tabla siguiente, los campos del mensaje son los valores que observa el código llamado desde dentro, y address(this) es el resultado que ADDRESS deja en la pila cuando se ejecuta el código destino.
| Instrucción | Número | Parámetros de pila | Origen del código | Propiedad del storage | address(this) | msg.sender | msg.value | Estado escribible |
|---|---|---|---|---|---|---|---|---|
| CALL | 0xf1 | 7 (con value) | Dirección destino | Dirección destino | Dirección destino | Contrato actual | Valor indicado, transfiere de verdad | Permitido |
| CALLCODE | 0xf2 | 7 (con value) | Dirección destino | Contrato actual | Contrato actual | Contrato actual | Valor indicado, debe pasar la comprobación de saldo pero no transfiere de verdad | Permitido |
| DELEGATECALL | 0xf4 | 6 | Dirección destino | Contrato actual | Contrato actual | Hereda del ámbito padre | Hereda del ámbito padre | Permitido |
| STATICCALL | 0xfa | 6 | Dirección destino | Dirección destino | Dirección destino | Contrato actual | Fijo en 0 | Prohibido |
Gas: precio base, coste de acceso y la regla de reserva 63/64
El gas de las cuatro instrucciones se compone de varias partes que se suman: el coste base, el coste de acceso a la dirección destino, el coste de expansión de memoria y la cantidad que realmente se transfiere a la subllamada. La parte entregada a la subllamada se devuelve si no se agota, así que se parece más a un cupo que a un gasto.
El coste de acceso a la dirección destino pasó a un precio frío/caliente tras la actualización Berlin (EIP-2929): el primer acceso dentro de una misma transacción es un acceso frío y cuesta 2600 gas; un acceso posterior es un acceso caliente y cuesta 100 gas. Antes, EIP-150 había elevado el coste base de CALL, CALLCODE y DELEGATECALL a 700 gas de forma uniforme; tras Berlin, la familia de llamadas pasó a un precio frío/caliente y 2600 y 100 sustituyeron al 700 fijo, con el cálculo aplicado antes de computar el gas disponible. El conjunto de direcciones se comparte dentro de la misma transacción, y si una capa de ejecución se revierte, los registros de acceso que esa capa añadió se revierten con ella. Quien quiera ahorrar este gasto puede adjuntar a la transacción una lista de acceso (access list) de EIP-2930 y declarar de antemano las direcciones y los slots que va a tocar, a cambio de pagar una tarifa fija por elemento.
El valor y la creación de cuentas añaden recargos. CALL con value distinto de cero cobra 9000 gas adicionales; si el receptor es una cuenta muerta (inexistente o vacía) y por tanto hay que crearla en el árbol de estado, se añaden 25000 más. CALLCODE con value distinto de cero también cobra 9000 adicionales, solo que su valor se transfiere a la cuenta actual y no implica crear ninguna cuenta. DELEGATECALL y STATICCALL no tienen parámetro value, así que no tienen ninguno de estos dos recargos.
El stipend de 2300 gas es otro punto fácil de recordar mal. Las CALL y CALLCODE con value distinto de cero hacen que la subllamada reciba 2300 gas adicionales; la tabla de tarifas del libro amarillo registra esta cantidad como «restada de G_callvalue», es decir, está incluida dentro del recargo de 9000 por transferencia de valor y no es una subvención aparte. Es también el origen del límite de gas de transfer() y send(): estos dos métodos reenvían solo 2300 gas, así que en cuanto el receptor necesita escribir storage o emitir un log fallan. La documentación de Solidity ya marca send() y transfer() como no recomendados y con intención de eliminarlos, y sugiere usar CALL comprobando el valor de retorno por cuenta propia.
EIP-150 introdujo además la regla de «reservar 1/64»: cuando el gas que se pide reenviar supera los 63/64 del gas restante de quien llama, solo se reenvían 63/64 y el ámbito padre se guarda siempre una porción de gas para gestionar el retorno fallido. Esta regla sustituyó la superficie de ataque basada en la profundidad de llamada por un límite basado en el gas, y explica por qué, aunque la subllamada agote su gas, la llamada padre todavía puede recibir el indicador de fallo y seguir ejecutando, en lugar de agotar la transacción entera.
DELEGATECALL es la base de los contratos proxy, a cambio de restricciones en el storage layout
Con DELEGATECALL, el patrón proxy se sostiene: el contrato proxy guarda el estado y los activos, reenvía las llamadas al contrato de implementación, y la implementación lee y escribe el storage del proxy en su contexto; al actualizar solo se cambia la dirección de implementación y el estado se conserva en su sitio. Esta es también la forma estándar de los contratos actualizables en la EVM. Una función de reenvío mínima se escribe más o menos así, con la dirección de implementación pasada como parámetro y sin ocupar storage a propósito, para evitar conflictos con el diseño del contrato de implementación.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Forwarder {
function _delegate(address impl) external payable {
assembly {
calldatacopy(0, 0, calldatasize())
// ejecuta el código de impl en el contexto de quien llama (el proxy); storage y msg.sender se quedan del lado del proxy
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
El precio es que el storage layout (diseño de almacenamiento) debe estar alineado. DELEGATECALL no renombra ni migra slots: el código lee y escribe según el número de slot o el desplazamiento asignado por el compilador. Si el proxy declara sus propias variables y el contrato de implementación declara variables justo en el mismo slot, ambos se sobrescriben mutuamente. Por eso EIP-1967 escribe la dirección de implementación en el slot calculado como bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1), es decir, 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, una posición que el compilador no asignará a variables de negocio salvo con probabilidad ínfima. Por la misma razón, un contrato de implementación solo puede añadir variables de estado al final; eliminar o reordenar variables desalinea por completo las lecturas y escrituras tras la actualización.
El constructor también tiene restricciones propias en el modo proxy. Al desplegar el contrato de implementación, el constructor se ejecuta en su propio contexto y escribe en el storage del propio contrato de implementación, no en el estado del proxy. Por eso la inicialización del proxy debe hacerse mediante una función de inicialización explícita, con una marca que garantice que solo se ejecuta una vez; de lo contrario, cualquiera podría reinicializarlo y tomar el control.
Hay además una clase de riesgo que proviene del propio código llamado. DELEGATECALL da al código destino todos los permisos del proxy: puede escribir cualquier slot, transferir el saldo, llamar a otros contratos e incluso afectar la ejecutabilidad del proxy mediante selfdestruct. Si la dirección de implementación se puede modificar libremente o se introduce una biblioteca no auditada, equivale a entregar por completo el control del proxy. Las funciones públicas de las bibliotecas (library) de Solidity se invocan precisamente con DELEGATECALL, por lo que el código de una biblioteca no puede declarar sus propias variables de estado, ya que entraría en conflicto con el diseño de quien la llama; las funciones de biblioteca que no son view ni pure están diseñadas para invocarse solo mediante DELEGATECALL y su código de tiempo de ejecución incluye una protección de llamada, de modo que una CALL directa a la dirección de la biblioteca hace revert en tiempo de ejecución (esta protección no se aplica al llamar a funciones view o pure).
STATICCALL convierte la modificación de estado en excepción
STATICCALL fue introducida por EIP-214 y activada con la actualización Byzantium. EIP-214 solo da el opcode y su semántica, sin indicar el nombre del fork; la atribución al fork proviene de EIP-609, que incluye EIP-214 en la lista de contenidos de Byzantium. La motivación es que, tras una CALL normal, quien llama no puede suponer que el estado del contrato llamado no ha cambiado, y por eso los problemas de reentrada son difíciles de razonar de forma local. El objetivo que plantea EIP-214 es que el estado de todas las cuentas sea idéntico antes y después de una llamada estática, de modo que esta pueda verse como una función pura que solo devuelve una salida y no produce efectos secundarios.
La implementación consiste en añadir un indicador STATIC a la EVM. Por defecto es false y al entrar en una subllamada normalmente se copia tal cual; solo STATICCALL lo pone a true y lo restaura al volver. Las prohibiciones ya se enumeraron en la sección anterior; la clave es que este indicador se propaga a lo largo de la cadena de llamadas: una CALL lanzada dentro de una llamada estática deja también al hijo en modo estático, y no hay forma de eludir la restricción dando un rodeo adicional.
En ingeniería hay dos clases de beneficios. Las consultas de solo lectura se pueden componer con seguridad: las vistas de precios y las llamadas agregadas a varios contratos no tienen que temer que el llamado reescriba el estado. La protección contra reentrada se puede cerrar antes: al poner las llamadas externas de solo lectura en modo estático, una escritura de reentrada falla directamente en la capa de la EVM. Desde Solidity 0.5.0, las llamadas a funciones view y pure que no son de biblioteca se compilan por defecto como STATICCALL, lo que añade una red de seguridad en tiempo de ejecución a las restricciones en tiempo de compilación. Las funciones view de las bibliotecas son la excepción: siguen usando DELEGATECALL, porque la EVM no tiene ninguna instrucción que combine semántica estática y semántica de delegación, de modo que las funciones de solo lectura de una biblioteca no tienen protección de estado en tiempo de ejecución; este punto debe confirmarse por separado en una auditoría.
También conviene dejar claros los límites. STATICCALL solo prohíbe modificar el estado, no prohíbe leer ni prohíbe las devoluciones de llamada; corta la vía de escritura de la reentrada, pero eso no significa que el problema de reentrada desaparezca. Tampoco sustituye a las comprobaciones en tiempo de compilación de view / pure, porque ambos cubren modos de fallo distintos. Una llamada estática también consume gas, y el contrato destino puede revertir a propósito para convertir la comisión de quien llama en coste de ataque; el modo estático solo garantiza que el estado no cambia, no garantiza que la llamada tenga éxito ni que el resultado sea fiable.
La posición histórica de CALLCODE y su retirada
CALLCODE es una instrucción que ya existía en la versión Frontier, y la sección de motivación de EIP-7 la toma directamente como contraste de DELEGATECALL. Su objetivo de diseño es cercano al de DELEGATECALL, pues ambos «toman prestado el código de otro y lo ejecutan en la cuenta actual», pero el tratamiento de los campos del mensaje es distinto: msg.sender se cambia por el contrato actual y msg.value se puede fijar a voluntad. Cuando hace falta propagar el emisor y el valor originales, no sirve, y EIP-7 cubrió esa carencia propagando el emisor y el valor.
La confusión de la semántica de valor aceleró su retirada. Una CALLCODE con value distinto de cero exige que la cuenta actual tenga saldo suficiente, pero transfiere el valor a su propia dirección; la subllamada lee un msg.value reescrito y en las cuentas no se produce ningún movimiento, un comportamiento difícil de razonar con la intuición. En cuanto al uso real, el autor de EIP-2488 sostiene que CALLCODE nunca llegó a usarse de verdad, y la popularización masiva del patrón proxy llegó más tarde (DELEGATECALL se lanzó con Homestead y el bloque de activación en la mainnet que da EIP-7 es 1,150,000); estos dos puntos son un juicio basado en la cronología pública y en las afirmaciones del autor, y faltan estadísticas cuantificables de llamadas.
La retirada tuvo dos pasos. Solidity dejó de permitir callcode desde la 0.5.0, y solo el ensamblador en línea puede seguir emitiendo la instrucción 0xf2. En la capa de protocolo el opcode nunca se eliminó de verdad; EIP-2488 propone que CALLCODE devuelva siempre fallo a partir de cierta altura de bloque, con el argumento de que borrar el opcode directamente haría abortar con excepción a los contratos que se lo encontraran, mientras que devolver fallo les da la oportunidad de detectarlo y recuperarse. La propuesta sigue hoy en estado estancado (Stagnant). La conclusión es que CALLCODE ya se ha retirado en la capa del lenguaje, pero en la capa del bytecode sigue siendo una semántica heredada que las implementaciones deben tratar correctamente; no conviene confundir ambas cosas.
El contexto de llamada decide en qué slot cae la escritura de estado
Las cuatro instrucciones responden en última instancia a la misma pregunta: en el contexto de quién se ejecuta este código y en el almacenamiento de quién escribe. CALL y STATICCALL escriben el estado en el contrato llamado; DELEGATECALL y CALLCODE, en el contrato que inicia la llamada. Para la ejecución de un solo hilo, esta diferencia solo afecta a la corrección; para la ejecución paralela, determina la entrada de la detección de conflictos. El planificador, para decidir si dos transacciones se interfieren, debe saber qué combinaciones de «dirección más slot de almacenamiento» acaban escribiendo, y el contexto de llamada es justo la clave para decidir si aquí la dirección es la del proxy o la de la implementación: en el patrón proxy, una operación de reenvío escribe en el slot del proxy, no en el del contrato de implementación. Cómo se despliega la detección de conflictos en torno a un mismo slot de almacenamiento queda para más adelante, cuando se traten el storage layout y los conjuntos de lectura y escritura.
Fuentes
- EIP-7, «DELEGATECALL», opcode
0xf4, propagación del emisor y del valor, bloque de activación en Homestead 1,150,000 y motivación histórica: https://eips.ethereum.org/EIPS/eip-7 - EIP-214, «New opcode STATICCALL», indicador estático, lista de operaciones prohibidas y excepción de CALLCODE: https://eips.ethereum.org/EIPS/eip-214
- EIP-609, «Hardfork Meta: Byzantium», metapropuesta que incluye EIP-211 y EIP-214 en la lista de contenidos de Byzantium: https://eips.ethereum.org/EIPS/eip-609
- EIP-211, «New opcodes: RETURNDATASIZE and RETURNDATACOPY», datos de retorno dinámicos y
BYZANTIUM_FORK_BLKNUM: https://eips.ethereum.org/EIPS/eip-211 - EIP-150, «Gas cost changes for IO-heavy operations», precio base de llamada 700 y regla de reserva 63/64: https://eips.ethereum.org/EIPS/eip-150
- EIP-2929, «Gas cost increases for state access opcodes», acceso frío 2600, acceso caliente 100 y conjunto de acceso: https://eips.ethereum.org/EIPS/eip-2929
- ERC-1967 (antes EIP-1967), «Proxy Storage Slots», slot de la dirección de implementación
keccak256("eip1967.proxy.implementation") - 1: https://eips.ethereum.org/EIPS/eip-1967 - EIP-2488, «Deprecate the CALLCODE opcode», motivación del retorno de fallo permanente, estado estancado (Stagnant) y sin activar: https://eips.ethereum.org/EIPS/eip-2488
- Ethereum Yellow Paper, definiciones y tabla de opcodes de CALL / CALLCODE / DELEGATECALL / STATICCALL del apéndice H (incluida la condición previa
value ≤ saldode CALLCODE) y tabla de tarifas del apéndice G: https://ethereum.github.io/yellowpaper/paper.pdf - Cambios de ruptura de Solidity 0.5.0 (
callcodeya no se permite), uso de STATICCALL por parte deview/pureen la documentación de contratos, uso de DELEGATECALL por parte de las funcionesviewde las bibliotecas, protección de llamada de las bibliotecas y notas sobresend()/transfer()ya no recomendados: https://docs.soliditylang.org/en/v0.5.0/050-breaking-changes.html , https://docs.soliditylang.org/en/latest/contracts.html - Referencia de opcodes de la EVM de ethereum.org y tabla dinámica de gas de wolflo/evm-opcodes, transferencia de valor 9000, creación de cuenta 25000 y stipend de 2300: https://ethereum.org/en/developers/docs/evm/opcodes/ , https://github.com/wolflo/evm-opcodes/blob/main/gas.md