diff --git a/scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol b/scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol new file mode 100644 index 000000000..cd73030a1 --- /dev/null +++ b/scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +import {AaveV4DeployBase} from 'src/deployments/orchestration/AaveV4DeployBase.sol'; +import {AaveV4DeployOrchestration} from 'src/deployments/orchestration/AaveV4DeployOrchestration.sol'; +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; +import {BytecodeHelper} from 'src/deployments/utils/libraries/BytecodeHelper.sol'; +import {DeployConstants} from 'src/deployments/utils/libraries/DeployConstants.sol'; + +/// @title AaveV4DeployCorrelatedSpokeBase +/// @author Aave Labs +/// @notice Generic base script to deploy a standalone Spoke instance (proxy + implementation + AaveOracle) +/// intended for a correlated-asset market. Concrete scripts override the deploy inputs, the +/// expected chain id and the deployment name for a specific market. +/// @dev Requires FOUNDRY_LIBRARIES to be populated in .env with the LiquidationLogic library address, as +/// SpokeInstance depends on it. +abstract contract AaveV4DeployCorrelatedSpokeBase is Script { + struct SpokeDeployInputs { + address proxyAdminOwner; + address authority; + uint8 oracleDecimals; + uint16 maxUserReservesLimit; + bytes32 salt; + } + + /// @dev Override to provide the market-specific deploy inputs. + function _getDeployInputs( + address deployer + ) internal view virtual returns (SpokeDeployInputs memory); + + /// @dev Override to return the expected chain id for this deployment. + function _expectedChainId() internal view virtual returns (uint256); + + /// @dev Override to return a human-readable name for this spoke deployment (used in logs). + function _deploymentName() internal view virtual returns (string memory); + + function run() external virtual returns (BatchReports.SpokeInstanceBatchReport memory report) { + require(block.chainid == _expectedChainId(), 'chain id mismatch'); + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + SpokeDeployInputs memory inputs = _getDeployInputs(deployer); + report = _deploy(inputs); + vm.stopBroadcast(); + + _logReport(deployer, inputs, report); + } + + function _deploy( + SpokeDeployInputs memory inputs + ) internal returns (BatchReports.SpokeInstanceBatchReport memory) { + return + AaveV4DeployBase.deploySpokeInstanceBatch({ + proxyAdminOwner: inputs.proxyAdminOwner, + authority: inputs.authority, + spokeBytecode: BytecodeHelper.getSpokeBytecode(), + oracleDecimals: inputs.oracleDecimals, + maxUserReservesLimit: inputs.maxUserReservesLimit, + salt: inputs.salt + }); + } + + function _logReport( + address deployer, + SpokeDeployInputs memory inputs, + BatchReports.SpokeInstanceBatchReport memory report + ) internal view { + console.log(string.concat(_deploymentName(), ' deployment complete')); + console.log(' deployer :', deployer); + console.log(' authority :', inputs.authority); + console.log(' proxyAdminOwner :', inputs.proxyAdminOwner); + console.log(' oracleDecimals :', uint256(inputs.oracleDecimals)); + console.log(' maxUserReservesLimit :', uint256(inputs.maxUserReservesLimit)); + console.log(' spokeProxy :', report.spokeProxy); + console.log(' spokeImpl :', report.spokeImplementation); + console.log(' aaveOracle :', report.aaveOracle); + } +} + +/// @title AaveV4DeployUSDGCorrelatedSpoke +/// @author Aave Labs +/// @notice Deploys the USDG correlated-asset Spoke on Ethereum mainnet. +/// @dev Usage (make sure FOUNDRY_LIBRARIES is populated in .env with the LiquidationLogic address): +/// forge clean && forge script \ +/// scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol:AaveV4DeployUSDGCorrelatedSpoke \ +/// --rpc-url mainnet --account --slow (--broadcast --verify) +contract AaveV4DeployUSDGCorrelatedSpoke is AaveV4DeployCorrelatedSpokeBase { + uint256 internal constant _ETHEREUM_CHAIN_ID = 1; + + // AaveV4Ethereum.ACCESS_MANAGER + // https://github.com/aave-dao/aave-address-book/blob/c48a741a10b94202f738d52a09e9c9a8bf18a67d/src/AaveV4Ethereum.sol#L8 + address public constant ACCESS_MANAGER = 0x08aE3BE30958cDd1847ec58fFfd4C451a87fDF01; + // GovernanceV3Ethereum.EXECUTOR_LVL_1 + // https://github.com/aave-dao/aave-address-book/blob/c48a741a10b94202f738d52a09e9c9a8bf18a67d/src/GovernanceV3Ethereum.sol#L56 + address public constant EXECUTOR_LVL_1 = 0x5300A1a15135EA4dc7aD5a167152C01EFc9b192A; + + uint256 internal constant _VERSION = 1; + string internal constant _SPOKE_LABEL = 'USDG_CORRELATED_SPOKE'; + + function spokeSalt(address deployer) public view returns (bytes32) { + bytes32 userSalt = keccak256( + bytes(string.concat('chain ', vm.toString(block.chainid), '_version ', vm.toString(_VERSION))) + ); + bytes32 rootSalt = AaveV4DeployOrchestration._deriveSalt(deployer, userSalt); + return AaveV4DeployOrchestration._deriveChildSalt(rootSalt, 'spoke', _SPOKE_LABEL); + } + + function _getDeployInputs( + address deployer + ) internal view override returns (SpokeDeployInputs memory) { + return + SpokeDeployInputs({ + proxyAdminOwner: EXECUTOR_LVL_1, + authority: ACCESS_MANAGER, + oracleDecimals: DeployConstants.ORACLE_DECIMALS, + maxUserReservesLimit: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT, + salt: spokeSalt(deployer) + }); + } + + function _expectedChainId() internal pure override returns (uint256) { + return _ETHEREUM_CHAIN_ID; + } + + function _deploymentName() internal pure override returns (string memory) { + return 'USDG Correlated Spoke'; + } +} + +/// @title AaveV4DeployMapleCorrelatedSpoke +/// @author Aave Labs +/// @notice Deploys the Maple correlated-asset Spoke (syrupUSDG collateral against USDG) on the Global +/// Dollar Hub, Ethereum mainnet. Reserve, price source, cap and interest rate configuration are +/// performed separately by the Protocol Security Council after deployment. +/// @dev Usage (make sure FOUNDRY_LIBRARIES is populated in .env with the LiquidationLogic address): +/// forge clean && forge script \ +/// scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol:AaveV4DeployMapleCorrelatedSpoke \ +/// --rpc-url mainnet --account --slow (--broadcast --verify) +contract AaveV4DeployMapleCorrelatedSpoke is AaveV4DeployCorrelatedSpokeBase { + uint256 internal constant _ETHEREUM_CHAIN_ID = 1; + + // AaveV4Ethereum.ACCESS_MANAGER + // https://github.com/aave-dao/aave-address-book/blob/c48a741a10b94202f738d52a09e9c9a8bf18a67d/src/AaveV4Ethereum.sol#L8 + address public constant ACCESS_MANAGER = 0x08aE3BE30958cDd1847ec58fFfd4C451a87fDF01; + // Protocol Security Council + // https://etherscan.io/address/0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9 + address public constant PROTOCOL_SECURITY_COUNCIL = 0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9; + + uint256 internal constant _VERSION = 1; + string internal constant _SPOKE_LABEL = 'MAPLE_CORRELATED_SPOKE'; + + function spokeSalt(address deployer) public view returns (bytes32) { + bytes32 userSalt = keccak256( + bytes(string.concat('chain ', vm.toString(block.chainid), '_version ', vm.toString(_VERSION))) + ); + bytes32 rootSalt = AaveV4DeployOrchestration._deriveSalt(deployer, userSalt); + return AaveV4DeployOrchestration._deriveChildSalt(rootSalt, 'spoke', _SPOKE_LABEL); + } + + function _getDeployInputs( + address deployer + ) internal view override returns (SpokeDeployInputs memory) { + return + SpokeDeployInputs({ + proxyAdminOwner: PROTOCOL_SECURITY_COUNCIL, + authority: ACCESS_MANAGER, + oracleDecimals: DeployConstants.ORACLE_DECIMALS, + maxUserReservesLimit: DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT, + salt: spokeSalt(deployer) + }); + } + + function _expectedChainId() internal pure override returns (uint256) { + return _ETHEREUM_CHAIN_ID; + } + + function _deploymentName() internal pure override returns (string memory) { + return 'Maple Correlated Spoke'; + } +} diff --git a/scripts/deploy/AaveV4DeployIsolatedHub.s.sol b/scripts/deploy/AaveV4DeployIsolatedHub.s.sol new file mode 100644 index 000000000..e95ffa6e2 --- /dev/null +++ b/scripts/deploy/AaveV4DeployIsolatedHub.s.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +import {AaveV4DeployBase} from 'src/deployments/orchestration/AaveV4DeployBase.sol'; +import {AaveV4DeployOrchestration} from 'src/deployments/orchestration/AaveV4DeployOrchestration.sol'; +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; +import {BytecodeHelper} from 'src/deployments/utils/libraries/BytecodeHelper.sol'; + +/// @title AaveV4DeployIsolatedHubBase +/// @author Aave Labs +/// @notice Generic base script to deploy a standalone Hub instance (proxy + implementation + interest rate +/// strategy) intended for an isolated market. Concrete scripts override the deploy inputs, the +/// expected chain id and the deployment name for a specific market. +abstract contract AaveV4DeployIsolatedHubBase is Script { + struct HubDeployInputs { + address proxyAdminOwner; + address authority; + bytes32 salt; + } + + /// @dev Override to provide the market-specific deploy inputs. + function _getDeployInputs( + address deployer + ) internal view virtual returns (HubDeployInputs memory); + + /// @dev Override to return the expected chain id for this deployment. + function _expectedChainId() internal view virtual returns (uint256); + + /// @dev Override to return a human-readable name for this hub deployment (used in logs). + function _deploymentName() internal view virtual returns (string memory); + + function run() external virtual returns (BatchReports.HubInstanceBatchReport memory report) { + require(block.chainid == _expectedChainId(), 'chain id mismatch'); + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + HubDeployInputs memory inputs = _getDeployInputs(deployer); + report = _deploy(inputs); + vm.stopBroadcast(); + + _logReport(deployer, inputs, report); + } + + function _deploy( + HubDeployInputs memory inputs + ) internal returns (BatchReports.HubInstanceBatchReport memory) { + return + AaveV4DeployBase.deployHubInstanceBatch({ + proxyAdminOwner: inputs.proxyAdminOwner, + authority: inputs.authority, + hubBytecode: BytecodeHelper.getHubBytecode(), + salt: inputs.salt + }); + } + + function _logReport( + address deployer, + HubDeployInputs memory inputs, + BatchReports.HubInstanceBatchReport memory report + ) internal view { + console.log(string.concat(_deploymentName(), ' deployment complete')); + console.log(' deployer :', deployer); + console.log(' authority :', inputs.authority); + console.log(' proxyAdminOwner :', inputs.proxyAdminOwner); + console.log(' hubProxy :', report.hubProxy); + console.log(' hubImpl :', report.hubImplementation); + console.log(' interestRateStrategy :', report.irStrategy); + } +} + +/// @title AaveV4DeployPendlePaxosIsolatedHub +/// @author Aave Labs +/// @notice Deploys the Pendle Paxos isolated-market Hub on Ethereum mainnet. +/// @dev Usage (FOUNDRY_LIBRARIES is not required, the Hub has no external library dependency): +/// forge clean && forge script \ +/// scripts/deploy/AaveV4DeployIsolatedHub.s.sol:AaveV4DeployPendlePaxosIsolatedHub \ +/// --rpc-url mainnet --account --slow (--broadcast --verify) +contract AaveV4DeployPendlePaxosIsolatedHub is AaveV4DeployIsolatedHubBase { + uint256 internal constant _ETHEREUM_CHAIN_ID = 1; + + // AaveV4Ethereum.ACCESS_MANAGER + // https://github.com/aave-dao/aave-address-book/blob/c48a741a10b94202f738d52a09e9c9a8bf18a67d/src/AaveV4Ethereum.sol#L8 + address public constant ACCESS_MANAGER = 0x08aE3BE30958cDd1847ec58fFfd4C451a87fDF01; + // GovernanceV3Ethereum.EXECUTOR_LVL_1 + // https://github.com/aave-dao/aave-address-book/blob/c48a741a10b94202f738d52a09e9c9a8bf18a67d/src/GovernanceV3Ethereum.sol#L56 + address public constant EXECUTOR_LVL_1 = 0x5300A1a15135EA4dc7aD5a167152C01EFc9b192A; + + uint256 internal constant _VERSION = 1; + string internal constant _HUB_LABEL = 'PENDLE_PAXOS_ISOLATED_HUB'; + + function hubSalt(address deployer) public view returns (bytes32) { + bytes32 userSalt = keccak256( + bytes(string.concat('chain ', vm.toString(block.chainid), '_version ', vm.toString(_VERSION))) + ); + bytes32 rootSalt = AaveV4DeployOrchestration._deriveSalt(deployer, userSalt); + return AaveV4DeployOrchestration._deriveChildSalt(rootSalt, 'hub', _HUB_LABEL); + } + + function _getDeployInputs( + address deployer + ) internal view override returns (HubDeployInputs memory) { + return + HubDeployInputs({ + proxyAdminOwner: EXECUTOR_LVL_1, + authority: ACCESS_MANAGER, + salt: hubSalt(deployer) + }); + } + + function _expectedChainId() internal pure override returns (uint256) { + return _ETHEREUM_CHAIN_ID; + } + + function _deploymentName() internal pure override returns (string memory) { + return 'Pendle Paxos Isolated Hub'; + } +} diff --git a/scripts/deploy/AaveV4DeployTokenizationSpoke.s.sol b/scripts/deploy/AaveV4DeployTokenizationSpoke.s.sol new file mode 100644 index 000000000..2039b9c14 --- /dev/null +++ b/scripts/deploy/AaveV4DeployTokenizationSpoke.s.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: LicenseRef-BUSL +pragma solidity ^0.8.0; + +import {Script} from 'forge-std/Script.sol'; +import {console2 as console} from 'forge-std/console2.sol'; + +import {AaveV4DeployBase} from 'src/deployments/orchestration/AaveV4DeployBase.sol'; +import {AaveV4DeployOrchestration} from 'src/deployments/orchestration/AaveV4DeployOrchestration.sol'; +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; + +/// @title AaveV4DeployTokenizationSpokeBase +/// @author Aave Labs +/// @notice Generic base script to deploy standalone TokenizationSpoke instances (proxy + implementation) +/// for existing Hubs. Concrete scripts override the deploy inputs, the expected chain id and the +/// deployment name for a specific market. Registration on the Hub (`addSpoke`) is not part of the +/// deployment and is performed separately by governance or the Protocol Security Council. +abstract contract AaveV4DeployTokenizationSpokeBase is Script { + struct TokenizationSpokeDeployInputs { + address hub; + address underlying; + address proxyAdminOwner; + string shareName; + string shareSymbol; + bytes32 salt; + } + + /// @dev Override to provide the market-specific deploy inputs, one entry per TokenizationSpoke. + function _getDeployInputs( + address deployer + ) internal view virtual returns (TokenizationSpokeDeployInputs[] memory); + + /// @dev Override to return the expected chain id for this deployment. + function _expectedChainId() internal view virtual returns (uint256); + + /// @dev Override to return a human-readable name for this deployment (used in logs). + function _deploymentName() internal view virtual returns (string memory); + + function run() + external + virtual + returns (BatchReports.TokenizationSpokeBatchReport[] memory reports) + { + require(block.chainid == _expectedChainId(), 'chain id mismatch'); + + vm.startBroadcast(); + (, address deployer, ) = vm.readCallers(); + TokenizationSpokeDeployInputs[] memory inputs = _getDeployInputs(deployer); + reports = _deploy(inputs); + vm.stopBroadcast(); + + _logReports(deployer, inputs, reports); + } + + function _deploy( + TokenizationSpokeDeployInputs[] memory inputs + ) internal returns (BatchReports.TokenizationSpokeBatchReport[] memory reports) { + reports = new BatchReports.TokenizationSpokeBatchReport[](inputs.length); + for (uint256 i; i < inputs.length; ++i) { + reports[i] = AaveV4DeployBase.deployTokenizationSpokeBatch({ + hub: inputs[i].hub, + underlying: inputs[i].underlying, + proxyAdminOwner: inputs[i].proxyAdminOwner, + shareName: inputs[i].shareName, + shareSymbol: inputs[i].shareSymbol, + salt: inputs[i].salt + }); + } + } + + function _logReports( + address deployer, + TokenizationSpokeDeployInputs[] memory inputs, + BatchReports.TokenizationSpokeBatchReport[] memory reports + ) internal view { + console.log(string.concat(_deploymentName(), ' deployment complete')); + console.log(' deployer :', deployer); + for (uint256 i; i < reports.length; ++i) { + console.log(string.concat(' ', inputs[i].shareSymbol)); + console.log(' hub :', inputs[i].hub); + console.log(' underlying :', inputs[i].underlying); + console.log(' proxyAdminOwner :', inputs[i].proxyAdminOwner); + console.log(' tokenizationSpoke :', reports[i].tokenizationSpokeProxy); + console.log(' tokenizationSpokeImpl:', reports[i].tokenizationSpokeImplementation); + } + } +} + +/// @title AaveV4DeployGlobalDollarTokenizationSpokes +/// @author Aave Labs +/// @notice Deploys replacement TokenizationSpokes (USDC, USDT, PT_USDG_24SEP2026) for the Global Dollar +/// Hub on Ethereum mainnet. The previously deployed instances are deprecated as their ProxyAdmins +/// are owned by the PayloadsController and can never exercise ownership; the replacements set the +/// ProxyAdmin owner to the Protocol Security Council, matching all other mainnet +/// TokenizationSpokes. Activation on the Hub is performed separately by the Protocol Security +/// Council. +/// @dev Usage: +/// forge clean && forge script \ +/// scripts/deploy/AaveV4DeployTokenizationSpoke.s.sol:AaveV4DeployGlobalDollarTokenizationSpokes \ +/// --rpc-url mainnet --account --slow (--broadcast --verify) +contract AaveV4DeployGlobalDollarTokenizationSpokes is AaveV4DeployTokenizationSpokeBase { + uint256 internal constant _ETHEREUM_CHAIN_ID = 1; + + // AaveV4EthereumHubs.PAXOS_HUB + // https://github.com/aave-dao/aave-address-book/blob/7e444a1e73b538fd0b9e093e5156401d6fccca7d/src/AaveV4Ethereum.sol#L38 + address public constant GLOBAL_DOLLAR_HUB = 0x62d63197660c080236193CA60b70E49A08E90368; + // Protocol Security Council + // https://etherscan.io/address/0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9 + address public constant PROTOCOL_SECURITY_COUNCIL = 0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9; + + address public constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address public constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address public constant PT_USDG_24SEP2026 = 0xc1906aeCf868749a2DeE203F59b904c0cf212140; + + uint256 internal constant _VERSION = 1; + + function tokenizationSpokeSalt( + address deployer, + string memory label + ) public view returns (bytes32) { + bytes32 userSalt = keccak256( + bytes(string.concat('chain ', vm.toString(block.chainid), '_version ', vm.toString(_VERSION))) + ); + bytes32 rootSalt = AaveV4DeployOrchestration._deriveSalt(deployer, userSalt); + return AaveV4DeployOrchestration._deriveChildSalt(rootSalt, 'tokenization-spoke', label); + } + + function _getDeployInputs( + address deployer + ) internal view override returns (TokenizationSpokeDeployInputs[] memory inputs) { + inputs = new TokenizationSpokeDeployInputs[](3); + inputs[0] = TokenizationSpokeDeployInputs({ + hub: GLOBAL_DOLLAR_HUB, + underlying: USDC, + proxyAdminOwner: PROTOCOL_SECURITY_COUNCIL, + shareName: 'Wrapped Aave Global Dollar USDC', + shareSymbol: 'waGlobalDollarUSDC', + salt: tokenizationSpokeSalt(deployer, 'waGlobalDollarUSDC') + }); + inputs[1] = TokenizationSpokeDeployInputs({ + hub: GLOBAL_DOLLAR_HUB, + underlying: USDT, + proxyAdminOwner: PROTOCOL_SECURITY_COUNCIL, + shareName: 'Wrapped Aave Global Dollar USDT', + shareSymbol: 'waGlobalDollarUSDT', + salt: tokenizationSpokeSalt(deployer, 'waGlobalDollarUSDT') + }); + inputs[2] = TokenizationSpokeDeployInputs({ + hub: GLOBAL_DOLLAR_HUB, + underlying: PT_USDG_24SEP2026, + proxyAdminOwner: PROTOCOL_SECURITY_COUNCIL, + shareName: 'Wrapped Aave Global Dollar PT_USDG_24SEP2026', + shareSymbol: 'waGlobalDollarPT_USDG_24SEP2026', + salt: tokenizationSpokeSalt(deployer, 'waGlobalDollarPT_USDG_24SEP2026') + }); + } + + function _expectedChainId() internal pure override returns (uint256) { + return _ETHEREUM_CHAIN_ID; + } + + function _deploymentName() internal pure override returns (string memory) { + return 'Global Dollar TokenizationSpokes'; + } +} diff --git a/tests/deployments/fork/GlobalDollarTokenizationSpokesActivation.t.sol b/tests/deployments/fork/GlobalDollarTokenizationSpokesActivation.t.sol new file mode 100644 index 000000000..f7c721b64 --- /dev/null +++ b/tests/deployments/fork/GlobalDollarTokenizationSpokesActivation.t.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; + +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {SafeERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; + +import {ProxyHelper} from 'tests/utils/ProxyHelper.sol'; + +/// @dev Validates the Global Dollar TokenizationSpoke replacement on a devnet where the Security Council +/// activation batch (`output/global-dollar-tokenization-spokes-activation.json`) has been executed. +/// Skipped unless TENDERLY_DEVNET_RPC is set. +contract GlobalDollarTokenizationSpokesActivationTest is Test { + using SafeERC20 for IERC20; + + address internal constant GLOBAL_DOLLAR_HUB = 0x62d63197660c080236193CA60b70E49A08E90368; + address internal constant PROTOCOL_SECURITY_COUNCIL = 0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9; + address internal constant PAYLOADS_CONTROLLER = 0xdAbad81aF85554E9ae636395611C58F7eC1aAEc5; + address internal constant EXECUTOR_LVL_1 = 0x5300A1a15135EA4dc7aD5a167152C01EFc9b192A; + + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address internal constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + address internal constant PT_USDG_24SEP2026 = 0xc1906aeCf868749a2DeE203F59b904c0cf212140; + + uint256 internal constant PT_USDG_ASSET_ID = 0; + uint256 internal constant USDC_ASSET_ID = 1; + uint256 internal constant USDT_ASSET_ID = 2; + + address internal constant NEW_WA_GLOBAL_DOLLAR_USDC = 0xaed7c529bD2878170B61C758DfAa215AC7a4FD07; + address internal constant NEW_WA_GLOBAL_DOLLAR_USDT = 0xa0e97e45C2f89003730E467Bd484fA3eEcE5B4Cf; + address internal constant NEW_WA_GLOBAL_DOLLAR_PT_USDG = + 0x7Df10B4A01350D2A1d95cFbE7c9207d7210A2663; + + address internal constant OLD_WA_GLOBAL_DOLLAR_USDC = 0x4131E0B2E7AFeCEAf3d3b4225aA61a3B2B7535b8; + address internal constant OLD_WA_GLOBAL_DOLLAR_USDT = 0x8Dabe53E8cB991c57f0307F6f419E6D469b0deAA; + address internal constant OLD_WA_GLOBAL_DOLLAR_PT_USDG = + 0x27eF1140364948A0E30E248297FfDFE5a4091ec4; + + address internal constant OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER = + 0x9cCf93089cb14F94BAeB8822F8CeFfd91Bd71649; + + uint40 internal constant ADD_CAP = 13_000_000; + + address internal USER = makeAddr('USER'); + + bool internal _devnetAvailable; + + modifier onlyDevnet() { + vm.skip(!_devnetAvailable, 'TENDERLY_DEVNET_RPC not set'); + _; + } + + function setUp() public { + string memory rpc = vm.envOr('TENDERLY_DEVNET_RPC', string('')); + if (bytes(rpc).length == 0) return; + vm.createSelectFork(rpc); + _devnetAvailable = true; + } + + function test_newSpokes_proxyAdminOwnership() public onlyDevnet { + address[3] memory spokes = [ + NEW_WA_GLOBAL_DOLLAR_USDC, + NEW_WA_GLOBAL_DOLLAR_USDT, + NEW_WA_GLOBAL_DOLLAR_PT_USDG + ]; + for (uint256 i; i < spokes.length; ++i) { + address owner = Ownable(ProxyHelper.getProxyAdmin(spokes[i])).owner(); + assertEq(owner, PROTOCOL_SECURITY_COUNCIL); + assertNotEq(owner, PAYLOADS_CONTROLLER); + assertNotEq(owner, EXECUTOR_LVL_1); + } + } + + function test_newSpokes_activationState() public onlyDevnet { + _assertSpokeConfig({ + assetId: USDC_ASSET_ID, + spoke: NEW_WA_GLOBAL_DOLLAR_USDC, + underlying: USDC, + expectedAddCap: ADD_CAP + }); + _assertSpokeConfig({ + assetId: USDT_ASSET_ID, + spoke: NEW_WA_GLOBAL_DOLLAR_USDT, + underlying: USDT, + expectedAddCap: ADD_CAP + }); + _assertSpokeConfig({ + assetId: PT_USDG_ASSET_ID, + spoke: NEW_WA_GLOBAL_DOLLAR_PT_USDG, + underlying: PT_USDG_24SEP2026, + expectedAddCap: 0 + }); + } + + function test_oldSpokes_remainFrozen() public onlyDevnet { + _assertSpokeConfig({ + assetId: USDC_ASSET_ID, + spoke: OLD_WA_GLOBAL_DOLLAR_USDC, + underlying: USDC, + expectedAddCap: 0 + }); + _assertSpokeConfig({ + assetId: USDT_ASSET_ID, + spoke: OLD_WA_GLOBAL_DOLLAR_USDT, + underlying: USDT, + expectedAddCap: 0 + }); + _assertSpokeConfig({ + assetId: PT_USDG_ASSET_ID, + spoke: OLD_WA_GLOBAL_DOLLAR_PT_USDG, + underlying: PT_USDG_24SEP2026, + expectedAddCap: 0 + }); + } + + function test_newUsdcSpoke_depositAndRedeem() public onlyDevnet { + _depositAndRedeem(NEW_WA_GLOBAL_DOLLAR_USDC, USDC, 1000e6); + } + + function test_newUsdtSpoke_depositAndRedeem() public onlyDevnet { + _depositAndRedeem(NEW_WA_GLOBAL_DOLLAR_USDT, USDT, 1000e6); + } + + function test_newUsdcSpoke_depositAboveCapReverts() public onlyDevnet { + uint256 amount = (uint256(ADD_CAP) + 1) * 1e6; + deal(USDC, USER, amount); + vm.startPrank(USER); + IERC20(USDC).forceApprove(NEW_WA_GLOBAL_DOLLAR_USDC, amount); + vm.expectRevert(abi.encodeWithSelector(IHub.AddCapExceeded.selector, ADD_CAP)); + ITokenizationSpoke(NEW_WA_GLOBAL_DOLLAR_USDC).deposit(amount, USER); + vm.stopPrank(); + } + + function test_newPtSpoke_depositReverts_zeroCap() public onlyDevnet { + deal(PT_USDG_24SEP2026, USER, 100e6); + vm.startPrank(USER); + IERC20(PT_USDG_24SEP2026).forceApprove(NEW_WA_GLOBAL_DOLLAR_PT_USDG, 100e6); + vm.expectRevert(abi.encodeWithSelector(IHub.AddCapExceeded.selector, 0)); + ITokenizationSpoke(NEW_WA_GLOBAL_DOLLAR_PT_USDG).deposit(100e6, USER); + vm.stopPrank(); + } + + function test_oldSpokes_depositsBlocked() public onlyDevnet { + deal(USDC, USER, 100e6); + deal(USDT, USER, 100e6); + vm.startPrank(USER); + + IERC20(USDC).forceApprove(OLD_WA_GLOBAL_DOLLAR_USDC, 100e6); + vm.expectRevert(abi.encodeWithSelector(IHub.AddCapExceeded.selector, 0)); + ITokenizationSpoke(OLD_WA_GLOBAL_DOLLAR_USDC).deposit(100e6, USER); + + IERC20(USDT).forceApprove(OLD_WA_GLOBAL_DOLLAR_USDT, 100e6); + vm.expectRevert(abi.encodeWithSelector(IHub.AddCapExceeded.selector, 0)); + ITokenizationSpoke(OLD_WA_GLOBAL_DOLLAR_USDT).deposit(100e6, USER); + + vm.stopPrank(); + } + + function test_oldUsdcSpoke_withdrawalsOpen() public onlyDevnet { + ITokenizationSpoke oldSpoke = ITokenizationSpoke(OLD_WA_GLOBAL_DOLLAR_USDC); + uint256 shares = oldSpoke.balanceOf(OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER); + assertGt(shares, 0); + + uint256 balanceBefore = IERC20(USDC).balanceOf(OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER); + vm.prank(OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER); + uint256 assets = oldSpoke.redeem( + shares, + OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER, + OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER + ); + + assertGt(assets, 0); + assertEq( + IERC20(USDC).balanceOf(OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER), + balanceBefore + assets, + 'holder should be able to fully exit the frozen spoke' + ); + assertEq(oldSpoke.balanceOf(OLD_WA_GLOBAL_DOLLAR_USDC_HOLDER), 0); + } + + function _depositAndRedeem(address spoke, address underlying, uint256 amount) internal { + deal(underlying, USER, amount); + vm.startPrank(USER); + IERC20(underlying).forceApprove(spoke, amount); + + uint256 shares = ITokenizationSpoke(spoke).deposit(amount, USER); + assertGt(shares, 0); + assertEq(ITokenizationSpoke(spoke).balanceOf(USER), shares); + + uint256 assets = ITokenizationSpoke(spoke).redeem(shares, USER, USER); + vm.stopPrank(); + + assertEq(ITokenizationSpoke(spoke).balanceOf(USER), 0); + assertApproxEqAbs(assets, amount, 2, 'redeem should return the deposited amount'); + assertEq(IERC20(underlying).balanceOf(USER), assets); + } + + function _assertSpokeConfig( + uint256 assetId, + address spoke, + address underlying, + uint40 expectedAddCap + ) internal view { + IHub hub = IHub(GLOBAL_DOLLAR_HUB); + assertTrue(hub.isSpokeListed(assetId, spoke)); + assertEq(ITokenizationSpoke(spoke).asset(), underlying); + + IHub.SpokeConfig memory config = hub.getSpokeConfig(assetId, spoke); + assertEq(config.addCap, expectedAddCap); + assertEq(config.drawCap, 0); + assertEq(config.riskPremiumThreshold, 0); + assertTrue(config.active); + assertFalse(config.halted); + } +} diff --git a/tests/scripts/AaveV4DeployGlobalDollarTokenizationSpokes.t.sol b/tests/scripts/AaveV4DeployGlobalDollarTokenizationSpokes.t.sol new file mode 100644 index 000000000..563b4923a --- /dev/null +++ b/tests/scripts/AaveV4DeployGlobalDollarTokenizationSpokes.t.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; + +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {ITokenizationSpoke} from 'src/spoke/interfaces/ITokenizationSpoke.sol'; + +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; + +import {ProxyHelper} from 'tests/utils/ProxyHelper.sol'; +import {AaveV4DeployGlobalDollarTokenizationSpokes} from 'scripts/deploy/AaveV4DeployTokenizationSpoke.s.sol'; + +contract AaveV4DeployGlobalDollarTokenizationSpokesTest is Test { + // deprecated instances whose ProxyAdmins are owned by the PayloadsController + address internal constant DEPRECATED_WA_GLOBAL_DOLLAR_USDC = + 0x4131E0B2E7AFeCEAf3d3b4225aA61a3B2B7535b8; + address internal constant DEPRECATED_WA_GLOBAL_DOLLAR_USDT = + 0x8Dabe53E8cB991c57f0307F6f419E6D469b0deAA; + address internal constant DEPRECATED_WA_GLOBAL_DOLLAR_PT_USDG = + 0x27eF1140364948A0E30E248297FfDFE5a4091ec4; + // GovernanceV3Ethereum.PAYLOADS_CONTROLLER + address internal constant PAYLOADS_CONTROLLER = 0xdAbad81aF85554E9ae636395611C58F7eC1aAEc5; + // GovernanceV3Ethereum.EXECUTOR_LVL_1 + address internal constant EXECUTOR_LVL_1 = 0x5300A1a15135EA4dc7aD5a167152C01EFc9b192A; + // AaveV4EthereumTokenizationSpokes.CORE_USDC_TOKENIZATION_SPOKE + address internal constant CORE_USDC_TOKENIZATION_SPOKE = + 0x531E90a2376902DE8915789Fcc1075e3B0c153E7; + + AaveV4DeployGlobalDollarTokenizationSpokes internal _script; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl('mainnet'), 25544900); + _script = new AaveV4DeployGlobalDollarTokenizationSpokes(); + } + + function test_run_deploysTokenizationSpokes() public { + BatchReports.TokenizationSpokeBatchReport[] memory reports = _script.run(); + assertEq(reports.length, 3); + + address[3] memory underlyings = [_script.USDC(), _script.USDT(), _script.PT_USDG_24SEP2026()]; + string[3] memory names = [ + 'Wrapped Aave Global Dollar USDC', + 'Wrapped Aave Global Dollar USDT', + 'Wrapped Aave Global Dollar PT_USDG_24SEP2026' + ]; + string[3] memory symbols = [ + 'waGlobalDollarUSDC', + 'waGlobalDollarUSDT', + 'waGlobalDollarPT_USDG_24SEP2026' + ]; + address[3] memory deprecated = [ + DEPRECATED_WA_GLOBAL_DOLLAR_USDC, + DEPRECATED_WA_GLOBAL_DOLLAR_USDT, + DEPRECATED_WA_GLOBAL_DOLLAR_PT_USDG + ]; + + for (uint256 i; i < reports.length; ++i) { + address proxy = reports[i].tokenizationSpokeProxy; + assertGt(proxy.code.length, 0); + assertGt(reports[i].tokenizationSpokeImplementation.code.length, 0); + assertNotEq(proxy, deprecated[i]); + + assertEq(ITokenizationSpoke(proxy).hub(), _script.GLOBAL_DOLLAR_HUB()); + assertEq(ITokenizationSpoke(proxy).asset(), underlyings[i]); + assertEq(ITokenizationSpoke(proxy).name(), names[i]); + assertEq(ITokenizationSpoke(proxy).symbol(), symbols[i]); + + address proxyAdminOwner = Ownable(ProxyHelper.getProxyAdmin(proxy)).owner(); + assertEq( + proxyAdminOwner, + _script.PROTOCOL_SECURITY_COUNCIL(), + 'ProxyAdmin owner should be the Protocol Security Council' + ); + assertNotEq( + proxyAdminOwner, + PAYLOADS_CONTROLLER, + 'ProxyAdmin owner must never be the PayloadsController' + ); + assertNotEq( + proxyAdminOwner, + EXECUTOR_LVL_1, + 'ProxyAdmin owner should not be the DAO executor' + ); + } + } + + function test_run_revertsOffMainnet_fuzz(uint64 wrongChainId) public { + vm.assume(wrongChainId != 1); + vm.chainId(wrongChainId); + + vm.expectRevert('chain id mismatch'); + _script.run(); + } + + function test_constantsMatchOnchainState() public view { + assertEq(_script.GLOBAL_DOLLAR_HUB(), 0x62d63197660c080236193CA60b70E49A08E90368); + + // the intended owner is the owner of the healthy mainnet TokenizationSpoke ProxyAdmins + assertEq( + _script.PROTOCOL_SECURITY_COUNCIL(), + Ownable(ProxyHelper.getProxyAdmin(CORE_USDC_TOKENIZATION_SPOKE)).owner() + ); + + // deploy inputs must match the deprecated instances they replace + assertEq(ITokenizationSpoke(DEPRECATED_WA_GLOBAL_DOLLAR_USDC).asset(), _script.USDC()); + assertEq(ITokenizationSpoke(DEPRECATED_WA_GLOBAL_DOLLAR_USDT).asset(), _script.USDT()); + assertEq( + ITokenizationSpoke(DEPRECATED_WA_GLOBAL_DOLLAR_PT_USDG).asset(), + _script.PT_USDG_24SEP2026() + ); + assertEq( + ITokenizationSpoke(DEPRECATED_WA_GLOBAL_DOLLAR_USDC).hub(), + _script.GLOBAL_DOLLAR_HUB() + ); + assertEq( + ITokenizationSpoke(DEPRECATED_WA_GLOBAL_DOLLAR_USDT).hub(), + _script.GLOBAL_DOLLAR_HUB() + ); + assertEq( + ITokenizationSpoke(DEPRECATED_WA_GLOBAL_DOLLAR_PT_USDG).hub(), + _script.GLOBAL_DOLLAR_HUB() + ); + } + + function test_tokenizationSpokeSaltMatchesOrchestrationFormula_fuzz( + address deployer + ) public view { + bytes32 orchestrationSalt = keccak256('AAVE_V4'); + bytes32 userSalt = keccak256(bytes('chain 1_version 1')); + bytes32 expectedRoot = bytes32(bytes20(deployer)) | + (keccak256(abi.encode(orchestrationSalt, userSalt)) >> 160); + bytes32 expected = keccak256( + abi.encode(expectedRoot, 'tokenization-spoke', 'waGlobalDollarUSDC') + ); + + assertEq(_script.tokenizationSpokeSalt(deployer, 'waGlobalDollarUSDC'), expected); + } +} diff --git a/tests/scripts/AaveV4DeployMapleCorrelatedSpoke.t.sol b/tests/scripts/AaveV4DeployMapleCorrelatedSpoke.t.sol new file mode 100644 index 000000000..b58539338 --- /dev/null +++ b/tests/scripts/AaveV4DeployMapleCorrelatedSpoke.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; + +import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; +import {Ownable} from 'src/dependencies/openzeppelin/Ownable.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IPriceOracle} from 'src/spoke/interfaces/IPriceOracle.sol'; + +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; +import {DeployConstants} from 'src/deployments/utils/libraries/DeployConstants.sol'; + +import {ProxyHelper} from 'tests/utils/ProxyHelper.sol'; + +import {AaveV4DeployMapleCorrelatedSpoke} from 'scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol'; + +contract AaveV4DeployMapleCorrelatedSpokeTest is Test { + AaveV4DeployMapleCorrelatedSpoke internal _script; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl('mainnet'), 25092080); + _script = new AaveV4DeployMapleCorrelatedSpoke(); + } + + function test_run_deploysSpoke() public { + BatchReports.SpokeInstanceBatchReport memory report = _script.run(); + + assertGt(report.spokeProxy.code.length, 0); + assertGt(report.spokeImplementation.code.length, 0); + assertGt(report.aaveOracle.code.length, 0); + + assertEq(IAccessManaged(report.spokeProxy).authority(), _script.ACCESS_MANAGER()); + assertEq(ISpoke(report.spokeProxy).ORACLE(), report.aaveOracle); + assertEq(IPriceOracle(report.aaveOracle).spoke(), report.spokeProxy); + assertEq( + uint256(IPriceOracle(report.aaveOracle).decimals()), + uint256(DeployConstants.ORACLE_DECIMALS) + ); + assertEq( + uint256(ISpoke(report.spokeProxy).MAX_USER_RESERVES_LIMIT()), + uint256(DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ); + } + + function test_run_proxyAdminOwnedBySecurityCouncil() public { + BatchReports.SpokeInstanceBatchReport memory report = _script.run(); + + address owner = Ownable(ProxyHelper.getProxyAdmin(report.spokeProxy)).owner(); + assertEq(owner, _script.PROTOCOL_SECURITY_COUNCIL()); + } + + // Same salt does NOT collide: each batch deploys a fresh CREATE-allocated AaveOracle whose + // address is in SpokeInstance's init code, so the CREATE2 spoke address differs across calls. + // Operator must avoid running the script twice — no on-chain safety check. + function test_run_repeatCallsProduceDistinctSpokes() public { + BatchReports.SpokeInstanceBatchReport memory a = _script.run(); + BatchReports.SpokeInstanceBatchReport memory b = _script.run(); + assertNotEq(a.spokeProxy, b.spokeProxy); + assertNotEq(a.aaveOracle, b.aaveOracle); + } + + function test_run_revertsOffMainnet_fuzz(uint64 wrongChainId) public { + vm.assume(wrongChainId != 1); + vm.chainId(wrongChainId); + + vm.expectRevert('chain id mismatch'); + _script.run(); + } + + function test_constantsMatchAddressBook() public view { + assertEq(_script.ACCESS_MANAGER(), 0x08aE3BE30958cDd1847ec58fFfd4C451a87fDF01); + assertEq(_script.PROTOCOL_SECURITY_COUNCIL(), 0x187AAE17d4931310B3fc75743e7F16Bdc9eD77e9); + } + + function test_spokeSaltMatchesOrchestrationFormula_fuzz(address deployer) public view { + bytes32 orchestrationSalt = keccak256('AAVE_V4'); + bytes32 userSalt = keccak256(bytes('chain 1_version 1')); + bytes32 expectedRoot = bytes32(bytes20(deployer)) | + (keccak256(abi.encode(orchestrationSalt, userSalt)) >> 160); + bytes32 expected = keccak256(abi.encode(expectedRoot, 'spoke', 'MAPLE_CORRELATED_SPOKE')); + + assertEq(_script.spokeSalt(deployer), expected); + } +} diff --git a/tests/scripts/AaveV4DeployUSDGCorrelatedSpoke.t.sol b/tests/scripts/AaveV4DeployUSDGCorrelatedSpoke.t.sol new file mode 100644 index 000000000..d607fc46f --- /dev/null +++ b/tests/scripts/AaveV4DeployUSDGCorrelatedSpoke.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from 'forge-std/Test.sol'; + +import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; +import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IPriceOracle} from 'src/spoke/interfaces/IPriceOracle.sol'; + +import {BatchReports} from 'src/deployments/libraries/BatchReports.sol'; +import {DeployConstants} from 'src/deployments/utils/libraries/DeployConstants.sol'; + +import {AaveV4DeployUSDGCorrelatedSpoke} from 'scripts/deploy/AaveV4DeployCorrelatedSpoke.s.sol'; + +contract AaveV4DeployUSDGCorrelatedSpokeTest is Test { + AaveV4DeployUSDGCorrelatedSpoke internal _script; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl('mainnet'), 25092080); + _script = new AaveV4DeployUSDGCorrelatedSpoke(); + } + + function test_run_deploysSpoke() public { + BatchReports.SpokeInstanceBatchReport memory report = _script.run(); + + assertGt(report.spokeProxy.code.length, 0); + assertGt(report.spokeImplementation.code.length, 0); + assertGt(report.aaveOracle.code.length, 0); + + assertEq(IAccessManaged(report.spokeProxy).authority(), _script.ACCESS_MANAGER()); + assertEq(ISpoke(report.spokeProxy).ORACLE(), report.aaveOracle); + assertEq(IPriceOracle(report.aaveOracle).spoke(), report.spokeProxy); + assertEq( + uint256(IPriceOracle(report.aaveOracle).decimals()), + uint256(DeployConstants.ORACLE_DECIMALS) + ); + assertEq( + uint256(ISpoke(report.spokeProxy).MAX_USER_RESERVES_LIMIT()), + uint256(DeployConstants.MAX_ALLOWED_USER_RESERVES_LIMIT) + ); + } + + // Same salt does NOT collide: each batch deploys a fresh CREATE-allocated AaveOracle whose + // address is in SpokeInstance's init code, so the CREATE2 spoke address differs across calls. + // Operator must avoid running the script twice — no on-chain safety check. + function test_run_repeatCallsProduceDistinctSpokes() public { + BatchReports.SpokeInstanceBatchReport memory a = _script.run(); + BatchReports.SpokeInstanceBatchReport memory b = _script.run(); + assertNotEq(a.spokeProxy, b.spokeProxy); + assertNotEq(a.aaveOracle, b.aaveOracle); + } + + function test_run_revertsOffMainnet_fuzz(uint64 wrongChainId) public { + vm.assume(wrongChainId != 1); + vm.chainId(wrongChainId); + + vm.expectRevert('chain id mismatch'); + _script.run(); + } + + function test_constantsMatchAddressBook() public view { + assertEq(_script.ACCESS_MANAGER(), 0x08aE3BE30958cDd1847ec58fFfd4C451a87fDF01); + assertEq(_script.EXECUTOR_LVL_1(), 0x5300A1a15135EA4dc7aD5a167152C01EFc9b192A); + } + + function test_spokeSaltMatchesOrchestrationFormula_fuzz(address deployer) public view { + bytes32 orchestrationSalt = keccak256('AAVE_V4'); + bytes32 userSalt = keccak256(bytes('chain 1_version 1')); + bytes32 expectedRoot = bytes32(bytes20(deployer)) | + (keccak256(abi.encode(orchestrationSalt, userSalt)) >> 160); + bytes32 expected = keccak256(abi.encode(expectedRoot, 'spoke', 'USDG_CORRELATED_SPOKE')); + + assertEq(_script.spokeSalt(deployer), expected); + } +}