
https://gist.github.com/nhancv/228d1e7db2b58842309e06de554a6640
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 with multi-signatures. This is just an example, and you should improve the checker about duplicate signer with mapping instead of the last one. Add deadline & nonce to prevent replay attack, add revoke signature immediately mechanism as well.
* @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
