¿Cómo enviar Ether desde un smart contract?

Para enviar Ether desde un smart contract actualmente existen tres funciones del lenguaje en solidity:

  1. send: remite la cantidad de Ether suministrada como parámetro y devuelve un bool para informar del resultado del envío.
  2. transfer: remite la cantidad de Ether suministrada como parámetro y aborta con excepción en caso de error en el envío.
  3. call: remite la cantidad de Ether suministrada como parámetro y devuelve un bool para informar del resultado del envío.

Si el saldo de Ether es enviado a otro smart contract, se invocará la función «receive» o la función «fallback» del contrato destino, según cuál de las dos exista y si la parte «msg.data» de la transacción está o no vacía.

En apariencia las tres funciones se podrían emplear de manera equivalente, sin embargo existen matices de uso:

  • send y transfer limitan a 2300 unidades de gas la ejecución del código de las funciones «receive» y «fallback».
  • call por contra no establece ningún límite por defecto, permitiendo consumir el gas disponible restante en la transacción, aunque opcionalmente se podría fijar un limite en la llamada.

En este enlace se puede profundizar sobre la forma de enviar Ether en solidity.

En caso de remitir el Ether a una EOA, no habría ninguna direrencia de uso entre las tres funciones.

¿Qué ataques hay que prevenir al enviar Ether desde un smart contract?

El envío de Ether desde un smart contract a otro smart contract es una operación susceptible de ataque de reentrada (reentrancy attack).

Puesto que el contrato destino, el que recibe el Ether, ejecutará el código programado en sus funciones «receive» o «fallback» (según corresponda), se podría desarrollar una lógica que intentase aprovechar una posible vulnerabilidad del contrato llamante como la que se ilustra a continuación:

contract Vulnerable {
   mapping(address => uint256) public balances;   
   
   function deposit() public payable {
       balances[msg.sender] += msg.value;
   }
   
   function withdraw(uint256 amount) public {
       require(balances[msg.sender] >= amount, "not enough balance");
       payable(msg.sender).transfer(amount);
       balances[msg.sender] -= amount;
   }
}

Si el llamante de la función «withdraw» es una EOA, no habrá ningún riesgo. Sin embargo supongamos que se trata de otro smart contract, al que llamaremos atacante, que invoca la siguiente función:

function attack() {      
   Vulnerable.deposit({value: 1 ether});
   Vulnerable.withdraw(1 ether);
}

La llamada a «withdraw» pasará la comprobación de saldo y efectuará un «transfer» sobre el smart contract atacante. En este caso si se ejecutase la siguiente función «fallback» vemos cómo se produciría el ataque de reentrada con éxito, logrando decrementar el saldo del contrato vulnerable de forma recursiva:

fallback() external payable {
   Vulnerable.withdraw(1 ether);
}

Para prevenir este tipo de ataques se ha limitado la cantidad de gas disponible a 2300 unidades en la ejecución de la llamada a «receive» o «fallback». La idea es permitir una operación sencilla como la emisión de un evento pero evitar otras más complejas como por ejemplo las derivadas de un ataque de reentrada.

¿Qué forma de enviar Ether es más segura?

A priori la limitación a 2300 unidades de gas sería suficiente para poder usar las llamadas «transfer» o «send» de manera segura para enviar Ether a otro contrato.

Sin embargo con la implantación de las distintas bifurcaciones de Ethereum ha quedado patente que no es adecuado confiar en consumos de gas constantes para limitar o garantizar la ejecución de determinadas funciones en solidity.

A principios de 2019 la bifurcación Constantinopla tuvo que se demorada porque la disminución de los costes de gas podría provocar que la limitación a 2300 unidades no fuese suficiente para evitar determinados ataques de reentrada.

Por otra parte la bifuración Estambul trajo consigo un incremento de gas en la operación SLOAD lo que podía romper la ejecución de algunos contratos cuya funciones «receive» y «fallback» confiasen en consumir menos de 2300 unidades. A partir de ese momento se recomendó evitar el uso de «send» y transfer» para enviar Ether.

El consenso actual es usar la función «call» (sin límite predeterminado de gas en la ejecución del contrado destino) y prevenir el ataque de reentrada mediante el patrón «Checks-Effects-Interactions» y/o el uso de guardianes de reentrada.

En nuestro ejemplo, el ataque se evitaría con una sencilla modificación que decrementase el saldo antes de transferirlo, aprovechando además para cambiar la función «transfer» por «call»:

function withdraw(uint256 amount) public {
       require(balances[msg.sender] >= amount, "not enough balance");
	   balances[msg.sender] -= amount;
	   payable(msg.sender).call({value: amount});              
}

Conclusión: enviando Ether de manera segura

A lo largo de la historia del lenguaje solidity se han ido estableciendo distintas pautas para enviar Ether entre contratos de forma segura.

Después de las últimas experiencias de uso en casos reales, el consenso actual consiste en usar la función «call» y prevenir posibles ataques de reentrada mediante patrones de programación que modifican el estado antes de efectuar las llamadas a contratos externos y los llamados guardianes de reentrada.

Por otra parte, es muy aconsejable no seguir las prácticas que confían la correcta ejecución de determinadas funciones de un smart contract a límites preestablecidos de consumo de gas puesto que se ha visto que esos límites pueden variar a lo largo de las sucesivas bifurcaciones de código de la red Ethereum.


¿Conoces algún caso llamativo de explotación de un contrato por ataque de reentrada en el envío de Ether? Te invito a que compartas tus conocimientos.