Los algoritmos de staking de MasterChef y Synthetix distribuyen un pool de recompensas fijo entre los stakers según sus contribuciones ponderadas por el tiempo a un pool. Para ahorrar gas, los algoritmos utilizan un contador acumulativo de recompensas a nivel de token y difieren la distribución de recompensas.
Imagina que tenemos un pool fijo de 100,000 tokens REWARD que deseamos distribuir de manera justa entre los stakers desde el bloque 1 hasta el 100.
Nuestro objetivo es distribuir 1,000 REWARD en cada bloque, divididos entre los stakers de acuerdo con su stake.
Por ejemplo, si en un bloque en particular, los saldos de staking en el contrato fueran los siguientes:
| Cantidad en Stake | % del Pool | |
|---|---|---|
| Alice | 100 | 25% |
| Bob | 100 | 25% |
| Chad | 200 | 50% |
Entonces, los 1,000 REWARD distribuidos en ese bloque serían los siguientes:
| Cantidad en Stake | % del Pool | Recompensa Distribuida | |
|---|---|---|---|
| Alice | 100 | 25% | 250 |
| Bob | 100 | 25% | 250 |
| Chad | 200 | 50% | 500 |
Terminología para token y recompensa
El token en stake y la recompensa de staking pueden o no ser la misma moneda. Para mayor claridad, nos referiremos a ellos como token y recompensa — a veces como TOKEN y REWARD.
Enviar una transacción en cada bloque para distribuir una recompensa es poco práctico
La solución ingenua sería que un bot fuera de la cadena (off-chain) enviara transacciones en cada bloque para leer los saldos indicados de TOKEN para cada uno de los stakers en el contrato y les acuñara a cada uno REWARD de acuerdo con su porcentaje del pool.
Sin embargo, no hay una forma confiable de lograr que las transacciones se incluyan en cada bloque. Si el bot se salta un bloque, entonces los usuarios obtendrán menos recompensa de la que esperan.
Esta estrategia también incurrirá en muchas tarifas de transacción.
Enviar transacciones en cada bloque es innecesario, podemos emitir recompensas de “puesta al día” (catch up) para los bloques en los que no se distribuyeron recompensas
Supongamos que mantuviéramos una variable lastUpdateBlockNumber que rastreara la última vez que emitimos una recompensa. Calcularíamos la cantidad de bloques desde la última recompensa como block.number - lastUpdateBlockNumber.
Entonces podríamos saltarnos algunos bloques donde distribuimos la recompensa y “ponernos al día” cuando realmente distribuyamos la recompensa.
El siguiente gráfico ilustra esto.

Sin embargo, todavía no tenemos una buena manera de distribuir las recompensas que acabamos de acuñar a todos los stakers de acuerdo con su porcentaje de stake.
Además, no sabemos si los saldos del token en stake fueron constantes durante el intervalo anterior desde la última distribución de recompensas. Por ejemplo, ¿qué pasaría si Chad supiera que íbamos a medir los saldos en el bloque 100 e hiciera un gran depósito en el bloque 99 para obtener una mayor parte de la recompensa?
Ese problema resulta ser fácil de resolver.
Invariante clave: sin transacciones, no hay cambios de saldo
En lugar de tener un bot que dispare transacciones cada 20 bloques más o menos, simplemente podemos esperar a que un usuario interactúe con el contrato a través de funciones que cambien el estado como deposit() o withdraw().
Entre las llamadas a estas funciones, podemos estar seguros de que el saldo de nadie cambió.
Por ejemplo, si entre el bloque 10 y el bloque 15 Alice tenía el 50% del stake y Bob tenía el 50% del stake, entonces podemos emitir 5,000 REWARD (5 bloques por 1,000) y dar a cada uno de ellos el 50%.
No hay forma de que Chad o Bob “se cuelen” y aumenten su saldo porque cuando llamen a deposit(), activarán una distribución de recompensas. Y la función de distribución de recompensas está programada para no incluir su depósito reciente.
Considera el siguiente gráfico que muestra los cambios en los saldos a lo largo del tiempo. La única forma de que esos cambios ocurran es que se haya realizado una transacción con el contrato inteligente.

Sin embargo, esta solución no escala.
Iterar sobre todos los stakers consume mucho gas
Distribuir a cada staker su REWARD cada vez que alguien llama a deposit() o withdraw() será muy costoso en gas si hay docenas de stakers. Transferir un token ERC-20 no es barato, y hacerlo docenas de veces en un bucle es prohibitivo.
Para hacer este staking de manera eficiente, las personas solo pueden recibir transferencias de recompensas si inician una transacción de cambio de estado. Para aquellos que no reclaman sus recompensas, estas se difieren. Las recompensas permanecen en el contrato esperando ser reclamadas por ellos.
Esto evitará que tengamos que hacer un montón de transferencias ERC-20.
Para tener una solución eficiente:
- solo podemos actualizar las variables de cuenta asociadas con la cuenta que inicia la transacción
- solo podemos actualizar una única variable global que rastree el aumento de la asignación de recompensas de los demás, no podemos actualizar explícitamente cada cuenta
En lugar de rastrear las acumulaciones de recompensas en las cuentas, rastreamos las ganancias de un solo token en stake
Supongamos que podemos rastrear con precisión cuánto ha acumulado en recompensas un solo token en stake “desde el principio de los tiempos” (cuando el contrato comenzó a distribuir recompensas).
De ser así, rastrear cuánta recompensa ha acumulado una cuenta es simplemente multiplicar su saldo de tokens por la cantidad de recompensa que ha acumulado un solo token desde el principio de los tiempos.
Supongamos que sabemos que un token en stake desde el principio de los tiempos hasta el momento actual ha recolectado 12 recompensas. Si Alice tiene un stake de 100, entonces se le deben 1,200 recompensas.
Esto es un poco como decir: “un dólar ahorrado en nuestro banco ha ganado $0.40 en intereses desde que abrimos el banco. Si abriste una cuenta cuando abrimos el banco, y no has depositado ni retirado desde entonces, has ganado un 40% en intereses”.
Esto nos lleva a dos preguntas:
-
¿Cómo rastreamos las acumulaciones de recompensas para un solo token desde el principio de los tiempos?
-
¿Qué pasa si Alice no ha estado haciendo staking desde el principio de los tiempos, sino que solo depositó recientemente?
Cómo rastreamos las acumulaciones de recompensas para un solo token desde el principio de los tiempos
Dado que se emite una cantidad fija de recompensas en cada bloque (1,000 en nuestro ejemplo en curso), cuantos más stakers haya, menor será la parte de las 1,000 recompensas fijas que ganan. No importa cuántas personas hagan staking, solo la oferta total de tokens en stake en el contrato.
Considera el siguiente ejemplo hipotético.
| recompensas emitidas por bloque | oferta de tokens en stake | Recompensa por token por bloque | |
|---|---|---|---|
| bloques 1-5 | 1,000 | 100 | 10 |
| bloques 6-13 | 1,000 | 200 | 5 |
| bloques 14-15 | 1,000 | 100 | 10 |
| bloques 16-20 | 1,000 | 500 | 2 |
Cuantos más tokens en stake, menos recompensa por token por bloque. Los stakes más grandes diluyen la recompensa, y como consecuencia, un solo token gana menos.
La tabla se representa visualmente a continuación. El gráfico rojo es la oferta de tokens en stake. La línea violeta es la cantidad de recompensa que se acumula para un solo token en ese bloque. Los bloques avanzan hacia la derecha en el eje x. La relación inversa entre las dos variables debería ser clara.

Aquí está la clave:
Cada vez que tenemos una transacción que cambia el estado, miramos hacia atrás cuántos bloques pasaron, lo multiplicamos por la recompensa por bloque y luego lo dividimos por la oferta total en stake. Esta es la cantidad de recompensa que un token acumuló durante ese intervalo. Luego sumamos este valor a un acumulador global que comenzó en cero al principio de los tiempos. Si seguimos repitiendo este proceso cada vez que entra una transacción, sabemos cuánto ha acumulado en recompensas un solo token desde el principio de los tiempos.
Aquí está el mismo gráfico con el acumulador agregado.

Y aquí hay una tabla que muestra los mismos valores:
| recompensas emitidas por bloque | oferta de tokens en stake | Recompensa por token por bloque | Número de bloques en el intervalo | Recompensas emitidas en el intervalo | Recompensa acumulada por token | |
|---|---|---|---|---|---|---|
| bloque 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| bloques 1-5 | 1,000 | 100 | 10 | 5 | 50 | 50 |
| bloques 6-13 | 1,000 | 200 | 5 | 8 | 40 | 90 |
| bloques 14-15 | 1,000 | 100 | 10 | 2 | 20 | 110 |
| bloques 16-20 | 1,000 | 500 | 2 | 5 | 10 | 120 |
Es decir, un solo token en stake durante el transcurso de nuestro gráfico ha acumulado 120 recompensas.
Caso de prueba: recompensando a Alice que ha estado haciendo staking desde el principio
Consideremos un ejemplo muy simple. De nuevo estamos emitiendo 1,000 recompensas por bloque. En el transcurso de 20 bloques, se emitirán 20,000 recompensas.
Esto es lo que hacen Alice y Bob:
- Alice ha puesto en stake 100 tokens desde el bloque 1 hasta el bloque 20.
- En el bloque 10, Bob pone en stake 100 tokens.
- En el bloque 20, a Alice se le debería deber el 75% de todas las recompensas emitidas hasta ese momento, o 15,000 recompensas.
Visualmente, la participación en el pool de stake por bloque se vería de la siguiente manera.

Del bloque 1 al bloque 10, la recompensa por token por bloque fue de 10 (1,000 ÷ 100). Durante ese intervalo de 10 bloques, cada token acumuló 100 recompensas (10 recompensas por token por bloque x 10 bloques).
Pero cuando Bob depositó en el bloque 10, la recompensa por token por bloque se diluyó a 5 (1,000 ÷ 200). Durante el siguiente intervalo de 10 bloques (bloques del 11 al 20), cada token acumuló 50 recompensas.
Por lo tanto, el valor total que un token había acumulado desde el bloque 1 hasta el bloque 10 era 100, y el valor acumulado del 11 al 20 era 50. En consecuencia, el valor total que un token acumuló es 100 + 50 = 150.
Como Alice tiene 100 tokens depositados, y cada token ha acumulado 150 recompensas, se le emitirán 15,000 recompensas, lo cual es efectivamente el 75% del total de las recompensas emitidas.
¿Qué pasa si alguien no ha estado haciendo staking desde el principio?
Un caso extremo obvio en el ejemplo anterior (y uno que predijimos en una sección anterior) es que si Bob fuera a reclamar recompensas, también obtendría 15,000 recompensas porque su stake en el bloque 20 es de 100, al igual que el de Alice.
Para resolver esto, solo queremos que el acumulador empiece a contar para Bob en el momento en que Bob depositó.
La solución intuitiva es almacenar el número de bloque en el que depositó Bob y corregirlo más tarde.
Sin embargo, es aún más simple calcular la cantidad de recompensas que se le habrían emitido si hubiera depositado en el bloque 10 y luego hubiera reclamado una recompensa de inmediato. Por ejemplo, en el bloque 10, la recompensa acumulada por token era 100. Dado que Bob depositó 100 tokens, en teoría podría haber reclamado 10,000 recompensas de inmediato.
Para evitar que esto suceda, tenemos una variable para Bob que llamamos la “deuda de recompensa” (reward debt). En el momento en que deposita, establecemos que la deuda de recompensa sea el saldo depositado multiplicado por el acumulador de recompensas por token. Eso evitará que reclame una recompensa de inmediato ya que, en ese momento, las recompensas que se le deben serían cero (recompensas actuales menos la deuda de recompensa).
Tenemos una variable separada para Bob llamada “deuda de recompensa” o “recompensas ya emitidas” y le asignamos esa cantidad hipotética de recompensa. En el bloque 10, la recompensa acumulada por token era 100 y el depósito de Bob fue de 100, por lo que su deuda de recompensa es 10,000.
Si Bob reclama recompensas en el bloque 20, a las 15,000 recompensas le restamos la deuda de recompensa de 10,000. Bob solo podrá reclamar 5,000 recompensas en el bloque 20.
Pseudocódigo para MasterChef
A continuación presentamos una versión simplificada del algoritmo MasterChef. Nos hemos tomado la libertad de cambiar los nombres de las variables del contrato original para mayor claridad. También omitimos eventos y detalles de implementación relacionados con la escala de los decimales de los tokens.

Diferencias entre Synthetix y MasterChef
Synthetix y MasterChef utilizan el mismo mecanismo para acumular la recompensa por token en función de la cantidad en stake. La principal diferencia es que, en lugar de rastrear la deuda de recompensa, Synthetix almacena una instantánea (snapshot) del acumulador de recompensas de la última vez que el usuario interactuó con el contrato. La diferencia entre el acumulador de recompensas actual y la instantánea se utiliza para calcular las recompensas para la cuenta del usuario.
Esa diferencia se agrega a un mapping de recompensas por usuario y se acumula ahí hasta que el usuario llama a getRewards(). Esta contabilidad adicional hace que el algoritmo de Synthetix sea menos eficiente.
El resto de las diferencias son bastante menores:
- MasterChef tiene
deposit()ywithdraw().- Synthetix tiene
stake(),withdraw(), ygetReward().
- Synthetix tiene
- MasterChef utiliza bloques como unidad de tiempo.
- Synthetix utiliza el timestamp (marca de tiempo).
- MasterChef se acuña recompensas a sí mismo como se describe en las secciones anteriores.
- Synthetix asume que el administrador ya ha transferido las recompensas al contrato y no acuña recompensas.
- MasterChef distribuye recompensas desde un
startBlockhasta unlastRewardBlockconfigurables.- Synthetix está programado de forma rígida (hardcoded) para distribuir recompensas a lo largo de una semana después de que el administrador inicia el reloj. Synthetix no necesariamente distribuirá el saldo completo de recompensas en el contrato, sino una cantidad especificada por el administrador.
- MasterChef transfiere la recompensa al usuario cada vez que llama a
deposit()owithdraw()con cantidades distintas de cero.- Synthetix acumula la recompensa debida al usuario en un mapping llamado rewards pero no la transfiere al usuario hasta que este llama explícitamente a
getRewards().
- Synthetix acumula la recompensa debida al usuario en un mapping llamado rewards pero no la transfiere al usuario hasta que este llama explícitamente a
- MasterChef admite múltiples pools dentro del mismo contrato y divide las recompensas por el peso del pool.
- Synthetix solo tiene un pool
El lector interesado puede consultar el código para el Staking de SushiSwap MasterChef.
Pseudocódigo para Synthetix
El siguiente gráfico muestra la subrutina de contabilidad de Synthetix que se llama durante deposit(), withdraw(), o getRewards(). Específicamente, se realiza antes de las actualizaciones de saldo en los depósitos o retiros, o en la distribución de recompensas.
En el gráfico a continuación, lastUpdateTime es la última vez que cualquier usuario llamó a una de las tres funciones. En el ejemplo de abajo, el usuario que reclama las recompensas no es el mismo que interactuó previamente con el contrato. El marcador primo (') significa el valor de la variable después de que la subrutina se completa.

El lector interesado puede consultar el código de staking de Synthetix por sí mismo.
Aprende más con RareSkills
Por favor, consulta nuestro bootcamp de blockchain para aprender temas técnicos más avanzados de Web3.
Publicado originalmente el 21 de noviembre de 2023