diff --git a/README.md b/README.md index c6ff7847..0dcbc162 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,34 @@ There are currently five ARM contracts: See the [ARM Registry](https://docs.originprotocol.com/registry/contracts/arm-registry) for the deployed contracts. +## Asset Adapter Interface + +The generic ARM integrates protocol-specific asynchronous redemption flows through `IAssetAdapter`. An adapter converts between a base asset's shares and the ARM's liquidity asset, queues redemptions, and transfers claimed liquidity back to the ARM. + +The mint functions are part of the same generic interface. Redemption-only adapters revert with `MintNotSupported` when either mint function is called. + +The ARM's adapter request and claim operations are implemented in the externally linked `ARMAdapterLib`. The ARM retains its public interface and authorization checks while the linked library performs adapter calls and updates the ARM's explicitly passed storage references. + +```Solidity +interface IAssetAdapter { + function asset() external view returns (address); + function convertToAssets(uint256 shares) external view returns (uint256 assets); + function convertToShares(uint256 assets) external view returns (uint256 shares); + function requestRedeem(uint256 shares) + external + returns (uint256 sharesRequested, uint256 assetsExpected); + function redeem(uint256 shares) + external + returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived); + function requestMint(uint256 assets) + external + returns (uint256 assetsRequested, uint256 sharesExpected); + function claimMint(uint256 shares) + external + returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived); +} +``` + ## Swap Interface [Uniswap V2 Router](https://docs.uniswap.org/contracts/v2/reference/smart-contracts/router-02) compatible interface for swapping ERC20 tokens. diff --git a/script/deploy/helpers/ARMDeploymentHelper.sol b/script/deploy/helpers/ARMDeploymentHelper.sol new file mode 100644 index 00000000..45a68faa --- /dev/null +++ b/script/deploy/helpers/ARMDeploymentHelper.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import {Vm} from "forge-std/Vm.sol"; + +import {MultiAssetARM} from "contracts/MultiAssetARM.sol"; +import {ARMAdapterLib} from "contracts/libraries/ARMAdapterLib.sol"; + +/// @title ARM deployment helper +/// @notice Deploys ARMAdapterLib and MultiAssetARM implementations linked to a specific library instance. +/// @dev Keeps unlinked MultiAssetARM creation bytecode out of dynamically loaded deployment scripts. +library ARMDeploymentHelper { + /// @dev Solidity's link placeholder for contracts/libraries/ARMAdapterLib.sol:ARMAdapterLib. + string internal constant ARM_ADAPTER_LIB_PLACEHOLDER = "__$13fc881d3fc475fdd3ac87119115f1ebd2$__"; + + struct MultiAssetARMConfig { + /// @notice Asset used for LP deposits, redeem claims, and base-asset quote pricing. + address liquidityAsset; + /// @notice Delay before an LP redeem request can be claimed. For example, 600 = 10 minutes. + uint256 claimDelay; + /// @notice Minimum market shares redeemed during allocation. For example, 1e6 = 1 share for a 6-decimal market. + uint256 minSharesToRedeem; + /// @notice Minimum excess liquidity moved during allocation. For example, 100e6 = 100 units for a 6-decimal asset. + int256 allocateThreshold; + } + + function deployARMAdapterLib() internal returns (address adapterLib) { + bytes memory creationCode = type(ARMAdapterLib).creationCode; + assembly ("memory-safe") { + adapterLib := create(0, add(creationCode, 0x20), mload(creationCode)) + } + require(adapterLib != address(0), "ARMAdapterLib deployment failed"); + } + + function deployMultiAssetARM( + Vm vm, + string memory projectRoot, + address adapterLib, + MultiAssetARMConfig memory config + ) internal returns (MultiAssetARM armImpl) { + string memory artifactPath = string.concat(projectRoot, "/out/MultiAssetARM.sol/MultiAssetARM.json"); + string memory artifact = vm.readFile(artifactPath); + string memory creationCodeHex = vm.parseJsonString(artifact, ".bytecode.object"); + string memory linkedCreationCodeHex = + vm.replace(creationCodeHex, ARM_ADAPTER_LIB_PLACEHOLDER, _addressWithoutHexPrefix(vm, adapterLib)); + require( + keccak256(bytes(linkedCreationCodeHex)) != keccak256(bytes(creationCodeHex)), + "ARMAdapterLib placeholder not found" + ); + + bytes memory creationCode = abi.encodePacked( + vm.parseBytes(linkedCreationCodeHex), + abi.encode(config.liquidityAsset, config.claimDelay, config.minSharesToRedeem, config.allocateThreshold) + ); + + address implementation; + assembly ("memory-safe") { + implementation := create(0, add(creationCode, 0x20), mload(creationCode)) + } + require(implementation != address(0), "MultiAssetARM deployment failed"); + armImpl = MultiAssetARM(payable(implementation)); + } + + function _addressWithoutHexPrefix(Vm vm, address account) private pure returns (string memory) { + bytes memory prefixed = bytes(vm.toString(account)); + bytes memory unprefixed = new bytes(40); + for (uint256 i = 0; i < 40; ++i) { + unprefixed[i] = prefixed[i + 2]; + } + return string(unprefixed); + } +} diff --git a/script/deploy/mainnet/000_Example.s.sol b/script/deploy/mainnet/000_Example.s.sol index 6d16aece..01939bab 100644 --- a/script/deploy/mainnet/000_Example.s.sol +++ b/script/deploy/mainnet/000_Example.s.sol @@ -24,7 +24,6 @@ pragma solidity ^0.8.36; // Contracts to deploy/upgrade import {Proxy} from "contracts/Proxy.sol"; -import {LidoARM} from "contracts/LidoARM.sol"; import {CapManager} from "contracts/CapManager.sol"; import {ZapperLidoARM} from "contracts/ZapperLidoARM.sol"; @@ -63,7 +62,7 @@ contract $000_Example is AbstractDeployScript("000_Example") { // Declare variables here for contracts deployed in _execute() // that need to be referenced in _buildGovernanceProposal() or _fork() - LidoARM public newImplementation; + address public newImplementation; // ==================== Main Deployment Logic ==================== // @@ -92,8 +91,11 @@ contract $000_Example is AbstractDeployScript("000_Example") { // Deploy your contracts here. The deployer address is already set via // vm.broadcast (real) or vm.prank (fork). - // Example: Deploy a new implementation contract - newImplementation = new LidoARM(weth, 10 minutes, 0, 0); + // Example: Deploy a new implementation contract. Keep concrete deployment imports and code + // in the copied script so this permanently skipped template remains safe to load even when + // an implementation requires external library linking. + // MyImplementation implementation = new MyImplementation(constructorArgs); + // newImplementation = address(implementation); // Example: Deploy a proxy with implementation // Proxy proxy = new Proxy(); @@ -105,7 +107,7 @@ contract $000_Example is AbstractDeployScript("000_Example") { // - Available to subsequent scripts via resolver.resolve() // - Logged for visibility - _recordDeployment("LIDO_ARM_IMPL", address(newImplementation)); + _recordDeployment("LIDO_ARM_IMPL", newImplementation); // Note: You can register multiple contracts // _recordDeployment("MY_PROXY", address(proxy)); @@ -140,7 +142,7 @@ contract $000_Example is AbstractDeployScript("000_Example") { // Example 1: Upgrade a proxy to new implementation address lidoArmProxy = resolver.resolve("LIDO_ARM"); - govProposal.action(lidoArmProxy, "upgradeTo(address)", abi.encode(address(newImplementation))); + govProposal.action(lidoArmProxy, "upgradeTo(address)", abi.encode(newImplementation)); // Example 2: Set a configuration value // address capManager = resolver.resolve("CAP_MANAGER"); diff --git a/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol b/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol index 2cf993a9..73bbd4f3 100644 --- a/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol +++ b/script/deploy/mainnet/024_UpgradeOETHARMDepositScript.s.sol @@ -1,35 +1,49 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.36; -// Contract -import {OriginARM} from "contracts/OriginARM.sol"; -import {Mainnet} from "contracts/utils/Addresses.sol"; +// The original contract and governance imports are retained as comments with the implementation +// below. This script was superseded before execution and is permanently skipped. DeployManager must +// still deploy the script artifact to read skip(), so active OriginARM creation code would introduce +// an unresolved ARMAdapterLib link and prevent the deployment runner from loading this skip marker. +// import {OriginARM} from "contracts/OriginARM.sol"; +// import {Mainnet} from "contracts/utils/Addresses.sol"; +// import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol"; // Deployment import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; -import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol"; +/// @dev Legacy deployment retained as an inert skip marker. It was superseded before execution. contract $024_UpgradeOETHARMDepositScript is AbstractDeployScript("024_UpgradeOETHARMDepositScript") { - using GovHelper for GovProposal; - bool public constant override skip = true; - function _execute() internal override { - // 1. Deploy new OriginARM implementation - uint256 claimDelay = 10 minutes; - uint256 minSharesToRedeem = 1e7; - int256 allocateThreshold = 1e18; - OriginARM originARMImpl = new OriginARM( - Mainnet.OETH, Mainnet.WETH, Mainnet.OETH_VAULT, claimDelay, minSharesToRedeem, allocateThreshold - ); - _recordDeployment("OETH_ARM_IMPL", address(originARMImpl)); - } - - function _buildGovernanceProposal() internal override { - govProposal.setDescription("Upgrade OETH ARM to restrict deposits during insolvency"); - - govProposal.action( - resolver.resolve("OETH_ARM"), "upgradeTo(address)", abi.encode(resolver.resolve("OETH_ARM_IMPL")) - ); - } + // The original deployment logic is commented out for the same linking reason documented above. + // It remains here as historical context for why this deployment file exists. + // + // using GovHelper for GovProposal; + // + // function _execute() internal override { + // // 1. Deploy new OriginARM implementation + // uint256 claimDelay = 10 minutes; + // uint256 minSharesToRedeem = 1e7; + // int256 allocateThreshold = 1e18; + // OriginARM originARMImpl = new OriginARM( + // Mainnet.OETH, + // Mainnet.WETH, + // Mainnet.OETH_VAULT, + // claimDelay, + // minSharesToRedeem, + // allocateThreshold + // ); + // _recordDeployment("OETH_ARM_IMPL", address(originARMImpl)); + // } + // + // function _buildGovernanceProposal() internal override { + // govProposal.setDescription("Upgrade OETH ARM to restrict deposits during insolvency"); + // + // govProposal.action( + // resolver.resolve("OETH_ARM"), + // "upgradeTo(address)", + // abi.encode(resolver.resolve("OETH_ARM_IMPL")) + // ); + // } } diff --git a/script/deploy/mainnet/025_UpgradeLidoARMDepositScript.s.sol b/script/deploy/mainnet/025_UpgradeLidoARMDepositScript.s.sol index a2229f62..8d1110e9 100644 --- a/script/deploy/mainnet/025_UpgradeLidoARMDepositScript.s.sol +++ b/script/deploy/mainnet/025_UpgradeLidoARMDepositScript.s.sol @@ -1,33 +1,42 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.36; -// Contract -import {LidoARM} from "contracts/LidoARM.sol"; -import {Mainnet} from "contracts/utils/Addresses.sol"; +// The original contract and governance imports are retained as comments with the implementation +// below. This script was superseded before execution and is permanently skipped. DeployManager must +// still deploy the script artifact to read skip(), so active LidoARM creation code would introduce +// an unresolved ARMAdapterLib link and prevent the deployment runner from loading this skip marker. +// import {LidoARM} from "contracts/LidoARM.sol"; +// import {Mainnet} from "contracts/utils/Addresses.sol"; +// import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol"; // Deployment import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; -import {GovHelper, GovProposal} from "script/deploy/helpers/GovHelper.sol"; +/// @dev Legacy deployment retained as an inert skip marker. It was superseded before execution. contract $025_UpgradeLidoARMDepositScript is AbstractDeployScript("025_UpgradeLidoARMDepositScript") { - using GovHelper for GovProposal; - bool public constant override skip = true; - function _execute() internal override { - // 1. Deploy new LidoARM implementation - uint256 claimDelay = 10 minutes; - uint256 minSharesToRedeem = 1e7; - int256 allocateThreshold = 1e18; - LidoARM lidoARMImpl = new LidoARM(Mainnet.WETH, claimDelay, minSharesToRedeem, allocateThreshold); - _recordDeployment("LIDO_ARM_IMPL", address(lidoARMImpl)); - } - - function _buildGovernanceProposal() internal override { - govProposal.setDescription("Upgrade Lido ARM to restrict deposits during insolvency"); - - govProposal.action( - resolver.resolve("LIDO_ARM"), "upgradeTo(address)", abi.encode(resolver.resolve("LIDO_ARM_IMPL")) - ); - } + // The original deployment logic is commented out for the same linking reason documented above. + // It remains here as historical context for why this deployment file exists. + // + // using GovHelper for GovProposal; + // + // function _execute() internal override { + // // 1. Deploy new LidoARM implementation + // uint256 claimDelay = 10 minutes; + // uint256 minSharesToRedeem = 1e7; + // int256 allocateThreshold = 1e18; + // LidoARM lidoARMImpl = new LidoARM(Mainnet.WETH, claimDelay, minSharesToRedeem, allocateThreshold); + // _recordDeployment("LIDO_ARM_IMPL", address(lidoARMImpl)); + // } + // + // function _buildGovernanceProposal() internal override { + // govProposal.setDescription("Upgrade Lido ARM to restrict deposits during insolvency"); + // + // govProposal.action( + // resolver.resolve("LIDO_ARM"), + // "upgradeTo(address)", + // abi.encode(resolver.resolve("LIDO_ARM_IMPL")) + // ); + // } } diff --git a/script/deploy/mainnet/043_UpgradeARMsPauseRolesScript.s.sol b/script/deploy/mainnet/043_UpgradeARMsPauseRolesScript.s.sol new file mode 100644 index 00000000..7680ffc4 --- /dev/null +++ b/script/deploy/mainnet/043_UpgradeARMsPauseRolesScript.s.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {MultiAssetARM} from "contracts/MultiAssetARM.sol"; + +// Deployment +import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; +import {ARMDeploymentHelper} from "script/deploy/helpers/ARMDeploymentHelper.sol"; + +/// @title Split the ARM pause and unpause roles +/// @notice Upgrades the WETH and USDC ARMs to the AbstractARM implementation that separates pausing +/// from unpausing, then wires the two new roles: +/// - `guardian` = 2/8 multisig, which hosts the threat-detection module. Can pause only. +/// - `adminMultisig` = 5/8 multisig. Can pause and unpause. +/// `owner` keeps doing upgrades and stays a valid caller on both, so governance is always a +/// fallback but never on the fast path. The result is that no single 2/8 key can both +/// re-open a paused ARM and change its code. +/// @dev Scope. Only the ARMs that can safely receive a current-source implementation are included: +/// +/// - LIDO_ARM / ETHER_FI_ARM: their current source exceeds the EIP-170 runtime limit, so no new +/// implementation can be deployed for them at all. +/// - ETHENA_ARM: EXCLUDED because the deployed implementation's storage layout does not match +/// the current source. The deployed AbstractARM still carries the `_deprecatedTraderate0/1`, +/// `_deprecatedCrossPrice`, `_deprecatedWithdrawsQueued/Claimed` and +/// `_deprecatedLastAvailableAssets` placeholders, so on-chain `feeCollector` sits at slot 57, +/// `activeMarket` at 59 and `armBuffer` at 61. The current source places them at 59, 53 and 55. +/// Upgrading would therefore corrupt live state. This predates this change; script 034 only +/// avoids it because its idempotency check short-circuits before upgrading. +/// - OETH_ARM (legacy, no pause) and ETH_ARM (unused, holds only the dead-shares seed). +/// +/// Both ARMs in scope are owned by a multisig directly, so no governance proposal is needed. +contract $043_UpgradeARMsPauseRolesScript is AbstractDeployScript("043_UpgradeARMsPauseRolesScript") { + MultiAssetARM public wethARMImpl; + MultiAssetARM public usdcARMImpl; + + function _execute() internal override { + uint256 claimDelay = 10 minutes; + address adapterLib = ARMDeploymentHelper.deployARMAdapterLib(); + _recordDeployment("ARM_ADAPTER_LIB", adapterLib); + + // Constructor args are unchanged from the scripts that deployed the current implementations + // (038 for WETH, 039 for USDC) so the pause roles are the only behavioural change. + + wethARMImpl = ARMDeploymentHelper.deployMultiAssetARM( + vm, + projectRoot, + adapterLib, + ARMDeploymentHelper.MultiAssetARMConfig({ + liquidityAsset: Mainnet.WETH, claimDelay: claimDelay, minSharesToRedeem: 1e7, allocateThreshold: 1 ether + }) + ); + _recordDeployment("WETH_ARM_IMPL", address(wethARMImpl)); + + usdcARMImpl = ARMDeploymentHelper.deployMultiAssetARM( + vm, + projectRoot, + adapterLib, + ARMDeploymentHelper.MultiAssetARMConfig({ + liquidityAsset: Mainnet.USDC, claimDelay: claimDelay, minSharesToRedeem: 1e6, allocateThreshold: 100e6 + }) + ); + _recordDeployment("USDC_ARM_IMPL", address(usdcARMImpl)); + } + + /// @notice Both ARMs are owned by a multisig directly, so we simulate their upgrade with a prank. + /// On real deployment the multisig executes upgradeTo + setPauseRoles as a single batched + /// Safe transaction. Batching matters: between the two calls the role slots are still + /// address(0), so unpause would briefly narrow back to owner-only. + function _fork() internal override { + _upgradeAndSetRoles("WETH_ARM", "WETH_ARM_IMPL"); + _upgradeAndSetRoles("USDC_ARM", "USDC_ARM_IMPL"); + + // Behavioural assertions (2/8 can pause but not unpause, 5/8 can unpause) live in the smoke + // tests. They must not run here: _fork() executes inside the test's setUp(), so anything + // that mutates ARM state or arms a vm.expectRevert would leak into the test body. + _assertRolesSet("WETH_ARM"); + _assertRolesSet("USDC_ARM"); + } + + function _upgradeAndSetRoles(string memory proxyName, string memory implementationName) internal { + Proxy armProxy = Proxy(payable(resolver.resolve(proxyName))); + address armImpl = resolver.resolve(implementationName); + + // Idempotent: the deployment runner can replay pending multisig actions on forks. + if (armProxy.implementation() == armImpl) return; + + // Guard the storage assumption this upgrade depends on. The new `guardian` and + // `adminMultisig` occupy slots 62 and 63, taken from the AbstractARM gap. If the deployed + // layout ever diverges from the current source, those slots hold live data and this upgrade + // would corrupt it, so fail loudly instead. This is exactly the condition that rules + // ETHENA_ARM out of scope. + require(vm.load(address(armProxy), bytes32(uint256(62))) == 0, "slot 62 not free"); + require(vm.load(address(armProxy), bytes32(uint256(63))) == 0, "slot 63 not free"); + + // Prank the live owner rather than a hardcoded Safe. Ownership of these proxies has moved + // since they were deployed, so the deploy scripts are not the source of truth for it. + vm.startPrank(armProxy.owner()); + armProxy.upgradeTo(armImpl); + AbstractARM(payable(address(armProxy))).setPauseRoles(Mainnet.MULTISIG_2_OF_8, Mainnet.MULTISIG_5_OF_8); + vm.stopPrank(); + } + + /// @dev Confirm the roles landed. Read-only: this must not mutate ARM state, because the smoke + /// tests run against whatever state _fork() leaves behind. + function _assertRolesSet(string memory proxyName) internal view { + AbstractARM arm = AbstractARM(payable(resolver.resolve(proxyName))); + + require(arm.guardian() == Mainnet.MULTISIG_2_OF_8, "guardian not set"); + require(arm.adminMultisig() == Mainnet.MULTISIG_5_OF_8, "adminMultisig not set"); + } +} diff --git a/script/deploy/mainnet/044_UpgradeUSDCARMMintScript.s.sol b/script/deploy/mainnet/044_UpgradeUSDCARMMintScript.s.sol new file mode 100644 index 00000000..20d0d691 --- /dev/null +++ b/script/deploy/mainnet/044_UpgradeUSDCARMMintScript.s.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +// Contracts +import {Proxy} from "contracts/Proxy.sol"; +import {Mainnet} from "contracts/utils/Addresses.sol"; +import {MultiAssetARM} from "contracts/MultiAssetARM.sol"; +import {PaxosAssetAdapter} from "contracts/adapters/PaxosAssetAdapter.sol"; + +// Deployment +import {AbstractDeployScript} from "script/deploy/helpers/AbstractDeployScript.s.sol"; +import {ARMDeploymentHelper} from "script/deploy/helpers/ARMDeploymentHelper.sol"; + +/// @title Upgrade the USDC ARM and Paxos adapters to support base-asset minting +/// @notice Deploys a mint-capable MultiAssetARM implementation and new PYUSD and USDG Paxos adapter +/// implementations. The existing proxies are upgraded by their owner, the Ethereum 5/8 +/// multisig, and each adapter's mint recipient is initialized from its existing Paxos +/// redemption recipient. +/// @dev Deploys ARMAdapterLib first, then links the MultiAssetARM creation bytecode to that exact +/// address at runtime. This avoids leaving an unlinked library placeholder in the dynamically +/// loaded deployment script. This is a logic-only proxy upgrade: proxy storage is preserved and +/// no proxy reinitializer is required. Fork actions are idempotent so they can be replayed safely. +contract $044_UpgradeUSDCARMMintScript is AbstractDeployScript("044_UpgradeUSDCARMMintScript") { + function _execute() internal override { + address usdcARM = resolver.resolve("USDC_ARM"); + + address adapterLib = ARMDeploymentHelper.deployARMAdapterLib(); + _recordDeployment("ARM_ADAPTER_LIB", adapterLib); + + // Use the same immutable constructor parameters as 039_DeployUSDCARMScript. + MultiAssetARM armImpl = ARMDeploymentHelper.deployMultiAssetARM( + vm, + projectRoot, + adapterLib, + ARMDeploymentHelper.MultiAssetARMConfig({ + liquidityAsset: Mainnet.USDC, + claimDelay: 10 minutes, + minSharesToRedeem: 1e6, // 1e6 = 1 USDC of minimum market shares to redeem + allocateThreshold: 100e6 // 100e6 = 100 USDC + }) + ); + _recordDeployment("USDC_ARM_IMPL", address(armImpl)); + + PaxosAssetAdapter pyusdAdapterImpl = new PaxosAssetAdapter(usdcARM, Mainnet.PYUSD, Mainnet.USDC); + _recordDeployment("USDC_ARM_PYUSD_ADAPTER_IMPL", address(pyusdAdapterImpl)); + + PaxosAssetAdapter usdgAdapterImpl = new PaxosAssetAdapter(usdcARM, Mainnet.USDG, Mainnet.USDC); + _recordDeployment("USDC_ARM_USDG_ADAPTER_IMPL", address(usdgAdapterImpl)); + } + + function _fork() internal override { + // Upgrade the adapters first so mint requests are supported as soon as the ARM is upgraded. + _upgradeProxy("USDC_ARM_PYUSD_ADAPTER", "USDC_ARM_PYUSD_ADAPTER_IMPL"); + _upgradeProxy("USDC_ARM_USDG_ADAPTER", "USDC_ARM_USDG_ADAPTER_IMPL"); + + // Existing proxies predate paxosMintRecipient. Use their already configured Paxos recipient + // as the initial mint recipient, matching initialize() for newly deployed adapters. + _initializeMintRecipient("USDC_ARM_PYUSD_ADAPTER"); + _initializeMintRecipient("USDC_ARM_USDG_ADAPTER"); + + _upgradeProxy("USDC_ARM", "USDC_ARM_IMPL"); + } + + function _upgradeProxy(string memory proxyName, string memory implementationName) internal { + Proxy proxy = Proxy(payable(resolver.resolve(proxyName))); + address implementation = resolver.resolve(implementationName); + + if (proxy.implementation() == implementation) return; + + require(proxy.owner() == Mainnet.MULTISIG_5_OF_8, "Unexpected proxy owner"); + vm.prank(Mainnet.MULTISIG_5_OF_8); + proxy.upgradeTo(implementation); + } + + function _initializeMintRecipient(string memory adapterName) internal { + PaxosAssetAdapter adapter = PaxosAssetAdapter(resolver.resolve(adapterName)); + if (adapter.paxosMintRecipient() != address(0)) return; + + address recipient = adapter.paxosRecipient(); + require(recipient != address(0), "Paxos recipient not configured"); + + Proxy proxy = Proxy(payable(address(adapter))); + require(proxy.owner() == Mainnet.MULTISIG_5_OF_8, "Unexpected adapter owner"); + vm.prank(Mainnet.MULTISIG_5_OF_8); + adapter.setPaxosMintRecipient(recipient); + } +} diff --git a/src/abis/MultiAssetARM.json b/src/abis/MultiAssetARM.json index cdba63b9..7c572d66 100644 --- a/src/abis/MultiAssetARM.json +++ b/src/abis/MultiAssetARM.json @@ -99,6 +99,19 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "adminMultisig", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "allocate", @@ -295,6 +308,40 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "claimBaseAssetMint", + "inputs": [ + { + "name": "mintBaseAsset", + "type": "address", + "internalType": "address" + }, + { + "name": "shares", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "sharesClaimed", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "assetsExpected", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sharesReceived", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "claimBaseAssetRedeem", @@ -557,6 +604,19 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "guardian", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "initialize", @@ -706,6 +766,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "pendingMintShares", + "inputs": [ + { + "name": "asset", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "shares", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "previewDeposit", @@ -757,6 +836,35 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "requestBaseAssetMint", + "inputs": [ + { + "name": "mintBaseAsset", + "type": "address", + "internalType": "address" + }, + { + "name": "assets", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "assetsRequested", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "sharesExpected", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "requestBaseAssetRedeem", @@ -932,6 +1040,24 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "setPauseRoles", + "inputs": [ + { + "name": "_guardian", + "type": "address", + "internalType": "address" + }, + { + "name": "_adminMultisig", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setPrices", @@ -1368,6 +1494,19 @@ ], "anonymous": false }, + { + "type": "event", + "name": "AdminMultisigChanged", + "inputs": [ + { + "name": "newAdminMultisig", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "Allocated", @@ -1563,6 +1702,19 @@ ], "anonymous": false }, + { + "type": "event", + "name": "GuardianChanged", + "inputs": [ + { + "name": "newGuardian", + "type": "address", + "indexed": false, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "Initialized", @@ -2026,6 +2178,16 @@ "name": "OnlyOwner", "inputs": [] }, + { + "type": "error", + "name": "OnlyPauser", + "inputs": [] + }, + { + "type": "error", + "name": "OnlyUnpauser", + "inputs": [] + }, { "type": "error", "name": "QueuePendingLiquidity", diff --git a/src/contracts/AbstractARM.sol b/src/contracts/AbstractARM.sol index a0d3675e..1f6445fd 100644 --- a/src/contracts/AbstractARM.sol +++ b/src/contracts/AbstractARM.sol @@ -8,6 +8,31 @@ import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {OwnableOperable} from "./OwnableOperable.sol"; import {IAssetAdapter, IERC20, ICapManager} from "./Interfaces.sol"; +import {ARMAdapterLib} from "./libraries/ARMAdapterLib.sol"; + +/// @notice Per-base-asset swap, valuation, and adapter configuration. +/// @dev Packed into four storage slots. `adapter != address(0)` is the supported-asset flag. +struct BaseAssetConfig { + /// @notice Price the ARM pays in liquidity-asset terms when buying this base asset from traders. + uint128 buyPrice; + /// @notice Price the ARM charges in liquidity-asset terms when selling this base asset to traders. + uint128 sellPrice; + /// @notice Remaining liquidity asset the ARM can pay out at the current buy price. + uint128 buyLiquidityRemaining; + /// @notice Remaining base asset the ARM can sell at the current sell price. + uint128 sellLiquidityRemaining; + /// @notice Valuation price used by totalAssets(), scaled to 36 decimals. + uint128 crossPrice; + /// @notice Liquidity-denominated value expected from adapter redemption queues. + uint128 pendingRedeemAssets; + /// @notice If true, conversions bypass the adapter and use a 1:1 value (decimal-scaled) amount. + /// Packed with `baseAssetDecimals` and `adapter` in the same slot as all three are read on conversions. + bool peggedToLiquidityAsset; + /// @notice Decimals of this base asset. Must be 6 or 18. + uint8 baseAssetDecimals; + /// @notice Adapter that owns protocol-specific redemption logic for this base asset. + address adapter; +} /** * @title Generic Automated Redemption Manager (ARM) @@ -70,30 +95,6 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu uint128 queued; } - /// @notice Per-base-asset swap, valuation, and adapter configuration. - /// @dev Packed into four storage slots. `adapter != address(0)` is the supported-asset flag. - struct BaseAssetConfig { - /// @notice Price the ARM pays in liquidity-asset terms when buying this base asset from traders. - uint128 buyPrice; - /// @notice Price the ARM charges in liquidity-asset terms when selling this base asset to traders. - uint128 sellPrice; - /// @notice Remaining liquidity asset the ARM can pay out at the current buy price. - uint128 buyLiquidityRemaining; - /// @notice Remaining base asset the ARM can sell at the current sell price. - uint128 sellLiquidityRemaining; - /// @notice Valuation price used by totalAssets(), scaled to 36 decimals. - uint128 crossPrice; - /// @notice Liquidity-denominated value expected from adapter redemption queues. - uint128 pendingRedeemAssets; - /// @notice If true, conversions bypass the adapter and use a 1:1 value (decimal-scaled) amount. - /// Packed with `baseAssetDecimals` and `adapter` in the same slot as all three are read on conversions. - bool peggedToLiquidityAsset; - /// @notice Decimals of this base asset. Must be 6 or 18. - uint8 baseAssetDecimals; - /// @notice Adapter that owns protocol-specific redemption logic for this base asset. - address adapter; - } - //////////////////////////////////////////////////// /// Storage //////////////////////////////////////////////////// @@ -135,7 +136,19 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu /// @notice Maximum liquidity assets reserved for outstanding LP withdrawal requests. uint128 public reservedWithdrawLiquidity; - uint256[50] private _gap; + /// @notice Account that can pause but never unpause. Held by the 2/8 Guardian multisig, + /// which hosts the threat-detection module that trips the pause automatically. + address public guardian; + /// @notice Account that can pause and unpause. Held by the 5/8 Admin multisig. + /// @dev Named `adminMultisig` rather than `admin` on purpose. `Proxy` inherits `Ownable` and + /// declares its own `admin()` returning the proxy owner, so a variable named `admin` would + /// generate a getter the proxy permanently shadows and it could never be read through the proxy. + address public adminMultisig; + + /// @notice Base-asset shares expected from asynchronous liquidity-to-base mint queues. + mapping(address asset => uint256 shares) public pendingMintShares; + + uint256[47] private _gap; //////////////////////////////////////////////////// /// Errors @@ -162,6 +175,8 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu error MarketActive(); // 0xaeb31949 error InvalidARMBuffer(); // 0x06f77af9 error ContractPaused(); // 0xab35696f + error OnlyPauser(); // 0x75df51dc + error OnlyUnpauser(); // 0x794821ff error Insolvent(); // 0xfc220038 error ZeroShares(); // 0x9811e0c7 error ClaimDelayNotMet(); // 0x4a1eec28 @@ -214,6 +229,8 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu event Allocated(address indexed market, int256 targetLiquidityDelta, int256 actualLiquidityDelta); event Paused(address indexed account); event Unpaused(address indexed account); + event GuardianChanged(address newGuardian); + event AdminMultisigChanged(address newAdminMultisig); //////////////////////////////////////////////////// /// Modifiers @@ -224,6 +241,23 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu _; } + /// @dev Restricts to the owner, operator, guardian or adminMultisig. Pausing is the safe + /// direction, so the caller list is deliberately wide: any of them can trip the circuit breaker. + modifier onlyPauser() { + if (msg.sender != _owner() && msg.sender != operator && msg.sender != guardian && msg.sender != adminMultisig) { + revert OnlyPauser(); + } + _; + } + + /// @dev Restricts to the owner or adminMultisig. The operator and guardian can pause but must + /// never unpause, so that no single hot key or 2/8 key can both re-open a paused ARM and, where + /// it is also the owner, change its code. + modifier onlyUnpauser() { + if (msg.sender != _owner() && msg.sender != adminMultisig) revert OnlyUnpauser(); + _; + } + //////////////////////////////////////////////////// /// Constructor //////////////////////////////////////////////////// @@ -621,32 +655,19 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu uint256 newCrossPrice, bool peggedToLiquidityAsset ) external onlyOwner { - if (newBaseAsset == address(0)) revert InvalidAsset(); - if (adapter == address(0)) revert InvalidAdapter(); - if (baseAssetConfigs[newBaseAsset].adapter != address(0)) revert AssetAlreadySupported(); - uint8 baseDecimals = IERC20(newBaseAsset).decimals(); - if (baseDecimals != 6 && baseDecimals != 18) revert InvalidAssetDecimals(); - if (IAssetAdapter(adapter).asset() != liquidityAsset) revert InvalidAdapterAsset(); - if (newCrossPrice < PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION) revert CrossPriceTooLow(); - if (newCrossPrice > PRICE_SCALE) revert CrossPriceTooHigh(); - _validatePrices(buyPrice, sellPrice, newCrossPrice); - - baseAssets.push(newBaseAsset); - // Allow the adapter to pull base assets when requesting protocol redemptions. - IERC20(newBaseAsset).approve(adapter, type(uint256).max); - baseAssetConfigs[newBaseAsset] = BaseAssetConfig({ - buyPrice: SafeCast.toUint128(buyPrice), - sellPrice: SafeCast.toUint128(sellPrice), - buyLiquidityRemaining: SafeCast.toUint128(buyAmount), - sellLiquidityRemaining: SafeCast.toUint128(sellAmount), - crossPrice: SafeCast.toUint128(newCrossPrice), - pendingRedeemAssets: 0, - peggedToLiquidityAsset: peggedToLiquidityAsset, - baseAssetDecimals: baseDecimals, - adapter: adapter - }); - - emit BaseAssetAdded(newBaseAsset, adapter, buyPrice, sellPrice, newCrossPrice, peggedToLiquidityAsset); + ARMAdapterLib.addBaseAsset( + baseAssets, + baseAssetConfigs, + liquidityAsset, + newBaseAsset, + adapter, + buyPrice, + sellPrice, + buyAmount, + sellAmount, + newCrossPrice, + peggedToLiquidityAsset + ); } /// @notice Set buy/sell prices and per-price liquidity limits for a supported base asset. @@ -693,13 +714,14 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu BaseAssetConfig storage config = baseAssetConfigs[priceBaseAsset]; if (config.adapter == address(0)) revert UnsupportedAsset(); if (newCrossPrice < PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION) revert CrossPriceTooLow(); - if (newCrossPrice > PRICE_SCALE) revert CrossPriceTooHigh(); + if (newCrossPrice > PRICE_SCALE + MAX_CROSS_PRICE_DEVIATION) revert CrossPriceTooHigh(); if (config.sellPrice < newCrossPrice) revert SellPriceTooLow(); if (config.buyPrice >= newCrossPrice) revert InvalidBuyPrice(); if (newCrossPrice < config.crossPrice) { - uint256 baseAssetExposure = - _convertToAssets(config, IERC20(priceBaseAsset).balanceOf(address(this))) + config.pendingRedeemAssets; + uint256 baseAssetExposure = _convertToAssets( + config, IERC20(priceBaseAsset).balanceOf(address(this)) + pendingMintShares[priceBaseAsset] + ) + config.pendingRedeemAssets; if (baseAssetExposure >= MIN_LIQUIDITY) revert TooManyBaseAssets(); } @@ -722,12 +744,7 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu onlyOperatorOrOwner returns (uint256 sharesRequested, uint256 assetsExpected) { - BaseAssetConfig storage config = baseAssetConfigs[redeemBaseAsset]; - if (config.adapter == address(0)) revert UnsupportedAsset(); - - (sharesRequested, assetsExpected) = IAssetAdapter(config.adapter).requestRedeem(shares); - // Track the liquidity-denominated value expected back from the adapter queue. - config.pendingRedeemAssets = SafeCast.toUint128(uint256(config.pendingRedeemAssets) + assetsExpected); + return ARMAdapterLib.requestRedeem(baseAssetConfigs, redeemBaseAsset, shares); } /// @notice Claim protocol redemptions through a base asset adapter. @@ -743,12 +760,49 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu onlyOperatorOrOwner returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived) { - BaseAssetConfig storage config = baseAssetConfigs[redeemBaseAsset]; - if (config.adapter == address(0)) revert UnsupportedAsset(); + return ARMAdapterLib.claimRedeem(baseAssetConfigs, redeemBaseAsset, shares); + } + + //////////////////////////////////////////////////// + /// Adapter Mints + //////////////////////////////////////////////////// + + /// @notice Commit liquidity assets to an asynchronous base-asset mint through an adapter. + /// @dev Keeps liquidity reserved for outstanding LP withdrawals in the ARM and records expected base shares so + /// totalAssets() continues to value the in-flight mint at the configured cross price. + /// @param mintBaseAsset Base asset expected from the adapter. + /// @param assets Liquidity assets to commit, in native liquidity-asset decimals. + /// eg 100e6 commits 100 USDC when the liquidity asset has 6 decimals. + /// @return assetsRequested Liquidity assets accepted by the adapter. + /// @return sharesExpected Base-asset shares expected from settlement. + function requestBaseAssetMint(address mintBaseAsset, uint256 assets) + external + onlyOperatorOrOwner + returns (uint256 assetsRequested, uint256 sharesExpected) + { + return ARMAdapterLib.requestMint( + baseAssetConfigs, + pendingMintShares, + liquidityAsset, + activeMarket, + reservedWithdrawLiquidity, + mintBaseAsset, + assets + ); + } - (sharesClaimed, assetsExpected, assetsReceived) = IAssetAdapter(config.adapter).redeem(shares); - // Remove expected queue value. Any received shortfall remains reflected in totalAssets(). - config.pendingRedeemAssets = SafeCast.toUint128(uint256(config.pendingRedeemAssets) - assetsExpected); + /// @notice Claim asynchronously minted base shares from an adapter into the ARM. + /// @param mintBaseAsset Base asset being claimed. + /// @param shares Base-asset shares represented by pending mint requests. + /// @return sharesClaimed Base shares removed from the adapter queue. + /// @return assetsExpected Liquidity assets committed for the claimed shares. + /// @return sharesReceived Base shares transferred into the ARM. + function claimBaseAssetMint(address mintBaseAsset, uint256 shares) + external + onlyOperatorOrOwner + returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived) + { + return ARMAdapterLib.claimMint(baseAssetConfigs, pendingMintShares, mintBaseAsset, shares); } //////////////////////////////////////////////////// @@ -1005,17 +1059,17 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu for (uint256 i = 0; i < length; ++i) { address supportedBaseAsset = baseAssets[i]; BaseAssetConfig memory config = baseAssetConfigs[supportedBaseAsset]; - // Base assets in the ARM are converted to liquidity assets and then the cross price is applied. - // The cross price is the discounted price for the redemption time delay. This ensures the ARM's - // assets per share does not decrease if the ARM sells base assets at a discount, because the base - // sell price is greater than or equal to the cross price. - uint256 baseConvertedToLiquid = - _convertToAssets(config, IERC20(supportedBaseAsset).balanceOf(address(this))); - availableAssets += baseConvertedToLiquid * config.crossPrice / PRICE_SCALE; + // Convert settled and pending-mint base shares to liquidity assets together, then value them at + // the cross price. This ensures assets per share does not decrease if the ARM sells base assets + // at a discount, because the base sell price is greater than or equal to the cross price. + uint256 baseConvertedToLiquid = _convertToAssets( + config, IERC20(supportedBaseAsset).balanceOf(address(this)) + pendingMintShares[supportedBaseAsset] + ); // Pending adapter redemptions are already tracked in liquidity terms and represent assets // expected back from protocol withdrawal queues. Value them at the live cross price so moving // base assets into a withdrawal queue does not create an immediate assets-per-share increase. - availableAssets += uint256(config.pendingRedeemAssets) * config.crossPrice / PRICE_SCALE; + availableAssets += (baseConvertedToLiquid + uint256(config.pendingRedeemAssets)) * config.crossPrice + / PRICE_SCALE; } address activeMarketMem = activeMarket; @@ -1223,18 +1277,34 @@ abstract contract AbstractARM is OwnableOperable, ERC20Upgradeable, ReentrancyGu /// Admin Functions //////////////////////////////////////////////////// - /// @notice Pause user-facing ARM actions. - function pause() external onlyOperatorOrOwner { + /// @notice Pause user-facing ARM actions. Callable by the owner, operator, guardian or + /// adminMultisig. + function pause() external onlyPauser { paused = true; emit Paused(msg.sender); } - /// @notice Unpause user-facing ARM actions. - function unpause() external onlyOwner { + /// @notice Unpause user-facing ARM actions. Callable by the owner or adminMultisig only. + function unpause() external onlyUnpauser { paused = false; emit Unpaused(msg.sender); } + /// @notice Set the accounts that can pause and unpause. + /// @dev Both roles are set in one call so that an upgrade and its role configuration fit in a + /// single governance action. Setting them separately would leave a window where the slots are + /// still address(0) and unpause has silently narrowed to owner-only. + /// @param _guardian The 2/8 Guardian multisig, which can pause but not unpause. + /// address(0) disables the role. + /// @param _adminMultisig The 5/8 Admin multisig, which can pause and unpause. + /// address(0) disables the role. + function setPauseRoles(address _guardian, address _adminMultisig) external onlyOwner { + guardian = _guardian; + adminMultisig = _adminMultisig; + emit GuardianChanged(_guardian); + emit AdminMultisigChanged(_adminMultisig); + } + /// @notice Set the CapManager contract. /// @param _capManager CapManager contract address, or address(0) to disable caps. function setCapManager(address _capManager) external onlyOwner { diff --git a/src/contracts/Interfaces.sol b/src/contracts/Interfaces.sol index db4ad0b7..6b4f6b26 100644 --- a/src/contracts/Interfaces.sol +++ b/src/contracts/Interfaces.sol @@ -37,6 +37,8 @@ interface ICapManager { /// @notice Adapter interface for assets that require protocol-specific redemption flows. /// @dev ARM calls adapters when a base asset cannot be redeemed synchronously into the ARM liquidity asset. interface IAssetAdapter { + error MintNotSupported(); // 0x6b24007b + /// @notice Returns the liquidity asset received by the ARM after adapter redemptions. function asset() external view returns (address); @@ -67,6 +69,21 @@ interface IAssetAdapter { function redeem(uint256 shares) external returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived); + + /// @notice Pulls liquidity assets from the ARM and queues them for an asynchronous base-asset mint. + /// @param assets Liquidity assets to commit to the mint. + /// @return assetsRequested Liquidity assets accepted into the mint queue. + /// @return sharesExpected Base-asset shares expected from settlement. + function requestMint(uint256 assets) external returns (uint256 assetsRequested, uint256 sharesExpected); + + /// @notice Claims base shares from a previously requested mint and transfers them to the ARM. + /// @param shares Base-asset shares represented by pending mint requests. + /// @return sharesClaimed Base-asset shares removed from the pending mint queue. + /// @return assetsExpected Liquidity assets committed for those shares. + /// @return sharesReceived Base-asset shares transferred to the ARM. + function claimMint(uint256 shares) + external + returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived); } interface LegacyAMM { diff --git a/src/contracts/adapters/AbstractLidoAssetAdapter.sol b/src/contracts/adapters/AbstractLidoAssetAdapter.sol index e8e45ca7..06356939 100644 --- a/src/contracts/adapters/AbstractLidoAssetAdapter.sol +++ b/src/contracts/adapters/AbstractLidoAssetAdapter.sol @@ -243,5 +243,13 @@ abstract contract AbstractLidoAssetAdapter is Initializable, IAssetAdapter { /// @return sharesOut Concrete adapter share amount. function _assetsToShares(uint256 assets) internal view virtual returns (uint256 sharesOut); + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } + receive() external payable {} } diff --git a/src/contracts/adapters/EthenaAssetAdapter.sol b/src/contracts/adapters/EthenaAssetAdapter.sol index 57e0773e..74bec982 100644 --- a/src/contracts/adapters/EthenaAssetAdapter.sol +++ b/src/contracts/adapters/EthenaAssetAdapter.sol @@ -180,4 +180,12 @@ contract EthenaAssetAdapter is IAssetAdapter, Ownable { function unstakerIndexAt(uint256 requestIndex) public pure returns (uint8) { return uint8(requestIndex % MAX_UNSTAKERS); } + + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } } diff --git a/src/contracts/adapters/EtherFiAssetAdapter.sol b/src/contracts/adapters/EtherFiAssetAdapter.sol index 7688ff69..b2a80739 100644 --- a/src/contracts/adapters/EtherFiAssetAdapter.sol +++ b/src/contracts/adapters/EtherFiAssetAdapter.sol @@ -179,4 +179,12 @@ contract EtherFiAssetAdapter is Initializable, IAssetAdapter, IERC721Receiver { function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } + + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } } diff --git a/src/contracts/adapters/OriginAssetAdapter.sol b/src/contracts/adapters/OriginAssetAdapter.sol index 2f3eeacd..1b3f306f 100644 --- a/src/contracts/adapters/OriginAssetAdapter.sol +++ b/src/contracts/adapters/OriginAssetAdapter.sol @@ -143,4 +143,12 @@ contract OriginAssetAdapter is Initializable, IAssetAdapter { function pendingRequestId(uint256 index) external view returns (uint256) { return pendingRequestIds[index]; } + + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } } diff --git a/src/contracts/adapters/PaxosAssetAdapter.sol b/src/contracts/adapters/PaxosAssetAdapter.sol index 4622724a..e686d6c1 100644 --- a/src/contracts/adapters/PaxosAssetAdapter.sol +++ b/src/contracts/adapters/PaxosAssetAdapter.sol @@ -26,13 +26,23 @@ contract PaxosAssetAdapter is Initializable, IAssetAdapter, OwnableOperable { /// @notice Base asset amount sent to Paxos and awaiting on-chain USDC settlement. uint256 public settlingShares; + /// @notice On-chain Paxos USDC deposit address used by Actions to initiate base-asset mints. + address public paxosMintRecipient; + /// @notice USDC pulled from the ARM but not yet sent to Paxos for minting. + uint256 public pendingMintAssets; + /// @notice USDC sent to Paxos and awaiting base-asset mint settlement. + uint256 public settlingMintAssets; + error InvalidPaxosRecipient(); // 0xfd956f0b error PaxosRecipientNotConfigured(); // 0x11f03d8a error RedeemAmountTooHigh(); // 0xc4526429 error InsufficientSettledAssets(uint256 required, uint256 available); // 0x34b0f470 error OnlyARM(); // 0x1628bf2a error ZeroShares(); // 0x9811e0c7 + error ZeroAssets(); // 0x32d971dc error DecimalsMismatch(); // 0x5a8dbaed + error MintAmountTooHigh(); // 0xc2f508f9 + error InsufficientMintedShares(uint256 required, uint256 available); // 0x39dddda7 event PaxosRecipientUpdated(address indexed paxosRecipient); /// @notice Emitted when base assets are queued for redemption, where `100e6` is 100 tokens for 6-decimal assets. @@ -41,6 +51,13 @@ contract PaxosAssetAdapter is Initializable, IAssetAdapter, OwnableOperable { /// @notice Emitted when settled liquidity assets are transferred to the ARM, where `100e6` is 100 USDC. event PaxosRedeemClaimed(uint256 shares, uint256 assetsExpected, uint256 assetsReceived); event ExcessLiquidityRecovered(address indexed to, uint256 amount); + event PaxosMintRecipientUpdated(address indexed paxosMintRecipient); + /// @notice Emitted when USDC is queued for minting, where `100e6` is 100 USDC. + event PaxosMintRequested(uint256 assets, uint256 sharesExpected); + event PaxosMintSubmitted(bytes32 indexed paxosMintId, uint256 assets, address indexed paxosMintRecipient); + /// @notice Emitted when minted base shares are transferred to the ARM. + event PaxosMintClaimed(uint256 shares, uint256 assetsExpected, uint256 sharesReceived); + event ExcessBaseAssetRecovered(address indexed to, uint256 amount); modifier onlyARM() { if (msg.sender != arm) revert OnlyARM(); @@ -72,6 +89,7 @@ contract PaxosAssetAdapter is Initializable, IAssetAdapter, OwnableOperable { function initialize(address _operator, address _paxosRecipient) external initializer { _initOwnableOperable(_operator); _setPaxosRecipient(_paxosRecipient); + _setPaxosMintRecipient(_paxosRecipient); } /// @notice Set the Paxos on-chain deposit address used for future submissions. @@ -80,6 +98,11 @@ contract PaxosAssetAdapter is Initializable, IAssetAdapter, OwnableOperable { _setPaxosRecipient(_paxosRecipient); } + /// @notice Set the Paxos USDC deposit address used for future base-asset mints. + function setPaxosMintRecipient(address _paxosMintRecipient) external onlyOwner { + _setPaxosMintRecipient(_paxosMintRecipient); + } + /// @notice Submit queued base assets to Paxos for API-orchestrated redemption. /// @dev Paxos Actions should use `paxosRedemptionId` to correlate this transfer with off-chain orchestration. /// @param shares Base asset amount to send. For example, `100e6` is 100 USDG/PYUSD. @@ -154,7 +177,9 @@ contract PaxosAssetAdapter is Initializable, IAssetAdapter, OwnableOperable { uint256 settlingSharesMem = settlingShares; if (shares > settlingSharesMem) revert RedeemAmountTooHigh(); - uint256 available = liquidityAsset.balanceOf(address(this)); + uint256 liquidityBalance = liquidityAsset.balanceOf(address(this)); + uint256 pendingMintAssetsMem = pendingMintAssets; + uint256 available = liquidityBalance > pendingMintAssetsMem ? liquidityBalance - pendingMintAssetsMem : 0; if (available < shares) revert InsufficientSettledAssets(shares, available); settlingShares = settlingSharesMem - shares; @@ -167,23 +192,100 @@ contract PaxosAssetAdapter is Initializable, IAssetAdapter, OwnableOperable { emit PaxosRedeemClaimed(sharesClaimed, assetsExpected, assetsReceived); } + /// @notice Pull USDC from the ARM and queue it for a Paxos base-asset mint. + /// @param assets USDC amount to queue. For example, `100e6` is 100 USDC. + function requestMint(uint256 assets) external onlyARM returns (uint256 assetsRequested, uint256 sharesExpected) { + if (assets == 0) revert ZeroAssets(); + + pendingMintAssets += assets; + liquidityAsset.transferFrom(arm, address(this), assets); + + assetsRequested = assets; + sharesExpected = assets; + + emit PaxosMintRequested(assetsRequested, sharesExpected); + } + + /// @notice Submit queued USDC to Paxos for API-orchestrated base-asset minting. + /// @param assets USDC amount to send. For example, `100e6` is 100 USDC. + /// @param paxosMintId Off-chain Paxos orchestration or idempotency identifier. + function submitPaxosMint(uint256 assets, bytes32 paxosMintId) external onlyOperatorOrOwner { + if (assets == 0) revert ZeroAssets(); + + uint256 pendingMintAssetsMem = pendingMintAssets; + if (assets > pendingMintAssetsMem) revert MintAmountTooHigh(); + + address paxosMintRecipientMem = paxosMintRecipient; + if (paxosMintRecipientMem == address(0)) revert PaxosRecipientNotConfigured(); + + pendingMintAssets = pendingMintAssetsMem - assets; + settlingMintAssets += assets; + liquidityAsset.transfer(paxosMintRecipientMem, assets); + + emit PaxosMintSubmitted(paxosMintId, assets, paxosMintRecipientMem); + } + + /// @notice Claim base assets minted by Paxos and transfer them into the ARM's sell inventory. + function claimMint(uint256 shares) + external + onlyARM + nonZeroShares(shares) + returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived) + { + uint256 settlingMintAssetsMem = settlingMintAssets; + if (shares > settlingMintAssetsMem) revert MintAmountTooHigh(); + + uint256 baseBalance = baseAsset.balanceOf(address(this)); + uint256 pendingRedeemShares = pendingShares; + uint256 available = baseBalance > pendingRedeemShares ? baseBalance - pendingRedeemShares : 0; + if (available < shares) revert InsufficientMintedShares(shares, available); + + settlingMintAssets = settlingMintAssetsMem - shares; + baseAsset.transfer(arm, shares); + + sharesClaimed = shares; + assetsExpected = shares; + sharesReceived = shares; + + emit PaxosMintClaimed(sharesClaimed, assetsExpected, sharesReceived); + } + /// @notice Recovers liquidity asset held beyond what `settlingShares` still owes, e.g. donated tokens /// or a Paxos settlement that arrived after its `settlingShares` was already closed out. /// @dev The recovered liquidity asset is always sent to the ARM. function recoverExcessLiquidity() external onlyOwner { uint256 balance = liquidityAsset.balanceOf(address(this)); - uint256 settlingSharesMem = settlingShares; - uint256 excess = balance > settlingSharesMem ? balance - settlingSharesMem : 0; + uint256 reserved = settlingShares + pendingMintAssets; + uint256 excess = balance > reserved ? balance - reserved : 0; liquidityAsset.transfer(arm, excess); emit ExcessLiquidityRecovered(arm, excess); } + /// @notice Recover base assets held beyond queued redemptions and unsettled mint obligations. + /// @dev The recovered base asset is always sent to the ARM. + function recoverExcessBaseAsset() external onlyOwner { + uint256 balance = baseAsset.balanceOf(address(this)); + uint256 reserved = pendingShares + settlingMintAssets; + uint256 excess = balance > reserved ? balance - reserved : 0; + + baseAsset.transfer(arm, excess); + + emit ExcessBaseAssetRecovered(arm, excess); + } + function _setPaxosRecipient(address _paxosRecipient) internal { if (_paxosRecipient == address(0)) revert InvalidPaxosRecipient(); paxosRecipient = _paxosRecipient; emit PaxosRecipientUpdated(_paxosRecipient); } + + function _setPaxosMintRecipient(address _paxosMintRecipient) internal { + if (_paxosMintRecipient == address(0)) revert InvalidPaxosRecipient(); + paxosMintRecipient = _paxosMintRecipient; + + emit PaxosMintRecipientUpdated(_paxosMintRecipient); + } } diff --git a/src/contracts/adapters/WeETHAssetAdapter.sol b/src/contracts/adapters/WeETHAssetAdapter.sol index 6a1da6e2..cae0c07c 100644 --- a/src/contracts/adapters/WeETHAssetAdapter.sol +++ b/src/contracts/adapters/WeETHAssetAdapter.sol @@ -189,4 +189,12 @@ contract WeETHAssetAdapter is Initializable, IAssetAdapter, IERC721Receiver { function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } + + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } } diff --git a/src/contracts/adapters/WrappedOriginAssetAdapter.sol b/src/contracts/adapters/WrappedOriginAssetAdapter.sol index 28047aaf..01e2960f 100644 --- a/src/contracts/adapters/WrappedOriginAssetAdapter.sol +++ b/src/contracts/adapters/WrappedOriginAssetAdapter.sol @@ -153,4 +153,12 @@ contract WrappedOriginAssetAdapter is Initializable, IAssetAdapter { function pendingRequestId(uint256 index) external view returns (uint256) { return pendingRequestIds[index]; } + + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } } diff --git a/src/contracts/libraries/ARMAdapterLib.sol b/src/contracts/libraries/ARMAdapterLib.sol new file mode 100644 index 00000000..f4288072 --- /dev/null +++ b/src/contracts/libraries/ARMAdapterLib.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.36; + +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import {BaseAssetConfig} from "../AbstractARM.sol"; +import {IAssetAdapter, IERC20} from "../Interfaces.sol"; + +/// @title ARM adapter operations +/// @notice Linked library for protocol adapter redemption and mint lifecycle operations. +/// @dev External calls execute by delegatecall and mutate only the explicitly passed ARM storage references. +/// @author Origin Protocol Inc +library ARMAdapterLib { + uint256 private constant MAX_CROSS_PRICE_DEVIATION = 20e32; + uint256 private constant PRICE_SCALE = 1e36; + + error UnsupportedAsset(); // 0x24a01144 + error InsufficientLiquidity(); // 0xbb55fd27 + error InvalidAsset(); // 0xc891add2 + error InvalidAdapter(); // 0xfbf66df1 + error AssetAlreadySupported(); // 0xb1093e5b + error InvalidAssetDecimals(); // 0xe2364765 + error InvalidAdapterAsset(); // 0x030f0830 + error InvalidBuyPrice(); // 0x36c64b27 + error SellPriceTooLow(); // 0x2394065c + error CrossPriceTooLow(); // 0xea59e662 + error CrossPriceTooHigh(); // 0x682101d7 + + event BaseAssetAdded( + address indexed asset, + address indexed adapter, + uint256 buyPrice, + uint256 sellPrice, + uint256 crossPrice, + bool peggedToLiquidityAsset + ); + + /// @notice Register a base asset and its adapter, prices, and available swap liquidity. + /// @dev Approves the adapter to transfer the base asset and appends the asset to `baseAssets`. + /// @param baseAssets ARM storage array of registered base-asset addresses. + /// @param configs ARM storage mapping from base assets to their configuration. + /// @param liquidityAsset Asset used for LP deposits and base-asset quote pricing. + /// @param newBaseAsset Base asset to register. Its token decimals must be either 6 or 18. + /// @param adapter Adapter that converts and handles protocol minting or redemption for the base asset. + /// @param buyPrice Price paid by the ARM when buying the base asset, scaled to 36 decimals. + /// For example, 0.998e36 is 0.998 liquidity asset per base asset. + /// @param sellPrice Price charged by the ARM when selling the base asset, scaled to 36 decimals. + /// For example, 1.001e36 is 1.001 liquidity asset per base asset. + /// @param buyAmount Liquidity asset available at `buyPrice`, in native liquidity-asset decimals. + /// For example, 100e6 is 100 USDC when the liquidity asset has 6 decimals. + /// @param sellAmount Base asset available at `sellPrice`, in native base-asset decimals. + /// For example, 100e18 is 100 base assets when the base asset has 18 decimals. + /// @param newCrossPrice Valuation price used by totalAssets(), scaled to 36 decimals. + /// For example, 1e36 values one base asset at one liquidity asset. + /// @param peggedToLiquidityAsset Whether conversions bypass the adapter and use decimal-scaled 1:1 amounts. + function addBaseAsset( + address[] storage baseAssets, + mapping(address asset => BaseAssetConfig) storage configs, + address liquidityAsset, + address newBaseAsset, + address adapter, + uint256 buyPrice, + uint256 sellPrice, + uint256 buyAmount, + uint256 sellAmount, + uint256 newCrossPrice, + bool peggedToLiquidityAsset + ) external { + if (newBaseAsset == address(0)) revert InvalidAsset(); + if (adapter == address(0)) revert InvalidAdapter(); + if (configs[newBaseAsset].adapter != address(0)) revert AssetAlreadySupported(); + + uint8 baseDecimals = IERC20(newBaseAsset).decimals(); + if (baseDecimals != 6 && baseDecimals != 18) revert InvalidAssetDecimals(); + if (IAssetAdapter(adapter).asset() != liquidityAsset) revert InvalidAdapterAsset(); + if (newCrossPrice < PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION) revert CrossPriceTooLow(); + if (newCrossPrice > PRICE_SCALE + MAX_CROSS_PRICE_DEVIATION) revert CrossPriceTooHigh(); + if (sellPrice < newCrossPrice) revert SellPriceTooLow(); + if (buyPrice < MAX_CROSS_PRICE_DEVIATION || buyPrice >= newCrossPrice) revert InvalidBuyPrice(); + + baseAssets.push(newBaseAsset); + IERC20(newBaseAsset).approve(adapter, type(uint256).max); + configs[newBaseAsset] = BaseAssetConfig({ + buyPrice: SafeCast.toUint128(buyPrice), + sellPrice: SafeCast.toUint128(sellPrice), + buyLiquidityRemaining: SafeCast.toUint128(buyAmount), + sellLiquidityRemaining: SafeCast.toUint128(sellAmount), + crossPrice: SafeCast.toUint128(newCrossPrice), + pendingRedeemAssets: 0, + peggedToLiquidityAsset: peggedToLiquidityAsset, + baseAssetDecimals: baseDecimals, + adapter: adapter + }); + + emit BaseAssetAdded(newBaseAsset, adapter, buyPrice, sellPrice, newCrossPrice, peggedToLiquidityAsset); + } + + /// @notice Request protocol redemption of base-asset shares through their configured adapter. + /// @dev Adds the liquidity-denominated amount expected from the adapter to `pendingRedeemAssets`. + /// @param configs ARM storage mapping from base assets to their configuration. + /// @param redeemBaseAsset Base asset whose shares are submitted for redemption. + /// @param shares Base-asset shares to redeem, in native base-asset decimals. + /// For example, 100e18 is 100 shares when the base asset has 18 decimals. + /// @return sharesRequested Base-asset shares accepted by the adapter. + /// @return assetsExpected Liquidity assets expected from settlement, in native liquidity-asset decimals. + function requestRedeem( + mapping(address asset => BaseAssetConfig) storage configs, + address redeemBaseAsset, + uint256 shares + ) external returns (uint256 sharesRequested, uint256 assetsExpected) { + BaseAssetConfig storage config = configs[redeemBaseAsset]; + if (config.adapter == address(0)) revert UnsupportedAsset(); + + (sharesRequested, assetsExpected) = IAssetAdapter(config.adapter).requestRedeem(shares); + config.pendingRedeemAssets = SafeCast.toUint128(uint256(config.pendingRedeemAssets) + assetsExpected); + } + + /// @notice Claim completed base-asset redemptions through their configured adapter. + /// @dev Removes the adapter's expected liquidity amount from `pendingRedeemAssets`; any settlement shortfall + /// is reflected in totalAssets() after the expected amount is removed. + /// @param configs ARM storage mapping from base assets to their configuration. + /// @param redeemBaseAsset Base asset whose completed redemptions are claimed. + /// @param shares Base-asset shares to claim, in native base-asset decimals. + /// For example, 100e18 is 100 shares when the base asset has 18 decimals. + /// @return sharesClaimed Base-asset shares removed from the adapter's redemption queue. + /// @return assetsExpected Liquidity assets expected for the claimed shares. + /// @return assetsReceived Liquidity assets actually transferred to the ARM. + function claimRedeem( + mapping(address asset => BaseAssetConfig) storage configs, + address redeemBaseAsset, + uint256 shares + ) external returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 assetsReceived) { + BaseAssetConfig storage config = configs[redeemBaseAsset]; + if (config.adapter == address(0)) revert UnsupportedAsset(); + + (sharesClaimed, assetsExpected, assetsReceived) = IAssetAdapter(config.adapter).redeem(shares); + config.pendingRedeemAssets = SafeCast.toUint128(uint256(config.pendingRedeemAssets) - assetsExpected); + } + + /// @notice Commit liquidity assets to an asynchronous base-asset mint through an adapter. + /// @dev Withdraws a shortfall from `activeMarket` when necessary, preserves liquidity reserved for LP + /// withdrawals, and tracks expected base shares in `pendingMintShares` for totalAssets() valuation. + /// @param configs ARM storage mapping from base assets to their configuration. + /// @param pendingMintShares ARM storage mapping of base-asset shares expected from outstanding mints. + /// @param liquidityAsset Asset committed to the mint. + /// @param activeMarket ERC-4626 market used to source a liquidity shortfall, or address(0) when none is active. + /// @param reservedWithdrawLiquidity Liquidity reserved for outstanding LP withdrawals, in native decimals. + /// For example, 100e6 reserves 100 USDC when the liquidity asset has 6 decimals. + /// @param mintBaseAsset Base asset expected from mint settlement. + /// @param assets Liquidity assets offered to the adapter, in native liquidity-asset decimals. + /// For example, 100e6 offers 100 USDC when the liquidity asset has 6 decimals. + /// @return assetsRequested Liquidity assets accepted by the adapter. + /// @return sharesExpected Base-asset shares expected from settlement. + function requestMint( + mapping(address asset => BaseAssetConfig) storage configs, + mapping(address asset => uint256 shares) storage pendingMintShares, + address liquidityAsset, + address activeMarket, + uint128 reservedWithdrawLiquidity, + address mintBaseAsset, + uint256 assets + ) external returns (uint256 assetsRequested, uint256 sharesExpected) { + BaseAssetConfig storage config = configs[mintBaseAsset]; + address adapter = config.adapter; + if (adapter == address(0)) revert UnsupportedAsset(); + + _ensureLiquidityAvailable(assets, liquidityAsset, activeMarket, reservedWithdrawLiquidity); + + IERC20 liquidityToken = IERC20(liquidityAsset); + if (liquidityToken.allowance(address(this), adapter) < assets) { + liquidityToken.approve(adapter, type(uint256).max); + } + + (assetsRequested, sharesExpected) = IAssetAdapter(adapter).requestMint(assets); + pendingMintShares[mintBaseAsset] += sharesExpected; + } + + /// @notice Claim asynchronously minted base-asset shares from their configured adapter. + /// @dev Removes the claimed shares from `pendingMintShares` after the adapter transfers settled inventory. + /// @param configs ARM storage mapping from base assets to their configuration. + /// @param pendingMintShares ARM storage mapping of base-asset shares expected from outstanding mints. + /// @param mintBaseAsset Base asset being claimed from the adapter. + /// @param shares Pending base-asset shares to claim, in native base-asset decimals. + /// For example, 100e18 is 100 shares when the base asset has 18 decimals. + /// @return sharesClaimed Base-asset shares removed from the adapter's mint queue. + /// @return assetsExpected Liquidity assets committed for the claimed shares. + /// @return sharesReceived Base-asset shares actually transferred to the ARM. + function claimMint( + mapping(address asset => BaseAssetConfig) storage configs, + mapping(address asset => uint256 shares) storage pendingMintShares, + address mintBaseAsset, + uint256 shares + ) external returns (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived) { + BaseAssetConfig storage config = configs[mintBaseAsset]; + if (config.adapter == address(0)) revert UnsupportedAsset(); + + (sharesClaimed, assetsExpected, sharesReceived) = IAssetAdapter(config.adapter).claimMint(shares); + pendingMintShares[mintBaseAsset] -= sharesClaimed; + } + + function _ensureLiquidityAvailable( + uint256 amount, + address liquidityAsset, + address activeMarket, + uint128 reservedWithdrawLiquidity + ) private { + uint256 liquidityBalance = IERC20(liquidityAsset).balanceOf(address(this)); + uint256 requiredLiquidity = amount + reservedWithdrawLiquidity; + if (requiredLiquidity <= liquidityBalance) return; + + if (activeMarket == address(0)) revert InsufficientLiquidity(); + + uint256 shortfall = requiredLiquidity - liquidityBalance; + try IERC4626(activeMarket).withdraw(shortfall, address(this), address(this)) {} + catch { + revert InsufficientLiquidity(); + } + } +} diff --git a/test/fork/PaxosARM/PaxosMint.t.sol b/test/fork/PaxosARM/PaxosMint.t.sol new file mode 100644 index 00000000..9dc17ef2 --- /dev/null +++ b/test/fork/PaxosARM/PaxosMint.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import {Fork_Shared_Test} from "test/fork/PaxosARM/shared/Shared.sol"; +import {PaxosAssetAdapter} from "contracts/adapters/PaxosAssetAdapter.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; +import {OwnableOperable} from "contracts/OwnableOperable.sol"; +import {IERC20} from "contracts/Interfaces.sol"; + +/// @notice Fork tests for converting ARM USDC into Paxos-minted PYUSD/USDG inventory. +contract Fork_Concrete_PaxosARM_PaxosMint_Test_ is Fork_Shared_Test { + uint256 public constant AMOUNT = 10_000e6; + bytes32 public constant PAXOS_MINT_ID = bytes32("paxos-mint-id"); + + function test_RequestAndClaimBaseAssetMint_RevertWhen_NotOperatorOrOwner() public { + vm.prank(address(0xBEEF)); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + arm.requestBaseAssetMint(address(pyusd), AMOUNT); + + vm.prank(address(0xBEEF)); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + arm.claimBaseAssetMint(address(pyusd), AMOUNT); + } + + function test_RequestAndClaimBaseAssetMint_RevertWhen_UnsupportedAsset() public { + vm.prank(operator); + vm.expectRevert(AbstractARM.UnsupportedAsset.selector); + arm.requestBaseAssetMint(address(badToken), AMOUNT); + + vm.prank(operator); + vm.expectRevert(AbstractARM.UnsupportedAsset.selector); + arm.claimBaseAssetMint(address(badToken), AMOUNT); + } + + function test_RequestBaseAssetMint_MovesUsdcAndTracksExpectedShares() public { + uint256 totalAssetsBefore = arm.totalAssets(); + uint256 armUsdcBefore = usdc.balanceOf(address(arm)); + + vm.prank(operator); + (uint256 assetsRequested, uint256 sharesExpected) = arm.requestBaseAssetMint(address(pyusd), AMOUNT); + + assertEq(assetsRequested, AMOUNT, "assetsRequested"); + assertEq(sharesExpected, AMOUNT, "sharesExpected"); + assertEq(usdc.balanceOf(address(arm)), armUsdcBefore - AMOUNT, "ARM USDC committed"); + assertEq(usdc.balanceOf(address(pyusdAdapter)), AMOUNT, "adapter USDC queued"); + assertEq(pyusdAdapter.pendingMintAssets(), AMOUNT, "adapter pending mint"); + assertEq(arm.pendingMintShares(address(pyusd)), AMOUNT, "ARM expected mint shares"); + + // Committing USDC to inventory recognizes the configured cross-price discount immediately. + uint256 expectedDiscount = AMOUNT - (AMOUNT * CROSS_PRICE / PRICE_SCALE); + assertApproxEqAbs(arm.totalAssets(), totalAssetsBefore - expectedDiscount, 1, "mint valued at cross price"); + } + + function test_ClaimBaseAssetMint_AfterSettlement() public { + vm.prank(operator); + arm.requestBaseAssetMint(address(pyusd), AMOUNT); + vm.prank(operator); + pyusdAdapter.submitPaxosMint(AMOUNT, PAXOS_MINT_ID); + + _settleMint(pyusd, pyusdAdapter, AMOUNT); + + uint256 totalAssetsBefore = arm.totalAssets(); + uint256 armBaseBefore = pyusd.balanceOf(address(arm)); + + vm.prank(operator); + (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived) = + arm.claimBaseAssetMint(address(pyusd), AMOUNT); + + assertEq(sharesClaimed, AMOUNT, "sharesClaimed"); + assertEq(assetsExpected, AMOUNT, "assetsExpected"); + assertEq(sharesReceived, AMOUNT, "sharesReceived"); + assertEq(pyusd.balanceOf(address(arm)), armBaseBefore + AMOUNT, "minted PYUSD in ARM"); + assertEq(arm.pendingMintShares(address(pyusd)), 0, "ARM expected mint shares cleared"); + assertEq(pyusdAdapter.settlingMintAssets(), 0, "adapter mint settlement cleared"); + assertApproxEqAbs(arm.totalAssets(), totalAssetsBefore, 1, "claim is NAV neutral"); + } + + function test_ClaimBaseAssetMint_RevertWhen_MoreThanPending() public { + vm.prank(operator); + arm.requestBaseAssetMint(address(pyusd), AMOUNT); + vm.prank(operator); + pyusdAdapter.submitPaxosMint(AMOUNT, PAXOS_MINT_ID); + _settleMint(pyusd, pyusdAdapter, AMOUNT); + + vm.prank(operator); + vm.expectRevert(PaxosAssetAdapter.MintAmountTooHigh.selector); + arm.claimBaseAssetMint(address(pyusd), AMOUNT + 1); + + assertEq(arm.pendingMintShares(address(pyusd)), AMOUNT, "ARM pending shares unchanged"); + assertEq(pyusdAdapter.settlingMintAssets(), AMOUNT, "settling shares unchanged"); + + vm.prank(operator); + arm.claimBaseAssetMint(address(pyusd), AMOUNT); + assertEq(arm.pendingMintShares(address(pyusd)), 0, "ARM pending shares cleared"); + assertEq(pyusdAdapter.settlingMintAssets(), 0, "full pending amount remains claimable"); + } + + function test_MintThenSell_Pyusd() public { + _mintThenSell(pyusd, pyusdAdapter); + } + + function test_MintThenSell_Usdg() public { + _mintThenSell(usdg, usdgAdapter); + } + + function test_SimultaneousMintAndRedeem_BalancesRemainIsolated() public { + vm.prank(operator); + arm.requestBaseAssetMint(address(pyusd), AMOUNT); + + vm.prank(operator); + arm.requestBaseAssetRedeem(address(pyusd), AMOUNT); + + // Pending mint USDC cannot satisfy a redemption claim. + vm.prank(operator); + pyusdAdapter.submitPaxosRedeem(AMOUNT, bytes32("redeem")); + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(PaxosAssetAdapter.InsufficientSettledAssets.selector, AMOUNT, 0)); + arm.claimBaseAssetRedeem(address(pyusd), AMOUNT); + + // Submit the mint. With redemption inventory sent away, no base shares are available until Paxos mints them. + vm.prank(operator); + pyusdAdapter.submitPaxosMint(AMOUNT, bytes32("mint")); + vm.prank(operator); + vm.expectRevert(abi.encodeWithSelector(PaxosAssetAdapter.InsufficientMintedShares.selector, AMOUNT, 0)); + arm.claimBaseAssetMint(address(pyusd), AMOUNT); + + _settle(pyusdAdapter, AMOUNT); + _settleMint(pyusd, pyusdAdapter, AMOUNT); + + vm.prank(operator); + arm.claimBaseAssetRedeem(address(pyusd), AMOUNT); + vm.prank(operator); + arm.claimBaseAssetMint(address(pyusd), AMOUNT); + + assertEq(arm.pendingMintShares(address(pyusd)), 0, "ARM mint queue cleared"); + assertEq(pyusdAdapter.settlingMintAssets(), 0, "mint queue cleared"); + assertEq(_pendingRedeemAssets(pyusd), 0, "redeem queue cleared"); + } + + function test_RevertWhen_LoweringCrossPrice_WithPendingMintExposure() public { + vm.prank(operator); + arm.requestBaseAssetMint(address(pyusd), AMOUNT); + + vm.prank(governor); + vm.expectRevert(AbstractARM.TooManyBaseAssets.selector); + arm.setCrossPrice(address(pyusd), CROSS_PRICE - 1); + } + + function _mintThenSell(IERC20 token, PaxosAssetAdapter adapter) internal { + uint256 armBaseBefore = token.balanceOf(address(arm)); + + vm.prank(operator); + arm.requestBaseAssetMint(address(token), AMOUNT); + vm.prank(operator); + adapter.submitPaxosMint(AMOUNT, PAXOS_MINT_ID); + _settleMint(token, adapter, AMOUNT); + vm.prank(operator); + arm.claimBaseAssetMint(address(token), AMOUNT); + + assertEq(token.balanceOf(address(arm)), armBaseBefore + AMOUNT, "mint inventory received"); + + uint256 traderUsdcBefore = usdc.balanceOf(address(this)); + arm.swapExactTokensForTokens(usdc, token, AMOUNT, 0, address(this)); + + assertEq(usdc.balanceOf(address(this)), traderUsdcBefore - AMOUNT, "trader paid USDC"); + assertEq(token.balanceOf(address(arm)), armBaseBefore, "mint inventory sold"); + } +} diff --git a/test/fork/PaxosARM/shared/Shared.sol b/test/fork/PaxosARM/shared/Shared.sol index c748cb80..d644ab14 100644 --- a/test/fork/PaxosARM/shared/Shared.sol +++ b/test/fork/PaxosARM/shared/Shared.sol @@ -195,6 +195,11 @@ abstract contract Fork_Shared_Test is Base_Test_ { deal(address(usdc), address(adapter), usdc.balanceOf(address(adapter)) + amount); } + /// @notice Simulates Paxos mint settlement by increasing the adapter's base-asset balance. + function _settleMint(IERC20 baseAsset, PaxosAssetAdapter adapter, uint256 amount) internal { + deal(address(baseAsset), address(adapter), baseAsset.balanceOf(address(adapter)) + amount); + } + ////////////////////////////////////////////////////// /// --- PRICE / CONFIG HELPERS ////////////////////////////////////////////////////// diff --git a/test/smoke/PaxosARMSmokeTest.t.sol b/test/smoke/PaxosARMSmokeTest.t.sol index fe8173ba..a1191d40 100644 --- a/test/smoke/PaxosARMSmokeTest.t.sol +++ b/test/smoke/PaxosARMSmokeTest.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.36; import {AbstractSmokeTest} from "./AbstractSmokeTest.sol"; import {IERC20} from "contracts/Interfaces.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; import {MultiAssetARM} from "contracts/MultiAssetARM.sol"; import {PaxosAssetAdapter} from "contracts/adapters/PaxosAssetAdapter.sol"; import {CapManager} from "contracts/CapManager.sol"; @@ -57,6 +58,7 @@ contract Fork_PaxosARM_Smoke_Test is AbstractSmokeTest { assertEq(pyusdAdapter.owner(), Mainnet.MULTISIG_5_OF_8, "PYUSD adapter owner"); assertEq(pyusdAdapter.operator(), operator, "PYUSD adapter operator"); assertNotEq(pyusdAdapter.paxosRecipient(), address(0), "PYUSD adapter paxos recipient"); + assertEq(pyusdAdapter.paxosMintRecipient(), pyusdAdapter.paxosRecipient(), "PYUSD adapter paxos mint recipient"); assertEq( address(pyusdAdapter), resolver.resolve("USDC_ARM_PYUSD_ADAPTER"), "PYUSD adapter proxy used by USDC ARM" ); @@ -68,6 +70,7 @@ contract Fork_PaxosARM_Smoke_Test is AbstractSmokeTest { assertEq(usdgAdapter.owner(), Mainnet.MULTISIG_5_OF_8, "USDG adapter owner"); assertEq(usdgAdapter.operator(), operator, "USDG adapter operator"); assertNotEq(usdgAdapter.paxosRecipient(), address(0), "USDG adapter paxos recipient"); + assertEq(usdgAdapter.paxosMintRecipient(), usdgAdapter.paxosRecipient(), "USDG adapter paxos mint recipient"); assertEq(address(usdgAdapter), resolver.resolve("USDC_ARM_USDG_ADAPTER"), "USDG adapter proxy used by USDC ARM"); address[] memory baseAssets = usdcARM.getBaseAssets(); @@ -234,6 +237,53 @@ contract Fork_PaxosARM_Smoke_Test is AbstractSmokeTest { usdcARM.setOwner(RANDOM_ADDRESS); } + ////////////////////////////////////////////////////// + /// --- pause roles + ////////////////////////////////////////////////////// + + /// @notice The 043 upgrade wires the 2/8 as guardian and the 5/8 as adminMultisig. + function test_PauseRolesConfigured() external view { + assertEq(usdcARM.guardian(), Mainnet.MULTISIG_2_OF_8, "guardian is the 2/8"); + assertEq(usdcARM.adminMultisig(), Mainnet.MULTISIG_5_OF_8, "adminMultisig is the 5/8"); + } + + /// @notice The 2/8 gets a no-delay pause but must never be able to re-open the ARM. + function test_GuardianCanPauseButNotUnpause() external { + vm.prank(Mainnet.MULTISIG_2_OF_8); + usdcARM.pause(); + assertTrue(usdcARM.paused(), "guardian paused"); + + vm.prank(Mainnet.MULTISIG_2_OF_8); + vm.expectRevert(AbstractARM.OnlyUnpauser.selector); + usdcARM.unpause(); + assertTrue(usdcARM.paused(), "still paused after guardian tried to unpause"); + + vm.prank(Mainnet.MULTISIG_5_OF_8); + usdcARM.unpause(); + assertFalse(usdcARM.paused(), "adminMultisig unpaused"); + } + + /// @notice Storage-layout proof: the roles landed in the gap at slots 62/63 and the live + /// variables bracketing them still read back sane. + function test_StorageLayoutPreservedAcrossUpgrade() external view { + assertEq(usdcARM.feeCollector(), Mainnet.BUYBACK_OPERATOR, "feeCollector (slot 59) intact"); + assertGe(usdcARM.withdrawsQueuedShares(), usdcARM.withdrawsClaimedShares(), "slot 60 queue invariant"); + + assertEq( + uint256(vm.load(address(usdcARM), bytes32(uint256(62)))), + uint256(uint160(Mainnet.MULTISIG_2_OF_8)), + "guardian at slot 62" + ); + assertEq( + uint256(vm.load(address(usdcARM), bytes32(uint256(63)))), + uint256(uint160(Mainnet.MULTISIG_5_OF_8)), + "adminMultisig at slot 63" + ); + + assertGt(usdcARM.totalAssets(), 0, "totalAssets intact"); + assertEq(usdcARM.liquidityAsset(), Mainnet.USDC, "liquidityAsset intact"); + } + /// @dev Assert `expected` appears in the ARM's `getBaseAssets()` list. A membership check /// rather than exact array equality keeps the assertion robust to registration order and /// to additional base assets being registered by future deployments. diff --git a/test/smoke/WETHARMSmokeTest.t.sol b/test/smoke/WETHARMSmokeTest.t.sol index 3a17303d..d1c990e6 100644 --- a/test/smoke/WETHARMSmokeTest.t.sol +++ b/test/smoke/WETHARMSmokeTest.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.36; import {AbstractSmokeTest} from "./AbstractSmokeTest.sol"; +import {AbstractARM} from "contracts/AbstractARM.sol"; import {MultiAssetARM} from "contracts/MultiAssetARM.sol"; import {CapManager} from "contracts/CapManager.sol"; import {Proxy} from "contracts/Proxy.sol"; @@ -23,7 +24,7 @@ contract Fork_WETHARM_Smoke_Test is AbstractSmokeTest { function test_InitialConfig() external view { assertEq(wethARM.name(), "WETH ARM", "name"); assertEq(wethARM.symbol(), "ARM-WETH", "symbol"); - assertEq(wethARM.owner(), Mainnet.MULTISIG_5_OF_8, "owner"); + assertEq(wethARM.owner(), Mainnet.TIMELOCK, "owner"); assertEq(wethARM.operator(), Mainnet.ARM_TALOS_RELAYER, "operator"); assertEq(wethARM.feeCollector(), Mainnet.BUYBACK_OPERATOR, "fee collector"); assertEq(wethARM.fee(), 2000, "performance fee"); @@ -56,8 +57,8 @@ contract Fork_WETHARM_Smoke_Test is AbstractSmokeTest { Proxy eethAdapter = Proxy(payable(resolver.resolve("WETH_ARM_EETH_ADAPTER"))); Proxy weethAdapter = Proxy(payable(resolver.resolve("WETH_ARM_WEETH_ADAPTER"))); - assertEq(eethAdapter.owner(), Mainnet.MULTISIG_5_OF_8, "eETH adapter owner"); - assertEq(weethAdapter.owner(), Mainnet.MULTISIG_5_OF_8, "weETH adapter owner"); + assertEq(eethAdapter.owner(), Mainnet.TIMELOCK, "eETH adapter owner"); + assertEq(weethAdapter.owner(), Mainnet.TIMELOCK, "weETH adapter owner"); assertEq( eethAdapter.implementation(), resolver.resolve("WETH_ARM_EETH_ADAPTER_IMPL"), "eETH adapter implementation" ); @@ -79,13 +80,87 @@ contract Fork_WETHARM_Smoke_Test is AbstractSmokeTest { assertEq(morphoMarket.market(), Mainnet.MORPHO_WETH_VAULT, "configured Morpho vault"); assertEq(morphoMarket.market(), lidoMarket.market(), "Lido Morpho vault"); assertEq(morphoMarket.market(), etherFiMarket.market(), "EtherFi Morpho vault"); - assertEq(morphoMarket.owner(), Mainnet.MULTISIG_5_OF_8, "market owner"); + assertEq(morphoMarket.owner(), Mainnet.TIMELOCK, "market owner"); assertEq(morphoMarket.harvester(), Mainnet.MULTISIG_2_OF_8, "market harvester"); assertEq(address(morphoMarket.merkleDistributor()), Mainnet.MERKLE_DISTRIBUTOR, "Merkle distributor"); assertTrue(wethARM.supportedMarkets(address(morphoMarket)), "market supported"); assertEq(wethARM.activeMarket(), address(morphoMarket), "active market"); } + ////////////////////////////////////////////////////// + /// --- pause roles + ////////////////////////////////////////////////////// + + /// @notice The 043 upgrade wires the 2/8 as guardian and the 5/8 as adminMultisig. + function test_PauseRolesConfigured() external view { + assertEq(wethARM.guardian(), Mainnet.MULTISIG_2_OF_8, "guardian is the 2/8"); + assertEq(wethARM.adminMultisig(), Mainnet.MULTISIG_5_OF_8, "adminMultisig is the 5/8"); + } + + /// @notice The 2/8 gets a no-delay pause, but must not be able to re-open the ARM. Together with + /// owner staying the upgrade admin, that stops any single 2/8 key from both unpausing and + /// changing the code. + function test_GuardianCanPauseButNotUnpause() external { + vm.prank(Mainnet.MULTISIG_2_OF_8); + wethARM.pause(); + assertTrue(wethARM.paused(), "guardian paused"); + + vm.prank(Mainnet.MULTISIG_2_OF_8); + vm.expectRevert(AbstractARM.OnlyUnpauser.selector); + wethARM.unpause(); + assertTrue(wethARM.paused(), "still paused after guardian tried to unpause"); + + // The 5/8 recovers with no delay and no governance vote. + vm.prank(Mainnet.MULTISIG_5_OF_8); + wethARM.unpause(); + assertFalse(wethARM.paused(), "adminMultisig unpaused"); + } + + /// @notice The Talos relayer is a hot key: it keeps its pause, but never gains unpause. + function test_OperatorCannotUnpause() external { + vm.prank(Mainnet.ARM_TALOS_RELAYER); + wethARM.pause(); + assertTrue(wethARM.paused(), "operator paused"); + + vm.prank(Mainnet.ARM_TALOS_RELAYER); + vm.expectRevert(AbstractARM.OnlyUnpauser.selector); + wethARM.unpause(); + + vm.prank(Mainnet.MULTISIG_5_OF_8); + wethARM.unpause(); + } + + /// @notice Storage-layout proof. `guardian`/`adminMultisig` were taken from the AbstractARM gap + /// at slots 62 and 63. Read the live variables that bracket them and confirm they still + /// hold sane values: a layout shift shows up here first. + function test_StorageLayoutPreservedAcrossUpgrade() external view { + // Slots 59-61, immediately before the new roles. + assertEq(wethARM.feeCollector(), Mainnet.BUYBACK_OPERATOR, "feeCollector (slot 59) intact"); + assertEq( + uint256(vm.load(address(wethARM), bytes32(uint256(59)))), + uint256(uint160(Mainnet.BUYBACK_OPERATOR)), + "slot 59 raw" + ); + assertGe(wethARM.withdrawsQueuedShares(), wethARM.withdrawsClaimedShares(), "slot 60 queue invariant"); + + // The new roles themselves, at slots 62 and 63. + assertEq( + uint256(vm.load(address(wethARM), bytes32(uint256(62)))), + uint256(uint160(Mainnet.MULTISIG_2_OF_8)), + "guardian at slot 62" + ); + assertEq( + uint256(vm.load(address(wethARM), bytes32(uint256(63)))), + uint256(uint160(Mainnet.MULTISIG_5_OF_8)), + "adminMultisig at slot 63" + ); + + // Live accounting still reads back sane, so nothing downstream shifted either. + assertGt(wethARM.totalAssets(), 0, "totalAssets intact"); + assertEq(wethARM.liquidityAsset(), Mainnet.WETH, "liquidityAsset intact"); + assertEq(wethARM.getBaseAssets().length, 4, "base assets intact"); + } + function _assertBaseAssetConfig(address baseAsset, string memory adapterName, bool pegged) internal view { (,,,,,, bool peggedToLiquidityAsset, uint8 baseAssetDecimals, address adapter) = wethARM.baseAssetConfigs(baseAsset); diff --git a/test/unit/MultiAssetARM/concrete/Admin.t.sol b/test/unit/MultiAssetARM/concrete/Admin.t.sol index 7a182ac4..a2d178c4 100644 --- a/test/unit/MultiAssetARM/concrete/Admin.t.sol +++ b/test/unit/MultiAssetARM/concrete/Admin.t.sol @@ -23,7 +23,7 @@ import {MockAssetAdapter} from "../mocks/MockAssetAdapter.sol"; /// the happy-path test asserts that side effect explicitly so the test /// catches removal of any of those behaviors. contract Unit_MultiAssetARM_Admin_Test is Unit_MultiAssetARM_Shared_Test { - // Valid price defaults inside the [PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION, PRICE_SCALE] band. + // Valid price defaults with cross price above the configured lower bound. uint256 internal constant CROSS_PRICE_DEFAULT = 1e36; uint256 internal constant BUY_PRICE_DEFAULT = 992 * 1e33; // 0.992e36 uint256 internal constant SELL_PRICE_DEFAULT = 1001 * 1e33; // 1.001e36 @@ -259,18 +259,35 @@ contract Unit_MultiAssetARM_Admin_Test is Unit_MultiAssetARM_Shared_Test { ); } + function test_AddBaseAsset_CrossPriceAtUpperBound() public { + uint256 upperBound = PRICE_SCALE + MAX_CROSS_PRICE_DEVIATION; + vm.prank(governor); + arm.addBaseAsset( + address(peg18), + address(adapterPeg18), + BUY_PRICE_DEFAULT, + upperBound, + LIQUIDITY_DEFAULT, + LIQUIDITY_DEFAULT, + upperBound, + true + ); + + assertEq(crossPrice(peg18), upperBound, "crossPrice at upper bound"); + } + function test_AddBaseAsset_RevertWhen_CrossPriceTooHigh() public { - // Cross price strictly above PRICE_SCALE (= 1e36) reverts. Equality is allowed. + uint256 tooHigh = PRICE_SCALE + MAX_CROSS_PRICE_DEVIATION + 1; vm.prank(governor); vm.expectRevert(AbstractARM.CrossPriceTooHigh.selector); arm.addBaseAsset( address(peg18), address(adapterPeg18), BUY_PRICE_DEFAULT, - SELL_PRICE_DEFAULT, + tooHigh, LIQUIDITY_DEFAULT, LIQUIDITY_DEFAULT, - PRICE_SCALE + 1, + tooHigh, true ); } @@ -473,11 +490,23 @@ contract Unit_MultiAssetARM_Admin_Test is Unit_MultiAssetARM_Shared_Test { arm.setCrossPrice(address(peg18), PRICE_SCALE - MAX_CROSS_PRICE_DEVIATION - 1); } + function test_SetCrossPrice_AtUpperBound() public { + addBaseAsset(peg18); + uint256 upperBound = PRICE_SCALE + MAX_CROSS_PRICE_DEVIATION; + + vm.prank(governor); + arm.setPrices(address(peg18), BUY_PRICE_DEFAULT, upperBound, 1 ether, 1 ether); + vm.prank(governor); + arm.setCrossPrice(address(peg18), upperBound); + + assertEq(crossPrice(peg18), upperBound, "crossPrice at upper bound"); + } + function test_SetCrossPrice_RevertWhen_TooHigh() public { addBaseAsset(peg18); vm.prank(governor); vm.expectRevert(AbstractARM.CrossPriceTooHigh.selector); - arm.setCrossPrice(address(peg18), PRICE_SCALE + 1); + arm.setCrossPrice(address(peg18), PRICE_SCALE + MAX_CROSS_PRICE_DEVIATION + 1); } function test_SetCrossPrice_RevertWhen_SellBelowNewCross() public { diff --git a/test/unit/MultiAssetARM/concrete/BaseAssetRedeem.t.sol b/test/unit/MultiAssetARM/concrete/BaseAssetRedeem.t.sol index 5bd25aad..20165fd2 100644 --- a/test/unit/MultiAssetARM/concrete/BaseAssetRedeem.t.sol +++ b/test/unit/MultiAssetARM/concrete/BaseAssetRedeem.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.36; import {Unit_MultiAssetARM_Shared_Test} from "../Shared.t.sol"; import {AbstractARM} from "contracts/AbstractARM.sol"; import {OwnableOperable} from "contracts/OwnableOperable.sol"; -import {IERC20} from "contracts/Interfaces.sol"; +import {IAssetAdapter, IERC20} from "contracts/Interfaces.sol"; /// @notice Adapter redemption flow (request/claim) through MockAssetAdapter, run at both 18 and 6 decimal /// liquidity, across 6 and 18 decimal base assets. `assetsExpected` / `pendingRedeemAssets` are tracked @@ -74,6 +74,16 @@ abstract contract BaseAssetRedeem_Test is Unit_MultiAssetARM_Shared_Test { vm.expectRevert(AbstractARM.UnsupportedAsset.selector); arm.requestBaseAssetRedeem(makeAddr("random"), 1e18); } + + function test_BaseAssetMint_RevertWhen_AdapterDoesNotSupportMinting() public { + vm.prank(operator); + vm.expectRevert(IAssetAdapter.MintNotSupported.selector); + arm.requestBaseAssetMint(address(adp18), 1); + + vm.prank(operator); + vm.expectRevert(IAssetAdapter.MintNotSupported.selector); + arm.claimBaseAssetMint(address(adp18), 1); + } } contract BaseAssetRedeem_18dec_Test is BaseAssetRedeem_Test { diff --git a/test/unit/MultiAssetARM/concrete/ContractSize.t.sol b/test/unit/MultiAssetARM/concrete/ContractSize.t.sol new file mode 100644 index 00000000..933f2a0a --- /dev/null +++ b/test/unit/MultiAssetARM/concrete/ContractSize.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.36; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "@solmate/test/utils/mocks/MockERC20.sol"; + +import {MultiAssetARM} from "contracts/MultiAssetARM.sol"; + +contract Unit_MultiAssetARM_ContractSize_Test is Test { + /// @notice EIP-170 maximum deployed runtime code size: 24,576 bytes = 24 KiB. + uint256 internal constant EIP_170_RUNTIME_SIZE_LIMIT = 24_576; + + function test_RuntimeCodeSize_DoesNotExceedEip170Limit() public { + MockERC20 liquidity = new MockERC20("Liquidity", "LIQ", 6); + MultiAssetARM implementation = new MultiAssetARM({ + _liquidityAsset: address(liquidity), + _claimDelay: 10 minutes, + _minSharesToRedeem: 1e6, + _allocateThreshold: 100e6 + }); + + assertLe(address(implementation).code.length, EIP_170_RUNTIME_SIZE_LIMIT, "MultiAssetARM exceeds EIP-170"); + } +} diff --git a/test/unit/MultiAssetARM/concrete/Pause.t.sol b/test/unit/MultiAssetARM/concrete/Pause.t.sol index fa4aa267..4f018bbb 100644 --- a/test/unit/MultiAssetARM/concrete/Pause.t.sol +++ b/test/unit/MultiAssetARM/concrete/Pause.t.sol @@ -7,14 +7,19 @@ import {Unit_MultiAssetARM_Shared_Test} from "../Shared.t.sol"; // Contracts import {AbstractARM} from "contracts/AbstractARM.sol"; import {Ownable} from "contracts/Ownable.sol"; -import {OwnableOperable} from "contracts/OwnableOperable.sol"; -/// @notice Coverage for `pause()` (operator or owner) and `unpause()` (owner only). +/// @notice Coverage for `pause()` (owner, operator, guardian or adminMultisig), `unpause()` +/// (owner or adminMultisig only) and `setPauseRoles()` (owner only). /// The downstream `whenNotPaused` reverts on user-facing functions are /// already covered in the per-function test files (Deposit, ClaimRedeem, /// RequestRedeem, Swap*). Here we focus on the access control, the /// `paused` state flip, and the events. contract Unit_MultiAssetARM_Pause_Test is Unit_MultiAssetARM_Shared_Test { + /// @dev The 2/8 Guardian multisig: can pause, must never unpause. + address public guardian = makeAddr("guardian"); + /// @dev The 5/8 Admin multisig: can pause and unpause. + address public adminMultisig = makeAddr("adminMultisig"); + ////////////////////////////////////////////////////// /// --- pause ////////////////////////////////////////////////////// @@ -42,9 +47,43 @@ contract Unit_MultiAssetARM_Pause_Test is Unit_MultiAssetARM_Shared_Test { assertEq(arm.paused(), true, "paused post"); } + function test_Pause_ByGuardian() public { + assertEq(arm.paused(), false, "paused pre"); + + vm.expectEmit(address(arm)); + emit AbstractARM.Paused(guardian); + + vm.prank(guardian); + arm.pause(); + + assertEq(arm.paused(), true, "paused post"); + } + + function test_Pause_ByAdminMultisig() public { + assertEq(arm.paused(), false, "paused pre"); + + vm.expectEmit(address(arm)); + emit AbstractARM.Paused(adminMultisig); + + vm.prank(adminMultisig); + arm.pause(); + + assertEq(arm.paused(), true, "paused post"); + } + function test_Pause_RevertWhen_NotAuthorized() public { vm.prank(alice); - vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + vm.expectRevert(AbstractARM.OnlyPauser.selector); + arm.pause(); + } + + /// @notice Clearing a role revokes its pause rights. + function test_Pause_RevertWhen_GuardianCleared() public { + vm.prank(governor); + arm.setPauseRoles(address(0), adminMultisig); + + vm.prank(guardian); + vm.expectRevert(AbstractARM.OnlyPauser.selector); arm.pause(); } @@ -65,14 +104,42 @@ contract Unit_MultiAssetARM_Pause_Test is Unit_MultiAssetARM_Shared_Test { assertEq(arm.paused(), false, "paused post"); } + function test_Unpause_ByAdminMultisig() public { + vm.prank(guardian); + arm.pause(); + assertEq(arm.paused(), true, "paused pre"); + + vm.expectEmit(address(arm)); + emit AbstractARM.Unpaused(adminMultisig); + + vm.prank(adminMultisig); + arm.unpause(); + + assertEq(arm.paused(), false, "paused post"); + } + function test_Unpause_RevertWhen_Operator() public { - // The operator can pause but cannot unpause — that's reserved for the owner. + // The operator is a hot key. It can trip the pause but must never lift it. vm.prank(operator); arm.pause(); vm.prank(operator); - vm.expectRevert(Ownable.OnlyOwner.selector); + vm.expectRevert(AbstractARM.OnlyUnpauser.selector); arm.unpause(); + + assertEq(arm.paused(), true, "must stay paused"); + } + + /// @notice The whole point of the split: the 2/8 guardian can pause but cannot re-open the ARM. + function test_Unpause_RevertWhen_Guardian() public { + vm.prank(guardian); + arm.pause(); + + vm.prank(guardian); + vm.expectRevert(AbstractARM.OnlyUnpauser.selector); + arm.unpause(); + + assertEq(arm.paused(), true, "must stay paused"); } function test_Unpause_RevertWhen_NotAuthorized() public { @@ -80,10 +147,50 @@ contract Unit_MultiAssetARM_Pause_Test is Unit_MultiAssetARM_Shared_Test { arm.pause(); vm.prank(alice); - vm.expectRevert(Ownable.OnlyOwner.selector); + vm.expectRevert(AbstractARM.OnlyUnpauser.selector); arm.unpause(); } + ////////////////////////////////////////////////////// + /// --- setPauseRoles + ////////////////////////////////////////////////////// + function test_SetPauseRoles_ByOwner() public { + address newGuardian = makeAddr("newGuardian"); + address newAdminMultisig = makeAddr("newAdminMultisig"); + + vm.expectEmit(address(arm)); + emit AbstractARM.GuardianChanged(newGuardian); + vm.expectEmit(address(arm)); + emit AbstractARM.AdminMultisigChanged(newAdminMultisig); + + vm.prank(governor); + arm.setPauseRoles(newGuardian, newAdminMultisig); + + assertEq(arm.guardian(), newGuardian, "guardian"); + assertEq(arm.adminMultisig(), newAdminMultisig, "adminMultisig"); + } + + function test_SetPauseRoles_RevertWhen_NotOwner() public { + vm.prank(alice); + vm.expectRevert(Ownable.OnlyOwner.selector); + arm.setPauseRoles(alice, alice); + } + + /// @notice Neither the operator nor the roles themselves can reassign the roles. + function test_SetPauseRoles_RevertWhen_Operator() public { + vm.prank(operator); + vm.expectRevert(Ownable.OnlyOwner.selector); + arm.setPauseRoles(alice, alice); + + vm.prank(guardian); + vm.expectRevert(Ownable.OnlyOwner.selector); + arm.setPauseRoles(alice, alice); + + vm.prank(adminMultisig); + vm.expectRevert(Ownable.OnlyOwner.selector); + arm.setPauseRoles(alice, alice); + } + /// @notice Pausing an already-paused ARM is a no-op state-wise but still emits the event. function test_Pause_WhenAlreadyPaused() public { vm.prank(governor); @@ -171,9 +278,13 @@ contract Unit_MultiAssetARM_Pause_Test is Unit_MultiAssetARM_Shared_Test { /// @dev Give alice liquidity for the deposit/redeem gating tests. Approvals are set by the shared harness. /// Caps are disabled so deposits are only gated by the pause state under test. + /// The guardian and adminMultisig roles are wired here; deployments set them at upgrade time. function setUp() public virtual override { super.setUp(); desactiveCapManager(); deal(address(liquidity), alice, 1_000 * DEFAULT_AMOUNT()); + + vm.prank(governor); + arm.setPauseRoles(guardian, adminMultisig); } } diff --git a/test/unit/MultiAssetARM/mocks/MockAssetAdapter.sol b/test/unit/MultiAssetARM/mocks/MockAssetAdapter.sol index e165adb8..bcc0f85f 100644 --- a/test/unit/MultiAssetARM/mocks/MockAssetAdapter.sol +++ b/test/unit/MultiAssetARM/mocks/MockAssetAdapter.sol @@ -134,6 +134,14 @@ contract MockAssetAdapter is IAssetAdapter { return pendingRequestIds[index]; } + function requestMint(uint256) external pure returns (uint256, uint256) { + revert MintNotSupported(); + } + + function claimMint(uint256) external pure returns (uint256, uint256, uint256) { + revert MintNotSupported(); + } + /// @dev Mirror of AbstractARM._scaleBaseToLiquidity: base-native -> liquidity-native decimals. function _scaleBaseToLiquidity(uint256 amount) internal view returns (uint256) { if (baseDecimals == liquidityDecimals) return amount; diff --git a/test/unit/adapters/concrete/PaxosAssetAdapter.t.sol b/test/unit/adapters/concrete/PaxosAssetAdapter.t.sol index 90c4ae38..f022695d 100644 --- a/test/unit/adapters/concrete/PaxosAssetAdapter.t.sol +++ b/test/unit/adapters/concrete/PaxosAssetAdapter.t.sol @@ -30,12 +30,18 @@ contract Unit_PaxosAssetAdapter_Test is Test { address internal paxosRecipient = makeAddr("paxosRecipient"); uint256 internal constant ARM_PYUSD_BALANCE = 5_000e6; + uint256 internal constant ARM_USDC_BALANCE = 5_000e6; event PaxosRecipientUpdated(address indexed paxosRecipient); event PaxosRedeemRequested(uint256 shares, uint256 assetsExpected); event PaxosRedeemSubmitted(bytes32 indexed paxosRedemptionId, uint256 shares, address indexed paxosRecipient); event PaxosRedeemClaimed(uint256 shares, uint256 assetsExpected, uint256 assetsReceived); event ExcessLiquidityRecovered(address indexed to, uint256 amount); + event PaxosMintRecipientUpdated(address indexed paxosMintRecipient); + event PaxosMintRequested(uint256 assets, uint256 sharesExpected); + event PaxosMintSubmitted(bytes32 indexed paxosMintId, uint256 assets, address indexed paxosMintRecipient); + event PaxosMintClaimed(uint256 shares, uint256 assetsExpected, uint256 sharesReceived); + event ExcessBaseAssetRecovered(address indexed to, uint256 amount); function setUp() public { usdc = new MockERC20("USD Coin", "USDC", 6); @@ -47,6 +53,8 @@ contract Unit_PaxosAssetAdapter_Test is Test { pyusd.mint(arm, ARM_PYUSD_BALANCE); vm.prank(arm); pyusd.approve(address(adapter), type(uint256).max); + vm.prank(arm); + usdc.approve(address(adapter), type(uint256).max); } /// @dev Deploys the adapter behind a proxy owned by `governor` and initialized with `operator`. @@ -106,6 +114,7 @@ contract Unit_PaxosAssetAdapter_Test is Test { PaxosAssetAdapter fresh = PaxosAssetAdapter(address(proxy)); assertEq(fresh.operator(), operator, "operator"); assertEq(fresh.paxosRecipient(), paxosRecipient, "paxosRecipient"); + assertEq(fresh.paxosMintRecipient(), paxosRecipient, "paxosMintRecipient"); assertEq(fresh.owner(), governor, "proxy owner"); } @@ -213,6 +222,55 @@ contract Unit_PaxosAssetAdapter_Test is Test { assertEq(adapter.paxosRecipient(), newRecipient, "paxosRecipient updated"); } + function test_SetPaxosMintRecipient_UpdatesAndEmits() public { + address newRecipient = makeAddr("newMintRecipient"); + + vm.expectEmit(true, false, false, true, address(adapter)); + emit PaxosMintRecipientUpdated(newRecipient); + vm.prank(governor); + adapter.setPaxosMintRecipient(newRecipient); + + assertEq(adapter.paxosMintRecipient(), newRecipient, "paxosMintRecipient updated"); + } + + function test_SetPaxosMintRecipient_RevertWhen_NotOwnerOrZero() public { + vm.prank(operator); + vm.expectRevert(Ownable.OnlyOwner.selector); + adapter.setPaxosMintRecipient(alice); + + vm.prank(governor); + vm.expectRevert(PaxosAssetAdapter.InvalidPaxosRecipient.selector); + adapter.setPaxosMintRecipient(address(0)); + } + + function test_RequestMint_RevertWhen_NotARM() public { + vm.prank(alice); + vm.expectRevert(PaxosAssetAdapter.OnlyARM.selector); + adapter.requestMint(100e6); + } + + function test_RequestMint_RevertWhen_ZeroAssets() public { + vm.prank(arm); + vm.expectRevert(PaxosAssetAdapter.ZeroAssets.selector); + adapter.requestMint(0); + } + + function test_ClaimMint_RevertWhen_NotARMOrZeroShares() public { + vm.prank(alice); + vm.expectRevert(PaxosAssetAdapter.OnlyARM.selector); + adapter.claimMint(100e6); + + vm.prank(arm); + vm.expectRevert(PaxosAssetAdapter.ZeroShares.selector); + adapter.claimMint(0); + } + + function test_SubmitPaxosMint_RevertWhen_NotOperatorOrOwner() public { + vm.prank(alice); + vm.expectRevert(OwnableOperable.OnlyOperatorOrOwner.selector); + adapter.submitPaxosMint(100e6, bytes32("mint")); + } + ////////////////////////////////////////////////////// /// --- requestRedeem ////////////////////////////////////////////////////// @@ -493,4 +551,185 @@ contract Unit_PaxosAssetAdapter_Test is Test { assertEq(usdc.balanceOf(arm), 0, "nothing to recover"); assertEq(usdc.balanceOf(address(adapter)), 100e6, "adapter balance untouched"); } + + ////////////////////////////////////////////////////// + /// --- Paxos mint lifecycle + ////////////////////////////////////////////////////// + function test_MintLifecycle_FullFlow() public { + uint256 assets = 500e6; + bytes32 mintId = keccak256("paxos-mint-1"); + usdc.mint(arm, ARM_USDC_BALANCE); + + vm.expectEmit(false, false, false, true, address(adapter)); + emit PaxosMintRequested(assets, assets); + vm.prank(arm); + (uint256 assetsRequested, uint256 sharesExpected) = adapter.requestMint(assets); + + assertEq(assetsRequested, assets, "assetsRequested"); + assertEq(sharesExpected, assets, "sharesExpected"); + assertEq(usdc.balanceOf(arm), ARM_USDC_BALANCE - assets, "ARM USDC post request"); + assertEq(usdc.balanceOf(address(adapter)), assets, "adapter USDC post request"); + assertEq(adapter.pendingMintAssets(), assets, "pendingMintAssets"); + + vm.expectEmit(true, true, false, true, address(adapter)); + emit PaxosMintSubmitted(mintId, assets, paxosRecipient); + vm.prank(operator); + adapter.submitPaxosMint(assets, mintId); + + assertEq(adapter.pendingMintAssets(), 0, "pending mint cleared"); + assertEq(adapter.settlingMintAssets(), assets, "mint settling"); + assertEq(usdc.balanceOf(paxosRecipient), assets, "Paxos funded with USDC"); + + // Simulate Paxos minting PYUSD to the adapter. + pyusd.mint(address(adapter), assets); + + vm.expectEmit(false, false, false, true, address(adapter)); + emit PaxosMintClaimed(assets, assets, assets); + vm.prank(arm); + (uint256 sharesClaimed, uint256 assetsExpected, uint256 sharesReceived) = adapter.claimMint(assets); + + assertEq(sharesClaimed, assets, "sharesClaimed"); + assertEq(assetsExpected, assets, "assetsExpected"); + assertEq(sharesReceived, assets, "sharesReceived"); + assertEq(adapter.settlingMintAssets(), 0, "mint settling cleared"); + assertEq(pyusd.balanceOf(arm), ARM_PYUSD_BALANCE + assets, "minted PYUSD in ARM"); + } + + function test_MintLifecycle_PartialFlow() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestMint(500e6); + vm.prank(operator); + adapter.submitPaxosMint(200e6, bytes32("mint-1")); + + pyusd.mint(address(adapter), 150e6); + vm.prank(arm); + adapter.claimMint(150e6); + + assertEq(adapter.pendingMintAssets(), 300e6, "unsubmitted mint assets"); + assertEq(adapter.settlingMintAssets(), 50e6, "unsettled mint assets"); + assertEq(pyusd.balanceOf(arm), ARM_PYUSD_BALANCE + 150e6, "partial mint claimed"); + } + + function test_SubmitPaxosMint_RevertWhen_ZeroOrMoreThanPending() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestMint(100e6); + + vm.prank(operator); + vm.expectRevert(PaxosAssetAdapter.ZeroAssets.selector); + adapter.submitPaxosMint(0, bytes32("zero")); + + vm.prank(operator); + vm.expectRevert(PaxosAssetAdapter.MintAmountTooHigh.selector); + adapter.submitPaxosMint(100e6 + 1, bytes32("too-high")); + + assertEq(adapter.pendingMintAssets(), 100e6, "pending mint unchanged"); + assertEq(adapter.settlingMintAssets(), 0, "nothing submitted"); + } + + function test_SubmitPaxosMint_RevertWhen_RecipientNotConfigured() public { + PaxosAssetAdapter impl = new PaxosAssetAdapter(arm, address(pyusd), address(usdc)); + Proxy proxy = new Proxy(); + // Simulate an existing proxy upgraded from the pre-mint implementation: the appended + // paxosMintRecipient storage slot is zero until governance configures it. + proxy.initialize(address(impl), governor, ""); + PaxosAssetAdapter upgradedAdapter = PaxosAssetAdapter(address(proxy)); + + vm.prank(arm); + usdc.approve(address(upgradedAdapter), type(uint256).max); + usdc.mint(arm, 100e6); + vm.prank(arm); + upgradedAdapter.requestMint(100e6); + + vm.prank(governor); + vm.expectRevert(PaxosAssetAdapter.PaxosRecipientNotConfigured.selector); + upgradedAdapter.submitPaxosMint(100e6, bytes32("mint")); + + assertEq(upgradedAdapter.pendingMintAssets(), 100e6, "pending mint preserved"); + } + + function test_ClaimMint_RevertWhen_MoreThanSettlingOrSettledBalance() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestMint(100e6); + vm.prank(operator); + adapter.submitPaxosMint(100e6, bytes32("mint")); + + vm.prank(arm); + vm.expectRevert(PaxosAssetAdapter.MintAmountTooHigh.selector); + adapter.claimMint(100e6 + 1); + + vm.prank(arm); + vm.expectRevert(abi.encodeWithSelector(PaxosAssetAdapter.InsufficientMintedShares.selector, 100e6, 0)); + adapter.claimMint(100e6); + + assertEq(adapter.settlingMintAssets(), 100e6, "settling mint preserved"); + } + + function test_Redeem_CannotConsumePendingMintUsdc() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestRedeem(100e6); + vm.prank(operator); + adapter.submitPaxosRedeem(100e6, bytes32("redeem")); + + // This USDC belongs to a pending mint, not to the redemption settlement. + vm.prank(arm); + adapter.requestMint(100e6); + + vm.prank(arm); + vm.expectRevert(abi.encodeWithSelector(PaxosAssetAdapter.InsufficientSettledAssets.selector, 100e6, 0)); + adapter.redeem(100e6); + } + + function test_ClaimMint_CannotConsumePendingRedeemBase() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestMint(100e6); + vm.prank(operator); + adapter.submitPaxosMint(100e6, bytes32("mint")); + + // These PYUSD shares belong to a pending redemption, not to mint settlement. + vm.prank(arm); + adapter.requestRedeem(100e6); + + vm.prank(arm); + vm.expectRevert(abi.encodeWithSelector(PaxosAssetAdapter.InsufficientMintedShares.selector, 100e6, 0)); + adapter.claimMint(100e6); + } + + function test_RecoverExcessLiquidity_PreservesPendingMintAssets() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestMint(100e6); + usdc.mint(address(adapter), 20e6); + + vm.prank(governor); + adapter.recoverExcessLiquidity(); + + assertEq(usdc.balanceOf(arm), ARM_USDC_BALANCE - 100e6 + 20e6, "only excess returned"); + assertEq(usdc.balanceOf(address(adapter)), 100e6, "pending mint USDC preserved"); + } + + function test_RecoverExcessBaseAsset_PreservesMintAndRedeemObligations() public { + usdc.mint(arm, ARM_USDC_BALANCE); + vm.prank(arm); + adapter.requestMint(100e6); + vm.prank(operator); + adapter.submitPaxosMint(100e6, bytes32("mint")); + vm.prank(arm); + adapter.requestRedeem(50e6); + + // 50 is queued for redemption, 100 settles the mint, and 20 is excess. + pyusd.mint(address(adapter), 120e6); + + vm.expectEmit(true, false, false, true, address(adapter)); + emit ExcessBaseAssetRecovered(arm, 20e6); + vm.prank(governor); + adapter.recoverExcessBaseAsset(); + + assertEq(pyusd.balanceOf(address(adapter)), 150e6, "all obligations preserved"); + assertEq(pyusd.balanceOf(arm), ARM_PYUSD_BALANCE - 50e6 + 20e6, "only excess returned"); + } }