WEB3DEV

Cover image for Como criar um Programa de Fidelidade usando Meta-transações
Banana Labs
Banana Labs

Posted on

Como criar um Programa de Fidelidade usando Meta-transações

E se os usuários pudessem ser recompensados por transações e não pagar a taxa de gás? Esse tutorial vai te mostrar como!

Quão legal seria se os usuários do seu dApp não precisassem pagar gás pelas transações? E se eles recebessem recompensas em vez disso?

Adoçãoooo-em-Maaassaaaaaaaaaa!

Aqui está o que vamos cobrir

✅ O que são Meta-Transações?
✅ Por que precisamos do ERC20Permit?
✅ Configurando o Projeto
✅ Implementando o Token do tipo ERC20Permit
✅ Implementando o Token de Recompensa de Fidelidade
✅ Implementação do Contrato do Programa de Fidelidade
✅ Testando os Contratos

O que são Meta-Transações?

Ao fazer transações em redes blockchain como a Celo, você encontrará taxas de gás. As taxas de gás são usadas para evitar que a rede seja DDosed (negação de serviço distribuída). Se não houvesse taxas de gás, os usuários mal-intencionados poderiam enviar spam à rede com transações, resultando em uma negação de serviço para os usuários legítimos.

Assim, as taxas de gás fazem sentido. Mas quando for a sua primeira vez desenvolvendo em uma rede, você não terá os tokens disponíveis para experimentar dApps e pagar pelo gás. É aí que entram as meta-transações.

meta-transações

Uma Meta-transação é um processo onde o usuário, ao invés de pagar uma taxa de gás, encaminha sua transação para um terceiro. Esse terceiro, um retransmissor, pagará a taxa de gás pela transação. Tudo isso é possível usando assinaturas.

Ao usar assinaturas no blockchain, você assina uma mensagem que especifica o contrato, a função e os parâmetros que você deseja passar usando sua chave privada. Uma vez assinado, você recebe uma assinatura que pode ser passada para qualquer pessoa que esteja disposta a pagar suas taxas de gás. Como as taxas de gás são cobradas do transmissor da transação, não importa quem é o retransmissor.

Tudo isso está sujeito ao fato de que o contrato suporta a chamada das funções especificadas por assinaturas!

Você deve ter um bom entendimento do que são meta-transações. Em seguida, analisaremos o ERC20Permit.

Nota: Se você não estiver familiarizado com o ERc20Permit, você pode saber mais neste link

Por que precisamos do ERC20Permit?

Ao visualizar a implementação do ERC20, você notará que, para que alguém transfira o ERC20 em seu nome, o valor da transferência precisa ser aprovado primeiro.

O programa de fidelidade que você implementará pressupõe que o usuário não possui tokens de gás para pagar as taxas de gás. Isso significa que um terceiro estará transferindo tokens em nome do usuário.

Mas para que um terceiro execute essa ação, ela precisa primeiro de aprovação. Para começar, dê uma olhada na implementação do ERC20 para aprovação.

ERC20 Aprovação Implementação ERC20 de Aprovação

approve assume que o chamador desta função é aquele que deseja aprovar um gastador (spender) para gastar uma quantidade (amount) de tokens.

Aprovação Primeiro aprove e depois o gastador pode gastar.

Como você pode ver acima, approve precisa ser chamada pelo próprio usuário e isso exigirá gás.

Mas, como mencionado nas meta-transações, o remetente é um retransmissor. O usuário, descrito acima, aprovará os tokens do retransmissor. Nós não queremos isso.

Para resolver isso, o ERC20Permit foi introduzido. ERC20Permit permite a aprovação por meio de assinaturas. Aqui está como ele parece.

ERC20permit Implementação do ERC20Permit

exemplo

O pagador (payer) (usuário) assina uma mensagem e envia a mensagem para o fornecedor (vendor) (relayer) por meio de um mecanismo off-chain. Pode ser qualquer API ou até mesmo uma mensagem de texto que funcione, não há necessidade de mantê-la privada.

v, r e s podem ser extraídos a partir da assinatura.

A função permit leva os detalhes para a aprovação e o v, r e s que são partes que permitem a recuperação do endereço público do signatário.

Usando esta função, podemos aprovar a transferência de tokens em seu nome e também realizar a transferência na mesma transação!

Neste ponto, você tem todas as informações necessárias para implementar o contrato do programa de fidelidade. Agora, vamos configurar o projeto e começar a codificar!

Programa de fidelidade Arquitetura do Programa de Fidelidade

Configurando o Projeto

Todo o código que você está prestes a escrever está disponível aqui para referência.

  • Crie uma pasta para seu projeto e abra um terminal no mesmo local
  • Execute o comando abaixo para obter um modelo inicial do projeto

npx hardhat init

Você deve obter a seguinte saída:

Hardhat projeto

  • Para este tutorial, selecione Create a Javascript project
  • Você pode ser solicitado a instalar dependências adicionais, se sim, digite Y e pressione Enter
  • Depois de concluído, você deve ter uma estrutura de projeto como esta (excluindo a pasta node_modules)

estrutura dos diretórios

Você pode remover o contrato inicial do Lock.sol da pasta /contracts, pois não é necessário.

É isso para configurar o projeto!

Implementando o Token do tipo ERC20Permit

Agora você pode implementar o token ERC20Permit. Para tornar o dApp realmente sem necessidade de gás, você precisará usar o token do tipo ERC20Permit para transações.

Felizmente, o OpenZeppelin facilita isso. O OpenZeppelin já fez a implementação para você e tudo que você precisa fazer é herdá-lo.

Aqui está o código para o token ERC20Permit:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";

/**
    @notice Token contract which can be used as payment without paying for gas, uses Permit for approve via signatures 
    @author Harpalsinh Jadeja
    @title Token
 */
contract Token is ERC20, ERC20Permit {
    constructor() ERC20Permit("Payment Token") ERC20("Payment Token", "PAY") {
        _mint(msg.sender, 500 * 10**18);
    }
}
Enter fullscreen mode Exit fullscreen mode

Você começa com um construtor no qual fornece o nome (name) do token ERC20Permit e o mesmo nome deve ser fornecido ao ERC20, junto com o símbolo (symbol).

Dentro do construtor, você está cunhando 500 tokens para o implantador, para que alguém possa ter esses tokens. Estes podem então ser repassados ou usados como pagamento por serviços.

Por que herdar ERC20 e ERC20Permit?

Como o ERC20Permit é um contrato abstrato - o que significa que não implementa todas as funcionalidades do ERC20 - apenas as partes da permissão.

Vamos falar brevemente sobre as diferenças entre ERC20 e ERC20Permit. A função permit possibilita que os usuários aprovem transferências por meio de assinaturas.

Existem algumas funções auxiliares que controlam o nonce, para evitar que assinaturas inválidas sejam repetidas. Por exemplo, isso pode acontecer se um fornecedor tentar reivindicar o mesmo valor duas vezes usando a mesma assinatura 😅

A fim de proteger a reutilização, um nonce é usado.

Há mais 2 coisas importantes DOMAIN_TYPEHASH e STRUCT_TYPEHASH

Eles estão relacionados ao EIP712, que especifica um padrão de como as assinaturas devem ser geradas para fornecer uma melhor UX para usuários de carteira e provedores de RPC.

Por que tudo isso? Usando um padrão para assinatura de mensagens, provedores de carteiras e RPC podem fornecer uma melhor UX para seus usuários, indicando o que está sendo assinado pelo usuário.

Metamask

O STRUCT_TYPEHASH é usado para definir a estrutura na qual os dados são codificados e ajuda o contrato e os signatários off-chain a entender como codificar os parâmetros a serem passados para a função.

Nota: Neste contexto, STRUCT_TYPEHASH é _PERMIT_TYPEHASH. Você pode conferir aqui

O DOMAIN_TYPEHASH é gerado usando outro contrato OpenZeppelin chamado EIP712.sol

A função de DOMAIN_TYPEHASH é dar suporte ao STRUCT_TYPEHASH especificando o endereço do contrato que irá verificar a assinatura, o nome do contrato, o chainid dependendo de qual chain este contrato está implantado e a versão do contrato.

Por que?

Especificar o endereço do contrato permite que apenas o endereço do contrato especificado verifique a assinatura fornecida.

  • O chainid é extremamente importante. Não queremos que um contrato com o mesmo endereço de contrato em outra cadeia possa verificar a assinatura, realizando ações em outras cadeias.
  • A versão version é usada caso seu contrato seja atualizável. Você pode especificar qual versão do seu contrato a assinatura deve ser verificável.

Tudo isso é para evitar a reutilização e repetição da assinatura fornecida pelo pagador ao fornecedor.

Leia mais sobre ataques de repetição aqui.

Você já deve ter uma compreensão bastante razoável de por que o ERC20Permit está sendo usado neste momento. Agora vamos implementar o token de recompensa de fidelidade!

Implementando o Token de Recompensa de Fidelidade

O código para o Token de Fidelidade está abaixo:

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";
import "./Token.sol";

/**
    @notice token contract which will act as the rewards for the program.
    @author Harpalsinh Jadeja
    @title LoyaltyToken
 */
contract LoyaltyToken is ERC20 {
    address loyaltyProgram;

    constructor() ERC20("Loyalty Token", "LOYAL") {
        loyaltyProgram = msg.sender;
    }

    modifier onlyProgram() {
        require(
            msg.sender == loyaltyProgram,
            "Only Loyalty Program contract can reward users"
        );
        _;
    }

    /**
        @notice function that rewards the user by minting more reward tokens
        @param user the address of user to be rewarded
        @param amount the amount of the reward tokens to be minted
     */
    function rewardUser(address user, uint256 amount) external onlyProgram {
        _mint(user, amount);
        emit Rewarded(user, amount);
    }

    event Rewarded(address indexed user, uint256 indexed amount);
}
Enter fullscreen mode Exit fullscreen mode

O Token de Fidelidade é um token ERC20 padrão com a funcionalidade adicional de recompensar os usuários.

  • Primeiro, você tem o construtor, estamos chamando-o de construtor ERC20 e passando o nome (name) e o símbolo (symbol)
  • Dentro do construtor, você atribui o msg.sender como o endereço do loyaltyProgram para que possamos acompanhá-lo
  • Em seguida, você tem um modificador modifier onlyProgram que nos permite marcar funções que só podem ser chamadas pelo nosso endereço do loyaltyProgram. Não queremos que ninguém faça uma chamada para rewardUser e se recompense 😅
  • A function rewardUser é bastante simples, basta cunhar mint a quantidade amount de tokens fornecida ao usuário e emitir emit um evento chamado Rewarded para serviços off-chain em seu frontend. Observe que a taxa de recompensa é de 10% do valor do pagamento.
  • A função é marcada como externa external tornando-a disponível para ser chamada por qualquer pessoa, mas só será executada quando chamada pelo loyaltyProgram, pois temos um modificador onlyProgram aplicado a ela.
  • Por fim, definimos o evento Recompensado Rewarded sobre o que deve ser indexado indexed

Isso é tudo para o token de fidelidade.

Agora você pode implementar a parte mais interessante, o contrato LoyaltyProgram!

Implementação do Contrato do Programa de Fidelidade

Segue o código para o contrato LoyaltyProgram.sol,

pragma solidity ^0.8.0;

import "./LoyaltyToken.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "./Token.sol";

/**
@notice This contract is the responsible for rewarding users, and acts as a middleman during a transaction between payer and vendor.
@author Harpalsinh Jadeja
@title LoyaltyProgram
 */
contract LoyaltyProgram {
    LoyaltyToken public immutable loyaltyToken;
    Token public immutable token;

    mapping(address => bool) public isVendorRegistered;

    constructor(Token _token) {
        loyaltyToken = new LoyaltyToken();
        token = _token;
    }

    /**
        @notice function to register vendor, if already registered reverts.
     */
    function registerVendor() external {
        require(!isVendorRegistered[msg.sender], "VENDOR_ALREADY_REGISTERED");
        isVendorRegistered[msg.sender] = true;
        emit VendorRegistered(msg.sender);
    }

    /**
        @notice function to make transactions via vendor relayer, permit is called on the respective token and the transfer is made along with rewarding the user.
        @param payer the person paying for goods & services
        @param amount the amount involved in the transaction
        @param deadline the time by which the signed transaction needs to utilized
    */
    function payViaSignature(
        address payer,
        uint256 amount,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        require(isVendorRegistered[msg.sender], "ONLY_VENDORS_CAN_RELAY");
        token.permit(payer, address(this), amount, deadline, v, r, s);
        token.transferFrom(payer, msg.sender, amount);
        loyaltyToken.rewardUser(payer, (amount * 10) / 100);
        emit Transaction(payer, msg.sender, amount);
        emit Rewarded(payer, (amount * 10) / 100);
    }

    event VendorRegistered(address indexed vendor);
    event Transaction(
        address indexed payer,
        address indexed vendor,
        uint256 indexed amount
    );
    event Rewarded(address indexed payer, uint256 indexed amount);
}
Enter fullscreen mode Exit fullscreen mode
  • Primeiro, você tem 2 variáveis loyaltyToken e token monitorando o token que atua como recompensa e aquele que atua como token de pagamento.
  • Você tem um mapping chamado isVendorRegistered, que rastreia os endereços que são fornecedores cadastrados que podem receber o pagamento por meio deste contrato e também retransmitir transações para seus respectivos pagadores.
  • No construtor, você está implantando o loyaltyToken, tornando este contrato capaz de chamar rewardUser no loyaltyToken. O construtor também recebe o endereço do token que atua como o token de pagamento.
  • function registerVendor() permite que qualquer endereço se registre como fornecedor no loyaltyProgram para receber o pagamento e poder retransmitir transações. Verifica se o fornecedor já está cadastrado e se não estiver, reverte.
  • function payViaSignature() esta é a função principal que faz a maior parte do trabalho. Esta função recebe payer, amount, deadline, v, r & s

Vamos um por um:

  • payer — O endereço do usuário que vai efetuar o pagamento
  • amount — O valor a ser transferido para o fornecedor neste pagamento
  • deadline — Esta assinatura pode ser configurada para expirar após um determinado prazo e se torna inútil
  • v — Um fator de recuperação, isso está relacionado a curvas elípticas onde o ponto pode estar no eixo +ve y ou no eixo -ve y esse fator v ajuda a escolher o caminho certo
  • r,s — Esses 2 fatores são usados para recuperar o endereço público que assinou a assinatura

Você pode ler mais sobre v, r & s aqui

Na primeira linha da função, você verifica se o remetente da transação é um fornecedor vendor cadastrado ou não. Lembre-se de que o fornecedor vendor é o remetente da transação, pois é uma meta-transação. Se o remetente não estiver registrado, ela será revertida.

  • A função então chama permit no token de pagamento para obter a aprovação para transferir o token em nome do pagador payer para o fornecedor vendor
  • A próxima linha transfere o token do pagador payer para o fornecedor vendor
  • Assim que a transferência for concluída, agora recompensamos o usuário chamando rewardUser em loyaltyToken, que está sendo chamado no contexto do loyaltyProgram, portanto, é permitido.
  • Por fim, você emite os eventos Transaction e Rewarded para indicar transferência bem-sucedida e entrega de recompensa para aplicativos fora da cadeia (off-chain) e frontends.

Por que especificar payer, amount & deadline ?

Assinaturas são hashes, que são irreversíveis, então, em vez de reverter o hash, nós o recriamos na cadeia e comparamos a saída. Se a saída for a mesma, a assinatura é verificada. Se não, é inválida. Também é usado para definir os eventos no contrato.

Neste ponto, você concluiu a implementação dos contratos que compõem o programa de Fidelidade. Abaixo tenho alguns testes preparados.. Isso ajudará você a descobrir se implementamos os contratos conforme o requisito.

Vamos dar uma olhada nos testes.

Testando os Contratos

Este é o código que disponibilizei para teste. Sinta-se à vontade para adicionar mais ao seu próprio repositório.

const { ethers } = require("hardhat");
const { utils } = ethers;
const { expect } = require("chai");

function parseEther(amount) {
    return utils.parseEther(amount);
}

// function to generate signature
async function getPermitSignature(signer, token, spender, value, deadline) {
    const [nonce, name, version, chainId] = await Promise.all([
        token.nonces(signer.address),
        token.name(),
        "1",
        signer.getChainId(),
    ]);

    return utils.splitSignature(
        await signer._signTypedData(
            {
                name,
                version,
                chainId,
                verifyingContract: token.address,
            },
            {
                Permit: [
                    {
                        name: "owner",
                        type: "address",
                    },
                    {
                        name: "spender",
                        type: "address",
                    },
                    {
                        name: "value",
                        type: "uint256",
                    },
                    {
                        name: "nonce",
                        type: "uint256",
                    },
                    {
                        name: "deadline",
                        type: "uint256",
                    },
                ],
            },
            {
                owner: signer.address,
                spender,
                value,
                nonce,
                deadline,
            }
        )
    );
}

let Token, token;

let LoyaltyProgram, loyaltyProgram, loyaltyTokenAddress, loyaltyToken;

let deployer, payer, vendor, invalidVendor;

describe("Loyalty Program", () => {
    before(async () => {
        [deployer, payer, vendor, invalidVendor] = await ethers.getSigners();

        Token = await ethers.getContractFactory("Token");
        token = await Token.deploy();
        await token.deployed();

        // the deployer has the token that can be used as payment so giving out some to the payer who will later pay for goods & services and receive loyalty rewards
        await token.transfer(payer.address, parseEther("50"));

        LoyaltyProgram = await ethers.getContractFactory("LoyaltyProgram");
        loyaltyProgram = await LoyaltyProgram.deploy(token.address);
        await loyaltyProgram.deployed();

        loyaltyTokenAddress = await loyaltyProgram.loyaltyToken();
        loyaltyToken = await ethers.getContractAt(
            "LoyaltyToken",
            loyaltyTokenAddress
        );
    });

    describe("Deployment", async () => {
        it("On Deployment Payer should have some tokens to pay for services", async () => {
            expect(await token.balanceOf(payer.address)).to.be.eq(
                parseEther("50")
            );
        });

        // any vendor should be able to register.
        it("Vendor should be able to register", async () => {
            await loyaltyProgram.connect(vendor).registerVendor();
            expect(await loyaltyProgram.isVendorRegistered(vendor.address)).to
                .be.true;
        });
    });

    describe("After Vendor Registrated", async () => {
        // Payer is paying 10 tokens and expect 1 loyaltyToken consider 10% loyalty rewards.
        it("Payer should be able to relay payment transaction to Vendor and in return receive 10% reward", async () => {
            const amount = parseEther("10");
            const deadline = ethers.constants.MaxUint256;
            const { v, r, s } = await getPermitSignature(
                payer,
                token,
                loyaltyProgram.address,
                amount,
                deadline
            );

            await loyaltyProgram
                .connect(vendor)
                .payViaSignature(payer.address, amount, deadline, v, r, s);

            expect(await token.balanceOf(payer.address)).to.be.eq(
                parseEther("40")
            );
            expect(await token.balanceOf(vendor.address)).to.be.eq(
                parseEther("10")
            );
            expect(await loyaltyToken.balanceOf(payer.address)).to.be.eq(
                parseEther("1")
            );
        });

        // The entity relaying the transaction is not a registered vendor.
        it("Invalid Vendor trying to relay transaction", async () => {
            const amount = parseEther("10");
            const deadline = ethers.constants.MaxUint256;
            const { v, r, s } = await getPermitSignature(
                payer,
                token,
                loyaltyProgram.address,
                amount,
                deadline
            );

            await expect(
                loyaltyProgram
                    .connect(invalidVendor)
                    .payViaSignature(payer.address, amount, deadline, v, r, s)
            ).to.be.revertedWith("ONLY_VENDORS_CAN_RELAY");
        });

        // Vendor is trying to get payment from someone other than the payer.
        it("Valid vendor passing invalid payer address", async () => {
            const amount = parseEther("10");
            const deadline = ethers.constants.MaxUint256;
            const { v, r, s } = await getPermitSignature(
                payer,
                token,
                loyaltyProgram.address,
                amount,
                deadline
            );

            await expect(
                loyaltyProgram
                    .connect(vendor)
                    .payViaSignature(
                        deployer.address,
                        amount,
                        deadline,
                        v,
                        r,
                        s
                    )
            ).to.be.revertedWith("ERC20Permit: invalid signature");
        });

        // Vendor is trying to get more payment then the payer has signed for.
        it("Vendor passing invalid amount", async () => {
            const amount = parseEther("10");
            const deadline = ethers.constants.MaxUint256;
            const { v, r, s } = await getPermitSignature(
                payer,
                token,
                loyaltyProgram.address,
                amount,
                deadline
            );

            await expect(
                loyaltyProgram
                    .connect(vendor)
                    .payViaSignature(
                        deployer.address,
                        parseEther("11"),
                        deadline,
                        v,
                        r,
                        s
                    )
            ).to.be.revertedWith("ERC20Permit: invalid signature");
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

O que esse arquivo faz?

  • Para verificar se o pagador payer está apto a pagar o fornecedor assinando uma mensagem no formato EIP712 e o vendedor pode enviar isso ao contrato loyaltyProgram para realizar o pagamento e recompensar o pagador
  • Para verificar se a transação é revertida quando o fornecedor vendor tenta solicitar um valor maior do que deveria receber
  • Para verificar se o fornecedor vendor não está solicitando valor de algum outro pagador/usuário usando a mesma assinatura

Este arquivo deve estar dentro da pasta /test.

Para executar esses testes use o seguinte comando no seu terminal

npx hardhat test

Se você obtiver a seguinte saída, tudo está funcionando bem!

output

Isenção de responsabilidade: Esta implementação não deve ser assumida como segura, pois está sendo usada para os propósitos do tutorial. Eu não recomendo implementá-la e auditá-la antes de ir para a rede principal.

Modificando seu programa de fidelidade

Isso é tudo o que é preciso para implementar um Programa de Fidelidade básico! Se você deseja estender seu programa de fidelidade, há muitas outras modificações que você pode fazer.

Aqui estão algumas ideias que você pode pegar e realizar no programa de fidelidade.

  • Implemente-o de forma que você possa modificar a porcentagem de recompensa
  • Implemente um mecanismo de pausa onde o organizador do programa possa suspender o programa de recompensas (por um determinado período de tempo ou completamente)
  • A implementação atual suporta apenas um único token de pagamento, tente implementá-lo de uma forma que suporte mais tokens compatíveis com ERC20Permit como pagamento

Referências

Contato

Caso precise de ajuda entre em contato:
twitter | harpaljadeja.eth#2927 no discord.


Esse artigo é uma tradução de Harpal Jardeja feita por @bananlabs. Você pode encontrar o artigo original aqui

Top comments (0)