diff --git a/contracts/token/superfluid/ISuperGoodDollar.sol b/contracts/token/superfluid/ISuperGoodDollar.sol index b36c2419..95b0ebd3 100644 --- a/contracts/token/superfluid/ISuperGoodDollar.sol +++ b/contracts/token/superfluid/ISuperGoodDollar.sol @@ -32,6 +32,8 @@ interface IGoodDollarCustom { function isPauser(address _pauser) external view returns (bool); + function isBlocked(address account) external view returns (bool); + function owner() external view returns (address); // state changing functions @@ -57,6 +59,8 @@ interface IGoodDollarCustom { function adminBurn(address account, uint256 amount) external; + function setBlocked(address[] calldata accounts, bool blocked) external; + function addMinter(address _minter) external; function renounceMinter() external; diff --git a/contracts/token/superfluid/SuperGoodDollar.sol b/contracts/token/superfluid/SuperGoodDollar.sol index bf051f10..b03d73eb 100644 --- a/contracts/token/superfluid/SuperGoodDollar.sol +++ b/contracts/token/superfluid/SuperGoodDollar.sol @@ -29,6 +29,13 @@ contract SuperGoodDollar is IGoodDollarCustom // without storage { error SUPER_GOODDOLLAR_PAUSED(); + error SUPER_GOODDOLLAR_BLOCKED(); + error SUPER_GOODDOLLAR_NOT_PAUSER(); + error SUPER_GOODDOLLAR_NOT_MINTER(); + error SUPER_GOODDOLLAR_CAP_EXCEEDED(); + error SUPER_GOODDOLLAR_BURN_EXCEEDS_ALLOWANCE(); + error SUPER_GOODDOLLAR_FALLBACK_FAILED(); + error SUPER_GOODDOLLAR_FEE_EXCEEDS_BALANCE(); // IMPORTANT! Never change the type (storage size) or order of state variables. // If a variable isn't needed anymore, leave it as padding (renaming is ok). @@ -37,6 +44,8 @@ contract SuperGoodDollar is IIdentity public identity; uint256 public cap; bool public disableHostOperations; + /// @dev accounts (eg. known DEX pools) that can neither send nor receive G$ + mapping(address => bool) public isBlocked; // Append additional state variables here! // ============== constants and immutables ============== @@ -45,6 +54,8 @@ contract SuperGoodDollar is bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + event BlockedUpdated(address indexed account, bool blocked); + event TransferFee( address from, address to, @@ -199,10 +210,8 @@ contract SuperGoodDollar is bool res = super._transferFrom(msg.sender, msg.sender, to, netAmount); emit ERC677.Transfer(msg.sender, to, netAmount, data); if (isContract(to)) { - require( - contractFallback(to, netAmount, data), - "Contract fallback failed" - ); + if (!contractFallback(to, netAmount, data)) + revert SUPER_GOODDOLLAR_FALLBACK_FAILED(); } return res; } @@ -261,6 +270,7 @@ contract SuperGoodDollar is bytes memory operatorData ) internal virtual override { _onlyNotPaused(); + _onlyNotBlocked(from); // handing over to the wrapper of SuperToken.transferFrom super._burn(operator, from, amount, userData, operatorData); } @@ -275,12 +285,10 @@ contract SuperGoodDollar is uint256 amount ) public override(IGoodDollarCustom) onlyMinter returns (bool) { _onlyNotPaused(); + _onlyNotBlocked(to); if (cap > 0) { - require( - totalSupply() + amount <= cap, - "Cannot increase supply beyond cap" - ); + if (totalSupply() + amount > cap) revert SUPER_GOODDOLLAR_CAP_EXCEEDED(); } _mint( msg.sender, @@ -296,7 +304,7 @@ contract SuperGoodDollar is function burnFrom(address account, uint256 amount) public { uint256 currentAllowance = allowance(account, _msgSender()); - require(currentAllowance >= amount, "ERC20: burn amount exceeds allowance"); + if (currentAllowance < amount) revert SUPER_GOODDOLLAR_BURN_EXCEEDS_ALLOWANCE(); unchecked { _approve(account, _msgSender(), currentAllowance - amount); } @@ -321,6 +329,27 @@ contract SuperGoodDollar is emit IERC20.Transfer(account, address(0), amount); } + /** + * @dev Blocks/unblocks accounts (eg. known liquidity pools) from sending or receiving G$. + * Owner only. + * Note: this covers the ERC20/ERC677/ERC777 surface (incl. the host batch operations). + * Superfluid streams settle via the agreement layer and are not covered - a stream can still + * credit a blocked account, but that account will not be able to move the funds out. + * @param accounts the addresses to update + * @param blocked true to block, false to unblock + */ + function setBlocked(address[] calldata accounts, bool blocked) external { + _onlyOwner(); + for (uint256 i; i < accounts.length; ) { + address account = accounts[i]; + isBlocked[account] = blocked; + emit BlockedUpdated(account, blocked); + unchecked { + ++i; + } + } + } + /** * @dev Gets the current transaction fees * @return fee senderPays that represents the current transaction fees and bool true if sender pays the fee or receiver @@ -356,7 +385,8 @@ contract SuperGoodDollar is // internal functions /** - * @dev Sends transactional fees to feeRecipient address from given address + * @dev Enforces the blocklist and sends transactional fees to feeRecipient address from given address. + * Called by every G$ movement (ERC20/ERC677/ERC777 and the superfluid host batch operations). * @param account The account that sends the fees * @param amount The amount to subtract fees from * @return an uint256 that represents the given amount minus the transactional fees @@ -366,12 +396,12 @@ contract SuperGoodDollar is address recipient, uint256 amount ) internal returns (uint256) { + _onlyNotBlocked(account); + _onlyNotBlocked(recipient); (uint256 txFees, bool senderPays) = getFees(amount, account, recipient); if (txFees > 0 && !identity.isDAOContract(msg.sender)) { - require( - senderPays == false || amount + txFees <= balanceOf(account), - "Not enough balance to pay TX fee" - ); + if (senderPays && amount + txFees > balanceOf(account)) + revert SUPER_GOODDOLLAR_FEE_EXCEEDS_BALANCE(); super._transferFrom(account, account, feeRecipient, txFees); emit TransferFee(account, recipient, amount, txFees, senderPays); return senderPays ? amount : amount - txFees; @@ -408,15 +438,19 @@ contract SuperGoodDollar is } function _onlyPauser() internal view { - require(hasRole(PAUSER_ROLE, msg.sender), "not pauser"); + if (!hasRole(PAUSER_ROLE, msg.sender)) revert SUPER_GOODDOLLAR_NOT_PAUSER(); } function _onlyNotPaused() internal view { if (paused()) revert SUPER_GOODDOLLAR_PAUSED(); } + function _onlyNotBlocked(address account) internal view { + if (isBlocked[account]) revert SUPER_GOODDOLLAR_BLOCKED(); + } + modifier onlyMinter() { - require(hasRole(MINTER_ROLE, msg.sender), "not minter"); + if (!hasRole(MINTER_ROLE, msg.sender)) revert SUPER_GOODDOLLAR_NOT_MINTER(); _; } } diff --git a/scripts/upgrades/blocked-pools.json b/scripts/upgrades/blocked-pools.json new file mode 100644 index 00000000..e365addb --- /dev/null +++ b/scripts/upgrades/blocked-pools.json @@ -0,0 +1,30 @@ +{ + "production-celo": { + "blocked": true, + "pools": [ + "0x6619871118D144c1c28eC3b23036FC1f0829ed3a", + "0x059ee811414230d1Fb157878D2b491240F4D8d3B", + "0x991f1AA7e0901f9AB3d583846bf5BE0EbACE1d7f", + "0x3D9e27C04076288eBfdC4815b4f6d81b0ED1b341", + "0x9491d57c5687AB75726423B55AC2d87D1cDa2c3F", + "0x31f9DeE850B4284b81B52b25a3194F2FC8fF18CF", + "0x07F86B39728Be613062bc7413FC2CA7293Eef022", + "0x25878951ae130014E827e6f54fd3B4CCa057a7e8", + "0xCB037f27eB3952222810966e28E0cEB650c65CD9", + "0x8b393470bef8bb27a9a5169531b4eBA5209b0b26", + "0xA0BeF7ff637c10b9Ec67a00687B4d4364A7f1c55", + "0x288dc841A52FCA2707c6947B3A777c5E56cd87BC", + "0xC10eE9031F2a0B84766A86B55a8D90F357910fb4", + "0xb92fe925DC43a0ECdE6c8b1a2709c170Ec4fFf4f", + "0x89c6340B1a1f4b25D36cd8B063D49045caF3f818" + ] + }, + "staging-celo": { + "blocked": true, + "pools": [] + }, + "development-celo": { + "blocked": true, + "pools": [] + } +} diff --git a/scripts/upgrades/supergooddollar-block-pools.ts b/scripts/upgrades/supergooddollar-block-pools.ts new file mode 100644 index 00000000..e9bd53a3 --- /dev/null +++ b/scripts/upgrades/supergooddollar-block-pools.ts @@ -0,0 +1,319 @@ +/*** + * Upgrade the celo SuperGoodDollar with a transfer blocklist and block the known pools. + * + * The token's owner (DEFAULT_ADMIN_ROLE) is the Avatar, so both calls must originate from + * the Avatar via Controller.genericCall. executeViaSafe wraps each call as + * Controller.genericCall(GoodDollar, , Avatar, 0) + * and proposes the batch to the GuardiansSafe, which is the registered scheme allowed to + * call genericCall (verified on celo mainnet: permissions 0x1f). + * + * Proposal: + * 1. GoodDollar.updateCode(newImpl) + * 2. GoodDollar.setBlocked(pools, true) + * Both land in a single Safe transaction and execute atomically in that order. + * + * NOTE: genericCall returns (bool success, bytes) and does NOT revert when the inner call + * fails - a proposal can execute "successfully" while doing nothing. Hence the pre-flight + * simulation below and the post-execution verification at the end. + * + * usage: see the commands at the bottom of this file. + */ + +import fs from "fs"; +import path from "path"; +import { network, ethers } from "hardhat"; +import { Contract } from "ethers"; + +import { + printDeploy, + executeViaGuardian, + executeViaSafe, + verifyProductionSigner, + verifyContract +} from "../multichain-deploy/helpers"; + +import dao from "../../releases/deployment.json"; + +let { name: networkName } = network; +networkName = networkName.replace("-fork", ""); + +// pools and their blocked flag are read from a file, not hardcoded. +// default: scripts/upgrades/blocked-pools.json, override with POOLS_FILE=path/to/file.{json,csv} +// +// json, per network - a group flag: +// { "production-celo": { "blocked": true, "pools": ["0xaaa", "0xbbb"] } } +// or a flag per entry (lets one run block some and unblock others): +// { "production-celo": [{ "address": "0xaaa", "blocked": true }, +// { "address": "0xbbb", "blocked": false }] } +// or the shorthand, which means blocked: true: +// { "production-celo": ["0xaaa"] } +// a flat top level array / object is also accepted and applies to any network. +// +// csv: address[,blocked][,label] - one pool per line. the second column is the flag when +// it is literally true/false, otherwise it is treated as a label and the flag defaults +// to true. lines starting with # and a header row are skipped. +const DEFAULT_POOLS_FILE = path.join(__dirname, "blocked-pools.json"); + +type PoolEntry = { address: string; blocked: boolean }; + +const toBool = (value: any, file: string): boolean => { + if (typeof value === "boolean") return value; + const asString = String(value).trim().toLowerCase(); + if (asString === "true") return true; + if (asString === "false") return false; + throw new Error(`invalid "blocked" value in ${file}: "${value}" (expected true or false)`); +}; + +const parseCsv = (raw: string, file: string): PoolEntry[] => { + const rows = raw + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line.length > 0 && !line.startsWith("#")) + .map(line => line.split(",").map(cell => cell.trim())); + + // drop a header row, but only the first row - every other row must be a valid + // address so that a typo is an error rather than a silently skipped pool + if (rows.length && !ethers.utils.isAddress(rows[0][0])) rows.shift(); + + return rows.map(([address, second]) => { + const isFlag = second !== undefined && ["true", "false"].includes(second.toLowerCase()); + return { address, blocked: isFlag ? toBool(second, file) : true }; + }); +}; + +const parseJson = (raw: string, net: string, file: string): PoolEntry[] => { + const parsed = JSON.parse(raw); + + // a flat array or a { blocked, pools } object applies to every network + let forNetwork = Array.isArray(parsed) || parsed.pools !== undefined ? parsed : parsed[net]; + if (forNetwork === undefined) { + throw new Error(`${file} has no entry for network "${net}" (found: ${Object.keys(parsed).join(", ")})`); + } + + // { blocked, pools } - one flag for the whole group + if (!Array.isArray(forNetwork)) { + if (!Array.isArray(forNetwork.pools)) throw new Error(`${file} entry for "${net}" has no "pools" array`); + const blocked = toBool(forNetwork.blocked, file); + return forNetwork.pools.map(address => ({ address, blocked })); + } + + // an array of addresses, or of { address, blocked } + return forNetwork.map(entry => + typeof entry === "string" + ? { address: entry, blocked: true } + : { address: entry.address, blocked: toBool(entry.blocked, file) } + ); +}; + +export const getPools = (net: string): PoolEntry[] => { + const file = process.env.POOLS_FILE || DEFAULT_POOLS_FILE; + if (!fs.existsSync(file)) throw new Error(`pools file not found: ${file}`); + + const raw = fs.readFileSync(file, "utf8"); + const entries = file.toLowerCase().endsWith(".csv") ? parseCsv(raw, file) : parseJson(raw, net, file); + + const pools = entries.map(({ address, blocked }) => { + const trimmed = String(address ?? "").trim(); + if (!ethers.utils.isAddress(trimmed)) { + throw new Error(`invalid address in ${file}: "${trimmed}" (bad checksum? try all-lowercase)`); + } + return { address: ethers.utils.getAddress(trimmed), blocked }; + }); + + const addresses = pools.map(_ => _.address); + const duplicates = addresses.filter((a, i) => addresses.indexOf(a) !== i); + if (duplicates.length) throw new Error(`duplicate pools in ${file}: ${[...new Set(duplicates)].join(", ")}`); + + console.log( + `loaded ${pools.length} pools from ${file}:`, + pools.map(_ => `${_.address} blocked=${_.blocked}`) + ); + return pools; +}; + +export const upgrade = async () => { + let [root] = await ethers.getSigners(); + + const isProduction = networkName.includes("production"); + const isForkSimulation = network.name === "localhost" || network.name === "fork"; + + // on a fork we run against the production-celo deployment + let networkEnv = isForkSimulation ? "production-celo" : networkName; + const release: { [key: string]: any } = dao[networkEnv]; + + // if (isProduction && !isForkSimulation) verifyProductionSigner(root); + + let guardian = root; + if (isForkSimulation) { + guardian = await ethers.getImpersonatedSigner(release.GuardiansSafe); + await root.sendTransaction({ + to: guardian.address, + value: ethers.constants.WeiPerEther.mul(3) + }); + } + + const pools = getPools(networkEnv); + if (pools.length === 0) { + throw new Error(`no pools to block for ${networkEnv}, add them to the pools file first`); + } + + // one setBlocked call per flag, so a file can block some pools and unblock others + const groups = [true, false] + .map(blocked => ({ blocked, addresses: pools.filter(_ => _.blocked === blocked).map(_ => _.address) })) + .filter(group => group.addresses.length > 0); + + const supergd = await ethers.getContractAt("SuperGoodDollar", release.GoodDollar); + const owner = await supergd.owner(); + const host = await supergd.getHost(); + + console.log({ + networkName, + networkEnv, + isProduction, + isForkSimulation, + signer: root.address, + guardiansSafe: release.GuardiansSafe, + controller: release.Controller, + avatar: release.Avatar, + goodDollar: supergd.address, + owner, + host, + pools: pools.length + }); + + // the whole plan depends on the Avatar being the token owner + if (owner.toLowerCase() !== release.Avatar.toLowerCase()) { + throw new Error(`token owner ${owner} is not the Avatar ${release.Avatar}, genericCall will not work`); + } + + // fail fast on a broke / wrong deployer instead of half way through the run + const gasPrice = (await ethers.provider.getGasPrice()).mul(11).div(10); // +10% headroom + const balance = await ethers.provider.getBalance(root.address); + const estimatedCost = gasPrice.mul(6_000_000); // the implementation deploy is ~5.4M gas + console.log("deployer:", { + address: root.address, + balance: ethers.utils.formatEther(balance), + gasPriceGwei: ethers.utils.formatUnits(gasPrice, "gwei"), + estimatedDeployCost: ethers.utils.formatEther(estimatedCost) + }); + if (balance.lt(estimatedCost)) { + throw new Error( + `deployer ${root.address} has ${ethers.utils.formatEther(balance)} but the deploy needs about ` + + `${ethers.utils.formatEther(estimatedCost)} - fund it, or set DEPLOYER_KEY in .env to the intended deployer` + ); + } + + const impl = (await ethers.deployContract("SuperGoodDollar", [host]).then(printDeploy)) as Contract; + + if (!isForkSimulation) { + // the constructor takes the superfluid host - without it etherscan rejects the source + await verifyContract( + impl.address, + "contracts/token/superfluid/SuperGoodDollar.sol:SuperGoodDollar", + networkEnv, + false, + host + ); + } + + const proposalContracts = [release.GoodDollar, ...groups.map(() => release.GoodDollar)]; + const proposalEthValues = proposalContracts.map(() => 0); + const proposalFunctionSignatures = ["updateCode(address)", ...groups.map(() => "setBlocked(address[],bool)")]; + const proposalFunctionInputs = [ + ethers.utils.defaultAbiCoder.encode(["address"], [impl.address]), + ...groups.map(group => + ethers.utils.defaultAbiCoder.encode(["address[]", "bool"], [group.addresses, group.blocked]) + ) + ]; + + // encode every call twice: the inner call on the token, and the genericCall wrapper that + // the guardians safe actually sends to the Controller. printed so the batch can also be + // proposed by hand in the safe UI, and reused for the simulation below. + const ctrl = await ethers.getContractAt("Controller", release.Controller); + // provider-connected copy: ethers v5 rejects a "from" override on a signer-connected contract + const ctrlRead = ctrl.connect(ethers.provider); + const encodeInner = (i: number) => + ethers.utils.solidityPack( + ["bytes4", "bytes"], + [ + ethers.utils.keccak256(ethers.utils.toUtf8Bytes(proposalFunctionSignatures[i])).slice(0, 10), + proposalFunctionInputs[i] + ] + ); + + const txs = proposalFunctionSignatures.map((sig, i) => { + const inner = encodeInner(i); + return { + sig, + target: proposalContracts[i], + inner, + genericCall: ctrl.interface.encodeFunctionData("genericCall", [ + proposalContracts[i], + inner, + release.Avatar, + 0 + ]) + }; + }); + + console.log("\n================ safe transaction batch ================"); + console.log("new SuperGoodDollar implementation:", impl.address); + txs.forEach((tx, i) => { + console.log(`\n--- tx #${i + 1}: ${tx.sig} ---`); + console.log(" inner call on the token"); + console.log(" target:", tx.target, "(GoodDollar)"); + console.log(" data: ", tx.inner); + console.log(" what the safe sends (genericCall wrapper)"); + console.log(" to: ", ctrl.address, "(Controller)"); + console.log(" value: ", 0); + console.log(" data: ", tx.genericCall); + }); + console.log("\n========================================================\n"); + + // pre-flight: updateCode must simulate green. setBlocked can not be simulated against + // mainnet state because the currently deployed implementation has no such function - + // it only becomes callable after tx #1 in the same batch. + const sim = await ctrlRead.callStatic + .genericCall(release.GoodDollar, txs[0].inner, release.Avatar, 0, { from: release.GuardiansSafe }) + .catch(e => ["revert: " + e.message]); + console.log("updateCode genericCall simulation:", sim[0]); + if (sim[0] !== true) throw new Error("updateCode simulation failed, aborting"); + + if (isProduction && !isForkSimulation) { + await executeViaSafe( + proposalContracts, + proposalEthValues, + proposalFunctionSignatures, + proposalFunctionInputs, + release.GuardiansSafe, + "celo" + ); + console.log("\nproposed to the guardians safe - verify isBlocked() after the guardians execute it"); + } else { + await executeViaGuardian( + proposalContracts, + proposalEthValues, + proposalFunctionSignatures, + proposalFunctionInputs, + guardian, + networkEnv + ); + + // genericCall swallows inner reverts, so verify the end state explicitly + for (const pool of pools) { + const onchain = await supergd.isBlocked(pool.address); + console.log("isBlocked", pool.address, onchain, "expected", pool.blocked); + if (onchain !== pool.blocked) throw new Error(`pool ${pool.address} is ${onchain}, expected ${pool.blocked}`); + } + console.log("upgrade + blocklist verified"); + } +}; + +export const main = async () => { + await upgrade().catch(e => { + console.error(e); + process.exit(1); + }); +}; + +if (process.argv[1].includes("supergooddollar-block-pools")) main(); diff --git a/test/identity/IdentityV4.test.ts b/test/identity/IdentityV4.test.ts index fa7ad8e8..86e2118d 100644 --- a/test/identity/IdentityV4.test.ts +++ b/test/identity/IdentityV4.test.ts @@ -416,7 +416,6 @@ describe("IdentityV4", () => { it("should follow reverify schedule and cycle authCount", async () => { // set timestamp to a fixed point (now) to avoid exclusion of old users // due to initialDate set in hardhat config - const block = await ethers.provider.getBlock("latest"); await time.setNextBlockTimestamp(Number((Date.now() / 1000).toFixed(0))); await expect(identity.setReverifyDaysOptions([1, 7, 180])).not.reverted; @@ -457,8 +456,5 @@ describe("IdentityV4", () => { expect(await identity.isWhitelisted(u.address)).to.be.true; // cleanup (remove whitelisted) to avoid affecting other tests await identity.removeWhitelisted(u.address); - - // restore time to normal flow - time.setNextBlockTimestamp(block.timestamp); }); }); diff --git a/test/token/SuperGoodDollar.nohost.test.ts b/test/token/SuperGoodDollar.nohost.test.ts index c35d7ffb..ad3bba6b 100644 --- a/test/token/SuperGoodDollar.nohost.test.ts +++ b/test/token/SuperGoodDollar.nohost.test.ts @@ -143,7 +143,7 @@ describe("SuperGoodDollar No Host", async function () { await expect( sgd.connect(alice).transfer(bob.address, tenDollars) - ).revertedWith(/Not enough balance to pay TX fee/); + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_FEE_EXCEEDS_BALANCE"); // mint the extra amount needed for 10% fees await sgd.mint(alice.address, oneDollar); @@ -172,7 +172,7 @@ describe("SuperGoodDollar No Host", async function () { await expect( sgd.connect(founder).transferFrom(alice.address, bob.address, tenDollars) - ).revertedWith(/Not enough balance to pay TX fee/); + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_FEE_EXCEEDS_BALANCE"); // mint the extra amount needed for 10% fees await sgd.connect(founder).mint(alice.address, oneDollar); diff --git a/test/token/SuperGoodDollar.test.ts b/test/token/SuperGoodDollar.test.ts index 85c0311d..5e979107 100644 --- a/test/token/SuperGoodDollar.test.ts +++ b/test/token/SuperGoodDollar.test.ts @@ -223,6 +223,162 @@ describe("SuperGoodDollar", async function () { ).reverted; }); + it("owner can block and unblock addresses in batch", async function () { + await loadFixture(initialState); + + expect(await sgd.isBlocked(eve.address)).equal(false); + + await expect( + sgd.connect(founder).setBlocked([eve.address, bob.address], true) + ) + .emit(sgd, "BlockedUpdated") + .withArgs(eve.address, true) + .emit(sgd, "BlockedUpdated") + .withArgs(bob.address, true); + expect(await sgd.isBlocked(eve.address)).equal(true); + expect(await sgd.isBlocked(bob.address)).equal(true); + + await expect( + sgd.connect(founder).setBlocked([eve.address, bob.address], false) + ) + .emit(sgd, "BlockedUpdated") + .withArgs(eve.address, false); + expect(await sgd.isBlocked(eve.address)).equal(false); + expect(await sgd.isBlocked(bob.address)).equal(false); + }); + + it("setBlocked accepts an empty array", async function () { + await loadFixture(initialState); + await sgd.connect(founder).setBlocked([], true); + }); + + it("setBlocked is only callable by the owner", async function () { + await loadFixture(initialState); + + await expect(sgd.connect(eve).setBlocked([eve.address], false)).revertedWith( + "not owner" + ); + await expect( + sgd.connect(alice).setBlocked([bob.address], true) + ).revertedWith("not owner"); + }); + + it("blocked address (eg. a known pool) can not receive or send", async function () { + await loadFixture(initialState); + // the "pool" holds funds from before it was blocked + await sgd.mint(bob.address, tenDollars); + await sgd.mint(alice.address, tenDollars); + await sgd.connect(founder).setBlocked([bob.address], true); + + // to the pool + await expect( + sgd.connect(alice).transfer(bob.address, oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + // from the pool + await expect( + sgd.connect(bob).transfer(alice.address, oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + // unrelated transfers are unaffected + const eveBefore = await sgd.balanceOf(eve.address); + await sgd.connect(alice).transfer(eve.address, oneDollar); + expect(await sgd.balanceOf(eve.address)).equal(eveBefore.add(oneDollar)); + }); + + it("blocking covers transferFrom, send and transferAndCall", async function () { + await loadFixture(initialState); + await sgd.mint(alice.address, tenDollars); + await sgd.mint(bob.address, tenDollars); + await sgd.connect(alice).approve(founder.address, tenDollars); + await sgd.connect(bob).approve(founder.address, tenDollars); + await sgd.connect(founder).setBlocked([bob.address], true); + + await expect( + sgd.connect(founder).transferFrom(alice.address, bob.address, oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + await expect( + sgd.connect(founder).transferFrom(bob.address, alice.address, oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + // erc777 + await expect( + sgd + .connect(alice) + ["send(address,uint256,bytes)"](bob.address, oneDollar, "0x") + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + await expect( + sgd + .connect(bob) + ["send(address,uint256,bytes)"](alice.address, oneDollar, "0x") + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + // erc677 + await sgd.connect(founder).setBlocked([receiverMock.address], true); + await expect( + sgd.connect(alice).transferAndCall(receiverMock.address, oneDollar, "0x") + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + }); + + it("unblocking restores transfers", async function () { + await loadFixture(initialState); + await sgd.mint(bob.address, tenDollars); + await sgd.connect(founder).setBlocked([bob.address], true); + await expect( + sgd.connect(bob).transfer(alice.address, oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + await sgd.connect(founder).setBlocked([bob.address], false); + const aliceBefore = await sgd.balanceOf(alice.address); + await sgd.connect(bob).transfer(alice.address, oneDollar); + expect(await sgd.balanceOf(alice.address)).equal( + aliceBefore.add(oneDollar) + ); + }); + + it("blocked address can not be minted to (bridge in)", async function () { + await loadFixture(initialState); + await sgd.connect(founder).setBlocked([eve.address], true); + + await expect( + sgd.connect(founder).mint(eve.address, tenDollars) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + await sgd.connect(founder).setBlocked([eve.address], false); + await sgd.connect(founder).mint(eve.address, tenDollars); + expect(await sgd.balanceOf(eve.address)).equal(tenDollars); + }); + + it("blocked address can not burn (bridge out)", async function () { + await loadFixture(initialState); + await sgd.mint(eve.address, tenDollars); + await sgd.connect(eve).approve(alice.address, tenDollars); + await sgd.connect(founder).setBlocked([eve.address], true); + + // self burn - "burn" is overloaded (erc777 burn(uint256,bytes)), so name the signature + await expect( + sgd.connect(eve)["burn(uint256)"](oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + // erc777 burn + await expect( + sgd.connect(eve)["burn(uint256,bytes)"](oneDollar, "0x") + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + + // burn via an allowance granted before the block + await expect( + sgd.connect(alice).burnFrom(eve.address, oneDollar) + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); + }); + + it("adminBurn works on a blocked address", async function () { + await loadFixture(initialState); + await sgd.mint(eve.address, tenDollars); + await sgd.connect(founder).setBlocked([eve.address], true); + + await sgd.connect(founder).adminBurn(eve.address, tenDollars); + expect(await sgd.balanceOf(eve.address)).equal(0); + }); + it("non-zero fees are applied", async function () { await loadFixture(initialState); @@ -231,7 +387,7 @@ describe("SuperGoodDollar", async function () { await expect( sgd.connect(alice).transfer(bob.address, tenDollars) - ).revertedWith(/Not enough balance to pay TX fee/); + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_FEE_EXCEEDS_BALANCE"); // mint the extra amount needed for 10% fees await sgd.mint(alice.address, oneDollar); @@ -260,7 +416,7 @@ describe("SuperGoodDollar", async function () { await expect( sgd.connect(founder).transferFrom(alice.address, bob.address, tenDollars) - ).revertedWith(/Not enough balance to pay TX fee/); + ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_FEE_EXCEEDS_BALANCE"); // mint the extra amount needed for 10% fees await sgd.connect(founder).mint(alice.address, oneDollar);