Verify smart contract request with multi-signature approach

First, create a mintable token

// SPDX-License-Identifier: MIT
pragma solidity 0.8.4;

interface IMintable {
  function mint(address account, uint256 amount) external;
}

ERC20Mintable

// SPDX-License-Identifier: MIT
// @nhancv
pragma solidity 0.8.4;

import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "./ERC20Token.sol";
import "./interfaces/IMintable.sol";

contract ERC20Mintable is ERC20Token, IMintable, AccessControlUpgradeable {
  bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

  /**
   * @dev Upgradable initializer
   */
  function __ERC20Mintable_init(
    string memory name_,
    string memory symbol_,
    uint8 decimals_,
    uint256 initialSupply_,
    address minter_
  ) public initializer {
    __ERC20Token_init(name_, symbol_, decimals_, initialSupply_);
    _setupRole(DEFAULT_ADMIN_ROLE, _msgSender());
    _setupRole(MINTER_ROLE, minter_);
  }

  /**
   * @dev Mint tokens.
   */
  function mint(address _to, uint256 _amount) public override onlyRole(MINTER_ROLE) {
    _mint(_to, _amount);
  }
}

Create EIP712 multi-signature

MultiSigEIP712

// SPDX-License-Identifier: MIT
// @nhancv
pragma solidity ^0.8.4;

import "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol";
import "./interfaces/IMintable.sol";

contract MultiSigEIP712 is EIP712Upgradeable, OwnableUpgradeable, AccessControlUpgradeable, ReentrancyGuardUpgradeable {
  // A valid validator must has VALIDATOR_ROLE
  bytes32 public constant VALIDATOR_ROLE = keccak256("VALIDATOR_ROLE");
  // A valid request requires at least validatorsRequired
  uint256 public validatorsRequired;

  // Request entries
  struct ProcessedEntry {
    uint256 processId;
    address user;
    address token;
    uint256 amount;
  }

  uint256 public depositIndex;
  mapping(uint256 => ProcessedEntry) public depositEntries;
  mapping(uint256 => ProcessedEntry) public withdrawalEntries;

  // Events
  event TokenDeposited(address _user, uint256 _depositId, address _token, uint256 _amount);
  event TokenWithdrawn(address _user, uint256 _withdrawId, address _token, uint256 _amount);

  /**
   * @dev Upgradable initializer
   */
  function __MultiSigEIP712_init() public initializer {
    __Ownable_init();
    __AccessControl_init();
    __ReentrancyGuard_init();
    __EIP712_init("MultiSig", "1.0.0");
    _setupRole(DEFAULT_ADMIN_ROLE, _msgSender());
    validatorsRequired = 2;
  }

  /**
   * @dev Deposit token
   * @param _token token address
   * @param _amount token amount
   */
  function depositToken(address _token, uint256 _amount) external nonReentrant {
    require(depositEntries[depositIndex].amount == 0, "Invalid id");
    depositEntries[depositIndex] = ProcessedEntry(depositIndex, _msgSender(), _token, _amount);
    depositIndex++;

    require(IERC20Upgradeable(_token).transferFrom(_msgSender(), address(this), _amount), "Transfer from failed");
    // Log and Event
    emit TokenDeposited(_msgSender(), depositIndex - 1, _token, _amount);
  }

  /**
   * @dev Withdraw token
   * @param _withdrawId withdraw id request
   * @param _token token address
   * @param _amount amount to withdraw
   * @param _signatures signature list of validators
   */
  function withdrawToken(
    uint256 _withdrawId,
    address _token,
    uint256 _amount,
    bytes[] calldata _signatures
  ) external nonReentrant {
    require(withdrawalEntries[_withdrawId].amount == 0, "Invalid id");
    // Authentication
    uint256 validValidator_ = 0;
    address lastSigner_ = address(0);
    for (uint256 i = 0; i < _signatures.length; i++) {
      address signer_ = validateSignature(_withdrawId, _token, _amount, _signatures[i]);
      require(hasRole(VALIDATOR_ROLE, signer_), "Wrong role");
      require(lastSigner_ != signer_, "Duplicate signer");
      lastSigner_ = signer_;
      validValidator_++;
    }
    require(validValidator_ >= validatorsRequired, "Insufficient validators");
    withdrawalEntries[_withdrawId] = ProcessedEntry(_withdrawId, _msgSender(), _token, _amount);

    // Mint and transfer
    uint256 balance_ = IERC20Upgradeable(_token).balanceOf(address(this));
    if (balance_ < _amount) {
      IMintable(_token).mint(address(this), _amount - balance_);
    }
    require(IERC20Upgradeable(_token).transfer(_msgSender(), _amount), "Transfer failed");
    // Log and Event
    emit TokenWithdrawn(_msgSender(), _withdrawId, _token, _amount);
  }

  /**
   * @dev Validate signature return signer address
   * @param _withdrawId withdraw id request
   * @param _token token address
   * @param _amount amount to withdraw
   * @param _signature bytes signature
   */
  function validateSignature(
    uint256 _withdrawId,
    address _token,
    uint256 _amount,
    bytes memory _signature
  ) public view returns (address) {
    bytes32 digest_ = _hash(_msgSender(), _withdrawId, _token, _amount);
    return ECDSAUpgradeable.recover(digest_, _signature);
  }

  /**
   * @dev Hash v4
   * @param _user address user address
   * @param _withdrawId uint256 withdraw id request
   * @param _token address token address
   * @param _amount uint256 withdraw amount
   */
  function _hash(
    address _user,
    uint256 _withdrawId,
    address _token,
    uint256 _amount
  ) internal view returns (bytes32) {
    return
      _hashTypedDataV4(
        keccak256(
          abi.encode(
            keccak256("MultiSig(address _user,uint256 _withdrawId,address _token,uint256 _amount)"),
            _user,
            _withdrawId,
            _token,
            _amount
          )
        )
      );
  }
}

Create test

scripts_truffle/eip712.js

// EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
const DOMAIN_TYPE = [
  { type: 'string', name: 'name' },
  { type: 'string', name: 'version' },
  { type: 'uint256', name: 'chainId' },
  { type: 'address', name: 'verifyingContract' },
];

module.exports = {
  createTypeData: function (domainData, primaryType, message, types) {
    return {
      types: Object.assign(
        {
          EIP712Domain: DOMAIN_TYPE,
        },
        types
      ),
      domain: domainData,
      primaryType: primaryType,
      message: message,
    };
  },

  signTypedData: function (web3, signer, message) {
    return new Promise(async (resolve, reject) => {
      function cb(err, result) {
        if (err) {
          return reject(err);
        }
        if (result.error) {
          return reject(result.error);
        }

        const sig = result.result;
        const sig0 = sig.substring(2);
        const r = '0x' + sig0.substring(0, 64);
        const s = '0x' + sig0.substring(64, 128);
        const v = parseInt(sig0.substring(128, 130), 16);

        resolve({
          message,
          sig,
          v,
          r,
          s,
        });
      }

      if (web3.currentProvider.isMetaMask) {
        web3.currentProvider.sendAsync(
          {
            jsonrpc: '2.0',
            method: 'eth_signTypedData_v4',
            params: [signer, JSON.stringify(message)],
            from: signer,
            id: new Date().getTime(),
          },
          cb
        );
      } else {
        let send = web3.currentProvider.sendAsync;
        if (!send) send = web3.currentProvider.send;
        // Ganache-cli does not support v4, use hardhat instead
        send.bind(web3.currentProvider)(
          {
            jsonrpc: '2.0',
            method: 'eth_signTypedData_v4',
            params: [signer, message],
            from: signer,
            id: new Date().getTime(),
          },
          cb
        );
      }
    });
  },
};

scripts_truffle/eip712ex.js

// @nhancv
const { createTypeData, signTypedData } = require('./EIP712');
// MultiSig(address _user,uint256 _withdrawId,address _token, uint256 _amount)
const TypeName = 'MultiSig';
const TypeVersion = '1.0.0';
const Types = {
  [TypeName]: [
    { name: '_user', type: 'address' },
    { name: '_withdrawId', type: 'uint256' },
    { name: '_token', type: 'address' },
    { name: '_amount', type: 'uint256' },
  ],
};

async function sign(_validator, _user, _withdrawId, _token, _amount, _verifyingContract) {
  const chainId = Number(await web3.eth.getChainId());
  const data = createTypeData(
    { name: TypeName, version: TypeVersion, chainId: chainId, verifyingContract: _verifyingContract },
    TypeName,
    { _user, _withdrawId, _token, _amount },
    Types
  );
  return (await signTypedData(web3, _validator, data)).sig;
}

module.exports = { sign };

test/MultiSigEIP712.test.js

// @nhancv
// npx hardhat node
// truffle test ./test/MultiSigEIP712.test.js --network test

const Web3 = require('web3');
const { deployProxy, upgradeProxy } = require('@openzeppelin/truffle-upgrades');
const { BN, expectRevert, time, expectEvent } = require('@openzeppelin/test-helpers');
const { assert } = require('chai');
const truffleAssert = require('truffle-assertions');
const BigNumber = require('bignumber.js');
const { sleep, toWei, fromWei } = require('../scripts_truffle/utils.js');
const { sign } = require('../scripts_truffle/eip712ex');

const MultiSigEIP712 = artifacts.require('MultiSigEIP712');
const ERC20Mintable = artifacts.require('ERC20Mintable');

contract('MultiSigEIP712.test.js', ([owner, bob, validator1, validator2]) => {
  let instanceToken;
  let instanceMultiSig;

  before(async () => {
    instanceMultiSig = await deployProxy(MultiSigEIP712, [], {
      initializer: '__MultiSigEIP712_init',
    });
    instanceToken = await deployProxy(ERC20Mintable, ['MockShit', 'EWW', '18', '1000000', instanceMultiSig.address], {
      initializer: '__ERC20Mintable_init',
    });
    // Grant validator role
    const validatorRole = await instanceMultiSig.VALIDATOR_ROLE();
    await instanceMultiSig.grantRole(validatorRole, validator1);
    await instanceMultiSig.grantRole(validatorRole, validator2);
  });

  it('Intial state', async () => {
    assert.equal(await instanceToken.totalSupply(), toWei('1000000').toString());
    assert.equal(await instanceToken.name(), 'MockShit');
    assert.equal(await instanceToken.symbol(), 'EWW');
    assert.equal(await instanceToken.decimals(), '18');
    assert.equal(await instanceToken.balanceOf(owner), toWei('1000000').toString());
    assert.equal(await instanceToken.balanceOf(bob), 0);
    assert.isTrue(await instanceToken.hasRole(Web3.utils.keccak256('MINTER_ROLE'), instanceMultiSig.address));

    const validatorRole = Web3.utils.keccak256('VALIDATOR_ROLE');
    assert.isTrue(await instanceMultiSig.hasRole(validatorRole, validator1));
    assert.isTrue(await instanceMultiSig.hasRole(validatorRole, validator2));
  });

  it('Deposit', async () => {
    // Owner deposit
    await instanceToken.approve(instanceMultiSig.address, 100);
    const depositTx = await instanceMultiSig.depositToken(instanceToken.address, 100);
    truffleAssert.eventEmitted(depositTx, 'TokenDeposited', (ev) => {
      return (
        ev._user === owner &&
        ev._depositId.toNumber() === 0 &&
        ev._token === instanceToken.address &&
        ev._amount.toNumber() === 100
      );
    });
    assert.equal(await instanceMultiSig.depositIndex(), 1);
    assert.equal(await instanceToken.balanceOf(instanceMultiSig.address), 100);
    const depositEntry = await instanceMultiSig.depositEntries(0);
    assert.equal(depositEntry.processId, 0);
    assert.equal(depositEntry.user, owner);
    assert.equal(depositEntry.token, instanceToken.address);
    assert.equal(depositEntry.amount, 100);

    // Bob deposit => error
    // Revert transfer from failed in sender doesn't enough token
    await instanceToken.approve(instanceMultiSig.address, 100, { from: bob });
    await truffleAssert.reverts(instanceMultiSig.depositToken(instanceToken.address, 100, { from: bob }));
  });

  it('Withdraw', async () => {
    // Owner withdraw
    const withdrawId = 0;
    const withdrawAmount = 100;
    const fakeSignature = await sign(
      bob,
      owner,
      withdrawId,
      instanceToken.address,
      withdrawAmount,
      instanceMultiSig.address
    );
    // Revert: not validator
    await truffleAssert.reverts(
      instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, [fakeSignature]),
      'Wrong role'
    );

    // Revert: not enough valid validator
    const signature1 = await sign(
      validator1,
      owner,
      withdrawId,
      instanceToken.address,
      withdrawAmount,
      instanceMultiSig.address
    );
    const signerFromContract = await instanceMultiSig.validateSignature(
      withdrawId,
      instanceToken.address,
      withdrawAmount,
      signature1
    );
    assert.equal(signerFromContract, validator1);
    await truffleAssert.reverts(
      instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, [signature1]),
      'Insufficient validators'
    );
    // Revert: duplicate validator
    await truffleAssert.reverts(
      instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, [signature1, signature1]),
      'Duplicate signer'
    );

    // Success
    const signature2 = await sign(
      validator2,
      owner,
      withdrawId,
      instanceToken.address,
      withdrawAmount,
      instanceMultiSig.address
    );
    const withdrawTx = await instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, [
      signature1,
      signature2,
    ]);
    truffleAssert.eventEmitted(withdrawTx, 'TokenWithdrawn', (ev) => {
      return (
        ev._user === owner &&
        ev._withdrawId.toNumber() === withdrawId &&
        ev._token === instanceToken.address &&
        ev._amount.toNumber() === withdrawAmount
      );
    });
    assert.equal(await instanceToken.balanceOf(owner), toWei('1000000').toString());
    assert.equal(await instanceToken.balanceOf(instanceMultiSig.address), 0);
    const withdrawalEntry = await instanceMultiSig.withdrawalEntries(withdrawId);
    assert.equal(withdrawalEntry.processId, withdrawId);
    assert.equal(withdrawalEntry.user, owner);
    assert.equal(withdrawalEntry.token, instanceToken.address);
    assert.equal(withdrawalEntry.amount, withdrawAmount);

    // Revert: duplicate withdrawId
    await truffleAssert.reverts(
      instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, [signature1, signature2])
    );
  });
});

Start test

Ganache-cli does not support eth_signTypedData_v4, you need hardhat instead https://hardhat.org/hardhat-network/

# Install hardhat node
npm install --save-dev hardhat
# Install hardhat utils
npm install --save-dev @nomiclabs/hardhat-ethers ethers @nomiclabs/hardhat-waffle ethereum-waffle chai
# Start node
npx hardhat node

run test: 

truffle test ./test/MultiSigEIP712.test.js --network test

Remember exit migrate process with test network in all migration files

ex: migrations/1_deploy.js
module.exports = async function (deployer, network, accounts) {
  if (network === 'test') return;
  ....
};

Results

Leave a Reply

Your email address will not be published.Required fields are marked *