diff --git a/cli/lib/nf3.mjs b/cli/lib/nf3.mjs index 29c3b2165..96d03dd73 100644 --- a/cli/lib/nf3.mjs +++ b/cli/lib/nf3.mjs @@ -338,6 +338,19 @@ class Nf3 { return res.data.address.toLowerCase(); } + /** + Returns the abi of a Nightfall_3 contract calling the client. + @method + @async + @param {string} contractName - the name of the smart contract in question. Possible + values are 'Shield', 'State', 'Proposers', 'Challengers'. + @returns {Promise} Resolves into the Ethereum abi of the contract + */ + async getContractAbi(contractName) { + const res = await axios.get(`${this.clientBaseUrl}/contract-abi/${contractName}`); + return res.data.abi; + } + /** Returns the address of a Nightfall_3 contract calling the optimist. @method diff --git a/nightfall-administrator/src/services/contract-transactions.mjs b/nightfall-administrator/src/services/contract-transactions.mjs index 09c10b4e9..3542123ca 100644 --- a/nightfall-administrator/src/services/contract-transactions.mjs +++ b/nightfall-administrator/src/services/contract-transactions.mjs @@ -2,7 +2,7 @@ import config from 'config'; import { waitForContract, web3 } from '../../../common-files/utils/contract.mjs'; import logger from '../../../common-files/utils/logger.mjs'; import constants from '../../../common-files/constants/index.mjs'; -import { addMultiSigSignature } from './helpers.mjs'; +import { addMultiSigSignature, getMultiSigNonce } from './helpers.mjs'; const { RESTRICTIONS } = config; const { SHIELD_CONTRACT_NAME } = constants; @@ -40,7 +40,9 @@ export async function setTokenRestrictions( return false; } -export async function removeTokenRestrictions(tokenName, signingKey, executorAddress, nonce) { +export async function removeTokenRestrictions(tokenName, signingKey, executorAddress, _nonce) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await getMultiSigNonce(); const shieldContractInstance = await waitForContract(SHIELD_CONTRACT_NAME); for (const token of RESTRICTIONS.tokens[process.env.ETH_NETWORK]) { if (token.name === tokenName) { @@ -59,8 +61,10 @@ export async function removeTokenRestrictions(tokenName, signingKey, executorAdd return false; } -export function pauseContracts(signingKey, executorAddress, nonce) { +export async function pauseContracts(signingKey, executorAddress, _nonce) { logger.info('All pausable contracts being paused'); + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await getMultiSigNonce(); return Promise.all( pausables.map(async (pausable, i) => { const contractInstance = await waitForContract(pausable); @@ -76,8 +80,10 @@ export function pauseContracts(signingKey, executorAddress, nonce) { ); } -export function unpauseContracts(signingKey, executorAddress, nonce) { +export async function unpauseContracts(signingKey, executorAddress, _nonce) { logger.info('All pausable contracts being unpaused'); + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await getMultiSigNonce(); return Promise.all( pausables.map(async (pausable, i) => { const contractInstance = await waitForContract(pausable); @@ -93,7 +99,9 @@ export function unpauseContracts(signingKey, executorAddress, nonce) { ); } -export function transferOwnership(newOwnerPrivateKey, signingKey, executorAddress, nonce) { +export async function transferOwnership(newOwnerPrivateKey, signingKey, executorAddress, _nonce) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await getMultiSigNonce(); const newOwner = web3.eth.accounts.privateKeyToAccount(newOwnerPrivateKey, true).address; return Promise.all( ownables.map(async (ownable, i) => { @@ -110,7 +118,9 @@ export function transferOwnership(newOwnerPrivateKey, signingKey, executorAddres ); } -export async function setBootProposer(newProposerPrivateKey, signingKey, executorAddress, nonce) { +export async function setBootProposer(newProposerPrivateKey, signingKey, executorAddress, _nonce) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await getMultiSigNonce(); const newProposer = web3.eth.accounts.privateKeyToAccount(newProposerPrivateKey, true).address; const shieldContractInstance = await waitForContract(SHIELD_CONTRACT_NAME); const data = shieldContractInstance.methods.setBootProposer(newProposer).encodeABI(); @@ -129,8 +139,10 @@ export async function setBootChallenger( newChallengerPrivateKey, signingKey, executorAddress, - nonce, + _nonce, ) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await getMultiSigNonce(); const newChallenger = web3.eth.accounts.privateKeyToAccount( newChallengerPrivateKey, true, diff --git a/nightfall-administrator/src/services/helpers.mjs b/nightfall-administrator/src/services/helpers.mjs index dd82eab93..e30b6c248 100644 --- a/nightfall-administrator/src/services/helpers.mjs +++ b/nightfall-administrator/src/services/helpers.mjs @@ -7,6 +7,17 @@ import { checkThreshold, saveSigned, getSigned } from './database.mjs'; const { RESTRICTIONS, WEB3_OPTIONS, MULTISIG } = config; const { SIGNATURE_THRESHOLD } = MULTISIG; const MULTISIG_CONSTANTS = {}; + +/** + Read the nonce from the multisig contract + */ +export async function getMultiSigNonce() { + const { multiSigInstance } = MULTISIG_CONSTANTS; + if (!multiSigInstance) throw new Error('No multisig instance'); + const nonce = await multiSigInstance.methods.nonce().call(); + return Number(nonce); +} + /** * Read the names of tokens from the config */ @@ -67,11 +78,10 @@ export async function addSignedTransaction(signed) { * This function creates the multisig message hash, which is signed (approved) by the key-holders. * It's worth looking at the multisig contract to see where this all comes from. */ -async function createMultiSigMessageHash(destination, value, data, _nonce, executor, gasLimit) { - const { domainSeparator, txTypeHash, multiSigInstance, txInputHashABI } = MULTISIG_CONSTANTS; - let nonce = _nonce; +async function createMultiSigMessageHash(destination, value, data, nonce, executor, gasLimit) { + const { domainSeparator, txTypeHash, txInputHashABI } = MULTISIG_CONSTANTS; // get the current multisig nonce if it's not provided (requires blockchain connection) - if (!_nonce) nonce = await multiSigInstance.methods.nonce().call(); + if (!Number.isInteger(nonce)) throw new Error(`Nonce is not an integer: ${nonce}`); // compute the hashes to sign over note, sometimes we want a keccak hash over encoded parameter // and sometimes over encodedPacked parameters. Hence the two slightly different approaches used. const dataHash = web3.utils.soliditySha3({ t: 'bytes', v: data }); diff --git a/nightfall-administrator/src/ui/get-info.mjs b/nightfall-administrator/src/ui/get-info.mjs index 41f2d1ba0..c1b3a52df 100644 --- a/nightfall-administrator/src/ui/get-info.mjs +++ b/nightfall-administrator/src/ui/get-info.mjs @@ -31,8 +31,6 @@ async function start() { tokenName, depositRestriction, withdrawRestriction, - pause, - unpause, newEthereumSigningKey, executorAddress, nonce, @@ -73,13 +71,10 @@ async function start() { break; } case 'Unpause contracts': { - if (!unpause) break; - logger.info('CALLING unpauseContracts'); approved = await unpauseContracts(ethereumSigningKey, executorAddress, nonce); break; } case 'Pause contracts': { - if (!pause) break; approved = await pauseContracts(ethereumSigningKey, executorAddress, nonce); break; } diff --git a/nightfall-administrator/src/ui/menu.mjs b/nightfall-administrator/src/ui/menu.mjs index 848cd555c..af68b9f55 100644 --- a/nightfall-administrator/src/ui/menu.mjs +++ b/nightfall-administrator/src/ui/menu.mjs @@ -113,18 +113,6 @@ export async function askQuestions(approved) { when: answers => answers.task === 'Set token restrictions', validate: input => Number.isInteger(Number(input)) && Number(input) > 0, }, - { - name: 'pause', - type: 'confirm', - message: 'Pause contracts?', - when: answers => answers.task === 'Pause contracts', - }, - { - name: 'unpause', - type: 'confirm', - message: 'Unpause contracts?', - when: answers => answers.task === 'Unpause contracts', - }, { name: 'amount', type: 'input', diff --git a/package.json b/package.json index d2a207216..7b0514bca 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test-erc1155-tokens": "npx hardhat test --bail --no-compile --grep ERC1155", "test-erc20-cli": "npx hardhat test --no-compile --bail test/client/erc20.test.mjs ", "ping-pong": "npx hardhat test --bail --no-compile test/ping-pong/ping-pong.test.mjs", + "test-administrator": "npx hardhat test --bail --no-compile test/multisig/administrator.test.mjs ", "test-optimist-sync": "npx hardhat test --no-compile --bail test/optimist-resync.test.mjs", "test-adversary": "CHALLENGE_TYPE=${CHALLENGE_TYPE} npx hardhat test --no-compile --bail test/adversary.test.mjs", "test-all-adversary": "for CHALLENGE_TYPE in IncorrectTreeRoot IncorrectLeafCount IncorrectTreeRoot IncorrectLeafCount; do CHALLENGE_TYPE=${CHALLENGE_TYPE} npm run test-adversary; sleep 5; done", diff --git a/test/multisig/administrator.test.mjs b/test/multisig/administrator.test.mjs new file mode 100644 index 000000000..e4c942564 --- /dev/null +++ b/test/multisig/administrator.test.mjs @@ -0,0 +1,396 @@ +/* This test relies on nightfall_3/cli + */ + +/* eslint-disable no-await-in-loop */ +import chai from 'chai'; +import chaiHttp from 'chai-http'; +import config from 'config'; +import chaiAsPromised from 'chai-as-promised'; +import Nf3 from '../../cli/lib/nf3.mjs'; +import { NightfallMultiSig } from './nightfall-multisig.mjs'; + +const { WEB3_OPTIONS } = config; +const { expect } = chai; +chai.use(chaiHttp); +chai.use(chaiAsPromised); + +const environment = config.ENVIRONMENTS[process.env.ENVIRONMENT] || config.ENVIRONMENTS.localhost; + +const { mnemonics, signingKeys, addresses } = config.TEST_OPTIONS; +const amount1 = 10; +const amount2 = 100; + +const getContractInstance = async (contractName, nf3) => { + const abi = await nf3.getContractAbi(contractName); + const contractAddress = await nf3.getContractAddress(contractName); + const contractInstance = new nf3.web3.eth.Contract(abi, contractAddress); + return contractInstance; +}; + +describe(`Testing Administrator`, () => { + let nf3User; + let stateContract; + let proposersContract; + let shieldContract; + let challengesContract; + let multisigContract; + let nfMultiSig; + + before(async () => { + nf3User = new Nf3(signingKeys.user1, environment); + + await nf3User.init(mnemonics.user1); + + stateContract = await getContractInstance('State', nf3User); + proposersContract = await getContractInstance('Proposers', nf3User); + shieldContract = await getContractInstance('Shield', nf3User); + challengesContract = await getContractInstance('Challenges', nf3User); + multisigContract = await getContractInstance('SimpleMultiSig', nf3User); + + if (!(await nf3User.healthcheck('client'))) throw new Error('Healthcheck failed'); + nfMultiSig = new NightfallMultiSig( + nf3User.web3, + { + state: stateContract, + proposers: proposersContract, + shield: shieldContract, + challenges: challengesContract, + multisig: multisigContract, + }, + 2, + await nf3User.web3.eth.getChainId(), + WEB3_OPTIONS.gas, + ); + }); + + describe(`Basic tests`, () => { + it('Owner of the State, Proposers, Shield and Challenges contract should be the multisig', async function () { + const ownerState = await stateContract.methods.owner().call(); + const ownerShield = await shieldContract.methods.owner().call(); + const ownerProposers = await proposersContract.methods.owner().call(); + const ownerChallenges = await challengesContract.methods.owner().call(); + const multisigAddress = multisigContract.options.address; + + expect(ownerState.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + expect(ownerShield.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + expect(ownerProposers.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + expect(ownerChallenges.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + }); + + it('Set boot proposer with the multisig', async () => { + const transactions = await nfMultiSig.setBootProposer( + signingKeys.user1, + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.setBootProposer( + signingKeys.user1, + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + const bootProposer = await shieldContract.methods.getBootProposer().call(); + + expect(bootProposer.toUpperCase()).to.be.equal(nf3User.ethereumAddress.toUpperCase()); + }); + + it('Set boot challenger with the multisig', async () => { + const transactions = await nfMultiSig.setBootChallenger( + signingKeys.user1, + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.setBootChallenger( + signingKeys.user1, + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + const bootChallenger = await shieldContract.methods.getBootChallenger().call(); + + expect(bootChallenger.toUpperCase()).to.be.equal(nf3User.ethereumAddress.toUpperCase()); + }); + + it('Set restriction with the multisig', async () => { + const transactions = await nfMultiSig.setTokenRestrictions( + nf3User.ethereumAddress, // simulate a token address + amount1, + amount2, + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.setTokenRestrictions( + nf3User.ethereumAddress, // simulate a token address + amount1, + amount2, + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + const restrictionDeposit = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 0) + .call(); + const restrictionWithdraw = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 1) + .call(); + + expect(Number(restrictionDeposit)).to.be.equal(amount1); + expect(Number(restrictionWithdraw)).to.be.equal(amount2); + }); + + it('Remove restriction with the multisig', async () => { + const transactions = await nfMultiSig.removeTokenRestrictions( + nf3User.ethereumAddress, // simulate a token address + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.removeTokenRestrictions( + nf3User.ethereumAddress, // simulate a token address + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + const restrictionDeposit = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 0) + .call(); + const restrictionWithdraw = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 1) + .call(); + + expect(Number(restrictionDeposit)).to.be.equal(0); + expect(Number(restrictionWithdraw)).to.be.equal(0); + }); + + it('Set MATIC address with the multisig', async () => { + const transactions = await nfMultiSig.setMaticAddress( + addresses.user1, + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.setMaticAddress( + addresses.user1, + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + const maticAddress = await shieldContract.methods.getMaticAddress().call(); + + expect(maticAddress.toUpperCase()).to.be.equal(addresses.user1.toUpperCase()); + }); + + it('Pause contracts with the multisig', async () => { + const paused1 = await stateContract.methods.paused().call(); + const transactions = await nfMultiSig.pauseContracts( + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.pauseContracts( + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + + const paused2 = await stateContract.methods.paused().call(); + + expect(paused1).to.be.equal(false); + expect(paused2).to.be.equal(true); + }); + + it('Unpause contracts with the multisig', async () => { + const paused1 = await stateContract.methods.paused().call(); + const transactions = await nfMultiSig.unpauseContracts( + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.unpauseContracts( + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + + const paused2 = await stateContract.methods.paused().call(); + + expect(paused1).to.be.equal(true); + expect(paused2).to.be.equal(false); + }); + + it('Be able to transfer ownership of contracts from multisig to a specific one', async () => { + const transactions = await nfMultiSig.transferOwnership( + signingKeys.user1, + signingKeys.user1, + addresses.user1, + await multisigContract.methods.nonce().call(), + [], + ); + const approved = await nfMultiSig.transferOwnership( + signingKeys.user1, + signingKeys.user2, + addresses.user1, + await multisigContract.methods.nonce().call(), + transactions, + ); + + await nfMultiSig.multiSig.executeMultiSigTransactions(approved, signingKeys.user1); + const owner = await stateContract.methods.owner().call(); + expect(owner.toUpperCase()).to.be.equal(nf3User.ethereumAddress.toUpperCase()); + }); + + it('Set boot proposer without multisig', async () => { + await shieldContract.methods + .setBootProposer(nf3User.ethereumAddress) + .send({ from: nf3User.ethereumAddress }); + const bootProposer = await shieldContract.methods.getBootProposer().call(); + + expect(bootProposer.toUpperCase()).to.be.equal(nf3User.ethereumAddress.toUpperCase()); + }); + + it('Set boot challenger without multisig', async () => { + await shieldContract.methods + .setBootChallenger(nf3User.ethereumAddress) + .send({ from: nf3User.ethereumAddress }); + const bootChallenger = await shieldContract.methods.getBootChallenger().call(); + + expect(bootChallenger.toUpperCase()).to.be.equal(nf3User.ethereumAddress.toUpperCase()); + }); + + it('Set restriction without multisig', async () => { + await shieldContract.methods + .setRestriction(nf3User.ethereumAddress, amount1, amount2) + .send({ from: nf3User.ethereumAddress }); + const restrictionDeposit = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 0) + .call(); + const restrictionWithdraw = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 1) + .call(); + + expect(Number(restrictionDeposit)).to.be.equal(amount1); + expect(Number(restrictionWithdraw)).to.be.equal(amount2); + }); + + it('Remove restriction without multisig', async () => { + await shieldContract.methods + .removeRestriction(nf3User.ethereumAddress) + .send({ from: nf3User.ethereumAddress }); + const restrictionDeposit = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 0) + .call(); + const restrictionWithdraw = await shieldContract.methods + .getRestriction(nf3User.ethereumAddress, 1) + .call(); + + expect(Number(restrictionDeposit)).to.be.equal(0); + expect(Number(restrictionWithdraw)).to.be.equal(0); + }); + + it('Set MATIC address without multisig', async () => { + await shieldContract.methods + .setMaticAddress(nf3User.ethereumAddress) + .send({ from: nf3User.ethereumAddress }); + const maticAddress = await shieldContract.methods.getMaticAddress().call(); + + expect(maticAddress.toUpperCase()).to.be.equal(nf3User.ethereumAddress.toUpperCase()); + }); + + it('Pause State contract without multisig', async () => { + const paused1 = await stateContract.methods.paused().call(); + await stateContract.methods.pause().send({ from: nf3User.ethereumAddress }); + const paused2 = await stateContract.methods.paused().call(); + + expect(paused1).to.be.equal(false); + expect(paused2).to.be.equal(true); + }); + + it('Unpause State contract without multisig', async () => { + const paused1 = await stateContract.methods.paused().call(); + await stateContract.methods.unpause().send({ from: nf3User.ethereumAddress }); + const paused2 = await stateContract.methods.paused().call(); + + expect(paused1).to.be.equal(true); + expect(paused2).to.be.equal(false); + }); + + it('Pause Shield contract without multisig', async () => { + const paused1 = await shieldContract.methods.paused().call(); + await shieldContract.methods.pause().send({ from: nf3User.ethereumAddress }); + const paused2 = await shieldContract.methods.paused().call(); + + expect(paused1).to.be.equal(false); + expect(paused2).to.be.equal(true); + }); + + it('Unpause Shield contract without multisig', async () => { + const paused1 = await shieldContract.methods.paused().call(); + await shieldContract.methods.unpause().send({ from: nf3User.ethereumAddress }); + const paused2 = await shieldContract.methods.paused().call(); + + expect(paused1).to.be.equal(true); + expect(paused2).to.be.equal(false); + }); + + it('Restore multisig', async () => { + const multisigAddress = multisigContract.options.address; + await Promise.all([ + shieldContract.methods + .transferOwnership(multisigAddress) + .send({ from: nf3User.ethereumAddress }), + stateContract.methods + .transferOwnership(multisigAddress) + .send({ from: nf3User.ethereumAddress }), + proposersContract.methods + .transferOwnership(multisigAddress) + .send({ from: nf3User.ethereumAddress }), + challengesContract.methods + .transferOwnership(multisigAddress) + .send({ from: nf3User.ethereumAddress }), + ]); + + const ownerState = await stateContract.methods.owner().call(); + const ownerShield = await shieldContract.methods.owner().call(); + const ownerProposers = await proposersContract.methods.owner().call(); + const ownerChallenges = await challengesContract.methods.owner().call(); + expect(ownerState.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + expect(ownerShield.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + expect(ownerProposers.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + expect(ownerChallenges.toUpperCase()).to.be.equal(multisigAddress.toUpperCase()); + }); + }); + + after(async () => { + nf3User.close(); + }); +}); diff --git a/test/multisig/multisig.mjs b/test/multisig/multisig.mjs new file mode 100644 index 000000000..b3e34fd48 --- /dev/null +++ b/test/multisig/multisig.mjs @@ -0,0 +1,218 @@ +/* ignore unused exports */ +import { ecsign } from 'ethereumjs-util'; +import logger from 'common-files/utils/logger.mjs'; + +// eslint-disable-next-line import/prefer-default-export +export class MultiSig { + MULTISIG_CONSTANTS = {}; + + SIGNATURE_THRESHOLD = 2; + + web3; + + gas; + + constructor(web3Provider, multiSigContractInstance, signatureThreshold, chainId, gasLimit) { + // constants used to create a mutlisig data structure + const EIP712DOMAINTYPE_HASH = + '0xd87cd6ef79d4e2b95e15ce8abf732db51ec771f1ca2edccf22a46c729ac56472'; + // keccak256("Simple MultiSig") + const NAME_HASH = '0xb7a0bfa1b79f2443f4d73ebb9259cddbcd510b18be6fc4da7d1aa7b1786e73e6'; + // keccak256("1") + const VERSION_HASH = '0xc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6'; + // keccak256("MultiSigTransaction(address destination,uint256 value,bytes data,uint256 nonce,address executor,uint256 gasLimit)") + const TXTYPE_HASH = '0x3ee892349ae4bbe61dce18f95115b5dc02daf49204cc602458cd4c1f540d56d7'; + const SALT = '0x251543af6a222378665a76fe38dbceae4871a070b7fdaf5c6c30cf758dc33cc0'; + // compute the EIP-712 domain separator + const domainSeparatorABI = ['bytes32', 'bytes32', 'bytes32', 'uint', 'address', 'bytes32']; + const domainSeparator = [ + EIP712DOMAINTYPE_HASH, + NAME_HASH, + VERSION_HASH, + chainId, + multiSigContractInstance.options.address, + SALT, + ]; + + this.SIGNATURE_THRESHOLD = signatureThreshold; + this.web3 = web3Provider; + this.gas = gasLimit; + const domainSeparatorEncoded = this.web3.eth.abi.encodeParameters( + domainSeparatorABI, + domainSeparator, + ); + const DOMAIN_SEPARATOR = this.web3.utils.soliditySha3({ + t: 'bytes', + v: domainSeparatorEncoded, + }); + this.MULTISIG_CONSTANTS.domainSeparator = DOMAIN_SEPARATOR; + this.MULTISIG_CONSTANTS.txTypeHash = TXTYPE_HASH; + this.MULTISIG_CONSTANTS.multiSigInstance = multiSigContractInstance; + this.MULTISIG_CONSTANTS.txInputHashABI = [ + 'bytes32', + 'address', + 'uint', + 'bytes32', + 'uint', + 'address', + 'uint', + ]; + } + + /** + Read the nonce from the multisig contract + */ + async getMultiSigNonce() { + const { multiSigInstance } = this.MULTISIG_CONSTANTS; + if (!multiSigInstance) throw new Error('No multisig instance'); + const nonce = await multiSigInstance.methods.nonce().call(); + return Number(nonce); + } + + /** + Function to send signed transaction + */ + async sendTransaction(unsignedTransaction, signingKey, contractAddress) { + const tx = { + from: this.web3.eth.accounts.privateKeyToAccount(signingKey).address, + to: contractAddress, + data: unsignedTransaction, + gas: this.gas, + gasPrice: await this.web3.eth.getGasPrice(), + }; + const signed = await this.web3.eth.accounts.signTransaction(tx, signingKey); + return this.web3.eth.sendSignedTransaction(signed.rawTransaction); + } + + /** + Function to save a signed transaction, ready for the multisig + */ + // eslint-disable-next-line class-methods-use-this + async saveSigned(signed, transactions) { + transactions.push({ _id: signed.messageHash.concat(signed.by.slice(2)), ...signed }); + } + + /** + Function to get the signatures + */ + // eslint-disable-next-line class-methods-use-this + async getSigned(messageHash, transactions) { + return transactions.filter(t => t.messageHash === messageHash); + } + + /** + Function to check that there are enough transactions to send some signed data + */ + // eslint-disable-next-line class-methods-use-this + async checkThreshold(messageHash, transactions) { + return transactions.filter(t => t.messageHash === messageHash).length; + } + + // This function saves a signed transaction and will return the array of so-far signed + // transactions + async addSignedTransaction(signed, transactions) { + // save the signed transaction until we meet the signature threshold, only if it's actually signed + if (signed.r) { + try { + await this.saveSigned(signed, transactions); + } catch (err) { + if (err.message.includes('duplicate key')) + console.log('You have already signed this message - no action taken'); + else throw new Error(err); + } + } + const numberOfSignatures = await this.checkThreshold(signed.messageHash, transactions); + logger.info(`Number of signatures for this transaction is ${numberOfSignatures}`); + if (numberOfSignatures === this.SIGNATURE_THRESHOLD) logger.info(`Signature threshold reached`); + const signedArray = (await this.getSigned(signed.messageHash, transactions)).sort((a, b) => { + const x = BigInt(a.by); + const y = BigInt(b.by); + return x < y ? -1 : x > y ? 1 : 0; // eslint-disable-line no-nested-ternary + }); + return signedArray; + } + + // This function creates the multisig message hash, which is signed (approved) by the key-holders. + // It's worth looking at the multisig contract to see where this all comes from. + async createMultiSigMessageHash(destination, value, data, nonce, executor, gasLimit) { + const { domainSeparator, txTypeHash, txInputHashABI } = this.MULTISIG_CONSTANTS; + // get the current multisig nonce if it's not provided (requires blockchain connection) + if (!Number.isInteger(nonce)) throw new Error(`Nonce is not an integer: ${nonce}`); + // compute the hashes to sign over note, sometimes we want a keccak hash over encoded parameter + // and sometimes over encodedPacked parameters. Hence the two slightly different approaches used. + const dataHash = this.web3.utils.soliditySha3({ t: 'bytes', v: data }); + const txInput = [txTypeHash, destination, value, dataHash, nonce, executor, gasLimit]; + const txInputEncoded = this.web3.eth.abi.encodeParameters(txInputHashABI, txInput); + const txInputHash = this.web3.utils.soliditySha3({ t: 'bytes', v: txInputEncoded }); // this is a hash of encoded params + const totalHash = this.web3.utils.soliditySha3( + { t: 'string', v: '\x19\x01' }, + { t: 'bytes32', v: domainSeparator }, + { t: 'bytes32', v: txInputHash }, + ); // this is a hash of encoded, packed params + return totalHash; + } + + // function enabling an approver to sign (approve) a multisig transaction + async addMultiSigSignature( + unsignedTransactionData, + signingKey, + contractAddress, + executorAddress, + nonce, + transactions, + ) { + // compute a signature over the unsigned transaction data + const messageHash = await this.createMultiSigMessageHash( + contractAddress, + 0, + unsignedTransactionData, + nonce, // eslint-disable-line no-param-reassign + executorAddress, + this.gas, + ); + if (!signingKey) return this.addSignedTransaction({ messageHash }, transactions); // if no signing key is given, don't create a new signed transaction + const { r, s, v } = ecsign( + Buffer.from(messageHash.slice(2), 'hex'), + Buffer.from(signingKey.slice(2), 'hex'), + ); + const signed = { + messageHash, + r: `0x${r.toString('hex').padStart(64, '0')}`, + s: `0x${s.toString('hex').padStart(64, '0')}`, + v: `0x${v.toString(16)}`, + by: this.web3.eth.accounts.privateKeyToAccount(signingKey).address, + contractAddress, + data: unsignedTransactionData, + }; + return this.addSignedTransaction(signed, transactions); + } + + async executeMultiSigTransaction(signedArray, executor) { + const { multiSigInstance } = this.MULTISIG_CONSTANTS; + // execute the multisig + const multiSigTransaction = multiSigInstance.methods + .execute( + signedArray.map(s => s.v), + signedArray.map(s => s.r), + signedArray.map(s => s.s), + signedArray[0].contractAddress, + 0, + signedArray[0].data, + this.web3.eth.accounts.privateKeyToAccount(executor).address, + this.gas, + ) + .encodeABI(); + return this.sendTransaction(multiSigTransaction, executor, multiSigInstance.options.address); + } + + /** + * Execute multisig transaction + */ + async executeMultiSigTransactions(approved, executor) { + for (const approval of approved) { + logger.info('Executing multisig transaction'); + // eslint-disable-next-line no-await-in-loop + await this.executeMultiSigTransaction(approval.slice(0, this.SIGNATURE_THRESHOLD), executor); + } + } +} diff --git a/test/multisig/nightfall-multisig.mjs b/test/multisig/nightfall-multisig.mjs new file mode 100644 index 000000000..51d46e8e9 --- /dev/null +++ b/test/multisig/nightfall-multisig.mjs @@ -0,0 +1,243 @@ +/* ignore unused exports */ +import logger from 'common-files/utils/logger.mjs'; +import { MultiSig } from './multisig.mjs'; + +// eslint-disable-next-line import/prefer-default-export +export class NightfallMultiSig { + multiSig; // MultiSig instance + + web3; // web3 instance + + contractInstances = []; // instances of the contracts + + contractsOwnables = ['shield', 'state', 'proposers', 'challenges']; // ownable contracts + + contractsPausables = ['shield', 'state']; // pausable contracts + + constructor(web3Instance, contractInstances, signatureThreshold, chainId, gasLimit) { + this.web3 = web3Instance; + this.multiSig = new MultiSig( + this.web3, + contractInstances.multisig, + signatureThreshold, + chainId, + gasLimit, + ); + this.contractInstances = contractInstances; + } + + contractInstancesOwnables() { + const contractInstancesResult = []; + this.contractsOwnables.forEach(contract => + contractInstancesResult.push(this.contractInstances[contract]), + ); + return contractInstancesResult; + } + + contractInstancesPausables() { + const contractInstancesResult = []; + this.contractsPausables.forEach(contract => + contractInstancesResult.push(this.contractInstances[contract]), + ); + return contractInstancesResult; + } + + /** + This function transfers the ownership of the contracts that are ownable + */ + async transferOwnership(newOwnerPrivateKey, signingKey, executorAddress, _nonce, transactions) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + const newOwner = this.web3.eth.accounts.privateKeyToAccount(newOwnerPrivateKey, true).address; + return Promise.all( + this.contractInstancesOwnables().map(async (ownable, i) => { + const contractInstance = ownable; + const data = contractInstance.methods.transferOwnership(newOwner).encodeABI(); + return this.multiSig.addMultiSigSignature( + data, + signingKey, + contractInstance.options.address, + executorAddress, + nonce + i, + transactions.flat(), + ); + }), + ); + } + + /** + This function sets the restriction data that the Shield contract is currently using + */ + async setTokenRestrictions( + tokenAddress, + depositRestriction, + withdrawRestriction, + signingKey, + executorAddress, + _nonce, + transactions, + ) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + const data = this.contractInstances.shield.methods + .setRestriction(tokenAddress, depositRestriction, withdrawRestriction) + .encodeABI(); + return Promise.all([ + this.multiSig.addMultiSigSignature( + data, + signingKey, + this.contractInstances.shield.options.address, + executorAddress, + nonce, + transactions.flat(), + ), + ]); + } + + /** + This function removes the restriction data that the Shield contract is currently using + */ + async removeTokenRestrictions(tokenAddress, signingKey, executorAddress, _nonce, transactions) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + const data = this.contractInstances.shield.methods.removeRestriction(tokenAddress).encodeABI(); + return Promise.all([ + this.multiSig.addMultiSigSignature( + data, + signingKey, + this.contractInstances.shield.options.address, + executorAddress, + nonce, + transactions.flat(), + ), + ]); + } + + /** + This function pauses contracts that are pausable + */ + async pauseContracts(signingKey, executorAddress, _nonce, transactions) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + logger.info('All pausable contracts being paused'); + return Promise.all( + this.contractInstancesPausables().map(async (pausable, i) => { + const contractInstance = pausable; + const data = contractInstance.methods.pause().encodeABI(); + return this.multiSig.addMultiSigSignature( + data, + signingKey, + contractInstance.options.address, + executorAddress, + nonce + i, + transactions.flat(), + ); + }), + ); + } + + /** + This function unpauses contracts that are pausable + */ + async unpauseContracts(signingKey, executorAddress, _nonce, transactions) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + logger.info('All pausable contracts being unpaused'); + return Promise.all( + this.contractInstancesPausables().map(async (pausable, i) => { + const contractInstance = pausable; + const data = contractInstance.methods.unpause().encodeABI(); + return this.multiSig.addMultiSigSignature( + data, + signingKey, + contractInstance.options.address, + executorAddress, + nonce + i, + transactions.flat(), + ); + }), + ); + } + + /** + This function sets the boot proposer + */ + async setBootProposer(newProposerPrivateKey, signingKey, executorAddress, _nonce, transactions) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + const newProposer = this.web3.eth.accounts.privateKeyToAccount( + newProposerPrivateKey, + true, + ).address; + const shieldContractInstance = this.contractInstances.shield; + const data = shieldContractInstance.methods.setBootProposer(newProposer).encodeABI(); + return Promise.all([ + this.multiSig.addMultiSigSignature( + data, + signingKey, + shieldContractInstance.options.address, + executorAddress, + nonce, + transactions.flat(), + ), + ]); + } + + /** + This function sets the boot challenger + */ + async setBootChallenger( + newChallengerPrivateKey, + signingKey, + executorAddress, + _nonce, + transactions, + ) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + const newChallenger = this.web3.eth.accounts.privateKeyToAccount( + newChallengerPrivateKey, + true, + ).address; + const shieldContractInstance = this.contractInstances.shield; + const data = shieldContractInstance.methods.setBootChallenger(newChallenger).encodeABI(); + return Promise.all([ + this.multiSig.addMultiSigSignature( + data, + signingKey, + shieldContractInstance.options.address, + executorAddress, + nonce, + transactions.flat(), + ), + ]); + } + + /** + This function sets the Matic address + */ + async setMaticAddress(newMaticAddress, signingKey, executorAddress, _nonce, transactions) { + let nonce = _nonce; + if (!Number.isInteger(nonce)) nonce = await this.multiSig.getMultiSigNonce(); + + const shieldContractInstance = this.contractInstances.shield; + const data = shieldContractInstance.methods.setMaticAddress(newMaticAddress).encodeABI(); + return Promise.all([ + this.multiSig.addMultiSigSignature( + data, + signingKey, + shieldContractInstance.options.address, + executorAddress, + nonce, + transactions.flat(), + ), + ]); + } +}