EIP712 makes a signed message’s fields and domain explicit. The earlier experiment used it to authorize a token withdrawal with signatures from validator-role accounts. Typed encoding and domain separation help define what is signed, but do not supply an application’s replay or quorum policy.
Bind the request to its domain
The source domain includes a name, version, chain ID, and verifying contract. The message includes the user, withdrawal ID, token, and amount. The JavaScript and Solidity type definitions must encode the same fields in the same order.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.4;
interface IMintable {
function mint(address account, uint256 amount) external;
}
// 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);
}
}
Read the historical multisignature contract
// 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
)
)
);
}
}
The source is not a safe quorum implementation
The loop compares each signer only with the previous signer. It rejects adjacent duplicates, but an A–B–A sequence can count A twice. A threshold must count distinct authorized signers across the whole request.
The processed-request guard uses a zero amount as an unused marker. A zero-amount request therefore does not create a reliable consumed marker. Expiry, revocation, signer-set changes, and request identity also require explicit rules. These are source defects and design gaps, not fixes made silently in this listing.
Signing helpers and the original test
Inspect the typed-data helpers and source test
// 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
);
}
});
},
};
// @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 };
// @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])
);
});
});
The old setup used a Hardhat node for the provider’s eth_signTypedData_v4 support and Truffle for the test runner. That is a historical toolchain observation, not a current Ganache/Hardhat compatibility claim. The migration-file sketch skips its test network:
truffle test ./test/MultiSigEIP712.test.js --network test
ex: migrations/1_deploy.js
module.exports = async function (deployer, network, accounts) {
if (network === 'test') return;
....
};
The snippets depend on local token, utility, and package versions beyond this page. The source test covers some successful and rejected paths, but not the nonadjacent duplicate case described above. It was not run during this revision.
Sources: retrieved Gist revision and EIP712 specification. No signature, token, deployment, or withdrawal was created.