diff --git a/contracts/utils/AdminBurnExecutor.sol b/contracts/utils/AdminBurnExecutor.sol new file mode 100644 index 00000000..161a1b05 --- /dev/null +++ b/contracts/utils/AdminBurnExecutor.sol @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "../DAOStackInterfaces.sol"; + +interface IAdminBurnable { + function adminBurn(address account, uint256 amount) external; + + function owner() external view returns (address); + + function balanceOf(address account) external view returns (uint256); +} + +/** + * @notice One-shot scheme that burns illegitimate G$ and records the USD refund + * owed to each affected address. + * + * Two ways in: + * - `execute()` burns the list fixed at construction time. Guardians can audit + * the exact (account, G$ amount, USD refund) tuples before registering this + * contract as a scheme with genericCall permission on the Controller. + * Entries that fail are skipped, not reverted, and the permission is *kept* + * so `execute()` can be re-run for whatever is left over. + * - `burn(accounts, amounts)` burns a freely supplied list, for anything the + * constructor list missed or got wrong. This is the terminal operation: it + * always relinquishes the scheme permission when it returns. + * + * The USD refund amounts are *recorded only* - they are emitted per account and + * kept readable on-chain for the off-chain compensation process. This contract + * does not move USD or any stablecoin. + */ +contract AdminBurnExecutor { + struct BurnEntry { + address account; + uint256 gdAmount; // G$ to burn, 18 decimals (SuperGoodDollar) + uint256 refundUSD; // USD owed back to `account`, 18 decimals + } + + event AdminBurn(address indexed account, uint256 gdAmount, uint256 refundUSD); + event BurnFailed(address indexed account, uint256 gdAmount, string reason); + /// @notice one `execute()` run; `complete` is false while entries remain + event Executed( + uint256 burnedNow, + uint256 failedNow, + uint256 totalGDBurned, + bool complete + ); + /// @notice one `burn()` run - the permission is gone once this is emitted + event FreeBurn(uint256 burnedNow, uint256 failedNow, uint256 totalGDBurned); + event PermissionRelinquished(); + + Controller public immutable controller; + IAdminBurnable public immutable token; + address public owner; + + /// @notice sum of all `gdAmount` in the constructor list + uint256 public immutable totalGDToBurn; + /// @notice sum of all `refundUSD` in the constructor list + uint256 public immutable totalRefundUSD; + + BurnEntry[] public entries; + /// @notice per-entry completion, parallel to `entries` + bool[] public entryBurned; + /// @notice how many of the constructor entries have burned so far + uint256 public burnedEntries; + /// @notice G$ actually burned by this contract, across every run + uint256 public totalGDBurned; + /// @notice true once the scheme permission has been given up + bool public relinquished; + + constructor( + Controller _controller, + IAdminBurnable _token, + address _owner, + BurnEntry[] memory _entries + ) { + require(address(_controller) != address(0), "controller required"); + require(address(_token) != address(0), "token required"); + require(_owner != address(0), "owner required"); + require(_entries.length > 0, "empty burn list"); + + controller = _controller; + token = _token; + owner = _owner; + + uint256 gd; + uint256 usd; + for (uint256 i = 0; i < _entries.length; i++) { + BurnEntry memory e = _entries[i]; + require(e.account != address(0), "zero account"); + require(e.gdAmount > 0, "zero burn amount"); + // duplicates would double count against `totalGDToBurn` + for (uint256 j = 0; j < i; j++) + require(_entries[j].account != e.account, "duplicate account"); + + entries.push(e); + entryBurned.push(false); + gd += e.gdAmount; + usd += e.refundUSD; + } + totalGDToBurn = gd; + totalRefundUSD = usd; + } + + function entriesCount() external view returns (uint256) { + return entries.length; + } + + function getEntries() external view returns (BurnEntry[] memory) { + return entries; + } + + /// @notice entries from the constructor list that have not burned yet + function pendingEntries() external view returns (BurnEntry[] memory pending) { + uint256 n = entries.length - burnedEntries; + pending = new BurnEntry[](n); + uint256 k; + for (uint256 i = 0; i < entries.length; i++) + if (!entryBurned[i]) pending[k++] = entries[i]; + } + + /// @notice the whole constructor list has burned + function complete() public view returns (bool) { + return burnedEntries == entries.length; + } + + /** + * @notice read-only pre-flight for `execute()`: is the scheme usable, and do + * the pending accounts still hold enough? `firstShortAccount` is the first + * pending account whose balance is short, if any. + */ + function canExecute() + external + view + returns (bool ok, address firstShortAccount) + { + if (relinquished || complete()) return (false, address(0)); + + address avatar = controller.avatar(); + if (token.owner() != avatar) return (false, address(0)); + if (!controller.isSchemeRegistered(address(this), avatar)) + return (false, address(0)); + + for (uint256 i = 0; i < entries.length; i++) + if (!entryBurned[i] && token.balanceOf(entries[i].account) < entries[i].gdAmount) + return (true, entries[i].account); + + return (true, address(0)); + } + + /** + * @notice burn the pending part of the constructor list. + * + * Best effort per account: an entry that can not be burned right now (short + * balance, reverting burn) is skipped and reported through `BurnFailed`, and + * the scheme permission is retained so this can be called again once the + * cause is resolved. The permission is only given up once every entry in the + * list has burned. + */ + function execute() external returns (uint256 burnedNow, uint256 failedNow) { + require(msg.sender == owner, "not owner"); + require(!relinquished, "permission already relinquished"); + require(!complete(), "already executed"); + + address avatar = _avatarWithBurnRights(); + + for (uint256 i = 0; i < entries.length; i++) { + if (entryBurned[i]) continue; + BurnEntry memory e = entries[i]; + + if (_burn(avatar, e.account, e.gdAmount)) { + entryBurned[i] = true; + burnedEntries++; + burnedNow++; + emit AdminBurn(e.account, e.gdAmount, e.refundUSD); + } else { + failedNow++; + } + } + + bool done = complete(); + emit Executed(burnedNow, failedNow, totalGDBurned, done); + + // keep the permission while anything is still pending, so a failed entry + // can be retried without redeploying and re-approving the scheme + if (done) _relinquish(avatar); + } + + /** + * @notice burn a freely supplied list of accounts, then give up the scheme + * permission unconditionally. + * + * This is the terminal operation - use it for whatever the constructor list + * missed. Failures are reported through `BurnFailed` rather than reverting, + * so a single bad account can not strand the rest of the list, and the + * permission is relinquished either way. Constructor entries burned here are + * marked off the list too. + */ + function burn( + address[] calldata accounts, + uint256[] calldata amounts + ) external returns (uint256 burnedNow, uint256 failedNow) { + require(msg.sender == owner, "not owner"); + require(!relinquished, "permission already relinquished"); + require(accounts.length == amounts.length, "length mismatch"); + require(accounts.length > 0, "empty burn list"); + + address avatar = _avatarWithBurnRights(); + + for (uint256 i = 0; i < accounts.length; i++) { + if (_burn(avatar, accounts[i], amounts[i])) { + burnedNow++; + _markEntry(accounts[i], amounts[i]); + emit AdminBurn(accounts[i], amounts[i], 0); + } else { + failedNow++; + } + } + + emit FreeBurn(burnedNow, failedNow, totalGDBurned); + + // terminal by design: no second free-form burn without a new approval + _relinquish(avatar); + } + + /** + * @notice give up the permission without burning anything (abort path). + */ + function cancel() external { + require(msg.sender == owner, "not owner"); + _relinquish(controller.avatar()); + } + + /** + * @dev one burn through the Avatar. Returns false instead of reverting so a + * single bad account never strands the rest of the list. + */ + function _burn( + address avatar, + address account, + uint256 amount + ) internal returns (bool) { + if (account == address(0)) { + emit BurnFailed(account, amount, "zero account"); + return false; + } + if (amount == 0) { + emit BurnFailed(account, amount, "zero amount"); + return false; + } + + uint256 balanceBefore = token.balanceOf(account); + if (balanceBefore < amount) { + emit BurnFailed(account, amount, "insufficient balance"); + return false; + } + + (bool ok, ) = controller.genericCall( + address(token), + abi.encodeCall(IAdminBurnable.adminBurn, (account, amount)), + avatar, + 0 + ); + if (!ok) { + emit BurnFailed(account, amount, "adminBurn reverted"); + return false; + } + // genericCall reports success for a call into a function the live + // implementation does not have, so verify the balance actually moved + if (token.balanceOf(account) != balanceBefore - amount) { + emit BurnFailed(account, amount, "burn had no effect"); + return false; + } + + totalGDBurned += amount; + return true; + } + + /// @dev mark a constructor entry off the list when `burn` covered it exactly + function _markEntry(address account, uint256 amount) internal { + for (uint256 i = 0; i < entries.length; i++) + if (!entryBurned[i] && entries[i].account == account && entries[i].gdAmount == amount) { + entryBurned[i] = true; + burnedEntries++; + return; + } + } + + function _avatarWithBurnRights() internal view returns (address avatar) { + avatar = controller.avatar(); + // adminBurn is owner-only on the token, and the owner must be the Avatar + // for the genericCall to be accepted + require(token.owner() == avatar, "token owner is not the avatar"); + require( + controller.isSchemeRegistered(address(this), avatar), + "not a registered scheme" + ); + } + + /// @dev drop the genericCall permission for good + function _relinquish(address avatar) internal { + relinquished = true; + owner = address(0); + if (controller.isSchemeRegistered(address(this), avatar)) + require(controller.unregisterSelf(avatar), "unregistering failed"); + emit PermissionRelinquished(); + } +} diff --git a/scripts/upgrades/admin-burn-executor-deploy.ts b/scripts/upgrades/admin-burn-executor-deploy.ts new file mode 100644 index 00000000..0b2d3f42 --- /dev/null +++ b/scripts/upgrades/admin-burn-executor-deploy.ts @@ -0,0 +1,314 @@ +/*** + * Deploy the AdminBurnExecutor and hand it to the guardians for approval. + * + * The burn list is fixed at construction time, so what the guardians register is + * exactly what they audited. Nothing here changes production state: the executor + * is inert until 5 of 9 guardians sign `registerScheme` in the Safe UI, and it can + * only burn after that. + * + * Requires the SuperGoodDollar implementation with `adminBurn` to already be live + * (see supergooddollar-admin-burn.ts). + * + * Burn list (BURN_LIST, default scripts/upgrades/admin-burn-list.json): + * [{ "account": "0x..", "gd": "1234.56", "refundUSD": "78.90", "note": "optional" }] + * `gd` is in whole G$ and `refundUSD` in whole USD - both are parsed to 18 decimals. + * Raw base units can be given instead as `gdWei` / `refundUSDWei`. + * + * Modes (env): + * DRY=true validate the list + preflight balances, deploy nothing + * DEPLOY_ONLY=1 deploy + verify, then stop (no Safe proposal) + * EXECUTOR=0x.. skip deployment and propose registration for this address + * PRINT_ONLY=1 print the raw Safe transaction instead of proposing it + * STRICT_SIGNER=1 enforce the repo's canonical deployer address + * SKIP_IMPL_CHECK=1 do not require adminBurn in the live implementation (dry runs + * before the token upgrade has been signed) + */ +import fs from "fs"; +import path from "path"; +import { network, ethers } from "hardhat"; +import { execSync } from "child_process"; +import { executeViaSafe, executeViaGuardian, printDeploy, verifyProductionSigner } from "../multichain-deploy/helpers"; +import dao from "../../releases/deployment.json"; + +let { name: networkName } = network; +networkName = networkName.replace("-fork", ""); + +const isSimulation = ["hardhat", "fork", "localhost"].includes(network.name); + +// executeViaSafe keys its chainId/RPC off these short names, not the hardhat name. +const SAFE_NETWORK: { [k: string]: string } = { + "production-celo": "celo", + "production-xdc": "xdc", + "production-mainnet": "mainnet", + production: "fuse" +}; + +// Controller permission bits: 0x10 == genericCall, nothing else. +const GENERIC_CALL_PERMISSION = "0x00000010"; + +const DRY = process.env.DRY === "true" || process.env.DRY === "1"; +const DEPLOY_ONLY = process.env.DEPLOY_ONLY === "true" || process.env.DEPLOY_ONLY === "1"; + +export type BurnListItem = { + account: string; + gd?: string | number; + gdWei?: string; + refundUSD?: string | number; + refundUSDWei?: string; + note?: string; +}; + +/** + * Parse + validate the burn list. Throws on anything the contract's constructor + * would reject, so a bad list fails before it costs a deployment. + */ +export const loadBurnList = (file: string) => { + const raw = JSON.parse(fs.readFileSync(file, "utf8")) as BurnListItem[]; + if (!Array.isArray(raw) || raw.length === 0) throw new Error(`${file}: expected a non-empty array`); + + const seen = new Set(); + return raw.map((item, i) => { + const where = `${file}[${i}]`; + let account: string; + try { + account = ethers.utils.getAddress(item.account); + } catch { + throw new Error(`${where}: invalid address '${item.account}'`); + } + if (seen.has(account.toLowerCase())) throw new Error(`${where}: duplicate account ${account}`); + seen.add(account.toLowerCase()); + + if (item.gd == null && item.gdWei == null) throw new Error(`${where}: missing 'gd' or 'gdWei'`); + const gdAmount = item.gdWei ? ethers.BigNumber.from(item.gdWei) : ethers.utils.parseEther(String(item.gd)); + if (gdAmount.lte(0)) throw new Error(`${where}: burn amount must be > 0`); + + const refundUSD = item.refundUSDWei + ? ethers.BigNumber.from(item.refundUSDWei) + : ethers.utils.parseEther(String(item.refundUSD ?? 0)); + + return { account, gdAmount, refundUSD, note: item.note ?? "" }; + }); +}; + +export const deploy = async () => { + const isProduction = networkName.includes("production"); + const [root] = await ethers.getSigners(); + const release: { [key: string]: any } = dao[networkName]; + + if (!release?.GoodDollar) throw new Error(`no GoodDollar in deployment.json for network '${networkName}'`); + const safeAddress = release.GuardiansSafe; + if (!safeAddress) throw new Error(`no GuardiansSafe in deployment.json for network '${networkName}'`); + + if (isProduction && process.env.STRICT_SIGNER) verifyProductionSigner(root); + + const listFile = path.resolve(process.env.BURN_LIST || path.join(__dirname, "admin-burn-list.json")); + const entries = loadBurnList(listFile); + const totalGD = entries.reduce((a, e) => a.add(e.gdAmount), ethers.constants.Zero); + const totalUSD = entries.reduce((a, e) => a.add(e.refundUSD), ethers.constants.Zero); + + const supergd = await ethers.getContractAt("SuperGoodDollar", release.GoodDollar); + const owner = await supergd.owner(); + + console.log("=== target ==="); + console.log({ + networkName, + burnList: listFile, + token: release.GoodDollar, + tokenOwner: owner, + avatar: release.Avatar, + controller: release.Controller, + guardiansSafe: safeAddress, + deployer: root.address, + deployerBalance: (await ethers.provider.getBalance(root.address)).toString() + }); + + if (owner.toLowerCase() !== release.Avatar.toLowerCase()) + throw new Error(`token owner ${owner} is not the Avatar ${release.Avatar} - adminBurn would revert`); + + // the executor is useless against a live implementation that has no adminBurn. + // The selector is derived here rather than read off the SuperGoodDollar ABI, so + // this script also works on a branch whose artifacts predate adminBurn. + const selector = ethers.utils.id("adminBurn(address,uint256)").slice(0, 10); + const liveImpl = await supergd.getCodeAddress(); + const liveCode = await ethers.provider.getCode(liveImpl); + const hasAdminBurn = liveCode.includes(selector.slice(2)); + if (!hasAdminBurn && !process.env.SKIP_IMPL_CHECK) + throw new Error( + `live SuperGoodDollar implementation ${liveImpl} has no adminBurn - run supergooddollar-admin-burn.ts first` + ); + console.log("live implementation:", liveImpl, hasAdminBurn ? "(adminBurn present)" : "(NO adminBurn - bypassed)"); + + // ---------------------------------------------------------------- the list + console.log("\n=== burn list ==="); + const rows: any[] = []; + let short = 0; + for (const e of entries) { + const balance = await supergd.balanceOf(e.account); + const enough = balance.gte(e.gdAmount); + if (!enough) short++; + rows.push({ + account: e.account, + burnGD: ethers.utils.formatEther(e.gdAmount), + balanceGD: ethers.utils.formatEther(balance), + enough, + refundUSD: ethers.utils.formatEther(e.refundUSD), + note: e.note + }); + } + console.table(rows); + console.log({ + accounts: entries.length, + totalBurnGD: ethers.utils.formatEther(totalGD), + totalRefundUSD: ethers.utils.formatEther(totalUSD), + totalSupplyGD: ethers.utils.formatEther(await supergd.totalSupply()), + accountsWithInsufficientBalance: short + }); + if (short > 0) + console.warn( + `WARNING: ${short} account(s) hold less than the listed amount. execute() will skip them and keep the ` + + `scheme permission so it can be re-run later.` + ); + + if (DRY) return console.log("\nDRY run complete - list validated, nothing deployed."); + + // ---------------------------------------------------------------- deploy + let executorAddress = process.env.EXECUTOR; + if (executorAddress) { + console.log("\n=== using existing executor ===", executorAddress); + if ((await ethers.provider.getCode(executorAddress)).length <= 2) + throw new Error(`no code at EXECUTOR ${executorAddress}`); + } else { + console.log("\n=== deploying AdminBurnExecutor ==="); + const ctorEntries = entries.map(e => [e.account, e.gdAmount, e.refundUSD]); + const executor = await ethers + .deployContract("AdminBurnExecutor", [release.Controller, release.GoodDollar, root.address, ctorEntries]) + .then(printDeploy); + executorAddress = (executor as any).address; + } + + const executor = await ethers.getContractAt("AdminBurnExecutor", executorAddress); + + // ---------------------------------------------------------------- preflight + console.log("\n=== preflight ==="); + const [onChainGD, onChainUSD, count] = await Promise.all([ + executor.totalGDToBurn(), + executor.totalRefundUSD(), + executor.entriesCount() + ]); + console.log({ + entries: count.toString(), + totalGDToBurn: ethers.utils.formatEther(onChainGD), + totalRefundUSD: ethers.utils.formatEther(onChainUSD), + owner: await executor.owner() + }); + if (!onChainGD.eq(totalGD) || !onChainUSD.eq(totalUSD) || !count.eq(entries.length)) + throw new Error("deployed executor does not match the local burn list"); + + // not registered yet, so canExecute is expected to be false on the scheme check + const [ok, firstShort] = await executor.canExecute(); + console.log("canExecute (pre-registration):", ok, firstShort !== ethers.constants.AddressZero ? firstShort : ""); + + if (!process.env.SKIP_VERIFY && !isSimulation) await verifyExecutor(executorAddress, entries, release); + + if (DEPLOY_ONLY) return console.log("\nDEPLOY_ONLY - executor ready:", executorAddress); + + // ---------------------------------------------------------------- registration + // genericCall permission only - the executor can not register schemes, upgrade + // the controller, or move the avatar's funds. + const registerArgs = ethers.utils.defaultAbiCoder.encode( + ["address", "bytes32", "bytes4", "address"], + [executorAddress, ethers.constants.HashZero, GENERIC_CALL_PERMISSION, release.Avatar] + ); + + if (process.env.PRINT_ONLY) { + const ctrl = await ethers.getContractAt("Controller", release.Controller); + const safeTxData = ctrl.interface.encodeFunctionData("registerScheme", [ + executorAddress, + ethers.constants.HashZero, + GENERIC_CALL_PERMISSION, + release.Avatar + ]); + console.log("\n=== Safe transaction - enter manually in the Safe UI ==="); + console.log("safe :", safeAddress); + console.log("to :", release.Controller, "(Controller)"); + console.log("value : 0"); + console.log("operation : 0 (CALL)"); + console.log("data :", safeTxData); + console.log("\ndecodes to: Controller.registerScheme("); + console.log(" _scheme :", executorAddress, "(AdminBurnExecutor)"); + console.log(" _paramsHash : 0x00..00"); + console.log(" _permissions:", GENERIC_CALL_PERMISSION, "(genericCall only)"); + console.log(" _avatar :", release.Avatar, ")"); + console.log("\nafter signing, run admin-burn-executor-execute.ts with EXECUTOR=" + executorAddress); + return; + } + + console.log("\n=== proposing registerScheme ==="); + if (isSimulation) { + const guardian = await ethers.getImpersonatedSigner(safeAddress); + await root.sendTransaction({ to: safeAddress, value: ethers.utils.parseEther("1") }); + await executeViaGuardian( + [release.Controller], + [0], + ["registerScheme(address,bytes32,bytes4,address)"], + [registerArgs], + guardian, + networkName + ); + } else { + const safeNetwork = SAFE_NETWORK[networkName]; + if (!safeNetwork) throw new Error(`no Safe network mapping for '${networkName}'`); + await executeViaSafe( + [release.Controller], + [0], + ["registerScheme(address,bytes32,bytes4,address)"], + [registerArgs], + safeAddress, + safeNetwork, + {}, + true // strict: throw instead of proposing a call that simulates false + ); + } + + console.log("\nproposed. 5 of 9 guardians must now sign in the Safe UI."); + console.log("executor:", executorAddress); + console.log( + "then run: EXECUTOR=" + + executorAddress + + " yarn hardhat run scripts/upgrades/admin-burn-executor-execute.ts --network " + + network.name + ); + return executorAddress; +}; + +/** + * The constructor takes a dynamic struct array, which the shared verifyContract + * helper can not express on the command line - hardhat needs a --constructor-args + * module instead. + */ +const verifyExecutor = async (address: string, entries: any[], release: any) => { + const argsFile = path.join(__dirname, ".admin-burn-ctor-args.js"); + const args = [ + release.Controller, + release.GoodDollar, + (await ethers.getSigners())[0].address, + entries.map(e => [e.account, e.gdAmount.toString(), e.refundUSD.toString()]) + ]; + fs.writeFileSync(argsFile, `module.exports = ${JSON.stringify(args, null, 2)};\n`); + const cmd = `yarn hardhat verify --contract contracts/utils/AdminBurnExecutor.sol:AdminBurnExecutor --constructor-args ${argsFile} ${address} --network ${network.name}`; + console.log("\n=== verifying ===\n" + cmd); + try { + execSync(cmd, { stdio: "inherit" }); + } catch (e) { + console.warn("verification failed (non-fatal). re-run manually:\n" + cmd); + } +}; + +export const main = async () => { + await deploy(); +}; +if (process.argv[1].includes("admin-burn-executor-deploy")) + main().catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/upgrades/admin-burn-executor-execute.ts b/scripts/upgrades/admin-burn-executor-execute.ts new file mode 100644 index 00000000..c4ca8771 --- /dev/null +++ b/scripts/upgrades/admin-burn-executor-execute.ts @@ -0,0 +1,260 @@ +/*** + * Run a registered AdminBurnExecutor and print the refund ledger it produced. + * + * Two modes: + * - default: `execute()` burns the pending part of the constructor list. Entries + * that fail are skipped and the scheme permission is kept, so this can be + * re-run for the leftovers. The permission is given up only once the whole + * list has burned. + * - FREE_LIST: `burn(accounts, amounts)` burns a freely supplied list for + * whatever the constructor list missed. This is terminal - the executor gives + * up its permission when it returns, so run it last. + * + * Prerequisites: + * - the SuperGoodDollar implementation with `adminBurn` is live + * - guardians have signed `registerScheme(executor, 0x0, 0x00000010, avatar)` + * - the signer is the executor's `owner` (the deployer, unless it was changed) + * + * Modes (env): + * EXECUTOR=0x.. required - the deployed AdminBurnExecutor + * DRY=true preflight + callStatic only, sends no transaction + * FREE_LIST=p.json call burn() with this list instead of execute() (terminal). + * Same format as the deploy burn list; refundUSD is ignored + * on-chain (burn() emits it as 0) but is kept in the ledger. + * CANCEL=1 call cancel() instead: drop the permission, burn nothing + * OUT=path.json write the refund ledger here (default alongside the script) + */ +import fs from "fs"; +import path from "path"; +import { network, ethers } from "hardhat"; +import { loadBurnList } from "./admin-burn-executor-deploy"; +import dao from "../../releases/deployment.json"; + +let { name: networkName } = network; +networkName = networkName.replace("-fork", ""); + +const DRY = process.env.DRY === "true" || process.env.DRY === "1"; +const CANCEL = process.env.CANCEL === "true" || process.env.CANCEL === "1"; + +export const execute = async () => { + const [root] = await ethers.getSigners(); + const release: { [key: string]: any } = dao[networkName]; + + const executorAddress = process.env.EXECUTOR; + if (!executorAddress) throw new Error("EXECUTOR=0x... is required"); + if ((await ethers.provider.getCode(executorAddress)).length <= 2) + throw new Error(`no code at EXECUTOR ${executorAddress}`); + + const executor = await ethers.getContractAt("AdminBurnExecutor", executorAddress); + const supergd = await ethers.getContractAt("SuperGoodDollar", release.GoodDollar); + const ctrl = await ethers.getContractAt("Controller", release.Controller); + + const [owner, relinquished, isComplete, entries, burnedEntries, totalGD, totalUSD, burnedSoFar] = await Promise.all([ + executor.owner(), + executor.relinquished(), + executor.complete(), + executor.getEntries(), + executor.burnedEntries(), + executor.totalGDToBurn(), + executor.totalRefundUSD(), + executor.totalGDBurned() + ]); + const registered = await ctrl.isSchemeRegistered(executorAddress, release.Avatar); + const perms = await ctrl.getSchemePermissions(executorAddress, release.Avatar); + + const freeListFile = process.env.FREE_LIST && path.resolve(process.env.FREE_LIST); + const freeList = freeListFile ? loadBurnList(freeListFile) : null; + + console.log("=== executor ==="); + console.log({ + networkName, + executor: executorAddress, + mode: CANCEL ? "cancel()" : freeList ? "burn() [terminal]" : "execute()", + owner, + signer: root.address, + relinquished, + complete: isComplete, + registered, + permissions: perms, + entries: entries.length, + entriesBurned: `${burnedEntries.toString()}/${entries.length}`, + totalGDToBurn: ethers.utils.formatEther(totalGD), + totalGDBurnedSoFar: ethers.utils.formatEther(burnedSoFar), + totalRefundUSD: ethers.utils.formatEther(totalUSD) + }); + + if (relinquished) throw new Error("executor has given up its permission - deploy a new one"); + if (owner.toLowerCase() !== root.address.toLowerCase()) + throw new Error(`signer ${root.address} is not the executor owner ${owner}`); + + if (CANCEL) { + if (DRY) return console.log("\nDRY - would call cancel() and drop the permission."); + console.log("\n=== cancel ==="); + const tx = await (await executor.cancel()).wait(); + console.log("cancelled in", tx.transactionHash); + console.log("still registered:", await ctrl.isSchemeRegistered(executorAddress, release.Avatar)); + return; + } + + if (!registered) + throw new Error("executor is not a registered scheme on the Controller - guardians must sign registerScheme first"); + if (!freeList && isComplete) + throw new Error("constructor list is fully burned - use FREE_LIST=... if more accounts need burning"); + + // ---------------------------------------------------------------- preflight + // what this run will actually attempt: the pending constructor entries, or + // the free list + const targets = freeList + ? freeList.map(e => ({ account: e.account, gdAmount: e.gdAmount, refundUSD: e.refundUSD })) + : (await executor.pendingEntries()).map(e => ({ + account: e.account, + gdAmount: e.gdAmount, + refundUSD: e.refundUSD + })); + + console.log("\n=== preflight ==="); + const balancesBefore: { [k: string]: any } = {}; + const rows: any[] = []; + let short = 0; + for (const t of targets) { + const balance = await supergd.balanceOf(t.account); + balancesBefore[t.account] = balance; + const enough = balance.gte(t.gdAmount); + if (!enough) short++; + rows.push({ + account: t.account, + burnGD: ethers.utils.formatEther(t.gdAmount), + balanceGD: ethers.utils.formatEther(balance), + enough, + refundUSD: ethers.utils.formatEther(t.refundUSD) + }); + } + console.table(rows); + + const [ok, firstShort] = await executor.canExecute(); + console.log("canExecute:", ok, firstShort !== ethers.constants.AddressZero ? `short: ${firstShort}` : ""); + if (!freeList && !ok) + throw new Error("canExecute() is false - check scheme registration, token ownership and relinquished state"); + if (short > 0) + console.warn( + `WARNING: ${short} of ${targets.length} account(s) hold less than their listed amount. ` + + (freeList + ? "burn() skips them and still gives up the permission - they can not be retried with this executor." + : "execute() skips them and keeps the permission, so it can be re-run later.") + ); + + const supplyBefore = await supergd.totalSupply(); + const sim = freeList + ? await executor.callStatic.burn( + freeList.map(e => e.account), + freeList.map(e => e.gdAmount) + ) + : await executor.callStatic.execute(); + console.log("simulation:", { + burnedNow: sim.burnedNow.toString(), + failedNow: sim.failedNow.toString() + }); + if (sim.burnedNow.eq(0)) console.warn("WARNING: simulation burns nothing at all"); + + if (DRY) return console.log("\nDRY run complete - nothing burned."); + + // ---------------------------------------------------------------- execute + console.log("\n=== burning ==="); + const receipt = await ( + await (freeList + ? executor.burn( + freeList.map(e => e.account), + freeList.map(e => e.gdAmount) + ) + : executor.execute()) + ).wait(); + console.log("burned in", receipt.transactionHash, "gas", receipt.gasUsed.toString()); + + // ---------------------------------------------------------------- ledger + const own = (e: any) => e.address.toLowerCase() === executorAddress.toLowerCase(); + const burns = receipt.events.filter(e => own(e) && e.event === "AdminBurn"); + const failures = receipt.events.filter(e => own(e) && e.event === "BurnFailed"); + + const ledger: any[] = []; + for (const e of burns) + ledger.push({ + account: e.args.account, + burnedGD: ethers.utils.formatEther(e.args.gdAmount), + // burn() has no refund data on-chain; fall back to the free list + refundUSD: ethers.utils.formatEther( + e.args.refundUSD.gt(0) ? e.args.refundUSD : freeList?.find(f => f.account === e.args.account)?.refundUSD ?? 0 + ), + balanceBefore: ethers.utils.formatEther(balancesBefore[e.args.account] ?? 0), + balanceAfter: ethers.utils.formatEther(await supergd.balanceOf(e.args.account)) + }); + console.table(ledger); + + if (failures.length) { + console.warn(`\n${failures.length} account(s) failed:`); + console.table( + failures.map(e => ({ + account: e.args.account, + gdAmount: ethers.utils.formatEther(e.args.gdAmount), + reason: e.args.reason + })) + ); + } + + const supplyAfter = await supergd.totalSupply(); + const burnedThisRun = burns.reduce((a, e) => a.add(e.args.gdAmount), ethers.constants.Zero); + const summary = { + network: networkName, + executor: executorAddress, + mode: freeList ? "burn" : "execute", + freeList: freeListFile ?? null, + txHash: receipt.transactionHash, + block: receipt.blockNumber, + burned: burns.length, + failed: failures.length, + burnedThisRunGD: ethers.utils.formatEther(burnedThisRun), + totalGDBurned: ethers.utils.formatEther(await executor.totalGDBurned()), + entriesBurned: `${(await executor.burnedEntries()).toString()}/${entries.length}`, + listComplete: await executor.complete(), + supplyBefore: ethers.utils.formatEther(supplyBefore), + supplyAfter: ethers.utils.formatEther(supplyAfter), + supplyDelta: ethers.utils.formatEther(supplyAfter.sub(supplyBefore)), + relinquished: await executor.relinquished(), + stillRegistered: await ctrl.isSchemeRegistered(executorAddress, release.Avatar), + refunds: ledger, + failures: failures.map(e => ({ + account: e.args.account, + gdAmount: ethers.utils.formatEther(e.args.gdAmount), + reason: e.args.reason + })) + }; + + console.log("\n=== summary ==="); + console.log({ ...summary, refunds: `${ledger.length} entries`, failures: `${failures.length} entries` }); + + if (!supplyBefore.sub(supplyAfter).eq(burnedThisRun)) + console.warn("WARNING: total supply moved by a different amount than burned - other activity in the same block?"); + if (summary.relinquished && summary.stillRegistered) + console.warn("WARNING: permission relinquished but the scheme is still registered - unregisterSelf failed?"); + if (!summary.relinquished) + console.log( + `\npermission retained: ${failures.length} entry(ies) still pending. ` + + `Re-run once resolved, or use FREE_LIST=... to finish and give up the permission.` + ); + + // the refund ledger is the input to the off-chain USD compensation process + const out = path.resolve( + process.env.OUT || path.join(__dirname, `admin-burn-refunds-${networkName}-${receipt.blockNumber}.json`) + ); + fs.writeFileSync(out, JSON.stringify(summary, null, 2)); + console.log("refund ledger written to", out); + return summary; +}; + +export const main = async () => { + await execute(); +}; +if (process.argv[1].includes("admin-burn-executor-execute")) + main().catch(e => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/upgrades/admin-burn-list.example.json b/scripts/upgrades/admin-burn-list.example.json new file mode 100644 index 00000000..a43e8b19 --- /dev/null +++ b/scripts/upgrades/admin-burn-list.example.json @@ -0,0 +1,14 @@ +[ + { + "account": "0x0000000000000000000000000000000000000001", + "gd": "1000000.00", + "refundUSD": "1234.56", + "note": "exploiter EOA - drained via " + }, + { + "account": "0x0000000000000000000000000000000000000002", + "gdWei": "500000000000000000000", + "refundUSDWei": "10000000000000000000", + "note": "raw base units are accepted too" + } +]