diff --git a/.gitignore b/.gitignore index 2d8fb7e1..d9e57217 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ soljson-latest.js # Gas report gasreport.ansi + +# Claude +.claude/ +.planning/ + +# Seismic toolchain (repo-local install — see scripts/seismic-env.sh) +.seismic-toolchain/ diff --git a/.gitmodules b/.gitmodules index 04a764d3..e686be7e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,6 +5,7 @@ [submodule "lib/common"] path = lib/common url = https://github.com/m0-foundation/common + branch = feat/erc20-virtual [submodule "lib/openzeppelin-foundry-upgrades"] path = lib/openzeppelin-foundry-upgrades url = https://github.com/OpenZeppelin/openzeppelin-foundry-upgrades diff --git a/.husky/pre-commit b/.husky/pre-commit index 6131ed3b..e9e22bcb 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,12 @@ +# If the repo-local Seismic toolchain is installed (see scripts/seismic-env.sh), +# put sforge/ssolc on PATH and pin FOUNDRY_PROFILE=seismic so the test step +# below compiles shielded-type code (suint256, etc.) that stock solc rejects. +# When the toolchain isn't present, the hook falls back to stock forge. +if [ -x ".seismic-toolchain/bin/sforge" ]; then + PATH="$PWD/.seismic-toolchain/bin:$PATH" + export PATH + FOUNDRY_PROFILE=seismic + export FOUNDRY_PROFILE +fi + npm run lint-staged && npm test diff --git a/Makefile b/Makefile index 4fa2b88b..7617ec57 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,9 @@ deploy-sepolia :; FOUNDRY_PROFILE=production forge script script/Deploy.s.sol -- slither :; FOUNDRY_PROFILE=production forge build --build-info --skip '*/test/**' --skip '*/script/**' --force && slither --compile-force-framework foundry --ignore-compile --sarif results.sarif --config-file slither.config.json . # Common tasks -profile ?=default +# Honor an inherited FOUNDRY_PROFILE (e.g. exported by .husky/pre-commit when the +# Seismic toolchain is detected). Falls back to "default" when neither is set. +profile ?= $(if $(FOUNDRY_PROFILE),$(FOUNDRY_PROFILE),default) build: @./build.sh -p production diff --git a/foundry.toml b/foundry.toml index 88496c97..eec08ef3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -27,6 +27,37 @@ lint_on_build = false build_info = true sizes = true +# Seismic profile — builds the MYieldToOne implementation with ssolc (currently 0.8.31). +# `evm_version = "mercury"` is the Seismic EVM revision that supports shielded types +# (suint, sbool, saddress, sbytes). The default profile keeps "cancun" + solc 0.8.26 +# for the rest of the suite so audit-reproducibility of sibling extensions is preserved. +# Activate with: `FOUNDRY_PROFILE=seismic sforge build` +# +# `no_match_path` skips two flavors of out-of-scope tests under the Seismic profile: +# +# (1) The entire `test/integration/` directory. Every integration suite forks +# ETH_MAINNET via `BaseIntegrationTest`, which deploys a shielded MYieldToOne; +# mainnet's JSON-RPC doesn't implement `eth_getFlaggedStorageAt`, so any setUp +# that touches a shielded balance aborts with HTTP 400. Integration coverage +# for this profile lives on Seismic devnet, not locally. +# +# (2) `test/unit/projects/JMIExtension.t.sol` — JMIExtension inherits MYieldToOne. +# At `optimizer_runs = 800` (see below) the contract is 23 240 bytes, within the +# EIP-170 limit (24 576); it overflows to 25 853 at the default 19 999 runs. The +# suite is still skipped here because it was written against the unshielded +# `balanceOf` surface, so it trips `Unauthorized()` on the new gated read. JMI is +# not a Seismic deployment target; its unit tests live under the default profile +# where the unshielded path still works. +[profile.seismic] +evm_version = "mercury" +# `optimizer_runs = 800` overrides the default profile's 19 999. The shielded +# MYieldToOne closure pushes JMIExtension to 25 853 bytes at 19 999 runs — 1 277 over +# the EIP-170 24 576-byte limit. 800 is the knee of the size/gas curve: it brings the +# contract to 23 240 bytes (+1 336 headroom) while keeping as much runtime-gas +# optimization as the size limit allows. Lower runs -> smaller code, higher per-call gas. +optimizer_runs = 800 +no_match_path = "test/{integration/**,unit/projects/JMIExtension.t.sol}" + [fuzz] runs = 5_000 diff --git a/lib/common b/lib/common index 20440d03..a1fbf37b 160000 --- a/lib/common +++ b/lib/common @@ -1 +1 @@ -Subproject commit 20440d03de21fe62970829245872e9adae6cd822 +Subproject commit a1fbf37b0ab10b0f8e71223793a0fd6af77b527d diff --git a/script/Config.sol b/script/Config.sol index f4efa247..497425cf 100644 --- a/script/Config.sol +++ b/script/Config.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.26; +pragma solidity ^0.8.26; contract Config { error UnsupportedChain(uint256 chainId); diff --git a/script/ProposeTransferSwapFacilityOwner.s.sol b/script/ProposeTransferSwapFacilityOwner.s.sol index 40e46ded..670b64e5 100644 --- a/script/ProposeTransferSwapFacilityOwner.s.sol +++ b/script/ProposeTransferSwapFacilityOwner.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { console } from "../lib/forge-std/src/console.sol"; import { AccessControl } from "../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/AccessControl.sol"; diff --git a/script/ScriptBase.s.sol b/script/ScriptBase.s.sol index ee46be22..a1f17722 100644 --- a/script/ScriptBase.s.sol +++ b/script/ScriptBase.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Script } from "forge-std/Script.sol"; diff --git a/script/deploy/DeployBase.s.sol b/script/deploy/DeployBase.s.sol index f1b694e2..5393e3f3 100644 --- a/script/deploy/DeployBase.s.sol +++ b/script/deploy/DeployBase.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployHelpers } from "../../lib/common/script/deploy/DeployHelpers.sol"; diff --git a/script/deploy/DeployJMIExtension.s.sol b/script/deploy/DeployJMIExtension.s.sol index c1e67960..f67476a1 100644 --- a/script/deploy/DeployJMIExtension.s.sol +++ b/script/deploy/DeployJMIExtension.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/DeployMEarnerManager.s.sol b/script/deploy/DeployMEarnerManager.s.sol index 4743469b..40115311 100644 --- a/script/deploy/DeployMEarnerManager.s.sol +++ b/script/deploy/DeployMEarnerManager.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/DeploySwapAdapter.s.sol b/script/deploy/DeploySwapAdapter.s.sol index d0bc4aae..d8474c40 100644 --- a/script/deploy/DeploySwapAdapter.s.sol +++ b/script/deploy/DeploySwapAdapter.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/DeploySwapFacility.s.sol b/script/deploy/DeploySwapFacility.s.sol index 1a64914e..5c63a290 100644 --- a/script/deploy/DeploySwapFacility.s.sol +++ b/script/deploy/DeploySwapFacility.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/DeployYieldToAllWithFee.s.sol b/script/deploy/DeployYieldToAllWithFee.s.sol index 30ef063a..1fda7f5e 100644 --- a/script/deploy/DeployYieldToAllWithFee.s.sol +++ b/script/deploy/DeployYieldToAllWithFee.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/DeployYieldToOne.s.sol b/script/deploy/DeployYieldToOne.s.sol index 863e313c..bb41ab76 100644 --- a/script/deploy/DeployYieldToOne.s.sol +++ b/script/deploy/DeployYieldToOne.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/DeployYieldToOneForcedTransfer.s.sol b/script/deploy/DeployYieldToOneForcedTransfer.s.sol index c9399be4..9866a885 100644 --- a/script/deploy/DeployYieldToOneForcedTransfer.s.sol +++ b/script/deploy/DeployYieldToOneForcedTransfer.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/MainnetDeploymentSim.s.sol b/script/deploy/MainnetDeploymentSim.s.sol index bc8f5a1a..84490aca 100644 --- a/script/deploy/MainnetDeploymentSim.s.sol +++ b/script/deploy/MainnetDeploymentSim.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { DeployBase } from "./DeployBase.s.sol"; import { console } from "forge-std/console.sol"; diff --git a/script/deploy/interfaces/ICreateXLike.sol b/script/deploy/interfaces/ICreateXLike.sol index 31bc0582..3e017eeb 100644 --- a/script/deploy/interfaces/ICreateXLike.sol +++ b/script/deploy/interfaces/ICreateXLike.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title CreateX Factory Interface Definition diff --git a/script/upgrade/ExecuteTimelockSwapFacilityUpgrade.s.sol b/script/upgrade/ExecuteTimelockSwapFacilityUpgrade.s.sol index 5bbdd014..efb9dce9 100644 --- a/script/upgrade/ExecuteTimelockSwapFacilityUpgrade.s.sol +++ b/script/upgrade/ExecuteTimelockSwapFacilityUpgrade.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IProxyAdmin } from "../../lib/openzeppelin-foundry-upgrades/src/internal/interfaces/IProxyAdmin.sol"; import { Upgrades } from "../../lib/openzeppelin-foundry-upgrades/src/Upgrades.sol"; diff --git a/script/upgrade/ProposeSwapFacilityUpgrade.s.sol b/script/upgrade/ProposeSwapFacilityUpgrade.s.sol index 8fd821b0..129b2fa5 100644 --- a/script/upgrade/ProposeSwapFacilityUpgrade.s.sol +++ b/script/upgrade/ProposeSwapFacilityUpgrade.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { ProposeUpgradeBase } from "./ProposeUpgradeBase.sol"; diff --git a/script/upgrade/ProposeTimelockSwapFacilityUpgrade.s.sol b/script/upgrade/ProposeTimelockSwapFacilityUpgrade.s.sol index e0e41d5a..26a17972 100644 --- a/script/upgrade/ProposeTimelockSwapFacilityUpgrade.s.sol +++ b/script/upgrade/ProposeTimelockSwapFacilityUpgrade.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { ProposeTimelockUpgradeBase } from "./ProposeTimelockUpgradeBase.sol"; diff --git a/script/upgrade/ProposeTimelockUpgradeBase.sol b/script/upgrade/ProposeTimelockUpgradeBase.sol index 36010978..ce1c5f67 100644 --- a/script/upgrade/ProposeTimelockUpgradeBase.sol +++ b/script/upgrade/ProposeTimelockUpgradeBase.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { console } from "../../lib/forge-std/src/console.sol"; diff --git a/script/upgrade/ProposeUpgradeBase.sol b/script/upgrade/ProposeUpgradeBase.sol index 2c71f946..034f7ab7 100644 --- a/script/upgrade/ProposeUpgradeBase.sol +++ b/script/upgrade/ProposeUpgradeBase.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { console } from "../../lib/forge-std/src/console.sol"; diff --git a/script/upgrade/UpgradeBase.sol b/script/upgrade/UpgradeBase.sol index 684c2f3e..6f879ef9 100644 --- a/script/upgrade/UpgradeBase.sol +++ b/script/upgrade/UpgradeBase.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { SwapFacility } from "../../src/swap/SwapFacility.sol"; import { JMIExtension } from "../../src/projects/jmi/JMIExtension.sol"; diff --git a/script/upgrade/UpgradeJMIExtension.s.sol b/script/upgrade/UpgradeJMIExtension.s.sol index ac0b0ad9..f2c5bbf0 100644 --- a/script/upgrade/UpgradeJMIExtension.s.sol +++ b/script/upgrade/UpgradeJMIExtension.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { UpgradeBase } from "./UpgradeBase.sol"; diff --git a/script/upgrade/UpgradeOldSwapFacility.s.sol b/script/upgrade/UpgradeOldSwapFacility.s.sol index d01110ac..23db1a51 100644 --- a/script/upgrade/UpgradeOldSwapFacility.s.sol +++ b/script/upgrade/UpgradeOldSwapFacility.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { UpgradeBase } from "./UpgradeBase.sol"; diff --git a/script/upgrade/UpgradeSwapFacility.s.sol b/script/upgrade/UpgradeSwapFacility.s.sol index 2c15c77b..822b04af 100644 --- a/script/upgrade/UpgradeSwapFacility.s.sol +++ b/script/upgrade/UpgradeSwapFacility.s.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { UpgradeBase } from "./UpgradeBase.sol"; diff --git a/scripts/seismic-env.sh b/scripts/seismic-env.sh new file mode 100755 index 00000000..4790e74a --- /dev/null +++ b/scripts/seismic-env.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Seismic toolchain environment — sources sforge / ssolc / sanvil from a +# repo-local install at .seismic-toolchain/ (git-ignored). +# +# USAGE +# From the repo root in any bash/zsh shell: +# source scripts/seismic-env.sh +# After sourcing, `sforge`, `ssolc`, and `sanvil` resolve to the repo-local +# binaries. The export survives until the shell exits. +# +# FIRST-TIME INSTALL +# 1. source scripts/seismic-env.sh +# 2. curl -L -H "Accept: application/vnd.github.v3.raw" \ +# "https://api.github.com/repos/SeismicSystems/seismic-foundry/contents/sfoundryup/install?ref=seismic" | bash +# The installer writes sfoundryup to $FOUNDRY_DIR/bin/sfoundryup. +# 3. sfoundryup +# Fetches sforge / ssolc / sanvil into $FOUNDRY_DIR/bin. +# 4. sforge --version && ssolc --version && sanvil --version +# +# NOTES +# - FOUNDRY_DIR is the env var the Seismic installer honors. Pointing it at +# the repo-local path overrides the default of "$HOME/.seismic". +# - Pre-prepending $FOUNDRY_DIR/bin to PATH causes the installer's rc-edit +# check to find the bin dir already on PATH and skip mutating ~/.zshenv +# on most versions of the upstream script. If a future installer revision +# still appends an export line, remove it from ~/.zshenv by hand — the +# binaries continue to work via this sourced env. +# - Coexists with stock Foundry: `forge` (stock) and `sforge` (Seismic) live +# in different bin dirs and have different names — no collisions. + +# Resolve repo root from this script's own path (works whether sourced from +# repo root, from a subdir, or from $PATH after `chmod +x`). +__SEISMIC_ENV_SH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +SEISMIC_REPO_ROOT="$(cd "$__SEISMIC_ENV_SH_DIR/.." && pwd)" + +export FOUNDRY_DIR="$SEISMIC_REPO_ROOT/.seismic-toolchain" +export FOUNDRY_BIN_DIR="$FOUNDRY_DIR/bin" + +# Only prepend if not already present (avoids growing PATH on repeated sources). +case ":$PATH:" in + *":$FOUNDRY_BIN_DIR:"*) ;; + *) export PATH="$FOUNDRY_BIN_DIR:$PATH" ;; +esac + +# Shell function that pins FOUNDRY_PROFILE=seismic for sforge invocations so +# `evm_version = "mercury"` (required for shielded types) is applied without +# the caller having to remember the flag. `forge` (stock) is untouched and +# continues using the default profile + cancun. +# Override per-call with `FOUNDRY_PROFILE=default sforge ...` if needed. +sforge() { + FOUNDRY_PROFILE="${FOUNDRY_PROFILE:-seismic}" command sforge "$@" +} + +# Surface a one-line confirmation so the user knows the env is active. +if [ -x "$FOUNDRY_BIN_DIR/sforge" ]; then + echo "seismic-env: ready — $($FOUNDRY_BIN_DIR/sforge --version 2>/dev/null | head -1) (sforge auto-uses FOUNDRY_PROFILE=seismic)" +else + echo "seismic-env: FOUNDRY_DIR=$FOUNDRY_DIR (binaries not yet installed — run sfoundryup)" +fi + +unset __SEISMIC_ENV_SH_DIR diff --git a/src/MExtension.sol b/src/MExtension.sol index 4135e7f1..1517f67c 100644 --- a/src/MExtension.sol +++ b/src/MExtension.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { ERC20ExtendedUpgradeable } from "../lib/common/src/ERC20ExtendedUpgradeable.sol"; @@ -288,7 +288,7 @@ abstract contract MExtension is IMExtension, ERC20ExtendedUpgradeable { * @param account Address of an account. * @param amount Amount to transfer or burn. */ - function _revertIfInsufficientBalance(address account, uint256 amount) internal view { + function _revertIfInsufficientBalance(address account, uint256 amount) internal view virtual { uint256 balance = balanceOf(account); if (balance < amount) revert InsufficientBalance(account, balance, amount); diff --git a/src/interfaces/IMExtension.sol b/src/interfaces/IMExtension.sol index da36a89f..95b5c058 100644 --- a/src/interfaces/IMExtension.sol +++ b/src/interfaces/IMExtension.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; diff --git a/src/interfaces/IMTokenLike.sol b/src/interfaces/IMTokenLike.sol index ec2d9f37..069da4c9 100644 --- a/src/interfaces/IMTokenLike.sol +++ b/src/interfaces/IMTokenLike.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Subset of M Token interface required for source contracts. diff --git a/src/projects/earnerManager/IMEarnerManager.sol b/src/projects/earnerManager/IMEarnerManager.sol index 7141eafb..7832ce59 100644 --- a/src/projects/earnerManager/IMEarnerManager.sol +++ b/src/projects/earnerManager/IMEarnerManager.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title M Extension where Earner Manager whitelists earners and sets fee rates for them. diff --git a/src/projects/earnerManager/MEarnerManager.sol b/src/projects/earnerManager/MEarnerManager.sol index 8f72ef22..c237ba94 100644 --- a/src/projects/earnerManager/MEarnerManager.sol +++ b/src/projects/earnerManager/MEarnerManager.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../../lib/common/src/interfaces/IERC20.sol"; diff --git a/src/projects/jmi/IJMIExtension.sol b/src/projects/jmi/IJMIExtension.sol index b5a4b932..76ea2163 100644 --- a/src/projects/jmi/IJMIExtension.sol +++ b/src/projects/jmi/IJMIExtension.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IMYieldToOne } from "../yieldToOne/interfaces/IMYieldToOne.sol"; diff --git a/src/projects/jmi/JMIExtension.sol b/src/projects/jmi/JMIExtension.sol index 0f0a387e..9a3cb1bb 100644 --- a/src/projects/jmi/JMIExtension.sol +++ b/src/projects/jmi/JMIExtension.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20Metadata as IERC20 } from "../../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol"; diff --git a/src/projects/yieldToAllWithFee/MSpokeYieldFee.sol b/src/projects/yieldToAllWithFee/MSpokeYieldFee.sol index 4a6e16ad..5c65eb2b 100644 --- a/src/projects/yieldToAllWithFee/MSpokeYieldFee.sol +++ b/src/projects/yieldToAllWithFee/MSpokeYieldFee.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MYieldFee } from "./MYieldFee.sol"; diff --git a/src/projects/yieldToAllWithFee/MYieldFee.sol b/src/projects/yieldToAllWithFee/MYieldFee.sol index b14932c8..5bc169e6 100644 --- a/src/projects/yieldToAllWithFee/MYieldFee.sol +++ b/src/projects/yieldToAllWithFee/MYieldFee.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { AccessControlUpgradeable } from "../../../lib/common/lib/openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; diff --git a/src/projects/yieldToAllWithFee/interfaces/IContinuousIndexing.sol b/src/projects/yieldToAllWithFee/interfaces/IContinuousIndexing.sol index cb03e575..c59a3ca0 100644 --- a/src/projects/yieldToAllWithFee/interfaces/IContinuousIndexing.sol +++ b/src/projects/yieldToAllWithFee/interfaces/IContinuousIndexing.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Continuous Indexing Interface. diff --git a/src/projects/yieldToAllWithFee/interfaces/IMSpokeYieldFee.sol b/src/projects/yieldToAllWithFee/interfaces/IMSpokeYieldFee.sol index 2871a99e..a6849a58 100644 --- a/src/projects/yieldToAllWithFee/interfaces/IMSpokeYieldFee.sol +++ b/src/projects/yieldToAllWithFee/interfaces/IMSpokeYieldFee.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title M Spoke Yield Fee interface. diff --git a/src/projects/yieldToAllWithFee/interfaces/IMYieldFee.sol b/src/projects/yieldToAllWithFee/interfaces/IMYieldFee.sol index 63af2036..621301dd 100644 --- a/src/projects/yieldToAllWithFee/interfaces/IMYieldFee.sol +++ b/src/projects/yieldToAllWithFee/interfaces/IMYieldFee.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Interface for M Yield Fee. diff --git a/src/projects/yieldToAllWithFee/interfaces/IRateOracle.sol b/src/projects/yieldToAllWithFee/interfaces/IRateOracle.sol index 65c8730b..8f264100 100644 --- a/src/projects/yieldToAllWithFee/interfaces/IRateOracle.sol +++ b/src/projects/yieldToAllWithFee/interfaces/IRateOracle.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Rate Oracle interface. diff --git a/src/projects/yieldToOne/MYieldToOne.sol b/src/projects/yieldToOne/MYieldToOne.sol index 5924dc7b..5bff1b12 100644 --- a/src/projects/yieldToOne/MYieldToOne.sol +++ b/src/projects/yieldToOne/MYieldToOne.sol @@ -1,8 +1,10 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; +import { ERC20ExtendedUpgradeable } from "../../../lib/common/src/ERC20ExtendedUpgradeable.sol"; import { IERC20 } from "../../../lib/common/src/interfaces/IERC20.sol"; +import { IERC20Extended } from "../../../lib/common/src/interfaces/IERC20Extended.sol"; import { IMYieldToOne } from "./interfaces/IMYieldToOne.sol"; @@ -13,9 +15,41 @@ import { MExtension } from "../../MExtension.sol"; abstract contract MYieldToOneStorageLayout { /// @custom:storage-location erc7201:M0.storage.MYieldToOne struct MYieldToOneStorageStruct { + // slot 0 — total supply (public, unshielded). uint256 totalSupply; + // slot 1 — yield destination (public). address yieldRecipient; - mapping(address account => uint256 balance) balanceOf; + // slot 2 — shielded balances keyed by holder. + mapping(address account => suint256 balance) balanceOf; + // slot 3 — shielded allowance storage — written by BOTH the shielded `approve` / `transferFrom` + // overloads and the native, infra-gated `approve` / `transferFrom` overloads (which cast at + // the ABI boundary), and read through the gated `allowance(address,address)` override below. + // The inherited `ERC20ExtendedStorageStruct.allowance` slot is never written to (native + // `approve` writes here instead; `permit` reverts) and remains zero forever. + mapping(address account => mapping(address spender => suint256 allowance)) shieldedAllowance; + // slot 4 — infra allowlist — admin-curated set of trusted M0 infrastructure contracts (the + // `swapFacility` immutable is additionally exempt without occupying a slot here). An + // allowlisted address may use the native `uint256` `approve` (as spender) / `transferFrom` + // (as caller) paths and may read any holder's cleartext `balanceOf`. Read via `_isInfra`. + mapping(address account => bool isAllowlisted) allowlist; + // === appended for encrypted Transfer events === + // slot 5 — recipient public-key registry. Each holder MAY register a compressed + // (33-byte) secp256k1 public key via `registerPublicKey`; the contract uses it as the + // ECDH peer when encrypting `Transfer` amounts to that holder. An unregistered + // recipient triggers the empty-ciphertext fallback in `_emitEncryptedTransfer`. + mapping(address account => bytes publicKey) publicKeys; + // slot 6 — contract public key (plain bytes, readable by anyone). Off-chain + // decryption clients fetch this to perform ECDH with their own private key. + bytes _contractPublicKey; + // slot 7 — contract private key (shielded). Set once via `setContractKey` (which MUST + // be sent as `TxSeismic` type `0x4A` so the key is encrypted in calldata). Used as + // the local ECDH input; never returned from any view or exposed in calldata. + sbytes32 contractPrivateKey; + // slot 8 — monotonic counter feeding the per-emit AES-GCM nonce. Pre-incremented + // before every encrypted emit, so the first emitted nonce uses counter value 1 and + // no two encrypted emits ever reuse a nonce under the same key — a deliberate + // departure from the tutorial's `keccak256(from, to, block.number)` formulation. + uint256 encryptedEventNonce; } // keccak256(abi.encode(uint256(keccak256("M0.storage.MYieldToOne")) - 1)) & ~bytes32(uint256(0xff)) @@ -133,11 +167,166 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free _setYieldRecipient(account); } + /* ============ Allowlist Management ============ */ + + /// @inheritdoc IMYieldToOne + function setAllowlisted(address account, bool status) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + _setAllowlisted(account, status); + } + + /// @inheritdoc IMYieldToOne + function setAllowlisted(address[] calldata accounts, bool status) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + for (uint256 i; i < accounts.length; ++i) { + _setAllowlisted(accounts[i], status); + } + } + + /* ============ Encrypted-Event Keypair Management ============ */ + + /// @inheritdoc IMYieldToOne + /// @dev OPERATIONAL REQUIREMENT (not enforceable from Solidity): the admin MUST send + /// this call as a Seismic `TxSeismic` transaction (type `0x4A`), so the private + /// key is encrypted in the calldata layer. If sent as a plain transaction the + /// private key is recoverable from the mempool / public tx history, defeating the + /// purpose of the shielded slot. + /// @dev Open question filed with Seismic: whether the `bytes32($.contractPrivateKey) + /// != bytes32(0)` one-shot guard reads cleanly from shielded storage without + /// leaking the value, and whether `sbytes32(0)` is the canonical unset sentinel. + function setContractKey( + sbytes32 privateKey, + bytes calldata publicKey + ) external virtual onlyRole(DEFAULT_ADMIN_ROLE) { + if (publicKey.length != 33) revert InvalidPublicKeyLength(); + + MYieldToOneStorageStruct storage $ = _getMYieldToOneStorageLocation(); + + // One-shot guard. The cast to `bytes32` is a control-flow comparison only — it + // mirrors the infinite-allowance shortcut in `_spendAllowanceAndTransfer` and does + // not write the shielded value back to storage. + if (bytes32($.contractPrivateKey) != bytes32(0)) revert ContractKeyAlreadySet(); + + $.contractPrivateKey = privateKey; + $._contractPublicKey = publicKey; + + emit ContractKeySet(publicKey); + } + + /// @inheritdoc IMYieldToOne + function registerPublicKey(bytes calldata publicKey) external virtual { + if (publicKey.length != 33) revert InvalidPublicKeyLength(); + + _getMYieldToOneStorageLocation().publicKeys[msg.sender] = publicKey; + + emit PublicKeyRegistered(msg.sender); + } + + /* ============ Shielded ERC20 Entry Points ============ */ + + /// @inheritdoc IMYieldToOne + function transfer(address recipient, suint256 amount) external returns (bool) { + // User-to-user path: amount is shielded end-to-end, so the emitted `Transfer` + // event must carry the encrypted-bytes overload. + _shieldedTransfer(msg.sender, recipient, amount, true); + return true; + } + + /// @inheritdoc IMYieldToOne + function approve(address spender, suint256 amount) external returns (bool) { + _shieldedApprove(msg.sender, spender, amount); + return true; + } + + /// @inheritdoc IMYieldToOne + function transferFrom(address sender, address recipient, suint256 amount) external returns (bool) { + // User-to-user path via allowance: encrypted-bytes emit. + _spendAllowanceAndTransfer(sender, recipient, amount, true); + return true; + } + + /* ============ Inherited IERC20 / IERC20Extended Entry Points (Allowlist-Gated) ============ */ + // The native `uint256` `approve` / `transferFrom` entry points are re-enabled for trusted M0 + // infra only: `approve` is allowed when the spender is infra, `transferFrom` when the caller is + // infra (see `_isInfra`). Both write the same `shieldedAllowance` slot as the `suint256` overloads + // (the `uint256` is only an ABI-boundary cast), so the two paths cannot diverge. Non-infra callers + // must use the `suint256`-typed overloads above. The native `transfer` and both `permit` overloads + // stay `pure` reverting — no infra calls them on the extension, so they remain shielded-only. + + /// @inheritdoc IERC20 + function transfer( + address /* recipient */, + uint256 /* amount */ + ) external pure override(ERC20ExtendedUpgradeable, IERC20) returns (bool) { + revert UseShieldedTransfer(); + } + + /// @inheritdoc IERC20 + /// @dev Native `uint256` path, allowed only when `msg.sender` is trusted M0 infra (`_isInfra`). + /// Shares the `shieldedAllowance` slot with the `suint256` overload via an ABI-boundary cast; + /// everyone else must use `transferFrom(address,address,suint256)`. + function transferFrom( + address sender, + address recipient, + uint256 amount + ) external override(ERC20ExtendedUpgradeable, IERC20) returns (bool) { + if (!_isInfra(msg.sender)) revert UseShieldedTransfer(); + + // Infra-mediated move: amount is already public on the bridge/calldata side, so + // the emit stays on the inherited plaintext `Transfer(uint256)` overload. + _spendAllowanceAndTransfer(sender, recipient, suint256(amount), false); + return true; + } + + /// @inheritdoc IERC20 + /// @dev Native `uint256` path, allowed only when `spender` is trusted M0 infra (`_isInfra`). + /// Writes the same `shieldedAllowance` slot as `approve(address,suint256)` via an + /// ABI-boundary cast; everyone else must use `approve(address,suint256)`. + function approve( + address spender, + uint256 amount + ) external override(ERC20ExtendedUpgradeable, IERC20) returns (bool) { + if (!_isInfra(spender)) revert UseShieldedApprove(); + + _shieldedApprove(msg.sender, spender, suint256(amount)); + return true; + } + + /// @inheritdoc IERC20Extended + function permit( + address /* owner */, + address /* spender */, + uint256 /* value */, + uint256 /* deadline */, + uint8 /* v */, + bytes32 /* r */, + bytes32 /* s */ + ) external pure override(ERC20ExtendedUpgradeable, IERC20Extended) { + revert UseShieldedApprove(); + } + + /// @inheritdoc IERC20Extended + function permit( + address /* owner */, + address /* spender */, + uint256 /* value */, + uint256 /* deadline */, + bytes memory /* signature */ + ) external pure override(ERC20ExtendedUpgradeable, IERC20Extended) { + revert UseShieldedApprove(); + } + /* ============ View/Pure Functions ============ */ /// @inheritdoc IERC20 + /// @dev Shielded read. Allowed callers: `account` itself (via a Seismic signed read, + /// TxSeismic type 0x4A — plain `eth_call` zeroes `msg.sender` and reverts), or any + /// trusted M0 infra address (`_isInfra`: the `swapFacility` immutable plus the + /// admin-curated allowlist). The infra exemption lets shared M0 infrastructure + /// (e.g. LimitOrderProtocol, which reads user balances to deliver liquidity) observe + /// extension balances for operational paths it controls; the holder's balance is not + /// exposed to arbitrary callers. function balanceOf(address account) public view override returns (uint256) { - return _getMYieldToOneStorageLocation().balanceOf[account]; + if (msg.sender != account && !_isInfra(msg.sender)) revert Unauthorized(); + return uint256(_getMYieldToOneStorageLocation().balanceOf[account]); } /// @inheritdoc IERC20 @@ -145,6 +334,20 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free return _getMYieldToOneStorageLocation().totalSupply; } + /// @inheritdoc IERC20 + /// @dev Shielded read. Requires `msg.sender == owner` or `msg.sender == spender`; external + /// clients must use a Seismic signed read (TxSeismic type 0x4A). The inherited + /// unshielded `ERC20ExtendedStorageStruct.allowance` slot is no longer written to (native + /// `approve` writes the `shieldedAllowance` slot instead, and `permit` reverts), so this + /// gated view is the sole readable allowance source. + function allowance( + address owner, + address spender + ) public view override(ERC20ExtendedUpgradeable, IERC20) returns (uint256) { + if (msg.sender != owner && msg.sender != spender) revert Unauthorized(); + return uint256(_getMYieldToOneStorageLocation().shieldedAllowance[owner][spender]); + } + /// @inheritdoc IMYieldToOne function yield() public view virtual returns (uint256) { unchecked { @@ -160,6 +363,21 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free return _getMYieldToOneStorageLocation().yieldRecipient; } + /// @inheritdoc IMYieldToOne + function isAllowlisted(address account) external view returns (bool) { + return _getMYieldToOneStorageLocation().allowlist[account]; + } + + /// @inheritdoc IMYieldToOne + function publicKeyOf(address account) external view returns (bytes memory) { + return _getMYieldToOneStorageLocation().publicKeys[account]; + } + + /// @inheritdoc IMYieldToOne + function contractPublicKey() external view returns (bytes memory) { + return _getMYieldToOneStorageLocation()._contractPublicKey; + } + /* ============ Hooks For Internal Interactive Functions ============ */ /** @@ -228,7 +446,7 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free // NOTE: Can be `unchecked` because the max amount of $M is never greater than `type(uint240).max`. unchecked { - $.balanceOf[recipient] += amount; + $.balanceOf[recipient] = $.balanceOf[recipient] + suint256(amount); $.totalSupply += amount; } @@ -245,7 +463,7 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free // NOTE: Can be `unchecked` because `_revertIfInsufficientBalance` is used in MExtension. unchecked { - $.balanceOf[account] -= amount; + $.balanceOf[account] = $.balanceOf[account] - suint256(amount); $.totalSupply -= amount; } @@ -253,19 +471,222 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free } /** - * @dev Internal balance update function called on transfer. - * @param sender The sender's address. - * @param recipient The recipient's address. - * @param amount The amount to be transferred. + * @dev Internal balance update used by both the inherited (now-unreachable from outside) + * and the shielded transfer paths, and by `MYieldToOneForcedTransfer._forceTransfer`. + * Casts the public `uint256` to the shielded storage type at the boundary. */ function _update(address sender, address recipient, uint256 amount) internal override { MYieldToOneStorageStruct storage $ = _getMYieldToOneStorageLocation(); - // NOTE: Can be `unchecked` because `_revertIfInsufficientBalance` for `sender` is used in MExtension. + // NOTE: Can be `unchecked` because `_revertIfInsufficientBalance` for `sender` runs + // before this call (in `MExtension._transfer` and in `_shieldedTransfer`). unchecked { - $.balanceOf[sender] -= amount; - $.balanceOf[recipient] += amount; + $.balanceOf[sender] = $.balanceOf[sender] - suint256(amount); + $.balanceOf[recipient] = $.balanceOf[recipient] + suint256(amount); + } + } + + /** + * @dev Shared allowance-spend + transfer path for both the shielded `transferFrom(suint256)` + * and the native, infra-gated `transferFrom(uint256)` overloads. Reads and decrements the + * shielded allowance in shielded space, then delegates to `_shieldedTransfer`. The single + * `shieldedAllowance` slot is the only allowance store, so both overloads stay consistent. + * @dev Reverts with `InsufficientAllowance(spender, 0, amount)` — zeroing the allowance field so + * the revert payload does not leak the shielded allowance value (native path included). + * @param encryptEmit If `true`, the downstream `_shieldedTransfer` emits the encrypted-bytes + * `Transfer(address,address,bytes)` overload; otherwise it emits the inherited + * plaintext `Transfer(address,address,uint256)` overload. The flag never + * changes the allowance or balance logic, only the event shape. + */ + function _spendAllowanceAndTransfer(address sender, address recipient, suint256 amount, bool encryptEmit) internal { + MYieldToOneStorageStruct storage $ = _getMYieldToOneStorageLocation(); + suint256 spenderAllowance = $.shieldedAllowance[sender][msg.sender]; + + // Infinite-allowance shortcut mirrors `ERC20ExtendedUpgradeable.transferFrom` (line 106): + // a max-value allowance does not decrement. The cast to `uint256` is a control-flow + // comparison only — it does not write back to storage and does not leak the shielded + // value to any external observer. + if (uint256(spenderAllowance) != type(uint256).max) { + if (spenderAllowance < amount) revert IERC20Extended.InsufficientAllowance(msg.sender, 0, uint256(amount)); + + // NOTE: Can be `unchecked` because the `spenderAllowance < amount` check above guarantees + // `spenderAllowance >= amount`, so the subtraction never underflows. + unchecked { + $.shieldedAllowance[sender][msg.sender] = spenderAllowance - amount; + } } + + _shieldedTransfer(sender, recipient, amount, encryptEmit); + } + + /** + * @dev Shielded transfer pipeline. Mirrors the structure of `MExtension._transfer` but + * entry-points to the same `_update` primitive via a `suint256 → uint256` bridge. + * Reuses the existing `_beforeTransfer` hook for freeze/pause checks. + * @dev Reverts with `InsufficientBalance(account, 0, amount)` — zeroing the `balance` + * field so the revert payload does not leak the shielded balance value, matching + * the precedent set by `_revertIfInsufficientBalance`. + * @param encryptEmit Selects the `Transfer` event overload: `true` emits the + * encrypted-bytes overload via `_emitEncryptedTransfer` (user-to-user + * `suint256` entry points); `false` emits the inherited plaintext + * `Transfer(uint256)` overload (native infra-gated `transferFrom`, + * where the amount is already public via the bridge calldata). + */ + function _shieldedTransfer(address sender, address recipient, suint256 amount, bool encryptEmit) internal { + uint256 amount_ = uint256(amount); + + _revertIfInvalidRecipient(recipient); + _beforeTransfer(sender, recipient, amount_); + + if (encryptEmit) { + _emitEncryptedTransfer(sender, recipient, amount); + } else { + emit Transfer(sender, recipient, amount_); + } + + if (amount_ == 0) return; + + if (_getMYieldToOneStorageLocation().balanceOf[sender] < amount) { + revert InsufficientBalance(sender, 0, amount_); + } + + _update(sender, recipient, amount_); + } + + /* ============ Encrypted Transfer Event Pipeline ============ */ + + /** + * @dev Emits the encrypted-bytes `Transfer(address,address,bytes)` overload for a + * user-to-user shielded transfer. The amount is encrypted under an AES-GCM key + * derived from ECDH between the contract's private key and the recipient's + * registered public key, plus HKDF. + * @dev Unregistered-recipient fallback: if the recipient has not called + * `registerPublicKey`, the event is emitted with empty `bytes` and the transfer + * still succeeds. The recipient recovers the amount only via their own gated + * `balanceOf` read — historical amounts are not recoverable from logs. + * @dev Reverts `ContractKeyNotSet` if the admin has not yet installed the contract + * keypair via `setContractKey`. This is a configuration error, not a runtime + * failure — it should only ever fire on a misconfigured deployment. + * @dev The AES-GCM nonce is derived from a contract-wide monotonic counter + * (`encryptedEventNonce`) hashed with `(from, to)` — see slot 8 NatSpec for + * the rationale (departure from the tutorial's collision-prone formulation). + * @param from The sender of the transfer (mirrors `Transfer.from`). + * @param to The recipient of the transfer (mirrors `Transfer.to`). + * @param amount The shielded amount being transferred. + */ + function _emitEncryptedTransfer(address from, address to, suint256 amount) internal { + MYieldToOneStorageStruct storage $ = _getMYieldToOneStorageLocation(); + bytes memory pubKey = $.publicKeys[to]; + + // Unregistered recipient — emit the empty-ciphertext fallback (transfer still + // succeeds; amount recoverable only via gated `balanceOf`). + if (pubKey.length == 0) { + emit Transfer(from, to, bytes("")); + return; + } + + // The control-flow comparison only — see `setContractKey` NatSpec for the open + // question filed with Seismic about shielded-storage zero-sentinel reads. + if (bytes32($.contractPrivateKey) == bytes32(0)) revert ContractKeyNotSet(); + + // Pre-increment so the first emitted nonce uses counter value 1, and no two + // encrypted emits ever share a nonce under the same AES-GCM key. + uint256 n = ++$.encryptedEventNonce; + + sbytes32 sharedSecret = _ecdh($.contractPrivateKey, pubKey); + sbytes32 aesKey = _hkdf(sharedSecret); + bytes12 nonce = bytes12(keccak256(abi.encode(from, to, n))); + bytes memory ciphertext = _aesGcmEncrypt(aesKey, nonce, abi.encode(uint256(amount))); + + emit Transfer(from, to, ciphertext); + } + + /** + * @dev Thin wrapper around the Seismic ECDH precompile at `0x65`. Computes the + * shared secret between `privKey` (kept in shielded storage) and the + * recipient's compressed (33-byte) `peerPubKey`. The output is shielded so it + * stays in flagged storage for the HKDF step. + * @dev Reverts `PrecompileFailed(0x65)` if the precompile returns failure. + */ + function _ecdh(sbytes32 privKey, bytes memory peerPubKey) internal view returns (sbytes32) { + (bool success, bytes memory result) = address(0x65).staticcall(abi.encodePacked(bytes32(privKey), peerPubKey)); + if (!success) revert PrecompileFailed(address(0x65)); + return sbytes32(abi.decode(result, (bytes32))); + } + + /** + * @dev Thin wrapper around the Seismic HKDF precompile at `0x68`. Expands the ECDH + * shared secret into a fresh AES-GCM key. Both input and output are shielded. + * @dev Reverts `PrecompileFailed(0x68)` if the precompile returns failure. + */ + function _hkdf(sbytes32 sharedSecret) internal view returns (sbytes32) { + (bool success, bytes memory result) = address(0x68).staticcall(abi.encodePacked(bytes32(sharedSecret))); + if (!success) revert PrecompileFailed(address(0x68)); + return sbytes32(abi.decode(result, (bytes32))); + } + + /** + * @dev Thin wrapper around the Seismic AES-GCM-encrypt precompile at `0x66`. + * Encrypts `plaintext` under `key` with the supplied 12-byte `nonce` and + * returns the raw ciphertext (authentication tag included, per the + * precompile's wire format). + * @dev Reverts `PrecompileFailed(0x66)` if the precompile returns failure. + */ + function _aesGcmEncrypt(sbytes32 key, bytes12 nonce, bytes memory plaintext) internal view returns (bytes memory) { + (bool success, bytes memory ciphertext) = address(0x66).staticcall( + abi.encodePacked(bytes32(key), nonce, plaintext) + ); + if (!success) revert PrecompileFailed(address(0x66)); + return ciphertext; + } + + /** + * @dev Shielded approve pipeline. Writes to `MYieldToOneStorageStruct.shieldedAllowance` + * in this contract's own ERC-7201 slot; the inherited + * `ERC20ExtendedStorageStruct.allowance` slot is never touched. Reuses the + * existing `_beforeApprove` hook for freeze checks. + */ + function _shieldedApprove(address account, address spender, suint256 amount) internal { + uint256 amount_ = uint256(amount); + + _beforeApprove(account, spender, amount_); + + _getMYieldToOneStorageLocation().shieldedAllowance[account][spender] = amount; + + emit Approval(account, spender, amount_); + } + + /** + * @dev Shielded balance accessor for internal callers. Bypasses the public `balanceOf` + * gate. Used by `_revertIfInsufficientBalance` (still reached via the unwrap path + * in `MExtension._unwrap`) and available for any future shielded internal logic. + * Returns the raw `suint256` so comparisons stay shielded. + */ + function _balanceOf(address account) internal view returns (suint256) { + return _getMYieldToOneStorageLocation().balanceOf[account]; + } + + /** + * @dev Returns whether `account` is trusted M0 infra — the single place the `swapFacility` + * immutable and the dynamic allowlist are OR'd together. `swapFacility` is permanently + * exempt (immutable, cannot be admin-removed); every other infra address (Portal, + * LimitOrderProtocol, ...) is curated in the admin-gated allowlist. + * Gates the native `approve` / `transferFrom` paths and the `balanceOf` read. + * @param account The address being checked. + * @return Whether the address is trusted M0 infra. + */ + function _isInfra(address account) internal view returns (bool) { + return account == swapFacility || _getMYieldToOneStorageLocation().allowlist[account]; + } + + /** + * @dev Overrides `MExtension._revertIfInsufficientBalance` to compare in shielded space and + * revert with `balance = 0` so the holder's actual balance does not leak via the revert + * payload. The `IMExtension.InsufficientBalance` error shape is preserved (no interface + * change) — only the `balance` field is zeroed at the call site. + */ + function _revertIfInsufficientBalance(address account, uint256 amount) internal view override { + if (_balanceOf(account) < suint256(amount)) revert InsufficientBalance(account, 0, amount); } /** @@ -283,4 +704,22 @@ contract MYieldToOne is IMYieldToOne, MYieldToOneStorageLayout, MExtension, Free emit YieldRecipientSet(yieldRecipient_); } + + /** + * @dev Sets the infra allowlist status of `account`. + * @param account The address whose allowlist status is being set. + * @param status The new allowlist status (`true` = allowlisted). + */ + function _setAllowlisted(address account, bool status) internal { + if (account == address(0)) revert ZeroAllowlistAccount(); + + MYieldToOneStorageStruct storage $ = _getMYieldToOneStorageLocation(); + + // Return early if the status is unchanged. + if ($.allowlist[account] == status) return; + + $.allowlist[account] = status; + + emit AllowlistSet(account, status); + } } diff --git a/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol b/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol index 72583806..42c80724 100644 --- a/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol +++ b/src/projects/yieldToOne/MYieldToOneForcedTransfer.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IMYieldToOne } from "./interfaces/IMYieldToOne.sol"; diff --git a/src/projects/yieldToOne/interfaces/IMYieldToOne.sol b/src/projects/yieldToOne/interfaces/IMYieldToOne.sol index ac45702f..33306ccc 100644 --- a/src/projects/yieldToOne/interfaces/IMYieldToOne.sol +++ b/src/projects/yieldToOne/interfaces/IMYieldToOne.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title M Extension where all yield is claimable by a single recipient. @@ -21,6 +21,46 @@ interface IMYieldToOne { */ event YieldRecipientSet(address indexed yieldRecipient); + /** + * @notice Emitted when an address is added to or removed from the infra allowlist. + * @param account The address whose allowlist status changed. + * @param status The new allowlist status (`true` = allowlisted). + */ + event AllowlistSet(address indexed account, bool status); + + /** + * @notice Emitted by user-to-user shielded transfers (the `suint256` overloads). The + * third field is an AES-GCM ciphertext of the amount, encrypted to the + * recipient's registered public key via ECDH against the contract's keypair. + * Empty bytes when the recipient has not registered a public key — the transfer + * still succeeds, but the amount is recoverable only via the recipient's gated + * `balanceOf` read. + * @dev Distinct `topic0` from the inherited `Transfer(address,address,uint256)`: + * indexers MUST subscribe to both signatures to observe the full transfer + * history. Infra-mediated paths (mint, burn, native `transferFrom(uint256)`, + * forced transfer) emit the inherited `uint256` overload exclusively. + * @param from The address transferring the tokens. + * @param to The address receiving the tokens. + * @param encryptedAmount AES-GCM ciphertext of the transferred amount, or `bytes("")` + * when `to` has not registered a public key. + */ + event Transfer(address indexed from, address indexed to, bytes encryptedAmount); + + /** + * @notice Emitted by `setContractKey` once the contract keypair is installed. Only the + * public key is logged; the private key is held in shielded storage and is + * never observable from logs or events. + * @param publicKey The contract's compressed (33-byte) secp256k1 public key. + */ + event ContractKeySet(bytes publicKey); + + /** + * @notice Emitted by `registerPublicKey` when an account installs or overwrites its + * recipient public key for encrypted-event decryption. + * @param account The account whose recipient public key was (re-)registered. + */ + event PublicKeyRegistered(address indexed account); + /* ============ Custom Errors ============ */ /// @notice Emitted in initializer if Yield Recipient is 0x0. @@ -32,6 +72,42 @@ interface IMYieldToOne { /// @notice Emitted in initializer if Admin is 0x0. error ZeroAdmin(); + /// @notice Emitted when a public read accesses a shielded value without holder authorization + /// (caller must use a Seismic signed read with `msg.sender == account`). + error Unauthorized(); + + /// @notice Reverted when native `IERC20.approve` is called with a non-allowlisted spender, or + /// when the `permit` path is invoked. Holders must use the shielded + /// `approve(address,suint256)` overload, or approve an allowlisted infra address. + error UseShieldedApprove(); + + /// @notice Reverted when the native `IERC20.transfer` path is invoked, or when native + /// `IERC20.transferFrom` is called by a non-allowlisted caller. Callers must use the + /// shielded overloads; only allowlisted infra may use the native `transferFrom` path. + error UseShieldedTransfer(); + + /// @notice Reverted in `setAllowlisted` if the account is the zero address. + error ZeroAllowlistAccount(); + + /// @notice Reverted by `setContractKey` / `registerPublicKey` if the supplied public + /// key is not exactly 33 bytes (compressed secp256k1 encoding). + error InvalidPublicKeyLength(); + + /// @notice Reverted by `setContractKey` if the contract keypair has already been + /// installed. Rotation is deliberately not supported — a new key would orphan + /// every historical ciphertext. + error ContractKeyAlreadySet(); + + /// @notice Reverted by the encrypted-emit path if a shielded transfer is attempted + /// before the admin has called `setContractKey`. + error ContractKeyNotSet(); + + /// @notice Reverted by an encrypted-emit precompile wrapper when the underlying + /// `staticcall` to the Seismic precompile fails. `precompile` is the address + /// of the precompile that returned a failing result (0x65 ECDH, 0x66 AES-GCM, + /// 0x68 HKDF). + error PrecompileFailed(address precompile); + /* ============ Interactive Functions ============ */ /// @notice Claims accrued yield to yield recipient. @@ -46,6 +122,88 @@ interface IMYieldToOne { */ function setYieldRecipient(address yieldRecipient) external; + /** + * @notice Adds or removes `account` from the infra allowlist. + * @dev MUST only be callable by the DEFAULT_ADMIN_ROLE. + * @dev Allowlisted addresses MUST be audited M0 infrastructure contracts (e.g. Portal, + * LimitOrderProtocol) — never EOAs or contracts that re-expose `balanceOf`. (SwapFacility + * is permanently exempt via the immutable and does not need allowlisting.) An + * allowlisted address may use the native `uint256` `approve` + * (as spender) and `transferFrom` (as caller) paths and may read any holder's cleartext + * `balanceOf`. + * @dev SHOULD revert if `account` is 0x0. SHOULD return early if the status is unchanged. + * @param account The address whose allowlist status is being set. + * @param status The new allowlist status (`true` = allowlisted). + */ + function setAllowlisted(address account, bool status) external; + + /** + * @notice Adds or removes a batch of accounts from the infra allowlist. + * @dev MUST only be callable by the DEFAULT_ADMIN_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`. + */ + function setAllowlisted(address[] calldata accounts, bool status) external; + + /** + * @notice Shielded ERC20 transfer. The amount is a Seismic shielded type and is stored + * and compared in shielded space; the public `Transfer` event still emits the + * cleartext amount for indexer compatibility (events are public on EVM). + * @param recipient The address receiving the tokens. + * @param amount The shielded amount to transfer. + * @return success Always `true` on non-revert (mirrors `IERC20.transfer`). + */ + function transfer(address recipient, suint256 amount) external returns (bool); + + /** + * @notice Shielded ERC20 approve. Stores the allowance as `suint256`. + * @param spender The address allowed to spend on behalf of `msg.sender`. + * @param amount The shielded allowance amount. Use `suint256(type(uint256).max)` for an + * infinite, non-decrementing allowance (matches `ERC20ExtendedUpgradeable`). + * @return success Always `true` on non-revert. + */ + function approve(address spender, suint256 amount) external returns (bool); + + /** + * @notice Shielded ERC20 transferFrom. Reads and decrements the shielded allowance in + * shielded space. Reverts with `InsufficientAllowance(spender, 0, amount)` — + * zeroing the allowance field so the revert payload does not leak the shielded + * allowance value. + * @param sender The address whose tokens are being moved. + * @param recipient The address receiving the tokens. + * @param amount The shielded amount to transfer. + * @return success Always `true` on non-revert. + */ + function transferFrom(address sender, address recipient, suint256 amount) external returns (bool); + + /** + * @notice Installs the contract's encryption keypair used to derive per-recipient + * AES-GCM keys for shielded `Transfer` event payloads. One-shot: reverts + * `ContractKeyAlreadySet` on any subsequent call. + * @dev MUST only be callable by the `DEFAULT_ADMIN_ROLE`. + * @dev MUST be sent as a Seismic `TxSeismic` transaction (type `0x4A`) so the + * private key is encrypted in calldata. This is an operational requirement + * that cannot be enforced from Solidity. + * @dev Reverts `InvalidPublicKeyLength` unless `publicKey.length == 33` + * (compressed secp256k1 encoding). + * @dev Rotation is intentionally out of scope: rotating the contract key would + * orphan every historical ciphertext stored in past events. + * @param privateKey The contract's secp256k1 private key, shielded at the ABI + * boundary so it remains in flagged storage. + * @param publicKey The contract's compressed (33-byte) secp256k1 public key. + */ + function setContractKey(sbytes32 privateKey, bytes calldata publicKey) external; + + /** + * @notice Registers the caller's recipient public key. Idempotent — a subsequent call + * overwrites the previously registered key (future ciphertexts use the new + * key; historical ciphertexts remain decryptable only with the old key). + * @dev Reverts `InvalidPublicKeyLength` unless `publicKey.length == 33`. + * @param publicKey The caller's compressed (33-byte) secp256k1 public key. + */ + function registerPublicKey(bytes calldata publicKey) external; + /* ============ View/Pure Functions ============ */ /// @notice The role that can manage the yield recipient. @@ -56,4 +214,28 @@ interface IMYieldToOne { /// @notice The address of the yield recipient. function yieldRecipient() external view returns (address); + + /** + * @notice Returns whether `account` is on the infra allowlist. + * @param account The address being queried. + * @return Whether the address is allowlisted. + */ + function isAllowlisted(address account) external view returns (bool); + + /** + * @notice Returns the recipient public key previously registered by `account` via + * `registerPublicKey`, or empty bytes if none has been registered. Plain + * (non-shielded) read — readable by any caller. + * @param account The address whose registered public key is being queried. + * @return The registered compressed (33-byte) secp256k1 public key, or empty bytes. + */ + function publicKeyOf(address account) external view returns (bytes memory); + + /** + * @notice Returns the contract's currently installed public key, or empty bytes if + * `setContractKey` has not yet been called. Plain (non-shielded) read — + * off-chain decryption clients fetch this to verify the ECDH peer. + * @return The contract's compressed (33-byte) secp256k1 public key, or empty bytes. + */ + function contractPublicKey() external view returns (bytes memory); } diff --git a/src/swap/ReentrancyLock.sol b/src/swap/ReentrancyLock.sol index 42814f14..ba195eea 100644 --- a/src/swap/ReentrancyLock.sol +++ b/src/swap/ReentrancyLock.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Locker } from "../../lib/uniswap-v4-periphery/src/libraries/Locker.sol"; diff --git a/src/swap/SwapFacility.sol b/src/swap/SwapFacility.sol index 5bc24021..d3177ac0 100644 --- a/src/swap/SwapFacility.sol +++ b/src/swap/SwapFacility.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; diff --git a/src/swap/UniswapV3SwapAdapter.sol b/src/swap/UniswapV3SwapAdapter.sol index 782bf928..8de12d24 100644 --- a/src/swap/UniswapV3SwapAdapter.sol +++ b/src/swap/UniswapV3SwapAdapter.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; diff --git a/src/swap/interfaces/IReentrancyLock.sol b/src/swap/interfaces/IReentrancyLock.sol index cd40d6bb..95cc5ac3 100644 --- a/src/swap/interfaces/IReentrancyLock.sol +++ b/src/swap/interfaces/IReentrancyLock.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Reentrancy Lock for SwapFacility contract. diff --git a/src/swap/interfaces/IRegistrarLike.sol b/src/swap/interfaces/IRegistrarLike.sol index beb1a804..344e8f7f 100644 --- a/src/swap/interfaces/IRegistrarLike.sol +++ b/src/swap/interfaces/IRegistrarLike.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Subset of Registrar interface required for source contracts. diff --git a/src/swap/interfaces/ISwapFacility.sol b/src/swap/interfaces/ISwapFacility.sol index ebe608ab..ee25d0e3 100644 --- a/src/swap/interfaces/ISwapFacility.sol +++ b/src/swap/interfaces/ISwapFacility.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title Swap Facility interface. diff --git a/src/swap/interfaces/IUniswapV3SwapAdapter.sol b/src/swap/interfaces/IUniswapV3SwapAdapter.sol index 4f097422..60fa4682 100644 --- a/src/swap/interfaces/IUniswapV3SwapAdapter.sol +++ b/src/swap/interfaces/IUniswapV3SwapAdapter.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /** * @title UniswapV3 swap adapter interface. diff --git a/src/swap/interfaces/uniswap/IV3SwapRouter.sol b/src/swap/interfaces/uniswap/IV3SwapRouter.sol index a8019d37..acbc2ef1 100644 --- a/src/swap/interfaces/uniswap/IV3SwapRouter.sol +++ b/src/swap/interfaces/uniswap/IV3SwapRouter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.26; +pragma solidity ^0.8.26; /// @title Router token swapping functionality /// @notice Functions for swapping tokens via Uniswap V3 diff --git a/test.sh b/test.sh index 80daafdd..0c1fb19e 100755 --- a/test.sh +++ b/test.sh @@ -15,7 +15,17 @@ while getopts d:g:p:t:v flag; do done export FOUNDRY_PROFILE=$profile + +# Use the Seismic-flavored forge for the seismic profile (mercury EVM, ssolc). +# Stock forge can't parse shielded types like suint256. +if [ "$FOUNDRY_PROFILE" = "seismic" ]; then + forge_bin=sforge +else + forge_bin=forge +fi + echo Using profile: $FOUNDRY_PROFILE +echo Forge binary: $forge_bin echo Higher verbosity: $verbose echo Gas report: $gas echo Test Match pattern: $test @@ -34,10 +44,10 @@ fi if [ -z "$test" ]; then if [ -z "$directory" ]; then - forge test --match-path "test/*" $gasReport $verbosity --force + "$forge_bin" test --match-path "test/*" $gasReport $verbosity --force else - forge test --match-path "$directory/*.t.sol" $gasReport $verbosity --force + "$forge_bin" test --match-path "$directory/*.t.sol" $gasReport $verbosity --force fi else - forge test --match-test "$test" $gasReport $verbosity --force + "$forge_bin" test --match-test "$test" $gasReport $verbosity --force fi diff --git a/test/harness/ForcedTransferableHarness.sol b/test/harness/ForcedTransferableHarness.sol index f04c55fe..a5144ae3 100644 --- a/test/harness/ForcedTransferableHarness.sol +++ b/test/harness/ForcedTransferableHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { ForcedTransferable } from "../../src/components/forcedTransferable/ForcedTransferable.sol"; diff --git a/test/harness/FreezableHarness.sol b/test/harness/FreezableHarness.sol index cce95d2d..d2fd9f61 100644 --- a/test/harness/FreezableHarness.sol +++ b/test/harness/FreezableHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Freezable } from "../../src/components/freezable/Freezable.sol"; diff --git a/test/harness/JMIExtensionHarness.sol b/test/harness/JMIExtensionHarness.sol index 2510cddf..78116c88 100644 --- a/test/harness/JMIExtensionHarness.sol +++ b/test/harness/JMIExtensionHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { JMIExtension } from "../../src/projects/jmi/JMIExtension.sol"; @@ -35,7 +35,7 @@ contract JMIExtensionHarness is JMIExtension { } function setBalanceOf(address account, uint256 amount) external { - _getMYieldToOneStorageLocation().balanceOf[account] = amount; + _getMYieldToOneStorageLocation().balanceOf[account] = suint256(amount); } function setTotalAssets(uint256 amount) external { diff --git a/test/harness/MEarnerManagerHarness.sol b/test/harness/MEarnerManagerHarness.sol index 4aeac67f..f660ee19 100644 --- a/test/harness/MEarnerManagerHarness.sol +++ b/test/harness/MEarnerManagerHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MEarnerManager } from "../../src/projects/earnerManager/MEarnerManager.sol"; diff --git a/test/harness/MExtensionHarness.sol b/test/harness/MExtensionHarness.sol index 734a2136..bbd17060 100644 --- a/test/harness/MExtensionHarness.sol +++ b/test/harness/MExtensionHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MExtension } from "../../src/MExtension.sol"; diff --git a/test/harness/MSpokeYieldFeeHarness.sol b/test/harness/MSpokeYieldFeeHarness.sol index c19c0da9..496c78b3 100644 --- a/test/harness/MSpokeYieldFeeHarness.sol +++ b/test/harness/MSpokeYieldFeeHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MSpokeYieldFee } from "../../src/projects/yieldToAllWithFee/MSpokeYieldFee.sol"; diff --git a/test/harness/MYieldFeeHarness.sol b/test/harness/MYieldFeeHarness.sol index cc1bdaa5..3bbfb270 100644 --- a/test/harness/MYieldFeeHarness.sol +++ b/test/harness/MYieldFeeHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MYieldFee } from "../../src/projects/yieldToAllWithFee/MYieldFee.sol"; diff --git a/test/harness/MYieldToOneForcedTransferHarness.sol b/test/harness/MYieldToOneForcedTransferHarness.sol index c0fbdd89..c300414a 100644 --- a/test/harness/MYieldToOneForcedTransferHarness.sol +++ b/test/harness/MYieldToOneForcedTransferHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MYieldToOneForcedTransfer } from "../../src/projects/yieldToOne/MYieldToOneForcedTransfer.sol"; @@ -31,6 +31,20 @@ contract MYieldToOneForcedTransferHarness is MYieldToOneForcedTransfer { } function setBalanceOf(address account, uint256 amount) external { - _getMYieldToOneStorageLocation().balanceOf[account] = amount; + _getMYieldToOneStorageLocation().balanceOf[account] = suint256(amount); + } + + /// @dev Bypasses the public `balanceOf` gate — for test assertions only. + function getBalanceOf(address account) external view returns (uint256) { + return uint256(_getMYieldToOneStorageLocation().balanceOf[account]); + } + + function setShieldedAllowance(address owner, address spender, uint256 amount) external { + _getMYieldToOneStorageLocation().shieldedAllowance[owner][spender] = suint256(amount); + } + + /// @dev Bypasses the `shieldedAllowance` gate — for test assertions only. + function getShieldedAllowance(address owner, address spender) external view returns (uint256) { + return uint256(_getMYieldToOneStorageLocation().shieldedAllowance[owner][spender]); } } diff --git a/test/harness/MYieldToOneHarness.sol b/test/harness/MYieldToOneHarness.sol index 8ca3cee1..c3e69613 100644 --- a/test/harness/MYieldToOneHarness.sol +++ b/test/harness/MYieldToOneHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { MYieldToOne } from "../../src/projects/yieldToOne/MYieldToOne.sol"; @@ -21,10 +21,29 @@ contract MYieldToOneHarness is MYieldToOne { } function setBalanceOf(address account, uint256 amount) external { - _getMYieldToOneStorageLocation().balanceOf[account] = amount; + _getMYieldToOneStorageLocation().balanceOf[account] = suint256(amount); + } + + /// @dev Bypasses the public `balanceOf` gate — for test assertions only. + function getBalanceOf(address account) external view returns (uint256) { + return uint256(_getMYieldToOneStorageLocation().balanceOf[account]); } function setTotalSupply(uint256 amount) external { _getMYieldToOneStorageLocation().totalSupply = amount; } + + function setShieldedAllowance(address owner, address spender, uint256 amount) external { + _getMYieldToOneStorageLocation().shieldedAllowance[owner][spender] = suint256(amount); + } + + /// @dev Bypasses the `shieldedAllowance` gate — for test assertions only. + function getShieldedAllowance(address owner, address spender) external view returns (uint256) { + return uint256(_getMYieldToOneStorageLocation().shieldedAllowance[owner][spender]); + } + + /// @dev Reads the monotonic encrypted-event nonce counter (slot 8) — for test assertions only. + function getEncryptedEventNonce() external view returns (uint256) { + return _getMYieldToOneStorageLocation().encryptedEventNonce; + } } diff --git a/test/harness/PausableHarness.sol b/test/harness/PausableHarness.sol index ba6cf443..893ef4f4 100644 --- a/test/harness/PausableHarness.sol +++ b/test/harness/PausableHarness.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Pausable } from "../../src/components/pausable/Pausable.sol"; diff --git a/test/integration/MEarnerManager.t.sol b/test/integration/MEarnerManager.t.sol index 7fb0d773..36d85a27 100644 --- a/test/integration/MEarnerManager.t.sol +++ b/test/integration/MEarnerManager.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Upgrades } from "../../lib/openzeppelin-foundry-upgrades/src/Upgrades.sol"; diff --git a/test/integration/MExtensionSystem.t.sol b/test/integration/MExtensionSystem.t.sol index 7c958d62..088ef427 100644 --- a/test/integration/MExtensionSystem.t.sol +++ b/test/integration/MExtensionSystem.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Upgrades } from "../../lib/openzeppelin-foundry-upgrades/src/Upgrades.sol"; import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; diff --git a/test/integration/MExtention.t.sol b/test/integration/MExtention.t.sol index f17381c2..70818d64 100644 --- a/test/integration/MExtention.t.sol +++ b/test/integration/MExtention.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Upgrades } from "../../lib/openzeppelin-foundry-upgrades/src/Upgrades.sol"; diff --git a/test/integration/MYieldFee.t.sol b/test/integration/MYieldFee.t.sol index 55ec66b0..add2cf26 100644 --- a/test/integration/MYieldFee.t.sol +++ b/test/integration/MYieldFee.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Upgrades } from "../../lib/openzeppelin-foundry-upgrades/src/Upgrades.sol"; diff --git a/test/integration/MYieldToOne.t.sol b/test/integration/MYieldToOne.t.sol index 96ed7500..02880e15 100644 --- a/test/integration/MYieldToOne.t.sol +++ b/test/integration/MYieldToOne.t.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../lib/common/src/interfaces/IERC20.sol"; +import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; import { IAccessControl } from "../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; @@ -77,7 +78,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { _swapInM(address(mYieldToOne), alice, alice, amount); // Check balances of MYieldToOne and Alice after wrapping - assertEq(mYieldToOne.balanceOf(alice), amount); // user receives exact amount + assertEq(mYieldToOne.getBalanceOf(alice), amount); // user receives exact amount assertApproxEqAbs(mToken.balanceOf(address(mYieldToOne)), amount, 2); // rounds down // Fast forward 10 days in the future to generate yield @@ -90,8 +91,8 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { vm.prank(alice); mYieldToOne.transfer(bob, amount / 2); - assertEq(mYieldToOne.balanceOf(bob), amount / 2); - assertEq(mYieldToOne.balanceOf(alice), amount / 2); + assertEq(mYieldToOne.getBalanceOf(bob), amount / 2); + assertEq(mYieldToOne.getBalanceOf(alice), amount / 2); // yield stays the same assertEq(mYieldToOne.yield(), 11375); @@ -107,17 +108,17 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { assertEq(mYieldToOne.yield(), 11373); - assertEq(mYieldToOne.balanceOf(bob), 0); - assertEq(mYieldToOne.balanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), 0); + assertEq(mYieldToOne.getBalanceOf(alice), 0); assertEq(mToken.balanceOf(bob), amount + amount / 2); assertEq(mToken.balanceOf(alice), amount / 2); - assertEq(mYieldToOne.balanceOf(yieldRecipient), 0); + assertEq(mYieldToOne.getBalanceOf(yieldRecipient), 0); // claim yield mYieldToOne.claimYield(); - assertEq(mYieldToOne.balanceOf(yieldRecipient), 11373); + assertEq(mYieldToOne.getBalanceOf(yieldRecipient), 11373); assertEq(mYieldToOne.yield(), 0); assertEq(mToken.balanceOf(address(mYieldToOne)), 11373); assertEq(mYieldToOne.totalSupply(), 11373); @@ -131,7 +132,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { _swapInM(address(mYieldToOne), bob, bob, amount); // Check balances of MYieldToOne and Bob after wrapping - assertEq(mYieldToOne.balanceOf(bob), amount); + assertEq(mYieldToOne.getBalanceOf(bob), amount); assertEq(mToken.balanceOf(address(mYieldToOne)), 11373 + amount); // Disable earning for the contract @@ -183,7 +184,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { _swapInM(address(mYieldToOne), alice, alice, 5e6); - assertEq(mYieldToOne.balanceOf(alice), 5e6); + assertEq(mYieldToOne.getBalanceOf(alice), 5e6); assertEq(mYieldToOne.totalSupply(), 5e6); assertEq(mToken.balanceOf(alice), 5e6); @@ -193,7 +194,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { _swapInM(address(mYieldToOne), alice, alice, 5e6); - assertEq(mYieldToOne.balanceOf(alice), 10e6); + assertEq(mYieldToOne.getBalanceOf(alice), 10e6); assertEq(mYieldToOne.totalSupply(), 10e6); assertEq(mToken.balanceOf(alice), 0); @@ -206,7 +207,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { assertEq(mYieldToOne.yield(), 42_3730); - assertEq(mYieldToOne.balanceOf(alice), 10e6); + assertEq(mYieldToOne.getBalanceOf(alice), 10e6); assertEq(mYieldToOne.totalSupply(), 10e6); } @@ -217,12 +218,12 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { _swapInMWithPermitVRS(address(mYieldToOne), alice, aliceKey, alice, 5e6, 0, block.timestamp); - assertEq(mYieldToOne.balanceOf(alice), 5e6); + assertEq(mYieldToOne.getBalanceOf(alice), 5e6); assertEq(mToken.balanceOf(alice), 5e6); _swapInMWithPermitSignature(address(mYieldToOne), alice, aliceKey, alice, 5e6, 1, block.timestamp); - assertEq(mYieldToOne.balanceOf(alice), 10e6); + assertEq(mYieldToOne.getBalanceOf(alice), 10e6); assertEq(mToken.balanceOf(alice), 0); } @@ -239,7 +240,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { // 2 wei are lost due to rounding assertApproxEqAbs(mToken.balanceOf(address(mYieldToOne)), 10e6, 2); assertEq(mToken.balanceOf(alice), 10e6); - assertEq(mYieldToOne.balanceOf(alice), 10e6); + assertEq(mYieldToOne.getBalanceOf(alice), 10e6); assertEq(mYieldToOne.totalSupply(), 10e6); // Move time forward to generate yield @@ -251,7 +252,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { assertApproxEqAbs(mToken.balanceOf(address(mYieldToOne)), 42_3730 + 5e6, 1); assertEq(mToken.balanceOf(alice), 15e6); - assertEq(mYieldToOne.balanceOf(alice), 5e6); + assertEq(mYieldToOne.getBalanceOf(alice), 5e6); assertEq(mYieldToOne.totalSupply(), 5e6); _swapMOut(address(mYieldToOne), alice, alice, 5e6); @@ -263,11 +264,16 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { assertEq(mYieldToOne.yield(), 42_3730 - 2); assertEq(mToken.balanceOf(address(mYieldToOne)), 42_3730 - 2); - assertEq(mYieldToOne.balanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(alice), 0); assertEq(mYieldToOne.totalSupply(), 0); } function test_unwrapWithPermits() external { + // MYieldToOne's `permit` is deferred (reverts), so the SwapFacility `swapWithPermit` + // swap-out path is unsupported for this token. Migrated to the plain native-`approve` + // swap-out path: the holder calls native `approve(swapFacility, amount)` (allowed because + // swapFacility is infra) then drives a non-permit swap-out. Same balance assertions as the + // original permit-based flow. _addToList(EARNERS_LIST, address(mYieldToOne)); mYieldToOne.enableEarning(); @@ -276,19 +282,36 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { _giveM(address(mYieldToOne), 11e6); assertEq(mToken.balanceOf(alice), 10e6); - assertEq(mYieldToOne.balanceOf(alice), 11e6); + assertEq(mYieldToOne.getBalanceOf(alice), 11e6); - _swapOutMWithPermitVRS(address(mYieldToOne), alice, aliceKey, alice, 5e6, 0, block.timestamp); + _swapMOut(address(mYieldToOne), alice, alice, 5e6); - assertEq(mYieldToOne.balanceOf(alice), 6e6); + assertEq(mYieldToOne.getBalanceOf(alice), 6e6); assertEq(mToken.balanceOf(alice), 15e6); - _swapOutMWithPermitSignature(address(mYieldToOne), alice, aliceKey, alice, 5e6, 1, block.timestamp); + _swapMOut(address(mYieldToOne), alice, alice, 5e6); - assertEq(mYieldToOne.balanceOf(alice), 1e6); + assertEq(mYieldToOne.getBalanceOf(alice), 1e6); assertEq(mToken.balanceOf(alice), 20e6); } + function test_unwrapWithPermits_unsupported() external { + // The `swapWithPermit` swap-out is explicitly unsupported for MYieldToOne: `permit` reverts, + // the revert is swallowed by SwapFacility's try/catch, the shielded allowance stays zero, and + // the subsequent native `transferFrom` (SwapFacility is infra) reverts on zero allowance. + _addToList(EARNERS_LIST, address(mYieldToOne)); + mYieldToOne.enableEarning(); + + mYieldToOne.setBalanceOf(alice, 11e6); + mYieldToOne.setTotalSupply(11e6); + _giveM(address(mYieldToOne), 11e6); + + vm.expectRevert( + abi.encodeWithSelector(IERC20Extended.InsufficientAllowance.selector, address(swapFacility), 0, 5e6) + ); + _swapOutMWithPermitVRS(address(mYieldToOne), alice, aliceKey, alice, 5e6, 0, block.timestamp); + } + /* ============ claimYield ============ */ function test_claimYield() external { @@ -301,7 +324,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { // 2 wei are lost due to rounding assertApproxEqAbs(mToken.balanceOf(address(mYieldToOne)), 10e6, 2); - assertEq(mYieldToOne.balanceOf(yieldRecipient), 0); + assertEq(mYieldToOne.getBalanceOf(yieldRecipient), 0); // Move time forward to generate yield vm.warp(vm.getBlockTimestamp() + 365 days); @@ -314,7 +337,7 @@ contract MYieldToOneIntegrationTests is BaseIntegrationTest { assertEq(mYieldToOne.yield(), 0); assertEq(mYieldToOne.totalSupply(), 10e6 + 42_3730); - assertEq(mYieldToOne.balanceOf(yieldRecipient), 42_3730); + assertEq(mYieldToOne.getBalanceOf(yieldRecipient), 42_3730); assertEq(mToken.balanceOf(address(mYieldToOne)), 10e6 + 42_3730); } } diff --git a/test/integration/SwapFacility.t.sol b/test/integration/SwapFacility.t.sol index fc28ad6b..c1640d22 100644 --- a/test/integration/SwapFacility.t.sol +++ b/test/integration/SwapFacility.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { PausableUpgradeable } from "../../lib/common/lib/openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol"; diff --git a/test/integration/UniswapV3SwapAdapter.t.sol b/test/integration/UniswapV3SwapAdapter.t.sol index 71f82205..549d934e 100644 --- a/test/integration/UniswapV3SwapAdapter.t.sol +++ b/test/integration/UniswapV3SwapAdapter.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; diff --git a/test/unit/MExtension.t.sol b/test/unit/MExtension.t.sol index bb83e9ac..f866fb29 100644 --- a/test/unit/MExtension.t.sol +++ b/test/unit/MExtension.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../lib/common/src/interfaces/IERC20.sol"; import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; diff --git a/test/unit/components/forcedTransferable/ForcedTransferable.t.sol b/test/unit/components/forcedTransferable/ForcedTransferable.t.sol index 2c395862..06582dbf 100644 --- a/test/unit/components/forcedTransferable/ForcedTransferable.t.sol +++ b/test/unit/components/forcedTransferable/ForcedTransferable.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IAccessControl } from "../../../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; diff --git a/test/unit/components/freezable/Freezable.t.sol b/test/unit/components/freezable/Freezable.t.sol index 31d9be43..b4cad99f 100644 --- a/test/unit/components/freezable/Freezable.t.sol +++ b/test/unit/components/freezable/Freezable.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IAccessControl } from "../../../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; diff --git a/test/unit/components/pausable/Pausable.t.sol b/test/unit/components/pausable/Pausable.t.sol index 3d3d4b61..cd38b182 100644 --- a/test/unit/components/pausable/Pausable.t.sol +++ b/test/unit/components/pausable/Pausable.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IAccessControl } from "../../../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; diff --git a/test/unit/projects/JMIExtension.t.sol b/test/unit/projects/JMIExtension.t.sol index 5a6a4a98..e98c0ff8 100644 --- a/test/unit/projects/JMIExtension.t.sol +++ b/test/unit/projects/JMIExtension.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../../lib/common/src/interfaces/IERC20.sol"; import { IERC20Extended } from "../../../lib/common/src/interfaces/IERC20Extended.sol"; diff --git a/test/unit/projects/MEarnerManager.t.sol b/test/unit/projects/MEarnerManager.t.sol index ce658d6a..22bc390a 100644 --- a/test/unit/projects/MEarnerManager.t.sol +++ b/test/unit/projects/MEarnerManager.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IAccessControl } from "../../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; import { PausableUpgradeable } from "../../../lib/common/lib/openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol"; diff --git a/test/unit/projects/yieldToAllWithFee/MSpokeYieldFee.t.sol b/test/unit/projects/yieldToAllWithFee/MSpokeYieldFee.t.sol index 55efe18c..48a67453 100644 --- a/test/unit/projects/yieldToAllWithFee/MSpokeYieldFee.t.sol +++ b/test/unit/projects/yieldToAllWithFee/MSpokeYieldFee.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { ContinuousIndexingMath } from "../../../../lib/common/src/libs/ContinuousIndexingMath.sol"; import { IndexingMath } from "../../../../lib/common/src/libs/IndexingMath.sol"; diff --git a/test/unit/projects/yieldToAllWithFee/MYieldFee.t.sol b/test/unit/projects/yieldToAllWithFee/MYieldFee.t.sol index 0a674bbb..d664afd0 100644 --- a/test/unit/projects/yieldToAllWithFee/MYieldFee.t.sol +++ b/test/unit/projects/yieldToAllWithFee/MYieldFee.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IAccessControl } from "../../../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/access/IAccessControl.sol"; import { PausableUpgradeable } from "../../../../lib/common/lib/openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol"; diff --git a/test/unit/projects/yieldToOne/MYieldToOne.t.sol b/test/unit/projects/yieldToOne/MYieldToOne.t.sol index 6c2c3a1b..e1b97baf 100644 --- a/test/unit/projects/yieldToOne/MYieldToOne.t.sol +++ b/test/unit/projects/yieldToOne/MYieldToOne.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; + +import { Vm } from "../../../../lib/forge-std/src/Vm.sol"; import { IERC20 } from "../../../../lib/common/src/interfaces/IERC20.sol"; import { IERC20Extended } from "../../../../lib/common/src/interfaces/IERC20Extended.sol"; @@ -12,6 +14,7 @@ import { Upgrades, UnsafeUpgrades } from "../../../../lib/openzeppelin-foundry-u import { MYieldToOne } from "../../../../src/projects/yieldToOne/MYieldToOne.sol"; import { IMYieldToOne } from "../../../../src/projects/yieldToOne/interfaces/IMYieldToOne.sol"; +import { IMExtension } from "../../../../src/interfaces/IMExtension.sol"; import { IFreezable } from "../../../../src/components/freezable/IFreezable.sol"; import { IPausable } from "../../../../src/components/pausable/IPausable.sol"; @@ -156,7 +159,133 @@ contract MYieldToOneUnitTests is BaseUnitTest { ); } - /* ============ _approve ============ */ + /* ============ setAllowlisted ============ */ + + function test_setAllowlisted_onlyAdmin() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, alice, DEFAULT_ADMIN_ROLE) + ); + + vm.prank(alice); + mYieldToOne.setAllowlisted(bob, true); + } + + function test_setAllowlisted_zeroAllowlistAccount() public { + vm.expectRevert(IMYieldToOne.ZeroAllowlistAccount.selector); + + vm.prank(admin); + mYieldToOne.setAllowlisted(address(0), true); + } + + function test_setAllowlisted_noUpdate() public { + // Setting an account to its current (default `false`) status is a no-op: no event. + vm.recordLogs(); + + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, false); + + assertEq(vm.getRecordedLogs().length, 0); + assertFalse(mYieldToOne.isAllowlisted(bob)); + } + + function test_setAllowlisted_noUpdateAfterSet() public { + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, true); + + assertTrue(mYieldToOne.isAllowlisted(bob)); + + // Re-setting the same status emits no second event and leaves state unchanged. + vm.recordLogs(); + + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, true); + + assertEq(vm.getRecordedLogs().length, 0); + assertTrue(mYieldToOne.isAllowlisted(bob)); + } + + function test_setAllowlisted() public { + assertFalse(mYieldToOne.isAllowlisted(bob)); + + vm.expectEmit(); + emit IMYieldToOne.AllowlistSet(bob, true); + + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, true); + + assertTrue(mYieldToOne.isAllowlisted(bob)); + + vm.expectEmit(); + emit IMYieldToOne.AllowlistSet(bob, false); + + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, false); + + assertFalse(mYieldToOne.isAllowlisted(bob)); + } + + /* ============ setAllowlisted (batch) ============ */ + + function test_setAllowlisted_batchOnlyAdmin() public { + address[] memory infra = new address[](2); + infra[0] = bob; + infra[1] = carol; + + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, alice, DEFAULT_ADMIN_ROLE) + ); + + vm.prank(alice); + mYieldToOne.setAllowlisted(infra, true); + } + + function test_setAllowlisted_batchZeroAllowlistAccount() public { + address[] memory infra = new address[](2); + infra[0] = bob; + infra[1] = address(0); + + vm.expectRevert(IMYieldToOne.ZeroAllowlistAccount.selector); + + vm.prank(admin); + mYieldToOne.setAllowlisted(infra, true); + } + + function test_setAllowlisted_batch() public { + address[] memory infra = new address[](3); + infra[0] = bob; + infra[1] = carol; + infra[2] = david; + + vm.expectEmit(); + emit IMYieldToOne.AllowlistSet(bob, true); + vm.expectEmit(); + emit IMYieldToOne.AllowlistSet(carol, true); + vm.expectEmit(); + emit IMYieldToOne.AllowlistSet(david, true); + + vm.prank(admin); + mYieldToOne.setAllowlisted(infra, true); + + assertTrue(mYieldToOne.isAllowlisted(bob)); + assertTrue(mYieldToOne.isAllowlisted(carol)); + assertTrue(mYieldToOne.isAllowlisted(david)); + + vm.prank(admin); + mYieldToOne.setAllowlisted(infra, false); + + assertFalse(mYieldToOne.isAllowlisted(bob)); + assertFalse(mYieldToOne.isAllowlisted(carol)); + assertFalse(mYieldToOne.isAllowlisted(david)); + } + + /* ============ isAllowlisted ============ */ + + function test_isAllowlisted_swapFacilityNotAllowlisted() public view { + // swapFacility is permanently infra via the immutable, not via the allowlist mapping. + assertFalse(mYieldToOne.isAllowlisted(address(swapFacility))); + } + + /* ============ approve (shielded) ============ */ function test_approve_frozenAccount() public { vm.prank(freezeManager); @@ -165,7 +294,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, alice)); vm.prank(alice); - mYieldToOne.approve(bob, 1_000e6); + mYieldToOne.approve(bob, suint256(1_000e6)); } function test_approve_frozenSpender() public { @@ -174,10 +303,379 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, bob)); + vm.prank(alice); + mYieldToOne.approve(bob, suint256(1_000e6)); + } + + function test_approve_writesShieldedStorage() public { + uint256 amount = 1_000e6; + + vm.expectEmit(); + emit IERC20.Approval(alice, bob, amount); + + vm.prank(alice); + mYieldToOne.approve(bob, suint256(amount)); + + assertEq(mYieldToOne.getShieldedAllowance(alice, bob), amount); + } + + function test_approve_inheritedPathReverts() public { + // The IERC20 `approve(address,uint256)` is overridden to revert at the entry point. + vm.expectRevert(IMYieldToOne.UseShieldedApprove.selector); + + vm.prank(alice); + mYieldToOne.approve(bob, 1_000e6); + } + + function test_approve_permitReverts() public { + // Inherited EIP-2612 `permit` is overridden to revert directly at the entry point — + // both the v/r/s overload and the bytes-signature overload. + vm.expectRevert(IMYieldToOne.UseShieldedApprove.selector); + mYieldToOne.permit(alice, bob, 1_000e6, type(uint256).max, 0, bytes32(0), bytes32(0)); + + vm.expectRevert(IMYieldToOne.UseShieldedApprove.selector); + mYieldToOne.permit(alice, bob, 1_000e6, type(uint256).max, ""); + } + + /* ============ approve (native, allowlist-gated) ============ */ + + function test_nativeApprove_nonInfraSpenderReverts() public { + // bob is not allowlisted and not the swapFacility → native path is closed. + vm.expectRevert(IMYieldToOne.UseShieldedApprove.selector); + + vm.prank(alice); + mYieldToOne.approve(bob, 1_000e6); + } + + function test_nativeApprove_allowlistedSpender() public { + uint256 amount = 1_000e6; + + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, true); + + vm.expectEmit(); + emit IERC20.Approval(alice, bob, amount); + + vm.prank(alice); + mYieldToOne.approve(bob, amount); + + // Native path writes the SAME shielded slot as the shielded `approve(address,suint256)`. + assertEq(mYieldToOne.getShieldedAllowance(alice, bob), amount); + } + + function test_nativeApprove_swapFacilitySpender() public { + uint256 amount = 1_000e6; + + // swapFacility is permanently infra via the immutable — no allowlisting needed. + vm.expectEmit(); + emit IERC20.Approval(alice, address(swapFacility), amount); + + vm.prank(alice); + mYieldToOne.approve(address(swapFacility), amount); + + assertEq(mYieldToOne.getShieldedAllowance(alice, address(swapFacility)), amount); + } + + function test_nativeApprove_frozenAccount() public { + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, true); + + vm.prank(freezeManager); + mYieldToOne.freeze(alice); + + // Freeze is still enforced on the native path (routes through `_beforeApprove`). + vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, alice)); + + vm.prank(alice); + mYieldToOne.approve(bob, 1_000e6); + } + + function test_nativeApprove_frozenSpender() public { + vm.prank(admin); + mYieldToOne.setAllowlisted(bob, true); + + vm.prank(freezeManager); + mYieldToOne.freeze(bob); + + vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, bob)); + vm.prank(alice); mYieldToOne.approve(bob, 1_000e6); } + /* ============ transferFrom (native, allowlist-gated) ============ */ + + function test_nativeTransferFrom_nonInfraCallerReverts() public { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + mYieldToOne.setShieldedAllowance(alice, carol, amount); + + // carol is not allowlisted and not the swapFacility → native path is closed. + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + } + + function test_nativeTransferFrom_allowlistedCaller() public { + uint256 amount = 1_000e6; + uint256 allowanceAmount = 1_500e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(allowanceAmount)); + + vm.expectEmit(); + emit IERC20.Transfer(alice, bob, amount); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + + assertEq(mYieldToOne.getBalanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), amount); + // Decrements the shared shielded allowance slot. + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), allowanceAmount - amount); + } + + function test_nativeTransferFrom_swapFacilityCaller() public { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(alice); + mYieldToOne.approve(address(swapFacility), suint256(amount)); + + vm.expectEmit(); + emit IERC20.Transfer(alice, bob, amount); + + vm.prank(address(swapFacility)); + mYieldToOne.transferFrom(alice, bob, amount); + + assertEq(mYieldToOne.getBalanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), amount); + assertEq(mYieldToOne.getShieldedAllowance(alice, address(swapFacility)), 0); + } + + function test_nativeTransferFrom_infiniteAllowanceNoDecrement() public { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(type(uint256).max)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + + // Infinite allowance is preserved (matches the shielded path). + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), type(uint256).max); + } + + function test_nativeTransferFrom_insufficientAllowance() public { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(amount - 1)); + + // Allowance field zeroed in the revert payload — matches the shielded-balance precedent. + vm.expectRevert(abi.encodeWithSelector(IERC20Extended.InsufficientAllowance.selector, carol, 0, amount)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + } + + function test_nativeTransferFrom_paused() public { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(amount)); + + vm.prank(pauser); + mYieldToOne.pause(); + + // Pause is still enforced on the native path (routes through `_beforeTransfer`). + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + } + + function test_nativeTransferFrom_frozenAccount() public { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(amount)); + + vm.prank(freezeManager); + mYieldToOne.freeze(alice); + + vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, alice)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + } + + function test_nativeTransferFrom_shieldedApproveSpentByNativePath() public { + // Cross-consistency: a shielded `approve(suint256)` is spendable by a native + // `transferFrom(uint256)` from an allowlisted caller — proves the single shared slot. + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(amount)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, amount); + + assertEq(mYieldToOne.getBalanceOf(bob), amount); + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), 0); + } + + function test_nativeApprove_spentByShieldedTransferFrom() public { + // Cross-consistency (reverse): a native `approve(uint256)` to an allowlisted spender is + // spendable by the shielded `transferFrom(suint256)` — proves the single shared slot. + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOne.approve(carol, amount); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); + + assertEq(mYieldToOne.getBalanceOf(bob), amount); + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), 0); + } + + function testFuzz_nativeTransferFrom(uint256 supply, uint256 aliceBalance, uint256 transferAmount) external { + supply = bound(supply, 1, type(uint240).max); + aliceBalance = bound(aliceBalance, 1, supply); + transferAmount = bound(transferAmount, 1, aliceBalance); + uint256 bobBalance = supply - aliceBalance; + + if (bobBalance == 0) return; + + mYieldToOne.setBalanceOf(alice, aliceBalance); + mYieldToOne.setBalanceOf(bob, bobBalance); + mYieldToOne.setShieldedAllowance(alice, carol, transferAmount); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, transferAmount); + + assertEq(mYieldToOne.getBalanceOf(alice), aliceBalance - transferAmount); + assertEq(mYieldToOne.getBalanceOf(bob), bobBalance + transferAmount); + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), 0); + } + + /* ============ balanceOf (gated read) ============ */ + + function test_balanceOf_holderCanRead() public { + mYieldToOne.setBalanceOf(alice, 1_000e6); + + vm.prank(alice); + assertEq(mYieldToOne.balanceOf(alice), 1_000e6); + } + + function test_balanceOf_unauthorized() public { + mYieldToOne.setBalanceOf(alice, 1_000e6); + + vm.expectRevert(IMYieldToOne.Unauthorized.selector); + vm.prank(bob); + mYieldToOne.balanceOf(alice); + } + + function test_balanceOf_swapFacilityCanRead() public { + mYieldToOne.setBalanceOf(alice, 1_000e6); + + // SwapFacility is exempted so M0 infra can observe extension balances along its + // operational paths without forcing a Seismic signed read. + vm.prank(address(swapFacility)); + assertEq(mYieldToOne.balanceOf(alice), 1_000e6); + } + + function test_balanceOf_allowlistedInfraCanReadAnyHolder() public { + mYieldToOne.setBalanceOf(alice, 1_000e6); + + // An allowlisted infra contract (e.g. LimitOrderProtocol) reads an arbitrary holder's + // cleartext balance to drive its operational paths. + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(carol); + assertEq(mYieldToOne.balanceOf(alice), 1_000e6); + } + + function test_balanceOf_removingFromAllowlistReblocks() public { + mYieldToOne.setBalanceOf(alice, 1_000e6); + + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, true); + + vm.prank(carol); + assertEq(mYieldToOne.balanceOf(alice), 1_000e6); + + // Removing the address from the allowlist re-blocks its read. + vm.prank(admin); + mYieldToOne.setAllowlisted(carol, false); + + vm.expectRevert(IMYieldToOne.Unauthorized.selector); + vm.prank(carol); + mYieldToOne.balanceOf(alice); + } + + /* ============ allowance (gated read) ============ */ + + function test_allowance_unauthorized() public { + // alice approves bob; carol (third party) attempts to read → reverts. + vm.prank(alice); + mYieldToOne.approve(bob, suint256(500e6)); + + vm.expectRevert(IMYieldToOne.Unauthorized.selector); + vm.prank(carol); + mYieldToOne.allowance(alice, bob); + } + + function test_allowance_ownerCanRead() public { + vm.prank(alice); + mYieldToOne.approve(bob, suint256(500e6)); + + vm.prank(alice); + assertEq(mYieldToOne.allowance(alice, bob), 500e6); + } + + function test_allowance_spenderCanRead() public { + vm.prank(alice); + mYieldToOne.approve(bob, suint256(500e6)); + + vm.prank(bob); + assertEq(mYieldToOne.allowance(alice, bob), 500e6); + } + /* ============ _wrap ============ */ function test_wrap_frozenAccount() external { @@ -237,7 +735,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.prank(address(swapFacility)); mYieldToOne.wrap(alice, amount); - assertEq(mYieldToOne.balanceOf(alice), amount); + assertEq(mYieldToOne.getBalanceOf(alice), amount); assertEq(mYieldToOne.totalSupply(), amount); assertEq(mToken.balanceOf(alice), 0); @@ -289,7 +787,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.unwrap(alice, 1e6); assertEq(mYieldToOne.totalSupply(), 999e6); - assertEq(mYieldToOne.balanceOf(address(swapFacility)), 999e6); + assertEq(mYieldToOne.getBalanceOf(address(swapFacility)), 999e6); assertEq(mToken.balanceOf(address(swapFacility)), 1e6); vm.expectEmit(); @@ -299,7 +797,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.unwrap(alice, 499e6); assertEq(mYieldToOne.totalSupply(), 500e6); - assertEq(mYieldToOne.balanceOf(address(swapFacility)), 500e6); + assertEq(mYieldToOne.getBalanceOf(address(swapFacility)), 500e6); assertEq(mToken.balanceOf(address(swapFacility)), 500e6); vm.expectEmit(); @@ -309,30 +807,30 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.unwrap(alice, 500e6); assertEq(mYieldToOne.totalSupply(), 0); - assertEq(mYieldToOne.balanceOf(address(swapFacility)), 0); + assertEq(mYieldToOne.getBalanceOf(address(swapFacility)), 0); // M tokens are sent to SwapFacility and then forwarded to Alice assertEq(mToken.balanceOf(address(swapFacility)), amount); assertEq(mToken.balanceOf(address(mYieldToOne)), 0); } - /* ============ _transfer ============ */ - function test_transfer_frozenSender() external { + /* ============ transfer (shielded) ============ */ + function test_transfer_frozenSpender() external { uint256 amount = 1_000e6; mYieldToOne.setBalanceOf(alice, amount); - // Alice allows Carol to transfer tokens on her behalf + // Alice allows Carol to transfer tokens on her behalf (shielded approve). vm.prank(alice); - mYieldToOne.approve(carol, amount); + mYieldToOne.approve(carol, suint256(amount)); vm.prank(freezeManager); mYieldToOne.freeze(carol); - // Reverts cause Carol is frozen and cannot transfer tokens on Alice's behalf + // Reverts because Carol (the spender / msg.sender) is frozen. vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, carol)); vm.prank(carol); - mYieldToOne.transferFrom(alice, bob, amount); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); } function test_transfer_frozenAccount() external { @@ -345,7 +843,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, alice)); vm.prank(alice); - mYieldToOne.transfer(bob, amount); + mYieldToOne.transfer(bob, suint256(amount)); } function test_transfer_frozenRecipient() external { @@ -358,7 +856,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountFrozen.selector, bob)); vm.prank(alice); - mYieldToOne.transfer(bob, amount); + mYieldToOne.transfer(bob, suint256(amount)); } function test_transfer_paused() public { @@ -370,21 +868,46 @@ contract MYieldToOneUnitTests is BaseUnitTest { vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); vm.prank(alice); - mYieldToOne.transfer(bob, 1); + mYieldToOne.transfer(bob, suint256(1)); } function test_transfer() external { uint256 amount = 1_000e6; mYieldToOne.setBalanceOf(alice, amount); - vm.expectEmit(); - emit IERC20.Transfer(alice, bob, amount); + // bob has not registered a public key, so the shielded entry point emits the + // bytes-variant Transfer overload with an empty ciphertext (the empty-fallback + // branch runs before the contract-key check). Assert the indexed (from, to) fields + // and the empty payload; cryptographic-payload assertions live in the dedicated + // encrypted-transfer tests below. + vm.expectEmit(true, true, false, true); + emit IMYieldToOne.Transfer(alice, bob, bytes("")); + + vm.prank(alice); + mYieldToOne.transfer(bob, suint256(amount)); + + assertEq(mYieldToOne.getBalanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), amount); + } + + function test_transfer_insufficientBalance() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount - 1); + + // Shielded comparison reverts with `balance = 0` (not the real balance) to avoid leak. + vm.expectRevert(abi.encodeWithSelector(IMExtension.InsufficientBalance.selector, alice, 0, amount)); vm.prank(alice); - mYieldToOne.transfer(bob, amount); + mYieldToOne.transfer(bob, suint256(amount)); + } - assertEq(mYieldToOne.balanceOf(alice), 0); - assertEq(mYieldToOne.balanceOf(bob), amount); + function test_transfer_inheritedPathReverts() external { + // The IERC20 `transfer(address,uint256)` is overridden to revert at the entry point — + // no balance / freeze / pause state matters. + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + + vm.prank(alice); + mYieldToOne.transfer(bob, 1_000e6); } function testFuzz_transfer(uint256 supply, uint256 aliceBalance, uint256 transferAmount) external { @@ -399,10 +922,81 @@ contract MYieldToOneUnitTests is BaseUnitTest { mYieldToOne.setBalanceOf(bob, bobBalance); vm.prank(alice); - mYieldToOne.transfer(bob, transferAmount); + mYieldToOne.transfer(bob, suint256(transferAmount)); - assertEq(mYieldToOne.balanceOf(alice), aliceBalance - transferAmount); - assertEq(mYieldToOne.balanceOf(bob), bobBalance + transferAmount); + assertEq(mYieldToOne.getBalanceOf(alice), aliceBalance - transferAmount); + assertEq(mYieldToOne.getBalanceOf(bob), bobBalance + transferAmount); + } + + /* ============ transferFrom (shielded) ============ */ + + function test_transferFrom_finiteAllowanceDecrements() external { + uint256 amount = 1_000e6; + uint256 allowanceAmount = 1_500e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(allowanceAmount)); + + // bob has not registered a public key, so the shielded entry point emits the + // bytes-variant Transfer overload with an empty ciphertext (empty-fallback branch). + vm.expectEmit(true, true, false, true); + emit IMYieldToOne.Transfer(alice, bob, bytes("")); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); + + assertEq(mYieldToOne.getBalanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), amount); + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), allowanceAmount - amount); + } + + function test_transferFrom_infiniteAllowanceNoDecrement() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(type(uint256).max)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); + + // Infinite allowance is preserved (matches ERC20ExtendedUpgradeable.transferFrom semantics). + assertEq(mYieldToOne.getShieldedAllowance(alice, carol), type(uint256).max); + } + + function test_transferFrom_insufficientAllowance() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(amount - 1)); + + // Allowance field zeroed in the revert payload — matches the shielded-balance precedent. + vm.expectRevert(abi.encodeWithSelector(IERC20Extended.InsufficientAllowance.selector, carol, 0, amount)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); + } + + function test_transferFrom_noAllowance() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + // No prior approve — shielded allowance is zero. + vm.expectRevert(abi.encodeWithSelector(IERC20Extended.InsufficientAllowance.selector, carol, 0, amount)); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); + } + + function test_transferFrom_inheritedPathReverts() external { + // The IERC20 `transferFrom(address,address,uint256)` is overridden to revert at the + // entry point. + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, 1_000e6); } /* ============ yield ============ */ @@ -452,7 +1046,7 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.totalSupply(), 1_500e6); assertEq(mToken.balanceOf(yieldRecipient), 0); - assertEq(mYieldToOne.balanceOf(yieldRecipient), yield); + assertEq(mYieldToOne.getBalanceOf(yieldRecipient), yield); } /* ============ setYieldRecipient ============ */ @@ -511,6 +1105,451 @@ contract MYieldToOneUnitTests is BaseUnitTest { assertEq(mYieldToOne.yieldRecipient(), alice); assertEq(mYieldToOne.yield(), 0); - assertEq(mYieldToOne.balanceOf(yieldRecipient), 500); + assertEq(mYieldToOne.getBalanceOf(yieldRecipient), 500); + } + + /* ============ Encrypted Transfer Events — Helpers ============ */ + + /// @dev Canonical compressed-secp256k1 (33-byte) shape for tests. The actual bytes are + /// irrelevant in unit tests because the three precompiles (`0x65` ECDH, + /// `0x68` HKDF, `0x66` AES-GCM encrypt) are mocked — Seismic validates the + /// precompile semantics on devnet, not here. See task spec §C. + function _validPubKey(bytes1 marker) internal pure returns (bytes memory) { + bytes memory key = new bytes(33); + key[0] = 0x02; // compressed-secp256k1 even-Y prefix + for (uint256 i = 1; i < 33; ++i) { + key[i] = marker; + } + return key; + } + + /// @dev Installs deterministic mocks for the three Seismic precompiles so the + /// encrypted-emit pipeline can run end-to-end under plain `sforge`. The chosen + /// return values are arbitrary but distinct, so tests can assert the contract + /// forwards the precompile output as the event payload. + function _mockPrecompiles() internal { + // 0x65 ECDH → 32-byte shared secret. + vm.mockCall(address(0x65), bytes(""), abi.encode(bytes32(uint256(1)))); + // 0x68 HKDF → 32-byte AES-GCM key. + vm.mockCall(address(0x68), bytes(""), abi.encode(bytes32(uint256(2)))); + // 0x66 AES-GCM encrypt → opaque non-empty ciphertext. + vm.mockCall(address(0x66), bytes(""), hex"deadbeefcafebabe"); + } + + /// @dev Installs a contract keypair through the admin path. The actual private-key + /// bytes are never observed externally (would require a Seismic signed read). + function _installContractKey() internal { + vm.prank(admin); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(0xC0FFEE))), _validPubKey(0xAA)); + } + + /* ============ setContractKey ============ */ + + function test_setContractKey_onlyAdmin() public { + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, alice, DEFAULT_ADMIN_ROLE) + ); + + vm.prank(alice); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(1))), _validPubKey(0xAA)); + } + + function test_setContractKey_oneShot() public { + vm.prank(admin); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(0xC0FFEE))), _validPubKey(0xAA)); + + // Second call must revert — rotation is intentionally not supported. + vm.expectRevert(IMYieldToOne.ContractKeyAlreadySet.selector); + + vm.prank(admin); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(0xBEEF))), _validPubKey(0xBB)); + } + + function test_setContractKey_invalidLength_short() public { + bytes memory tooShort = new bytes(32); + + vm.expectRevert(IMYieldToOne.InvalidPublicKeyLength.selector); + + vm.prank(admin); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(1))), tooShort); + } + + function test_setContractKey_invalidLength_long() public { + bytes memory tooLong = new bytes(34); + + vm.expectRevert(IMYieldToOne.InvalidPublicKeyLength.selector); + + vm.prank(admin); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(1))), tooLong); + } + + function test_setContractKey_emitsContractKeySet() public { + bytes memory pubKey = _validPubKey(0xAA); + + vm.expectEmit(); + emit IMYieldToOne.ContractKeySet(pubKey); + + vm.prank(admin); + mYieldToOne.setContractKey(sbytes32(bytes32(uint256(0xC0FFEE))), pubKey); + + assertEq(mYieldToOne.contractPublicKey(), pubKey); + } + + /* ============ registerPublicKey ============ */ + + function test_registerPublicKey_writesStorage() public { + bytes memory pubKey = _validPubKey(0xBB); + + vm.prank(alice); + mYieldToOne.registerPublicKey(pubKey); + + assertEq(mYieldToOne.publicKeyOf(alice), pubKey); + } + + function test_registerPublicKey_idempotentOverwrite() public { + bytes memory firstKey = _validPubKey(0xBB); + bytes memory secondKey = _validPubKey(0xCC); + + vm.prank(alice); + mYieldToOne.registerPublicKey(firstKey); + + assertEq(mYieldToOne.publicKeyOf(alice), firstKey); + + // Re-registration overwrites — historical ciphertexts decrypt with the old key, + // future ciphertexts with the new one. + vm.prank(alice); + mYieldToOne.registerPublicKey(secondKey); + + assertEq(mYieldToOne.publicKeyOf(alice), secondKey); + } + + function test_registerPublicKey_invalidLength_short() public { + bytes memory tooShort = new bytes(32); + + vm.expectRevert(IMYieldToOne.InvalidPublicKeyLength.selector); + + vm.prank(alice); + mYieldToOne.registerPublicKey(tooShort); + } + + function test_registerPublicKey_invalidLength_long() public { + bytes memory tooLong = new bytes(34); + + vm.expectRevert(IMYieldToOne.InvalidPublicKeyLength.selector); + + vm.prank(alice); + mYieldToOne.registerPublicKey(tooLong); + } + + function test_registerPublicKey_emitsPublicKeyRegistered() public { + vm.expectEmit(); + emit IMYieldToOne.PublicKeyRegistered(alice); + + vm.prank(alice); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + } + + /* ============ Shielded Transfer — Encrypted Emit (registered recipient) ============ */ + + function test_shieldedTransfer_registeredRecipient_emitsBytesPayload() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + _installContractKey(); + _mockPrecompiles(); + + bytes memory recipientKey = _validPubKey(0xBB); + vm.prank(bob); + mYieldToOne.registerPublicKey(recipientKey); + + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); + + vm.recordLogs(); + + vm.prank(alice); + mYieldToOne.transfer(bob, suint256(amount)); + + // Counter incremented exactly once for the single encrypted emit. + assertEq(mYieldToOne.getEncryptedEventNonce(), 1); + + // Locate the emitted bytes-variant Transfer log and assert shape. + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); + + bool foundBytes; + bool foundPlaintext; + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(mYieldToOne)) continue; + if (logs[i].topics.length == 0) continue; + + if (logs[i].topics[0] == bytesTopic) { + foundBytes = true; + assertEq(address(uint160(uint256(logs[i].topics[1]))), alice); + assertEq(address(uint160(uint256(logs[i].topics[2]))), bob); + bytes memory payload = abi.decode(logs[i].data, (bytes)); + assertGt(payload.length, 0); + } else if (logs[i].topics[0] == plaintextTopic) { + foundPlaintext = true; + } + } + + assertTrue(foundBytes, "missing Transfer(address,address,bytes) emit"); + assertFalse(foundPlaintext, "plaintext Transfer(uint256) emitted on shielded path"); + + // Balance updates still happen. + assertEq(mYieldToOne.getBalanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), amount); + } + + function test_shieldedTransferFrom_registeredRecipient_emitsBytesPayload() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + _installContractKey(); + _mockPrecompiles(); + + vm.prank(bob); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + + vm.prank(alice); + mYieldToOne.approve(carol, suint256(amount)); + + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); + + vm.recordLogs(); + + vm.prank(carol); + mYieldToOne.transferFrom(alice, bob, suint256(amount)); + + assertEq(mYieldToOne.getEncryptedEventNonce(), 1); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); + + bool foundBytes; + bool foundPlaintext; + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(mYieldToOne)) continue; + if (logs[i].topics.length == 0) continue; + + if (logs[i].topics[0] == bytesTopic) { + foundBytes = true; + assertEq(address(uint160(uint256(logs[i].topics[1]))), alice); + assertEq(address(uint160(uint256(logs[i].topics[2]))), bob); + bytes memory payload = abi.decode(logs[i].data, (bytes)); + assertGt(payload.length, 0); + } else if (logs[i].topics[0] == plaintextTopic) { + foundPlaintext = true; + } + } + + assertTrue(foundBytes, "missing Transfer(address,address,bytes) emit"); + assertFalse(foundPlaintext, "plaintext Transfer(uint256) emitted on shielded path"); + } + + /* ============ Shielded Transfer — Unregistered Recipient Fallback ============ */ + + function test_shieldedTransfer_unregisteredRecipient_emitsEmptyBytesAndSucceeds() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + // Contract key IS set — we want to isolate the unregistered-recipient branch from + // the no-contract-key branch (the fallback fires BEFORE the contract-key check, so + // both branches are independently testable). + _installContractKey(); + + // bob is intentionally NOT registered; precompiles intentionally NOT mocked — the + // fallback path must not call any of them. + + vm.expectEmit(true, true, false, true); + emit IMYieldToOne.Transfer(alice, bob, bytes("")); + + vm.prank(alice); + mYieldToOne.transfer(bob, suint256(amount)); + + // Counter does NOT increment on the empty-bytes fallback (saves an SSTORE). + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); + + // Balances still update. + assertEq(mYieldToOne.getBalanceOf(alice), 0); + assertEq(mYieldToOne.getBalanceOf(bob), amount); + } + + /* ============ Shielded Transfer — Contract Key Not Set ============ */ + + function test_shieldedTransfer_contractKeyNotSet_reverts() external { + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + // Recipient IS registered but contract keypair is NOT installed → revert. + vm.prank(bob); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + + vm.expectRevert(IMYieldToOne.ContractKeyNotSet.selector); + + vm.prank(alice); + mYieldToOne.transfer(bob, suint256(amount)); + } + + /* ============ Dual-Emit Regression — Infra transferFrom stays plaintext ============ */ + + function test_nativeTransferFrom_registeredRecipient_emitsPlaintextOnly() external { + // Regression guard for the dual-emit refactor: the infra-gated native + // `transferFrom(uint256)` MUST keep emitting the inherited plaintext + // `Transfer(uint256)` overload, even when the recipient has a registered key. + uint256 amount = 1_000e6; + mYieldToOne.setBalanceOf(alice, amount); + + _installContractKey(); + _mockPrecompiles(); + + vm.prank(bob); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + + vm.prank(alice); + mYieldToOne.approve(address(swapFacility), suint256(amount)); + + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); + + vm.recordLogs(); + + vm.prank(address(swapFacility)); + mYieldToOne.transferFrom(alice, bob, amount); + + // Encrypted path was never entered. + assertEq(mYieldToOne.getEncryptedEventNonce(), 0); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); + + bool foundBytes; + bool foundPlaintext; + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(mYieldToOne)) continue; + if (logs[i].topics.length == 0) continue; + + if (logs[i].topics[0] == bytesTopic) { + foundBytes = true; + } else if (logs[i].topics[0] == plaintextTopic) { + foundPlaintext = true; + assertEq(address(uint160(uint256(logs[i].topics[1]))), alice); + assertEq(address(uint160(uint256(logs[i].topics[2]))), bob); + uint256 emittedAmount = abi.decode(logs[i].data, (uint256)); + assertEq(emittedAmount, amount); + } + } + + assertTrue(foundPlaintext, "missing plaintext Transfer(uint256) emit on infra path"); + assertFalse(foundBytes, "infra path leaked into encrypted-bytes Transfer overload"); + } + + /* ============ Dual-Emit Regression — Mint / Burn stay plaintext ============ */ + + function test_mint_emitsPlaintextOnly() external { + // _mint is exercised through SwapFacility.wrap (mirror of `test_wrap`). Even with a + // contract key installed and a recipient pubkey registered, mint MUST stay on the + // inherited plaintext `Transfer(uint256)` overload — bridge amounts are public. + uint256 amount = 1_000e6; + mToken.setBalanceOf(address(swapFacility), amount); + + _installContractKey(); + _mockPrecompiles(); + + vm.prank(alice); + mYieldToOne.registerPublicKey(_validPubKey(0xBB)); + + uint256 nonceBefore = mYieldToOne.getEncryptedEventNonce(); + + vm.recordLogs(); + + vm.prank(address(swapFacility)); + mYieldToOne.wrap(alice, amount); + + // Counter untouched: mint never enters the encrypted-emit path. + assertEq(mYieldToOne.getEncryptedEventNonce(), nonceBefore); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); + + bool foundBytes; + bool foundPlaintextMint; + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(mYieldToOne)) continue; + if (logs[i].topics.length == 0) continue; + + if (logs[i].topics[0] == bytesTopic) { + foundBytes = true; + } else if (logs[i].topics[0] == plaintextTopic) { + // Mint: from == address(0). + if (address(uint160(uint256(logs[i].topics[1]))) == address(0)) { + foundPlaintextMint = true; + assertEq(address(uint160(uint256(logs[i].topics[2]))), alice); + assertEq(abi.decode(logs[i].data, (uint256)), amount); + } + } + } + + assertTrue(foundPlaintextMint, "missing plaintext Transfer(0, recipient, amount) on mint"); + assertFalse(foundBytes, "mint leaked into encrypted-bytes Transfer overload"); + } + + function test_burn_emitsPlaintextOnly() external { + // _burn is exercised through SwapFacility.unwrap (mirror of `test_unwrap`). Even + // with a contract key installed and the (notional) recipient pubkey registered, + // burn MUST stay on the inherited plaintext `Transfer(uint256)` overload. + uint256 amount = 1_000e6; + + mYieldToOne.setBalanceOf(address(swapFacility), amount); + mYieldToOne.setTotalSupply(amount); + + mToken.setBalanceOf(address(mYieldToOne), amount); + + _installContractKey(); + _mockPrecompiles(); + + // Register a pubkey for swapFacility just to prove the dual-emit refactor does + // not accidentally route burn through the encrypted path even when the source + // address has a registered key. + vm.prank(address(swapFacility)); + mYieldToOne.registerPublicKey(_validPubKey(0xCC)); + + uint256 nonceBefore = mYieldToOne.getEncryptedEventNonce(); + + vm.recordLogs(); + + vm.prank(address(swapFacility)); + mYieldToOne.unwrap(alice, amount); + + // Counter untouched. + assertEq(mYieldToOne.getEncryptedEventNonce(), nonceBefore); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 bytesTopic = keccak256("Transfer(address,address,bytes)"); + bytes32 plaintextTopic = keccak256("Transfer(address,address,uint256)"); + + bool foundBytes; + bool foundPlaintextBurn; + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(mYieldToOne)) continue; + if (logs[i].topics.length == 0) continue; + + if (logs[i].topics[0] == bytesTopic) { + foundBytes = true; + } else if (logs[i].topics[0] == plaintextTopic) { + // Burn: to == address(0). + if (address(uint160(uint256(logs[i].topics[2]))) == address(0)) { + foundPlaintextBurn = true; + assertEq(address(uint160(uint256(logs[i].topics[1]))), address(swapFacility)); + assertEq(abi.decode(logs[i].data, (uint256)), amount); + } + } + } + + assertTrue(foundPlaintextBurn, "missing plaintext Transfer(account, 0, amount) on burn"); + assertFalse(foundBytes, "burn leaked into encrypted-bytes Transfer overload"); } } diff --git a/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol b/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol index 68da3bc3..ada4dece 100644 --- a/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol +++ b/test/unit/projects/yieldToOne/MYieldToOneForcedTransfer.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { IERC20 } from "../../../../lib/common/src/interfaces/IERC20.sol"; @@ -94,7 +94,7 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { function test_forceTransfer_succeedsForManager() public { uint256 amount = 1_000e6; mYieldToOneForcedTransfer.setBalanceOf(address(alice), amount); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), 0); vm.prank(freezeManager); mYieldToOneForcedTransfer.freeze(alice); @@ -102,21 +102,21 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { vm.prank(forcedTransferManager); mYieldToOneForcedTransfer.forceTransfer(alice, bob, amount); - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), 0); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), amount); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), amount); } function test_forceTransfer_revertsWhenNotFrozen() public { uint256 amount = 1_000e6; mYieldToOneForcedTransfer.setBalanceOf(address(alice), amount); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), 0); vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountNotFrozen.selector, alice)); vm.prank(forcedTransferManager); mYieldToOneForcedTransfer.forceTransfer(alice, bob, amount); - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), amount); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), amount); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), 0); } function test_forceTransfer_arrayLengthMismatch() public { @@ -138,7 +138,7 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { function test_forceTransfer_revertsForNonManager() public { uint256 amount = 1_000e6; mYieldToOneForcedTransfer.setBalanceOf(address(alice), amount); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), 0); vm.prank(freezeManager); mYieldToOneForcedTransfer.freeze(alice); @@ -172,15 +172,15 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { vm.prank(forcedTransferManager); mYieldToOneForcedTransfer.forceTransfer(alice, bob, transferAmount); - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), aliceBalance - transferAmount); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), bobBalance + transferAmount); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), aliceBalance - transferAmount); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), bobBalance + transferAmount); } else { vm.prank(forcedTransferManager); vm.expectRevert(abi.encodeWithSelector(IFreezable.AccountNotFrozen.selector, alice)); mYieldToOneForcedTransfer.forceTransfer(alice, bob, transferAmount); - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), aliceBalance); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), bobBalance); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), aliceBalance); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), bobBalance); } } @@ -212,10 +212,10 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { vm.prank(forcedTransferManager); mYieldToOneForcedTransfer.forceTransfers(frozenAccounts, recipients, amounts); - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), 0); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), 0); - assertEq(mYieldToOneForcedTransfer.balanceOf(carol), amount1); - assertEq(mYieldToOneForcedTransfer.balanceOf(david), amount2); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(carol), amount1); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(david), amount2); } function test_forceTransfers_revertsWhenNotFrozen() public { @@ -244,10 +244,10 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { mYieldToOneForcedTransfer.forceTransfers(frozenAccounts, recipients, amounts); // alice and bob's balance should remain unchanged - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), amount1); - assertEq(mYieldToOneForcedTransfer.balanceOf(bob), amount2); - assertEq(mYieldToOneForcedTransfer.balanceOf(carol), 0); - assertEq(mYieldToOneForcedTransfer.balanceOf(david), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), amount1); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), amount2); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(carol), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(david), 0); } function test_forceTransfers_revertsForNonManager() public { @@ -333,8 +333,8 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { mYieldToOneForcedTransfer.forceTransfers(frozenAccounts, recipients, amounts); for (uint256 i = 0; i < numOfAccounts; i++) { - assertEq(mYieldToOneForcedTransfer.balanceOf(frozenAccounts[i]), initialBalances[i] - amounts[i]); - assertEq(mYieldToOneForcedTransfer.balanceOf(recipients[i]), amounts[i]); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(frozenAccounts[i]), initialBalances[i] - amounts[i]); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(recipients[i]), amounts[i]); } } else { vm.prank(forcedTransferManager); @@ -342,12 +342,61 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { mYieldToOneForcedTransfer.forceTransfers(frozenAccounts, recipients, amounts); for (uint256 i = 0; i < numOfAccounts; i++) { - assertEq(mYieldToOneForcedTransfer.balanceOf(frozenAccounts[i]), initialBalances[i]); - assertEq(mYieldToOneForcedTransfer.balanceOf(recipients[i]), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(frozenAccounts[i]), initialBalances[i]); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(recipients[i]), 0); } } } + /* ============ inherited native infra paths ============ */ + + function test_nativeTransferFrom_allowlistedCaller() public { + // The FT subclass does not override the native, allowlist-gated `approve` / `transferFrom` + // paths, so it inherits them from MYieldToOne. An allowlisted caller can drive a native + // `transferFrom(uint256)` against the shared shielded allowance slot. + uint256 amount = 1_000e6; + mYieldToOneForcedTransfer.setBalanceOf(alice, amount); + + vm.prank(admin); + mYieldToOneForcedTransfer.setAllowlisted(carol, true); + + vm.prank(alice); + mYieldToOneForcedTransfer.approve(carol, amount); + + assertEq(mYieldToOneForcedTransfer.getShieldedAllowance(alice, carol), amount); + + vm.expectEmit(); + emit IERC20.Transfer(alice, bob, amount); + + vm.prank(carol); + mYieldToOneForcedTransfer.transferFrom(alice, bob, amount); + + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(bob), amount); + assertEq(mYieldToOneForcedTransfer.getShieldedAllowance(alice, carol), 0); + } + + function test_nativeTransferFrom_nonInfraCallerReverts() public { + uint256 amount = 1_000e6; + mYieldToOneForcedTransfer.setBalanceOf(alice, amount); + mYieldToOneForcedTransfer.setShieldedAllowance(alice, carol, amount); + + vm.expectRevert(IMYieldToOne.UseShieldedTransfer.selector); + + vm.prank(carol); + mYieldToOneForcedTransfer.transferFrom(alice, bob, amount); + } + + function test_balanceOf_allowlistedInfraCanReadAnyHolder() public { + mYieldToOneForcedTransfer.setBalanceOf(alice, 1_000e6); + + vm.prank(admin); + mYieldToOneForcedTransfer.setAllowlisted(carol, true); + + vm.prank(carol); + assertEq(mYieldToOneForcedTransfer.balanceOf(alice), 1_000e6); + } + /* ============ claimYield ============ */ function test_claimYield_noYield() external { @@ -370,7 +419,7 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { assertEq(mYieldToOneForcedTransfer.claimYield(), yield); assertEq(mYieldToOneForcedTransfer.yield(), 0); - assertEq(mYieldToOneForcedTransfer.balanceOf(yieldRecipient), yield); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(yieldRecipient), yield); } function test_claimYield_paused() external { @@ -446,8 +495,8 @@ contract MYieldToOneForcedTransferUnitTest is BaseUnitTest { // Previously accrued yield is NOT claimed: it remains with the contract, // neither recipient receives a mint, and yield() still reflects the full amount. assertEq(mYieldToOneForcedTransfer.yield(), accruedYield); - assertEq(mYieldToOneForcedTransfer.balanceOf(yieldRecipient), 0); - assertEq(mYieldToOneForcedTransfer.balanceOf(alice), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(yieldRecipient), 0); + assertEq(mYieldToOneForcedTransfer.getBalanceOf(alice), 0); } function test_setYieldRecipient_paused() public { diff --git a/test/unit/swap/SwapFacility.t.sol b/test/unit/swap/SwapFacility.t.sol index f01ab169..ccc11105 100644 --- a/test/unit/swap/SwapFacility.t.sol +++ b/test/unit/swap/SwapFacility.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Test } from "../../../lib/forge-std/src/Test.sol"; diff --git a/test/unit/swap/UniswapV3SwapAdapter.t.sol b/test/unit/swap/UniswapV3SwapAdapter.t.sol index 6755a64a..442b917b 100644 --- a/test/unit/swap/UniswapV3SwapAdapter.t.sol +++ b/test/unit/swap/UniswapV3SwapAdapter.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Test } from "../../../lib/forge-std/src/Test.sol"; diff --git a/test/utils/BaseIntegrationTest.sol b/test/utils/BaseIntegrationTest.sol index d19df7d0..3f5fbf4a 100644 --- a/test/utils/BaseIntegrationTest.sol +++ b/test/utils/BaseIntegrationTest.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Test } from "../../lib/forge-std/src/Test.sol"; diff --git a/test/utils/BaseUnitTest.sol b/test/utils/BaseUnitTest.sol index 3392130c..836d0c61 100644 --- a/test/utils/BaseUnitTest.sol +++ b/test/utils/BaseUnitTest.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Test } from "../../lib/forge-std/src/Test.sol"; diff --git a/test/utils/Helpers.sol b/test/utils/Helpers.sol index 5b90c4f9..e9917b28 100644 --- a/test/utils/Helpers.sol +++ b/test/utils/Helpers.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { UIntMath } from "../../lib/common/src/libs/UIntMath.sol"; diff --git a/test/utils/Mocks.sol b/test/utils/Mocks.sol index f89e003d..3deee0c6 100644 --- a/test/utils/Mocks.sol +++ b/test/utils/Mocks.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.26; +pragma solidity ^0.8.26; import { Initializable } from "../../lib/common/lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/proxy/utils/Initializable.sol";