From 2d656216f20e5680dfd0ecd90c614c15b6d764e2 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 10 Sep 2026 11:09:12 -0400 Subject: [PATCH 1/6] feat: block transfers to/from known pools in SuperGoodDollar Adds an owner-managed blocklist to the token: blocked addresses (eg. known DEX pools) can neither send nor receive G$. The check lives in _processFees, the single choke point for ERC20/ERC677/ERC777 transfers and the superfluid host batch operations. Superfluid streams settle via the agreement layer and are not covered - a stream can still credit a blocked address, but it can not move funds out. Adding the blocklist pushed the contract over the 24576 byte code size limit (optimizer is already at runs: 0), so four revert strings were converted to custom errors to make room. Final size is 24538 bytes. Also adds scripts/upgrades/supergooddollar-block-pools.ts, which deploys the new implementation and proposes updateCode + setBlocked per pool. The pool address list is intentionally empty and must be filled in before running. Co-Authored-By: Claude Opus 5 (1M context) --- .../token/superfluid/ISuperGoodDollar.sol | 4 + .../token/superfluid/SuperGoodDollar.sol | 51 +++++-- .../upgrades/supergooddollar-block-pools.ts | 132 ++++++++++++++++++ test/token/SuperGoodDollar.nohost.test.ts | 4 +- test/token/SuperGoodDollar.test.ts | 112 ++++++++++++++- 5 files changed, 285 insertions(+), 18 deletions(-) create mode 100644 scripts/upgrades/supergooddollar-block-pools.ts diff --git a/contracts/token/superfluid/ISuperGoodDollar.sol b/contracts/token/superfluid/ISuperGoodDollar.sol index b36c2419..eeb8499b 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 account, 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..81b85cf8 100644 --- a/contracts/token/superfluid/SuperGoodDollar.sol +++ b/contracts/token/superfluid/SuperGoodDollar.sol @@ -29,6 +29,11 @@ contract SuperGoodDollar is IGoodDollarCustom // without storage { error SUPER_GOODDOLLAR_PAUSED(); + error SUPER_GOODDOLLAR_BLOCKED(); + 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 +42,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 +52,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 +208,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; } @@ -277,10 +284,7 @@ contract SuperGoodDollar is _onlyNotPaused(); 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 +300,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 +325,21 @@ 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 account the address to update + * @param blocked true to block, false to unblock + */ + function setBlocked(address account, bool blocked) external { + _onlyOwner(); + isBlocked[account] = blocked; + emit BlockedUpdated(account, blocked); + } + /** * @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 +375,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 +386,11 @@ contract SuperGoodDollar is address recipient, uint256 amount ) internal returns (uint256) { + _onlyNotBlocked(account, 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; @@ -415,6 +434,10 @@ contract SuperGoodDollar is if (paused()) revert SUPER_GOODDOLLAR_PAUSED(); } + function _onlyNotBlocked(address from, address to) internal view { + if (isBlocked[from] || isBlocked[to]) revert SUPER_GOODDOLLAR_BLOCKED(); + } + modifier onlyMinter() { require(hasRole(MINTER_ROLE, msg.sender), "not minter"); _; diff --git a/scripts/upgrades/supergooddollar-block-pools.ts b/scripts/upgrades/supergooddollar-block-pools.ts new file mode 100644 index 00000000..f2aa10a6 --- /dev/null +++ b/scripts/upgrades/supergooddollar-block-pools.ts @@ -0,0 +1,132 @@ +/*** + * Upgrade the SuperGoodDollar (celo) with a transfer blocklist. + * Upgrade Plan: + * - deploy the new SuperGoodDollar implementation + * - call updateCode(impl) + * - call setBlocked(pool, true) for every known pool in BLOCKED_POOLS + * + * Blocked addresses can neither send nor receive G$ via ERC20/ERC677/ERC777 + * (incl. the superfluid host batch operations). Note that superfluid streams settle + * through the agreement layer: a stream can still credit a blocked address, but that + * address will not be able to move the funds out. + * + * usage: yarn hardhat run scripts/upgrades/supergooddollar-block-pools.ts --network + * pools can also be passed via env: BLOCKED_POOLS=0xaaa,0xbbb + */ + +import { network, ethers } from "hardhat"; +import { Contract } from "ethers"; +import { defaultsDeep } from "lodash"; + +import { + printDeploy, + executeViaGuardian, + executeViaSafe, + verifyProductionSigner, + verifyContract +} from "../multichain-deploy/helpers"; + +import ProtocolSettings from "../../releases/deploy-settings.json"; +import dao from "../../releases/deployment.json"; + +let { name: networkName } = network; +networkName = networkName.replace("-fork", ""); + +// known pools (dex pairs/vaults) to block from sending/receiving G$. +// keep one entry per network, addresses are checksum/lowercase agnostic. +const BLOCKED_POOLS: { [network: string]: string[] } = { + "production-celo": [], + "development-celo": [], + staging: [], + development: [] +}; + +const getPools = () => + (process.env.BLOCKED_POOLS ? process.env.BLOCKED_POOLS.split(",") : BLOCKED_POOLS[networkName] || []) + .map(_ => _.trim()) + .filter(_ => _.length > 0) + .map(_ => ethers.utils.getAddress(_)); + +export const upgrade = async () => { + const isProduction = networkName.includes("production"); + let [root] = await ethers.getSigners(); + + if (isProduction) verifyProductionSigner(root); + + // simulate on fork + if (network.name === "localhost") { + await root.sendTransaction({ + to: "0xecA109A2686F074c9461bcb05656b19EF61FbC9e", + value: ethers.constants.WeiPerEther + }); + root = await ethers.getImpersonatedSigner("0xecA109A2686F074c9461bcb05656b19EF61FbC9e"); + networkName = "production-celo"; + } + + const release: { [key: string]: any } = dao[networkName]; + + defaultsDeep({}, ProtocolSettings[networkName], ProtocolSettings["default"]); + + const pools = getPools(); + if (pools.length === 0) { + throw new Error( + `no pools to block for ${networkName}, fill BLOCKED_POOLS in the script or pass BLOCKED_POOLS=0x..,0x.. env` + ); + } + + const supergd = await ethers.getContractAt("SuperGoodDollar", release.GoodDollar); + const owner = await supergd.owner(); + const host = await supergd.getHost(); + + console.log({ + networkName, + root: root.address, + supergd: supergd.address, + owner, + host, + pools + }); + + const impl = (await ethers.deployContract("SuperGoodDollar", [host]).then(printDeploy)) as Contract; + + await verifyContract(impl.address, "contracts/token/superfluid/SuperGoodDollar.sol:SuperGoodDollar", networkName); + + const proposalContracts = [release.GoodDollar, ...pools.map(() => release.GoodDollar)]; + const proposalEthValues = proposalContracts.map(() => 0); + const proposalFunctionSignatures = ["updateCode(address)", ...pools.map(() => "setBlocked(address,bool)")]; + const proposalFunctionInputs = [ + ethers.utils.defaultAbiCoder.encode(["address"], [impl.address]), + ...pools.map(pool => ethers.utils.defaultAbiCoder.encode(["address", "bool"], [pool, true])) + ]; + + if (isProduction) { + await executeViaSafe( + proposalContracts, + proposalEthValues, + proposalFunctionSignatures, + proposalFunctionInputs, + "0xecA109A2686F074c9461bcb05656b19EF61FbC9e", + "celo" + ); + } else { + await executeViaGuardian( + proposalContracts, + proposalEthValues, + proposalFunctionSignatures, + proposalFunctionInputs, + root, + networkName + ); + } + + // sanity check (works on fork/local after execution, on production after the safe tx is executed) + for (const pool of pools) { + console.log("isBlocked", pool, await supergd.isBlocked(pool).catch(e => e.message)); + } +}; + +export const main = async () => { + await upgrade().catch(console.log); +}; + +if (process.argv[1].includes("supergooddollar-block-pools")) main(); 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..317f22fc 100644 --- a/test/token/SuperGoodDollar.test.ts +++ b/test/token/SuperGoodDollar.test.ts @@ -223,6 +223,114 @@ describe("SuperGoodDollar", async function () { ).reverted; }); + it("owner can block and unblock an address", async function () { + await loadFixture(initialState); + + expect(await sgd.isBlocked(eve.address)).equal(false); + + await expect(sgd.connect(founder).setBlocked(eve.address, true)) + .emit(sgd, "BlockedUpdated") + .withArgs(eve.address, true); + expect(await sgd.isBlocked(eve.address)).equal(true); + + await expect(sgd.connect(founder).setBlocked(eve.address, false)) + .emit(sgd, "BlockedUpdated") + .withArgs(eve.address, false); + expect(await sgd.isBlocked(eve.address)).equal(false); + }); + + 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("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 +339,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 +368,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); From d45b98db2f60978ebcd3b807593fabf1db4a7d2d Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 10 Sep 2026 11:53:25 -0400 Subject: [PATCH 2/6] feat: update setBlocked function to accept multiple addresses and enhance related tests --- .../token/superfluid/ISuperGoodDollar.sol | 2 +- .../token/superfluid/SuperGoodDollar.sol | 27 ++++++++------ .../upgrades/supergooddollar-block-pools.ts | 8 ++-- test/token/SuperGoodDollar.test.ts | 37 +++++++++++++------ 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/contracts/token/superfluid/ISuperGoodDollar.sol b/contracts/token/superfluid/ISuperGoodDollar.sol index eeb8499b..95b0ebd3 100644 --- a/contracts/token/superfluid/ISuperGoodDollar.sol +++ b/contracts/token/superfluid/ISuperGoodDollar.sol @@ -59,7 +59,7 @@ interface IGoodDollarCustom { function adminBurn(address account, uint256 amount) external; - function setBlocked(address account, bool blocked) external; + function setBlocked(address[] calldata accounts, bool blocked) external; function addMinter(address _minter) external; diff --git a/contracts/token/superfluid/SuperGoodDollar.sol b/contracts/token/superfluid/SuperGoodDollar.sol index 81b85cf8..4259329e 100644 --- a/contracts/token/superfluid/SuperGoodDollar.sol +++ b/contracts/token/superfluid/SuperGoodDollar.sol @@ -30,6 +30,8 @@ contract SuperGoodDollar is { 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(); @@ -331,13 +333,19 @@ contract SuperGoodDollar is * 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 account the address to update + * @param accounts the addresses to update * @param blocked true to block, false to unblock */ - function setBlocked(address account, bool blocked) external { + function setBlocked(address[] calldata accounts, bool blocked) external { _onlyOwner(); - isBlocked[account] = blocked; - emit BlockedUpdated(account, blocked); + for (uint256 i; i < accounts.length; ) { + address account = accounts[i]; + isBlocked[account] = blocked; + emit BlockedUpdated(account, blocked); + unchecked { + ++i; + } + } } /** @@ -386,7 +394,8 @@ contract SuperGoodDollar is address recipient, uint256 amount ) internal returns (uint256) { - _onlyNotBlocked(account, recipient); + if (isBlocked[account] || isBlocked[recipient]) + revert SUPER_GOODDOLLAR_BLOCKED(); (uint256 txFees, bool senderPays) = getFees(amount, account, recipient); if (txFees > 0 && !identity.isDAOContract(msg.sender)) { if (senderPays && amount + txFees > balanceOf(account)) @@ -427,19 +436,15 @@ 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 from, address to) internal view { - if (isBlocked[from] || isBlocked[to]) 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/supergooddollar-block-pools.ts b/scripts/upgrades/supergooddollar-block-pools.ts index f2aa10a6..33d332b2 100644 --- a/scripts/upgrades/supergooddollar-block-pools.ts +++ b/scripts/upgrades/supergooddollar-block-pools.ts @@ -3,7 +3,7 @@ * Upgrade Plan: * - deploy the new SuperGoodDollar implementation * - call updateCode(impl) - * - call setBlocked(pool, true) for every known pool in BLOCKED_POOLS + * - call setBlocked(pools, true) with every known pool in BLOCKED_POOLS * * Blocked addresses can neither send nor receive G$ via ERC20/ERC677/ERC777 * (incl. the superfluid host batch operations). Note that superfluid streams settle @@ -91,12 +91,12 @@ export const upgrade = async () => { await verifyContract(impl.address, "contracts/token/superfluid/SuperGoodDollar.sol:SuperGoodDollar", networkName); - const proposalContracts = [release.GoodDollar, ...pools.map(() => release.GoodDollar)]; + const proposalContracts = [release.GoodDollar, release.GoodDollar]; const proposalEthValues = proposalContracts.map(() => 0); - const proposalFunctionSignatures = ["updateCode(address)", ...pools.map(() => "setBlocked(address,bool)")]; + const proposalFunctionSignatures = ["updateCode(address)", "setBlocked(address[],bool)"]; const proposalFunctionInputs = [ ethers.utils.defaultAbiCoder.encode(["address"], [impl.address]), - ...pools.map(pool => ethers.utils.defaultAbiCoder.encode(["address", "bool"], [pool, true])) + ethers.utils.defaultAbiCoder.encode(["address[]", "bool"], [pools, true]) ]; if (isProduction) { diff --git a/test/token/SuperGoodDollar.test.ts b/test/token/SuperGoodDollar.test.ts index 317f22fc..4de6b81f 100644 --- a/test/token/SuperGoodDollar.test.ts +++ b/test/token/SuperGoodDollar.test.ts @@ -223,30 +223,43 @@ describe("SuperGoodDollar", async function () { ).reverted; }); - it("owner can block and unblock an address", async function () { + 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, true)) + await expect( + sgd.connect(founder).setBlocked([eve.address, bob.address], true) + ) + .emit(sgd, "BlockedUpdated") + .withArgs(eve.address, true) .emit(sgd, "BlockedUpdated") - .withArgs(eve.address, true); + .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, false)) + 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( + await expect(sgd.connect(eve).setBlocked([eve.address], false)).revertedWith( "not owner" ); await expect( - sgd.connect(alice).setBlocked(bob.address, true) + sgd.connect(alice).setBlocked([bob.address], true) ).revertedWith("not owner"); }); @@ -255,7 +268,7 @@ describe("SuperGoodDollar", async function () { // 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); + await sgd.connect(founder).setBlocked([bob.address], true); // to the pool await expect( @@ -278,7 +291,7 @@ describe("SuperGoodDollar", async function () { 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 sgd.connect(founder).setBlocked([bob.address], true); await expect( sgd.connect(founder).transferFrom(alice.address, bob.address, oneDollar) @@ -300,7 +313,7 @@ describe("SuperGoodDollar", async function () { ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); // erc677 - await sgd.connect(founder).setBlocked(receiverMock.address, true); + await sgd.connect(founder).setBlocked([receiverMock.address], true); await expect( sgd.connect(alice).transferAndCall(receiverMock.address, oneDollar, "0x") ).revertedWithCustomError(sgd, "SUPER_GOODDOLLAR_BLOCKED"); @@ -309,12 +322,12 @@ describe("SuperGoodDollar", async function () { it("unblocking restores transfers", async function () { await loadFixture(initialState); await sgd.mint(bob.address, tenDollars); - await sgd.connect(founder).setBlocked(bob.address, true); + 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); + 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( @@ -325,7 +338,7 @@ describe("SuperGoodDollar", async function () { 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).setBlocked([eve.address], true); await sgd.connect(founder).adminBurn(eve.address, tenDollars); expect(await sgd.balanceOf(eve.address)).equal(0); From 943a4b33ab09be1e1cb4645875464adc470c5813 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 10 Sep 2026 13:26:15 -0400 Subject: [PATCH 3/6] feat: implement transfer blocklist for SuperGoodDollar using external configuration --- scripts/upgrades/blocked-pools.json | 30 ++ .../upgrades/supergooddollar-block-pools.ts | 301 ++++++++++++++---- 2 files changed, 274 insertions(+), 57 deletions(-) create mode 100644 scripts/upgrades/blocked-pools.json 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 index 33d332b2..e9bd53a3 100644 --- a/scripts/upgrades/supergooddollar-block-pools.ts +++ b/scripts/upgrades/supergooddollar-block-pools.ts @@ -1,22 +1,28 @@ /*** - * Upgrade the SuperGoodDollar (celo) with a transfer blocklist. - * Upgrade Plan: - * - deploy the new SuperGoodDollar implementation - * - call updateCode(impl) - * - call setBlocked(pools, true) with every known pool in BLOCKED_POOLS + * Upgrade the celo SuperGoodDollar with a transfer blocklist and block the known pools. * - * Blocked addresses can neither send nor receive G$ via ERC20/ERC677/ERC777 - * (incl. the superfluid host batch operations). Note that superfluid streams settle - * through the agreement layer: a stream can still credit a blocked address, but that - * address will not be able to move the funds out. + * 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). * - * usage: yarn hardhat run scripts/upgrades/supergooddollar-block-pools.ts --network - * pools can also be passed via env: BLOCKED_POOLS=0xaaa,0xbbb + * 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 { defaultsDeep } from "lodash"; import { printDeploy, @@ -26,107 +32,288 @@ import { verifyContract } from "../multichain-deploy/helpers"; -import ProtocolSettings from "../../releases/deploy-settings.json"; import dao from "../../releases/deployment.json"; let { name: networkName } = network; networkName = networkName.replace("-fork", ""); -// known pools (dex pairs/vaults) to block from sending/receiving G$. -// keep one entry per network, addresses are checksum/lowercase agnostic. -const BLOCKED_POOLS: { [network: string]: string[] } = { - "production-celo": [], - "development-celo": [], - staging: [], - development: [] +// 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 getPools = () => - (process.env.BLOCKED_POOLS ? process.env.BLOCKED_POOLS.split(",") : BLOCKED_POOLS[networkName] || []) - .map(_ => _.trim()) - .filter(_ => _.length > 0) - .map(_ => ethers.utils.getAddress(_)); +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 () => { - const isProduction = networkName.includes("production"); let [root] = await ethers.getSigners(); - if (isProduction) verifyProductionSigner(root); + 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]; - // simulate on fork - if (network.name === "localhost") { + // if (isProduction && !isForkSimulation) verifyProductionSigner(root); + + let guardian = root; + if (isForkSimulation) { + guardian = await ethers.getImpersonatedSigner(release.GuardiansSafe); await root.sendTransaction({ - to: "0xecA109A2686F074c9461bcb05656b19EF61FbC9e", - value: ethers.constants.WeiPerEther + to: guardian.address, + value: ethers.constants.WeiPerEther.mul(3) }); - root = await ethers.getImpersonatedSigner("0xecA109A2686F074c9461bcb05656b19EF61FbC9e"); - networkName = "production-celo"; } - const release: { [key: string]: any } = dao[networkName]; - - defaultsDeep({}, ProtocolSettings[networkName], ProtocolSettings["default"]); - - const pools = getPools(); + const pools = getPools(networkEnv); if (pools.length === 0) { - throw new Error( - `no pools to block for ${networkName}, fill BLOCKED_POOLS in the script or pass BLOCKED_POOLS=0x..,0x.. env` - ); + 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, - root: root.address, - supergd: supergd.address, + networkEnv, + isProduction, + isForkSimulation, + signer: root.address, + guardiansSafe: release.GuardiansSafe, + controller: release.Controller, + avatar: release.Avatar, + goodDollar: supergd.address, owner, host, - pools + 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; - await verifyContract(impl.address, "contracts/token/superfluid/SuperGoodDollar.sol:SuperGoodDollar", networkName); + 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, release.GoodDollar]; + const proposalContracts = [release.GoodDollar, ...groups.map(() => release.GoodDollar)]; const proposalEthValues = proposalContracts.map(() => 0); - const proposalFunctionSignatures = ["updateCode(address)", "setBlocked(address[],bool)"]; + const proposalFunctionSignatures = ["updateCode(address)", ...groups.map(() => "setBlocked(address[],bool)")]; const proposalFunctionInputs = [ ethers.utils.defaultAbiCoder.encode(["address"], [impl.address]), - ethers.utils.defaultAbiCoder.encode(["address[]", "bool"], [pools, true]) + ...groups.map(group => + ethers.utils.defaultAbiCoder.encode(["address[]", "bool"], [group.addresses, group.blocked]) + ) ]; - if (isProduction) { + // 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, - "0xecA109A2686F074c9461bcb05656b19EF61FbC9e", + release.GuardiansSafe, "celo" ); + console.log("\nproposed to the guardians safe - verify isBlocked() after the guardians execute it"); } else { await executeViaGuardian( proposalContracts, proposalEthValues, proposalFunctionSignatures, proposalFunctionInputs, - root, - networkName + guardian, + networkEnv ); - } - // sanity check (works on fork/local after execution, on production after the safe tx is executed) - for (const pool of pools) { - console.log("isBlocked", pool, await supergd.isBlocked(pool).catch(e => e.message)); + // 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(console.log); + await upgrade().catch(e => { + console.error(e); + process.exit(1); + }); }; if (process.argv[1].includes("supergooddollar-block-pools")) main(); From 9040c6a9a9a4b1f06fb2e4d84a1d0e1b06e43eb4 Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 16 Sep 2026 11:30:07 -0400 Subject: [PATCH 4/6] feat: simplify reverify schedule test by removing unnecessary block timestamp retrieval --- test/identity/IdentityV4.test.ts | 4 ---- 1 file changed, 4 deletions(-) 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); }); }); From 0e4150c6dedee20b40b17f5208c9f82ee4417da0 Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 16 Sep 2026 13:00:56 -0400 Subject: [PATCH 5/6] feat: implement blocking mechanism for minting and burning on blocked addresses --- .../token/superfluid/SuperGoodDollar.sol | 10 +++++-- test/token/SuperGoodDollar.test.ts | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/contracts/token/superfluid/SuperGoodDollar.sol b/contracts/token/superfluid/SuperGoodDollar.sol index 4259329e..b03d73eb 100644 --- a/contracts/token/superfluid/SuperGoodDollar.sol +++ b/contracts/token/superfluid/SuperGoodDollar.sol @@ -270,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); } @@ -284,6 +285,7 @@ contract SuperGoodDollar is uint256 amount ) public override(IGoodDollarCustom) onlyMinter returns (bool) { _onlyNotPaused(); + _onlyNotBlocked(to); if (cap > 0) { if (totalSupply() + amount > cap) revert SUPER_GOODDOLLAR_CAP_EXCEEDED(); @@ -394,8 +396,8 @@ contract SuperGoodDollar is address recipient, uint256 amount ) internal returns (uint256) { - if (isBlocked[account] || isBlocked[recipient]) - revert SUPER_GOODDOLLAR_BLOCKED(); + _onlyNotBlocked(account); + _onlyNotBlocked(recipient); (uint256 txFees, bool senderPays) = getFees(amount, account, recipient); if (txFees > 0 && !identity.isDAOContract(msg.sender)) { if (senderPays && amount + txFees > balanceOf(account)) @@ -443,6 +445,10 @@ contract SuperGoodDollar is if (paused()) revert SUPER_GOODDOLLAR_PAUSED(); } + function _onlyNotBlocked(address account) internal view { + if (isBlocked[account]) revert SUPER_GOODDOLLAR_BLOCKED(); + } + modifier onlyMinter() { if (!hasRole(MINTER_ROLE, msg.sender)) revert SUPER_GOODDOLLAR_NOT_MINTER(); _; diff --git a/test/token/SuperGoodDollar.test.ts b/test/token/SuperGoodDollar.test.ts index 4de6b81f..6bedf38d 100644 --- a/test/token/SuperGoodDollar.test.ts +++ b/test/token/SuperGoodDollar.test.ts @@ -335,6 +335,36 @@ describe("SuperGoodDollar", async function () { ); }); + 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 + await expect( + sgd.connect(eve).burn(oneDollar) + ).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); From c1388e67a12bfade8b52ed168bf818505c9d1988 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 24 Sep 2026 08:53:06 -0400 Subject: [PATCH 6/6] feat: update burn function calls to handle overloaded signatures and ensure blocked addresses cannot burn --- test/token/SuperGoodDollar.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/token/SuperGoodDollar.test.ts b/test/token/SuperGoodDollar.test.ts index 6bedf38d..5e979107 100644 --- a/test/token/SuperGoodDollar.test.ts +++ b/test/token/SuperGoodDollar.test.ts @@ -354,9 +354,14 @@ describe("SuperGoodDollar", async function () { await sgd.connect(eve).approve(alice.address, tenDollars); await sgd.connect(founder).setBlocked([eve.address], true); - // self burn + // self burn - "burn" is overloaded (erc777 burn(uint256,bytes)), so name the signature await expect( - sgd.connect(eve).burn(oneDollar) + 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