¿Que son los errores personalizados en Solidity?

Los errores personalizados en Solidity son un mecanismo del lenguaje para informar al llamante de una función en Solidity sobre la ocurrencia de un determinado tipo de error. Complementan el lanzamiento de strings en excepciones para la comunicación de errores mediante «revert» y «require» de una forma más eficiente:

  • Permiten definir causas de error de manera estructurada, por ejemplo en combinación con tipos de datos enumerados para declarar códigos de error.
  • Reducen el gasto de gas frente al uso de strings, tanto en el despliegue de los contratos como en la invocación.

¿Cuál es la sintaxis de los errores personalizados en Solidity?

Sintácticamente se definen con la palabra clave «error», admiten parámetros al estilo de los eventos y pueden declararse tanto dentro como fuera de contratos convencionales, interfaces e incluso librerías.

En la siguiente entrada se pueden consultar los detalles técnicos sobre el uso errores personalizados en Solidity.

¿Cómo se usan los errores personalizados en la aplicación descentralizada?

Un caso de uso habitual en la aplicación descentralizada comprende la propagación de un error desde una librería al contrato llamante y de ahí al exterior, revirtiendo la transacción. Una variante de este caso de uso sería la propagación directa de un error desde una función de un contrato al exterior.

Se ilustra en el siguiente fragmento de código en que la función de librería «setMilestone» llamada desde otro contrato revertiría la transacción. Se puede observar la combinación con un tipo enumerado para informar sobre la causa particular del error al código llamante:

// Error details thrown within CryptoTaskLibraryError
enum CryptoTaskLibraryErrorDetail {
    EMPTY_DESCRIPTION,
    DESCRIPTION_TOO_LONG
}  

library CryptoTaskLibrary  {       
    
    // Custom error to revert when some error 
	// condition happens
    error CryptoTaskLibraryError(CryptoTaskLibraryErrorDetail details);
	
	uint8 constant DESCRIPTION_MAX_LENGTH = 100;
    
    function setMilestone(Milestone calldata milestone, 
						    Milestone[] storage _milestones) external {        
       
		if(bytes(milestone.description).length == 0)
            revert CryptoTaskLibraryError(CryptoTaskLibraryErrorDetail.
						EMPTY_DESCRIPTION);      
			
		if(bytes(milestone.description).length > DESCRIPTION_MAX_LENGTH)
			revert CryptoTaskLibraryError(CryptoTaskLibraryErrorDetail.
						DESCRIPTION_TOO_LONG);

        // Adds a new empty Milestone to list in storage
        Milestone storage newMilestone = _milestones.push(); 

        // Fills newly added Milestone with validated supplied data         
        newMilestone.description = milestone.description;          
    }
}
contract RevertTransaction {

    Milestone[] internal _milestones;

	function createMilestone(Milestone calldata milestone) external {   
		
		// Transaction will be reverted if library call fails. Error 
		// will be propagated outside.
		CryptoTaskLibrary.setMilestone(milestone, _milestones); 
		
		//More stuff if library function is successfully called
		// ...
	}
}

Otro caso de uso comprende la captura del error en un bloque try/catch. Esta situación sólo puede darse si el error procede de una llamada externa a otro contrato o librería, no es posible para llamadas internas dentro de un contrato. El código ejecutado en la llamada externa será deshecho pero la transacción globalmente no será revertida.  

Se ilustra a continuación, llamando de nuevo a otra variante de la función «setMilestone» que emite dos errores distintos, en esta ocasión sin argumentos. La transacción no será revertida por la presencia del bloque try/catch; por contra se emite un evento para notificar la condición de error:

library CryptoTaskLibrary  {       
    
    // Custom error to revert when milestone 
	// description is empty
    error CryptoTaskMilestoneDescriptionIsEmpty();
	
	// Custom error to revert when milestone 
	// description is too long
    error CryptoTaskMilestoneDescriptionTooLong();
	
	uint8 constant DESCRIPTION_MAX_LENGTH = 100;
    
    function setMilestone(Milestone calldata milestone, 
							Milestone[] storage _milestones) external {     
	
        if(bytes(milestone.description).length == 0)
            revert CryptoTaskMilestoneDescriptionIsEmpty();     
			
		if(bytes(milestone.description).length > DESCRIPTION_MAX_LENGTH)
            revert CryptoTaskMilestoneDescriptionTooLong();

        // Adds a new empty Milestone to list in storage
        Milestone storage newMilestone = _milestones.push(); 

        // Fills newly added Milestone with validated supplied data         
        newMilestone.description = milestone.description; 
    }
}
// Status codes
enum ErrorDetail {
	SUCCESS,
    EMPTY_DESCRIPTION,
	DESCRIPTION_TOO_LONG,
	UNKNOWN_ERROR
}  

contract CatchError {

	event MilestoneCreated(uint256 id, uint8 status);

	Milestone[] internal _milestones;

	function createMilestone(Milestone calldata milestone) external {   
		
		try CryptoTaskLibrary.setMilestone(milestone, _milestones) {
			emit MilestoneCreated(milestone.id, uint8(ErrorDetail.SUCCESS));
		} catch (bytes memory reason) {
			//Gets error selector from catched exception
			bytes4 receivedSelector = bytes4(reason);
			
			// Compares received selector with every expected error   
			if(receivedSelector ==
				bytes4(keccak256(bytes("CryptoTaskMilestoneDescriptionIsEmpty()"))))
				
				emit MilestoneCreated(milestone.id, 
										uint8(ErrorDetail.EMPTY_DESCRIPTION));
			else if(receivedSelector == 
				bytes4(keccak256(bytes("CryptoTaskMilestoneDescriptionTooLong()"))))
				
				emit MilestoneCreated(milestone.id, 
										uint8(ErrorDetail.DESCRIPTION_TOO_LONG));
			else 
				emit MilestoneCreated(milestone.id, 
										uint8(ErrorDetail.UNKNOWN_ERROR));		
		}
	}
}

Es de especial interés el procedimiento para identificar el tipo de excepción capturada en el bloque catch a partir del selector del error.

Filtrado de los errores personalizados en la aplicación descentralizada

En la aplicación descentralizada se interactúa con la blockchain mediante la librería Viem. Se trata de una alternativa a las tradicionales librerías «web3.js» y «ethers.js».

Un procedimiento habitual para comprobar posibles errores en la llamada a las funciones de un smart contract es a través de la acción «simultateContract» del «Public Client» de Viem. Internamente se traduce en una llamada RPC a «eth_call»:

import { createPublicClient, 
		http, 
		BaseError, 
		ContractFunctionRevertedError } from 'viem';
			 
import { polygon } from 'viem/chains';
 
const publicClient = createPublicClient({ 
  chain: mainnet,
  transport: http()
})

const inputParams = {
    account: // client wallet address,
    address: // contract address,
    abi: // contract ABI,
    functionName: // contract function to call,
    args: // contract function input arguments,
    chainId: // blockchain where contract is deployed,                      
}

// ..... Code snippet of function interacting with blochchain

try {
    const { request } = await publicClient.simulateContract(inputParams);
}
catch(error) {

	if (error instanceof BaseError) {
        const revertError = error.walk(err => 
			err instanceof ContractFunctionRevertedError)
			
        if (revertError instanceof ContractFunctionRevertedError) {
            console.log("Contract revert error", revertError.data);			
            const errorName = revertError.data?.errorName ?? '';
            const errorArgs = revertError.data?.args ?? '';            
            return `${errorName} ${errorArgs}`;
        }
		else {
			console.log("General error");
			return (error.shortMessage || error.message);
		}	
    }    
    else
        return (error.shortMessage || error.message);
}

El tipo de error «ContractFunctionRevertedError» permite separar el nombre del error personalizado propagado en la llamada al contrato y los posibles argumentos. Así se podría informar al usuario de la DApp sobre la causa detallada del error, mejorando la experiencia de usuario.

Conclusión: gestión eficiente de errores en los smart contracs

El uso de errores personalizados en Solidity aporta mejoras en la gestión de las llamadas erróneas a los contratos inteligentes:

  • El código Solidity generado es más eficiente en el consumo de gas y más estructurado en el tipado de las causas de error.
  • La experiencia de usuario en las aplicaciones descentralizadas se realza, al poder informar de forma más conveniente sobre los detalles de cada error.

Es destacable mencionar que librerías de código reputadas como las de OpenZeppelin han actualizado la propagación de errores desde sus contratos al modelo de errores personalizados.

Por ejemplo, el modificador «onlyOwner» del contrato Ownable en versiones antiguas de la librería emitía el string «Ownable: caller is not the owner» en caso de error de acceso mediante la sentencia «require».

Por contra la versión actual de la librería emite el error personalizado «OwnableUnauthorizedAccount(account)» como puede verse en el api de la librería de control de acceso.


¿Has adoptado el uso de errores personalizados en Solidity en algún contrato o librería? Comparte tu propia visión sobre esta herramienta del lenguaje.