diff --git a/.env.example b/.env.example index 063dcde7..f65a83f8 100644 --- a/.env.example +++ b/.env.example @@ -46,7 +46,7 @@ export SONEIUM_TESTNET_VERIFIER_URL="https://soneium-minato.blockscout.com/api" # Seismic # Verifier URL must be the FULL socialscan command_api path, not the bare host. -export SEISMIC_TESTNET_RPC_URL=https://testnet-1.seismictest.net/rpc +export SEISMIC_TESTNET_RPC_URL=https://testnet-2.seismictest.net/rpc export SEISMIC_TESTNET_VERIFIER_URL=https://api.socialscan.io/seismic-testnet/v1/explorer/command_api/contract # Set when Seismic mainnet is live (deploy targets fail closed until then). # export SEISMIC_MAINNET_RPC_URL= diff --git a/audits/README.md b/audits/README.md deleted file mode 100644 index 8fb1b6a0..00000000 --- a/audits/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Prior Audit Reports — coverage on this branch - -The seven reports in this directory audited `main`'s **unshielded** code, compiled with -**stock solc 0.8.26** (last common ancestor: merge-base `87a2f42`). **None of them cover -the Seismic branch**: not the shielded `MYieldToOne` rewrite (`suint256` -balances/allowances, gated reads, infra allowlist, encrypted events), nor any bytecode -produced by the ssolc compiler fork — including the "unchanged" contracts -(SwapFacility, the other extensions), whose source is identical modulo pragma but whose -deployed bytecode on Seismic is a fresh, unaudited ssolc/mercury compilation. Scope for -the Seismic audit is summarized in the [repo README](../README.md#audit-scope). - -| Report | Auditor | Covers | -| ------------------------------------------------------------------- | --------------------- | -------------------------------------------------- | -| `Certora MExtension Security Assessment Final Report.pdf` | Certora | `main` M Extensions (unshielded, stock solc) | -| `ChainSecurity_M0_M_Extensions_audit.pdf` | ChainSecurity | `main` M Extensions (unshielded, stock solc) | -| `ChainSecurity_M0_M_Extensions_audit_draft.pdf` | ChainSecurity (draft) | `main` M Extensions (unshielded, stock solc) | -| `Guardian Audits M0 Extensions Report Aug 5.pdf` | Guardian Audits | `main` M Extensions (unshielded, stock solc) | -| `GuardianAudits_M0_MExtensions_report.pdf` | Guardian Audits | `main` M Extensions (unshielded, stock solc) | -| `JMI/2025_12_10_Final_M0_Collaborative_Audit_Report_1765332345.pdf` | Collaborative (JMI) | `main` JMIExtension (unshielded, stock solc) | -| `JMI/M0_EVM-M_Extensions_Review_report.pdf` | JMI review | `main` M Extensions / JMI (unshielded, stock solc) | diff --git a/lib/common b/lib/common index a1fbf37b..703ba8ae 160000 --- a/lib/common +++ b/lib/common @@ -1 +1 @@ -Subproject commit a1fbf37b0ab10b0f8e71223793a0fd6af77b527d +Subproject commit 703ba8ae663195c58247fcb843f96746497bd405 diff --git a/script/Config.sol b/script/Config.sol index 534ed0df..37290f21 100644 --- a/script/Config.sol +++ b/script/Config.sol @@ -31,6 +31,7 @@ contract Config { address freezeManager; address yieldRecipientManager; address pauser; + address allowlistAdmin; } struct YieldToOneForcedTransferConfig { @@ -43,6 +44,7 @@ contract Config { address yieldRecipientManager; address pauser; address forcedTransferManager; + address allowlistAdmin; } struct JMIExtensionConfig { @@ -55,6 +57,7 @@ contract Config { address freezeManager; address pauser; address yieldRecipientManager; + address allowlistAdmin; } struct YieldToAllWithFeeConfig { @@ -97,7 +100,8 @@ contract Config { uint256 public constant BASE_SEPOLIA_CHAIN_ID = 84532; uint256 public constant SEISMIC_TESTNET_CHAIN_ID = 5124; - address public constant DEPLOYER = 0x12b1A4226ba7D9Ad492779c924b0fC00BDCb6217; + address public constant DEPLOYER = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; + address public constant TESTNET_OWNER = 0x12b1A4226ba7D9Ad492779c924b0fC00BDCb6217; address public constant M_TOKEN = 0x866A2BF4E572CbcF37D5071A7a58503Bfb36be1b; address public constant WRAPPED_M_TOKEN = 0x437cc33344a0B27A429f795ff6B469C72698B291; diff --git a/script/ConfigureSeismicExtension.s.sol b/script/ConfigureSeismicExtension.s.sol index 5f261c7a..90b785ba 100644 --- a/script/ConfigureSeismicExtension.s.sol +++ b/script/ConfigureSeismicExtension.s.sol @@ -35,7 +35,10 @@ contract ConfigureSeismicExtension is ScriptBase { vm.stopBroadcast(); - console.log("Extension approved on SwapFacility:", extension); + for (uint256 i; i < infra.length; ++i) { + require(IMYieldToOne(extension).isAllowlisted(infra[i]), "allowlist failed"); + } + console.log("Allowlisted Portal:", infra[0]); if (limitOrderProtocol != address(0)) console.log("Allowlisted LimitOrderProtocol:", limitOrderProtocol); } diff --git a/script/decrypt-transfer-event.py b/script/decrypt-transfer-event.py index 84b5ac1c..a0df38cd 100755 --- a/script/decrypt-transfer-event.py +++ b/script/decrypt-transfer-event.py @@ -20,8 +20,11 @@ --peer-pubkey the OTHER party's 33-byte compressed public key --from event `from` topic (sender / approver) — bound into the nonce --to event `to` topic (recipient / spender) — bound into the nonce - --nonce-counter the contract's encryptedEventNonce value used for this emit (1-based) - --scan try counters 1..max-n until the GCM tag verifies (when the counter is unknown) + --nonce-counter the encryptedEventNonce for this emit (1-based) — read it straight from the + `EncryptedAmountNonce(from, to, nonce)` event the contract emits alongside the + ciphertext; preferred over --scan + --scan legacy fallback: try counters 1..max-n until the GCM tag verifies, for ciphertexts + from logs that predate the EncryptedAmountNonce event (when the counter is unknown) --ciphertext event payload hex (ciphertext || 16-byte tag) --self-test verify against vectors pinned from the Seismic precompiles (sforge mercury EVM) diff --git a/script/deploy/DeployBase.s.sol b/script/deploy/DeployBase.s.sol index 5393e3f3..bf4fdd73 100644 --- a/script/deploy/DeployBase.s.sol +++ b/script/deploy/DeployBase.s.sol @@ -138,16 +138,7 @@ contract DeployBase is DeployHelpers, ScriptBase { proxy = _deployCreate3TransparentProxy( implementation, extensionConfig.admin, - abi.encodeWithSelector( - MYieldToOne.initialize.selector, - extensionConfig.extensionName, - extensionConfig.symbol, - extensionConfig.yieldRecipient, - extensionConfig.admin, - extensionConfig.freezeManager, - extensionConfig.yieldRecipientManager, - extensionConfig.pauser - ), + _yieldToOneInitData(extensionConfig), _computeSalt(deployer, extensionConfig.contractName) ); @@ -165,17 +156,7 @@ contract DeployBase is DeployHelpers, ScriptBase { proxy = _deployCreate3TransparentProxy( implementation, extensionConfig.admin, - abi.encodeWithSelector( - MYieldToOneForcedTransfer.initialize.selector, - extensionConfig.extensionName, - extensionConfig.symbol, - extensionConfig.yieldRecipient, - extensionConfig.admin, - extensionConfig.freezeManager, - extensionConfig.yieldRecipientManager, - extensionConfig.pauser, - extensionConfig.forcedTransferManager - ), + _yieldToOneForcedTransferInitData(extensionConfig), _computeSalt(deployer, extensionConfig.contractName) ); @@ -193,23 +174,62 @@ contract DeployBase is DeployHelpers, ScriptBase { proxy = _deployCreate3TransparentProxy( implementation, extensionConfig.admin, - abi.encodeWithSelector( - JMIExtension.initialize.selector, - extensionConfig.extensionName, - extensionConfig.symbol, - extensionConfig.yieldRecipient, - extensionConfig.admin, - extensionConfig.assetCapManager, - extensionConfig.freezeManager, - extensionConfig.pauser, - extensionConfig.yieldRecipientManager - ), + _jmiExtensionInitData(extensionConfig), _computeSalt(deployer, extensionConfig.contractName) ); proxyAdmin = Upgrades.getAdminAddress(proxy); } + function _yieldToOneInitData(YieldToOneConfig memory c) private pure returns (bytes memory) { + return + abi.encodeWithSelector( + MYieldToOne.initialize.selector, + c.extensionName, + c.symbol, + c.yieldRecipient, + c.admin, + c.freezeManager, + c.yieldRecipientManager, + c.pauser, + c.allowlistAdmin + ); + } + + function _yieldToOneForcedTransferInitData( + YieldToOneForcedTransferConfig memory c + ) private pure returns (bytes memory) { + return + abi.encodeWithSelector( + MYieldToOneForcedTransfer.initialize.selector, + c.extensionName, + c.symbol, + c.yieldRecipient, + c.admin, + c.freezeManager, + c.yieldRecipientManager, + c.pauser, + c.forcedTransferManager, + c.allowlistAdmin + ); + } + + function _jmiExtensionInitData(JMIExtensionConfig memory c) private pure returns (bytes memory) { + return + abi.encodeWithSelector( + JMIExtension.initialize.selector, + c.extensionName, + c.symbol, + c.yieldRecipient, + c.admin, + c.assetCapManager, + c.freezeManager, + c.pauser, + c.yieldRecipientManager, + c.allowlistAdmin + ); + } + function _deployYieldToAllWithFee( address deployer, YieldToAllWithFeeConfig memory extensionConfig diff --git a/script/deploy/DeployJMIExtension.s.sol b/script/deploy/DeployJMIExtension.s.sol index f67476a1..fe837aa9 100644 --- a/script/deploy/DeployJMIExtension.s.sol +++ b/script/deploy/DeployJMIExtension.s.sol @@ -19,6 +19,7 @@ contract DeployJMIExtension is DeployBase { extensionConfig.freezeManager = vm.envAddress("FREEZE_MANAGER"); extensionConfig.pauser = vm.envAddress("PAUSER"); extensionConfig.yieldRecipientManager = vm.envAddress("YIELD_RECIPIENT_MANAGER"); + extensionConfig.allowlistAdmin = vm.envAddress("ALLOWLIST_ADMIN"); // Verify predicted address (if PREDICTED_ADDRESS env var is set) if (_shouldVerifyPredictedAddress()) { diff --git a/script/deploy/DeployYieldToOne.s.sol b/script/deploy/DeployYieldToOne.s.sol index bb41ab76..6686dd1c 100644 --- a/script/deploy/DeployYieldToOne.s.sol +++ b/script/deploy/DeployYieldToOne.s.sol @@ -18,6 +18,7 @@ contract DeployYieldToOne is DeployBase { extensionConfig.freezeManager = vm.envAddress("FREEZE_MANAGER"); extensionConfig.yieldRecipientManager = vm.envAddress("YIELD_RECIPIENT_MANAGER"); extensionConfig.pauser = vm.envAddress("PAUSER"); + extensionConfig.allowlistAdmin = vm.envAddress("ALLOWLIST_ADMIN"); // Verify predicted address (if PREDICTED_ADDRESS env var is set) if (_shouldVerifyPredictedAddress()) { diff --git a/script/deploy/DeployYieldToOneForcedTransfer.s.sol b/script/deploy/DeployYieldToOneForcedTransfer.s.sol index 9866a885..79408002 100644 --- a/script/deploy/DeployYieldToOneForcedTransfer.s.sol +++ b/script/deploy/DeployYieldToOneForcedTransfer.s.sol @@ -19,6 +19,7 @@ contract DeployYieldToOneForcedTransfer is DeployBase { extensionConfig.yieldRecipientManager = vm.envAddress("YIELD_RECIPIENT_MANAGER"); extensionConfig.pauser = vm.envAddress("PAUSER"); extensionConfig.forcedTransferManager = vm.envAddress("FORCED_TRANSFER_MANAGER"); + extensionConfig.allowlistAdmin = vm.envAddress("ALLOWLIST_ADMIN"); // Verify predicted address (if PREDICTED_ADDRESS env var is set) if (_shouldVerifyPredictedAddress()) { diff --git a/src/projects/jmi/JMIExtension.sol b/src/projects/jmi/JMIExtension.sol index 9a3cb1bb..a90ed24e 100644 --- a/src/projects/jmi/JMIExtension.sol +++ b/src/projects/jmi/JMIExtension.sol @@ -105,6 +105,7 @@ contract JMIExtension is IJMIExtension, JMIExtensionLayout, MYieldToOne { * @param freezeManager The address of a freeze manager. * @param pauser The address of a pauser. * @param yieldRecipientManager The address of a yield recipient manager. + * @param allowlistAdmin The address granted the allowlist admin role (manages the allowlist-manager role). */ function initialize( string memory name, @@ -114,7 +115,8 @@ contract JMIExtension is IJMIExtension, JMIExtensionLayout, MYieldToOne { address assetCapManager, address freezeManager, address pauser, - address yieldRecipientManager + address yieldRecipientManager, + address allowlistAdmin ) public virtual initializer { __JMIExtension_init( name, @@ -124,7 +126,8 @@ contract JMIExtension is IJMIExtension, JMIExtensionLayout, MYieldToOne { assetCapManager, freezeManager, pauser, - yieldRecipientManager + yieldRecipientManager, + allowlistAdmin ); } @@ -138,6 +141,7 @@ contract JMIExtension is IJMIExtension, JMIExtensionLayout, MYieldToOne { * @param freezeManager The address of a freeze manager. * @param pauser The address of a pauser. * @param yieldRecipientManager The address of a yield recipient setter. + * @param allowlistAdmin The address granted the allowlist admin role (manages the allowlist-manager role). */ function __JMIExtension_init( string memory name, @@ -147,11 +151,21 @@ contract JMIExtension is IJMIExtension, JMIExtensionLayout, MYieldToOne { address assetCapManager, address freezeManager, address pauser, - address yieldRecipientManager + address yieldRecipientManager, + address allowlistAdmin ) internal onlyInitializing { if (assetCapManager == address(0)) revert ZeroAssetCapManager(); - __MYieldToOne_init(name, symbol, yieldRecipient, admin, freezeManager, yieldRecipientManager, pauser); + __MYieldToOne_init( + name, + symbol, + yieldRecipient, + admin, + freezeManager, + yieldRecipientManager, + pauser, + allowlistAdmin + ); _grantRole(ASSET_CAP_MANAGER_ROLE, assetCapManager); } diff --git a/src/projects/yieldToOne/MYieldToOne.sol b/src/projects/yieldToOne/MYieldToOne.sol index 88e2f92f..580a5333 100644 --- a/src/projects/yieldToOne/MYieldToOne.sol +++ b/src/projects/yieldToOne/MYieldToOne.sol @@ -3,8 +3,10 @@ pragma solidity ^0.8.26; import { ERC20ExtendedUpgradeable } from "../../../lib/common/src/ERC20ExtendedUpgradeable.sol"; +import { ERC3009Upgradeable } from "../../../lib/common/src/ERC3009Upgradeable.sol"; import { IERC20 } from "../../../lib/common/src/interfaces/IERC20.sol"; import { IERC20Extended } from "../../../lib/common/src/interfaces/IERC20Extended.sol"; +import { IERC3009 } from "../../../lib/common/src/interfaces/IERC3009.sol"; import { IMYieldToOne } from "./interfaces/IMYieldToOne.sol"; @@ -22,7 +24,7 @@ abstract contract MYieldToOneStorageLayout { mapping(address account => mapping(address spender => suint256 allowance)) shieldedAllowance; mapping(address account => bool isAllowlisted) allowlist; mapping(address account => bytes publicKey) publicKeys; - bytes _contractPublicKey; + bytes contractPublicKey; // Set once via `setContractKey` (TxSeismic 0x4A only); no getter exposes it. sbytes32 contractPrivateKey; // Monotonic counter feeding the per-emit AES-GCM nonce; pre-incremented so nonces never repeat. @@ -52,6 +54,12 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free /// @inheritdoc IMYieldToOne bytes32 public constant YIELD_RECIPIENT_MANAGER_ROLE = keccak256("YIELD_RECIPIENT_MANAGER_ROLE"); + /// @inheritdoc IMYieldToOne + bytes32 public constant ALLOWLIST_MANAGER_ROLE = keccak256("ALLOWLIST_MANAGER_ROLE"); + + /// @inheritdoc IMYieldToOne + bytes32 public constant ALLOWLIST_ADMIN_ROLE = keccak256("ALLOWLIST_ADMIN_ROLE"); + /* ============ Constructor ============ */ /** @@ -74,6 +82,7 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free * @param freezeManager The address of a freeze manager. * @param yieldRecipientManager The address of a yield recipient setter. * @param pauser The address of a pauser. + * @param allowlistAdmin The address granted the allowlist admin role (manages the allowlist-manager role). */ function initialize( string memory name, @@ -82,9 +91,19 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free address admin, address freezeManager, address yieldRecipientManager, - address pauser + address pauser, + address allowlistAdmin ) public virtual initializer { - __MYieldToOne_init(name, symbol, yieldRecipient_, admin, freezeManager, yieldRecipientManager, pauser); + __MYieldToOne_init( + name, + symbol, + yieldRecipient_, + admin, + freezeManager, + yieldRecipientManager, + pauser, + allowlistAdmin + ); } /** @@ -96,6 +115,7 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free * @param freezeManager The address of a freeze manager. * @param yieldRecipientManager The address of a yield recipient setter. * @param pauser The address of a pauser. + * @param allowlistAdmin The address granted the allowlist admin role (manages the allowlist-manager role). */ function __MYieldToOne_init( string memory name, @@ -104,10 +124,12 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free address admin, address freezeManager, address yieldRecipientManager, - address pauser + address pauser, + address allowlistAdmin ) internal onlyInitializing { if (yieldRecipientManager == address(0)) revert ZeroYieldRecipientManager(); if (admin == address(0)) revert ZeroAdmin(); + if (allowlistAdmin == address(0)) revert ZeroAllowlistAdmin(); __MExtension_init(name, symbol); __Freezable_init(freezeManager); @@ -115,8 +137,13 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free _setYieldRecipient(yieldRecipient_); + // The allowlist manager (who calls `setAllowlisted`) is administered by a dedicated allowlist + // admin rather than the DEFAULT_ADMIN_ROLE, so allowlist control can be delegated independently. + _setRoleAdmin(ALLOWLIST_MANAGER_ROLE, ALLOWLIST_ADMIN_ROLE); + _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(YIELD_RECIPIENT_MANAGER_ROLE, yieldRecipientManager); + _grantRole(ALLOWLIST_ADMIN_ROLE, allowlistAdmin); } /* ============ Interactive Functions ============ */ @@ -145,12 +172,15 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free } /// @inheritdoc IMYieldToOne - function setAllowlisted(address account, bool status) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + function setAllowlisted(address account, bool status) external virtual onlyRole(ALLOWLIST_MANAGER_ROLE) { _setAllowlisted(account, status); } /// @inheritdoc IMYieldToOne - function setAllowlisted(address[] calldata accounts, bool status) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + function setAllowlisted( + address[] calldata accounts, + bool status + ) external virtual onlyRole(ALLOWLIST_MANAGER_ROLE) { for (uint256 i; i < accounts.length; ++i) { _setAllowlisted(accounts[i], status); } @@ -171,7 +201,7 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free if (bytes32($.contractPrivateKey) != bytes32(0)) revert ContractKeyAlreadySet(); $.contractPrivateKey = privateKey; - $._contractPublicKey = publicKey; + $.contractPublicKey = publicKey; emit ContractKeySet(publicKey); } @@ -258,6 +288,93 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free revert UseShieldedApprove(); } + // ERC-3009 transfer/receive hard-code a plaintext `uint256 value`, so they cannot be shielded. Disable + // them (mirroring the `permit` reverts above) to keep amounts off the public, encryption-bypassing path. + + /// @inheritdoc IERC3009 + function transferWithAuthorization( + address /* from */, + address /* to */, + uint256 /* value */, + uint256 /* validAfter */, + uint256 /* validBefore */, + bytes32 /* nonce */, + bytes memory /* signature */ + ) external pure override(ERC3009Upgradeable, IERC3009) { + revert UseShieldedTransfer(); + } + + /// @inheritdoc IERC3009 + function transferWithAuthorization( + address /* from */, + address /* to */, + uint256 /* value */, + uint256 /* validAfter */, + uint256 /* validBefore */, + bytes32 /* nonce */, + bytes32 /* r */, + bytes32 /* vs */ + ) external pure override(ERC3009Upgradeable, IERC3009) { + revert UseShieldedTransfer(); + } + + /// @inheritdoc IERC3009 + function transferWithAuthorization( + address /* from */, + address /* to */, + uint256 /* value */, + uint256 /* validAfter */, + uint256 /* validBefore */, + bytes32 /* nonce */, + uint8 /* v */, + bytes32 /* r */, + bytes32 /* s */ + ) external pure override(ERC3009Upgradeable, IERC3009) { + revert UseShieldedTransfer(); + } + + /// @inheritdoc IERC3009 + function receiveWithAuthorization( + address /* from */, + address /* to */, + uint256 /* value */, + uint256 /* validAfter */, + uint256 /* validBefore */, + bytes32 /* nonce */, + bytes memory /* signature */ + ) external pure override(ERC3009Upgradeable, IERC3009) { + revert UseShieldedTransfer(); + } + + /// @inheritdoc IERC3009 + function receiveWithAuthorization( + address /* from */, + address /* to */, + uint256 /* value */, + uint256 /* validAfter */, + uint256 /* validBefore */, + bytes32 /* nonce */, + bytes32 /* r */, + bytes32 /* vs */ + ) external pure override(ERC3009Upgradeable, IERC3009) { + revert UseShieldedTransfer(); + } + + /// @inheritdoc IERC3009 + function receiveWithAuthorization( + address /* from */, + address /* to */, + uint256 /* value */, + uint256 /* validAfter */, + uint256 /* validBefore */, + bytes32 /* nonce */, + uint8 /* v */, + bytes32 /* r */, + bytes32 /* s */ + ) external pure override(ERC3009Upgradeable, IERC3009) { + revert UseShieldedTransfer(); + } + /* ============ View/Pure Functions ============ */ /// @inheritdoc IERC20 @@ -313,7 +430,7 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free /// @inheritdoc IMYieldToOne function contractPublicKey() external view returns (bytes memory) { - return _getMYieldToOneStorageLocation()._contractPublicKey; + return _getMYieldToOneStorageLocation().contractPublicKey; } /* ============ Hooks For Internal Interactive Functions ============ */ @@ -464,17 +581,18 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free function _shieldedTransfer(address sender, address recipient, suint256 amount, bool encryptEmit) internal { uint256 amount_ = uint256(amount); + if (amount_ == 0) return; + _revertIfInvalidRecipient(recipient); _beforeTransfer(sender, recipient, amount_); if (encryptEmit) { - emit Transfer(sender, recipient, _encryptAmount(sender, recipient, amount)); + (bytes32 encryptKeyHash, bytes memory ciphertext) = _encryptAmount(sender, recipient, amount); + emit Transfer(sender, recipient, encryptKeyHash, ciphertext); } else { emit Transfer(sender, recipient, amount_); } - if (amount_ == 0) return; - // NOTE: Branching on a shielded value leaks a 1-bit comparison via revert-vs-success; // accepted — inherent to ERC20 insufficient-balance semantics (ssolc 10311). if (_getMYieldToOneStorageLocation().balanceOf[sender] < amount) { @@ -490,24 +608,33 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free * @param from The counterparty address bound into the nonce derivation (sender / approver). * @param to The account whose registered key the ciphertext is encrypted to. * @param amount The shielded amount to encrypt. - * @return The AES-GCM ciphertext, or empty bytes if `to` has not registered a key. + * @return encryptKeyHash `keccak256` of `to`'s registered public key, or `bytes32(0)` if `to` has no key. + * @return ciphertext The AES-GCM ciphertext, or empty bytes if `to` has not registered a key. */ - function _encryptAmount(address from, address to, suint256 amount) internal returns (bytes memory) { + function _encryptAmount( + address from, + address to, + suint256 amount + ) internal returns (bytes32 encryptKeyHash, bytes memory ciphertext) { MYieldToOneStorageStruct storage $ = _getMYieldToOneStorageLocation(); if (bytes32($.contractPrivateKey) == bytes32(0)) revert ContractKeyNotSet(); bytes memory pubKey = $.publicKeys[to]; - if (pubKey.length == 0) return bytes(""); + if (pubKey.length == 0) return (bytes32(0), bytes("")); + + encryptKeyHash = keccak256(pubKey); uint256 n = ++$.encryptedEventNonce; + emit EncryptedAmountNonce(from, to, n); + sbytes32 sharedSecret = _ecdh($.contractPrivateKey, pubKey); sbytes32 aesKey = _hkdf(sharedSecret); bytes12 nonce = bytes12(keccak256(abi.encode(from, to, n))); - return _aesGcmEncrypt(aesKey, nonce, abi.encode(uint256(amount))); + ciphertext = _aesGcmEncrypt(aesKey, nonce, abi.encode(uint256(amount))); } /** @@ -563,7 +690,8 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free _getMYieldToOneStorageLocation().shieldedAllowance[account][spender] = amount; if (encryptEmit) { - emit Approval(account, spender, _encryptAmount(account, spender, amount)); + (bytes32 encryptKeyHash, bytes memory ciphertext) = _encryptAmount(account, spender, amount); + emit Approval(account, spender, encryptKeyHash, ciphertext); } else { emit Approval(account, spender, amount_); } diff --git a/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol b/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol index dfd45c41..bbceae48 100644 --- a/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol +++ b/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol @@ -40,6 +40,7 @@ contract MYieldToOneForcedTransfer is MYieldToOne, ForcedTransferable { * @param yieldRecipientManager The address of a yield recipient setter. * @param pauser The address of a pauser. * @param forcedTransferManager The address of a force transfer manager. + * @param allowlistAdmin The address granted the allowlist admin role (manages the allowlist-manager role). */ function initialize( string memory name, @@ -49,7 +50,8 @@ contract MYieldToOneForcedTransfer is MYieldToOne, ForcedTransferable { address freezeManager, address yieldRecipientManager, address pauser, - address forcedTransferManager + address forcedTransferManager, + address allowlistAdmin ) public virtual initializer { __MYieldToOneForcedTransfer_init( name, @@ -59,7 +61,8 @@ contract MYieldToOneForcedTransfer is MYieldToOne, ForcedTransferable { freezeManager, yieldRecipientManager, pauser, - forcedTransferManager + forcedTransferManager, + allowlistAdmin ); } @@ -75,6 +78,7 @@ contract MYieldToOneForcedTransfer is MYieldToOne, ForcedTransferable { * @param yieldRecipientManager The address of a yield recipient setter. * @param pauser The address of a pauser. * @param forcedTransferManager The address of a force transfer manager. + * @param allowlistAdmin The address granted the allowlist admin role (manages the allowlist-manager role). */ function __MYieldToOneForcedTransfer_init( string memory name, @@ -84,11 +88,21 @@ contract MYieldToOneForcedTransfer is MYieldToOne, ForcedTransferable { address freezeManager, address yieldRecipientManager, address pauser, - address forcedTransferManager + address forcedTransferManager, + address allowlistAdmin ) internal onlyInitializing { if (forcedTransferManager == address(0)) revert ZeroForcedTransferManager(); - __MYieldToOne_init(name, symbol, yieldRecipient_, admin, freezeManager, yieldRecipientManager, pauser); + __MYieldToOne_init( + name, + symbol, + yieldRecipient_, + admin, + freezeManager, + yieldRecipientManager, + pauser, + allowlistAdmin + ); __ForcedTransferable_init(forcedTransferManager); } diff --git a/src/projects/yieldToOne/interfaces/IMYieldToOne.sol b/src/projects/yieldToOne/interfaces/IMYieldToOne.sol index 6362e26b..038ab635 100644 --- a/src/projects/yieldToOne/interfaces/IMYieldToOne.sol +++ b/src/projects/yieldToOne/interfaces/IMYieldToOne.sol @@ -34,19 +34,45 @@ interface IMYieldToOne { * @dev Distinct `topic0` from the inherited `Transfer(address,address,uint256)` — indexers MUST track both. * @param from The address transferring the tokens. * @param to The address receiving the tokens. + * @param encryptKeyHash `keccak256` of `to`'s registered public key — the key `encryptedAmount` is encrypted + * to — or `bytes32(0)` when `to` has no registered key (empty `encryptedAmount`). Indexed + * so a recipient can tell which registered key a ciphertext is bound to (its key may have + * been overwritten via `registerPublicKey`) and select the matching private key to decrypt. * @param encryptedAmount AES-GCM ciphertext of the amount; empty bytes if `to` has no registered key. */ - event Transfer(address indexed from, address indexed to, bytes encryptedAmount); + event Transfer(address indexed from, address indexed to, bytes32 indexed encryptKeyHash, bytes encryptedAmount); /** * @notice Emitted by user-path shielded approvals (the `suint256` overload); the allowance is * encrypted to the spender's registered key. * @dev Distinct `topic0` from the inherited `Approval(address,address,uint256)` — indexers MUST track both. - * @param account The account granting the allowance. - * @param spender The account allowed to spend on behalf of `account`. + * @param owner The account granting the allowance. + * @param spender The account allowed to spend on behalf of `owner`. + * @param encryptKeyHash `keccak256` of `spender`'s registered public key — the key `encryptedAmount` is + * encrypted to — or `bytes32(0)` when `spender` has no registered key (empty + * `encryptedAmount`). Indexed so a spender can tell which registered key a ciphertext is + * bound to (its key may have been overwritten via `registerPublicKey`) and decrypt. * @param encryptedAmount AES-GCM ciphertext of the allowance; empty bytes if `spender` has no registered key. */ - event Approval(address indexed account, address indexed spender, bytes encryptedAmount); + event Approval( + address indexed owner, + address indexed spender, + bytes32 indexed encryptKeyHash, + bytes encryptedAmount + ); + + /** + * @notice Emitted with each encrypted `Transfer`/`Approval` whose counterparty has a registered key, + * publishing the counter mixed into that ciphertext's AES-GCM nonce so off-chain decryptors + * read the exact nonce directly instead of brute-force scanning the counter space. + * @dev Bound to the same `(from, to)` hashed into `keccak256(from, to, nonce)`; emitted immediately + * before its `Transfer`/`Approval`. Not emitted on the no-registered-key fallback (empty + * ciphertext, no counter consumed) or on the plaintext infra paths. + * @param from The counterparty bound into the nonce — sender for transfers, owner for approvals. + * @param to The account whose registered key the amount is encrypted to (recipient / spender). + * @param nonce The `encryptedEventNonce` counter mixed into this ciphertext's AES-GCM nonce. + */ + event EncryptedAmountNonce(address indexed from, address indexed to, uint256 nonce); /** * @notice Emitted when the contract's encrypted-event keypair is installed; only the public key is logged. @@ -71,6 +97,9 @@ interface IMYieldToOne { /// @notice Emitted in initializer if Admin is 0x0. error ZeroAdmin(); + /// @notice Emitted in initializer if Allowlist Admin is 0x0. + error ZeroAllowlistAdmin(); + /// @notice Emitted when a gated read (`balanceOf` / `allowance`) is called by an unauthorized account. error Unauthorized(); @@ -122,7 +151,7 @@ interface IMYieldToOne { /** * @notice Adds or removes `account` from the infra allowlist. - * @dev MUST only be callable by the DEFAULT_ADMIN_ROLE. + * @dev MUST only be callable by the ALLOWLIST_MANAGER_ROLE. * @dev Allowlisted addresses MUST be audited M0 infra contracts, never EOAs or contracts re-exposing `balanceOf`. * @dev Grants native `approve` (as spender) and `transferFrom` (as caller) paths and ungated `balanceOf` reads. * @dev SHOULD revert if `account` is 0x0. SHOULD return early if the status is unchanged. @@ -133,7 +162,7 @@ interface IMYieldToOne { /** * @notice Adds or removes a batch of accounts from the infra allowlist. - * @dev MUST only be callable by the DEFAULT_ADMIN_ROLE. + * @dev MUST only be callable by the ALLOWLIST_MANAGER_ROLE. * @dev Reverts atomically (the whole batch) if any `accounts` entry is the zero address. * @param accounts The addresses whose allowlist status is being set. * @param status The new allowlist status applied to every address in `accounts`. @@ -142,7 +171,7 @@ interface IMYieldToOne { /** * @notice Shielded ERC20 transfer of `amount` tokens to `recipient`. - * @dev Emits the encrypted-bytes `Transfer(address,address,bytes)` overload. + * @dev Emits the encrypted-bytes `Transfer(address,address,bytes32,bytes)` overload. * @param recipient The address receiving the tokens. * @param amount The shielded amount to transfer. * @return Whether or not the transfer was successful. @@ -151,7 +180,7 @@ interface IMYieldToOne { /** * @notice Shielded ERC20 approval of `spender` for `amount` of the caller's tokens. - * @dev Emits the encrypted-bytes `Approval(address,address,bytes)` overload. + * @dev Emits the encrypted-bytes `Approval(address,address,bytes32,bytes)` overload. * @param spender The address allowed to spend on behalf of `msg.sender`. * @param amount The shielded allowance; `suint256(type(uint256).max)` is an infinite, non-decrementing allowance. * @return Whether or not the approval was successful. @@ -160,7 +189,7 @@ interface IMYieldToOne { /** * @notice Shielded ERC20 transferFrom; reads and decrements the allowance in shielded space. - * @dev Emits the encrypted-bytes `Transfer(address,address,bytes)` overload. + * @dev Emits the encrypted-bytes `Transfer(address,address,bytes32,bytes)` overload. * @param sender The address whose tokens are being moved. * @param recipient The address receiving the tokens. * @param amount The shielded amount to transfer. @@ -192,6 +221,12 @@ interface IMYieldToOne { /// @notice The role that can manage the yield recipient. function YIELD_RECIPIENT_MANAGER_ROLE() external view returns (bytes32); + /// @notice The role that can set the infra allowlist (via `setAllowlisted`). + function ALLOWLIST_MANAGER_ROLE() external view returns (bytes32); + + /// @notice The role that administers the `ALLOWLIST_MANAGER_ROLE` (grants/revokes it). + function ALLOWLIST_ADMIN_ROLE() external view returns (bytes32); + /// @notice The amount of accrued yield. function yield() external view returns (uint256); diff --git a/test/harness/JMIExtensionHarness.sol b/test/harness/JMIExtensionHarness.sol index fa040347..3f3561b8 100644 --- a/test/harness/JMIExtensionHarness.sol +++ b/test/harness/JMIExtensionHarness.sol @@ -16,7 +16,8 @@ contract JMIExtensionHarness is JMIExtension { address assetCapManager, address freezeManager, address pauser, - address yieldRecipientManager + address yieldRecipientManager, + address allowlistAdmin ) public override initializer { super.initialize( name, @@ -26,7 +27,8 @@ contract JMIExtensionHarness is JMIExtension { assetCapManager, freezeManager, pauser, - yieldRecipientManager + yieldRecipientManager, + allowlistAdmin ); } diff --git a/test/harness/MYieldToOneForcedTransferHarness.sol b/test/harness/MYieldToOneForcedTransferHarness.sol index 4f045d15..1ce40efa 100644 --- a/test/harness/MYieldToOneForcedTransferHarness.sol +++ b/test/harness/MYieldToOneForcedTransferHarness.sol @@ -16,7 +16,8 @@ contract MYieldToOneForcedTransferHarness is MYieldToOneForcedTransfer { address admin, address freezeManager, address pauser, - address forcedTransferManager + address forcedTransferManager, + address allowlistAdmin ) public override initializer { super.initialize( name, @@ -26,7 +27,8 @@ contract MYieldToOneForcedTransferHarness is MYieldToOneForcedTransfer { admin, freezeManager, pauser, - forcedTransferManager + forcedTransferManager, + allowlistAdmin ); } diff --git a/test/harness/MYieldToOneHarness.sol b/test/harness/MYieldToOneHarness.sol index b6bc0e2d..d20cec8c 100644 --- a/test/harness/MYieldToOneHarness.sol +++ b/test/harness/MYieldToOneHarness.sol @@ -15,9 +15,19 @@ contract MYieldToOneHarness is MYieldToOne { address admin, address freezeManager, address yieldRecipientManager, - address pauser + address pauser, + address allowlistAdmin ) public override initializer { - super.initialize(name, symbol, yieldRecipient, admin, freezeManager, yieldRecipientManager, pauser); + super.initialize( + name, + symbol, + yieldRecipient, + admin, + freezeManager, + yieldRecipientManager, + pauser, + allowlistAdmin + ); } function setBalanceOf(address account, uint256 amount) external { diff --git a/test/integration/MExtensionSystem.t.sol b/test/integration/MExtensionSystem.t.sol index 316dd749..67965120 100644 --- a/test/integration/MExtensionSystem.t.sol +++ b/test/integration/MExtensionSystem.t.sol @@ -75,12 +75,16 @@ contract MExtensionSystemIntegrationTests is BaseIntegrationTest { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ), mExtensionDeployOptions ) ); + vm.prank(allowlistAdmin); + mYieldToOne.grantRole(ALLOWLIST_MANAGER_ROLE, admin); + mYieldFee = MYieldFeeHarness( Upgrades.deployTransparentProxy( "MYieldFeeHarness.sol:MYieldFeeHarness", diff --git a/test/integration/MYieldToOne.t.sol b/test/integration/MYieldToOne.t.sol index 34790e25..4ad262ce 100644 --- a/test/integration/MYieldToOne.t.sol +++ b/test/integration/MYieldToOne.t.sol @@ -37,7 +37,8 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ), mExtensionDeployOptions ) diff --git a/test/integration/SwapFacility.t.sol b/test/integration/SwapFacility.t.sol index c1640d22..685239fc 100644 --- a/test/integration/SwapFacility.t.sol +++ b/test/integration/SwapFacility.t.sol @@ -52,7 +52,8 @@ contract SwapFacilityIntegrationTest is BaseIntegrationTest, UpgradeBase { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ), mExtensionDeployOptions ) @@ -91,7 +92,8 @@ contract SwapFacilityIntegrationTest is BaseIntegrationTest, UpgradeBase { assetCapManager, freezeManager, pauser, - yieldRecipientManager + yieldRecipientManager, + allowlistAdmin ), mExtensionDeployOptions ) diff --git a/test/integration/UniswapV3SwapAdapter.t.sol b/test/integration/UniswapV3SwapAdapter.t.sol index 549d934e..affeade2 100644 --- a/test/integration/UniswapV3SwapAdapter.t.sol +++ b/test/integration/UniswapV3SwapAdapter.t.sol @@ -41,7 +41,8 @@ contract UniswapV3SwapAdapterIntegrationTest is BaseIntegrationTest { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ), mExtensionDeployOptions ) diff --git a/test/integration/seismic/MExtensionSystemSeismic.t.sol b/test/integration/seismic/MExtensionSystemSeismic.t.sol index 6c5dba88..d64f1501 100644 --- a/test/integration/seismic/MExtensionSystemSeismic.t.sol +++ b/test/integration/seismic/MExtensionSystemSeismic.t.sol @@ -32,7 +32,7 @@ import { BaseUnitTest } from "../../utils/BaseUnitTest.sol"; /// RELATIONAL assertions — conservation, monotonicity, typed reverts — not the fork suite's /// hardcoded mainnet yield constants. contract MExtensionSystemSeismicIntegrationTests is BaseUnitTest { - bytes32 internal constant TRANSFER_BYTES_TOPIC = keccak256("Transfer(address,address,bytes)"); + bytes32 internal constant TRANSFER_BYTES_TOPIC = keccak256("Transfer(address,address,bytes32,bytes)"); // Non-round index so the sibling extension's principal math runs on a realistic value. uint128 internal constant _M_INDEX = 1_100000068703; @@ -68,7 +68,8 @@ contract MExtensionSystemSeismicIntegrationTests is BaseUnitTest { freezeManager, yieldRecipientManager, pauser, - forcedTransferManager + forcedTransferManager, + allowlistAdmin ), mExtensionDeployOptions ) @@ -206,7 +207,7 @@ contract MExtensionSystemSeismicIntegrationTests is BaseUnitTest { vm.prank(alice); mYieldToOne.transfer(bob, suint256(amount_)); - // A real encrypted `Transfer(address,address,bytes)` is emitted to the registered recipient, + // A real encrypted `Transfer(address,address,bytes32,bytes)` is emitted to the registered recipient, // with non-empty ciphertext, and the per-emit nonce advances. bytes memory ciphertext_ = _extractPayload(TRANSFER_BYTES_TOPIC, alice, bob); assertGt(ciphertext_.length, 0); diff --git a/test/integration/seismic/MYieldToOneSeismic.t.sol b/test/integration/seismic/MYieldToOneSeismic.t.sol index b671337c..e0d4208f 100644 --- a/test/integration/seismic/MYieldToOneSeismic.t.sol +++ b/test/integration/seismic/MYieldToOneSeismic.t.sol @@ -29,8 +29,8 @@ contract MYieldToOneSeismicIntegrationTests is BaseUnitTest { // 33 bytes, valid 0x02 prefix, x = 5 is not on secp256k1 (5^3 + 7 is a non-residue). bytes internal constant OFF_CURVE_KEY = hex"020000000000000000000000000000000000000000000000000000000000000005"; - bytes32 internal constant TRANSFER_BYTES_TOPIC = keccak256("Transfer(address,address,bytes)"); - bytes32 internal constant APPROVAL_BYTES_TOPIC = keccak256("Approval(address,address,bytes)"); + bytes32 internal constant TRANSFER_BYTES_TOPIC = keccak256("Transfer(address,address,bytes32,bytes)"); + bytes32 internal constant APPROVAL_BYTES_TOPIC = keccak256("Approval(address,address,bytes32,bytes)"); MYieldToOneHarness public mYieldToOne; @@ -54,7 +54,8 @@ contract MYieldToOneSeismicIntegrationTests is BaseUnitTest { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ), mExtensionDeployOptions ) diff --git a/test/integration/seismic/README.md b/test/integration/seismic/README.md index 0749fbb3..2ca4d712 100644 --- a/test/integration/seismic/README.md +++ b/test/integration/seismic/README.md @@ -78,5 +78,7 @@ suite and `script/decrypt-transfer-event.py --self-test` both assert these vecto Event key = `0x68(0x65(contractPriv, recipientPub))` — a double HKDF. Event nonce = first 12 bytes of `keccak256(abi.encode(from, to, nonceCounter))`, -`nonceCounter` pre-incremented per encrypted emit (1-based, shared across Transfer/Approval). +`nonceCounter` pre-incremented per encrypted emit (1-based, shared across Transfer/Approval) and +published on-chain via the `EncryptedAmountNonce(from, to, nonce)` event emitted alongside each +encrypted Transfer/Approval — so a decryptor reads the exact counter instead of scanning for it. Plaintext = `abi.encode(uint256 amount)`. diff --git a/test/integration/seismic/SanvilStack.s.sol b/test/integration/seismic/SanvilStack.s.sol index 94a6f3ce..3933e725 100644 --- a/test/integration/seismic/SanvilStack.s.sol +++ b/test/integration/seismic/SanvilStack.s.sol @@ -47,7 +47,8 @@ contract SanvilStack is Script { deployer, // freezeManager deployer, // yieldRecipientManager deployer, // pauser - deployer // forcedTransferManager + deployer, // forcedTransferManager + deployer // allowlistAdmin ) ) ) diff --git a/test/integration/seismic/run-sanvil-e2e.sh b/test/integration/seismic/run-sanvil-e2e.sh index b80eae88..7d038177 100755 --- a/test/integration/seismic/run-sanvil-e2e.sh +++ b/test/integration/seismic/run-sanvil-e2e.sh @@ -117,7 +117,7 @@ RECEIPT=$(scast send "$EXTENSION" "transfer(address,suint256)" $BOB $TRANSFER_AM --private-key $ALICE_KEY --rpc-url "$RPC" --seismic --json) [ "$(echo "$RECEIPT" | json_get "['status']")" = "0x1" ] || fail "shielded transfer reverted" [ "$(echo "$RECEIPT" | json_get "['type']")" = "0x4a" ] || fail "transfer was not TxSeismic" -TRANSFER_BYTES_TOPIC=$(scast keccak "Transfer(address,address,bytes)") +TRANSFER_BYTES_TOPIC=$(scast keccak "Transfer(address,address,bytes32,bytes)") CIPHERTEXT=$(echo "$RECEIPT" | python3 -c " import json, sys raw = sys.stdin.read() diff --git a/test/unit/projects/JMIExtension.t.sol b/test/unit/projects/JMIExtension.t.sol index dbdcec29..e872ac5d 100644 --- a/test/unit/projects/JMIExtension.t.sol +++ b/test/unit/projects/JMIExtension.t.sol @@ -67,12 +67,16 @@ contract JMIExtensionUnitTests is BaseUnitTest { assetCapManager, freezeManager, pauser, - yieldRecipientManager + yieldRecipientManager, + allowlistAdmin ), mExtensionDeployOptions ) ); + vm.prank(allowlistAdmin); + jmi.grantRole(ALLOWLIST_MANAGER_ROLE, admin); + vm.prank(assetCapManager); jmi.setAssetCap(address(mockUSDC), mockUSDCCap); @@ -147,7 +151,8 @@ contract JMIExtensionUnitTests is BaseUnitTest { address(0), freezeManager, pauser, - yieldRecipientManager + yieldRecipientManager, + allowlistAdmin ) ) ); @@ -413,7 +418,7 @@ contract JMIExtensionUnitTests is BaseUnitTest { jmi.wrap(address(mockUSDC), alice, amount); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bool foundBytes; @@ -753,8 +758,8 @@ contract JMIExtensionUnitTests is BaseUnitTest { assertEq(jmi.totalAssets(), amount); assertEq(jmi.totalSupply(), amount); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, bytes32(0), bytes("")); vm.prank(alice); jmi.transfer(bob, suint256(amount)); diff --git a/test/unit/projects/yieldToOne/MYieldToOne.t.sol b/test/unit/projects/yieldToOne/MYieldToOne.t.sol index eb60fa8c..1f9de491 100644 --- a/test/unit/projects/yieldToOne/MYieldToOne.t.sol +++ b/test/unit/projects/yieldToOne/MYieldToOne.t.sol @@ -46,12 +46,16 @@ contract MYieldToOneUnitTests is BaseUnitTest { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ), mExtensionDeployOptions ) ); + vm.prank(allowlistAdmin); + mYieldToOne.grantRole(ALLOWLIST_MANAGER_ROLE, admin); + registrar.setEarner(address(mYieldToOne), true); } @@ -69,6 +73,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertTrue(mYieldToOne.hasRole(FREEZE_MANAGER_ROLE, freezeManager)); assertTrue(mYieldToOne.hasRole(YIELD_RECIPIENT_MANAGER_ROLE, yieldRecipientManager)); assertTrue(mYieldToOne.hasRole(PAUSER_ROLE, pauser)); + assertTrue(mYieldToOne.hasRole(ALLOWLIST_ADMIN_ROLE, allowlistAdmin)); + assertEq(mYieldToOne.getRoleAdmin(ALLOWLIST_MANAGER_ROLE), ALLOWLIST_ADMIN_ROLE); } function test_initialize_zeroYieldRecipient() external { @@ -87,7 +93,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { admin, freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ) ) ); @@ -109,7 +116,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { address(0), freezeManager, yieldRecipientManager, - pauser + pauser, + allowlistAdmin ) ) ); @@ -131,7 +139,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { admin, freezeManager, address(0), - pauser + pauser, + allowlistAdmin ) ) ); @@ -153,17 +162,75 @@ contract MYieldToOneUnitTests is BaseUnitTest { admin, freezeManager, yieldRecipientManager, + address(0), + allowlistAdmin + ) + ) + ); + } + + function test_initialize_zeroAllowlistAdmin() external { + address implementation = address(new MYieldToOneHarness(address(mToken), address(swapFacility))); + + vm.expectRevert(IMYieldToOne.ZeroAllowlistAdmin.selector); + MYieldToOneHarness( + UnsafeUpgrades.deployTransparentProxy( + implementation, + admin, + abi.encodeWithSelector( + MYieldToOne.initialize.selector, + NAME, + SYMBOL, + yieldRecipient, + admin, + freezeManager, + yieldRecipientManager, + pauser, address(0) ) ) ); } + /* ============ ALLOWLIST_MANAGER_ROLE delegation ============ */ + + function test_setAllowlisted_allowlistAdminCanGrantManager() public { + address operator = makeAddr("allowlistOperator"); + + vm.prank(allowlistAdmin); + mYieldToOne.grantRole(ALLOWLIST_MANAGER_ROLE, operator); + + vm.expectEmit(); + emit IMYieldToOne.AllowlistSet(bob, true); + + vm.prank(operator); + mYieldToOne.setAllowlisted(bob, true); + + assertTrue(mYieldToOne.isAllowlisted(bob)); + } + + function test_setAllowlisted_defaultAdminCannotGrantManager() public { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + admin, + ALLOWLIST_ADMIN_ROLE + ) + ); + + vm.prank(admin); + mYieldToOne.grantRole(ALLOWLIST_MANAGER_ROLE, carol); + } + /* ============ setAllowlisted ============ */ - function test_setAllowlisted_onlyAdmin() public { + function test_setAllowlisted_onlyAllowlistManager() public { vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, alice, DEFAULT_ADMIN_ROLE) + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + alice, + ALLOWLIST_MANAGER_ROLE + ) ); vm.prank(alice); @@ -224,13 +291,17 @@ contract MYieldToOneUnitTests is BaseUnitTest { /* ============ setAllowlisted (batch) ============ */ - function test_setAllowlisted_batchOnlyAdmin() public { + function test_setAllowlisted_batchOnlyAllowlistManager() public { address[] memory infra = new address[](2); infra[0] = bob; infra[1] = carol; vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, alice, DEFAULT_ADMIN_ROLE) + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, + alice, + ALLOWLIST_MANAGER_ROLE + ) ); vm.prank(alice); @@ -310,8 +381,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { _installContractKey(); // bob has no registered key => empty-ciphertext fallback emit on the bytes overload. - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Approval(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Approval(alice, bob, bytes32(0), bytes("")); vm.prank(alice); mYieldToOne.approve(bob, suint256(amount)); @@ -657,6 +728,116 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getShieldedAllowance(alice, carol), 0); } + /* ============ transferWithAuthorization / receiveWithAuthorization (ERC-3009) ============ */ + + function test_transferWithAuthorization_reverts() public { + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.transferWithAuthorization(alice, bob, 1_000e6, 0, type(uint256).max, bytes32(0), ""); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.transferWithAuthorization( + alice, + bob, + 1_000e6, + 0, + type(uint256).max, + bytes32(0), + bytes32(0), + bytes32(0) + ); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.transferWithAuthorization( + alice, + bob, + 1_000e6, + 0, + type(uint256).max, + bytes32(0), + uint8(0), + bytes32(0), + bytes32(0) + ); + } + + function test_receiveWithAuthorization_reverts() public { + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.receiveWithAuthorization(alice, bob, 1_000e6, 0, type(uint256).max, bytes32(0), ""); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.receiveWithAuthorization( + alice, + bob, + 1_000e6, + 0, + type(uint256).max, + bytes32(0), + bytes32(0), + bytes32(0) + ); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.receiveWithAuthorization( + alice, + bob, + 1_000e6, + 0, + type(uint256).max, + bytes32(0), + uint8(0), + bytes32(0), + bytes32(0) + ); + } + + function test_transferWithAuthorization_revertsWithValidSignature() public { + uint256 amount = 1_000e6; + uint256 validBefore = type(uint256).max; + bytes32 nonce = keccak256("transfer-nonce"); + + mYieldToOne.setBalanceOf(alice, amount); + vm.warp(1 days); + + (uint8 v, bytes32 r, bytes32 s) = _signAuthorization( + mYieldToOne.TRANSFER_WITH_AUTHORIZATION_TYPEHASH(), + alice, + bob, + amount, + 0, + validBefore, + nonce, + aliceKey + ); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOne.transferWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + } + + function test_receiveWithAuthorization_revertsWithValidSignature() public { + uint256 amount = 1_000e6; + uint256 validBefore = type(uint256).max; + bytes32 nonce = keccak256("receive-nonce"); + + mYieldToOne.setBalanceOf(alice, amount); + vm.warp(1 days); + + (uint8 v, bytes32 r, bytes32 s) = _signAuthorization( + mYieldToOne.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), + alice, + bob, + amount, + 0, + validBefore, + nonce, + aliceKey + ); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + + vm.prank(bob); + mYieldToOne.receiveWithAuthorization(alice, bob, amount, 0, validBefore, nonce, v, r, s); + } + /* ============ balanceOf ============ */ function test_balanceOf_holderCanRead() public { @@ -966,8 +1147,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { _installContractKey(); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, bytes32(0), bytes("")); vm.prank(alice); mYieldToOne.transfer(bob, suint256(amount)); @@ -994,8 +1175,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { _installContractKey(); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, alice, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, alice, bytes32(0), bytes("")); vm.prank(alice); mYieldToOne.transfer(alice, suint256(amount)); @@ -1051,8 +1232,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.prank(alice); mYieldToOne.approve(carol, suint256(allowanceAmount)); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, bytes32(0), bytes("")); vm.prank(carol); mYieldToOne.transferFrom(alice, bob, suint256(amount)); @@ -1306,6 +1487,30 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.setContractKey(sbytes32(bytes32(uint256(0xC0FFEE))), _validPubKey(0xAA)); } + /// @dev Signs an ERC-3009 transfer/receive authorization with `signerKey` over the token's EIP-712 domain. + function _signAuthorization( + bytes32 typehash, + address from, + address to, + uint256 value, + uint256 validAfter, + uint256 validBefore, + bytes32 nonce, + uint256 signerKey + ) internal view returns (uint8 v, bytes32 r, bytes32 s) { + return + vm.sign( + signerKey, + keccak256( + abi.encodePacked( + "\x19\x01", + mYieldToOne.DOMAIN_SEPARATOR(), + keccak256(abi.encode(typehash, from, to, value, validAfter, validBefore, nonce)) + ) + ) + ); + } + /* ============ setContractKey ============ */ function test_setContractKey_onlyAdmin() public { @@ -1463,7 +1668,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), 1); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bool foundBytes; @@ -1483,7 +1688,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } } - assertTrue(foundBytes, "missing Transfer(address,address,bytes) emit"); + assertTrue(foundBytes, "missing Transfer(address,address,bytes32,bytes) emit"); assertFalse(foundPlaintext, "plaintext Transfer(uint256) emitted on shielded path"); assertEq(mYieldToOne.getBalanceOf(alice), 0); @@ -1513,7 +1718,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), 1); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bool foundBytes; @@ -1533,7 +1738,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } } - assertTrue(foundBytes, "missing Transfer(address,address,bytes) emit"); + assertTrue(foundBytes, "missing Transfer(address,address,bytes32,bytes) emit"); assertFalse(foundPlaintext, "plaintext Transfer(uint256) emitted on shielded path"); } @@ -1547,8 +1752,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.prank(bob); mYieldToOne.registerPublicKey(_validPubKey(0xBB)); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, hex"deadbeefcafebabe"); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, keccak256(_validPubKey(0xBB)), hex"deadbeefcafebabe"); vm.prank(alice); mYieldToOne.transfer(bob, suint256(amount)); @@ -1600,18 +1805,18 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.setBalanceOf(alice, amount); _installContractKey(); - _mockPrecompiles(); vm.prank(bob); mYieldToOne.registerPublicKey(_validPubKey(0xBB)); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, hex"deadbeefcafebabe"); + vm.recordLogs(); vm.prank(alice); mYieldToOne.transfer(bob, suint256(0)); - assertEq(mYieldToOne.getEncryptedEventNonce(), 1); + // Zero-amount transfer is a no-op: early return before any emit, counter increment, or balance change. + assertEq(vm.getRecordedLogs().length, 0); + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); assertEq(mYieldToOne.getBalanceOf(alice), amount); assertEq(mYieldToOne.getBalanceOf(bob), 0); } @@ -1624,8 +1829,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { _installContractKey(); // Precompiles are intentionally not mocked: the fallback path must not call them. - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, bytes32(0), bytes("")); vm.prank(alice); mYieldToOne.transfer(bob, suint256(amount)); @@ -1642,12 +1847,13 @@ contract MYieldToOneUnitTests is BaseUnitTest { _installContractKey(); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, bytes("")); + vm.recordLogs(); vm.prank(alice); mYieldToOne.transfer(bob, suint256(0)); + // Zero-amount transfer is a no-op regardless of whether the recipient has registered a key. + assertEq(vm.getRecordedLogs().length, 0); assertEq(mYieldToOne.getEncryptedEventNonce(), 0); assertEq(mYieldToOne.getBalanceOf(alice), amount); assertEq(mYieldToOne.getBalanceOf(bob), 0); @@ -1780,6 +1986,66 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), 3); } + /* ============ EncryptedAmountNonce ============ */ + + function test_shieldedTransfer_registeredRecipient_emitsNonce() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + _installContractKey(); + _mockPrecompiles(); + + vm.prank(bob); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.EncryptedAmountNonce(alice, bob, 1); + + vm.prank(alice); + mYieldToOne.transfer(bob, suint256(amount)); + + assertEq(mYieldToOne.getEncryptedEventNonce(), 1); + } + + function test_shieldedApprove_registeredSpender_emitsNonce() external { + _installContractKey(); + _mockPrecompiles(); + + vm.prank(bob); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.EncryptedAmountNonce(alice, bob, 1); + + vm.prank(alice); + mYieldToOne.approve(bob, suint256(1_000e6)); + + assertEq(mYieldToOne.getEncryptedEventNonce(), 1); + } + + function test_shieldedTransfer_unregisteredRecipient_noNonceEvent() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + // Key set, but bob unregistered => empty-ciphertext fallback: no counter consumed, no nonce event. + _installContractKey(); + + vm.recordLogs(); + + vm.prank(alice); + mYieldToOne.transfer(bob, suint256(amount)); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 nonceTopic = keccak256("EncryptedAmountNonce(address,address,uint256)"); + + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(mYieldToOne) || logs[i].topics.length == 0) continue; + assertTrue(logs[i].topics[0] != nonceTopic, "EncryptedAmountNonce emitted on the no-key fallback"); + } + + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); + } + /* ============ _shieldedApprove ============ */ function test_shieldedApprove_registeredSpender_emitsBytesPayload() external { @@ -1801,7 +2067,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), 1); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Approval(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Approval(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Approval(address,address,uint256)"); bool foundBytes; @@ -1821,7 +2087,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } } - assertTrue(foundBytes, "missing Approval(address,address,bytes) emit"); + assertTrue(foundBytes, "missing Approval(address,address,bytes32,bytes) emit"); assertFalse(foundPlaintext, "plaintext Approval(uint256) emitted on shielded path"); assertEq(mYieldToOne.getShieldedAllowance(alice, bob), amount); @@ -1832,8 +2098,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { _installContractKey(); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Approval(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Approval(alice, bob, bytes32(0), bytes("")); vm.prank(alice); mYieldToOne.approve(bob, suint256(amount)); @@ -1875,7 +2141,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), 0); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Approval(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Approval(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Approval(address,address,uint256)"); bool foundBytes; @@ -1923,7 +2189,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), 0); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bool foundBytes; @@ -1969,7 +2235,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), nonceBefore); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bool foundBytes; @@ -2018,7 +2284,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.getEncryptedEventNonce(), nonceBefore); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bool foundBytes; @@ -2048,7 +2314,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { uint256 amount, bool selfTransfer ) external { - amount = bound(amount, 0, type(uint240).max); + amount = bound(amount, 1, type(uint240).max); address recipient = selfTransfer ? alice : bob; @@ -2069,7 +2335,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } function testFuzz_shieldedTransfer_unregisteredRecipient_neverBurnsNonce(uint256 amount) external { - amount = bound(amount, 0, type(uint240).max); + amount = bound(amount, 1, type(uint240).max); mYieldToOne.setBalanceOf(alice, type(uint240).max); @@ -2078,8 +2344,8 @@ contract MYieldToOneUnitTests is BaseUnitTest { uint256 nonceBefore = mYieldToOne.getEncryptedEventNonce(); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, bytes("")); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, bytes32(0), bytes("")); vm.prank(alice); mYieldToOne.transfer(bob, suint256(amount)); @@ -2133,7 +2399,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } function testFuzz_shieldedTransfer_ciphertextMatchesPrecompileOutput(uint256 amount) external { - amount = bound(amount, 0, type(uint240).max); + amount = bound(amount, 1, type(uint240).max); mYieldToOne.setBalanceOf(alice, type(uint240).max); @@ -2149,7 +2415,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.transfer(bob, suint256(amount)); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); assertTrue(bytesTopic != plaintextTopic, "bytes overload topic0 collides with uint256 overload"); @@ -2170,7 +2436,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } } - assertTrue(foundBytes, "missing Transfer(address,address,bytes) emit"); + assertTrue(foundBytes, "missing Transfer(address,address,bytes32,bytes) emit"); assertFalse(foundPlaintext, "plaintext Transfer(uint256) emitted on shielded path"); } @@ -2189,7 +2455,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.approve(bob, suint256(amount)); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Approval(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Approval(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Approval(address,address,uint256)"); assertTrue(bytesTopic != plaintextTopic, "bytes overload topic0 collides with uint256 overload"); @@ -2210,7 +2476,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { } } - assertTrue(foundBytes, "missing Approval(address,address,bytes) emit"); + assertTrue(foundBytes, "missing Approval(address,address,bytes32,bytes) emit"); assertFalse(foundPlaintext, "plaintext Approval(uint256) emitted on shielded path"); assertEq(mYieldToOne.getShieldedAllowance(alice, bob), amount); @@ -2260,16 +2526,15 @@ contract MYieldToOneUnitTests is BaseUnitTest { uint256 nonceBefore = mYieldToOne.getEncryptedEventNonce(); - // Encrypted-bytes overload still fires (the encrypt pipeline runs before the zero-amount early return). - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, hex"deadbeefcafebabe"); + vm.recordLogs(); vm.prank(carol); mYieldToOne.transferFrom(alice, bob, suint256(0)); - // Allowance decremented by 0; a nonce is still burned; balances unchanged. + // Zero-amount transferFrom is a no-op: allowance decremented by 0, no emit/nonce, balances unchanged. + assertEq(vm.getRecordedLogs().length, 0); assertEq(mYieldToOne.getShieldedAllowance(alice, carol), allowanceAmount); - assertEq(mYieldToOne.getEncryptedEventNonce(), nonceBefore + 1); + assertEq(mYieldToOne.getEncryptedEventNonce(), nonceBefore); assertEq(mYieldToOne.getBalanceOf(alice), 1_000e6); assertEq(mYieldToOne.getBalanceOf(bob), 0); } diff --git a/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol b/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol index e3718924..205c2897 100644 --- a/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol +++ b/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol @@ -42,12 +42,16 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { freezeManager, yieldRecipientManager, pauser, - forcedTransferManager + forcedTransferManager, + allowlistAdmin ), mExtensionDeployOptions ) ); + vm.prank(allowlistAdmin); + mYieldToOneForcedTransfer.grantRole(ALLOWLIST_MANAGER_ROLE, admin); + registrar.setEarner(address(mYieldToOneForcedTransfer), true); } @@ -85,7 +89,8 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { freezeManager, yieldRecipientManager, pauser, - address(0) + address(0), + allowlistAdmin ) ) ); @@ -209,7 +214,7 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { assertEq(mYieldToOneForcedTransfer.getEncryptedEventNonce(), 0); Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes32,bytes)"); bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); bytes32 forcedTopic = keccak256("ForcedTransfer(address,address,address,uint256)"); @@ -457,6 +462,16 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { mYieldToOneForcedTransfer.transferFrom(alice, bob, amount); } + /* ============ transferWithAuthorization / receiveWithAuthorization (ERC-3009, inherited) ============ */ + + function test_authorizationTransfersRevert() public { + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOneForcedTransfer.transferWithAuthorization(alice, bob, 1_000e6, 0, type(uint256).max, bytes32(0), ""); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + mYieldToOneForcedTransfer.receiveWithAuthorization(alice, bob, 1_000e6, 0, type(uint256).max, bytes32(0), ""); + } + /* ============ balanceOf ============ */ function test_balanceOf_allowlistedInfraCanReadAnyHolder() public { @@ -553,8 +568,8 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { vm.prank(bob); mYieldToOneForcedTransfer.registerPublicKey(_validPubKey(0xBB)); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, hex"deadbeefcafebabe"); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, keccak256(_validPubKey(0xBB)), hex"deadbeefcafebabe"); vm.prank(alice); mYieldToOneForcedTransfer.transfer(bob, suint256(amount)); @@ -577,8 +592,8 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { vm.prank(alice); mYieldToOneForcedTransfer.approve(carol, suint256(amount)); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Transfer(alice, bob, hex"deadbeefcafebabe"); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Transfer(alice, bob, keccak256(_validPubKey(0xBB)), hex"deadbeefcafebabe"); vm.prank(carol); mYieldToOneForcedTransfer.transferFrom(alice, bob, suint256(amount)); @@ -597,8 +612,8 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { vm.prank(bob); mYieldToOneForcedTransfer.registerPublicKey(_validPubKey(0xBB)); - vm.expectEmit(true, true, false, true); - emit IMYieldToOne.Approval(alice, bob, hex"deadbeefcafebabe"); + vm.expectEmit(true, true, true, true); + emit IMYieldToOne.Approval(alice, bob, keccak256(_validPubKey(0xBB)), hex"deadbeefcafebabe"); vm.prank(alice); mYieldToOneForcedTransfer.approve(bob, suint256(amount)); diff --git a/test/unit/projects/yieldToOne/MYieldToOneSimulation.t.sol b/test/unit/projects/yieldToOne/MYieldToOneSimulation.t.sol index 1f69635b..3d63094c 100644 --- a/test/unit/projects/yieldToOne/MYieldToOneSimulation.t.sol +++ b/test/unit/projects/yieldToOne/MYieldToOneSimulation.t.sol @@ -41,7 +41,8 @@ contract MYieldToOneSimulationTests is BaseUnitTest { freezeManager, yieldRecipientManager, pauser, - forcedTransferManager + forcedTransferManager, + allowlistAdmin ), mExtensionDeployOptions ) @@ -49,6 +50,9 @@ contract MYieldToOneSimulationTests is BaseUnitTest { registrar.setEarner(address(mYieldToOne), true); + vm.prank(allowlistAdmin); + mYieldToOne.grantRole(ALLOWLIST_MANAGER_ROLE, admin); + vm.prank(admin); mYieldToOne.setAllowlisted(infra, true); diff --git a/test/utils/BaseIntegrationTest.sol b/test/utils/BaseIntegrationTest.sol index 3f5fbf4a..70c66b82 100644 --- a/test/utils/BaseIntegrationTest.sol +++ b/test/utils/BaseIntegrationTest.sol @@ -59,6 +59,8 @@ contract BaseIntegrationTest is Helpers, Test { bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE"); bytes32 public constant YIELD_RECIPIENT_MANAGER_ROLE = keccak256("YIELD_RECIPIENT_MANAGER_ROLE"); + bytes32 public constant ALLOWLIST_MANAGER_ROLE = keccak256("ALLOWLIST_MANAGER_ROLE"); + bytes32 public constant ALLOWLIST_ADMIN_ROLE = keccak256("ALLOWLIST_ADMIN_ROLE"); bytes32 public constant EARNER_MANAGER_ROLE = keccak256("EARNER_MANAGER_ROLE"); bytes32 public constant M_SWAPPER_ROLE = keccak256("M_SWAPPER_ROLE"); bytes32 public constant CLAIM_RECIPIENT_MANAGER_ROLE = keccak256("CLAIM_RECIPIENT_MANAGER_ROLE"); @@ -74,6 +76,7 @@ contract BaseIntegrationTest is Helpers, Test { address public freezeManager = makeAddr("freezeManager"); address public yieldRecipient = makeAddr("yieldRecipient"); address public yieldRecipientManager = makeAddr("yieldRecipientManager"); + address public allowlistAdmin = makeAddr("allowlistAdmin"); address public feeManager = makeAddr("feeManager"); address public claimRecipientManager = makeAddr("claimRecipientManager"); address public earnerManager = makeAddr("earnerManager"); diff --git a/test/utils/BaseUnitTest.sol b/test/utils/BaseUnitTest.sol index 836d0c61..31e5fe56 100644 --- a/test/utils/BaseUnitTest.sol +++ b/test/utils/BaseUnitTest.sol @@ -34,6 +34,8 @@ contract BaseUnitTest is Helpers, Test { bytes32 public constant M_SWAPPER_ROLE = keccak256("M_SWAPPER_ROLE"); bytes32 public constant YIELD_RECIPIENT_MANAGER_ROLE = keccak256("YIELD_RECIPIENT_MANAGER_ROLE"); bytes32 public constant FORCED_TRANSFER_MANAGER_ROLE = keccak256("FORCED_TRANSFER_MANAGER_ROLE"); + bytes32 public constant ALLOWLIST_MANAGER_ROLE = keccak256("ALLOWLIST_MANAGER_ROLE"); + bytes32 public constant ALLOWLIST_ADMIN_ROLE = keccak256("ALLOWLIST_ADMIN_ROLE"); MockM public mToken; MockRateOracle public rateOracle; @@ -52,6 +54,7 @@ contract BaseUnitTest is Helpers, Test { address public yieldRecipient = makeAddr("yieldRecipient"); address public yieldRecipientManager = makeAddr("yieldRecipientManager"); address public forcedTransferManager = makeAddr("forcedTransferManager"); + address public allowlistAdmin = makeAddr("allowlistAdmin"); address public feeRecipient = makeAddr("feeRecipient"); address public feeManager = makeAddr("feeManager");