lunes, julio 18, 2022

Blockchain & SmartContracts: Actualizar SmartContracts con Patrones Proxy

Siempre que hablamos de SmartContracts decimos que es muy importante tener presente que una vez subas tu código a la Blockchain, éste se quedará así para siempre y si tiene un bug o vulnerabilidad pues lo tendrá permanentemente. Pero y si tuviéramos algún tipo de patrón mediante el cual pudiéramos actualizar la lógica de nuestro contrato, entonces no tendríamos que pedir a nuestros usuarios que cambien el contrato que usaban, simplemente podemos actualizarlo y ya. 

Figura 1: Blockchain & SmartContracts.
Actualizar SmartContracts con Patrones Proxy

Si bien existen diferentes formas en las que podemos “actualizar” la lógica de un SmartContract como por ejemplo usando un modelo de “módulos arbitrarios” aprovechándose de que estos contratos pueden ejecutar código de manera arbitraría. Pero el patrón más usado es el patrón “Proxy”.

Figura 2: Idea general del patrón Proxy

Pero hoy nos vamos a centrar en los Proxies, que son un patrón que nos permite actualizar completamente la lógica de nuestros contratos usando solamente dos contratos diferentes, cabe destacar que estos dos contratos son inmutables, pero combinándolos podremos actualizar su funcionamiento. La idea básica de su funcionamiento es la siguiente:

Figura 3: Despliegue de SmartContract Proxy

Tenemos dos contratos diferentes, el primero el Proxy o Storage que se encargará de guardar en la Blockchain todos los datos del SmartContract. Este contrato siempre será el mismo y nunca cambiará, esto es así dado que mover los datos de un contrato a otro es muy costoso. El segundo el contrato de la lógica, como su nombre nos indica este se encargará de manejar la lógica por la que se regirá el SmartContract, este irá cambiando con el tiempo oséa que crearemos nuevos contratos y cambiaremos éste por los nuevos.

Figura 4: Actualizando contrato Proxy

Ahora, en cada interacción que el usuario haga la hará con el contrato Storage y esté a su vez le pasará la llamada al contrato que guarda la lógica y éste modificará la memoria del contrato Storage y no la suya. Ahora imaginemos que queremos actualizar el contrato que utiliza el usuario, pues simplemente subimos un nuevo contrato a la Blockchain que tenga esta lógica nueva y cambiamos en el contrato storage el puntero que redirigía al contrato anterior por el nuevo.

Funcionamiento del Patrón Proxy

Acabamos de ver la idea general y básica sobre cómo funcionan los sistemas proxies en los contratos basados en la EVM, veamos ahora cómo convertir esta idea en algo real. En la EVM (Ethereum Virtual Machine) existe una instrucción llamada delegatecall que funciona de la siguiente manera.
“””
delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
“””
Cabe notar que en Ethereum las instrucciones de código ensamblador se usan como si fueran funciones y no como “ add bx, 5; “ usado por la mayoría de lenguajes de ensamblador. Esta instrucción nos permite llamar a un contrato externo, igual que la instrucción “ call “. Bien pero, ¿qué diferencia hay entre estas instrucciones? Pues que delegatecall no solamente llama a la función de un contrato externo sino que además pasa todo el contenido guardado en msg.data (parámetros de llamada) y msg.value (ETH enviado).

Además teniendo la peculiaridad de que todas las modificaciones que haga el contrato al que se le delega la llamada no se reflejará en el propio contrato sino que lo hará en aquel que haya ejecutado delegatecall. La instrucción delegatecall recibe cinco parámetros:
  • gas: El gas del que va a disponer el contrato al que se le delega la llamada. No siempre este es el máximo posible porque a veces antes de delegar la llamada primero evaluamos unas ciertas condiciones que gastan el gas inicial, por ello es importante calcularlo adecuadamente.
  • _impl: El address del contrato hacia el cual delegamos la llamada. En el patrón proxy se le suele llamar implementación.
  • prt: Los datos del msg.data que se pasan al nuevo contrato, puede bastar con pasar msg.data en vez de ptr, pero puede que primero necesitemos procesar los datos de entrada. El msg.data acabará convirtiendose en los parámetros que le lleguen al contrato final.
  • calldatasize: El valor en bytes de la longitud de memoria que ocupa el msg.data.
  • últimos 2 parámetros: Estos dos últimos parámetros no son importantes, solo sirven para que el valor devuelto por el contrato al que se delegue sea acotado entre el rango provisto en esos dos valores.
Cabe destacar que si usamos la función addr.delegatecall(...) en Solidity, ésta sólo nos devolverá un booleano que nos dirá si la función ha fallado o no. Sin embargo si usamos la instrucción de ensamblador podremos acceder a los datos que devuelva el contrato al que se delegue usando un pequeño truco que veremos más adelante.

Implementación en Solidity

Toda esta teoría está muy bien pero veamos cómo podemos implementar todo esto en Solidity. Para ello vamos ha hacer uso de él “inline-assembly” que nos ofrece Solidity para así poder escribir ensamblador dentro del propio código de Solidity.

Figura 5: Función fallback en un Proxy

Antes de explicar que ocurre dentro de la función veamos el contexto de porque esa función. Si nos fijamos bien veremos que no estamos definiendo una función normal, sino una “fallback” esta función tiene un rol muy importante dentro de los SmartContracts basados en EVM, ésta se ejecutará siempre que se llame a un contrato y la función a la que se está llamando no exista en el propio contrato. Si queréis entender en profundidad cómo funciona este sistema de llamadas os recomiendo leer este artículo sobre los selectores de las funciones en la EVM.

Explicacion del codigo:

Lo primero que hacemos es reservar en memoria un espacio en el que poder guardar los datos que se envían junto con la función como parámetros, etcétera. Y ahora os preguntaréis ¿Y cómo accedemos a ellos si no están declarados en la función? Pues en la EVM todos los datos que se envían a una función están guardados dentro de una variable global llamada calldata. Después copiamos al espacio reservado en memoria los datos guardados en calldata. Esto es necesario ya que calldata es un tipo de memoria que no se puede ni modificar ni pasar a otras funciones como parámetros.

Luego delegamos la llamada a un address llamado _impl y le pasamos el calldata que escribimos en memoria, delegatecall nos devolverá un valor boolean que debemos guardar. Y también guardamos en memoria la longitud de la respuesta del delegatecall. Una vez hecho eso, copiamos el espacio reservado en 0 los valores devueltos por 2 y evaluamos si la llamada delegada fue exitosa o no. En caso de que no devolvemos el error que nos tiró el contrato de lógica, sino devolvemos los valores.

Problemas que pueden surgir

Una cosa muy importante a tener en cuenta es cómo se guarda el nuevo almacenamiento que creamos al añadir lógica a un contrato. Veamos primero cómo transforma Solidity su código a instrucciones.
“””
contract storage{
	uint256 age = 12;// transforms to sstore(0x00,0xC)
	uint256 other = 1; // transforms to sstore (0x40, 0x1)

	function modify() external{
		age = 13; // transforms to sstore(0x00,0xD)
		other = 2; // transforms to sstore(0x40, 0x2)
              }
}
“””
A la hora de actualizar este contrato tenemos que tener mucho cuidado de no hacer override de estas variables o podríamos tener un problema bastante serio, miremos el siguiente ejemplo para ver a lo que me refiero.
“””
contract storageV2{
uint256 score = 100; // transforms to sstore(0x00,0x64)
	uint256 age; // setted in V1 as 0x00 but not is 0x40
	uint256 other; // we setted in V1 0x40 but now is in position 0x60

	function modify() external{
		age = 13; // now it stores age in other slot
		other = 2; // now it stores other in a new slot
		score = 75; // stores it in the age slot
              }
}
“””
Este contrato que trata de actualizar la lógica del contrato anterior está mal, porque lleva a una colisión de almacenamiento. Esto es que cuando tratemos de leer lo que había en age v1 estaremos leyendo lo que había en otro v1 y si leemos other obtendremos 0 dado que no hay datos guardados en el slot nuevo. De manera visual la colisión sería la siguiente:

Figura 6: Storage Collision

Y los datos los podemos seguir leyendo porque aunque estén cambiados a fin de cuentas un uint256 es 32bytes con lo que se lee de la misma manera, pero si estuviésemos leyendo un mapping o un string los datos que leeremos no tendrían siquiera sentido alguno. También existen otros tipos de problemas como el de la transparencia para el usuario, control de acceso y actualización. Estos se resuelven de manera un poco más compleja.

¿Descentralización y transparencia?

Es verdad que es muy útil el poder actualizar la lógica de tus contratos por si acaso algún día se encuentra una vulnerabilidad en tu código o simplemente necesitas extender su funcionalidad, pero esto rompe un poco con los principios de inmutabilidad y transparencia. ¿Pero es esto una carencia? ¿De verdad rompe con la descentralización? Os voy adelantando que no, existen mecanismos para mantener descentralizada la propiedad del contrato y sus actualizaciones, pero esto no lo veremos hoy.


En el siguiente artículo os mostraré algunos problemas más comunes a la hora de usar Proxies en SmartContracts y como está el “state of the art” en lo relacionado a proxies en desarrollo Blockchain y como podéis implementarlo vosotros en vuestros proyectos. Nos vemos en el próximo artículo. Más artículos sobre este mundo Web3:
Saludos,

AutorChema Garabito. Desarrollador Full-Stack. Ideas Locas Telefónica CDO.


No hay comentarios:

Entrada destacada

Cibercriminales con Inteligencia Artificial: Una charla para estudiantes en la Zaragoza

Hoy domingo toca ir a participar en un evento, con una charla y una pequeña demo. Ahora mismo sí, así que el tiempo apremia, os dejo una cha...

Entradas populares