Introducción
En este artículo, discutiremos los invariantes y cómo realizar una prueba de invariantes en los contratos inteligentes de Solidity utilizando las suites de pruebas de Foundry.
Las pruebas de invariantes son otra metodología de prueba, al igual que las pruebas unitarias y el fuzzing, para verificar la corrección del código. Si no está familiarizado con las pruebas unitarias, consulte nuestro artículo sobre pruebas unitarias usando Foundry.
Para seguir el aspecto práctico de este artículo, se espera que esté familiarizado con Solidity y tenga Foundry instalado en su computadora. De lo contrario, vea cómo hacerlo aquí.
Repositorio adjunto
Si solo desea copiar y pegar algo de código, clone el repositorio que hemos proporcionado aquí. También puede usar el repositorio para seguir este tutorial. github.com/RareSkills/invariant-testing-foundry-tutorial
Por qué debería realizar pruebas de invariantes
Las pruebas de invariantes nos permiten probar aspectos de un contrato inteligente que las pruebas unitarias probablemente pasarán por alto. Las pruebas unitarias solo cubren las propiedades especificadas en la prueba y nada más. Pero con las pruebas de invariantes, los contratos inteligentes se prueban y ensayan bajo múltiples estados aleatorios para encontrar fallas en el código.
Al probar estos invariantes, los desarrolladores pueden detectar problemas potenciales que las pruebas unitarias o las revisiones manuales de código podrían no detectar.
Qué son los invariantes
Los invariantes son condiciones que siempre deben ser verdaderas bajo un cierto conjunto de suposiciones bien definidas. Por ejemplo, en un contrato ERC20, un invariante sería que la suma de todos los saldos en el contrato debe ser igual al suministro total. Si una llamada a una función o una transacción viola este invariante, algo ha salido mal con el código y el sistema ya no funciona correctamente.
Mientras que las pruebas unitarias verifican un comportamiento específico, los invariantes dicen algo sobre el sistema en su conjunto. Aquí hay algunos ejemplos:
- El suministro total de un token ERC20 no cambia si no se llama a
mintoburn - Las recompensas totales de un contrato inteligente no pueden exceder un cierto porcentaje durante un período fijo
- Los usuarios no pueden retirar más de lo que depositan + alguna recompensa limitada
Primeros pasos
Una prueba de invariante en Foundry es una prueba de fuzzing con estado (stateful fuzz test), donde el fuzzer llama aleatoriamente a las funciones de un contrato con entradas aleatorias, todo para intentar romper cualquier invariante especificado. Una prueba de fuzzing con estado significa que el estado de la prueba en una llamada se guarda para la siguiente llamada.
Inicialicemos un nuevo proyecto de Foundry para realizar una prueba de invariante en un contrato inteligente.
Ejecute el siguiente comando:
forge init invariant-exercise
cd invariant-exercise
Ahora tenemos nuestro proyecto de Foundry listo.
Configuraciones de Foundry
Podemos establecer valores de configuración opcionales para nuestra prueba de invariantes dentro del archivo foundry.toml. Foundry utiliza valores predeterminados si no se establecen valores de configuración. Solo estableceremos los importantes a medida que avancemos en este artículo. Para ver todas las configuraciones de invariantes disponibles visite aquí.
runs: El número de ejecuciones que deben realizarse para cada grupo de pruebas de invariantes (el valor predeterminado es 256).depth: El número de llamadas ejecutadas para intentar romper los invariantes en una sola ejecución (el valor predeterminado es 15).fail_on_revert: Hace que el fuzzing de invariantes falle si ocurre un revert (el valor predeterminado es false).
Un ejemplo de configuración en foundry.toml se vería así:
[invariant]
runs = 1000
depth = 1000
Alternativamente, estos parámetros se pueden establecer en variables de entorno, por ejemplo FOUNDRY_INVARIANT_RUNS=10000.
Un ejemplo simple
Ahora cambie el nombre del Counter.sol que viene con Foundry a Deposit.sol y pegue este código.
contract Deposit {
address public seller = msg.sender;
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balance[msg.sender];
balance[msg.sender] = 0;
(bool s, ) = msg.sender.call{value: amount}("");
require(s, "failed to send");
}
}
Este es un contrato simple que permite a cualquier persona depositar ether y retirarlo.
El ether depositado siempre debe poder ser retirado por el depositante en todo momento, ya que no hay restricciones.
Nuestro invariante debería ser que cualquier cantidad depositada debe poder ser retirada por la misma persona y en la misma cantidad.
Implementaremos una prueba de invariantes para confirmar que:
- El depositante puede retirar el ether depositado.
- La misma cantidad depositada sería la misma cantidad retirada por el depositante.
Verifiquemos que nuestro código es correcto escribiendo una prueba de invariantes para ambos casos. Diríjase a la carpeta test en nuestro proyecto de Foundry, cambie el nombre de Counter.t.sol a Deposit.t.sol y pegue el código a continuación.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Deposit.sol";
contract InvariantDeposit is Test {
Deposit deposit;
function setUp() external {
deposit = new Deposit();
vm.deal(address(deposit), 100 ether);
}
function invariant_alwaysWithdrawable() external payable {
deposit.deposit{value: 1 ether}();
uint256 balanceBefore = deposit.balance(address(this));
assertEq(balanceBefore, 1 ether);
deposit.withdraw();
uint256 balanceAfter = deposit.balance(address(this));
assertGt(balanceBefore, balanceAfter);
}
receive() external payable {}
}
Explicación de la prueba
Lo que estamos a punto de hacer es el ‘‘Open Testing’’. El Open Testing es donde la configuración predeterminada para los contratos objetivo se establece en todos los contratos implementados dentro de la función de prueba. Puede leer más al respecto aquí si lo desea.
Invariantes: El depositante puede retirar el ether depositado, y la misma cantidad depositada sería la misma cantidad retirada por el depositante.
El código que verifica que esto es correcto es la función de prueba invariant_alwaysWithdrawable, que es:
function invariant_alwaysWithdrawable() external payable {
deposit.deposit{value: 1 ether}();
uint256 balanceBefore = deposit.balance(address(this));
assertEq(balanceBefore, 1 ether);
deposit.withdraw();
uint256 balanceAfter = deposit.balance(address(this));
assertGt(balanceBefore, balanceAfter);
}
Note que la función de prueba comienza con la palabra clave invariant. Esto es importante porque Foundry usa esto para reconocer que se trata de una prueba de invariante.
Comenzamos depositando un ether desde el contrato de prueba. Dado que el contrato Deposit realiza un seguimiento de la cantidad depositada a través del mapping balance, lo usamos para tomar nota de nuestro saldo inmediatamente después de depositar (esto debería ser igual a un ether ya que es lo que depositamos).
A continuación, llamamos a la función withdraw para recuperar el ether y también tomamos nota de nuestro saldo nuevamente (debería ser cero en este punto).
Esta toma de notas del saldo se realiza con las variables locales balanceBefore y balanceAfter.
Esperamos que la cantidad que depositamos sea de un ether, por lo que lo confirmamos con assertEq(balanceBefore, 1 ether);.
Para confirmar que el invariante se mantiene, esperamos que balanceBefore sea mayor que balanceAfter, ya que este era nuestro saldo cuando depositamos.
Para verificar esto, utilizamos la aserción de Foundry assertGt(balanceBefore, balanceAfter);
Si ejecutamos la prueba con forge test --mt invariant_alwaysWithdrawable , obtenemos la siguiente salida:
Running 1 test for test/Deposit.t.sol:InvariantDeposit
[PASS] invariant_alwaysWithdrawable() (runs: 256, calls: 3840, reverts: 1917)
Test result: ok. 1 passed; 0 failed; finished in 347.19ms
Parámetros de prueba
El parámetro runs se refiere al número de veces que se ejecuta una función de prueba en particular. Cada vez que se ejecuta la función de prueba, pasa diferentes entradas o condiciones para probar diferentes escenarios y garantizar que el contrato funcione correctamente bajo diferentes condiciones.
Calls se refiere al número de veces que se llama a las funciones del contrato inteligente durante una sola ejecución de prueba.
Reverts se refiere al número de veces que una llamada a cualquier función dentro del contrato inteligente resultó en que una transacción se revirtiera (revert) debido a un error o excepción.
Esperando un revert
Podemos ver que la prueba fue exitosa, y la prueba realizó llamadas a las funciones de nuestro contrato 3840 veces con el fin de romper nuestros invariantes, como se muestra en el número de calls.
También hizo revert 1917 veces. Esto puede suceder cuando la prueba de invariantes o el fuzzer intenta llamar a cualquier función en el contrato inteligente sin cumplir con los requisitos de la función. Modificaremos nuestro archivo foundry.toml y agregaremos la siguiente configuración de prueba de invariantes para confirmar esto.
[invariant]
fail_on_revert = true
Esto hará que la prueba falle si hay un revert al intentar romper nuestro invariante.
Ahora, volvemos a ejecutar la prueba con forge test --mt invariant_alwaysWithdrawable, y obtenemos lo siguiente:
Test result: FAILED. 0 passed; 1 failed; finished in 8.53ms
Failing tests:
Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit
[FAIL. Reason: no balance]
[Sequence]
sender=0x00000000000000000000000000000000e3d670d7 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
invariant_alwaysWithdrawable() (runs: 1, calls: 1, reverts: 1)
Encountered a total of 1 failing tests, 0 tests succeeded
Podemos ver que la prueba de invariante llama aleatoriamente a la función withdraw desde el principio, incluso si no lo especificamos (Tenga en cuenta que esto es completamente aleatorio, y puede obtener un resultado diferente en diferentes intentos). Esto se debe a que todas las funciones de nuestro contrato están disponibles para el fuzzer a través del método open-testing. Veremos cómo excluir o incluir contratos/funciones específicos cuando hablemos de los “Objetivos de invariantes” más adelante en este artículo.
Esta llamada a función aleatoria intenta romper nuestro invariante por todos los medios. Pero como se especifica en el código, la función hará revert si el remitente no tiene saldo.
Debido a que la prueba de invariantes se comporta de esta manera, vemos algunos casos de revert incluso si nuestra prueba pasa.
(Recuerde cambiar el fail_on_revert a false, para que nuestra prueba no deje de ejecutarse).
Introduciendo una vulnerabilidad en el contrato para pruebas
Para realizar más pruebas, introduzcamos una vulnerabilidad en el contrato que permita a cualquier persona cambiar el saldo depositado de cualquier dirección.
Agregue el siguiente código al contrato Deposit:
function changeBalance(address depositor, uint amount) public {
balance[depositor] = amount;
}
Ahora volvemos a ejecutar la prueba con,
forge test --mt invariant_alwaysWithdrawable
y obtenemos la siguiente salida:
Test result: FAILED. 0 passed; 1 failed; finished in 74.09ms
Failing tests:
Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit
[FAIL. Reason: Assertion failed.]
[Sequence]
sender=0x0000000000000000000000000000000000000f7a addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x73575ade2424045cf0df8fa1712dde9137c56416 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0xba2840574eA60882e96881D1cC3C1d7D90af0e1d, 3]
sender=0xff1cb1b0420410582bfd4b6b345769b2cc4a51f1 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x000000000000000000000808080808149a59da1d addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x6383b40e80395f66de7f61df26bc9bafbbf3cb0f addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x97c68648bd6e6ed8a62e640937543f7bf47e39ba addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 2193]
invariant_alwaysWithdrawable() (runs: 31, calls: 456, reverts: 160)
Encountered a total of 1 failing tests, 0 tests succeeded
Preste atención a la última llamada a función en la secuencia de llamadas. Podemos ver que se llama a la función changeBalance. Los parámetros pasados aquí son; 1. La address del contrato de prueba de Foundry y 2. 2193 (un número totalmente aleatorio).
Esto cambiará el saldo del contrato de prueba, que usamos para depositar un ether anteriormente. Así que en lugar de tener un saldo de un ether, ahora tenemos 2193 como nuestro saldo. Por lo tanto, esto rompe el invariante de que “la misma cantidad depositada sería la misma cantidad retirada por el depositante”.
Para confirmar que la address pasada a la función changeBalance era la address del contrato de prueba, podemos, en cambio, suplantar (impersonate) una address deseada para la prueba.
Pero changeBalance() no está en las pruebas, ¡¿cómo se llamó?!
Esto es lo que hace que las pruebas de invariantes sean asombrosas. A pesar de que nunca llamamos explícitamente a changeBalance(), el probador de invariantes introdujo llamadas a esta función de manera aleatoria mientras llevaba a cabo la secuencia explícita de llamadas a funciones en la prueba.
Esto permite que las pruebas de invariantes evalúen aspectos en los que ‘‘no habíamos pensado’’.
Cambiando el saldo del usuario en lugar del saldo del contrato
Modifiquemos la función de prueba a esto:
function invariant_alwaysWithdrawable() external payable {
vm.startPrank(address(0xaa));
vm.deal(address(0xaa), 10 ether);
deposit.deposit{value: 1 ether}();
uint256 balanceBefore = deposit.balance(address(0xaa));
vm.stopPrank();
assertEq(balanceBefore, 1 ether);
vm.prank(address(0xaa));
deposit.withdraw();
uint256 balanceAfter = deposit.balance(address(0xaa));
vm.stopPrank();
assertGt(balanceBefore, balanceAfter);
}
Seguimos haciendo lo mismo que antes, excepto que en lugar de que el contrato de prueba sea el msg.sender, será el address(0xaa) que acabamos de suplantar con prank.
Ahora vuelva a ejecutar la prueba con forge test --mt invariant_alwaysWithdrawable, y obtenemos lo siguiente:
Test result: FAILED. 0 passed; 1 failed; finished in 85.64ms
Failing tests:
Encountered 1 failing test in test/Deposit.t.sol:InvariantDeposit
[FAIL. Reason: Assertion failed.]
[Sequence]
sender=0x00000000000000000000000000000000000000e6 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000090c5013b addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000000000001 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x00000000000000000000000000000000000000A1, 296312983667185193009]
sender=0x000000000000000000000000000000000000000c addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x0000000000000000000000000000000000000009 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000000000fc5 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x00000000000000000000000000000000000005fb addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=withdraw(), args=[]
sender=0x0000000000000000000000000000000000000005 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0xb30de0face1af7a50fbd59f1a0d9f31e9282d40f addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=deposit(), args=[]
sender=0x0000000000000000000000000000000000000a94 addr=[src/Deposit.sol:Deposit]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=changeBalance(address,uint256), args=[0x00000000000000000000000000000000000000AA, 4594637]
invariant_alwaysWithdrawable() (runs: 2, calls: 33, reverts: 8)
Encountered a total of 1 failing tests, 0 tests succeeded
La misma acción se repitió, pero esta vez con address(0xaa) (como podemos ver en la última secuencia de llamadas) y no con la dirección del contrato de prueba.
Lógicamente, también rompe el primer invariante: ‘‘El depositante puede retirar el ether depositado’’. La función changeBalance que introdujimos puede ser llamada con cualquier address y cero como amount para cambiar el balance.
Esto hará que esa dirección, que presumiblemente ha depositado antes, ahora tenga saldo cero y por lo tanto no pueda retirar, incluso si su ether está en el contrato.
Invariantes condicionalales
Mientras que los invariantes deben mantenerse en todo momento, algunos invariantes requieren que se cumplan ciertas condiciones. Por ejemplo, un invariante como assertEq(token.totalSupply(), 0); solo debería mantenerse cuando no ha habido un mint. El suministro total no sería cero si el token fuera acuñado.
Estos invariantes se denominan invariantes condicionales porque el protocolo o contrato inteligente debe encontrarse bajo ciertas condiciones antes de que deban mantenerse. Para obtener más información, puede consultarlo aquí.
Cambiando la configuración de la prueba de invariantes
Si queremos aumentar el número de ejecuciones para cada prueba, podemos agregar las configuraciones en el archivo foundry.toml, como se indicó anteriormente en este artículo.
Agregue lo siguiente en el archivo foundry.toml debajo de la sección [invariant].
[invariant] #invariant section
fail_on_revert = false
runs = 1215
depth = 23
Ahora vuelva a ejecutar la prueba con forge test --mt invariant_alwaysWithdrawable (asegúrese de haber eliminado o comentado la función changeBalance).
Running 1 test for test/Deposit.t.sol:InvariantDeposit
[PASS] invariant_alwaysWithdrawable() (runs: 1215, calls: 27945, reverts: 13965)
Test result: ok. 1 passed; 0 failed; finished in 4.39s
La prueba sigue pasando, pero esta vez el número de runs, calls y revert es significativamente mayor de lo habitual porque lo hemos modificado en nuestra configuración. Puede elegir usar cualquier número desde cero hasta uint32.max.
Si establecemos el parámetro runs en un número mayor que uint32, Foundry arrojará un error cuando intentemos ejecutar la prueba.
Por ejemplo, establezcámoslo en 23000000000000 e intentemos ejecutar la prueba.
Obtenemos esto:
Error:
failed to extract foundry config:
foundry config error: invalid value signed int `23000000000000`, expected u32 for setting `invariant.depth`
Un número mayor significa más escenarios de prueba, pero los números mayores hacen que la prueba sea más lenta.
Ejemplos cercanos a la vida real
Hemos cubierto al menos los conceptos básicos de las pruebas de invariantes con Foundry con nuestro contrato, pero vayamos más allá y realicemos una prueba de invariantes en un contrato popular.
Probaremos el contrato SideEntranceLenderPool, que es el contrato del cuarto nivel en el popularmente conocido Damn Vulnerable DeFi CTF.
Este es el contrato a continuación:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "openzeppelin-contracts/contracts/utils/Address.sol";
interface IFlashLoanEtherReceiver {
function execute() external payable;
}
/**
* @title SideEntranceLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/contract SideEntranceLenderPool {
using Address for address payable;
mapping(address => uint256) private balances;
uint256 public initialPoolBalance;
constructor() payable {
initialPoolBalance = address(this).balance;
}
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amountToWithdraw = balances[msg.sender];
balances[msg.sender] = 0;
payable(msg.sender).sendValue(amountToWithdraw);
}
function flashLoan(uint256 amount) external {
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "Not enough ETH in balance");
IFlashLoanEtherReceiver(msg.sender).execute{value: amount}();
require(
address(this).balance >= balanceBefore,
"Flash loan hasn't been paid back"
);
}
}
El contrato ha sido modificado un poco (también tenga en cuenta la importación de OpenZeppelin) para encajar en nuestro proyecto de Foundry y en lo que queremos. También hemos instalado las dependencias necesarias (la importación de la biblioteca Address de OpenZeppelin).
Este contrato es vulnerable en la función flashLoan que permite a alguien explotarlo y vaciar su saldo de ether. Un atacante podría llamar a la función flashLoan para tomar un préstamo y depositar el mismo préstamo nuevamente en el contrato con la función deposit como saldo del atacante; más tarde pueden retirar el saldo y salirse con la suya, incluso si originalmente era un préstamo y no su propio ether.
Entonces, ¿cuál va a ser nuestro invariante aquí?
Primero, es importante tener en cuenta que el contrato tiene un constructor payable, y el ether utilizado para los préstamos se deposita durante el despliegue. Tampoco hay forma de que ese ether depositado inicialmente sea retirado. Solo se puede agregar ether al contrato usando la función deposit y retirarse con la función withdraw (solo si quien llama a la función ha depositado previamente).
Así que, si tenemos esto en mente, podemos decir que el invariante sería
assert(address(SideEntranceLenderPool).balance >= SideEntranceLenderPool.initialPoolBalance());
(initialPoolBalance es una variable de estado public utilizada para almacenar cuánto ether se depositó durante el despliegue).
Afirmamos mediante aserción que el saldo de ether del SideEntranceLenderPool siempre es mayor o igual al ether depositado durante el despliegue.
Si todo funciona bien, este invariante debería mantenerse. Pero como se dijo anteriormente, una vulnerabilidad permite a alguien depositar un préstamo tomado del contrato y retirarlo más tarde.
En la siguiente sección, introduciremos un nuevo concepto en las pruebas de invariantes de Foundry llamado — Handler (Manejador) para lograr mejores resultados.
Pruebas basadas en Handler
Se utiliza un contrato handler para probar protocolos o contratos más complejos. Funciona como un contrato envoltorio que se utilizará para interactuar o realizar llamadas al contrato que deseamos.
Es particularmente necesario cuando el entorno necesita configurarse de cierta manera (es decir, se llama a un constructor con ciertos parámetros).
Funciona de la siguiente manera: en la función setUp del archivo de prueba, implementamos el contrato handler que hará llamadas al contrato del pool y estableceremos solo este contrato handler como el contrato objetivo en la prueba utilizando la función de ayuda de prueba targetContract(address target).
Debido a esto, solo las funciones del contrato handler serían llamadas de forma aleatoria por el fuzzer.
Otro beneficio es que si una función en el contrato principal (el contrato SideEntranceLenderPool en este caso) requiere de una cierta condición antes de que pueda ser llamada, podemos definirla fácilmente en el contrato handler antes de la llamada a la función.
El contrato handler también puede heredar el forge-std Test y utilizar los cheatsheets de Foundry como vm.deal, vm.prank, etc. Demostraremos esto a medida que avancemos.
Creemos una carpeta /handler dentro de la carpeta test y un archivo handler.sol dentro de ella.
Este será el código para nuestro contrato handler.
import {SideEntranceLenderPool} from "../../src/SideEntranceLenderPool.sol";
import "forge-std/Test.sol";
contract Handler is Test {
// the pool contract
SideEntranceLenderPool pool;
// used to check if the handler can withdraw ether after the exploit
bool canWithdraw;
constructor(SideEntranceLenderPool _pool) {
pool = _pool;
vm.deal(address(this), 10 ether);
}
// this function will be called by the pool during the flashloan
function execute() external payable {
pool.deposit{value: msg.value}();
canWithdraw = true;
}
// used for withdrawing ether balance in the pool
function withdraw() external {
if (canWithdraw) pool.withdraw();
}
// call the flashloan function of the pool, with a fuzzed amount
function flashLoan(uint amount) external {
pool.flashLoan(amount);
}
receive() external payable {}
}
Hemos definido funciones en el contrato handler que llaman a las funciones del contrato SideEntranceLenderPool. Esto es para que podamos probar más casos extremos y explotar prácticamente la vulnerabilidad.
El contrato handler hereda el forge-std Test como se indicó anteriormente, y el método vm.deal se usa dentro del constructor del contrato handler para darle algo de ether al contrato.
Objetivos de invariantes y ayudantes de prueba
Foundry viene con funciones de ayuda de prueba en la biblioteca forge-std que nos permiten especificar nuestros contratos objetivo, artefactos objetivo, selectores objetivo y selectores de artefactos objetivo.
Algunas funciones de ayuda son
targetContract(address newTargetedContract_)targetSelector(FuzzSelector memory newTargetedSelector_)excludeContract(address newExcludedContract_).
Para ver todas las funciones de ayuda de prueba disponibles, consulte aquí y aquí.
Crearemos un archivo de prueba SideEntranceLenderPool.t.sol dentro de la carpeta test. Aquí es donde definiremos nuestra prueba de invariantes para el contrato SideEntranceLenderPool y especificaremos el contrato handler como nuestro objetivo de invariante.
Pegue el siguiente código dentro del archivo de prueba:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "forge-std/Vm.sol";
import "forge-std/console2.sol";
import "../src/SideEntranceLenderPool.sol";
import "./handlers/Handler.sol";
contract InvariantSideEntranceLenderPool is Test {
SideEntranceLenderPool pool;
Handler handler;
function setUp() external {
// deploy the pool contract with 25 ether
pool = new SideEntranceLenderPool{value: 25 ether}();
// deploy the handler contract
handler = new Handler(pool);
// set the handler contract as the target for our test
targetContract(address(handler));
}
// invariant test function
function invariant_poolBalanceAlwaysGtThanInitialBalance() external {
// assert that the pool balance will never go below the initial balance (the 10 ether deposited during deployment)
assert(address(pool).balance >= pool.initialPoolBalance());
}
}
Después de pegar el código, ejecutemos la prueba con
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance
Obtenemos esta salida:
Test result: FAILED. 0 passed; 1 failed; finished in 19.08ms
Failing tests:
Encountered 1 failing test in test/SideEntranceLenderPool.t.sol:InvariantSideEntranceLenderPool
[FAIL. Reason: Assertion violated]
[Sequence]
sender=0x0000000000000000000000000000000000000531 addr=[test/handlers/Handler.sol:Handler]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=flashLoan(uint256), args=[3041954473]
sender=0x0000000000000000000000000000000000000423 addr=[test/handlers/Handler.sol:Handler]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=withdraw(), args=[]
invariant_poolBalanceAlwaysGtThanInitialBalance() (runs: 1, calls: 8, reverts: 0)
La prueba fue capaz de romper el invariante y encontrar el exploit.
Primero se llamó a la función flashLoan y luego a la función withdraw.
Para ver el stack trace completo y la secuencia de llamadas, podemos volver a ejecutar la prueba con
forge test --mt invariant_poolBalanceAlwaysGtThanInitialBalance -vvvv
[45514] Handler::flashLoan(3041954473)
├─ [40246] SideEntranceLenderPool::flashLoan(3041954473)
│ ├─ [32885] Handler::execute{value: 3041954473}()
│ │ ├─ [22437] SideEntranceLenderPool::deposit{value: 3041954473}()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
[14076] Handler::withdraw()
├─ [9828] SideEntranceLenderPool::withdraw()
│ ├─ [55] Handler::receive{value: 3041954473}()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
[7724] InvariantSideEntranceLenderPool::invariant_poolBalanceAlwaysGtThanInitialBalance()
├─ [2261] SideEntranceLenderPool::initialPoolBalance() [staticcall]
│ └─ ← 25000000000000000000 #initial balance was 25 ether
└─ ← "Assertion violated"
Ahora podemos visualizar toda la secuencia de llamadas y ver cómo se rompió el invariante.
Un ejemplo con una declaración matemática
Este ejemplo será un fuzz sin estado (stateless fuzz), es decir, el comportamiento no depende de llamadas anteriores. La intención aquí es demostrar las limitaciones del fuzzing y cómo superarlas. Podríamos agregar algunas variables de almacenamiento para convertir esto en un fuzz con estado, pero eso sería una distracción por ahora.
Aquí está nuestro contrato de ejemplo:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Quadratic {
bool public ok = true;
function notOkay(int x) external {
if ((x - 11111) * (x - 11113) < 0) {
ok = false;
}
}
}
Este es muy directo, y solo vamos a probar que la variable booleana ok sea true en todo momento, es decir; assertTrue(quadratic.ok());
Solo se vuelve false si se llama a la función notOkay con un número que cumpla con esta declaración: (x - 11111) * (x - 11113) < 0.
Esto puede parecer fácil, pero veamos si el fuzzer puede encontrar un número y romper el invariante.
También usaríamos el método handler aquí, así que cree un archivo Handler_2.sol dentro de la carpeta /test/ handler y pegue este código.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "../../src/Quadratic.sol";
import "forge-std/Test.sol";
contract Handler_2 is Test {
Quadratic quadratic;
constructor(Quadratic _quadratic) {
quadratic = _quadratic;
}
function notOkay(int x) external {
quadratic.notOkay(x);
}
}
Ahora cree un archivo Quadratic.t.sol dentro de la carpeta test y pegue este código dentro:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "./handlers/Handler_2.sol";
import "../src/Quadratic.sol";
contract InvariantQuadratic is Test {
Quadratic quadratic;
Handler_2 handler;
function setUp() external {
quadratic = new Quadratic();
handler = new Handler_2(quadratic);
targetContract(address(handler));
}
function invariant_NotOkay() external {
assertTrue(quadratic.ok());
}
}
Hemos definido nuestro invariante en la función invariant_NotOkay.
Ejecute la prueba con
forge test --mt invariant_NotOkay
y obtenemos:
Running 1 test for test/Quadratic.t.sol:InvariantQuadratic
[PASS] invariant_NotOkay() (runs: 256, calls: 3840, reverts: 760)
Test result: ok. 1 passed; 0 failed; finished in 576.70ms
La prueba pasó y el fuzzer no pudo romper el invariante. Pero existe un número que romperá este invariante, y mostraremos cuál es más adelante, pero por ahora, aumentemos el número de ejecuciones para la prueba para ver si lo encuentra.
Establezca el número de runs en 20,000.
[invariant]
runs = 20000
Volvimos a ejecutar la prueba y obtuvimos lo siguiente:
Running 1 test for test/Quadratic.t.sol:InvariantQuadratic
[PASS] invariant_NotOkay() (runs: 20000, calls: 300000, reverts: 74275)
Test result: ok. 1 passed; 0 failed; finished in 92.41s
Incluso con una ejecución alta, la prueba no pudo romper el invariante, pasando un número que hace que ok sea false.
Para confirmar que existe un número, el gráfico de desmos muestra este número cuando se ingresa la ecuación, como se muestra en la parte con el círculo azul en la imagen a continuación.

La imagen muestra que el número que necesitamos es 11112.
Intentemos limitar el rango de números que el fuzzer usará en el contrato handler con x = bound(x, 11_000, 100_000);. Agregue esta línea de código en la función notOkay del contrato handler (el segundo contrato handler).
Ahora debería verse así:
function notOkay(int x) external {
x = bound(x, 10_000, 100_000);
quadratic.notOkay(x);
}
La función de ayuda bound viene con la biblioteca Test de forge-std; podemos limitar el rango de las entradas fuzzeadas.
Vuelva a ejecutar la prueba con
forge test --mt invariant_NotOkay -vvv
y obtenemos lo siguiente:
Test result: FAILED. 0 passed; 1 failed; finished in 20.49s
Failing tests:
Encountered 1 failing test in test/Quadratic.t.sol:InvariantQuadratic
[FAIL. Reason: Assertion failed.]
[Sequence]
sender=0x000000000000000000000000000000000001373a addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-5675015641267]
sender=0x0000000000000000000000000000000000002df6 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-3]
sender=0x0000000000000000000000000000000000009208 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[1912195698230241887953774934318906299036]
sender=0x00000000000000000000000000000000000172fd addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x41b9a90e4836f4df4fe8ed9933c618c49163d8c3 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x0000000000000000000000000000000000005001 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[57896044618658097711785492504343953926634992332820282019728792003956564819794]
sender=0x000000000000000000000000000000000000e860 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x2039383034370000000000000000000000000000 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[5137619242564313626262060176411679498446697733570]
sender=0x0000000000000000000000000000000000008ead addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0x2d4326d8f5a6b7c3ef871eb0063dc7771fd571d8 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=failed():(bool), args=[]
sender=0xc7ebe193ccfed949da23e957c37020d88a068c34 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[57896044618658097711785492504343953926634992332813620401282714769779013280756]
sender=0xd72485927db413065ce2730222fc574be7f38a83 addr=[test/handlers/Handler_2.sol:Handler_2]0x2e234dae75c793f67a35089c9d99245e1c58470b calldata=notOkay(int256), args=[-57896044618658097711785492504343953926634992332820282019728792003956564809711]
invariant_NotOkay() (runs: 3470, calls: 52047, reverts: 0)
Encountered a total of 1 failing tests, 0 tests succeeded
El invariante sí rompió la aserción esta vez debido al rango limitado de entradas fuzzeadas, pero mirando la secuencia de llamadas, no podemos ver dónde se llamó a la función notOkay con 11112.
Usamos la bandera de verbosidad -vvv para ver qué estaba pasando.
También hay rastros de registro en el resultado de la prueba, que son:
invariant_NotOkay() (runs: 3470, calls: 52047, reverts: 0)
Logs:
Bound result 23762
Bound result 89998
Bound result 44363
Bound result 88972
Bound result 11664
Bound result 33484
Bound result 11112
Traces:
[14840] Handler_2::notOkay(-5675015641267)
├─ [0] VM::toString(23762) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053233373632000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 23762) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(23762)
│ └─ ← ()
└─ ← ()
[14840] Handler_2::notOkay(-3)
├─ [0] VM::toString(89998) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053839393938000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 89998) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(89998)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(1912195698230241887953774934318906299036)
├─ [0] VM::toString(44363) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053434333633000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 44363) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(44363)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(57896044618658097711785492504343953926634992332820282019728792003956564819794)
├─ [0] VM::toString(88972) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053838393732000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 88972) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(88972)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(5137619242564313626262060176411679498446697733570)
├─ [0] VM::toString(11664) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053131363634000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 11664) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(11664)
│ └─ ← ()
└─ ← ()
[14772] Handler_2::notOkay(57896044618658097711785492504343953926634992332813620401282714769779013280756)
├─ [0] VM::toString(33484) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053333343834000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 33484) [staticcall]
│ └─ ← ()
├─ [607] Quadratic::notOkay(33484)
│ └─ ← ()
└─ ← ()
[15887] Handler_2::notOkay(-57896044618658097711785492504343953926634992332820282019728792003956564809711)
├─ [0] VM::toString(11112) [staticcall]
│ └─ ← 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000053131313132000000000000000000000000000000000000000000000000000000
├─ [0] console::log(Bound result, 11112) [staticcall]
│ └─ ← ()
├─ [4500] Quadratic::notOkay(11112)
│ └─ ← ()
└─ ← ()
Los log traces muestran que el fuzzer llamó a la función notOkay con números que ni siquiera están cerca del número deseado. Aún así, la función bound siguió cambiando estas entradas hasta que se obtuvo el número correcto, como se muestra en el último resultado de bound y en la secuencia de llamadas.
El uso de la función bound en los casos en que se debe probar un rango particular de números puede ser útil y ayuda a lograr mejores resultados.
Conclusión
En este artículo, aprendimos qué son los invariantes, por qué son importantes y cómo realizar pruebas de invariantes en Foundry.
También discutimos los invariantes condicionales, la configuración basada en handler, y cómo y cuándo limitar el rango de los valores de entrada fuzzeados.
Más información
Nuestro entrenamiento avanzado de Solidity enseña pruebas de contratos inteligentes modernas más allá de las pruebas unitarias. Échele un vistazo para aprender más.
Autoría
Este artículo fue coescrito por Jesse Raymond (LinkedIn, Twitter) como parte del Programa de Investigación y Escritura Técnica de RareSkills.
Publicado originalmente el 28 de abril de 2023