diff --git a/.env.example b/.env.example index 30aec24..2ae07a2 100644 --- a/.env.example +++ b/.env.example @@ -39,4 +39,8 @@ ALLOWED_ROUTER_COUNT=2 ALLOWED_ROUTER_0="0x204FAca1764B154221e35c0d20aBb3c525710498" # 4663 Universal Router 2.1.2 ALLOWED_ROUTER_0_APPROVAL="PERMIT2" ALLOWED_ROUTER_1="0x8876789976dEcBfCbBbe364623C63652db8C0904" # 4663 Universal Router 2.1.1 -ALLOWED_ROUTER_1_APPROVAL="PERMIT2" \ No newline at end of file +ALLOWED_ROUTER_1_APPROVAL="PERMIT2" +# Post-deploy configuration +KEEPER_ADDRESS= +CONSOL_USDX_MAXIMUM_CAP= # 0 leaves the cap unchanged +ADMIN_ADDRESS= # Defaults to the deployer diff --git a/.gitignore b/.gitignore index d6c8fd9..e6e8091 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ coverage/ /broadcast/*/31337/ /broadcast/**/dry-run/ +# Forge-test address fixtures; only the per-chain books are tracked +/addresses/tests/* +!/addresses/tests/.gitkeep + # Docs docs/ diff --git a/addresses/addresses-4663.json b/addresses/addresses-4663.json new file mode 100644 index 0000000..a10f026 --- /dev/null +++ b/addresses/addresses-4663.json @@ -0,0 +1,5 @@ +{ + "fulfillmentVaultAddress": "0x5DAaF4cacd8b1EE5Db0715C790c58124c2Dc006f", + "rolloverVaultAddress": "0x0ADE37a031530FDE25BF36Be6ED3F3547366f2e0", + "routerAddress": "0xD90E52f001CDa68E1B3a0c4355419edA9493BaFe" +} \ No newline at end of file diff --git a/addresses/tests/.gitkeep b/addresses/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/foundry.toml b/foundry.toml index 8becaa3..ad1f19b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -29,6 +29,7 @@ fs_permissions = [ { access = "read-write", path = "./addresses/addresses-999.json"}, { access = "read-write", path = "./addresses/addresses-46630.json"}, { access = "read-write", path = "./addresses/addresses-4663.json"}, + { access = "read-write", path = "./addresses/tests/"}, { access = "read", path = "./test/artifacts/WHYPE9.json"} ] diff --git a/script/ConfigureUniswapFulfillment.s.sol b/script/ConfigureUniswapFulfillment.s.sol new file mode 100644 index 0000000..616bf13 --- /dev/null +++ b/script/ConfigureUniswapFulfillment.s.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {BaseScript} from "./BaseScript.s.sol"; +import {console} from "forge-std/console.sol"; +import {UniswapFulfillmentVault} from "../src/UniswapFulfillmentVault.sol"; +import {RolloverVault} from "../src/RolloverVault.sol"; +import {Roles} from "@core/libraries/Roles.sol"; +import {IConsol} from "@core/interfaces/IConsol/IConsol.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @notice Post-deploy configuration for a chain that fills on Uniswap: wires the UniswapFulfillmentVault + * into the core stack and hands out the operational roles. Reads both address books for the current + * chain. Every step is idempotent, so a partial run can be repeated. + * + * Env: KEEPER_ADDRESS (KEEPER_ROLE on both vaults), CONSOL_USDX_MAXIMUM_CAP (0 leaves the cap alone), + * ADMIN_ADDRESS (PAUSE_ROLE holder; defaults to the deployer). + */ +contract ConfigureUniswapFulfillmentScript is BaseScript { + address public keeperAddress; + address public adminAddress; + uint256 public consolUsdxMaximumCap; + + address public usdx; + address public consol; + address public orderPool; + address[] public collaterals; + address[] public pausables; + UniswapFulfillmentVault public uniswapFulfillmentVault; + RolloverVault public rolloverVault; + + function setUp() public virtual override { + super.setUp(); + keeperAddress = vm.envAddress("KEEPER_ADDRESS"); + console.log("Keeper address: %s", keeperAddress); + adminAddress = vm.envOr("ADMIN_ADDRESS", deployerAddress); + console.log("Admin (PAUSE_ROLE) address: %s", adminAddress); + consolUsdxMaximumCap = vm.envOr("CONSOL_USDX_MAXIMUM_CAP", uint256(0)); + console.log("Consol USDX maximum cap: %s", consolUsdxMaximumCap); + readAddressBooks(); + } + + /// @dev The core book lives in the sibling core checkout, which is only present in the monorepo. + /// Overridable, with its pair below, so a test can point at fixtures it owns. + function coreAddressBookPath() public view virtual returns (string memory) { + return string.concat(vm.projectRoot(), "/../contracts/addresses/addresses-", vm.toString(block.chainid), ".json"); + } + + function peripheryAddressBookPath() public view virtual returns (string memory) { + return string.concat(vm.projectRoot(), "/addresses/addresses-", vm.toString(block.chainid), ".json"); + } + + function readAddressBooks() public { + string memory core = vm.readFile(coreAddressBookPath()); + string memory periphery = vm.readFile(peripheryAddressBookPath()); + + usdx = vm.parseJsonAddress(core, ".usdxAddress"); + consol = vm.parseJsonAddress(core, ".consolAddress"); + orderPool = vm.parseJsonAddress(core, ".orderPoolAddress"); + collaterals = vm.parseJsonAddressArray(core, ".collateralAddresses"); + pausables.push(vm.parseJsonAddress(core, ".generalManagerAddress")); + pausables.push(vm.parseJsonAddress(core, ".forfeitedAssetsPoolAddress")); + pausables.push(vm.parseJsonAddress(core, ".usdxQueue")); + pausables.push(vm.parseJsonAddress(core, ".forfeitedAssetsQueue")); + pausables.push(vm.parseJsonAddress(core, ".originationPoolSchedulerAddress")); + address[] memory conversionQueues = vm.parseJsonAddressArray(core, ".conversionQueues"); + for (uint256 i = 0; i < conversionQueues.length; i++) { + pausables.push(conversionQueues[i]); + } + + uniswapFulfillmentVault = + UniswapFulfillmentVault(payable(vm.parseJsonAddress(periphery, ".fulfillmentVaultAddress"))); + rolloverVault = RolloverVault(payable(vm.parseJsonAddress(periphery, ".rolloverVaultAddress"))); + + require(uniswapFulfillmentVault.orderPool() == orderPool, "Vault order pool does not match the core address book"); + require(uniswapFulfillmentVault.usdx() == usdx, "Vault USDX does not match the core address book"); + console.log("Address books read: %s collaterals, %s pausable contracts", collaterals.length, pausables.length); + } + + function run() public virtual override { + startDeployerBroadcast(); + configureConsolCap(); + configureFulfillment(); + configurePauseRole(); + configureKeepers(); + vm.stopBroadcast(); + assertConfigured(); + } + + function configureConsolCap() public { + if (consolUsdxMaximumCap == 0) { + console.log("Consol USDX cap: unchanged"); + return; + } + if (IConsol(consol).maximumCap(usdx) == consolUsdxMaximumCap) { + console.log("Consol USDX cap: already set"); + return; + } + IConsol(consol).setMaximumCap(usdx, consolUsdxMaximumCap); + console.log("Consol USDX cap: set"); + } + + function configureFulfillment() public { + address vault = address(uniswapFulfillmentVault); + _grantIfMissing(orderPool, Roles.FULFILLMENT_ROLE, vault, "OrderPool FULFILLMENT_ROLE -> vault"); + _grantIfMissing(usdx, Roles.IGNORE_CAP_ROLE, vault, "USDX IGNORE_CAP_ROLE -> vault"); + for (uint256 i = 0; i < collaterals.length; i++) { + _approveIfMissing(collaterals[i]); + } + _approveIfMissing(usdx); + } + + function configurePauseRole() public { + for (uint256 i = 0; i < pausables.length; i++) { + _grantIfMissing(pausables[i], Roles.PAUSE_ROLE, adminAddress, "PAUSE_ROLE -> admin"); + } + } + + function configureKeepers() public { + _grantIfMissing( + address(rolloverVault), rolloverVault.KEEPER_ROLE(), keeperAddress, "RolloverVault KEEPER_ROLE -> keeper" + ); + _grantIfMissing( + address(uniswapFulfillmentVault), + uniswapFulfillmentVault.KEEPER_ROLE(), + keeperAddress, + "Vault KEEPER_ROLE -> keeper" + ); + } + + /// @dev Re-reads everything after the broadcast so a silent miss fails the run. + function assertConfigured() public view { + address vault = address(uniswapFulfillmentVault); + if (consolUsdxMaximumCap != 0) { + require(IConsol(consol).maximumCap(usdx) == consolUsdxMaximumCap, "Consol USDX cap not set"); + } + require(IAccessControl(orderPool).hasRole(Roles.FULFILLMENT_ROLE, vault), "FULFILLMENT_ROLE missing"); + require(IAccessControl(usdx).hasRole(Roles.IGNORE_CAP_ROLE, vault), "IGNORE_CAP_ROLE missing"); + for (uint256 i = 0; i < collaterals.length; i++) { + require(IERC20(collaterals[i]).allowance(vault, orderPool) == type(uint256).max, "Collateral allowance missing"); + } + require(IERC20(usdx).allowance(vault, orderPool) == type(uint256).max, "USDX allowance missing"); + for (uint256 i = 0; i < pausables.length; i++) { + require(IAccessControl(pausables[i]).hasRole(Roles.PAUSE_ROLE, adminAddress), "PAUSE_ROLE missing"); + } + require(rolloverVault.hasRole(rolloverVault.KEEPER_ROLE(), keeperAddress), "RolloverVault keeper missing"); + require( + uniswapFulfillmentVault.hasRole(uniswapFulfillmentVault.KEEPER_ROLE(), keeperAddress), "Vault keeper missing" + ); + console.log("Configuration verified"); + } + + function _grantIfMissing(address target, bytes32 role, address account, string memory label) internal { + if (IAccessControl(target).hasRole(role, account)) { + console.log("%s: already held (%s)", label, target); + return; + } + IAccessControl(target).grantRole(role, account); + console.log("%s: granted (%s)", label, target); + } + + function _approveIfMissing(address asset) internal { + if (IERC20(asset).allowance(address(uniswapFulfillmentVault), orderPool) == type(uint256).max) { + console.log("Order pool approval: already max (%s)", asset); + return; + } + uniswapFulfillmentVault.approveAssetToOrderPool(asset); + console.log("Order pool approval: set (%s)", asset); + } +} diff --git a/test/script/ConfigureUniswapFulfillment.t.sol b/test/script/ConfigureUniswapFulfillment.t.sol new file mode 100644 index 0000000..4dd2de3 --- /dev/null +++ b/test/script/ConfigureUniswapFulfillment.t.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {BaseTest} from "../BaseTest.t.sol"; +import {ConfigureUniswapFulfillmentScript} from "../../script/ConfigureUniswapFulfillment.s.sol"; +import {DeployRolloverVaultScript} from "../../script/DeployRolloverVault.s.sol"; +import {DeployUniswapFulfillmentVaultScript} from "../../script/DeployUniswapFulfillmentVault.s.sol"; +import {RolloverVault} from "../../src/RolloverVault.sol"; +import {UniswapFulfillmentVault} from "../../src/UniswapFulfillmentVault.sol"; +import {MockPermit2} from "../mocks/MockPermit2.sol"; +import {ForfeitedAssetsQueue} from "@core/ForfeitedAssetsQueue.sol"; +import {UsdxQueue} from "@core/UsdxQueue.sol"; +import {Roles} from "@core/libraries/Roles.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Vm} from "forge-std/Vm.sol"; + +/// @dev Reads both books from fixtures the test owns: the core book the script reads in production +/// lives in the sibling core checkout, which is not part of this repo's checkout, and the periphery +/// book is a tracked file the suite must not leave rewritten. +contract ConfigureUniswapFulfillmentHarness is ConfigureUniswapFulfillmentScript { + string internal corePath; + string internal peripheryPath; + + constructor(string memory corePath_, string memory peripheryPath_) { + corePath = corePath_; + peripheryPath = peripheryPath_; + } + + function coreAddressBookPath() public view override returns (string memory) { + return corePath; + } + + function peripheryAddressBookPath() public view override returns (string memory) { + return peripheryPath; + } +} + +/** + * @dev vm.setEnv writes process-global state; every var read by the deploy and configure scripts is + * set here so the test does not depend on the shell's .env. + */ +contract ConfigureUniswapFulfillmentScriptTest is BaseTest { + uint256 public constant TEST_CHAIN_ID = 4663; + uint256 public constant USDX_CAP = 1_000_000e18; + + bytes32 internal constant ROLE_GRANTED = keccak256("RoleGranted(bytes32,address,address)"); + bytes32 internal constant APPROVAL = keccak256("Approval(address,address,uint256)"); + bytes32 internal constant MAXIMUM_CAP_SET = keccak256("MaximumCapSet(address,uint256)"); + + DeployRolloverVaultScript public rolloverVaultScript; + DeployUniswapFulfillmentVaultScript public vaultScript; + ConfigureUniswapFulfillmentHarness public script; + MockPermit2 public permit2; + + UsdxQueue public usdxQueue; + ForfeitedAssetsQueue public forfeitedAssetsQueue; + + RolloverVault public deployedRolloverVault; + UniswapFulfillmentVault public vault; + + address public pauseAdmin = makeAddr("pauseAdmin"); + address public universalRouter = makeAddr("universalRouter"); + address public swapRouter02 = makeAddr("swapRouter02"); + + function setUp() public { + setUpCore(); + permit2 = new MockPermit2(); + usdxQueue = new UsdxQueue(address(usdx), address(consol), admin); + forfeitedAssetsQueue = new ForfeitedAssetsQueue(address(forfeitedAssetsPool), address(consol), admin); + + _setDeployEnv(); + vm.setEnv("KEEPER_ADDRESS", vm.toString(keeper)); + vm.setEnv("ADMIN_ADDRESS", vm.toString(pauseAdmin)); + vm.setEnv("CONSOL_USDX_MAXIMUM_CAP", vm.toString(USDX_CAP)); + + vm.chainId(TEST_CHAIN_ID); + + // The per-vault scripts write no address book, so this suite never touches the tracked one that + // DeployAllUniswap's own test snapshots and restores + rolloverVaultScript = new DeployRolloverVaultScript(); + rolloverVaultScript.setUp(); + rolloverVaultScript.run(); + deployedRolloverVault = rolloverVaultScript.rolloverVault(); + + vaultScript = new DeployUniswapFulfillmentVaultScript(); + vaultScript.setUp(); + vaultScript.run(); + vault = vaultScript.uniswapFulfillmentVault(); + + _writePeripheryFixture(); + _writeCoreFixture(); + script = new ConfigureUniswapFulfillmentHarness(_coreFixturePath(), _peripheryFixturePath()); + script.setUp(); + } + + function test_setUp_readsBothBooks() public view { + assertEq(script.keeperAddress(), keeper); + assertEq(script.adminAddress(), pauseAdmin); + assertEq(script.consolUsdxMaximumCap(), USDX_CAP); + assertEq(script.usdx(), address(usdx)); + assertEq(script.consol(), address(consol)); + assertEq(script.orderPool(), address(orderPool)); + assertEq(address(script.uniswapFulfillmentVault()), address(vault)); + assertEq(address(script.rolloverVault()), address(deployedRolloverVault)); + } + + function test_run_configuresStack() public { + assertFalse(IAccessControl(address(orderPool)).hasRole(Roles.FULFILLMENT_ROLE, address(vault))); + assertEq(consol.maximumCap(address(usdx)), type(uint256).max); + + script.run(); + + assertEq(consol.maximumCap(address(usdx)), USDX_CAP); + assertTrue(IAccessControl(address(orderPool)).hasRole(Roles.FULFILLMENT_ROLE, address(vault))); + assertTrue(IAccessControl(address(usdx)).hasRole(Roles.IGNORE_CAP_ROLE, address(vault))); + + assertEq(IERC20(address(whype)).allowance(address(vault), address(orderPool)), type(uint256).max); + assertEq(IERC20(address(ubtc)).allowance(address(vault), address(orderPool)), type(uint256).max); + assertEq(IERC20(address(usdx)).allowance(address(vault), address(orderPool)), type(uint256).max); + + address[7] memory pausables = [ + address(generalManager), + address(forfeitedAssetsPool), + address(usdxQueue), + address(forfeitedAssetsQueue), + address(originationPoolScheduler), + address(whypeConversionQueue), + address(ubtcConversionQueue) + ]; + for (uint256 i = 0; i < pausables.length; i++) { + assertTrue(IAccessControl(pausables[i]).hasRole(Roles.PAUSE_ROLE, pauseAdmin)); + } + + assertTrue(deployedRolloverVault.hasRole(deployedRolloverVault.KEEPER_ROLE(), keeper)); + assertTrue(vault.hasRole(vault.KEEPER_ROLE(), keeper)); + + // Re-reads the chain rather than the script's own bookkeeping + script.assertConfigured(); + } + + function test_run_secondRunIsIdempotent() public { + vm.recordLogs(); + script.run(); + (uint256 grants, uint256 approvals, uint256 caps) = _countWrites(vm.getRecordedLogs()); + // FULFILLMENT_ROLE, IGNORE_CAP_ROLE, PAUSE_ROLE on 7 contracts, KEEPER_ROLE on both vaults + assertEq(grants, 11); + // Both collaterals and USDX + assertEq(approvals, 3); + assertEq(caps, 1); + + vm.recordLogs(); + script.run(); + (grants, approvals, caps) = _countWrites(vm.getRecordedLogs()); + assertEq(grants, 0); + assertEq(approvals, 0); + assertEq(caps, 0); + + script.assertConfigured(); + } + + function test_run_zeroCapLeavesConsolCapUntouched() public { + vm.setEnv("CONSOL_USDX_MAXIMUM_CAP", "0"); + ConfigureUniswapFulfillmentHarness zeroCapScript = + new ConfigureUniswapFulfillmentHarness(_coreFixturePath(), _peripheryFixturePath()); + zeroCapScript.setUp(); + assertEq(zeroCapScript.consolUsdxMaximumCap(), 0); + + uint256 capBefore = consol.maximumCap(address(usdx)); + assertEq(capBefore, type(uint256).max); + + vm.recordLogs(); + zeroCapScript.run(); + (,, uint256 caps) = _countWrites(vm.getRecordedLogs()); + + assertEq(caps, 0); + assertEq(consol.maximumCap(address(usdx)), capBefore); + } + + function test_setUp_revertsWhenVaultOrderPoolMismatches() public { + vm.mockCall(address(vault), abi.encodeWithSelector(UniswapFulfillmentVault.orderPool.selector), abi.encode(rando)); + ConfigureUniswapFulfillmentHarness mismatched = + new ConfigureUniswapFulfillmentHarness(_coreFixturePath(), _peripheryFixturePath()); + + vm.expectRevert("Vault order pool does not match the core address book"); + mismatched.setUp(); + } + + function test_setUp_revertsWhenVaultUsdxMismatches() public { + vm.mockCall(address(vault), abi.encodeWithSelector(UniswapFulfillmentVault.usdx.selector), abi.encode(rando)); + ConfigureUniswapFulfillmentHarness mismatched = + new ConfigureUniswapFulfillmentHarness(_coreFixturePath(), _peripheryFixturePath()); + + vm.expectRevert("Vault USDX does not match the core address book"); + mismatched.setUp(); + } + + function _countWrites(Vm.Log[] memory logs) internal pure returns (uint256 grants, uint256 approvals, uint256 caps) { + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == ROLE_GRANTED) grants++; + if (logs[i].topics[0] == APPROVAL) approvals++; + if (logs[i].topics[0] == MAXIMUM_CAP_SET) caps++; + } + } + + function _setDeployEnv() internal { + vm.setEnv("DEPLOYER_ADDRESS", vm.toString(admin)); + vm.setEnv("DEPLOYER_PRIVATE_KEY", "0"); + vm.setEnv("IS_TEST", "true"); + vm.setEnv("WRAPPED_NATIVE_TOKEN_ADDRESS", vm.toString(address(whype))); + vm.setEnv("GENERAL_MANAGER_ADDRESS", vm.toString(address(generalManager))); + vm.setEnv("SIMPLE_ORACLE_ADDRESS", vm.toString(address(0))); + + vm.setEnv("ROLLOVER_VAULT_NAME", "Rollover Vault"); + vm.setEnv("ROLLOVER_VAULT_SYMBOL", "RLV"); + vm.setEnv("ROLLOVER_VAULT_DECIMALS", "24"); + vm.setEnv("ROLLOVER_VAULT_DECIMALS_OFFSET", "6"); + vm.setEnv("ROLLOVER_VAULT_ADMIN_ADDRESS", vm.toString(admin)); + + vm.setEnv("UNISWAP_FULFILLMENT_VAULT_NAME", "Uniswap Fulfillment Vault"); + vm.setEnv("UNISWAP_FULFILLMENT_VAULT_SYMBOL", "UFLV"); + vm.setEnv("UNISWAP_FULFILLMENT_VAULT_DECIMALS", "24"); + vm.setEnv("UNISWAP_FULFILLMENT_VAULT_DECIMALS_OFFSET", "6"); + vm.setEnv("UNISWAP_FULFILLMENT_VAULT_ADMIN_ADDRESS", vm.toString(admin)); + vm.setEnv("USDG_ADDRESS", vm.toString(address(usdt))); + vm.setEnv("PERMIT2_ADDRESS", vm.toString(address(permit2))); + vm.setEnv("ALLOWED_ROUTER_COUNT", "2"); + vm.setEnv("ALLOWED_ROUTER_0", vm.toString(universalRouter)); + vm.setEnv("ALLOWED_ROUTER_0_APPROVAL", "PERMIT2"); + vm.setEnv("ALLOWED_ROUTER_1", vm.toString(swapRouter02)); + vm.setEnv("ALLOWED_ROUTER_1_APPROVAL", "ERC20"); + } + + function _coreFixturePath() internal view returns (string memory) { + return string.concat(vm.projectRoot(), "/addresses/tests/core-addresses-", vm.toString(TEST_CHAIN_ID), ".json"); + } + + function _peripheryFixturePath() internal view returns (string memory) { + return string.concat(vm.projectRoot(), "/addresses/tests/periphery-addresses-", vm.toString(TEST_CHAIN_ID), ".json"); + } + + /// @dev Only the keys the script reads. + function _writePeripheryFixture() internal { + string memory obj = "periphery"; + vm.serializeAddress(obj, "rolloverVaultAddress", address(deployedRolloverVault)); + string memory json = vm.serializeAddress(obj, "fulfillmentVaultAddress", address(vault)); + _write(_peripheryFixturePath(), json); + } + + /// @dev Only the keys the script reads. + function _writeCoreFixture() internal { + address[] memory collaterals = new address[](2); + collaterals[0] = address(whype); + collaterals[1] = address(ubtc); + address[] memory conversionQueues = new address[](2); + conversionQueues[0] = address(whypeConversionQueue); + conversionQueues[1] = address(ubtcConversionQueue); + + string memory obj = "core"; + vm.serializeAddress(obj, "usdxAddress", address(usdx)); + vm.serializeAddress(obj, "consolAddress", address(consol)); + vm.serializeAddress(obj, "orderPoolAddress", address(orderPool)); + vm.serializeAddress(obj, "generalManagerAddress", address(generalManager)); + vm.serializeAddress(obj, "forfeitedAssetsPoolAddress", address(forfeitedAssetsPool)); + vm.serializeAddress(obj, "usdxQueue", address(usdxQueue)); + vm.serializeAddress(obj, "forfeitedAssetsQueue", address(forfeitedAssetsQueue)); + vm.serializeAddress(obj, "originationPoolSchedulerAddress", address(originationPoolScheduler)); + vm.serializeAddress(obj, "collateralAddresses", collaterals); + string memory json = vm.serializeAddress(obj, "conversionQueues", conversionQueues); + + _write(_coreFixturePath(), json); + } + + function _write(string memory path, string memory json) internal { + if (vm.isFile(path)) { + vm.removeFile(path); + } + vm.writeFile(path, json); + } +}