diff --git a/.changeset/late-dolphins-crash.md b/.changeset/late-dolphins-crash.md new file mode 100644 index 000000000..cf98207d8 --- /dev/null +++ b/.changeset/late-dolphins-crash.md @@ -0,0 +1,5 @@ +--- +'@celo/celocli': minor +--- + +Add useSafe flags for governance:propose and governance:withdraw commands diff --git a/docs/command-line-interface/governance.md b/docs/command-line-interface/governance.md index baa24da8e..747b05c68 100644 --- a/docs/command-line-interface/governance.md +++ b/docs/command-line-interface/governance.md @@ -80,7 +80,7 @@ FLAGS True means the request will be sent through multisig. --useSafe - True means the request will be sent through safe. + True means the request will be sent through SAFE (http://safe.global) DESCRIPTION Approve a dequeued governance proposal (or hotfix) @@ -580,8 +580,9 @@ USAGE | --useLedger | ] [-n ] [--gasCurrency 0x1234567890123456789012345678901234567890] [--ledgerAddresses ] [--ledgerLiveMode ] [--globalHelp] [--for 0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d - --useMultiSig] [--force] [--noInfo] [--afterExecutingProposal | - --afterExecutingID ] + [--useMultiSig | --useSafe]] [--safeAddress + 0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d ] [--force] [--noInfo] + [--afterExecutingProposal | --afterExecutingID ] FLAGS -k, --privateKey= @@ -633,12 +634,18 @@ FLAGS --noInfo Skip printing the proposal info + --safeAddress=0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d + Address of the safe. + --useLedger Set it to use a ledger wallet --useMultiSig True means the request will be sent through multisig. + --useSafe + True means the request will be sent through SAFE (http://safe.global). + DESCRIPTION Submit a governance proposal @@ -1407,7 +1414,8 @@ USAGE | --useLedger | ] [-n ] [--gasCurrency 0x1234567890123456789012345678901234567890] [--ledgerAddresses ] [--ledgerLiveMode ] [--globalHelp] [--for 0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d - --useMultiSig] + [--useMultiSig | --useSafe]] [--safeAddress + 0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d ] FLAGS -k, --privateKey= @@ -1438,12 +1446,18 @@ FLAGS the 5th. This is useful to use same address on you Ledger with celocli as you do on Ledger Live + --safeAddress=0xc1912fEE45d61C87Cc5EA59DaE31190FFFFf232d + Address of the safe. + --useLedger Set it to use a ledger wallet --useMultiSig True means the request will be sent through multisig. + --useSafe + True means the request will be sent through SAFE (http://safe.global). + DESCRIPTION Withdraw refunded governance proposal deposits. diff --git a/packages/cli/src/commands/governance/approve.ts b/packages/cli/src/commands/governance/approve.ts index 9ec3a78fb..49a98bf1a 100644 --- a/packages/cli/src/commands/governance/approve.ts +++ b/packages/cli/src/commands/governance/approve.ts @@ -40,7 +40,7 @@ export default class Approve extends BaseCommand { exclusive: ['useSafe'], }), useSafe: Flags.boolean({ - description: 'True means the request will be sent through safe.', + description: 'True means the request will be sent through SAFE (http://safe.global)', exclusive: ['useMultiSig'], }), hotfix: Flags.string({ diff --git a/packages/cli/src/commands/governance/propose-l2.test.ts b/packages/cli/src/commands/governance/propose-l2.test.ts index bae748874..03fb3199e 100644 --- a/packages/cli/src/commands/governance/propose-l2.test.ts +++ b/packages/cli/src/commands/governance/propose-l2.test.ts @@ -1,13 +1,19 @@ import { StrongAddress } from '@celo/base' +import { CeloProvider } from '@celo/connect/lib/celo-provider' import { newKitFromWeb3 } from '@celo/contractkit' import { GoldTokenWrapper } from '@celo/contractkit/lib/wrappers/GoldTokenWrapper' import { GovernanceWrapper } from '@celo/contractkit/lib/wrappers/Governance' -import { testWithAnvilL2 } from '@celo/dev-utils/lib/anvil-test' +import { + setBalance, + testWithAnvilL2, + withImpersonatedAccount, +} from '@celo/dev-utils/lib/anvil-test' import { ux } from '@oclif/core' +import Safe, { getSafeAddressFromDeploymentTx } from '@safe-global/protocol-kit' import * as fs from 'fs' import Web3 from 'web3' import { EXTRA_LONG_TIMEOUT_MS, testLocallyWithWeb3Node } from '../../test-utils/cliUtils' -import { createMultisig } from '../../test-utils/multisigUtils' +import { createMultisig, setupSafeContracts } from '../../test-utils/multisigUtils' import Approve from '../multisig/approve' import Propose from './propose' @@ -137,387 +143,573 @@ const structAbiDefinition = { type: 'function', } -testWithAnvilL2('governance:propose cmd', (web3: Web3) => { - const TRANSACTION_FILE_PATH = 'governance-propose-l2.test.json' - - let governance: GovernanceWrapper - let goldToken: GoldTokenWrapper - let minDeposit: string - - const kit = newKitFromWeb3(web3) - - let accounts: StrongAddress[] = [] - - beforeEach(async () => { - accounts = (await web3.eth.getAccounts()) as StrongAddress[] - kit.defaultAccount = accounts[0] - governance = await kit.contracts.getGovernance() - goldToken = await kit.contracts.getGoldToken() - minDeposit = (await governance.minDeposit()).toFixed() - }) - - test( - 'will successfully create proposal based on Core contract', - async () => { - const transactionsToBeSaved = JSON.stringify(transactions) - fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) - - await ( - await kit.sendTransaction({ - to: governance.address, - from: accounts[0], - value: web3.utils.toWei('1', 'ether'), - }) - ).waitReceipt() - - const proposalBefore = await governance.getProposal(1) - expect(proposalBefore).toEqual([]) - - await testLocallyWithWeb3Node( - Propose, - [ - '--jsonTransactions', - TRANSACTION_FILE_PATH, - '--deposit', - minDeposit, - '--from', - accounts[0], - '--descriptionURL', - 'https://example.com', - ], - web3 - ) +testWithAnvilL2( + 'governance:propose cmd', + (web3: Web3) => { + const TRANSACTION_FILE_PATH = 'governance-propose-l2.test.json' + + let governance: GovernanceWrapper + let goldToken: GoldTokenWrapper + let minDeposit: string + + const kit = newKitFromWeb3(web3) + + let accounts: StrongAddress[] = [] + + beforeEach(async () => { + accounts = (await web3.eth.getAccounts()) as StrongAddress[] + kit.defaultAccount = accounts[0] + governance = await kit.contracts.getGovernance() + goldToken = await kit.contracts.getGoldToken() + minDeposit = (await governance.minDeposit()).toFixed() + }) + + test( + 'will successfully create proposal based on Core contract', + async () => { + const transactionsToBeSaved = JSON.stringify(transactions) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + + await ( + await kit.sendTransaction({ + to: governance.address, + from: accounts[0], + value: web3.utils.toWei('1', 'ether'), + }) + ).waitReceipt() + + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + minDeposit, + '--from', + accounts[0], + '--descriptionURL', + 'https://example.com', + ], + web3 + ) - const proposal = await governance.getProposal(1) - expect(proposal.length).toEqual(transactions.length) - expect(proposal[0].to).toEqual(goldToken.address) - expect(proposal[0].value).toEqual(transactions[0].value) - const expectedInput = goldToken['contract'].methods['transfer']( - transactions[0].args[0], - transactions[0].args[1] - ).encodeABI() - expect(proposal[0].input).toEqual(expectedInput) - }, - EXTRA_LONG_TIMEOUT_MS * 2 - ) - - test( - 'will successfully create proposal based on Core contract with multisig (1 signer)', - async () => { - const transactionsToBeSaved = JSON.stringify(transactions) - fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) - - await ( - await kit.sendTransaction({ - from: accounts[0], - to: governance.address, - value: web3.utils.toWei('1', 'ether'), - }) - ).waitReceipt() - - const multisigWithOneSigner = await createMultisig(kit, [accounts[0]], 1, 1) - /** - * Faucet the multisig with 20,000 CELO so it has more than sufficient CELO - * to submit governance proposals (2x minDeposit in this case). - * In practice, `minDeposit` is currently 1 CELO on the devchain, so practically 20,000 CELO - * is too much. But I'm leaving this in case we update the devchain to match - * Alfajores or Mainnet parameters in the future. - */ - await ( - await kit.sendTransaction({ - from: accounts[2], - to: multisigWithOneSigner, - value: web3.utils.toWei('20000', 'ether'), // 2x min deposit on Mainnet - }) - ).waitReceipt() - - const proposalBefore = await governance.getProposal(1) - expect(proposalBefore).toEqual([]) - - await testLocallyWithWeb3Node( - Propose, - [ - '--jsonTransactions', - TRANSACTION_FILE_PATH, - '--deposit', - '10000e18', - '--from', - accounts[0], - '--useMultiSig', - '--for', - multisigWithOneSigner, - '--descriptionURL', - 'https://dummyurl.com', - ], - web3 - ) + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual(goldToken.address) + expect(proposal[0].value).toEqual(transactions[0].value) + const expectedInput = goldToken['contract'].methods['transfer']( + transactions[0].args[0], + transactions[0].args[1] + ).encodeABI() + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS * 2 + ) + + test( + 'will successfully create proposal based on Core contract with multisig (1 signer)', + async () => { + const transactionsToBeSaved = JSON.stringify(transactions) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + + await ( + await kit.sendTransaction({ + from: accounts[0], + to: governance.address, + value: web3.utils.toWei('1', 'ether'), + }) + ).waitReceipt() + + const multisigWithOneSigner = await createMultisig(kit, [accounts[0]], 1, 1) + /** + * Faucet the multisig with 20,000 CELO so it has more than sufficient CELO + * to submit governance proposals (2x minDeposit in this case). + * In practice, `minDeposit` is currently 1 CELO on the devchain, so practically 20,000 CELO + * is too much. But I'm leaving this in case we update the devchain to match + * Alfajores or Mainnet parameters in the future. + */ + await ( + await kit.sendTransaction({ + from: accounts[2], + to: multisigWithOneSigner, + value: web3.utils.toWei('20000', 'ether'), // 2x min deposit on Mainnet + }) + ).waitReceipt() + + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + '10000e18', + '--from', + accounts[0], + '--useMultiSig', + '--for', + multisigWithOneSigner, + '--descriptionURL', + 'https://dummyurl.com', + ], + web3 + ) - const proposal = await governance.getProposal(1) - expect(proposal.length).toEqual(transactions.length) - expect(proposal[0].to).toEqual(goldToken.address) - expect(proposal[0].value).toEqual(transactions[0].value) - const expectedInput = goldToken['contract'].methods['transfer']( - transactions[0].args[0], - transactions[0].args[1] - ).encodeABI() - expect(proposal[0].input).toEqual(expectedInput) - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'will successfully create proposal based on Core contract with multisig (2 signers)', - async () => { - const transactionsToBeSaved = JSON.stringify(transactions) - fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) - - await ( - await kit.sendTransaction({ - to: governance.address, - from: accounts[0], - value: web3.utils.toWei('1', 'ether'), - }) - ).waitReceipt() - - const multisigWithTwoSigners = await createMultisig(kit, [accounts[0], accounts[1]], 2, 2) - /** - * Faucet the multisig with 20,000 CELO so it has more than sufficient CELO - * to submit governance proposals (2x minDeposit in this case). - * In practice, `minDeposit` is currently 1 CELO on the devchain, so practically 20,000 CELO - * is too much. But I'm leaving this in case we update the devchain to match - * Alfajores or Mainnet parameters in the future. - */ - await ( - await kit.sendTransaction({ - from: accounts[2], - to: multisigWithTwoSigners, - value: web3.utils.toWei('20000', 'ether'), // 2x min deposit on Mainnet - }) - ).waitReceipt() - - const proposalBefore = await governance.getProposal(1) - expect(proposalBefore).toEqual([]) - - // Submit proposal from signer A - await testLocallyWithWeb3Node( - Propose, - [ - '--jsonTransactions', - TRANSACTION_FILE_PATH, - '--deposit', - '10000e18', - '--from', - accounts[0], - '--useMultiSig', - '--for', - multisigWithTwoSigners, - '--descriptionURL', - 'https://dummyurl.com', - ], - web3 - ) + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual(goldToken.address) + expect(proposal[0].value).toEqual(transactions[0].value) + const expectedInput = goldToken['contract'].methods['transfer']( + transactions[0].args[0], + transactions[0].args[1] + ).encodeABI() + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS + ) + + test( + 'will successfully create proposal based on Core contract with multisig (2 signers)', + async () => { + const transactionsToBeSaved = JSON.stringify(transactions) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + + await ( + await kit.sendTransaction({ + to: governance.address, + from: accounts[0], + value: web3.utils.toWei('1', 'ether'), + }) + ).waitReceipt() + + const multisigWithTwoSigners = await createMultisig(kit, [accounts[0], accounts[1]], 2, 2) + /** + * Faucet the multisig with 20,000 CELO so it has more than sufficient CELO + * to submit governance proposals (2x minDeposit in this case). + * In practice, `minDeposit` is currently 1 CELO on the devchain, so practically 20,000 CELO + * is too much. But I'm leaving this in case we update the devchain to match + * Alfajores or Mainnet parameters in the future. + */ + await ( + await kit.sendTransaction({ + from: accounts[2], + to: multisigWithTwoSigners, + value: web3.utils.toWei('20000', 'ether'), // 2x min deposit on Mainnet + }) + ).waitReceipt() + + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + // Submit proposal from signer A + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + '10000e18', + '--from', + accounts[0], + '--useMultiSig', + '--for', + multisigWithTwoSigners, + '--descriptionURL', + 'https://dummyurl.com', + ], + web3 + ) - const proposalBetween = await governance.getProposal(1) - expect(proposalBetween).toEqual([]) + const proposalBetween = await governance.getProposal(1) + expect(proposalBetween).toEqual([]) - // Approve proposal from signer B - await testLocallyWithWeb3Node( - Approve, - ['--from', accounts[1], '--for', multisigWithTwoSigners, '--tx', '0'], - web3 - ) + // Approve proposal from signer B + await testLocallyWithWeb3Node( + Approve, + ['--from', accounts[1], '--for', multisigWithTwoSigners, '--tx', '0'], + web3 + ) - const proposal = await governance.getProposal(1) - expect(proposal.length).toEqual(transactions.length) - expect(proposal[0].to).toEqual(goldToken.address) - expect(proposal[0].value).toEqual(transactions[0].value) - const expectedInput = goldToken['contract'].methods['transfer']( - transactions[0].args[0], - transactions[0].args[1] - ).encodeABI() - expect(proposal[0].input).toEqual(expectedInput) - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'will successfully create proposal with random contract', - async () => { - const transactionsToBeSaved = JSON.stringify(transactionsUnknownAddress) - fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) - - await ( - await kit.sendTransaction({ - to: governance.address, - from: accounts[0], - value: web3.utils.toWei('1', 'ether'), - }) - ).waitReceipt() - - const proposalBefore = await governance.getProposal(1) - expect(proposalBefore).toEqual([]) - - await testLocallyWithWeb3Node( - Propose, - [ - '--jsonTransactions', - TRANSACTION_FILE_PATH, - '--deposit', - minDeposit, - '--from', - accounts[0], - '--descriptionURL', - 'https://dummyurl.com', - '--force', - '--noInfo', - ], - web3 + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual(goldToken.address) + expect(proposal[0].value).toEqual(transactions[0].value) + const expectedInput = goldToken['contract'].methods['transfer']( + transactions[0].args[0], + transactions[0].args[1] + ).encodeABI() + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS + ) + + describe('with safe', () => { + beforeEach(async () => { + await setupSafeContracts(web3) + }) + + test( + 'will successfully create proposal based on Core contract (1 owner)', + async () => { + const [owner1] = (await web3.eth.getAccounts()) as StrongAddress[] + const safeAccountConfig = { + owners: [owner1], + threshold: 1, + } + + const predictSafe = { + safeAccountConfig, + } + const protocolKit = await Safe.init({ + predictedSafe: predictSafe, + provider: (web3.currentProvider as any as CeloProvider).toEip1193Provider(), + signer: owner1, + }) + const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + const receipt = await web3.eth.sendTransaction({ + from: owner1, + ...deploymentTransaction, + }) + const safeAddress = getSafeAddressFromDeploymentTx( + receipt as unknown as Parameters[0], + '1.3.0' + ) as StrongAddress + await protocolKit.connect({ safeAddress }) + + const balance = BigInt(web3.utils.toWei('100', 'ether')) + await setBalance(web3, goldToken.address, balance) + await setBalance(web3, governance.address, balance) + await setBalance(web3, owner1, balance) + await setBalance(web3, safeAddress, balance) + + const transactionsToBeSaved = JSON.stringify(transactions) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + const deposit = await governance.minDeposit() + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + // Submit proposal from signer A + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + deposit.toString(), + '--from', + owner1, + '--useSafe', + '--safeAddress', + safeAddress, + '--descriptionURL', + 'https://dummyurl.com', + ], + web3 + ) + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual(goldToken.address) + expect(proposal[0].value).toEqual(transactions[0].value) + const expectedInput = goldToken['contract'].methods['transfer']( + transactions[0].args[0], + transactions[0].args[1] + ).encodeABI() + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS ) - const proposal = await governance.getProposal(1) - expect(proposal.length).toEqual(transactions.length) - expect(proposal[0].to).toEqual(randomAddress) - expect(proposal[0].value).toEqual(transactions[0].value) - const expectedInput = goldToken['contract'].methods['transfer']( - transactions[0].args[0], - transactions[0].args[1] - ).encodeABI() - expect(proposal[0].input).toEqual(expectedInput) - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'will successfully create proposal with struct as input', - async () => { - const transactionsToBeSaved = JSON.stringify(transactionsWithStruct) - fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) - - await ( - await kit.sendTransaction({ - to: governance.address, - from: accounts[0], - value: web3.utils.toWei('1', 'ether'), - }) - ).waitReceipt() - - const proposalBefore = await governance.getProposal(1) - expect(proposalBefore).toEqual([]) - - await testLocallyWithWeb3Node( - Propose, - [ - '--jsonTransactions', - TRANSACTION_FILE_PATH, - '--deposit', - minDeposit, - '--from', - accounts[0], - '--descriptionURL', - 'https://dummyurl.com', - '--force', - '--noInfo', - ], - web3 + test( + 'will successfully create proposal based on Core contract (2 owners)', + async () => { + const [owner1] = (await web3.eth.getAccounts()) as StrongAddress[] + const owner2 = '0x6C666E57A5E8715cFE93f92790f98c4dFf7b69e2' + const safeAccountConfig = { + owners: [owner1, owner2], + threshold: 2, + } + + const predictSafe = { + safeAccountConfig, + } + const protocolKit = await Safe.init({ + predictedSafe: predictSafe, + provider: (web3.currentProvider as any as CeloProvider).toEip1193Provider(), + signer: owner1, + }) + const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + const receipt = await web3.eth.sendTransaction({ + from: owner1, + ...deploymentTransaction, + }) + const safeAddress = getSafeAddressFromDeploymentTx( + receipt as unknown as Parameters[0], + '1.3.0' + ) as StrongAddress + await protocolKit.connect({ safeAddress }) + + const balance = BigInt(web3.utils.toWei('100', 'ether')) + await setBalance(web3, goldToken.address, balance) + await setBalance(web3, governance.address, balance) + await setBalance(web3, owner1, balance) + await setBalance(web3, owner2, balance) + await setBalance(web3, safeAddress, balance) + + const transactionsToBeSaved = JSON.stringify(transactions) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + const deposit = await governance.minDeposit() + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + // Submit proposal from signer 1 + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + deposit.toString(), + '--from', + owner1, + '--useSafe', + '--safeAddress', + safeAddress, + '--descriptionURL', + 'https://dummyurl.com', + ], + web3 + ) + const proposalBefore2ndOwner = await governance.getProposal(1) + expect(proposalBefore2ndOwner).toEqual([]) + + await withImpersonatedAccount(web3, owner2, async () => { + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + deposit.toString(), + '--from', + owner2, + '--useSafe', + '--safeAddress', + safeAddress, + '--descriptionURL', + 'https://dummyurl.com', + ], + web3 + ) + }) + + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual(goldToken.address) + expect(proposal[0].value).toEqual(transactions[0].value) + const expectedInput = goldToken['contract'].methods['transfer']( + transactions[0].args[0], + transactions[0].args[1] + ).encodeABI() + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS ) + }) + + test( + 'will successfully create proposal with random contract', + async () => { + const transactionsToBeSaved = JSON.stringify(transactionsUnknownAddress) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + + await ( + await kit.sendTransaction({ + to: governance.address, + from: accounts[0], + value: web3.utils.toWei('1', 'ether'), + }) + ).waitReceipt() + + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + minDeposit, + '--from', + accounts[0], + '--descriptionURL', + 'https://dummyurl.com', + '--force', + '--noInfo', + ], + web3 + ) - const proposal = await governance.getProposal(1) - expect(proposal.length).toEqual(transactions.length) - expect(proposal[0].to).toEqual('0x3d79EdAaBC0EaB6F08ED885C05Fc0B014290D95A') - expect(proposal[0].value).toEqual(transactions[0].value) - - const expectedInput = kit.connection - .getAbiCoder() - .encodeFunctionCall(structAbiDefinition, [JSON.parse(transactionsWithStruct[0].args[0])]) + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual(randomAddress) + expect(proposal[0].value).toEqual(transactions[0].value) + const expectedInput = goldToken['contract'].methods['transfer']( + transactions[0].args[0], + transactions[0].args[1] + ).encodeABI() + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS + ) + + test( + 'will successfully create proposal with struct as input', + async () => { + const transactionsToBeSaved = JSON.stringify(transactionsWithStruct) + fs.writeFileSync(TRANSACTION_FILE_PATH, transactionsToBeSaved, { flag: 'w' }) + + await ( + await kit.sendTransaction({ + to: governance.address, + from: accounts[0], + value: web3.utils.toWei('1', 'ether'), + }) + ).waitReceipt() + + const proposalBefore = await governance.getProposal(1) + expect(proposalBefore).toEqual([]) + + await testLocallyWithWeb3Node( + Propose, + [ + '--jsonTransactions', + TRANSACTION_FILE_PATH, + '--deposit', + minDeposit, + '--from', + accounts[0], + '--descriptionURL', + 'https://dummyurl.com', + '--force', + '--noInfo', + ], + web3 + ) - expect(proposal[0].input).toEqual(expectedInput) - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'fails when descriptionURl is missing', - async () => { - await expect( - testLocallyWithWeb3Node( + const proposal = await governance.getProposal(1) + expect(proposal.length).toEqual(transactions.length) + expect(proposal[0].to).toEqual('0x3d79EdAaBC0EaB6F08ED885C05Fc0B014290D95A') + expect(proposal[0].value).toEqual(transactions[0].value) + + const expectedInput = kit.connection + .getAbiCoder() + .encodeFunctionCall(structAbiDefinition, [JSON.parse(transactionsWithStruct[0].args[0])]) + + expect(proposal[0].input).toEqual(expectedInput) + }, + EXTRA_LONG_TIMEOUT_MS + ) + + test( + 'fails when descriptionURl is missing', + async () => { + await expect( + testLocallyWithWeb3Node( + Propose, + [ + '--from', + accounts[0], + '--deposit', + '0', + '--jsonTransactions', + './exampleProposal.json', + ], + web3 + ) + ).rejects.toThrow('Missing required flag descriptionURL') + }, + EXTRA_LONG_TIMEOUT_MS + ) + + test( + 'can submit empty proposal', + async () => { + await testLocallyWithWeb3Node( Propose, - ['--from', accounts[0], '--deposit', '0', '--jsonTransactions', './exampleProposal.json'], + [ + '--from', + accounts[0], + '--deposit', + minDeposit, + '--jsonTransactions', + './exampleProposal.json', + '--descriptionURL', + 'https://example.com', + ], web3 ) - ).rejects.toThrow('Missing required flag descriptionURL') - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'can submit empty proposal', - async () => { - await testLocallyWithWeb3Node( - Propose, - [ - '--from', - accounts[0], - '--deposit', - minDeposit, - '--jsonTransactions', - './exampleProposal.json', - '--descriptionURL', - 'https://example.com', - ], - web3 - ) - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'can submit proposal using e notion for deposit', - async () => { - const spyStart = jest.spyOn(ux.action, 'start') - const spyStop = jest.spyOn(ux.action, 'stop') - await testLocallyWithWeb3Node( - Propose, - [ - '--from', - accounts[0], - '--deposit', - '10000e18', - '--jsonTransactions', - './exampleProposal.json', - '--descriptionURL', - 'https://example.com', - ], - web3 - ) - expect(spyStart).toHaveBeenCalledWith('Sending Transaction: proposeTx') - expect(spyStop).toHaveBeenCalled() - }, - EXTRA_LONG_TIMEOUT_MS - ) - - test( - 'when deposit is 10K it succeeds', - async () => { - const spyStart = jest.spyOn(ux.action, 'start') - const spyStop = jest.spyOn(ux.action, 'stop') - - await testLocallyWithWeb3Node( - Propose, - [ - '--from', - accounts[0], - '--deposit', - '10000000000000000000000', - '--jsonTransactions', - './exampleProposal.json', - '--descriptionURL', - 'https://example.com', - ], - web3 - ) - expect(spyStart).toHaveBeenCalledWith('Sending Transaction: proposeTx') - expect(spyStop).toHaveBeenCalled() - }, - EXTRA_LONG_TIMEOUT_MS - ) -}) + }, + EXTRA_LONG_TIMEOUT_MS + ) + + test( + 'can submit proposal using e notion for deposit', + async () => { + const spyStart = jest.spyOn(ux.action, 'start') + const spyStop = jest.spyOn(ux.action, 'stop') + await testLocallyWithWeb3Node( + Propose, + [ + '--from', + accounts[0], + '--deposit', + '10000e18', + '--jsonTransactions', + './exampleProposal.json', + '--descriptionURL', + 'https://example.com', + ], + web3 + ) + expect(spyStart).toHaveBeenCalledWith('Sending Transaction: proposeTx') + expect(spyStop).toHaveBeenCalled() + }, + EXTRA_LONG_TIMEOUT_MS + ) + + test( + 'when deposit is 10K it succeeds', + async () => { + const spyStart = jest.spyOn(ux.action, 'start') + const spyStop = jest.spyOn(ux.action, 'stop') + + await testLocallyWithWeb3Node( + Propose, + [ + '--from', + accounts[0], + '--deposit', + '10000000000000000000000', + '--jsonTransactions', + './exampleProposal.json', + '--descriptionURL', + 'https://example.com', + ], + web3 + ) + expect(spyStart).toHaveBeenCalledWith('Sending Transaction: proposeTx') + expect(spyStop).toHaveBeenCalled() + }, + EXTRA_LONG_TIMEOUT_MS + ) + }, + { + chainId: 42220, + } +) diff --git a/packages/cli/src/commands/governance/propose.ts b/packages/cli/src/commands/governance/propose.ts index f032c3eef..c86b395fe 100644 --- a/packages/cli/src/commands/governance/propose.ts +++ b/packages/cli/src/commands/governance/propose.ts @@ -6,18 +6,24 @@ import { BaseCommand } from '../../base' import { newCheckBuilder } from '../../utils/checks' import { displaySendTx, printValueMapRecursive } from '../../utils/cli' import { CustomFlags } from '../../utils/command' -import { MultiSigFlags } from '../../utils/flags' +import { MultiSigFlags, SafeFlags } from '../../utils/flags' import { addExistingProposalIDToBuilder, addExistingProposalJSONFileToBuilder, checkProposal, } from '../../utils/governance' +import { + createSafeFromWeb3, + performSafeTransaction, + safeTransactionMetadataFromCeloTransactionObject, +} from '../../utils/safe' export default class Propose extends BaseCommand { static description = 'Submit a governance proposal' static flags = { ...BaseCommand.flags, ...MultiSigFlags, + ...SafeFlags, jsonTransactions: Flags.string({ required: true, description: 'Path to json transactions', @@ -62,13 +68,19 @@ export default class Propose extends BaseCommand { ) } const useMultiSig = res.flags.useMultiSig - if (res.flags.for && !res.flags.useMultiSig) { + if (res.flags.for && !useMultiSig) { this.error('If the --for flag is set, then the --useMultiSig flag has to also be set.') } const proposerMultiSig = res.flags.for ? await kit.contracts.getMultiSig(res.flags.for) : undefined - const proposer = useMultiSig ? proposerMultiSig!.address : account + const useSafe = res.flags.useSafe + const governance = await kit.contracts.getGovernance() + const proposer = useMultiSig + ? proposerMultiSig!.address + : useSafe + ? res.flags.safeAddress! + : account const builder = new ProposalBuilder(kit) @@ -93,14 +105,16 @@ export default class Propose extends BaseCommand { printValueMapRecursive(await proposalToJSON(kit, proposal, builder.registryAdditions)) } - const governance = await kit.contracts.getGovernance() - await newCheckBuilder(this, proposer) .hasEnoughCelo(proposer, deposit) .exceedsProposalMinDeposit(deposit) .addConditionalCheck(`${account} is multisig signatory`, useMultiSig, () => proposerMultiSig!.isOwner(account) ) + .addConditionalCheck(`${account} is a safe owner`, useSafe, async () => { + const safe = await createSafeFromWeb3(await this.getWeb3(), account, proposer) + return safe.isOwner(account) + }) .runChecks() if (!res.flags.force) { @@ -119,6 +133,17 @@ export default class Propose extends BaseCommand { deposit.toFixed() ) await displaySendTx('proposeTx', multiSigTx, {}, 'ProposalQueued') + } else if (useSafe) { + await performSafeTransaction( + await this.getWeb3(), + proposer, + account, + await safeTransactionMetadataFromCeloTransactionObject( + governanceTx, + governance.address, + deposit.toFixed() + ) + ) } else { await displaySendTx( 'proposeTx', diff --git a/packages/cli/src/commands/governance/withdraw-l2.test.ts b/packages/cli/src/commands/governance/withdraw-l2.test.ts index d50eb553d..74d5531b0 100644 --- a/packages/cli/src/commands/governance/withdraw-l2.test.ts +++ b/packages/cli/src/commands/governance/withdraw-l2.test.ts @@ -1,4 +1,5 @@ import { StrongAddress } from '@celo/base' +import { CeloProvider } from '@celo/connect/lib/celo-provider' import { newKitFromWeb3 } from '@celo/contractkit' import { GovernanceWrapper, Proposal } from '@celo/contractkit/lib/wrappers/Governance' import { @@ -8,189 +9,312 @@ import { } from '@celo/dev-utils/lib/anvil-test' import { timeTravel } from '@celo/dev-utils/lib/ganache-test' import { ProposalBuilder } from '@celo/governance' +import Safe, { getSafeAddressFromDeploymentTx } from '@safe-global/protocol-kit' import BigNumber from 'bignumber.js' import Web3 from 'web3' import { stripAnsiCodesFromNestedArray, testLocallyWithWeb3Node } from '../../test-utils/cliUtils' -import { createMultisig } from '../../test-utils/multisigUtils' +import { createMultisig, setupSafeContracts } from '../../test-utils/multisigUtils' import Withdraw from './withdraw' process.env.NO_SYNCCHECK = 'true' -testWithAnvilL2('governance:withdraw', (web3: Web3) => { - let logMock = jest.spyOn(console, 'log') - let errorMock = jest.spyOn(console, 'error') - - let minDeposit: string - const kit = newKitFromWeb3(web3) - - let accounts: StrongAddress[] = [] - let governance: GovernanceWrapper - - beforeEach(async () => { - logMock.mockClear().mockImplementation() - errorMock.mockClear().mockImplementation() - - accounts = (await web3.eth.getAccounts()) as StrongAddress[] - kit.defaultAccount = accounts[0] - governance = await kit.contracts.getGovernance() - minDeposit = (await governance.minDeposit()).toFixed() - const proposal: Proposal = await new ProposalBuilder(kit).build() - await governance - .propose(proposal, 'URL') - .sendAndWaitForReceipt({ from: accounts[0], value: minDeposit }) - const dequeueFrequency = (await governance.dequeueFrequency()).toNumber() - await timeTravel(dequeueFrequency + 1, web3) - await governance.dequeueProposalsIfReady().sendAndWaitForReceipt() - }) - - test('can withdraw', async () => { - const balanceBefore = await kit.connection.getBalance(accounts[0]) - - await testLocallyWithWeb3Node(Withdraw, ['--from', accounts[0]], web3) - - const balanceAfter = await kit.connection.getBalance(accounts[0]) - const latestTransactionReceipt = await web3.eth.getTransactionReceipt( - ( - await web3.eth.getBlock('latest') - ).transactions[0] - ) - - // Safety check if the latest transaction was originated by expected account - expect(latestTransactionReceipt.from.toLowerCase()).toEqual(accounts[0].toLowerCase()) - - const difference = new BigNumber(balanceAfter) - .minus(balanceBefore) - .plus(latestTransactionReceipt.effectiveGasPrice * latestTransactionReceipt.gasUsed) - - expect(difference.toFixed()).toEqual(minDeposit) - - expect(stripAnsiCodesFromNestedArray(logMock.mock.calls)).toMatchInlineSnapshot(` - [ - [ - "Running Checks:", - ], - [ - " ✔ 0x5409ED021D9299bf6814279A6A1411A7e866A631 has refunded governance deposits ", - ], - [ - "All checks passed", - ], - [ - "SendTransaction: withdraw", - ], - [ - "txHash: 0xtxhash", - ], - ] - `) - expect(stripAnsiCodesFromNestedArray(errorMock.mock.calls)).toMatchInlineSnapshot(`[]`) - }) - - describe('multisig', () => { - let multisigAddress: StrongAddress - let multisigOwner: StrongAddress +testWithAnvilL2( + 'governance:withdraw', + (web3: Web3) => { + let logMock = jest.spyOn(console, 'log') + let errorMock = jest.spyOn(console, 'error') - beforeEach(async () => { - multisigOwner = accounts[0] - multisigAddress = await createMultisig(kit, [multisigOwner], 1, 1) + let minDeposit: string + const kit = newKitFromWeb3(web3) - await withImpersonatedAccount( - web3, - multisigAddress, - async () => { - await governance - .propose(await new ProposalBuilder(kit).build(), 'http://example.com/proposal.json') - .sendAndWaitForReceipt({ from: multisigAddress, value: minDeposit }) - }, - // make sure the multisig contract has enough balance to perform the transaction - new BigNumber(minDeposit).multipliedBy(2) - ) + let accounts: StrongAddress[] = [] + let governance: GovernanceWrapper - // Zero out the balance for easier testing - await setBalance(web3, multisigAddress, 0) + beforeEach(async () => { + logMock.mockClear().mockImplementation() + errorMock.mockClear().mockImplementation() - // Dequeue so the proposal can be refunded + accounts = (await web3.eth.getAccounts()) as StrongAddress[] + kit.defaultAccount = accounts[0] + governance = await kit.contracts.getGovernance() + minDeposit = (await governance.minDeposit()).toFixed() + const proposal: Proposal = await new ProposalBuilder(kit).build() + await governance + .propose(proposal, 'URL') + .sendAndWaitForReceipt({ from: accounts[0], value: minDeposit }) const dequeueFrequency = (await governance.dequeueFrequency()).toNumber() await timeTravel(dequeueFrequency + 1, web3) await governance.dequeueProposalsIfReady().sendAndWaitForReceipt() }) - it('can withdraw using --useMultiSig', async () => { - // Safety check - expect(await kit.connection.getBalance(multisigAddress)).toEqual('0') + test('can withdraw', async () => { + const balanceBefore = await kit.connection.getBalance(accounts[0]) + + await testLocallyWithWeb3Node(Withdraw, ['--from', accounts[0]], web3) - await testLocallyWithWeb3Node( - Withdraw, - ['--useMultiSig', '--for', multisigAddress, '--from', multisigOwner], - web3 + const balanceAfter = await kit.connection.getBalance(accounts[0]) + const latestTransactionReceipt = await web3.eth.getTransactionReceipt( + ( + await web3.eth.getBlock('latest') + ).transactions[0] ) - // After withdrawing the refunded deposit should be the minDeposit (as we zeroed out the balance before) - expect(await kit.connection.getBalance(multisigAddress)).toEqual(minDeposit) + // Safety check if the latest transaction was originated by expected account + expect(latestTransactionReceipt.from.toLowerCase()).toEqual(accounts[0].toLowerCase()) + + const difference = new BigNumber(balanceAfter) + .minus(balanceBefore) + .plus(latestTransactionReceipt.effectiveGasPrice * latestTransactionReceipt.gasUsed) + + expect(difference.toFixed()).toEqual(minDeposit) expect(stripAnsiCodesFromNestedArray(logMock.mock.calls)).toMatchInlineSnapshot(` - [ - [ - "Running Checks:", - ], - [ - " ✔ 0x871DD7C2B4b25E1Aa18728e9D5f2Af4C4e431f5c has refunded governance deposits ", - ], - [ - " ✔ The provided address is an owner of the multisig ", - ], - [ - "All checks passed", - ], - [ - "SendTransaction: withdraw", - ], - [ - "txHash: 0xtxhash", - ], - [ - "Deposit:", - ], - [ - "sender: 0x2EB25B5eb9d5A4f61deb1e4F846343F862eB67D9 - value: 100000000000000000000", - ], - ] - `) + [ + [ + "Running Checks:", + ], + [ + " ✔ 0x5409ED021D9299bf6814279A6A1411A7e866A631 has refunded governance deposits ", + ], + [ + "All checks passed", + ], + [ + "SendTransaction: withdraw", + ], + [ + "txHash: 0xtxhash", + ], + ] + `) expect(stripAnsiCodesFromNestedArray(errorMock.mock.calls)).toMatchInlineSnapshot(`[]`) }) - it('fails if trying to withdraw using --useMultiSig not as a signatory', async () => { - const otherAccount = accounts[1] + describe('multisig', () => { + let multisigAddress: StrongAddress + let multisigOwner: StrongAddress + + beforeEach(async () => { + multisigOwner = accounts[0] + multisigAddress = await createMultisig(kit, [multisigOwner], 1, 1) - // Safety check - expect(await kit.connection.getBalance(multisigAddress)).toEqual('0') + await withImpersonatedAccount( + web3, + multisigAddress, + async () => { + await governance + .propose(await new ProposalBuilder(kit).build(), 'http://example.com/proposal.json') + .sendAndWaitForReceipt({ from: multisigAddress, value: minDeposit }) + }, + // make sure the multisig contract has enough balance to perform the transaction + new BigNumber(minDeposit).multipliedBy(2) + ) + + // Zero out the balance for easier testing + await setBalance(web3, multisigAddress, 0) + + // Dequeue so the proposal can be refunded + const dequeueFrequency = (await governance.dequeueFrequency()).toNumber() + await timeTravel(dequeueFrequency + 1, web3) + await governance.dequeueProposalsIfReady().sendAndWaitForReceipt() + }) - await expect( - testLocallyWithWeb3Node( + it('can withdraw using --useMultiSig', async () => { + // Safety check + expect(await kit.connection.getBalance(multisigAddress)).toEqual('0') + + await testLocallyWithWeb3Node( Withdraw, - ['--useMultiSig', '--for', multisigAddress, '--from', otherAccount], + ['--useMultiSig', '--for', multisigAddress, '--from', multisigOwner], web3 ) - ).rejects.toMatchInlineSnapshot(`[Error: Some checks didn't pass!]`) - // After failing to withdraw the deposit, the balance should still be zero - expect(await kit.connection.getBalance(multisigAddress)).toEqual('0') + // After withdrawing the refunded deposit should be the minDeposit (as we zeroed out the balance before) + expect(await kit.connection.getBalance(multisigAddress)).toEqual(minDeposit) - expect(stripAnsiCodesFromNestedArray(logMock.mock.calls)).toMatchInlineSnapshot(` - [ - [ - "Running Checks:", - ], - [ - " ✔ 0x871DD7C2B4b25E1Aa18728e9D5f2Af4C4e431f5c has refunded governance deposits ", - ], - [ - " ✘ The provided address is an owner of the multisig ", - ], + expect(stripAnsiCodesFromNestedArray(logMock.mock.calls)).toMatchInlineSnapshot(` + [ + [ + "Running Checks:", + ], + [ + " ✔ 0x871DD7C2B4b25E1Aa18728e9D5f2Af4C4e431f5c has refunded governance deposits ", + ], + [ + " ✔ The provided address is an owner of the multisig ", + ], + [ + "All checks passed", + ], + [ + "SendTransaction: withdraw", + ], + [ + "txHash: 0xtxhash", + ], + [ + "Deposit:", + ], + [ + "sender: 0x2EB25B5eb9d5A4f61deb1e4F846343F862eB67D9 + value: 100000000000000000000", + ], + ] + `) + expect(stripAnsiCodesFromNestedArray(errorMock.mock.calls)).toMatchInlineSnapshot(`[]`) + }) + + it('fails if trying to withdraw using --useMultiSig not as a signatory', async () => { + const otherAccount = accounts[1] + + // Safety check + expect(await kit.connection.getBalance(multisigAddress)).toEqual('0') + + await expect( + testLocallyWithWeb3Node( + Withdraw, + ['--useMultiSig', '--for', multisigAddress, '--from', otherAccount], + web3 + ) + ).rejects.toMatchInlineSnapshot(`[Error: Some checks didn't pass!]`) + + // After failing to withdraw the deposit, the balance should still be zero + expect(await kit.connection.getBalance(multisigAddress)).toEqual('0') + + expect(stripAnsiCodesFromNestedArray(logMock.mock.calls)).toMatchInlineSnapshot(` + [ + [ + "Running Checks:", + ], + [ + " ✔ 0x871DD7C2B4b25E1Aa18728e9D5f2Af4C4e431f5c has refunded governance deposits ", + ], + [ + " ✘ The provided address is an owner of the multisig ", + ], + ] + `) + expect(stripAnsiCodesFromNestedArray(errorMock.mock.calls)).toMatchInlineSnapshot(`[]`) + }) + }) + + describe('useSafe', () => { + let safeAddress: StrongAddress + let owners: StrongAddress[] + + beforeEach(async () => { + await setupSafeContracts(web3) + + owners = [ + (await web3.eth.getAccounts())[0] as StrongAddress, + '0x6C666E57A5E8715cFE93f92790f98c4dFf7b69e2', ] - `) - expect(stripAnsiCodesFromNestedArray(errorMock.mock.calls)).toMatchInlineSnapshot(`[]`) + const safeAccountConfig = { + owners, + threshold: 2, + } + + const predictSafe = { + safeAccountConfig, + } + const protocolKit = await Safe.init({ + predictedSafe: predictSafe, + provider: (web3.currentProvider as any as CeloProvider).toEip1193Provider(), + signer: owners[0], + }) + const deploymentTransaction = await protocolKit.createSafeDeploymentTransaction() + const receipt = await web3.eth.sendTransaction({ + from: owners[0], + ...deploymentTransaction, + }) + safeAddress = getSafeAddressFromDeploymentTx( + receipt as unknown as Parameters[0], + '1.3.0' + ) as StrongAddress + await protocolKit.connect({ safeAddress }) + + const balance = new BigNumber(minDeposit).multipliedBy(2) + await setBalance(web3, safeAddress, balance) + for (const owner of owners) { + await setBalance(web3, owner, balance) + } + + await withImpersonatedAccount(web3, safeAddress, async () => { + await governance + .propose(await new ProposalBuilder(kit).build(), 'http://example.com/proposal.json') + .sendAndWaitForReceipt({ from: safeAddress, value: minDeposit }) + }) + + // Dequeue so the proposal can be refunded + const dequeueFrequency = (await governance.dequeueFrequency()).toNumber() + await timeTravel(dequeueFrequency + 1, web3) + await governance.dequeueProposalsIfReady().sendAndWaitForReceipt() + }) + + it('can withdraw using --useSafe', async () => { + // Safety check + const amountBeforeRefund = await kit.connection.getBalance(safeAddress) + + for (const owner of owners) { + await withImpersonatedAccount(web3, owner, async () => { + await testLocallyWithWeb3Node( + Withdraw, + ['--from', owner, '--useSafe', '--safeAddress', safeAddress], + web3 + ) + }) + if (owner !== owners.at(-1)) { + expect(await kit.connection.getBalance(safeAddress)).toEqual(amountBeforeRefund) + } + } + + // After withdrawing the refunded deposit should be the minDeposit (as we zeroed out the balance before) + expect(await kit.connection.getBalance(safeAddress)).toEqual( + (BigInt(minDeposit) + BigInt(amountBeforeRefund)).toString() + ) + + expect(stripAnsiCodesFromNestedArray(logMock.mock.calls)).toMatchInlineSnapshot(` + [ + [ + "Running Checks:", + ], + [ + " ✔ 0xF5f1A68E82209C433AFBE260737801E75FD84ff4 has refunded governance deposits ", + ], + [ + " ✔ 0x5409ED021D9299bf6814279A6A1411A7e866A631 is a safe owner ", + ], + [ + "All checks passed", + ], + [ + "txHash: 0xtxhash", + ], + [ + "Running Checks:", + ], + [ + " ✔ 0xF5f1A68E82209C433AFBE260737801E75FD84ff4 has refunded governance deposits ", + ], + [ + " ✔ 0x6C666E57A5E8715cFE93f92790f98c4dFf7b69e2 is a safe owner ", + ], + [ + "All checks passed", + ], + [ + "txHash: 0xtxhash", + ], + [ + "txHash: 0xtxhash", + ], + ] + `) + expect(stripAnsiCodesFromNestedArray(errorMock.mock.calls)).toMatchInlineSnapshot(`[]`) + }) }) - }) -}) + }, + { + chainId: 42220, + } +) diff --git a/packages/cli/src/commands/governance/withdraw.ts b/packages/cli/src/commands/governance/withdraw.ts index 6b98ad4a7..89bf85dcf 100644 --- a/packages/cli/src/commands/governance/withdraw.ts +++ b/packages/cli/src/commands/governance/withdraw.ts @@ -5,7 +5,12 @@ import { BaseCommand } from '../../base' import { newCheckBuilder } from '../../utils/checks' import { displaySendTx } from '../../utils/cli' import { CustomFlags } from '../../utils/command' -import { MultiSigFlags } from '../../utils/flags' +import { MultiSigFlags, SafeFlags } from '../../utils/flags' +import { + createSafeFromWeb3, + performSafeTransaction, + safeTransactionMetadataFromCeloTransactionObject, +} from '../../utils/safe' export default class Withdraw extends BaseCommand { static description = 'Withdraw refunded governance proposal deposits.' @@ -13,6 +18,8 @@ export default class Withdraw extends BaseCommand { static flags = { ...BaseCommand.flags, ...MultiSigFlags, + ...SafeFlags, + from: CustomFlags.address({ required: true, description: "Proposer's address" }), } @@ -28,6 +35,15 @@ export default class Withdraw extends BaseCommand { if (multiSigWrapper) { checkBuilder.isMultiSigOwner(res.flags.from, multiSigWrapper) + } else if (res.flags.useSafe) { + checkBuilder.addCheck(`${res.flags.from} is a safe owner`, async () => { + const safe = await createSafeFromWeb3( + await this.getWeb3(), + res.flags.from, + res.flags.safeAddress! + ) + return safe.isOwner(res.flags.from) + }) } await checkBuilder.runChecks() @@ -43,6 +59,13 @@ export default class Withdraw extends BaseCommand { // "Deposit" event is emitted when the MultiSig contract receives the funds await displaySendTx('withdraw', multiSigTx, {}, 'Deposit') + } else if (res.flags.useSafe) { + await performSafeTransaction( + await this.getWeb3(), + res.flags.safeAddress!, + res.flags.from, + await safeTransactionMetadataFromCeloTransactionObject(withdrawTx, governance.address) + ) } else { // No event is emited otherwise await displaySendTx('withdraw', withdrawTx) @@ -64,7 +87,13 @@ export default class Withdraw extends BaseCommand { from: StrongAddress useMultiSig: boolean for?: StrongAddress + useSafe: boolean + safeAddress?: StrongAddress }): StrongAddress { - return flags.useMultiSig ? (flags.for as StrongAddress) : flags.from + return flags.useMultiSig + ? (flags.for as StrongAddress) + : flags.useSafe + ? flags.safeAddress! + : flags.from } } diff --git a/packages/cli/src/test-utils/multisigUtils.ts b/packages/cli/src/test-utils/multisigUtils.ts index 95f676080..815aad866 100644 --- a/packages/cli/src/test-utils/multisigUtils.ts +++ b/packages/cli/src/test-utils/multisigUtils.ts @@ -70,6 +70,20 @@ export async function createMultisig( return proxyAddress as StrongAddress } +/** + * + * # Warning + * + * For future users: safe expects hardcoded/deterministic + * addresses according to the chainId + * This means your tests may fail with the following error: + * > `Invalid multiSend contract address` + * + * In that case, please add the additional paramater `{ chainId: 42220 }` to the + * `testWithAnvil` options (last parameter). + * + * A working example can be found in packages/cli/src/commands/governance/approve-l2.test.ts` + */ export const setupSafeContracts = async (web3: Web3) => { // Set up safe 1.3.0 in devchain await setCode(web3, SAFE_MULTISEND_ADDRESS, SAFE_MULTISEND_CODE) diff --git a/packages/cli/src/utils/flags.ts b/packages/cli/src/utils/flags.ts index ebb989b6a..d9b178e40 100644 --- a/packages/cli/src/utils/flags.ts +++ b/packages/cli/src/utils/flags.ts @@ -33,9 +33,21 @@ export const ViewCommmandFlags = { export const MultiSigFlags = { useMultiSig: Flags.boolean({ description: 'True means the request will be sent through multisig.', + exclusive: ['useSafe'], }), for: CustomFlags.address({ dependsOn: ['useMultiSig'], description: 'Address of the multi-sig contract', }), } + +export const SafeFlags = { + useSafe: Flags.boolean({ + description: 'True means the request will be sent through SAFE (http://safe.global).', + exclusive: ['useMultiSig'], + }), + safeAddress: CustomFlags.address({ + dependsOn: ['useSafe'], + description: 'Address of the safe.', + }), +} diff --git a/packages/cli/src/utils/safe.ts b/packages/cli/src/utils/safe.ts index 3b4ffca88..4782b960e 100644 --- a/packages/cli/src/utils/safe.ts +++ b/packages/cli/src/utils/safe.ts @@ -24,12 +24,13 @@ export const createSafeFromWeb3 = async ( export const safeTransactionMetadataFromCeloTransactionObject = async ( tx: CeloTransactionObject, - toAddress: StrongAddress + toAddress: StrongAddress, + value = '0' ): Promise => { return { to: toAddress, data: tx.txo.encodeABI(), - value: '0', + value, } }