EIP-2612 Permit: Guía Práctica para Integrar en Smart Contracts y DApps

EIP-2612 Permit: Guía Práctica para Integrar en Smart Contracts y DApps
La Propuesta de Mejora de Ethereum (EIP) 2612 introduce una forma simplificada para que los usuarios interactúen con tokens en smart contracts de Ethereum usando una funcionalidad llamada "Permit". El Permit de EIP-2612 está construido sobre el estándar EIP-712, permitiendo a los usuarios autorizar transacciones de tokens con una sola firma, simplificando las interacciones dentro de aplicaciones descentralizadas (DApps).
Tradicionalmente, las transferencias de tokens en smart contracts requieren un proceso de dos pasos: primero, el propietario del token debe aprobar la transacción, luego inicia la transferencia. Este enfoque, aunque seguro, puede generar tarifas de gas adicionales y múltiples interacciones con la blockchain. EIP-2612 permite a los usuarios saltarse el paso típico de approve, reduciendo las tarifas de gas y mejorando la experiencia de usuario al simplificar el proceso.
En esta guía, exploraremos la funcionalidad Permit de EIP-2612, sus implicaciones de seguridad y cómo implementarla en tus smart contracts. Además, construiremos un contrato de token ERC-20 usando la librería de OpenZeppelin para mostrar un ejemplo práctico de EIP-2612 en acción.
¿Qué es EIP-2612?
Una de las razones principales detrás del éxito de los tokens ERC-20 (EIP-20) es el mecanismo approve y transferFrom, que permite que los tokens no solo se transfieran entre cuentas de propiedad externa (EOA), sino que se usen en otros contratos bajo condiciones específicas de la aplicación al abstraer msg.sender como mecanismo definitorio para el control de acceso a tokens. Sin embargo, este mecanismo requiere la aprobación explícita del propietario del token, ejecutada a través de su cuenta de propiedad externa (EOA). Este requisito de doble transacción (aprobación, seguido de transferFrom dentro de un contrato) no solo incurre en tarifas de gas adicionales sino que también exige que el usuario posea ETH para estas transacciones.
EIP-2612 extiende el estándar ERC-20, agregando una función permit que permite a los usuarios actualizar su asignación de tokens usando un mensaje firmado en lugar de requerir que envíen una transacción on-chain. Esto significa que los usuarios pueden autorizar transferencias firmando datos off-chain, pasando esa firma a un contrato y evitando la necesidad de la transacción approve.
Con EIP-2612, los usuarios pueden autorizar funciones "permit" a través de la estructura de firma signTypedData de EIP-712, permitiendo ahorros de gas mientras se preserva la funcionalidad del método tradicional approve.
Existe la opción de ahorrar GAS a través del enfoque clásico vía
approve— aprobaciones ilimitadas, lo que significa que solo necesitas pagar la tarifa de gas de la aprobación una vez. Esto puede ser un alto riesgo de seguridad, ya que la aplicación mantiene este acceso ilimitado indefinidamente.
Implementación Práctica
Para comenzar, escribiremos un smart contract de token ERC-20 usando las librerías de OpenZeppelin. Para quienes quieran obtener una versión lista de inmediato, pueden descargar el proyecto de este artículo en Github.
Implementación Básica
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract CollectableToken is ERC20 {
constructor()
ERC20("CollectableToken", "CT")
{
}
}
Nuestro smart contract básico está listo. Expandámoslo permitiendo acuñación ilimitada para cualquier usuario y estableciendo los decimales del token en 6.
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract CollectableToken is ERC20 {
constructor()
ERC20("CollectableToken", "CT")
{
//MINT a la dirección del creador.
_mint(msg.sender, 1000 * 10 ** decimals());
}
//MINT sin restricciones solo para el ejemplo.
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
function decimals() public pure override returns (uint8) {
return 6;
}
}
Todo lo que queda es agregar soporte para Permit según el estándar EIP-2612. Para esto, necesitamos incluir la librería ERC20Permit y agregar el constructor ERC20Permit("CollectableToken") — ¡y eso es todo! Bastante simple, ¿verdad?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract CollectableToken is ERC20, ERC20Permit {
constructor()
ERC20("CollectableToken", "CT")
ERC20Permit("CollectableToken")
{
//MINT a la dirección del creador.
_mint(msg.sender, 1000 * 10 ** decimals());
}
//MINT sin restricciones solo para el ejemplo.
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
function decimals() public pure override returns (uint8) {
return 6;
}
}
Estos pequeños pasos son todo lo que se requiere de los autores de smart contracts de tokens ERC-20 para agregar soporte para Permit según el estándar EIP-2612. OpenZeppelin y los autores del estándar se encargaron de las partes más desafiantes por nosotros — ¡muchas gracias a ellos por eso!
Ahora, escribamos un smart contract que usará este token vía Permit. Por ejemplo, creemos otro contrato ERC-20 donde acuñar tokens cuesta una cantidad específica de CollectableToken, y lo llamaremos YourNewToken para este ejemplo. Normalmente, un usuario primero necesitaría autorizar a YourNewToken para gastar sus fondos llamando a la función approve y firmando una transacción, luego llamar a la función mint en YourNewToken con una segunda transacción. Con Permit, sin embargo, el usuario solo necesitará hacer una transacción on-chain llamando a la función mint directamente.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract YourNewToken is ERC20, ERC20Permit {
address owner;
address cTokenAddress;
modifier onlyOwner() {
require(msg.sender == owner, "Not the contract owner");
_;
}
constructor(address collectableTokenAddress)
ERC20("YourNewToken", "YNT")
ERC20Permit("YourNewToken")
{
cTokenAddress = collectableTokenAddress;
owner = msg.sender;
}
function decimals() public pure override returns (uint8) {
return 6;
}
function withdrawCollectableToken(address recipient) public onlyOwner {
IERC20 token = IERC20(cTokenAddress);
uint256 contractBalance = token.balanceOf(address(this));
require(token.transfer(recipient, contractBalance), "Token transfer from YourNewToken failed");
}
}
Hemos creado un contrato ERC-20 estándar con soporte ERC20Permit, así como una función withdrawCollectableToken, que permite al propietario del contrato retirar tokens de este smart contract.
Por simplicidad, estamos usando al creador del contrato como administrador. En proyectos reales, si tu smart contract involucra acumulación y retiro de fondos, es ESENCIAL implementar funcionalidad para transferir derechos administrativos, ya que la billetera del administrador podría ser comprometida.
Implementemos la función mint con soporte de Permit:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
interface IERC20PermitWithTransfer {
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
function transferFrom(
address sender,
address recipient,
uint256 amount
) external returns (bool);
}
contract YourNewToken is ERC20, ERC20Permit {
address owner;
address cTokenAddress;
modifier onlyOwner() {
require(msg.sender == owner, "Not the contract owner");
_;
}
constructor(address collectableTokenAddress)
ERC20("YourNewToken", "YNT")
ERC20Permit("YourNewToken")
{
cTokenAddress = collectableTokenAddress;
owner = msg.sender;
}
function decimals() public pure override returns (uint8) {
return 6;
}
function mint(
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s,
address to,
uint256 amount
) public {
IERC20PermitWithTransfer cToken = IERC20PermitWithTransfer(cTokenAddress);
cToken.permit(msg.sender, address(this), amount, deadline, v, r, s);
require(cToken.transferFrom(msg.sender, address(this), amount), "Transfer CollectableToken failed");
_mint(to, amount);
}
function withdrawCollectableToken(address recipient) public onlyOwner {
IERC20 token = IERC20(cTokenAddress);
uint256 contractBalance = token.balanceOf(address(this));
require(token.transfer(recipient, contractBalance), "Token transfer from YourNewToken failed");
}
}
Para implementar la función mint, definimos la interfaz IERC20PermitWithTransfer con las funciones permit y transferFrom. La función mint en sí llama a permit con los parámetros de firma proporcionados, luego transfiere CollectableToken del llamador de la función al smart contract YourNewToken. Y eso es todo — bastante simple, ¿verdad?
Pruebas
Ahora que hemos escrito el código, es hora de probarlo. Para esto, usaremos Hardhat. Comencemos probando CollectableToken.
describe("CollectableToken", function () {
it("Deploy contract", async function () {
const ContractFactory = await ethers.getContractFactory("CollectableToken");
const initialOwner = (await ethers.getSigners())[0].address;
const instance = await ContractFactory.deploy();
await instance.waitForDeployment();
expect(await instance.name()).to.equal("CollectableToken");
});
it("Mint", async function () {
const ContractFactory = await ethers.getContractFactory("CollectableToken");
const initialOwner = (await ethers.getSigners())[0].address;
const instance = await ContractFactory.deploy();
await instance.waitForDeployment();
const amount = await instance.balanceOf(initialOwner);
await instance.mint(initialOwner, 1000);
const delta = await instance.balanceOf(initialOwner) - amount;
expect(delta).to.equal(1000);
});
});
Ahora probemos YourNewToken, incluyendo el despliegue y la funcionalidad de retiro de fondos:
describe("YourNewToken", function () {
it("Deploy contract", async function () {
const ContractFactory = await ethers.getContractFactory("YourNewToken");
const instance = await ContractFactory.deploy(
"0x0000000000000000000000000000000000000000"
);
await instance.waitForDeployment();
expect(await instance.name()).to.equal("YourNewToken");
});
it("Test withdraw", async function () {
const [initialOwner, user] = await ethers.getSigners();
const CollectableFactory = await ethers.getContractFactory("CollectableToken");
const instanceCollectable = await CollectableFactory.deploy();
await instanceCollectable.waitForDeployment();
const ContractFactory = await ethers.getContractFactory("YourNewToken");
const instance = await ContractFactory.deploy(
await instanceCollectable.getAddress()
);
await instance.waitForDeployment();
await instanceCollectable.mint(await instance.getAddress(), 100000);
await instance.withdrawCollectableToken(await user.getAddress());
expect(
await instanceCollectable.balanceOf(await user.getAddress())
).to.equal("100000");
});
});
Ahora viene la parte más importante — probar Permit. Esta prueba es crucial, ya que sirve como ejemplo clave de código para DApps:
it("Mint with Permit", async function () {
const [initialOwner] = await ethers.getSigners();
const CollectableFactory = await ethers.getContractFactory("CollectableToken");
const instanceCollectable = await CollectableFactory.deploy();
await instanceCollectable.waitForDeployment();
const ContractFactory = await ethers.getContractFactory("YourNewToken");
const instance = await ContractFactory.deploy(
await instanceCollectable.getAddress()
);
await instance.waitForDeployment();
const deadline = Math.floor(Date.now() / 1000) + 3600;
const nonce = await instanceCollectable.nonces(initialOwner.address);
const domain = {
name: await instanceCollectable.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: await instanceCollectable.getAddress(),
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const amount_ = 1000;
const value = {
owner: initialOwner.address,
spender: await instance.getAddress(),
value: amount_,
nonce: nonce,
deadline: deadline,
};
const signature = await initialOwner.signTypedData(domain, types, value);
const sign = ethers.Signature.from(signature);
await instance.mint(
deadline,
sign.v,
sign.r,
sign.s,
initialOwner.address,
amount_
);
expect(await instance.balanceOf(initialOwner.address)).to.equal(amount_);
});
El aspecto más importante es la formación del domain — basado en los datos del smart contract — y los values — basados en los datos de la transacción. ¡Presta mucha atención a esto!
Consideraciones de Seguridad para EIP-2612 Permit
ERC-20 Permit protege del riesgo de ataques de repetición usando un sistema de nonce. Una vez que una firma es verificada y aprobada, el nonce incrementa, previniendo que firmas idénticas sean reutilizadas.
Sin embargo, debido a que las transacciones permanecen en el mempool antes de la ejecución, un atacante podría interceptar la firma y usarla para ejecutar la función permit antes de que la transacción del usuario sea procesada. Dado que esta es una firma válida, el token la acepta e incrementa el nonce, causando que la transacción del usuario falle.
Aunque esto no compromete los fondos, puede interrumpir la experiencia del usuario. Para abordar este riesgo, los desarrolladores deberían considerar implementar mecanismos de respaldo o advertencias para los usuarios en transacciones de alto valor.
Conclusión
EIP-2612 proporciona a los desarrolladores de Solidity una forma poderosa de simplificar las aprobaciones y transferencias de tokens mientras reduce las tarifas de gas y mejora la funcionalidad de las DApps. Los ejemplos proporcionados aquí muestran lo simple que puede ser agregar funcionalidad Permit a los tokens ERC-20.
Para los desarrolladores de DApps, integrar Permit puede mejorar significativamente la experiencia del usuario y potencialmente ahorrar costos de transacción, especialmente en redes con altas tarifas de gas como Ethereum. Sin embargo, es importante mantener el método tradicional approve como respaldo e incorporar medidas de seguridad contra el potencial front-running.
El código completo está disponible en Github.
Enlaces
- Martin Lundfall, "ERC-2612: Permit Extension for EIP-20 Signed Approvals," Ethereum Improvement Proposals, no. 2612, abril 2020.
- Remco Bloemen, Leonid Logvinov, Jacob Evans, "EIP-712: Typed structured data hashing and signing," Ethereum Improvement Proposals, no. 712, septiembre 2017.
- OpenZeppelin
- Ejemplo en Github