Una implementación de solidez del patrón de diseño de máquina de estado

Escribir contratos inteligentes da miedo. Manejan dinero real, y olvidarse de agregar una sola palabra clave, o mal ordenar 2 líneas de código aparentemente intercambiables puede resultar en la pérdida de millones de dólares. Sí, puede escribir pruebas y hacer que los profesionales auditen su código, pero si la estructura y la funcionalidad de sus contratos comienzan a complicarse, todavía hay una buena posibilidad de que se pierdan algo. No puede estar 100% seguro de que su código sea seguro.

Afortunadamente, existe una gran cantidad de mejores prácticas, fallas de seguridad conocidas y patrones de diseño que pueden ayudarlo a minimizar los riesgos. En Token Foundry, una de las formas en que ayudamos a asegurar nuestras ventas de tokens es mediante el uso de un patrón de diseño de programación conocido como el patrón "State Machine".

Si ha estado programando durante un tiempo, es posible que ya esté al tanto de este patrón. El propósito de este artículo es explicar:

  • Cuál es el patrón de la máquina de estado y cuándo debe usarse
  • Un desglose de la máquina de estado que hemos desarrollado en Token Foundry, cómo usarla usted mismo y cómo funciona realmente
  • Cómo este patrón puede ayudar a garantizar la seguridad de sus contratos
  • Cómo esperamos desarrollar aún más nuestra máquina de estado en el futuro

¿Qué es el patrón de diseño de máquina de estado?

El patrón de la máquina de estado divide la funcionalidad de un programa en varios "estados" diferentes. En cualquier momento dado, el programa se encuentra en un solo estado, durante el cual solo es posible la funcionalidad específica del estado. El programa puede hacer la transición entre estos estados de una manera predefinida. Por ejemplo, un programa puede requerir que las transiciones se activen manualmente, o puede hacer una transición automática entre estados. En Token Foundry definimos estas transiciones automáticas utilizando condiciones de inicio de estado, que pueden incluir (entre otras):

  • Una variable ahora toma algún valor deseado
  • Se ha alcanzado un tiempo específico
  • Se ha producido un evento requerido o llamada de función

Al ingresar al nuevo estado, pueden ocurrir cambios en el valor variable o ciertas funciones pueden llevarse a cabo automáticamente. Las funciones que deben ejecutarse al ingresar a un nuevo estado son funciones de devolución de llamada.

¿Cuándo se debe usar el patrón?

El patrón de diseño de State Machine no es adecuado para su uso en todos los programas o contratos inteligentes. Los sistemas que se adaptan bien al patrón deben desglosarse fácilmente en etapas distintas, donde se produce un comportamiento diferente o se permite una funcionalidad diferente. Estas etapas del sistema están representadas por estados en una máquina de estados, y deben ocurrir una después de la otra durante un período de tiempo.

Por ejemplo, al revelar información en cadena, es común que todas las partes cometan el hash de su información antes de revelar todos los valores reales. Un ejemplo de esto es la votación: el contrato puede necesitar estados de la siguiente manera:

  • Registro: los votantes pueden registrarse en el contrato para votar más tarde
  • Compromiso de votos: se comprometen los hash de las opciones elegidas por los votantes
  • Revelación de votos: los votantes ahora revelan su voto (que coincide con su hash)
  • La votación ha terminado: no se permiten más comentarios de los votantes

Las condiciones de inicio para estos estados podrían desencadenar transiciones una vez que un cierto número de votantes se hayan registrado o una vez que haya pasado cierto tiempo.

Un desglose de la máquina de estado de Token Foundry y cómo usarla usted mismo

En Token Foundry, hemos creado algunos contratos inteligentes que permiten a los desarrolladores implementar fácilmente un patrón de máquina de estado lineal (por ahora). Nuestros contratos y pruebas de StateMachine son de código abierto, y se pueden encontrar en Token Foundry GitHub para que cualquiera pueda leerlos, probarlos y usarlos.

Nuestra implementación permite definir un número arbitrario de estados, junto con un número arbitrario de condiciones de inicio y funciones de devolución de llamada para cada estado.

Ofrecemos dos contratos: StateMachine.sol y TimedStateMachine.sol. El primero de ellos es la implementación del patrón base, y el segundo es una extensión que permite condiciones de inicio basadas en la marca de tiempo para los estados.

La idea básica para configurar su máquina de estado se puede dividir en unos simples pasos:

  1. Identifique los estados de alto nivel que tendrá su máquina.

Se definen como valores constantes en el contrato. Por ejemplo, en una venta de tokens simple, podría tener:

bytes32 constante FREEZE = "congelar";
bytes32 constante IN_PROGRESS = "inProgress";
bytes32 constante ENDED = "finalizada"

Estos estados deben pasarse a la función setStates (bytes32 [] estados) para configurar la máquina de estados. Esto generalmente debe hacerse en el constructor de su contrato.

2. Defina qué funciones se permitirán en cada estado.

Esto también se recomienda que se lleve a cabo en el constructor del contrato, de modo que cualquier función no permitida se establezca como tal desde el principio. Por ejemplo, continuando con el ejemplo anterior, solo queremos que un contribuyente pueda comprar tokens durante nuestro estado IN_PROGRESS.

En el constructor ponemos:
allowFunction (IN_PROGRESS, this.buy.selector);

Esto establece que la función comprar solo se permite dentro de IN_PROGRESS: si la máquina de estado está CONGELADA o FINALIZADA, entonces la compra no se puede ejecutar. Más sobre cómo funciona esto más adelante.

3. Defina las condiciones de inicio y las funciones de devolución de llamada de cualquier estado

La condición de inicio y las funciones de devolución de llamada deben definirse dentro de su contrato inteligente y deben agregarse a los estados relevantes al construir su máquina de estados.

Las condiciones de inicio deben tomar la siguiente forma, donde bytes32 es el ID de estado (por ejemplo, CONGELACIÓN constante):
función exampleStartCondition (bytes32) retornos internos (bool) {...}
Las devoluciones de llamada ejecutadas automáticamente al ingresar a un estado toman una forma diferente:
ejemplo de función Callback () interno {...}

Estos se configuran para los estados relevantes de la siguiente manera:

addStartCondition (ENDED, hasSaleSoldOut);
addCallback (FINALIZADO, transferMoneyToTeam);

Para simplificar las transiciones activadas por marca de tiempo, también definimos un TimedStateMachine. Este contrato tiene una condición de inicio activada por marca de tiempo predefinida y utiliza la siguiente función para permitir fácilmente que estas transiciones se agreguen a una máquina de estado:
función setStateStartTime (bytes32 stateId, uint256 timestamp) interno

¿Entonces, cómo funciona todo esto?

En nuestro contrato de StateMachine, tenemos un modificador llamado checkAllowed. Que se define de la siguiente manera:

modificador checkAllowed {
    conditionalTransitions ();
    require (states [currentStateId] .allowedFunctions [msg.sig]);
    _;
}

Cuando una función se define usando el modificador checkAllowed, la función conditionalTransitions () se ejecuta primero. conditionalTransitions () verifica cada una de las condiciones de inicio del estado posterior, y si alguna de estas son transiciones verdaderas a dicho estado. Este proceso se repite hasta que la máquina de estado esté en el estado actual correcto. La siguiente línea requiere que se permita ejecutar la función en el estado actual.

Por ejemplo, supongamos que tenemos una máquina de estado en el estado A, y ese estado B tiene una condición de inicio de tiempo> = 10pm. Si se llama a una función onlyInStateA marcada checkAllowed a las 10.05pm, conditionalTransitions verá que (time> = 10pm) == true, y hará la transición de la máquina al estado B automáticamente, llamando a cualquier devolución de llamada necesaria en este momento. Luego verá que la máquina está de hecho en estado B y no se ejecuta solo en Estado A.

Cómo nuestra máquina de estado ayuda a mejorar la seguridad, el razonamiento y la capacidad de administración del código

El uso de patrones de diseño al programar, incluido el patrón de máquina de estados, divide las ideas complejas en construcciones más simples. Un sistema rediseñado como máquina de estados permite que los estados sean probados y razonados individualmente, sin riesgo de interferencia de otros estados y su comportamiento.

Las construcciones de State Machine también permiten aumentar la claridad del flujo de un sistema. Tal simplicidad y claridad en los contratos inteligentes es clave, especialmente cuando manejan sumas de dinero potencialmente grandes.

Limitaciones y Planes Futuros

Actualmente, nuestra implementación solo permite máquinas de estado lineal (cada estado solo puede tener 1 transición saliente). Esto funciona bien para nuestros contratos de venta, que tienen un flujo simple y solo están destinados a vivir por un corto período de tiempo. Sin embargo, si su sistema requiere características más complejas, esta implementación puede no ser suficiente. Ejemplos de tales características son invertir una transición, ramificar flujos o tener ciclos.

Actualmente estamos trabajando en la refactorización de este proyecto para soportar estructuras y ciclos de máquinas no lineales, en (con suerte) un futuro no muy lejano.

Realmente apreciamos todas las preguntas, consultas y comentarios sobre el código que escribimos, así que no dude en comunicarse.

Alice Henshaw
Ingeniero de Solidez @ Token Foundry
www.tokenfoundry.com