Skip to content

Instantly share code, notes, and snippets.

@nhancv
Last active February 12, 2025 07:46
Show Gist options
  • Save nhancv/228d1e7db2b58842309e06de554a6640 to your computer and use it in GitHub Desktop.
Save nhancv/228d1e7db2b58842309e06de554a6640 to your computer and use it in GitHub Desktop.
Verify smart contract request with multi-signature approach MultiSigEIP712
// 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);
}
}
// @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