ERC20 Snapshot resuelve el problema del doble voto.
Si los votos se ponderan por la cantidad de tokens que alguien posee, entonces un actor malintencionado puede usar sus tokens para votar, luego transferir los tokens a otra dirección, votar con ellos, y así sucesivamente. Si cada dirección es un contrato inteligente (smart contract), entonces el hacker puede realizar todos estos votos en una sola transacción. Un ataque relacionado consiste en usar un flash loan para obtener un montón de tokens de gobernanza, votar y luego devolver el flash loan.
Existe un problema similar al reclamar airdrops. Alguien podría usar sus tokens ERC20 para reclamar un airdrop, luego transferir sus tokens a otra dirección y reclamar el airdrop nuevamente.
Fundamentalmente, ERC20 Snapshot proporciona un mecanismo para defenderse de los usuarios que transfieren tokens y reutilizan la utilidad de los mismos en la misma transacción.
Al principio, la creación de snapshots podría parecer un problema intratable. La solución de fuerza bruta, o ingenua, para esto es iterar a través de cada dirección en el mapping “balances” de ERC20, para luego copiarlas a otro mapping. No es posible iterar de forma nativa a través de un mapping en ERC20, por lo que el programador tendría que usar un enumerable map: un map con un array que rastrea todas las claves.
Como uno puede imaginar, esta operación O(n) consumiría una cantidad extrema de gas.
Hay un dicho en informática que dice que “Todo problema en la informática se puede resolver con otro nivel de indirección”, y así es como lo resuelve ERC20 Snapshot.
Solución eficiente pero ingenua
Tomemos el ejemplo del mapping balances.
Aquí hay una solución con errores en solidity, pero es un paso en la dirección correcta.
balances[snapshotNumber][user]
En este caso, snapshotNumber es un contador que comienza en cero y se incrementa en uno cada vez que se realiza un snapshot.
Volviendo a nuestro ejemplo de votación, creamos un snapshot en un momento determinado, dejamos que todos sigan con sus asuntos y luego creamos otro snapshot. En el momento de la votación, usamos el snapshot anterior, ya que el snapshot actual aún puede modificarse mediante la transferencia de tokens.
De esta manera, podemos consultar el saldo de alguien suministrando tanto el snapshotNumber como su dirección en el snapshot que nos interesa. Dado que conocemos el snapshot actual, balanceOf es simplemente los balances en el snapshot más reciente.
¡Ah, pero hay un problema! ¡Cada vez que hacemos un snapshot, los saldos de todos se establecen en cero! Es posible resolver esto con un poco de contabilidad: simplemente rastreando el último snapshot en el que el usuario realizó transacciones, pero esto se complica rápidamente a medida que el ingeniero intenta cubrir todos los casos extremos (corner cases).
Solución de OpenZeppelin
Así es como lo logra OpenZeppelin. código
Cada balance almacena un struct
struct Snapshots {
uint256[] ids;
uint256[] values;
}
mapping(address => Snapshots) private _accountBalanceSnapshots;
function balanceOfAt(address account, uint256 snapshotId) public view virtual returns (uint256) {
(bool snapshotted, uint256 value) = _valueAt(snapshotId, _accountBalanceSnapshots[account]);
return snapshotted ? value : balanceOf(account);
}
Dentro de los balances de los usuarios, almacenamos un struct que tiene un array de ids y values. El array de ids es un id de snapshot monótonamente creciente, y los values son el saldo cuando ese id era el snapshot activo.
Tomar un snapshot
Aquí está la función del snapshot. Simplemente incrementa el id del snapshot actual.
function _snapshot() internal virtual returns (uint256) {
_currentSnapshotId.increment();
uint256 currentId = _getCurrentSnapshotId();
emit Snapshot(currentId);
return currentId;
}
Cuando un usuario realiza una transferencia en el nuevo snapshot, se llama al hook _beforeTokenTransfer, el cual tiene el siguiente código.
Tanto para el receptor como para el remitente se llama a _updateAccountSnapshot.
// Update balance and/or total supply snapshots before the values are modified. This is implemented
// in the _beforeTokenTransfer hook, which is executed for _mint, _burn, and _transfer operations.
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
super._beforeTokenTransfer(from, to, amount);
if (from == address(0)) {
// mint
_updateAccountSnapshot(to);
_updateTotalSupplySnapshot();
} else if (to == address(0)) {
// burn
_updateAccountSnapshot(from);
_updateTotalSupplySnapshot();
} else {
// transfer
_updateAccountSnapshot(from);
_updateAccountSnapshot(to);
}
}
Esta es la función _updateAccountSnapshot que es llamada
function _updateAccountSnapshot(address account) private {
_updateSnapshot(_accountBalanceSnapshots[account], balanceOf(account));
}
La cual a su vez llama a _updateSnapshot . La definición está a continuación
function _updateSnapshot(Snapshots storage snapshots, uint256 currentValue) private {
uint256 currentId = _getCurrentSnapshotId();
if (_lastSnapshotId(snapshots.ids) < currentId) {
snapshots.ids.push(currentId);
snapshots.values.push(currentValue);
}
}
Debido a que el currentId acaba de ser incrementado, la declaración if será verdadera. Dentro del array de snapshots, se adjuntará el saldo actual. Como esto fue llamado en el hook _beforeTokenTransfer, refleja el saldo antes de que sea modificado.
Por lo tanto, una vez que el ID del snapshot aumenta, cualquier transferencia que ocurra después de la transacción del snapshot almacenará los balances antes de que se lleve a cabo la transacción y los guardará en el array. Esto efectivamente “congela” el saldo actual de todos porque cualquier transferencia que ocurra después del snapshot hace que se almacene el valor “antiguo”. ¿Qué sucede si ocurren dos snapshots, pero una dirección no realiza transacciones durante esos snapshots? En ese caso, los ids de los snapshots no serán contiguos.
Debido a esto, no podemos acceder al saldo de una cuenta en un snapshot haciendo “ids[snapshotId]”. En su lugar, se utiliza una búsqueda binaria para encontrar el id del snapshot que solicita el usuario. Si no se encuentra el id, entonces usamos el valor del snapshot adyacente anterior. Por ejemplo, si queremos saber el saldo de un usuario en el snapshot 5, pero no transfirió tokens durante los snapshots 3 y 4, buscaríamos en el snapshot 2.
El total supply se rastrea de la misma manera
El lector puede notar que el struct Snapshots tiene nombres de variables aparentemente demasiado genéricos, como ids y values. ¿No debería simplemente llamarse “balance” para ser más preciso?
ERC20 Snapshot realiza un seguimiento del total supply usando la misma estrategia, por lo que los nombres de las variables capturan el hecho de que el mismo struct se usa tanto para rastrear los balances de los usuarios como el total supply.
Solo mint y burn cambian el total supply, por lo que cuando se invocan estas funciones, se verifica el struct que almacena el total supply para ver si el snapshot ha cambiado antes de actualizar estos valores.
Tenga en cuenta que no se crean snapshots de los valores históricos de allowance.
Costo adicional de gas
Las transferencias regulares son más costosas porque verificamos si el último id en los ids del usuario coincide con el snapshot actual y agregamos un nuevo id si ese no es el caso. Agregar al array de ids y values incurrirá en dos SSTOREs adicionales. Cuando ocurre un nuevo snapshot, la primera transferencia desde o hacia una dirección será más costosa. Pero la segunda transacción costará aproximadamente lo mismo que una transferencia en un token ERC20 regular.
Ser hackeado
Si alguien solicita un flash loan y crea un snapshot en la misma transacción, puede inflar artificialmente su poder de voto. Si los tokens se pueden tomar prestados a una tasa de interés baja, y un atacante sabe cuándo ocurrirá el próximo snapshot, puede tomar tokens prestados justo antes del snapshot para lograr algo similar. Sin embargo, un flash loan no será una forma viable de inflar el poder de voto, ya que necesitarán que el saldo se mantenga alto durante una transacción de snapshot separada.
Conteo de votos
Esto es simplemente el balance de una dirección dividido por el total supply, todo en un snapshot en particular.
Publicado originalmente el 22 de feb. de 2023