From 2848efc6eb80a9bc3b9dff9075b86f2839a6dabc Mon Sep 17 00:00:00 2001 From: blueogin Date: Wed, 9 Sep 2026 12:24:51 -0400 Subject: [PATCH 1/2] feat: add AdminBurnExecutor contract and deployment scripts --- contracts/utils/AdminBurnExecutor.sol | 179 ++++++++++ .../upgrades/admin-burn-executor-deploy.ts | 315 ++++++++++++++++++ .../upgrades/admin-burn-executor-execute.ts | 167 ++++++++++ scripts/upgrades/admin-burn-list.example.json | 14 + 4 files changed, 675 insertions(+) create mode 100644 contracts/utils/AdminBurnExecutor.sol create mode 100644 scripts/upgrades/admin-burn-executor-deploy.ts create mode 100644 scripts/upgrades/admin-burn-executor-execute.ts create mode 100644 scripts/upgrades/admin-burn-list.example.json diff --git a/contracts/utils/AdminBurnExecutor.sol b/contracts/utils/AdminBurnExecutor.sol new file mode 100644 index 00000000..3fa3a278 --- /dev/null +++ b/contracts/utils/AdminBurnExecutor.sol @@ -0,0 +1,179 @@ +// 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$ from a fixed list of + * addresses and records the USD refund owed to each of them. + * + * The list is fixed at construction time, so guardians can audit the exact + * (account, G$ amount, USD refund) tuples before registering this contract as a + * scheme with genericCall permission on the Controller. The contract has no way + * to burn anything that is not in the list, and no way to change the list. + * + * Flow: + * 1. deploy with the full list + * 2. guardians register this address as a scheme (genericCall permission) + * 3. anyone (or `owner`, see `execute`) calls `execute()` + * 4. the contract unregisters itself, permanently disarming it + * + * 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 Executed( + uint256 entries, + uint256 totalGDBurned, + uint256 totalRefundUSD + ); + + Controller public immutable controller; + IAdminBurnable public immutable token; + address public owner; + + /// @notice sum of all `gdAmount` in the list, checked against the actual burns + uint256 public immutable totalGDToBurn; + /// @notice sum of all `refundUSD` in the list + uint256 public immutable totalRefundUSD; + + BurnEntry[] public entries; + + bool public executed; + + 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(_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 make the pre-flight balance check misleading + for (uint256 j = 0; j < i; j++) + require(_entries[j].account != e.account, "duplicate account"); + + entries.push(e); + 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 read-only pre-flight: are all listed balances still sufficient? + * Guardians can call this before signing the scheme registration, and it is + * re-checked inside `execute`. + */ + function canExecute() + external + view + returns (bool ok, address firstShortAccount) + { + 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 (token.balanceOf(entries[i].account) < entries[i].gdAmount) + return (false, entries[i].account); + + return (!executed, address(0)); + } + + /** + * @notice burn the whole list in one transaction, then unregister. + * All or nothing: if a single burn fails, the whole transaction reverts and + * the scheme stays armed so it can be retried. + */ + function execute() external { + require(msg.sender == owner, "not owner"); + require(!executed, "already executed"); + executed = true; + + address 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"); + + uint256 burned; + uint256 len = entries.length; + for (uint256 i = 0; i < len; i++) { + BurnEntry memory e = entries[i]; + uint256 balanceBefore = token.balanceOf(e.account); + require(balanceBefore >= e.gdAmount, "insufficient balance"); + + (bool ok, ) = controller.genericCall( + address(token), + abi.encodeCall(IAdminBurnable.adminBurn, (e.account, e.gdAmount)), + avatar, + 0 + ); + require(ok, "adminBurn failed"); + // genericCall swallows reverts of non-existent functions on some + // proxies, so verify the balance actually moved + require( + token.balanceOf(e.account) == balanceBefore - e.gdAmount, + "burn had no effect" + ); + + burned += e.gdAmount; + emit AdminBurn(e.account, e.gdAmount, e.refundUSD); + } + + require(burned == totalGDToBurn, "burn total mismatch"); + emit Executed(len, burned, totalRefundUSD); + + owner = address(0); // mark as run + // prevent this contract from ever calling genericCall again + require(controller.unregisterSelf(avatar), "unregistering failed"); + } + + /** + * @notice give up the permission without burning anything (abort path). + */ + function cancel() external { + require(msg.sender == owner, "not owner"); + owner = address(0); + address avatar = controller.avatar(); + if (controller.isSchemeRegistered(address(this), avatar)) + require(controller.unregisterSelf(avatar), "unregistering failed"); + } +} diff --git a/scripts/upgrades/admin-burn-executor-deploy.ts b/scripts/upgrades/admin-burn-executor-deploy.ts new file mode 100644 index 00000000..e04c7f5d --- /dev/null +++ b/scripts/upgrades/admin-burn-executor-deploy.ts @@ -0,0 +1,315 @@ +/*** + * 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 an implementation that has no adminBurn + if (!supergd.interface.functions["adminBurn(address,uint256)"]) + throw new Error("local artifacts have no adminBurn(address,uint256) - run `yarn compile`"); + // ...and against a *live* implementation that has no adminBurn either. The + // proxy would delegate the call into an implementation that reverts, so check + // the selector is actually present in the deployed code. + const selector = supergd.interface.getSighash("adminBurn(address,uint256)"); + const liveImpl = await supergd.getCodeAddress(); + const liveCode = await ethers.provider.getCode(liveImpl); + if (!liveCode.includes(selector.slice(2)) && !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, "(adminBurn present)"); + + // ---------------------------------------------------------------- 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() is all-or-nothing and would revert.` + ); + + 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..5e236114 --- /dev/null +++ b/scripts/upgrades/admin-burn-executor-execute.ts @@ -0,0 +1,167 @@ +/*** + * Run a registered AdminBurnExecutor: burn the whole list in one transaction and + * print the refund ledger the burns produced. + * + * 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 + * CANCEL=1 call cancel() instead: drop the permission, burn nothing + * OUT=path.json write the refund ledger to this file (default alongside the script) + */ +import fs from "fs"; +import path from "path"; +import { network, ethers } from "hardhat"; +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, executed, entries, totalGD, totalUSD] = await Promise.all([ + executor.owner(), + executor.executed(), + executor.getEntries(), + executor.totalGDToBurn(), + executor.totalRefundUSD() + ]); + const registered = await ctrl.isSchemeRegistered(executorAddress, release.Avatar); + const perms = await ctrl.getSchemePermissions(executorAddress, release.Avatar); + + console.log("=== executor ==="); + console.log({ + networkName, + executor: executorAddress, + owner, + signer: root.address, + executed, + registered, + permissions: perms, + entries: entries.length, + totalGDToBurn: ethers.utils.formatEther(totalGD), + totalRefundUSD: ethers.utils.formatEther(totalUSD) + }); + + if (executed) throw new Error("executor has already run - deploy a new one for a new list"); + 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`); + + // ---------------------------------------------------------------- preflight + console.log("\n=== preflight ==="); + const [ok, firstShort] = await executor.canExecute(); + const balancesBefore: { [k: string]: any } = {}; + const rows: any[] = []; + for (const e of entries) { + const balance = await supergd.balanceOf(e.account); + balancesBefore[e.account] = balance; + rows.push({ + account: e.account, + burnGD: ethers.utils.formatEther(e.gdAmount), + balanceGD: ethers.utils.formatEther(balance), + enough: balance.gte(e.gdAmount), + refundUSD: ethers.utils.formatEther(e.refundUSD) + }); + } + console.table(rows); + console.log("canExecute:", ok, firstShort !== ethers.constants.AddressZero ? `short: ${firstShort}` : ""); + if (!ok) + throw new Error( + firstShort !== ethers.constants.AddressZero + ? `${firstShort} holds less than its listed burn amount - execute() is all-or-nothing` + : "canExecute() is false - check scheme registration and token ownership" + ); + + const supplyBefore = await supergd.totalSupply(); + await executor.callStatic.execute(); + console.log("execute() simulation: OK"); + + if (DRY) return console.log("\nDRY run complete - nothing burned."); + + // ---------------------------------------------------------------- execute + console.log("\n=== executing ==="); + const receipt = await (await executor.execute()).wait(); + console.log("burned in", receipt.transactionHash, "gas", receipt.gasUsed.toString()); + + // ---------------------------------------------------------------- ledger + const burns = receipt.events + .filter(e => e.address.toLowerCase() === executorAddress.toLowerCase() && e.event === "AdminBurn") + .map(e => ({ + account: e.args.account, + burnedGD: ethers.utils.formatEther(e.args.gdAmount), + refundUSD: ethers.utils.formatEther(e.args.refundUSD), + balanceBefore: ethers.utils.formatEther(balancesBefore[e.args.account] ?? 0) + })); + + const after: any[] = []; + for (const b of burns) + after.push({ ...b, balanceAfter: ethers.utils.formatEther(await supergd.balanceOf(b.account)) }); + console.table(after); + + const supplyAfter = await supergd.totalSupply(); + const summary = { + network: networkName, + executor: executorAddress, + txHash: receipt.transactionHash, + block: receipt.blockNumber, + accounts: burns.length, + totalBurnedGD: ethers.utils.formatEther(totalGD), + totalRefundUSD: ethers.utils.formatEther(totalUSD), + supplyBefore: ethers.utils.formatEther(supplyBefore), + supplyAfter: ethers.utils.formatEther(supplyAfter), + supplyDelta: ethers.utils.formatEther(supplyAfter.sub(supplyBefore)), + stillRegistered: await ctrl.isSchemeRegistered(executorAddress, release.Avatar), + refunds: after + }; + console.log("\n=== summary ==="); + console.log({ ...summary, refunds: `${after.length} entries` }); + + if (!supplyBefore.sub(supplyAfter).eq(totalGD)) + console.warn("WARNING: total supply moved by a different amount than burned - other activity in the same block?"); + if (summary.stillRegistered) console.warn("WARNING: executor is still a registered scheme - unregisterSelf failed?"); + + // 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}.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" + } +] From f4a02f3ead23b6fdd1357a20e6281d2090533266 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 10 Sep 2026 08:34:58 -0400 Subject: [PATCH 2/2] feat: enhance AdminBurnExecutor with flexible burning options and improved error handling --- contracts/utils/AdminBurnExecutor.sol | 255 +++++++++++++----- .../upgrades/admin-burn-executor-deploy.ts | 19 +- .../upgrades/admin-burn-executor-execute.ts | 187 +++++++++---- 3 files changed, 341 insertions(+), 120 deletions(-) diff --git a/contracts/utils/AdminBurnExecutor.sol b/contracts/utils/AdminBurnExecutor.sol index 3fa3a278..161a1b05 100644 --- a/contracts/utils/AdminBurnExecutor.sol +++ b/contracts/utils/AdminBurnExecutor.sol @@ -12,19 +12,18 @@ interface IAdminBurnable { } /** - * @notice One-shot scheme that burns illegitimate G$ from a fixed list of - * addresses and records the USD refund owed to each of them. + * @notice One-shot scheme that burns illegitimate G$ and records the USD refund + * owed to each affected address. * - * The list is fixed at construction time, so guardians can audit the exact - * (account, G$ amount, USD refund) tuples before registering this contract as a - * scheme with genericCall permission on the Controller. The contract has no way - * to burn anything that is not in the list, and no way to change the list. - * - * Flow: - * 1. deploy with the full list - * 2. guardians register this address as a scheme (genericCall permission) - * 3. anyone (or `owner`, see `execute`) calls `execute()` - * 4. the contract unregisters itself, permanently disarming it + * 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 @@ -38,24 +37,36 @@ contract AdminBurnExecutor { } 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 entries, + uint256 burnedNow, + uint256 failedNow, uint256 totalGDBurned, - uint256 totalRefundUSD + 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 list, checked against the actual burns + /// @notice sum of all `gdAmount` in the constructor list uint256 public immutable totalGDToBurn; - /// @notice sum of all `refundUSD` in the list + /// @notice sum of all `refundUSD` in the constructor list uint256 public immutable totalRefundUSD; BurnEntry[] public entries; - - bool public executed; + /// @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, @@ -65,6 +76,7 @@ contract AdminBurnExecutor { ) { 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; @@ -77,11 +89,12 @@ contract AdminBurnExecutor { BurnEntry memory e = _entries[i]; require(e.account != address(0), "zero account"); require(e.gdAmount > 0, "zero burn amount"); - // duplicates would make the pre-flight balance check misleading + // 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; } @@ -97,73 +110,117 @@ contract AdminBurnExecutor { 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: are all listed balances still sufficient? - * Guardians can call this before signing the scheme registration, and it is - * re-checked inside `execute`. + * @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 (token.balanceOf(entries[i].account) < entries[i].gdAmount) - return (false, entries[i].account); + if (!entryBurned[i] && token.balanceOf(entries[i].account) < entries[i].gdAmount) + return (true, entries[i].account); - return (!executed, address(0)); + return (true, address(0)); } /** - * @notice burn the whole list in one transaction, then unregister. - * All or nothing: if a single burn fails, the whole transaction reverts and - * the scheme stays armed so it can be retried. + * @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 { + function execute() external returns (uint256 burnedNow, uint256 failedNow) { require(msg.sender == owner, "not owner"); - require(!executed, "already executed"); - executed = true; + require(!relinquished, "permission already relinquished"); + require(!complete(), "already executed"); - address 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"); + address avatar = _avatarWithBurnRights(); - uint256 burned; - uint256 len = entries.length; - for (uint256 i = 0; i < len; i++) { + for (uint256 i = 0; i < entries.length; i++) { + if (entryBurned[i]) continue; BurnEntry memory e = entries[i]; - uint256 balanceBefore = token.balanceOf(e.account); - require(balanceBefore >= e.gdAmount, "insufficient balance"); - - (bool ok, ) = controller.genericCall( - address(token), - abi.encodeCall(IAdminBurnable.adminBurn, (e.account, e.gdAmount)), - avatar, - 0 - ); - require(ok, "adminBurn failed"); - // genericCall swallows reverts of non-existent functions on some - // proxies, so verify the balance actually moved - require( - token.balanceOf(e.account) == balanceBefore - e.gdAmount, - "burn had no effect" - ); - - burned += e.gdAmount; - emit AdminBurn(e.account, e.gdAmount, e.refundUSD); + + 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++; + } } - require(burned == totalGDToBurn, "burn total mismatch"); - emit Executed(len, burned, totalRefundUSD); + emit FreeBurn(burnedNow, failedNow, totalGDBurned); - owner = address(0); // mark as run - // prevent this contract from ever calling genericCall again - require(controller.unregisterSelf(avatar), "unregistering failed"); + // terminal by design: no second free-form burn without a new approval + _relinquish(avatar); } /** @@ -171,9 +228,81 @@ contract AdminBurnExecutor { */ 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); - address avatar = controller.avatar(); 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 index e04c7f5d..0b2d3f42 100644 --- a/scripts/upgrades/admin-burn-executor-deploy.ts +++ b/scripts/upgrades/admin-burn-executor-deploy.ts @@ -125,20 +125,18 @@ export const deploy = async () => { 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 an implementation that has no adminBurn - if (!supergd.interface.functions["adminBurn(address,uint256)"]) - throw new Error("local artifacts have no adminBurn(address,uint256) - run `yarn compile`"); - // ...and against a *live* implementation that has no adminBurn either. The - // proxy would delegate the call into an implementation that reverts, so check - // the selector is actually present in the deployed code. - const selector = supergd.interface.getSighash("adminBurn(address,uint256)"); + // 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); - if (!liveCode.includes(selector.slice(2)) && !process.env.SKIP_IMPL_CHECK) + 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, "(adminBurn present)"); + console.log("live implementation:", liveImpl, hasAdminBurn ? "(adminBurn present)" : "(NO adminBurn - bypassed)"); // ---------------------------------------------------------------- the list console.log("\n=== burn list ==="); @@ -167,7 +165,8 @@ export const deploy = async () => { }); if (short > 0) console.warn( - `WARNING: ${short} account(s) hold less than the listed amount. execute() is all-or-nothing and would revert.` + `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."); diff --git a/scripts/upgrades/admin-burn-executor-execute.ts b/scripts/upgrades/admin-burn-executor-execute.ts index 5e236114..c4ca8771 100644 --- a/scripts/upgrades/admin-burn-executor-execute.ts +++ b/scripts/upgrades/admin-burn-executor-execute.ts @@ -1,6 +1,14 @@ /*** - * Run a registered AdminBurnExecutor: burn the whole list in one transaction and - * print the refund ledger the burns produced. + * 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 @@ -8,14 +16,18 @@ * - 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 - * CANCEL=1 call cancel() instead: drop the permission, burn nothing - * OUT=path.json write the refund ledger to this file (default alongside the script) + * 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; @@ -37,31 +49,41 @@ export const execute = async () => { const supergd = await ethers.getContractAt("SuperGoodDollar", release.GoodDollar); const ctrl = await ethers.getContractAt("Controller", release.Controller); - const [owner, executed, entries, totalGD, totalUSD] = await Promise.all([ + const [owner, relinquished, isComplete, entries, burnedEntries, totalGD, totalUSD, burnedSoFar] = await Promise.all([ executor.owner(), - executor.executed(), + executor.relinquished(), + executor.complete(), executor.getEntries(), + executor.burnedEntries(), executor.totalGDToBurn(), - executor.totalRefundUSD() + 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, - executed, + 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 (executed) throw new Error("executor has already run - deploy a new one for a new list"); + 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}`); @@ -75,83 +97,154 @@ export const execute = async () => { } if (!registered) - throw new Error(`executor is not a registered scheme on the Controller - guardians must sign registerScheme first`); + 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 [ok, firstShort] = await executor.canExecute(); const balancesBefore: { [k: string]: any } = {}; const rows: any[] = []; - for (const e of entries) { - const balance = await supergd.balanceOf(e.account); - balancesBefore[e.account] = balance; + 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: e.account, - burnGD: ethers.utils.formatEther(e.gdAmount), + account: t.account, + burnGD: ethers.utils.formatEther(t.gdAmount), balanceGD: ethers.utils.formatEther(balance), - enough: balance.gte(e.gdAmount), - refundUSD: ethers.utils.formatEther(e.refundUSD) + 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 (!ok) - throw new Error( - firstShort !== ethers.constants.AddressZero - ? `${firstShort} holds less than its listed burn amount - execute() is all-or-nothing` - : "canExecute() is false - check scheme registration and token ownership" + 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(); - await executor.callStatic.execute(); - console.log("execute() simulation: OK"); + 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=== executing ==="); - const receipt = await (await executor.execute()).wait(); + 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 burns = receipt.events - .filter(e => e.address.toLowerCase() === executorAddress.toLowerCase() && e.event === "AdminBurn") - .map(e => ({ + 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), - refundUSD: ethers.utils.formatEther(e.args.refundUSD), - balanceBefore: ethers.utils.formatEther(balancesBefore[e.args.account] ?? 0) - })); + // 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); - const after: any[] = []; - for (const b of burns) - after.push({ ...b, balanceAfter: ethers.utils.formatEther(await supergd.balanceOf(b.account)) }); - console.table(after); + 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, - accounts: burns.length, - totalBurnedGD: ethers.utils.formatEther(totalGD), - totalRefundUSD: ethers.utils.formatEther(totalUSD), + 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: after + 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: `${after.length} entries` }); + console.log({ ...summary, refunds: `${ledger.length} entries`, failures: `${failures.length} entries` }); - if (!supplyBefore.sub(supplyAfter).eq(totalGD)) + 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.stillRegistered) console.warn("WARNING: executor is still a registered scheme - unregisterSelf failed?"); + 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}.json`)); + 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;