-
-
Save nhancv/228d1e7db2b58842309e06de554a6640 to your computer and use it in GitHub Desktop.
Verify smart contract request with multi-signature approach MultiSigEIP712
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// SPDX-License-Identifier: MIT | |
// @nhancv | |
pragma solidity ^0.8.22; | |
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; | |
import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; | |
import "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; | |
import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | |
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; | |
import "./interfaces/IMintable.sol"; | |
contract MultiSigEIP712 is EIP712Upgradeable, AccessControlUpgradeable, ReentrancyGuardUpgradeable { | |
bytes32 public constant EDITOR_ROLE = keccak256("EDITOR_ROLE"); | |
// 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; | |
mapping(bytes => bool) public revokedSignatures; | |
// 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 MultiSigEIP712Init() external initializer { | |
__AccessControl_init(); | |
__ReentrancyGuard_init(); | |
__EIP712_init("MultiSig", "1.0.0"); | |
_grantRole(DEFAULT_ADMIN_ROLE, _msgSender()); | |
_grantRole(EDITOR_ROLE, _msgSender()); | |
validatorsRequired = 2; | |
} | |
/** | |
* @dev Set roles | |
*/ | |
function setRoles(bytes32 role, address[] calldata accounts, bool enable) external onlyRole(getRoleAdmin(role)) { | |
for (uint256 i = 0; i < accounts.length; i++) { | |
if (enable) grantRole(role, accounts[i]); | |
else revokeRole(role, accounts[i]); | |
} | |
} | |
/** | |
* @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(IERC20(_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, | |
uint256 _deadline, | |
bytes[] calldata _signatures | |
) external nonReentrant { | |
require(withdrawalEntries[_withdrawId].amount == 0, "Invalid id"); | |
// Check deadline should be in a valid window 15 minutes | |
require(block.timestamp <= _deadline, "Expired deadline"); | |
require(_deadline <= block.timestamp + 15 minutes, "Invalid deadline"); | |
// Authentication | |
uint256 validValidator_ = 0; | |
address[] memory seenSigners = new address[](_signatures.length); | |
for (uint256 i = 0; i < _signatures.length; i++) { | |
// Extend signature with deadline & nonce if needed to prevent replay attack | |
address signer_ = _validateSignature(_withdrawId, _token, _amount, _deadline, _signatures[i]); | |
require(hasRole(VALIDATOR_ROLE, signer_), "Wrong role"); | |
for (uint256 j = 0; j < i; j++) { | |
require(seenSigners[j] != signer_, "Duplicate signer"); | |
} | |
seenSigners[i] = signer_; | |
validValidator_++; | |
} | |
require(validValidator_ >= validatorsRequired, "Insufficient validators"); | |
withdrawalEntries[_withdrawId] = ProcessedEntry(_withdrawId, _msgSender(), _token, _amount); | |
// Mint and transfer | |
uint256 balance_ = IERC20(_token).balanceOf(address(this)); | |
if (balance_ < _amount) { | |
IMintable(_token).fMint(address(this), _amount - balance_); | |
} | |
require(IERC20(_token).transfer(_msgSender(), _amount), "Transfer failed"); | |
// Log and Event | |
emit TokenWithdrawn(_msgSender(), _withdrawId, _token, _amount); | |
} | |
/** | |
* @dev Revoke signature | |
* @param _signature bytes signature | |
*/ | |
function revokeSignature(bytes memory _signature) external onlyRole(EDITOR_ROLE) { | |
revokedSignatures[_signature] = true; | |
} | |
/** | |
* @dev Validate signature return signer address | |
* @param _withdrawId withdraw id request | |
* @param _token token address | |
* @param _amount amount to withdraw | |
* @param _signature bytes signature | |
* NOTE: | |
* If you have a string param, should use 'string memory' instead of 'string calldata' to avoid stack too deep error. | |
* And also, you need to use 'keccak256(bytes(your_string))' in '_hashTypedDataV4(keccak256(abi.encode( TYPEHASH, ... ,stringHash, ... )))' instead of string var directly. | |
*/ | |
function _validateSignature( | |
uint256 _withdrawId, | |
address _token, | |
uint256 _amount, | |
uint256 _deadline, | |
bytes calldata _signature | |
) internal view returns (address) { | |
require(!revokedSignatures[_signature], "Signature revoked"); | |
bytes32 digest_ = _hashTypedDataV4( | |
keccak256( | |
abi.encode( | |
keccak256("MultiSig(address _user,uint256 _withdrawId,address _token,uint256 _amount,uint256 _deadline)"), | |
_msgSender(), | |
_withdrawId, | |
_token, | |
_amount, | |
_deadline | |
) | |
) | |
); | |
return ECDSA.recover(digest_, _signature); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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'); | |
// Consider uncomment when you get Error: CONNECTION ERROR: Couldn't connect to node | |
// require('@openzeppelin/test-helpers/configure')({ | |
// provider: 'http://127.0.0.1:8545' | |
// }); | |
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, | |
await time.latest(), | |
instanceMultiSig.address | |
); | |
// Revert: not validator | |
await truffleAssert.reverts( | |
instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, await time.latest(), [fakeSignature]), | |
'Wrong role' | |
); | |
// Revert: not enough valid validator | |
const signature1 = await sign( | |
validator1, | |
owner, | |
withdrawId, | |
instanceToken.address, | |
withdrawAmount, | |
await time.latest(), | |
instanceMultiSig.address | |
); | |
const signerFromContract = await instanceMultiSig.validateSignature( | |
withdrawId, | |
instanceToken.address, | |
withdrawAmount, | |
await time.latest(), | |
signature1 | |
); | |
assert.equal(signerFromContract, validator1); | |
await truffleAssert.reverts( | |
instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, await time.latest(), [signature1]), | |
'Insufficient validators' | |
); | |
// Revert: duplicate validator | |
await truffleAssert.reverts( | |
instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, await time.latest(), [signature1, signature1]), | |
'Duplicate signer' | |
); | |
// Success | |
const signature2 = await sign( | |
validator2, | |
owner, | |
withdrawId, | |
instanceToken.address, | |
withdrawAmount, | |
await time.latest(), | |
instanceMultiSig.address | |
); | |
const withdrawTx = await instanceMultiSig.withdrawToken(withdrawId, instanceToken.address, withdrawAmount, await time.latest(), [ | |
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, await time.latest(), [signature1, signature2]) | |
); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment