diff --git a/.changeset/two-poems-complain.md b/.changeset/two-poems-complain.md new file mode 100644 index 000000000..a82a10188 --- /dev/null +++ b/.changeset/two-poems-complain.md @@ -0,0 +1,5 @@ +--- +'@celo/celocli': patch +--- + +Require addresses the cli sends from or to not to be sanctioned diff --git a/packages/cli/package.json b/packages/cli/package.json index 4ab904cf8..c4d2580e9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "dependencies": { "@celo/abis": "10.0.0", "@celo/base": "^6.0.0", + "@celo/compliance": "~1.0.17", "@celo/connect": "^5.1.2", "@celo/contractkit": "^7.0.0", "@celo/cryptographic-utils": "^5.0.7", @@ -60,6 +61,7 @@ "bip32": "3.1.0", "chalk": "^2.4.2", "command-exists": "^1.2.9", + "cross-fetch": "3.0.6", "debug": "^4.1.1", "ethers": "5", "fs-extra": "^8.1.0", diff --git a/packages/cli/src/commands/exchange/celo.ts b/packages/cli/src/commands/exchange/celo.ts index 233a9820d..e932e1f65 100644 --- a/packages/cli/src/commands/exchange/celo.ts +++ b/packages/cli/src/commands/exchange/celo.ts @@ -49,7 +49,10 @@ export default class ExchangeCelo extends BaseCommand { const minBuyAmount = res.flags.forAtLeast const stableToken = stableTokenOptions[res.flags.stableToken] - await newCheckBuilder(this).hasEnoughCelo(res.flags.from, sellAmount).runChecks() + await newCheckBuilder(this) + .isNotSanctioned(res.flags.from) + .hasEnoughCelo(res.flags.from, sellAmount) + .runChecks() const [celoToken, stableTokenAddress, { mento, brokerAddress }] = await Promise.all([ kit.contracts.getGoldToken(), diff --git a/packages/cli/src/commands/lockedgold/withdraw.ts b/packages/cli/src/commands/lockedgold/withdraw.ts index 2e6cdaf55..8e3d8d873 100644 --- a/packages/cli/src/commands/lockedgold/withdraw.ts +++ b/packages/cli/src/commands/lockedgold/withdraw.ts @@ -20,7 +20,7 @@ export default class Withdraw extends BaseCommand { kit.defaultAccount = flags.from const lockedgold = await kit.contracts.getLockedGold() - await newCheckBuilder(this).isAccount(flags.from).runChecks() + await newCheckBuilder(this).isAccount(flags.from).isNotSanctioned(flags.from).runChecks() const currentTime = Math.round(new Date().getTime() / 1000) let madeWithdrawal = false diff --git a/packages/cli/src/commands/releasecelo/transfer-dollars.ts b/packages/cli/src/commands/releasecelo/transfer-dollars.ts index ccdde1e50..4c145049d 100644 --- a/packages/cli/src/commands/releasecelo/transfer-dollars.ts +++ b/packages/cli/src/commands/releasecelo/transfer-dollars.ts @@ -1,3 +1,4 @@ +import { newCheckBuilder } from '../../utils/checks' import { displaySendTx } from '../../utils/cli' import { CustomFlags } from '../../utils/command' import { ReleaseGoldBaseCommand } from '../../utils/release-gold-base' @@ -31,6 +32,7 @@ export default class TransferDollars extends ReleaseGoldBaseCommand { kit.defaultAccount = isRevoked ? await this.releaseGoldWrapper.getReleaseOwner() : await this.releaseGoldWrapper.getBeneficiary() + newCheckBuilder(this).isNotSanctioned(kit.defaultAccount).isNotSanctioned(flags.to).runChecks() await displaySendTx('transfer', this.releaseGoldWrapper.transfer(flags.to, flags.value)) } } diff --git a/packages/cli/src/commands/releasecelo/withdraw.test.ts b/packages/cli/src/commands/releasecelo/withdraw.test.ts index 1acd89dd5..b25967cc9 100644 --- a/packages/cli/src/commands/releasecelo/withdraw.test.ts +++ b/packages/cli/src/commands/releasecelo/withdraw.test.ts @@ -45,6 +45,7 @@ testWithGanache('releasegold:withdraw cmd', (web3: Web3) => { }) test("can't withdraw the whole balance if there is a cUSD balance", async () => { + const spy = jest.spyOn(console, 'log') await testLocally(SetLiquidityProvision, ['--contract', contractAddress, '--yesreally']) // ReleasePeriod of default contract await timeTravel(300000000, web3) @@ -65,7 +66,9 @@ testWithGanache('releasegold:withdraw cmd', (web3: Web3) => { await expect( testLocally(Withdraw, ['--contract', contractAddress, '--value', remainingBalance.toString()]) ).rejects.toThrow() - + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('The liquidity provision has not already been set') + ) // Move out the cUSD balance await testLocally(RGTransferDollars, [ '--contract', @@ -84,7 +87,6 @@ testWithGanache('releasegold:withdraw cmd', (web3: Web3) => { ]) const balanceAfter = await kit.getTotalBalance(beneficiary) expect(balanceBefore.CELO!.toNumber()).toBeLessThan(balanceAfter.CELO!.toNumber()) - // Contract should self-destruct now await expect(releaseGoldWrapper.getRemainingUnlockedBalance()).rejects.toThrow() }) diff --git a/packages/cli/src/commands/releasecelo/withdraw.ts b/packages/cli/src/commands/releasecelo/withdraw.ts index 2899153e5..a0aad9095 100644 --- a/packages/cli/src/commands/releasecelo/withdraw.ts +++ b/packages/cli/src/commands/releasecelo/withdraw.ts @@ -29,6 +29,7 @@ export default class Withdraw extends ReleaseGoldBaseCommand { const remainingUnlockedBalance = await this.releaseGoldWrapper.getRemainingUnlockedBalance() const maxDistribution = await this.releaseGoldWrapper.getMaxDistribution() const totalWithdrawn = await this.releaseGoldWrapper.getTotalWithdrawn() + kit.defaultAccount = await this.releaseGoldWrapper.getBeneficiary() await newCheckBuilder(this) .addCheck('Value does not exceed available unlocked celo', () => value.lte(remainingUnlockedBalance) @@ -53,9 +54,9 @@ export default class Withdraw extends ReleaseGoldBaseCommand { return true } ) + .isNotSanctioned(kit.defaultAccount as string) .runChecks() - kit.defaultAccount = await this.releaseGoldWrapper.getBeneficiary() await displaySendTx('withdrawTx', this.releaseGoldWrapper.withdraw(value)) } } diff --git a/packages/cli/src/commands/transfer/celo.test.ts b/packages/cli/src/commands/transfer/celo.test.ts new file mode 100644 index 000000000..9731c79f4 --- /dev/null +++ b/packages/cli/src/commands/transfer/celo.test.ts @@ -0,0 +1,85 @@ +import { COMPLIANT_ERROR_RESPONSE, SANCTIONED_ADDRESSES } from '@celo/compliance' +import { ContractKit, newKitFromWeb3 } from '@celo/contractkit' +import { testWithGanache } from '@celo/dev-utils/lib/ganache-test' +import Web3 from 'web3' +import { testLocally } from '../../test-utils/cliUtils' +import TransferCelo from './celo' + +process.env.NO_SYNCCHECK = 'true' + +// Lots of commands, sometimes times out +jest.setTimeout(15000) + +testWithGanache('transfer:celo cmd', (web3: Web3) => { + let accounts: string[] = [] + let kit: ContractKit + + beforeEach(async () => { + kit = newKitFromWeb3(web3) + accounts = await web3.eth.getAccounts() + }) + + test('can transfer celo', async () => { + const balanceBefore = await kit.getTotalBalance(accounts[0]) + const receiverBalanceBefore = await kit.getTotalBalance(accounts[1]) + const amountToTransfer = '500000000000000000000' + // Send cUSD to RG contract + await testLocally(TransferCelo, [ + '--from', + accounts[0], + '--to', + accounts[1], + '--value', + amountToTransfer, + '--gasCurrency', + 'cusd', + ]) + // RG cUSD balance should match the amount sent + const receiverBalance = await kit.getTotalBalance(accounts[1]) + expect(receiverBalance.CELO!.toFixed()).toEqual( + receiverBalanceBefore.CELO!.plus(amountToTransfer).toFixed() + ) + // Attempt to send cUSD back + await testLocally(TransferCelo, [ + '--from', + accounts[1], + '--to', + accounts[0], + '--value', + amountToTransfer, + '--gasCurrency', + 'cusd', + ]) + const balanceAfter = await kit.getTotalBalance(accounts[0]) + expect(balanceBefore.CELO).toEqual(balanceAfter.CELO) + }) + + test('should fail if to address is sanctioned', async () => { + const spy = jest.spyOn(console, 'log') + await expect( + testLocally(TransferCelo, [ + '--from', + accounts[1], + '--to', + SANCTIONED_ADDRESSES[0], + '--value', + '1', + ]) + ).rejects.toThrow() + expect(spy).toHaveBeenCalledWith(expect.stringContaining(COMPLIANT_ERROR_RESPONSE)) + }) + test('should fail if from address is sanctioned', async () => { + const spy = jest.spyOn(console, 'log') + await expect( + testLocally(TransferCelo, [ + '--from', + SANCTIONED_ADDRESSES[0], + '--to', + accounts[0], + '--value', + '1', + ]) + ).rejects.toThrow() + expect(spy).toHaveBeenCalledWith(expect.stringContaining(COMPLIANT_ERROR_RESPONSE)) + }) +}) diff --git a/packages/cli/src/commands/transfer/celo.ts b/packages/cli/src/commands/transfer/celo.ts index c1c36619d..8b8d2ecd7 100644 --- a/packages/cli/src/commands/transfer/celo.ts +++ b/packages/cli/src/commands/transfer/celo.ts @@ -32,7 +32,11 @@ export default class TransferCelo extends BaseCommand { kit.defaultAccount = from const celoToken = await kit.contracts.getGoldToken() - await newCheckBuilder(this).hasEnoughCelo(from, value).runChecks() + await newCheckBuilder(this) + .isNotSanctioned(from) + .isNotSanctioned(to) + .hasEnoughCelo(from, value) + .runChecks() await (res.flags.comment ? displaySendTx( diff --git a/packages/cli/src/commands/transfer/dollars.test.ts b/packages/cli/src/commands/transfer/dollars.test.ts new file mode 100644 index 000000000..552f6d80b --- /dev/null +++ b/packages/cli/src/commands/transfer/dollars.test.ts @@ -0,0 +1,71 @@ +import { COMPLIANT_ERROR_RESPONSE, SANCTIONED_ADDRESSES } from '@celo/compliance' +import { ContractKit, newKitFromWeb3 } from '@celo/contractkit' +import { testWithGanache } from '@celo/dev-utils/lib/ganache-test' +import Web3 from 'web3' +import { testLocally } from '../../test-utils/cliUtils' +import TransferCUSD from './dollars' + +process.env.NO_SYNCCHECK = 'true' + +// Lots of commands, sometimes times out +jest.setTimeout(15000) + +testWithGanache('transfer:dollars cmd', (web3: Web3) => { + let accounts: string[] = [] + let kit: ContractKit + + beforeEach(async () => { + kit = newKitFromWeb3(web3) + accounts = await web3.eth.getAccounts() + }) + + test('can transfer cusd', async () => { + const balanceBefore = await kit.getTotalBalance(accounts[0]) + const receiverBalanceBefore = await kit.getTotalBalance(accounts[1]) + const amountToTransfer = '500000000000000000000' + // Send cUSD to RG contract + await testLocally(TransferCUSD, [ + '--from', + accounts[0], + '--to', + accounts[1], + '--value', + amountToTransfer, + '--gasCurrency', + 'CELO', + ]) + // RG cUSD balance should match the amount sent + const receiverBalance = await kit.getTotalBalance(accounts[1]) + expect(receiverBalance.cUSD!.toFixed()).toEqual( + receiverBalanceBefore.cUSD!.plus(amountToTransfer).toFixed() + ) + // Attempt to send cUSD back + await testLocally(TransferCUSD, [ + '--from', + accounts[1], + '--to', + accounts[0], + '--value', + amountToTransfer, + '--gasCurrency', + 'CELO', + ]) + const balanceAfter = await kit.getTotalBalance(accounts[0]) + expect(balanceBefore.cUSD).toEqual(balanceAfter.cUSD) + }) + + test('should fail if to address is sanctioned', async () => { + const spy = jest.spyOn(console, 'log') + await expect( + testLocally(TransferCUSD, [ + '--from', + accounts[1], + '--to', + SANCTIONED_ADDRESSES[0], + '--value', + '1', + ]) + ).rejects.toThrow() + expect(spy).toHaveBeenCalledWith(expect.stringContaining(COMPLIANT_ERROR_RESPONSE)) + }) +}) diff --git a/packages/cli/src/commands/transfer/erc20.ts b/packages/cli/src/commands/transfer/erc20.ts index fee7796b2..95d98748f 100644 --- a/packages/cli/src/commands/transfer/erc20.ts +++ b/packages/cli/src/commands/transfer/erc20.ts @@ -50,7 +50,11 @@ export default class TransferErc20 extends BaseCommand { } catch { failWith('Invalid erc20 address') } - await newCheckBuilder(this).hasEnoughErc20(from, value, res.flags.erc20Address).runChecks() + await newCheckBuilder(this) + .isNotSanctioned(from) + .isNotSanctioned(to) + .hasEnoughErc20(from, value, res.flags.erc20Address) + .runChecks() await displaySendTx('transfer', celoToken.transfer(to, value.toFixed())) } diff --git a/packages/cli/src/commands/transfer/euros.test.ts b/packages/cli/src/commands/transfer/euros.test.ts new file mode 100644 index 000000000..f1090fb3a --- /dev/null +++ b/packages/cli/src/commands/transfer/euros.test.ts @@ -0,0 +1,71 @@ +import { COMPLIANT_ERROR_RESPONSE, SANCTIONED_ADDRESSES } from '@celo/compliance' +import { ContractKit, newKitFromWeb3 } from '@celo/contractkit' +import { testWithGanache } from '@celo/dev-utils/lib/ganache-test' +import Web3 from 'web3' +import { testLocally } from '../../test-utils/cliUtils' +import TransferEURO from './euros' + +process.env.NO_SYNCCHECK = 'true' + +// Lots of commands, sometimes times out +jest.setTimeout(15000) + +testWithGanache('transfer:euros cmd', (web3: Web3) => { + let accounts: string[] = [] + let kit: ContractKit + + beforeEach(async () => { + kit = newKitFromWeb3(web3) + accounts = await web3.eth.getAccounts() + }) + + test('can transfer ceur', async () => { + const balanceBefore = await kit.getTotalBalance(accounts[0]) + const receiverBalanceBefore = await kit.getTotalBalance(accounts[1]) + const amountToTransfer = '500000000000000000000' + // Send cEUR to RG contract + await testLocally(TransferEURO, [ + '--from', + accounts[0], + '--to', + accounts[1], + '--value', + amountToTransfer, + '--gasCurrency', + 'CELO', + ]) + // RG cEUR balance should match the amount sent + const receiverBalance = await kit.getTotalBalance(accounts[1]) + expect(receiverBalance.cEUR!.toFixed()).toEqual( + receiverBalanceBefore.cEUR!.plus(amountToTransfer).toFixed() + ) + // Attempt to send cEUR back + await testLocally(TransferEURO, [ + '--from', + accounts[1], + '--to', + accounts[0], + '--value', + amountToTransfer, + '--gasCurrency', + 'CELO', + ]) + const balanceAfter = await kit.getTotalBalance(accounts[0]) + expect(balanceBefore.cEUR).toEqual(balanceAfter.cEUR) + }) + + test('should fail if to address is sanctioned', async () => { + const spy = jest.spyOn(console, 'log') + await expect( + testLocally(TransferEURO, [ + '--from', + accounts[1], + '--to', + SANCTIONED_ADDRESSES[0], + '--value', + '1', + ]) + ).rejects.toThrow() + expect(spy).toHaveBeenCalledWith(expect.stringContaining(COMPLIANT_ERROR_RESPONSE)) + }) +}) diff --git a/packages/cli/src/exchange-stable-base.ts b/packages/cli/src/exchange-stable-base.ts index 10c72deea..26c3eaea5 100644 --- a/packages/cli/src/exchange-stable-base.ts +++ b/packages/cli/src/exchange-stable-base.ts @@ -39,6 +39,7 @@ export default class ExchangeStableBase extends BaseCommand { } await newCheckBuilder(this) .hasEnoughStable(res.flags.from, sellAmount, this._stableCurrency) + .isNotSanctioned(res.flags.from) .runChecks() const [stableToken, celoNativeTokenAddress, { mento, brokerAddress }] = await Promise.all([ diff --git a/packages/cli/src/transfer-stable-base.ts b/packages/cli/src/transfer-stable-base.ts index 3fe40df06..345e7c078 100644 --- a/packages/cli/src/transfer-stable-base.ts +++ b/packages/cli/src/transfer-stable-base.ts @@ -47,6 +47,8 @@ export abstract class TransferStableBase extends BaseCommand { await newCheckBuilder(this) .hasEnoughStable(from, value, this._stableCurrency) + .isNotSanctioned(from) + .isNotSanctioned(to) .addConditionalCheck( `Account can afford transfer and gas paid in ${this._stableCurrency}`, kit.connection.defaultFeeCurrency === stableToken.address, diff --git a/packages/cli/src/utils/checks.ts b/packages/cli/src/utils/checks.ts index 0ecb986a3..7e3f009f7 100644 --- a/packages/cli/src/utils/checks.ts +++ b/packages/cli/src/utils/checks.ts @@ -1,4 +1,9 @@ import { eqAddress, NULL_ADDRESS } from '@celo/base/lib/address' +import { + COMPLIANT_ERROR_RESPONSE, + OFAC_SANCTIONS_LIST_URL, + SANCTIONED_ADDRESSES, +} from '@celo/compliance' import { Address } from '@celo/connect' import { StableToken } from '@celo/contractkit' import { AccountsWrapper } from '@celo/contractkit/lib/wrappers/Accounts' @@ -10,6 +15,7 @@ import { isValidAddress } from '@celo/utils/lib/address' import { verifySignature } from '@celo/utils/lib/signatureUtils' import BigNumber from 'bignumber.js' import chalk from 'chalk' +import { fetch } from 'cross-fetch' import utils from 'web3-utils' import { BaseCommand } from '../base' import { printValueMapRecursive } from './cli' @@ -109,6 +115,12 @@ class CheckBuilder { } } + /* + * Add a check to the list of checks to be run + * @param name - Name of the check + * @param predicate - When this returns true a green check will be displayed, otherwise a red x + * @param errorMessage - Optional error message to display if the check returns false + */ addCheck(name: string, predicate: () => Promise | boolean, errorMessage?: string) { this.checks.push(check(name, predicate, errorMessage)) return this @@ -266,6 +278,16 @@ class CheckBuilder { validators.meetsValidatorGroupBalanceRequirements(account) ) ) + isNotSanctioned = (address: Address) => { + return this.addCheck( + 'Compliant Address', + async () => { + const isSanctioned = await this.fetchIsSanctioned(address) + return !isSanctioned + }, + COMPLIANT_ERROR_RESPONSE + ) + } isValidAddress = (address: Address) => this.addCheck(`${address} is a valid address`, () => isValidAddress(address)) @@ -484,6 +506,32 @@ class CheckBuilder { } } + // SANCTIONED_ADDRESSES is so well typed that if you call includes with a string it gives a type error. + // same if you make it a set or use indexOf so concat it with an empty string to give type without needing to ts-ignore + private readonly SANCTIONED_SET = { + data: new Set([''].concat(SANCTIONED_ADDRESSES)), + wasRefreshed: false, + } + + private async fetchIsSanctioned(address: string) { + // Would like to avoid calling this EVERY run. but at least calling + // twice in a row (such as when checking from and to addresses) should be cached + // using boolean because either it's been refreshed or this is the first run of the invocation. its short lived + if (!this.SANCTIONED_SET.wasRefreshed) { + try { + const result = await fetch(OFAC_SANCTIONS_LIST_URL) + const data = await result.json() + if (Array.isArray(data)) { + this.SANCTIONED_SET.data = new Set(data) + this.SANCTIONED_SET.wasRefreshed = true + } + } catch (e) { + console.error('Error fetching OFAC sanctions list', e) + } + } + return this.SANCTIONED_SET.data.has(address) + } + // async executeValidatorTx( // name: string, // f: ( diff --git a/yarn.lock b/yarn.lock index 0e0011d89..0144b0b37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1593,6 +1593,7 @@ __metadata: "@celo/abis": "npm:10.0.0" "@celo/base": "npm:^6.0.0" "@celo/celo-devchain": "npm:^6.0.3" + "@celo/compliance": "npm:~1.0.17" "@celo/connect": "npm:^5.1.2" "@celo/contractkit": "npm:^7.0.0" "@celo/cryptographic-utils": "npm:^5.0.7" @@ -1629,6 +1630,7 @@ __metadata: bip32: "npm:3.1.0" chalk: "npm:^2.4.2" command-exists: "npm:^1.2.9" + cross-fetch: "npm:3.0.6" debug: "npm:^4.1.1" ethers: "npm:5" fs-extra: "npm:^8.1.0" @@ -1650,6 +1652,13 @@ __metadata: languageName: unknown linkType: soft +"@celo/compliance@npm:~1.0.17": + version: 1.0.17 + resolution: "@celo/compliance@npm:1.0.17" + checksum: d38bcff8468066e348afba47747f4300533e90351ef9f7ae1de92df3979dd8072127d053f32bc36e0d0008c8a232d5f5bb522dd5aea8a48e05eeff361c7e44fb + languageName: node + linkType: hard + "@celo/connect@npm:^5.1.1": version: 5.1.1 resolution: "@celo/connect@npm:5.1.1"