diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..795a987 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,33 @@ +# Examples + +This folder contains assertion examples for credible-std across many protocols, consolidated from per-protocol git branches. + +Each subfolder is compiled standalone via a Foundry profile that reuses the root `src/` (credible-std) and `lib/` (forge submodules) — there are no nested Foundry projects, only profiles in the root `foundry.toml`. + +## Build a specific protocol + +```sh +FOUNDRY_PROFILE= forge build +``` + +## Protocols + +| Folder | Source branch | Highlights | +|---|---|---| +| `aave/` | `aave` | Aave V3 Horizon oracle & reserve-backing; Aave V4 hub/spoke | +| `aerodrome/` | `aerodrome` | Aerodrome pool assertions | +| `cap/` | `cap` | OFAC compliance + redemption-gate (ERC-4626) | +| `curve/` | `curve` | crvUSD controller, LlamaLend, CurveLlamma, StableSwap-NG, TriCrypto-NG | +| `denaria/` | `all-protection-suites` | Denaria perpetual operation safety | +| `euler/` | `eulerv2` | EVault, circuit breaker, sandwich detection | +| `nado/` | `ink/assertions` | Nado perpetual clearinghouse | +| `royco/` | `royco-dawn` | Royco kernel accounting, cumulative flow, vault tranche | +| `safe/` | `safe-protection-suite` | Safe config lock + tx-shape assertions | +| `spark/` | `spark` | Spark vault + SparkLend oracle/SLL inflow | +| `symbiotic/` | `symbiotic` | Symbiotic vault (flow, config, circuit breaker) + relay | +| `tydro/` | `ink/assertions` | Tydro Aave-v3-like operation safety on Ink | +| `uniswap/` | `0x` | Uniswap V3 pool assertions (V4 lives in root `src/`) | +| `veda/` | `ink/assertions` | Veda BoringVault assertions | +| `zeroex/` | `0x` | 0x Settler assertion | + +Existing example projects on master (`assertions-book/`, `micro-patterns/`, `vault_demo/`) are unchanged. diff --git a/examples/aave/README.md b/examples/aave/README.md new file mode 100644 index 0000000..4e96ef6 --- /dev/null +++ b/examples/aave/README.md @@ -0,0 +1,20 @@ +# aave examples + +Assertion examples and supporting helpers extracted from the `aave` branch. + +## Build + +```sh +FOUNDRY_PROFILE=aave forge build +``` + +## Files + +- AaveV3HorizonHelpers.sol +- AaveV3HorizonInterfaces.sol +- AaveV3HorizonOracleAssertion.sol +- AaveV3HorizonReserveBackingAssertion.sol +- AaveV4Helpers.sol +- AaveV4HubAccountingAssertion.sol +- AaveV4Interfaces.sol +- AaveV4SpokeRiskAssertion.sol diff --git a/examples/aave/src/AaveV3HorizonHelpers.sol b/examples/aave/src/AaveV3HorizonHelpers.sol new file mode 100644 index 0000000..86dde30 --- /dev/null +++ b/examples/aave/src/AaveV3HorizonHelpers.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {AaveV3LikeTypes, IAaveV3LikeAddressesProvider, IAaveV3LikePool} from "credible-std/protection/lending/examples/AaveV3LikeInterfaces.sol"; +import {IAaveV3HorizonOracle} from "./AaveV3HorizonInterfaces.sol"; + +/// @title AaveV3HorizonHelpers +/// @author Phylax Systems +/// @notice Shared fork-aware readers and decoders for Aave v3 Horizon examples. +/// @dev Horizon assertions intentionally read through the Pool, AddressesProvider, AaveOracle, +/// Chainlink-compatible sources, aTokens, and debt tokens rather than restating one local +/// require branch. Constructor inputs are explicit so assertion deployment does not depend +/// on target-state reads in an isolated runtime. +abstract contract AaveV3HorizonHelpers is Assertion { + uint256 internal constant BPS = 10_000; + + constructor() { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function _viewFailureMessage() internal pure override returns (string memory) { + return "AaveV3Horizon: fork view failed"; + } + + function _oracleAt(address addressesProvider, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(addressesProvider, abi.encodeCall(IAaveV3LikeAddressesProvider.getPriceOracle, ()), fork); + } + + function _reservesListAt(address pool, PhEvm.ForkId memory fork) internal view returns (address[] memory reserves) { + reserves = abi.decode(_viewAt(pool, abi.encodeCall(IAaveV3LikePool.getReservesList, ()), fork), (address[])); + } + + function _reserveDataAt(address pool, address asset, PhEvm.ForkId memory fork) + internal + view + returns (AaveV3LikeTypes.ReserveData memory reserveData) + { + reserveData = abi.decode( + _viewAt(pool, abi.encodeCall(IAaveV3LikePool.getReserveData, (asset)), fork), (AaveV3LikeTypes.ReserveData) + ); + } + + function _userConfigDataAt(address pool, address account, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + AaveV3LikeTypes.UserConfigurationMap memory userConfig = abi.decode( + _viewAt(pool, abi.encodeCall(IAaveV3LikePool.getUserConfiguration, (account)), fork), + (AaveV3LikeTypes.UserConfigurationMap) + ); + return userConfig.data; + } + + function _assetPriceAt(address oracle, address asset, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(oracle, abi.encodeCall(IAaveV3HorizonOracle.getAssetPrice, (asset)), fork); + } + + function _sourceOfAssetAt(address oracle, address asset, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(oracle, abi.encodeCall(IAaveV3HorizonOracle.getSourceOfAsset, (asset)), fork); + } + + function _assertPriceBounded( + address oracle, + address asset, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post, + uint256 deviationBps + ) internal view { + uint256 prePrice = _assetPriceAt(oracle, asset, pre); + uint256 postPrice = _assetPriceAt(oracle, asset, post); + require(prePrice > 0 && postPrice > 0, "AaveV3Horizon: reserve oracle price invalid"); + require( + ph.ratioGe(postPrice, 1, prePrice, 1, deviationBps) && ph.ratioGe(prePrice, 1, postPrice, 1, deviationBps), + "AaveV3Horizon: reserve oracle price drift" + ); + } + + function _requireAdopter(address expected, string memory message) internal view { + require(ph.getAssertionAdopter() == expected, message); + } + + function _isBorrowing(uint256 userConfigData, uint256 reserveId) internal pure returns (bool) { + return ((userConfigData >> (reserveId * 2)) & 1) != 0; + } + + function _isUsingAsCollateral(uint256 userConfigData, uint256 reserveId) internal pure returns (bool) { + return ((userConfigData >> (reserveId * 2 + 1)) & 1) != 0; + } +} diff --git a/examples/aave/src/AaveV3HorizonInterfaces.sol b/examples/aave/src/AaveV3HorizonInterfaces.sol new file mode 100644 index 0000000..db239d7 --- /dev/null +++ b/examples/aave/src/AaveV3HorizonInterfaces.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Minimal AaveOracle surface used by the Horizon-specific assertions. +interface IAaveV3HorizonOracle { + function getAssetPrice(address asset) external view returns (uint256); + function getSourceOfAsset(address asset) external view returns (address); + function getFallbackOracle() external view returns (address); + function setAssetSources(address[] calldata assets, address[] calldata sources) external; + function setFallbackOracle(address fallbackOracle) external; +} + +/// @notice Minimal ERC20/accounting-token surface used by Horizon reserve backing checks. +interface IAaveV3HorizonToken { + function balanceOf(address account) external view returns (uint256); + function totalSupply() external view returns (uint256); +} diff --git a/examples/aave/src/AaveV3HorizonOracleAssertion.sol b/examples/aave/src/AaveV3HorizonOracleAssertion.sol new file mode 100644 index 0000000..0c0284f --- /dev/null +++ b/examples/aave/src/AaveV3HorizonOracleAssertion.sol @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {AaveV3LikeTypes, IAaveV3LikePool} from "credible-std/protection/lending/examples/AaveV3LikeInterfaces.sol"; +import {AaveV3HorizonHelpers} from "./AaveV3HorizonHelpers.sol"; + +/// @title AaveV3HorizonOracleAssertion +/// @author Phylax Systems +/// @notice Protects Horizon's oracle-backed lending risk state. +/// @dev This assertion targets properties that are not local `require` checks: +/// - Risk-changing Pool operations must not share a transaction envelope with material +/// PreTx/PostTx oracle drift for active collateral, debt reserves, or touched assets. +/// - The active oracle source for those reserves must not be swapped earlier or later in the +/// same transaction while the Pool consumes the resulting risk state. +/// - The checks span Pool reserve/user bitmaps, the AddressesProvider, AaveOracle, and +/// Chainlink-compatible source contracts across the whole transaction, not one call frame. +contract AaveV3HorizonOracleAssertion is AaveV3HorizonHelpers { + address internal immutable POOL; + address internal immutable ADDRESSES_PROVIDER; + uint256 internal immutable MAX_RESERVES_TO_SCAN; + uint256 internal immutable ORACLE_DEVIATION_BPS; + + constructor(address pool_, address addressesProvider_, uint256 maxReservesToScan_, uint256 oracleDeviationBps_) { + require(pool_ != address(0), "AaveV3Horizon: pool zero"); + require(addressesProvider_ != address(0), "AaveV3Horizon: provider zero"); + require(maxReservesToScan_ != 0, "AaveV3Horizon: max reserves zero"); + require(oracleDeviationBps_ <= BPS, "AaveV3Horizon: bad oracle tolerance"); + + POOL = pool_; + ADDRESSES_PROVIDER = addressesProvider_; + MAX_RESERVES_TO_SCAN = maxReservesToScan_; + ORACLE_DEVIATION_BPS = oracleDeviationBps_; + } + + /// @notice Registers one transaction-end check for Horizon oracle/risk coupling. + /// @dev This intentionally runs after the whole transaction, so it catches bundled oracle + /// changes that happened before or after a Pool risk operation. The Pool function itself + /// cannot reproduce the PreTx oracle/source baseline with a local require. + function triggers() external view override { + registerTxEndTrigger(this.assertRiskOperationOracleEnvelope.selector); + } + + /// @notice Bounds oracle/source movement across any transaction that includes Pool risk operations. + /// @dev Scans successful Horizon Pool calls in the transaction, resolves affected users/assets, + /// and compares oracle prices and source addresses between PreTx and PostTx. A failure + /// means the transaction consumed lending risk state while also changing the oracle basis + /// used to value that risk. + function assertRiskOperationOracleEnvelope() external view { + _requireAdopter(POOL, "AaveV3Horizon: configured pool is not adopter"); + + PhEvm.ForkId memory pre = _preTx(); + PhEvm.ForkId memory post = _postTx(); + address oracle = _oracleAt(ADDRESSES_PROVIDER, post); + + _assertCallGroupOracleEnvelope(oracle, IAaveV3LikePool.borrow.selector, pre, post); + _assertCallGroupOracleEnvelope(oracle, IAaveV3LikePool.withdraw.selector, pre, post); + _assertCallGroupOracleEnvelope(oracle, IAaveV3LikePool.setUserUseReserveAsCollateral.selector, pre, post); + _assertCallGroupOracleEnvelope(oracle, IAaveV3LikePool.finalizeTransfer.selector, pre, post); + _assertCallGroupOracleEnvelope(oracle, IAaveV3LikePool.setUserEMode.selector, pre, post); + _assertCallGroupOracleEnvelope(oracle, IAaveV3LikePool.liquidationCall.selector, pre, post); + } + + function _assertCallGroupOracleEnvelope( + address oracle, + bytes4 selector, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post + ) internal view { + PhEvm.CallInputs[] memory calls = ph.getAllCallInputs(POOL, selector); + + for (uint256 i; i < calls.length; ++i) { + address account = _operationAccount(selector, calls[i].input, calls[i].caller); + if (account != address(0)) { + _assertAccountReservePricesBounded(account, oracle, pre, post); + } + + _assertTouchedAssetPricesBounded(selector, calls[i].input, oracle, pre, post); + } + } + + function _assertAccountReservePricesBounded( + address account, + address oracle, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post + ) internal view { + address[] memory reserves = _reservesListAt(POOL, post); + require(reserves.length <= MAX_RESERVES_TO_SCAN, "AaveV3Horizon: too many reserves"); + + uint256 preConfig = _userConfigDataAt(POOL, account, pre); + uint256 postConfig = _userConfigDataAt(POOL, account, post); + + for (uint256 i; i < reserves.length; ++i) { + AaveV3LikeTypes.ReserveData memory reserveData = _reserveDataAt(POOL, reserves[i], post); + bool activeBefore = + _isBorrowing(preConfig, reserveData.id) || _isUsingAsCollateral(preConfig, reserveData.id); + bool activeAfter = + _isBorrowing(postConfig, reserveData.id) || _isUsingAsCollateral(postConfig, reserveData.id); + + if (!activeBefore && !activeAfter) { + continue; + } + + _assertSourceStable(oracle, reserves[i], pre, post); + _assertPriceBounded(oracle, reserves[i], pre, post, ORACLE_DEVIATION_BPS); + } + } + + function _assertTouchedAssetPricesBounded( + bytes4 selector, + bytes memory input, + address oracle, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post + ) internal view { + if (selector == IAaveV3LikePool.borrow.selector) { + (address asset,,,,) = abi.decode(input, (address, uint256, uint256, uint16, address)); + _assertSourceStable(oracle, asset, pre, post); + _assertPriceBounded(oracle, asset, pre, post, ORACLE_DEVIATION_BPS); + return; + } + + if (selector == IAaveV3LikePool.withdraw.selector) { + (address asset,,) = abi.decode(input, (address, uint256, address)); + _assertSourceStable(oracle, asset, pre, post); + _assertPriceBounded(oracle, asset, pre, post, ORACLE_DEVIATION_BPS); + return; + } + + if (selector == IAaveV3LikePool.setUserUseReserveAsCollateral.selector) { + (address asset,) = abi.decode(input, (address, bool)); + _assertSourceStable(oracle, asset, pre, post); + _assertPriceBounded(oracle, asset, pre, post, ORACLE_DEVIATION_BPS); + return; + } + + if (selector == IAaveV3LikePool.finalizeTransfer.selector) { + (address asset,,,,,) = abi.decode(input, (address, address, address, uint256, uint256, uint256)); + _assertSourceStable(oracle, asset, pre, post); + _assertPriceBounded(oracle, asset, pre, post, ORACLE_DEVIATION_BPS); + return; + } + + if (selector == IAaveV3LikePool.liquidationCall.selector) { + (address collateralAsset, address debtAsset,,,) = + abi.decode(input, (address, address, address, uint256, bool)); + _assertSourceStable(oracle, collateralAsset, pre, post); + _assertSourceStable(oracle, debtAsset, pre, post); + _assertPriceBounded(oracle, collateralAsset, pre, post, ORACLE_DEVIATION_BPS); + _assertPriceBounded(oracle, debtAsset, pre, post, ORACLE_DEVIATION_BPS); + } + } + + function _assertSourceStable(address oracle, address asset, PhEvm.ForkId memory pre, PhEvm.ForkId memory post) + internal + view + { + address preSource = _sourceOfAssetAt(oracle, asset, pre); + address postSource = _sourceOfAssetAt(oracle, asset, post); + require(preSource == postSource, "AaveV3Horizon: reserve oracle source changed"); + } + + function _operationAccount(bytes4 selector, bytes memory input, address caller) internal pure returns (address) { + if (selector == IAaveV3LikePool.borrow.selector) { + (,,,, address onBehalfOf) = abi.decode(input, (address, uint256, uint256, uint16, address)); + return onBehalfOf; + } + + if ( + selector == IAaveV3LikePool.withdraw.selector + || selector == IAaveV3LikePool.setUserUseReserveAsCollateral.selector + || selector == IAaveV3LikePool.setUserEMode.selector + ) { + return caller; + } + + if (selector == IAaveV3LikePool.finalizeTransfer.selector) { + (, address from,,,,) = abi.decode(input, (address, address, address, uint256, uint256, uint256)); + return from; + } + + if (selector == IAaveV3LikePool.liquidationCall.selector) { + (,, address user,,) = abi.decode(input, (address, address, address, uint256, bool)); + return user; + } + + return address(0); + } +} diff --git a/examples/aave/src/AaveV3HorizonReserveBackingAssertion.sol b/examples/aave/src/AaveV3HorizonReserveBackingAssertion.sol new file mode 100644 index 0000000..f66c2f4 --- /dev/null +++ b/examples/aave/src/AaveV3HorizonReserveBackingAssertion.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {AaveV3LikeTypes} from "credible-std/protection/lending/examples/AaveV3LikeInterfaces.sol"; +import {IAaveV3HorizonToken} from "./AaveV3HorizonInterfaces.sol"; +import {AaveV3HorizonHelpers} from "./AaveV3HorizonHelpers.sol"; + +/// @title AaveV3HorizonReserveBackingAssertion +/// @author Phylax Systems +/// @notice Protects Horizon reserve accounting against external underlying-token balance changes. +/// @dev A Pool require can validate Pool-owned supply, borrow, repay, and withdraw paths, but it +/// cannot run when an underlying token balance changes directly on an aToken through token +/// admin action, rebasing, hooks, or other cross-protocol side effects. This assertion checks +/// transaction-end reserve backing from external token balances and debt-token supply. +contract AaveV3HorizonReserveBackingAssertion is AaveV3HorizonHelpers { + address internal immutable POOL; + uint256 internal immutable MAX_BACKING_DEFICIT; + address[] internal RESERVE_ASSETS; + + constructor(address pool_, address[] memory reserveAssets_, uint256 maxBackingDeficit_) { + require(pool_ != address(0), "AaveV3Horizon: pool zero"); + require(reserveAssets_.length != 0, "AaveV3Horizon: no reserve assets"); + + POOL = pool_; + MAX_BACKING_DEFICIT = maxBackingDeficit_; + RESERVE_ASSETS = reserveAssets_; + } + + /// @notice Registers a transaction-end backing check for configured Horizon reserve assets. + /// @dev The trigger intentionally runs after the whole transaction, including direct reserve + /// token movements outside the Pool call surface. That transaction envelope is not a place + /// where Horizon can add a Pool-level require. + function triggers() external view override { + registerTxEndTrigger(this.assertReserveBacking.selector); + } + + /// @notice Checks all configured reserves remain backed at transaction end. + /// @dev For each reserve, compares aToken supply with underlying held by the aToken plus + /// stable debt, variable debt, and unbacked bridge debt. Existing deficits are skipped so + /// old bad state does not cause false positives on unrelated balance changes. + function assertReserveBacking() external view { + PhEvm.ForkId memory pre = _preTx(); + PhEvm.ForkId memory post = _postTx(); + + for (uint256 i; i < RESERVE_ASSETS.length; ++i) { + _assertReserveBacking(RESERVE_ASSETS[i], pre, post); + } + } + + function _assertReserveBacking(address asset, PhEvm.ForkId memory pre, PhEvm.ForkId memory post) internal view { + ReserveBacking memory beforeBacking = _reserveBackingAt(asset, pre); + if (!_isBacked(beforeBacking)) { + return; + } + + ReserveBacking memory afterBacking = _reserveBackingAt(asset, post); + require(_isBacked(afterBacking), "AaveV3Horizon: reserve backing deficit"); + } + + struct ReserveBacking { + uint256 aTokenSupply; + uint256 backingClaims; + } + + function _reserveBackingAt(address asset, PhEvm.ForkId memory fork) + internal + view + returns (ReserveBacking memory backing) + { + AaveV3LikeTypes.ReserveData memory reserveData = _reserveDataAt(POOL, asset, fork); + require(reserveData.aTokenAddress != address(0), "AaveV3Horizon: reserve not listed"); + + uint256 availableLiquidity = _readBalanceAt(asset, reserveData.aTokenAddress, fork); + uint256 stableDebt = _optionalTotalSupplyAt(reserveData.stableDebtTokenAddress, fork); + uint256 variableDebt = _optionalTotalSupplyAt(reserveData.variableDebtTokenAddress, fork); + + backing.aTokenSupply = _totalSupplyAt(reserveData.aTokenAddress, fork); + backing.backingClaims = availableLiquidity + stableDebt + variableDebt + reserveData.unbacked; + } + + function _isBacked(ReserveBacking memory backing) internal view returns (bool) { + return backing.aTokenSupply <= backing.backingClaims + MAX_BACKING_DEFICIT; + } + + function _optionalTotalSupplyAt(address token, PhEvm.ForkId memory fork) internal view returns (uint256) { + if (token == address(0)) { + return 0; + } + + return _totalSupplyAt(token, fork); + } + + function _totalSupplyAt(address token, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(token, abi.encodeCall(IAaveV3HorizonToken.totalSupply, ()), fork); + } +} diff --git a/examples/aave/src/AaveV4Helpers.sol b/examples/aave/src/AaveV4Helpers.sol new file mode 100644 index 0000000..2fd9f9f --- /dev/null +++ b/examples/aave/src/AaveV4Helpers.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {IAaveV4Hub, IAaveV4Oracle, IAaveV4Spoke} from "./AaveV4Interfaces.sol"; + +/// @title AaveV4Helpers +/// @author Phylax Systems +/// @notice Fork-aware state readers and calldata decoders shared by the Aave v4 examples. +/// @dev The helpers read Hub, Spoke, and oracle state at V2 fork snapshots. Constructor +/// parameters are supplied explicitly by each assertion so deployment never needs to read +/// mutable target state from the isolated assertion runtime. +abstract contract AaveV4Helpers is Assertion { + uint256 internal constant BPS = 10_000; + uint256 internal constant WAD = 1e18; + uint256 internal constant RAY = 1e27; + uint8 internal constant ORACLE_DECIMALS = 8; + uint256 internal constant HEALTH_FACTOR_LIQUIDATION_THRESHOLD = 1e18; + + constructor() { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function _viewFailureMessage() internal pure override returns (string memory) { + return "AaveV4: fork view failed"; + } + + function _hubAssetAt(address hub, uint256 assetId, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Hub.Asset memory asset) + { + asset = abi.decode(_viewAt(hub, abi.encodeCall(IAaveV4Hub.getAsset, (assetId)), fork), (IAaveV4Hub.Asset)); + } + + function _hubSpokeAt(address hub, uint256 assetId, address spoke, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Hub.SpokeData memory data) + { + data = abi.decode( + _viewAt(hub, abi.encodeCall(IAaveV4Hub.getSpoke, (assetId, spoke)), fork), (IAaveV4Hub.SpokeData) + ); + } + + function _hubSpokeAddedSharesAt(address hub, uint256 assetId, address spoke, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(hub, abi.encodeCall(IAaveV4Hub.getSpokeAddedShares, (assetId, spoke)), fork); + } + + function _hubSpokeAddedAssetsAt(address hub, uint256 assetId, address spoke, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(hub, abi.encodeCall(IAaveV4Hub.getSpokeAddedAssets, (assetId, spoke)), fork); + } + + function _hubSpokeDrawnSharesAt(address hub, uint256 assetId, address spoke, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(hub, abi.encodeCall(IAaveV4Hub.getSpokeDrawnShares, (assetId, spoke)), fork); + } + + function _hubSpokePremiumDataAt(address hub, uint256 assetId, address spoke, PhEvm.ForkId memory fork) + internal + view + returns (uint256 premiumShares, int256 premiumOffsetRay) + { + (premiumShares, premiumOffsetRay) = abi.decode( + _viewAt(hub, abi.encodeCall(IAaveV4Hub.getSpokePremiumData, (assetId, spoke)), fork), (uint256, int256) + ); + } + + function _hubPreviewRemoveBySharesAt(address hub, uint256 assetId, uint256 shares, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(hub, abi.encodeCall(IAaveV4Hub.previewRemoveByShares, (assetId, shares)), fork); + } + + function _hubDrawnIndexAt(address hub, uint256 assetId, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(hub, abi.encodeCall(IAaveV4Hub.getAssetDrawnIndex, (assetId)), fork); + } + + function _spokeReserveAt(address spoke, uint256 reserveId, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Spoke.Reserve memory reserve) + { + reserve = abi.decode( + _viewAt(spoke, abi.encodeCall(IAaveV4Spoke.getReserve, (reserveId)), fork), (IAaveV4Spoke.Reserve) + ); + } + + function _spokeDynamicConfigAt(address spoke, uint256 reserveId, uint32 dynamicConfigKey, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Spoke.DynamicReserveConfig memory config) + { + config = abi.decode( + _viewAt(spoke, abi.encodeCall(IAaveV4Spoke.getDynamicReserveConfig, (reserveId, dynamicConfigKey)), fork), + (IAaveV4Spoke.DynamicReserveConfig) + ); + } + + function _spokeUserReserveStatusAt(address spoke, uint256 reserveId, address user, PhEvm.ForkId memory fork) + internal + view + returns (bool collateral, bool borrowing) + { + (collateral, borrowing) = abi.decode( + _viewAt(spoke, abi.encodeCall(IAaveV4Spoke.getUserReserveStatus, (reserveId, user)), fork), (bool, bool) + ); + } + + function _spokeUserPositionAt(address spoke, uint256 reserveId, address user, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Spoke.UserPosition memory position) + { + position = abi.decode( + _viewAt(spoke, abi.encodeCall(IAaveV4Spoke.getUserPosition, (reserveId, user)), fork), + (IAaveV4Spoke.UserPosition) + ); + } + + function _spokeAccountDataAt(address spoke, address user, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Spoke.UserAccountData memory accountData) + { + accountData = abi.decode( + _viewAt(spoke, abi.encodeCall(IAaveV4Spoke.getUserAccountData, (user)), fork), + (IAaveV4Spoke.UserAccountData) + ); + } + + function _spokeLastRiskPremiumAt(address spoke, address user, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(spoke, abi.encodeCall(IAaveV4Spoke.getUserLastRiskPremium, (user)), fork); + } + + function _oraclePriceAt(address oracle, uint256 reserveId, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(oracle, abi.encodeCall(IAaveV4Oracle.getReservePrice, (reserveId)), fork); + } + + function _firstUint256Arg(bytes memory input) internal pure returns (uint256 value) { + require(input.length >= 36, "AaveV4: short calldata"); + assembly ("memory-safe") { + value := mload(add(input, 36)) + } + } + + function _args(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "AaveV4: short calldata"); + + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } + + function _requireAdopter(address expected, string memory message) internal view { + require(ph.getAssertionAdopter() == expected, message); + } + + function _fromRayUp(uint256 value) internal pure returns (uint256) { + return value / RAY + (value % RAY == 0 ? 0 : 1); + } + + function _divUp(uint256 value, uint256 denominator) internal pure returns (uint256) { + return value / denominator + (value % denominator == 0 ? 0 : 1); + } + + function _toValue(uint256 amount, uint8 decimals, uint256 price) internal pure returns (uint256) { + return amount * price * (10 ** uint256(18 - decimals)); + } +} diff --git a/examples/aave/src/AaveV4HubAccountingAssertion.sol b/examples/aave/src/AaveV4HubAccountingAssertion.sol new file mode 100644 index 0000000..90f2b4a --- /dev/null +++ b/examples/aave/src/AaveV4HubAccountingAssertion.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {AaveV4Helpers} from "./AaveV4Helpers.sol"; +import {IAaveV4Hub} from "./AaveV4Interfaces.sol"; + +/// @title AaveV4HubAccountingAssertion +/// @author Phylax Systems +/// @notice Example assertion for one Aave v4 Hub asset. +/// @dev Protects cross-spoke accounting that is not expressed by a single Hub `require`: +/// - Hub aggregate shares, premium data, and deficits match the sum of listed Spokes. +/// - Hub added assets cover the sum of each Spoke's converted added assets. +/// - Drawn index and added-asset share price do not move backwards across Hub mutations. +contract AaveV4HubAccountingAssertion is AaveV4Helpers { + address internal immutable HUB; + uint256 internal immutable ASSET_ID; + uint256 internal immutable MAX_SPOKES_TO_SCAN; + uint256 internal immutable SHARE_PRICE_TOLERANCE_BPS; + + constructor(address hub_, uint256 assetId_, uint256 maxSpokesToScan_, uint256 sharePriceToleranceBps_) { + require(hub_ != address(0), "AaveV4Hub: hub zero"); + require(maxSpokesToScan_ > 0, "AaveV4Hub: max spokes zero"); + require(sharePriceToleranceBps_ <= BPS, "AaveV4Hub: bad tolerance"); + + HUB = hub_; + ASSET_ID = assetId_; + MAX_SPOKES_TO_SCAN = maxSpokesToScan_; + SHARE_PRICE_TOLERANCE_BPS = sharePriceToleranceBps_; + } + + /// @notice Registers Hub mutators that can change aggregate accounting, indices, or spoke sums. + /// @dev The assertion is configured for one `assetId`; calls for other assets no-op after + /// decoding the first calldata argument. + function triggers() external view override { + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.add.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.remove.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.draw.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.restore.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.reportDeficit.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.refreshPremium.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.payFeeShares.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.transferShares.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.mintFeeShares.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.eliminateDeficit.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.sweep.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.reclaim.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.updateAssetConfig.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.addSpoke.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.updateSpokeConfig.selector); + registerFnCallTrigger(this.assertHubAssetAccounting.selector, IAaveV4Hub.setInterestRateData.selector); + } + + /// @notice Checks one Hub asset remains backed and internally coherent after a Hub mutation. + /// @dev Reads the Hub's aggregate asset state and enumerates listed Spokes at the post-call + /// fork. A failure means cross-Spoke accounting no longer agrees with Hub totals, + /// Spoke-level added assets exceed Hub added assets, or a monotonic index moved backwards. + function assertHubAssetAccounting() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireAdopter(HUB, "AaveV4Hub: configured hub is not adopter"); + + if (_firstUint256Arg(ph.callinputAt(ctx.callStart)) != ASSET_ID) { + return; + } + + PhEvm.ForkId memory pre = _preCall(ctx.callStart); + PhEvm.ForkId memory post = _postCall(ctx.callEnd); + IAaveV4Hub.Asset memory preAsset = _hubAssetAt(HUB, ASSET_ID, pre); + IAaveV4Hub.Asset memory postAsset = _hubAssetAt(HUB, ASSET_ID, post); + + _assertSpokeSums(postAsset, post); + _assertMonotonicAssetRatios(preAsset, postAsset, pre, post); + } + + function _assertSpokeSums(IAaveV4Hub.Asset memory asset, PhEvm.ForkId memory fork) internal view { + uint256 count = _readUintAt(HUB, abi.encodeCall(IAaveV4Hub.getSpokeCount, (ASSET_ID)), fork); + require(count <= MAX_SPOKES_TO_SCAN, "AaveV4Hub: too many spokes"); + + uint256 addedShares; + uint256 drawnShares; + uint256 premiumShares; + int256 premiumOffsetRay; + uint256 deficitRay; + uint256 spokeAddedAssets; + + for (uint256 i; i < count; ++i) { + address spoke = _readAddressAt(HUB, abi.encodeCall(IAaveV4Hub.getSpokeAddress, (ASSET_ID, i)), fork); + IAaveV4Hub.SpokeData memory spokeData = _hubSpokeAt(HUB, ASSET_ID, spoke, fork); + addedShares += spokeData.addedShares; + drawnShares += spokeData.drawnShares; + premiumShares += spokeData.premiumShares; + premiumOffsetRay += spokeData.premiumOffsetRay; + deficitRay += spokeData.deficitRay; + spokeAddedAssets += _hubSpokeAddedAssetsAt(HUB, ASSET_ID, spoke, fork); + } + + require(addedShares == asset.addedShares, "AaveV4Hub: added shares mismatch"); + require(drawnShares == asset.drawnShares, "AaveV4Hub: drawn shares mismatch"); + require(premiumShares == asset.premiumShares, "AaveV4Hub: premium shares mismatch"); + require(premiumOffsetRay == asset.premiumOffsetRay, "AaveV4Hub: premium offset mismatch"); + require(deficitRay == asset.deficitRay, "AaveV4Hub: deficit mismatch"); + require( + _readUintAt(HUB, abi.encodeCall(IAaveV4Hub.getAddedAssets, (ASSET_ID)), fork) >= spokeAddedAssets, + "AaveV4Hub: spoke assets exceed added assets" + ); + } + + function _assertMonotonicAssetRatios( + IAaveV4Hub.Asset memory preAsset, + IAaveV4Hub.Asset memory postAsset, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post + ) internal view { + require(postAsset.drawnIndex >= preAsset.drawnIndex, "AaveV4Hub: drawn index decreased"); + + uint256 preShares = preAsset.addedShares; + uint256 postShares = postAsset.addedShares; + if (preShares == 0 || postShares == 0) { + return; + } + + uint256 preAssets = _readUintAt(HUB, abi.encodeCall(IAaveV4Hub.getAddedAssets, (ASSET_ID)), pre); + uint256 postAssets = _readUintAt(HUB, abi.encodeCall(IAaveV4Hub.getAddedAssets, (ASSET_ID)), post); + + require( + ph.ratioGe(postAssets, postShares, preAssets, preShares, SHARE_PRICE_TOLERANCE_BPS), + "AaveV4Hub: added share price decreased" + ); + } +} diff --git a/examples/aave/src/AaveV4Interfaces.sol b/examples/aave/src/AaveV4Interfaces.sol new file mode 100644 index 0000000..537660f --- /dev/null +++ b/examples/aave/src/AaveV4Interfaces.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Minimal ERC20 surface used by the Aave v4 example assertions. +interface IAaveV4ERC20Like { + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Minimal Hub surface used by the Aave v4 lending examples. +interface IAaveV4Hub { + struct PremiumDelta { + int256 sharesDelta; + int256 offsetRayDelta; + uint256 restoredPremiumRay; + } + + struct Asset { + uint120 liquidity; + uint120 realizedFees; + uint8 decimals; + uint120 addedShares; + uint120 swept; + int200 premiumOffsetRay; + uint120 drawnShares; + uint120 premiumShares; + uint16 liquidityFee; + uint120 drawnIndex; + uint96 drawnRate; + uint40 lastUpdateTimestamp; + address underlying; + address irStrategy; + address reinvestmentController; + address feeReceiver; + uint200 deficitRay; + } + + struct SpokeData { + uint120 drawnShares; + uint120 premiumShares; + int200 premiumOffsetRay; + uint120 addedShares; + uint40 addCap; + uint40 drawCap; + uint24 riskPremiumThreshold; + bool active; + bool halted; + uint200 deficitRay; + } + + struct SpokeConfig { + uint40 addCap; + uint40 drawCap; + uint24 riskPremiumThreshold; + bool active; + bool halted; + } + + struct AssetConfig { + address feeReceiver; + uint16 liquidityFee; + address irStrategy; + address reinvestmentController; + } + + function add(uint256 assetId, uint256 amount) external returns (uint256); + function remove(uint256 assetId, uint256 amount, address to) external returns (uint256); + function draw(uint256 assetId, uint256 amount, address to) external returns (uint256); + function restore(uint256 assetId, uint256 drawnAmount, PremiumDelta calldata premiumDelta) + external + returns (uint256); + function reportDeficit(uint256 assetId, uint256 drawnAmount, PremiumDelta calldata premiumDelta) + external + returns (uint256, uint256); + function refreshPremium(uint256 assetId, PremiumDelta calldata premiumDelta) external; + function payFeeShares(uint256 assetId, uint256 shares) external; + function transferShares(uint256 assetId, uint256 shares, address toSpoke) external; + function mintFeeShares(uint256 assetId) external returns (uint256); + function eliminateDeficit(uint256 assetId, uint256 amount, address spoke) external returns (uint256, uint256); + function sweep(uint256 assetId, uint256 amount) external; + function reclaim(uint256 assetId, uint256 amount) external; + function updateAssetConfig(uint256 assetId, AssetConfig calldata config, bytes calldata irData) external; + function addSpoke(uint256 assetId, address spoke, SpokeConfig calldata config) external; + function updateSpokeConfig(uint256 assetId, address spoke, SpokeConfig calldata config) external; + function setInterestRateData(uint256 assetId, bytes calldata irData) external; + + function getAsset(uint256 assetId) external view returns (Asset memory); + function previewRemoveByShares(uint256 assetId, uint256 shares) external view returns (uint256); + function getAssetDrawnIndex(uint256 assetId) external view returns (uint256); + function getAddedAssets(uint256 assetId) external view returns (uint256); + function getAddedShares(uint256 assetId) external view returns (uint256); + function getSpokeCount(uint256 assetId) external view returns (uint256); + function getSpokeAddress(uint256 assetId, uint256 index) external view returns (address); + function getSpoke(uint256 assetId, address spoke) external view returns (SpokeData memory); + function getSpokeAddedAssets(uint256 assetId, address spoke) external view returns (uint256); + function getSpokeAddedShares(uint256 assetId, address spoke) external view returns (uint256); + function getSpokeDrawnShares(uint256 assetId, address spoke) external view returns (uint256); + function getSpokePremiumData(uint256 assetId, address spoke) external view returns (uint256, int256); +} + +/// @notice Minimal Spoke surface used by the Aave v4 lending examples. +interface IAaveV4Spoke { + struct Reserve { + address underlying; + address hub; + uint16 assetId; + uint8 decimals; + uint24 collateralRisk; + uint8 flags; + uint32 dynamicConfigKey; + } + + struct ReserveConfig { + uint24 collateralRisk; + bool paused; + bool frozen; + bool borrowable; + bool receiveSharesEnabled; + } + + struct DynamicReserveConfig { + uint16 collateralFactor; + uint32 maxLiquidationBonus; + uint16 liquidationFee; + } + + struct UserPosition { + uint120 drawnShares; + uint120 premiumShares; + int200 premiumOffsetRay; + uint120 suppliedShares; + uint32 dynamicConfigKey; + } + + struct UserAccountData { + uint256 riskPremium; + uint256 avgCollateralFactor; + uint256 healthFactor; + uint256 totalCollateralValue; + uint256 totalDebtValueRay; + uint256 activeCollateralCount; + uint256 borrowCount; + } + + function supply(uint256 reserveId, uint256 amount, address onBehalfOf) external returns (uint256, uint256); + function withdraw(uint256 reserveId, uint256 amount, address onBehalfOf) external returns (uint256, uint256); + function borrow(uint256 reserveId, uint256 amount, address onBehalfOf) external returns (uint256, uint256); + function repay(uint256 reserveId, uint256 amount, address onBehalfOf) external returns (uint256, uint256); + function liquidationCall( + uint256 collateralReserveId, + uint256 debtReserveId, + address user, + uint256 debtToCover, + bool receiveShares + ) external; + function setUsingAsCollateral(uint256 reserveId, bool usingAsCollateral, address onBehalfOf) external; + function updateUserRiskPremium(address onBehalfOf) external; + function updateUserDynamicConfig(address onBehalfOf) external; + + function ORACLE() external view returns (address); + function getReserveCount() external view returns (uint256); + function getReserve(uint256 reserveId) external view returns (Reserve memory); + function getDynamicReserveConfig(uint256 reserveId, uint32 dynamicConfigKey) + external + view + returns (DynamicReserveConfig memory); + function getUserReserveStatus(uint256 reserveId, address user) external view returns (bool, bool); + function getUserPosition(uint256 reserveId, address user) external view returns (UserPosition memory); + function getUserAccountData(address user) external view returns (UserAccountData memory); + function getUserLastRiskPremium(address user) external view returns (uint256); +} + +/// @notice Minimal reserve-price oracle surface used by Aave v4 Spokes. +interface IAaveV4Oracle { + function getReservePrice(uint256 reserveId) external view returns (uint256); +} diff --git a/examples/aave/src/AaveV4SpokeRiskAssertion.sol b/examples/aave/src/AaveV4SpokeRiskAssertion.sol new file mode 100644 index 0000000..7d5bdb5 --- /dev/null +++ b/examples/aave/src/AaveV4SpokeRiskAssertion.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {AaveV4Helpers} from "./AaveV4Helpers.sol"; +import {IAaveV4Hub, IAaveV4Spoke} from "./AaveV4Interfaces.sol"; + +/// @title AaveV4SpokeRiskAssertion +/// @author Phylax Systems +/// @notice Example assertion bundle for one Aave v4 Spoke. +/// @dev Protects oracle-backed, cross-contract risk state that is not just a local `require`: +/// - Recomputes account data independently from reserves, user positions, Hub indices, and +/// oracle prices, then compares it with the Spoke's public account view. +/// - Checks stored risk premium only on paths that are supposed to refresh premium state. +/// - Bounds reserve oracle movement across risk-changing calls. +/// - Checks liquidation reduces borrower risk using independently recomputed account data. +contract AaveV4SpokeRiskAssertion is AaveV4Helpers { + struct CollateralItem { + uint256 risk; + uint256 value; + } + + struct ReserveContribution { + bool activeCollateral; + uint256 collateralRisk; + uint256 collateralValue; + uint256 weightedCollateralFactor; + bool borrowing; + uint256 debtValueRay; + } + + address internal immutable SPOKE; + uint256 internal immutable MAX_RESERVES_TO_SCAN; + uint256 internal immutable ORACLE_DEVIATION_BPS; + + constructor(address spoke_, uint256 maxReservesToScan_, uint256 oracleDeviationBps_) { + require(spoke_ != address(0), "AaveV4Spoke: spoke zero"); + require(maxReservesToScan_ > 0, "AaveV4Spoke: max reserves zero"); + require(oracleDeviationBps_ <= BPS, "AaveV4Spoke: bad oracle tolerance"); + + SPOKE = spoke_; + MAX_RESERVES_TO_SCAN = maxReservesToScan_; + ORACLE_DEVIATION_BPS = oracleDeviationBps_; + } + + /// @notice Registers Spoke operations that modify oracle-backed account risk. + /// @dev Calls that intentionally refresh stored risk premium are distinguished from paths + /// that only change collateral composition without refreshing premium debt. + function triggers() external view override { + registerFnCallTrigger(this.assertAccountDataMatchesIndependentState.selector, IAaveV4Spoke.withdraw.selector); + registerFnCallTrigger(this.assertAccountDataMatchesIndependentState.selector, IAaveV4Spoke.borrow.selector); + registerFnCallTrigger( + this.assertAccountDataMatchesIndependentState.selector, IAaveV4Spoke.setUsingAsCollateral.selector + ); + registerFnCallTrigger( + this.assertAccountDataMatchesIndependentState.selector, IAaveV4Spoke.updateUserRiskPremium.selector + ); + registerFnCallTrigger( + this.assertAccountDataMatchesIndependentState.selector, IAaveV4Spoke.updateUserDynamicConfig.selector + ); + registerFnCallTrigger( + this.assertAccountDataMatchesIndependentState.selector, IAaveV4Spoke.liquidationCall.selector + ); + + registerFnCallTrigger( + this.assertLiquidationImprovesBorrowerRisk.selector, IAaveV4Spoke.liquidationCall.selector + ); + } + + /// @notice Recomputes account data from primitive state and compares it to the Spoke view. + /// @dev The recomputation enumerates reserves, reads user positions, dynamic configs, Hub + /// drawn indices, Hub supply conversions, and oracle prices at the post-call fork. A + /// failure means the public account data or stored risk premium no longer follows the + /// cross-contract state that liquidations and borrow safety depend on. + function assertAccountDataMatchesIndependentState() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireAdopter(SPOKE, "AaveV4Spoke: configured spoke is not adopter"); + + (address user, bool shouldCheckStoredRiskPremium) = + _accountDataUser(ctx.selector, ph.callinputAt(ctx.callStart)); + PhEvm.ForkId memory pre = _preCall(ctx.callStart); + PhEvm.ForkId memory post = _postCall(ctx.callEnd); + + _assertOraclePricesBounded(pre, post); + + IAaveV4Spoke.UserAccountData memory expected = _recomputeAccountDataAt(user, post); + IAaveV4Spoke.UserAccountData memory actual = _spokeAccountDataAt(SPOKE, user, post); + _assertAccountDataEqual(expected, actual); + + if (shouldCheckStoredRiskPremium) { + uint256 storedRiskPremium = _spokeLastRiskPremiumAt(SPOKE, user, post); + require(storedRiskPremium == expected.riskPremium, "AaveV4Spoke: stored risk premium mismatch"); + } + } + + /// @notice Checks liquidation reduces borrower risk. + /// @dev Compares independently recomputed account data around a successful liquidation. Debt + /// may be fully cleared or reported as deficit; otherwise post-liquidation debt must not + /// increase and health factor must not be worse than before liquidation. + function assertLiquidationImprovesBorrowerRisk() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireAdopter(SPOKE, "AaveV4Spoke: configured spoke is not adopter"); + + (,, address user,,) = + abi.decode(_args(ph.callinputAt(ctx.callStart)), (uint256, uint256, address, uint256, bool)); + + IAaveV4Spoke.UserAccountData memory beforeData = _recomputeAccountDataAt(user, _preCall(ctx.callStart)); + IAaveV4Spoke.UserAccountData memory afterData = _recomputeAccountDataAt(user, _postCall(ctx.callEnd)); + + if (beforeData.healthFactor >= HEALTH_FACTOR_LIQUIDATION_THRESHOLD || afterData.totalDebtValueRay == 0) { + return; + } + + require(afterData.totalDebtValueRay <= beforeData.totalDebtValueRay, "AaveV4Spoke: liquidation increased debt"); + require(afterData.healthFactor >= beforeData.healthFactor, "AaveV4Spoke: liquidation health factor worsened"); + } + + function _recomputeAccountDataAt(address user, PhEvm.ForkId memory fork) + internal + view + returns (IAaveV4Spoke.UserAccountData memory accountData) + { + uint256 reserveCount = _readUintAt(SPOKE, abi.encodeCall(IAaveV4Spoke.getReserveCount, ()), fork); + require(reserveCount <= MAX_RESERVES_TO_SCAN, "AaveV4Spoke: too many reserves"); + + address oracle = _readAddressAt(SPOKE, abi.encodeCall(IAaveV4Spoke.ORACLE, ()), fork); + CollateralItem[] memory collateralItems = new CollateralItem[](reserveCount); + + for (uint256 reserveId; reserveId < reserveCount; ++reserveId) { + ReserveContribution memory contribution = _reserveContributionAt(user, reserveId, oracle, fork); + if (contribution.activeCollateral) { + accountData.totalCollateralValue += contribution.collateralValue; + accountData.avgCollateralFactor += contribution.weightedCollateralFactor; + collateralItems[accountData.activeCollateralCount] = + CollateralItem({risk: contribution.collateralRisk, value: contribution.collateralValue}); + accountData.activeCollateralCount++; + } + + if (contribution.borrowing) { + accountData.totalDebtValueRay += contribution.debtValueRay; + accountData.borrowCount++; + } + } + + accountData.healthFactor = _healthFactor(accountData.avgCollateralFactor, accountData.totalDebtValueRay); + if (accountData.totalCollateralValue > 0) { + accountData.avgCollateralFactor = + (accountData.avgCollateralFactor * (WAD / BPS)) / accountData.totalCollateralValue; + } + accountData.riskPremium = + _riskPremium(collateralItems, accountData.activeCollateralCount, accountData.totalDebtValueRay); + } + + function _reserveContributionAt(address user, uint256 reserveId, address oracle, PhEvm.ForkId memory fork) + internal + view + returns (ReserveContribution memory contribution) + { + IAaveV4Spoke.Reserve memory reserve = _spokeReserveAt(SPOKE, reserveId, fork); + IAaveV4Spoke.UserPosition memory position = _spokeUserPositionAt(SPOKE, reserveId, user, fork); + (bool collateral, bool borrowing) = _spokeUserReserveStatusAt(SPOKE, reserveId, user, fork); + uint256 price = _oraclePriceAt(oracle, reserveId, fork); + require(price > 0, "AaveV4Spoke: oracle price invalid"); + + if (collateral) { + (contribution.activeCollateral, contribution.collateralValue, contribution.weightedCollateralFactor) = + _collateralContribution(reserveId, reserve, position, price, fork); + contribution.collateralRisk = reserve.collateralRisk; + } + + if (borrowing) { + contribution.borrowing = true; + contribution.debtValueRay = _debtValueRay(reserve, position, price, fork); + } + } + + function _collateralContribution( + uint256 reserveId, + IAaveV4Spoke.Reserve memory reserve, + IAaveV4Spoke.UserPosition memory position, + uint256 price, + PhEvm.ForkId memory fork + ) internal view returns (bool active, uint256 collateralValue, uint256 weightedFactor) { + IAaveV4Spoke.DynamicReserveConfig memory config = + _spokeDynamicConfigAt(SPOKE, reserveId, position.dynamicConfigKey, fork); + if (config.collateralFactor == 0 || position.suppliedShares == 0) { + return (false, 0, 0); + } + + uint256 suppliedAssets = + _hubPreviewRemoveBySharesAt(reserve.hub, reserve.assetId, position.suppliedShares, fork); + collateralValue = _toValue(suppliedAssets, reserve.decimals, price); + weightedFactor = collateralValue * config.collateralFactor; + active = true; + } + + function _debtValueRay( + IAaveV4Spoke.Reserve memory reserve, + IAaveV4Spoke.UserPosition memory position, + uint256 price, + PhEvm.ForkId memory fork + ) internal view returns (uint256) { + uint256 drawnIndex = _hubDrawnIndexAt(reserve.hub, reserve.assetId, fork); + uint256 premiumDebtRay = _premiumDebtRay(position.premiumShares, position.premiumOffsetRay, drawnIndex); + uint256 debtRay = uint256(position.drawnShares) * drawnIndex + premiumDebtRay; + return _toValue(debtRay, reserve.decimals, price); + } + + function _assertAccountDataEqual( + IAaveV4Spoke.UserAccountData memory expected, + IAaveV4Spoke.UserAccountData memory actual + ) internal pure { + require(actual.riskPremium == expected.riskPremium, "AaveV4Spoke: account risk premium mismatch"); + require(actual.avgCollateralFactor == expected.avgCollateralFactor, "AaveV4Spoke: account CF mismatch"); + require(actual.healthFactor == expected.healthFactor, "AaveV4Spoke: account health factor mismatch"); + require( + actual.totalCollateralValue == expected.totalCollateralValue, "AaveV4Spoke: account collateral mismatch" + ); + require(actual.totalDebtValueRay == expected.totalDebtValueRay, "AaveV4Spoke: account debt mismatch"); + require( + actual.activeCollateralCount == expected.activeCollateralCount, + "AaveV4Spoke: active collateral count mismatch" + ); + require(actual.borrowCount == expected.borrowCount, "AaveV4Spoke: borrow count mismatch"); + } + + function _premiumDebtRay(uint256 premiumShares, int256 premiumOffsetRay, uint256 drawnIndex) + internal + pure + returns (uint256) + { + int256 premiumRay = int256(premiumShares * drawnIndex) - premiumOffsetRay; + require(premiumRay >= 0, "AaveV4Spoke: negative premium debt"); + return uint256(premiumRay); + } + + function _healthFactor(uint256 weightedCollateralFactor, uint256 totalDebtValueRay) + internal + pure + returns (uint256) + { + if (totalDebtValueRay == 0) { + return type(uint256).max; + } + return ((weightedCollateralFactor * (WAD / BPS)) * RAY) / totalDebtValueRay; + } + + function _riskPremium(CollateralItem[] memory items, uint256 length, uint256 totalDebtValueRay) + internal + pure + returns (uint256) + { + if (totalDebtValueRay == 0 || length == 0) { + return 0; + } + + _sortCollateralItems(items, length); + + uint256 totalDebtValue = _fromRayUp(totalDebtValueRay); + uint256 debtLeft = totalDebtValue; + uint256 weightedRisk; + uint256 coveredDebt; + + for (uint256 i; i < length && debtLeft != 0; ++i) { + uint256 used = items[i].value < debtLeft ? items[i].value : debtLeft; + weightedRisk += used * items[i].risk; + debtLeft -= used; + coveredDebt += used; + } + + if (coveredDebt == 0) { + return 0; + } + return _divUp(weightedRisk, coveredDebt); + } + + function _sortCollateralItems(CollateralItem[] memory items, uint256 length) internal pure { + for (uint256 i = 1; i < length; ++i) { + CollateralItem memory item = items[i]; + uint256 j = i; + while (j > 0 && _comesBefore(item, items[j - 1])) { + items[j] = items[j - 1]; + --j; + } + items[j] = item; + } + } + + function _comesBefore(CollateralItem memory a, CollateralItem memory b) internal pure returns (bool) { + return a.risk < b.risk || (a.risk == b.risk && a.value > b.value); + } + + function _assertOraclePricesBounded(PhEvm.ForkId memory pre, PhEvm.ForkId memory post) internal view { + uint256 reserveCount = _readUintAt(SPOKE, abi.encodeCall(IAaveV4Spoke.getReserveCount, ()), post); + require(reserveCount <= MAX_RESERVES_TO_SCAN, "AaveV4Spoke: too many reserves"); + + address oracle = _readAddressAt(SPOKE, abi.encodeCall(IAaveV4Spoke.ORACLE, ()), post); + for (uint256 reserveId; reserveId < reserveCount; ++reserveId) { + uint256 prePrice = _oraclePriceAt(oracle, reserveId, pre); + uint256 postPrice = _oraclePriceAt(oracle, reserveId, post); + require(prePrice > 0 && postPrice > 0, "AaveV4Spoke: oracle price invalid"); + + require( + ph.ratioGe(postPrice, 1, prePrice, 1, ORACLE_DEVIATION_BPS) + && ph.ratioGe(prePrice, 1, postPrice, 1, ORACLE_DEVIATION_BPS), + "AaveV4Spoke: oracle price drift" + ); + } + } + + function _accountDataUser(bytes4 selector, bytes memory input) + internal + pure + returns (address user, bool shouldCheckStoredRiskPremium) + { + if (selector == IAaveV4Spoke.borrow.selector || selector == IAaveV4Spoke.withdraw.selector) { + (,, user) = abi.decode(_args(input), (uint256, uint256, address)); + return (user, true); + } + + if (selector == IAaveV4Spoke.setUsingAsCollateral.selector) { + (, bool usingAsCollateral, address onBehalfOf) = abi.decode(_args(input), (uint256, bool, address)); + return (onBehalfOf, !usingAsCollateral); + } + + if (selector == IAaveV4Spoke.updateUserRiskPremium.selector) { + user = abi.decode(_args(input), (address)); + return (user, true); + } + + if (selector == IAaveV4Spoke.updateUserDynamicConfig.selector) { + user = abi.decode(_args(input), (address)); + return (user, true); + } + + if (selector == IAaveV4Spoke.liquidationCall.selector) { + (,, user,,) = abi.decode(_args(input), (uint256, uint256, address, uint256, bool)); + return (user, true); + } + } +} diff --git a/examples/aerodrome/README.md b/examples/aerodrome/README.md new file mode 100644 index 0000000..8fd4e11 --- /dev/null +++ b/examples/aerodrome/README.md @@ -0,0 +1,15 @@ +# aerodrome examples + +Assertion examples and supporting helpers extracted from the `aerodrome` branch. + +## Build + +```sh +FOUNDRY_PROFILE=aerodrome forge build +``` + +## Files + +- AerodromePoolAssertion.sol +- AerodromePoolHelpers.sol +- AerodromePoolInterfaces.sol diff --git a/examples/aerodrome/src/AerodromePoolAssertion.sol b/examples/aerodrome/src/AerodromePoolAssertion.sol new file mode 100644 index 0000000..564716f --- /dev/null +++ b/examples/aerodrome/src/AerodromePoolAssertion.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {AerodromePoolHelpers} from "./AerodromePoolHelpers.sol"; +import {IAerodromePoolLike} from "./AerodromePoolInterfaces.sol"; + +/// @title AerodromePoolAssertion +/// @author Phylax Systems +/// @notice Protects Aerodrome AMM pool accounting that is expensive to validate in production. +/// - Confirms post-call reserves are backed by token custody on the pool contract. +/// - Confirms swaps do not reduce the stable or volatile pool invariant across the call. +/// - Confirms `claimFees()` only debits the separated `PoolFees` custody it reports. +contract AerodromePoolAssertion is AerodromePoolHelpers { + constructor(address pool_) AerodromePoolHelpers(pool_) {} + + /// @notice Registers Aerodrome pool mutation surfaces that affect reserves, fees, or custody. + /// @dev Reserve backing is broad, while K and fee-debit checks are bound to their specific + /// call selectors for lower noise and clearer failures. + function triggers() external view override { + registerFnCallTrigger(this.assertReservesBackedByBalances.selector, IAerodromePoolLike.swap.selector); + registerFnCallTrigger(this.assertReservesBackedByBalances.selector, IAerodromePoolLike.mint.selector); + registerFnCallTrigger(this.assertReservesBackedByBalances.selector, IAerodromePoolLike.burn.selector); + registerFnCallTrigger(this.assertReservesBackedByBalances.selector, IAerodromePoolLike.skim.selector); + registerFnCallTrigger(this.assertReservesBackedByBalances.selector, IAerodromePoolLike.sync.selector); + registerFnCallTrigger(this.assertReservesBackedByBalances.selector, IAerodromePoolLike.claimFees.selector); + + registerFnCallTrigger(this.assertSwapKNonDecreasing.selector, IAerodromePoolLike.swap.selector); + registerFnCallTrigger( + this.assertClaimFeesDebitsSeparatedCustody.selector, IAerodromePoolLike.claimFees.selector + ); + } + + /// @notice Checks pool reserves remain externally backed after a pool mutation. + /// @dev A failure means reserve accounting claims more token custody than the pool actually + /// holds after swap, mint, burn, skim, sync, or fee-claim side effects complete. + function assertReservesBackedByBalances() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + + PoolSnapshot memory post = _poolSnapshotAt(_postCall(ctx.callEnd)); + require(post.poolBalance0 >= post.reserve0, "AerodromePool: token0 reserves underbacked"); + require(post.poolBalance1 >= post.reserve1, "AerodromePool: token1 reserves underbacked"); + } + + /// @notice Checks a swap does not reduce the pool's core curve invariant. + /// @dev Recomputes the same stable or volatile K shape from forked reserve snapshots. A failure + /// means a successful swap left LP reserves in a worse invariant state than pre-call. + function assertSwapKNonDecreasing() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + + PoolSnapshot memory pre = _poolSnapshotAt(_preCall(ctx.callStart)); + PoolSnapshot memory post = _poolSnapshotAt(_postCall(ctx.callEnd)); + require(post.k >= pre.k, "AerodromePool: swap decreased K"); + } + + /// @notice Checks fee claims are paid only from separated fee custody. + /// @dev Uses the call return values and pre/post `PoolFees` balances. A failure means + /// `claimFees()` reported one amount while debiting a different amount from fee custody. + function assertClaimFeesDebitsSeparatedCustody() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + + PoolSnapshot memory pre = _poolSnapshotAt(_preCall(ctx.callStart)); + PoolSnapshot memory post = _poolSnapshotAt(_postCall(ctx.callEnd)); + (uint256 claimed0, uint256 claimed1) = abi.decode(ph.callOutputAt(ctx.callStart), (uint256, uint256)); + + require(pre.feeBalance0 >= post.feeBalance0, "AerodromePool: token0 fee custody increased on claim"); + require(pre.feeBalance1 >= post.feeBalance1, "AerodromePool: token1 fee custody increased on claim"); + require(pre.feeBalance0 - post.feeBalance0 == claimed0, "AerodromePool: token0 claim/custody mismatch"); + require(pre.feeBalance1 - post.feeBalance1 == claimed1, "AerodromePool: token1 claim/custody mismatch"); + } +} diff --git a/examples/aerodrome/src/AerodromePoolHelpers.sol b/examples/aerodrome/src/AerodromePoolHelpers.sol new file mode 100644 index 0000000..c0be2a4 --- /dev/null +++ b/examples/aerodrome/src/AerodromePoolHelpers.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {IAerodromePoolLike, IERC20BalanceReaderLike} from "./AerodromePoolInterfaces.sol"; + +/// @title AerodromePoolHelpers +/// @notice Fork-aware state readers and math helpers for Aerodrome pool assertions. +abstract contract AerodromePoolHelpers is Assertion { + struct PoolSnapshot { + uint256 dec0; + uint256 dec1; + uint256 reserve0; + uint256 reserve1; + bool stable; + address token0; + address token1; + address poolFees; + uint256 poolBalance0; + uint256 poolBalance1; + uint256 feeBalance0; + uint256 feeBalance1; + uint256 k; + } + + address internal immutable POOL; + + constructor(address pool_) { + POOL = pool_; + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function _viewFailureMessage() internal pure override returns (string memory) { + return "AerodromePool: fork read failed"; + } + + function _requireConfiguredPoolIsAdopter() internal view { + require(ph.getAssertionAdopter() == POOL, "AerodromePool: configured pool is not adopter"); + } + + function _poolSnapshotAt(PhEvm.ForkId memory fork) internal view returns (PoolSnapshot memory snapshot) { + PhEvm.StaticCallResult memory metadataResult = + ph.staticcallAt(POOL, abi.encodeCall(IAerodromePoolLike.metadata, ()), FORK_VIEW_GAS, fork); + require(metadataResult.ok, "AerodromePool: metadata read failed"); + + ( + snapshot.dec0, + snapshot.dec1, + snapshot.reserve0, + snapshot.reserve1, + snapshot.stable, + snapshot.token0, + snapshot.token1 + ) = abi.decode(metadataResult.data, (uint256, uint256, uint256, uint256, bool, address, address)); + + PhEvm.StaticCallResult memory feesResult = + ph.staticcallAt(POOL, abi.encodeCall(IAerodromePoolLike.poolFees, ()), FORK_VIEW_GAS, fork); + require(feesResult.ok, "AerodromePool: poolFees read failed"); + snapshot.poolFees = abi.decode(feesResult.data, (address)); + + snapshot.poolBalance0 = _balanceAt(snapshot.token0, POOL, fork); + snapshot.poolBalance1 = _balanceAt(snapshot.token1, POOL, fork); + snapshot.feeBalance0 = _balanceAt(snapshot.token0, snapshot.poolFees, fork); + snapshot.feeBalance1 = _balanceAt(snapshot.token1, snapshot.poolFees, fork); + snapshot.k = _poolK(snapshot.reserve0, snapshot.reserve1, snapshot.dec0, snapshot.dec1, snapshot.stable); + } + + function _balanceAt(address token, address account, PhEvm.ForkId memory fork) internal view returns (uint256) { + PhEvm.StaticCallResult memory result = + ph.staticcallAt(token, abi.encodeCall(IERC20BalanceReaderLike.balanceOf, (account)), FORK_VIEW_GAS, fork); + require(result.ok, "AerodromePool: balance read failed"); + return abi.decode(result.data, (uint256)); + } + + function _poolK(uint256 x, uint256 y, uint256 dec0, uint256 dec1, bool stable) internal pure returns (uint256) { + if (!stable) { + return x * y; + } + + require(dec0 != 0 && dec1 != 0, "AerodromePool: zero decimals"); + uint256 scaledX = (x * 1e18) / dec0; + uint256 scaledY = (y * 1e18) / dec1; + uint256 a = (scaledX * scaledY) / 1e18; + uint256 b = ((scaledX * scaledX) / 1e18) + ((scaledY * scaledY) / 1e18); + return (a * b) / 1e18; + } +} diff --git a/examples/aerodrome/src/AerodromePoolInterfaces.sol b/examples/aerodrome/src/AerodromePoolInterfaces.sol new file mode 100644 index 0000000..88db0c6 --- /dev/null +++ b/examples/aerodrome/src/AerodromePoolInterfaces.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Minimal Aerodrome pool surface used by the pool assertion example. +interface IAerodromePoolLike { + function claimFees() external returns (uint256 claimed0, uint256 claimed1); + function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external; + function burn(address to) external returns (uint256 amount0, uint256 amount1); + function mint(address to) external returns (uint256 liquidity); + function skim(address to) external; + function sync() external; + + function metadata() + external + view + returns (uint256 dec0, uint256 dec1, uint256 r0, uint256 r1, bool st, address t0, address t1); + function poolFees() external view returns (address); +} + +/// @notice Minimal ERC20 balance reader used for fork-aware pool custody checks. +interface IERC20BalanceReaderLike { + function balanceOf(address account) external view returns (uint256); +} diff --git a/examples/cap/README.md b/examples/cap/README.md new file mode 100644 index 0000000..da3c88c --- /dev/null +++ b/examples/cap/README.md @@ -0,0 +1,16 @@ +# cap examples + +Assertion examples and supporting helpers extracted from the `cap` branch. + +## Build + +```sh +FOUNDRY_PROFILE=cap forge build +``` + +## Files + +- CapOfacComplianceAssertion.sol +- CapOfacComplianceInterfaces.sol +- CapRedemptionGateAssertion.sol +- CapRedemptionGateInterfaces.sol diff --git a/examples/cap/src/CapOfacComplianceAssertion.sol b/examples/cap/src/CapOfacComplianceAssertion.sol new file mode 100644 index 0000000..01ac799 --- /dev/null +++ b/examples/cap/src/CapOfacComplianceAssertion.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import { + ICapAccessControlLike, + ICapERC20Like, + ICapERC4626Like, + ICapLenderLike, + ICapStabledropLike, + ICapVaultLike, + IOfacCompliancePrecompile +} from "./CapOfacComplianceInterfaces.sol"; + +/// @title CapOfacComplianceAssertion +/// @author Phylax Systems +/// @notice Example Cap assertion for blocking sanctioned participants from sensitive paths. +/// @dev This example assumes a hypothetical OFAC precompile at +/// `address(uint160(uint256(keccak256("OfacCompliancePrecompile"))))`. +/// The precompile is expected to expose `isListed(address) returns (bool listed)`. +/// +/// Deploy this assertion against the Cap contract surface being protected: +/// - Vault: mint, burn, redeem, borrow, repay, rescue, insurance-fund updates +/// - Lender: borrow, repay, liquidation, restaker interest, interest receiver updates +/// - Stabledrop: claim, operator approvals, recovery +/// - AccessControl: grant/revoke access +/// - Cap ERC20 / StakedCap ERC4626 inherited transfer and share-entry/exit paths +contract CapOfacComplianceAssertion is Assertion { + IOfacCompliancePrecompile internal constant OFAC = + IOfacCompliancePrecompile(address(uint160(uint256(keccak256("OfacCompliancePrecompile"))))); + + uint256 internal constant MAX_MATCHING_CALLS = 1024; + + constructor() { + registerAssertionSpec(AssertionSpec.Experimental); + } + + function triggers() external view override { + _registerAccessControlTriggers(); + _registerErc20Triggers(); + _registerErc4626Triggers(); + _registerLenderTriggers(); + _registerStabledropTriggers(); + _registerVaultTriggers(); + } + + /// @notice Checks the triggering Cap operation does not involve a sanctioned participant. + /// @dev Checks the transaction sender, immediate caller, and selector-specific account + /// arguments decoded from calldata. Fails when the hypothetical OFAC precompile reports + /// any participant address as listed. + function assertOfacCompliantParticipants() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + bytes memory input = ph.callinputAt(ctx.callStart); + + _assertNotListed(ph.getTxObject().from); + _assertNotListed(_triggeredCaller(ctx)); + _assertSelectorSpecificParticipants(ctx.selector, input); + } + + function _registerAccessControlTriggers() internal view { + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapAccessControlLike.grantAccess.selector); + registerFnCallTrigger( + this.assertOfacCompliantParticipants.selector, ICapAccessControlLike.revokeAccess.selector + ); + } + + function _registerErc20Triggers() internal view { + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC20Like.transfer.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC20Like.transferFrom.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC20Like.approve.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC20Like.permit.selector); + } + + function _registerErc4626Triggers() internal view { + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC4626Like.deposit.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC4626Like.mint.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC4626Like.withdraw.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapERC4626Like.redeem.selector); + } + + function _registerLenderTriggers() internal view { + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapLenderLike.borrow.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapLenderLike.repay.selector); + registerFnCallTrigger( + this.assertOfacCompliantParticipants.selector, ICapLenderLike.realizeRestakerInterest.selector + ); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapLenderLike.openLiquidation.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapLenderLike.closeLiquidation.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapLenderLike.liquidate.selector); + registerFnCallTrigger( + this.assertOfacCompliantParticipants.selector, ICapLenderLike.setInterestReceiver.selector + ); + } + + function _registerStabledropTriggers() internal view { + registerFnCallTrigger( + this.assertOfacCompliantParticipants.selector, ICapStabledropLike.approveOperator.selector + ); + registerFnCallTrigger( + this.assertOfacCompliantParticipants.selector, ICapStabledropLike.approveOperatorFor.selector + ); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapStabledropLike.claim.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapStabledropLike.recoverERC20.selector); + } + + function _registerVaultTriggers() internal view { + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapVaultLike.mint.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapVaultLike.burn.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapVaultLike.redeem.selector); + // `borrow(address,uint256,address)` is already registered through the Lender surface. + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapVaultLike.repay.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapVaultLike.setInsuranceFund.selector); + registerFnCallTrigger(this.assertOfacCompliantParticipants.selector, ICapVaultLike.rescueERC20.selector); + } + + function _assertSelectorSpecificParticipants(bytes4 selector, bytes memory input) internal view { + if (selector == ICapAccessControlLike.grantAccess.selector) { + _assertNotListed(_addressArg(input, 2)); + return; + } + if (selector == ICapAccessControlLike.revokeAccess.selector) { + _assertNotListed(_addressArg(input, 2)); + return; + } + if (selector == ICapERC20Like.transfer.selector) { + _assertNotListed(_addressArg(input, 0)); + return; + } + if (selector == ICapERC20Like.transferFrom.selector) { + _assertNotListed(_addressArg(input, 0)); + _assertNotListed(_addressArg(input, 1)); + return; + } + if (selector == ICapERC20Like.approve.selector) { + _assertNotListed(_addressArg(input, 0)); + return; + } + if (selector == ICapERC20Like.permit.selector) { + _assertNotListed(_addressArg(input, 0)); + _assertNotListed(_addressArg(input, 1)); + return; + } + if (selector == ICapERC4626Like.deposit.selector || selector == ICapERC4626Like.mint.selector) { + _assertNotListed(_addressArg(input, 1)); + return; + } + if (selector == ICapERC4626Like.withdraw.selector || selector == ICapERC4626Like.redeem.selector) { + _assertNotListed(_addressArg(input, 1)); + _assertNotListed(_addressArg(input, 2)); + return; + } + _assertCapSpecificParticipants(selector, input); + } + + function _assertCapSpecificParticipants(bytes4 selector, bytes memory input) internal view { + if (selector == ICapLenderLike.borrow.selector || selector == ICapVaultLike.borrow.selector) { + _assertNotListed(_addressArg(input, 2)); + return; + } + if (selector == ICapLenderLike.repay.selector) { + _assertNotListed(_addressArg(input, 2)); + return; + } + if ( + selector == ICapLenderLike.realizeRestakerInterest.selector + || selector == ICapLenderLike.openLiquidation.selector + || selector == ICapLenderLike.closeLiquidation.selector || selector == ICapLenderLike.liquidate.selector + ) { + _assertNotListed(_addressArg(input, 0)); + return; + } + if (selector == ICapLenderLike.setInterestReceiver.selector) { + _assertNotListed(_addressArg(input, 1)); + return; + } + if (selector == ICapStabledropLike.approveOperator.selector) { + _assertNotListed(_addressArg(input, 0)); + return; + } + if (selector == ICapStabledropLike.approveOperatorFor.selector) { + _assertNotListed(_addressArg(input, 0)); + _assertNotListed(_addressArg(input, 1)); + return; + } + if (selector == ICapStabledropLike.claim.selector) { + _assertNotListed(_addressArg(input, 0)); + _assertNotListed(_addressArg(input, 1)); + return; + } + if (selector == ICapStabledropLike.recoverERC20.selector) { + _assertNotListed(_addressArg(input, 1)); + return; + } + _assertVaultParticipants(selector, input); + } + + function _assertVaultParticipants(bytes4 selector, bytes memory input) internal view { + if (selector == ICapVaultLike.mint.selector || selector == ICapVaultLike.burn.selector) { + _assertNotListed(_addressArg(input, 3)); + return; + } + if (selector == ICapVaultLike.redeem.selector) { + _assertNotListed(_addressArg(input, 2)); + return; + } + if (selector == ICapVaultLike.setInsuranceFund.selector) { + _assertNotListed(_addressArg(input, 0)); + return; + } + if (selector == ICapVaultLike.rescueERC20.selector) { + _assertNotListed(_addressArg(input, 1)); + } + } + + function _triggeredCaller(PhEvm.TriggerContext memory ctx) internal view returns (address caller) { + PhEvm.TriggerCall[] memory calls = + ph.matchingCalls(ph.getAssertionAdopter(), ctx.selector, _successOnlyFilter(), MAX_MATCHING_CALLS); + + for (uint256 i = 0; i < calls.length; i++) { + if (calls[i].callId == ctx.callStart) return calls[i].caller; + } + + revert("CapOFAC: triggering call not found"); + } + + function _assertNotListed(address account) internal view { + if (account == address(0)) return; + require(!OFAC.isListed(account), "CapOFAC: listed address"); + } + + function _addressArg(bytes memory input, uint256 argIndex) internal pure returns (address account) { + uint256 offset = 4 + (argIndex * 32); + require(input.length >= offset + 32, "CapOFAC: malformed calldata"); + + assembly { + account := shr(96, mload(add(add(input, 0x20), offset))) + } + } +} diff --git a/examples/cap/src/CapOfacComplianceInterfaces.sol b/examples/cap/src/CapOfacComplianceInterfaces.sol new file mode 100644 index 0000000..cae9b5e --- /dev/null +++ b/examples/cap/src/CapOfacComplianceInterfaces.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Hypothetical OFAC compliance precompile. +/// @dev Returns true when `account` appears on the sanctions list. +interface IOfacCompliancePrecompile { + function isListed(address account) external view returns (bool listed); +} + +interface ICapAccessControlLike { + function grantAccess(bytes4 selector, address target, address account) external; + function revokeAccess(bytes4 selector, address target, address account) external; +} + +interface ICapERC20Like { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); + + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external; +} + +interface ICapERC4626Like { + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + function mint(uint256 shares, address receiver) external returns (uint256 assets); + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares); + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets); +} + +interface ICapLenderLike { + function borrow(address asset, uint256 amount, address receiver) external returns (uint256 borrowed); + function repay(address asset, uint256 amount, address agent) external returns (uint256 repaid); + function realizeRestakerInterest(address agent, address asset) external returns (uint256 actualRealized); + function openLiquidation(address agent) external; + function closeLiquidation(address agent) external; + function liquidate(address agent, address asset, uint256 amount, uint256 minLiquidatedValue) + external + returns (uint256 liquidatedValue); + function setInterestReceiver(address asset, address interestReceiver) external; +} + +interface ICapStabledropLike { + function approveOperator(address operator, bool approved) external; + function approveOperatorFor(address claimant, address operator, bool approved) external; + function claim(address claimant, address recipient, uint256 amount, bytes32[] calldata proofs) external; + function recoverERC20(address token, address to, uint256 amount) external; +} + +interface ICapVaultLike { + function mint(address asset, uint256 amountIn, uint256 minAmountOut, address receiver, uint256 deadline) + external + returns (uint256 amountOut); + function burn(address asset, uint256 amountIn, uint256 minAmountOut, address receiver, uint256 deadline) + external + returns (uint256 amountOut); + function redeem(uint256 amountIn, uint256[] calldata minAmountsOut, address receiver, uint256 deadline) + external + returns (uint256[] memory amountsOut); + function borrow(address asset, uint256 amount, address receiver) external; + function repay(address asset, uint256 amount) external; + function setInsuranceFund(address insuranceFund) external; + function rescueERC20(address asset, address receiver) external; +} diff --git a/examples/cap/src/CapRedemptionGateAssertion.sol b/examples/cap/src/CapRedemptionGateAssertion.sol new file mode 100644 index 0000000..434d61e --- /dev/null +++ b/examples/cap/src/CapRedemptionGateAssertion.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import { + ICapGateFractionalReserveLike, + ICapGateVaultLike, + IERC20BalanceReaderLike +} from "./CapRedemptionGateInterfaces.sol"; + +/// @title CapRedemptionGateAssertion +/// @author Phylax Systems +/// @notice Example Cap vault bank-run gate using rolling per-asset outflow thresholds. +/// @dev Deploy this assertion against the Cap cUSD Vault and pass the supported underlying +/// assets. Each asset gets its own independent watcher so a run on one reserve does not +/// block unrelated reserves. The gate uses the built-in cumulative outflow trigger, then +/// recalculates utilization against Cap's true per-asset TVL: idle vault balance plus the +/// amount loaned into fractional-reserve strategies. +/// +/// Selector policy: +/// - >= 15% absolute outflow over 72h blocks new `borrow(asset,amount,receiver)`. +/// - >= 30% absolute outflow over 72h blocks user `burn` and `redeem` redemptions. +/// - >= 50% absolute outflow over 72h blocks `investAll(asset)`. +/// - `mint`, `repay`, `divestAll`, and rescue/admin paths are intentionally not gated. +contract CapRedemptionGateAssertion is Assertion { + uint256 internal constant WINDOW = 72 hours; + uint256 internal constant TIER2_BPS = 1_500; + uint256 internal constant TIER3_BPS = 3_000; + uint256 internal constant TIER3_HALT_INVEST_BPS = 5_000; + + // Use a low trigger threshold because the built-in TVL snapshot is the idle vault balance. + // The assertion recalculates against strategy-inclusive TVL before deciding what to block. + uint256 internal constant WATCH_TRIGGER_BPS = 1; + uint256 internal constant MAX_MATCHING_CALLS = 1024; + + address internal immutable ASSET0; + address internal immutable ASSET1; + address internal immutable ASSET2; + address internal immutable ASSET3; + address internal immutable ASSET4; + + constructor(address _asset0, address _asset1, address _asset2, address _asset3, address _asset4) { + ASSET0 = _asset0; + ASSET1 = _asset1; + ASSET2 = _asset2; + ASSET3 = _asset3; + ASSET4 = _asset4; + + registerAssertionSpec(AssertionSpec.Experimental); + } + + function triggers() external view override { + _watchAsset(ASSET0); + _watchAsset(ASSET1); + _watchAsset(ASSET2); + _watchAsset(ASSET3); + _watchAsset(ASSET4); + } + + /// @notice Applies Cap's tiered per-asset withdrawal gate after a rolling outflow breach. + /// @dev Reads `ph.outflowContext()` for the token that breached the built-in watcher, then + /// recomputes outflow bps using `absoluteOutflow / (idle + loaned)` at the PreTx fork. + /// Fails only when the current transaction contains a selector blocked at the active + /// tier for the same asset. + function assertCapRedemptionGate() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + require(_isWatchedAsset(ctx.token), "CapGate: unwatched asset"); + + uint256 currentBps = _absoluteOutflowBps(ctx); + if (currentBps < TIER2_BPS) return; + + if (_hasAssetCall(ICapGateVaultLike.borrow.selector, ctx.token)) { + revert("CapGate: borrow disabled"); + } + + if (currentBps >= TIER3_BPS) { + if (_hasAssetCall(ICapGateVaultLike.burn.selector, ctx.token) || _hasRedeemCall()) { + revert("CapGate: redemption capacity reached"); + } + } + + if (currentBps >= TIER3_HALT_INVEST_BPS) { + if (_hasAssetCall(ICapGateFractionalReserveLike.investAll.selector, ctx.token)) { + revert("CapGate: invest disabled"); + } + } + } + + function _watchAsset(address asset) internal view { + if (asset == address(0)) return; + watchCumulativeOutflow(asset, WATCH_TRIGGER_BPS, WINDOW, this.assertCapRedemptionGate.selector); + } + + function _absoluteOutflowBps(PhEvm.OutflowContext memory ctx) internal view returns (uint256) { + uint256 trueTvl = _trueTvlAt(ctx.token, _preTx()); + require(trueTvl > 0, "CapGate: zero TVL"); + return ctx.absoluteOutflow * 10_000 / trueTvl; + } + + function _trueTvlAt(address asset, PhEvm.ForkId memory fork) internal view returns (uint256) { + address vault = ph.getAssertionAdopter(); + uint256 idle = _readUintAt(asset, abi.encodeCall(IERC20BalanceReaderLike.balanceOf, (vault)), fork); + uint256 loaned = _readUintAt(vault, abi.encodeCall(ICapGateFractionalReserveLike.loaned, (asset)), fork); + return idle + loaned; + } + + function _hasAssetCall(bytes4 selector, address asset) internal view returns (bool) { + PhEvm.TriggerCall[] memory calls = + ph.matchingCalls(ph.getAssertionAdopter(), selector, _successOnlyFilter(), MAX_MATCHING_CALLS); + + for (uint256 i; i < calls.length; ++i) { + bytes memory input = ph.callinputAt(calls[i].callId); + if (_addressArg(input, 0) == asset) return true; + } + + return false; + } + + function _hasRedeemCall() internal view returns (bool) { + PhEvm.TriggerCall[] memory calls = ph.matchingCalls( + ph.getAssertionAdopter(), ICapGateVaultLike.redeem.selector, _successOnlyFilter(), MAX_MATCHING_CALLS + ); + return calls.length > 0; + } + + function _isWatchedAsset(address asset) internal view returns (bool) { + return asset != address(0) + && (asset == ASSET0 || asset == ASSET1 || asset == ASSET2 || asset == ASSET3 || asset == ASSET4); + } + + function _addressArg(bytes memory input, uint256 argIndex) internal pure returns (address account) { + uint256 offset = 4 + argIndex * 32; + require(input.length >= offset + 32, "CapGate: malformed calldata"); + + assembly { + account := shr(96, mload(add(add(input, 0x20), offset))) + } + } +} diff --git a/examples/cap/src/CapRedemptionGateInterfaces.sol b/examples/cap/src/CapRedemptionGateInterfaces.sol new file mode 100644 index 0000000..128c711 --- /dev/null +++ b/examples/cap/src/CapRedemptionGateInterfaces.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +interface ICapGateVaultLike { + function burn(address asset, uint256 amountIn, uint256 minAmountOut, address receiver, uint256 deadline) + external + returns (uint256 amountOut); + function redeem(uint256 amountIn, uint256[] calldata minAmountsOut, address receiver, uint256 deadline) + external + returns (uint256[] memory amountsOut); + function borrow(address asset, uint256 amount, address receiver) external; +} + +interface ICapGateFractionalReserveLike { + function investAll(address asset) external; + function divestAll(address asset) external; + function loaned(address asset) external view returns (uint256 loanedAmount); +} + +interface IERC20BalanceReaderLike { + function balanceOf(address account) external view returns (uint256 balance); +} diff --git a/examples/curve/README.md b/examples/curve/README.md new file mode 100644 index 0000000..3d1ba33 --- /dev/null +++ b/examples/curve/README.md @@ -0,0 +1,23 @@ +# curve examples + +Assertion examples and supporting helpers extracted from the `curve` branch. + +## Build + +```sh +FOUNDRY_PROFILE=curve forge build +``` + +## Files + +- CurveLlammaAssertion.sol +- CurveLlammaProtocol.sol +- CurveUsdControllerAssertion.sol +- CurveUsdProtocol.sol +- LlamaLendControllerAssertion.sol +- LlamaLendProtocol.sol +- LlamaLendVaultAssertion.sol +- StableSwapNGPoolAssertion.sol +- StableSwapNGProtocol.sol +- TriCryptoNGPoolAssertion.sol +- TriCryptoNGProtocol.sol diff --git a/examples/curve/src/CurveLlammaAssertion.sol b/examples/curve/src/CurveLlammaAssertion.sol new file mode 100644 index 0000000..d79d4a9 --- /dev/null +++ b/examples/curve/src/CurveLlammaAssertion.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {CurveLlammaProtocolHelpers} from "./CurveLlammaProtocol.sol"; + +/// @title CurveLlammaAssertion +/// @notice Example LLAMMA checks for band custody, band layout, swap price bounds, +/// and a hard cumulative inflow circuit breaker on both token legs. +contract CurveLlammaAssertion is CurveLlammaProtocolHelpers { + uint256 public constant INFLOW_THRESHOLD_BPS = 1_000; + uint256 public constant INFLOW_WINDOW_DURATION = 6 hours; + + constructor( + address amm_, + address borrowedToken_, + address collateralToken_, + uint256 borrowedPrecision_, + uint256 collateralPrecision_, + uint256 maxBandsToScan_, + uint256 dustTolerance_, + uint256 priceTolerance_ + ) + CurveLlammaProtocolHelpers( + amm_, + borrowedToken_, + collateralToken_, + borrowedPrecision_, + collateralPrecision_, + maxBandsToScan_, + dustTolerance_, + priceTolerance_ + ) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers checks over band sums vs ERC20 custody, one-sided inactive bands, swap prices, + /// and 10% token inflow caps over a rolling 6 hour window for both AMM legs. + function triggers() external view override { + watchCumulativeInflow( + borrowedToken, INFLOW_THRESHOLD_BPS, INFLOW_WINDOW_DURATION, this.assertCumulativeInflow.selector + ); + watchCumulativeInflow( + collateralToken, INFLOW_THRESHOLD_BPS, INFLOW_WINDOW_DURATION, this.assertCumulativeInflow.selector + ); + registerTxEndTrigger(this.assertAMMCustodyCoversBands.selector); + registerTxEndTrigger(this.assertBandShape.selector); + _registerLlammaSwapTriggers(this.assertPostSwapPriceInsideActiveBand.selector); + } + + /// @notice Hard circuit breaker that blocks transactions while either monitored inflow stays above threshold. + function assertCumulativeInflow() external pure { + revert("CurveLLAMMA: cumulative inflow breaker tripped"); + } + + /// @notice Compares AMM ERC20 balances with summed `bands_x` and `bands_y` across scanned bands. + function assertAMMCustodyCoversBands() external { + PhEvm.ForkId memory fork = _postTx(); + int256 minBand = _ammMinBandAt(fork); + int256 maxBand = _ammMaxBandAt(fork); + uint256 span = _bandSpan(minBand, maxBand); + + uint256 sumX; + uint256 sumY; + for (uint256 offset; offset < span; ++offset) { + int256 band = _bandAt(minBand, offset); + sumX += _ammBandXAt(band, fork); + sumY += _ammBandYAt(band, fork); + } + + uint256 borrowedBalance = _readBalanceAt(borrowedToken, amm, fork); + uint256 collateralBalance = _readBalanceAt(collateralToken, amm, fork); + + require( + borrowedBalance * borrowedPrecision + dustTolerance >= sumX, "CurveLLAMMA: borrowed custody below bands_x" + ); + require( + collateralBalance * collateralPrecision + dustTolerance >= sumY, + "CurveLLAMMA: collateral custody below bands_y" + ); + } + + /// @notice Checks `bands_y == 0` below `active_band` and `bands_x == 0` above it. + function assertBandShape() external { + PhEvm.ForkId memory fork = _postTx(); + int256 active = _ammActiveBandAt(fork); + int256 minBand = _ammMinBandAt(fork); + int256 maxBand = _ammMaxBandAt(fork); + uint256 span = _bandSpan(minBand, maxBand); + + for (uint256 offset; offset < span; ++offset) { + int256 band = _bandAt(minBand, offset); + uint256 x = _ammBandXAt(band, fork); + uint256 y = _ammBandYAt(band, fork); + + if (band < active) { + require(y == 0, "CurveLLAMMA: collateral below active band"); + } + + if (band > active) { + require(x == 0, "CurveLLAMMA: borrowed token above active band"); + } + } + } + + /// @notice Checks `get_p()` stays between `p_current_down` and `p_current_up` for the active band. + function assertPostSwapPriceInsideActiveBand() external { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory fork = _postCall(ctx.callEnd); + + int256 active = _ammActiveBandAt(fork); + uint256 price = _ammPriceAt(fork); + uint256 priceDown = _ammBandPriceDownAt(active, fork); + uint256 priceUp = _ammBandPriceUpAt(active, fork); + + require(price + priceTolerance >= priceDown, "CurveLLAMMA: price below active band"); + require(price <= priceUp + priceTolerance, "CurveLLAMMA: price above active band"); + } +} diff --git a/examples/curve/src/CurveLlammaProtocol.sol b/examples/curve/src/CurveLlammaProtocol.sol new file mode 100644 index 0000000..5386c27 --- /dev/null +++ b/examples/curve/src/CurveLlammaProtocol.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +interface ICurveLlammaAMM { + function active_band() external view returns (int256); + function min_band() external view returns (int256); + function max_band() external view returns (int256); + function bands_x(int256 n) external view returns (uint256); + function bands_y(int256 n) external view returns (uint256); + function get_p() external view returns (uint256); + function p_current_down(int256 n) external view returns (uint256); + function p_current_up(int256 n) external view returns (uint256); +} + +library CurveLlammaSelectors { + bytes4 internal constant EXCHANGE = bytes4(keccak256("exchange(uint256,uint256,uint256,uint256)")); + bytes4 internal constant EXCHANGE_FOR = bytes4(keccak256("exchange(uint256,uint256,uint256,uint256,address)")); + bytes4 internal constant EXCHANGE_DY = bytes4(keccak256("exchange_dy(uint256,uint256,uint256,uint256)")); + bytes4 internal constant EXCHANGE_DY_FOR = + bytes4(keccak256("exchange_dy(uint256,uint256,uint256,uint256,address)")); +} + +abstract contract CurveLlammaProtocolHelpers is Assertion { + address internal immutable amm; + address internal immutable borrowedToken; + address internal immutable collateralToken; + uint256 internal immutable borrowedPrecision; + uint256 internal immutable collateralPrecision; + uint256 internal immutable maxBandsToScan; + uint256 internal immutable dustTolerance; + uint256 internal immutable priceTolerance; + + /// @dev Token addresses and precisions are passed explicitly so the constructor never + /// reads from the AMM or its coins. The Credible Layer's assertion-deploy runtime + /// is isolated from the adopter; a `coins()` or `decimals()` call inside the + /// constructor would revert with EXTCODESIZE = 0. + constructor( + address amm_, + address borrowedToken_, + address collateralToken_, + uint256 borrowedPrecision_, + uint256 collateralPrecision_, + uint256 maxBandsToScan_, + uint256 dustTolerance_, + uint256 priceTolerance_ + ) { + amm = amm_; + borrowedToken = borrowedToken_; + collateralToken = collateralToken_; + borrowedPrecision = borrowedPrecision_; + collateralPrecision = collateralPrecision_; + maxBandsToScan = maxBandsToScan_; + dustTolerance = dustTolerance_; + priceTolerance = priceTolerance_; + } + + function _registerLlammaSwapTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, CurveLlammaSelectors.EXCHANGE); + registerFnCallTrigger(assertionSelector, CurveLlammaSelectors.EXCHANGE_FOR); + registerFnCallTrigger(assertionSelector, CurveLlammaSelectors.EXCHANGE_DY); + registerFnCallTrigger(assertionSelector, CurveLlammaSelectors.EXCHANGE_DY_FOR); + } + + function _readIntAt(address target, bytes memory data, PhEvm.ForkId memory fork) + internal + view + returns (int256 value) + { + value = abi.decode(_viewAt(target, data, fork), (int256)); + } + + function _bandSpan(int256 minBand, int256 maxBand) internal view returns (uint256 span) { + require(maxBand >= minBand, "CurveLLAMMA: invalid band range"); + span = uint256(maxBand - minBand) + 1; + require(span <= maxBandsToScan, "CurveLLAMMA: band scan too large"); + } + + function _bandAt(int256 minBand, uint256 offset) internal pure returns (int256) { + require(offset <= uint256(type(int256).max), "CurveLLAMMA: band offset too large"); + return minBand + int256(offset); + } + + function _ammMinBandAt(PhEvm.ForkId memory fork) internal view returns (int256) { + return _readIntAt(amm, abi.encodeCall(ICurveLlammaAMM.min_band, ()), fork); + } + + function _ammMaxBandAt(PhEvm.ForkId memory fork) internal view returns (int256) { + return _readIntAt(amm, abi.encodeCall(ICurveLlammaAMM.max_band, ()), fork); + } + + function _ammActiveBandAt(PhEvm.ForkId memory fork) internal view returns (int256) { + return _readIntAt(amm, abi.encodeCall(ICurveLlammaAMM.active_band, ()), fork); + } + + function _ammBandXAt(int256 band, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(amm, abi.encodeCall(ICurveLlammaAMM.bands_x, (band)), fork); + } + + function _ammBandYAt(int256 band, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(amm, abi.encodeCall(ICurveLlammaAMM.bands_y, (band)), fork); + } + + function _ammPriceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(amm, abi.encodeCall(ICurveLlammaAMM.get_p, ()), fork); + } + + function _ammBandPriceDownAt(int256 band, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(amm, abi.encodeCall(ICurveLlammaAMM.p_current_down, (band)), fork); + } + + function _ammBandPriceUpAt(int256 band, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(amm, abi.encodeCall(ICurveLlammaAMM.p_current_up, (band)), fork); + } +} diff --git a/examples/curve/src/CurveUsdControllerAssertion.sol b/examples/curve/src/CurveUsdControllerAssertion.sol new file mode 100644 index 0000000..67a5a4f --- /dev/null +++ b/examples/curve/src/CurveUsdControllerAssertion.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {CurveUsdControllerProtocolHelpers} from "./CurveUsdProtocol.sol"; + +/// @title CurveUsdControllerAssertion +/// @notice Example crvUSD controller checks for loan lists, debt totals, and post-action solvency. +contract CurveUsdControllerAssertion is CurveUsdControllerProtocolHelpers { + constructor(address controller_, address amm_, uint256 maxLoansToScan_, uint256 debtTolerance_) + CurveUsdControllerProtocolHelpers(controller_, amm_, maxLoansToScan_, debtTolerance_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers checks over loan-list indexing, aggregate debt, and post-action health rules. + function triggers() external view override { + registerTxEndTrigger(this.assertLoanListIntegrity.selector); + registerTxEndTrigger(this.assertDebtAccounting.selector); + _registerCurveUsdRiskIncreasingTriggers(this.assertPostRiskIncreasingOperationHealth.selector); + _registerCurveUsdLiquidationTriggers(this.assertPostLiquidationHealthRule.selector); + } + + /// @notice Checks `loans(i)`, `loan_ix(user)`, `loan_exists(user)`, and AMM liquidity stay consistent. + function assertLoanListIntegrity() external { + PhEvm.ForkId memory fork = _postTx(); + uint256 loanCount = _controllerNLoansAt(fork); + require(loanCount <= maxLoansToScan, "CurveUSD: too many loans to scan"); + + for (uint256 i; i < loanCount; ++i) { + address user = _controllerLoanAt(i, fork); + + require(user != address(0), "CurveUSD: empty active loan slot"); + require(_controllerLoanExistsAt(user, fork), "CurveUSD: listed loan does not exist"); + require(_controllerLoanIndexAt(user, fork) == i, "CurveUSD: bad loan_ix"); + require(_ammHasLiquidityAt(user, fork), "CurveUSD: loan without AMM liquidity"); + } + } + + /// @notice Checks `total_debt()` stays within rounding distance of the summed user debts. + function assertDebtAccounting() external { + PhEvm.ForkId memory fork = _postTx(); + uint256 loanCount = _controllerNLoansAt(fork); + require(loanCount <= maxLoansToScan, "CurveUSD: too many loans to scan"); + + uint256 sumDebt; + for (uint256 i; i < loanCount; ++i) { + sumDebt += _controllerDebtAt(_controllerLoanAt(i, fork), fork); + } + + uint256 totalDebt = _controllerTotalDebtAt(fork); + require(sumDebt + debtTolerance >= totalDebt, "CurveUSD: sum debt below total debt"); + require(sumDebt <= totalDebt + loanCount + debtTolerance, "CurveUSD: sum debt too high"); + } + + /// @notice Checks risk-increasing actions leave an existing loan with nonnegative health. + function assertPostRiskIncreasingOperationHealth() external { + CurveUsdTriggeredCall memory triggered = _resolveCurveUsdTriggeredCall(); + address user = _curveUsdAccountFromCall(triggered); + PhEvm.ForkId memory fork = _postCall(triggered.callEnd); + + if (!_controllerLoanExistsAt(user, fork)) { + return; + } + + require(_controllerHealthAt(user, false, fork) >= 0, "CurveUSD: unhealthy post-operation"); + } + + /// @notice Checks a liquidation that starts from healthy state does not leave a surviving loan unhealthy. + function assertPostLiquidationHealthRule() external { + CurveUsdTriggeredCall memory triggered = _resolveCurveUsdTriggeredCall(); + address user = _curveUsdAccountFromCall(triggered); + + if (_controllerHealthAt(user, true, _preCall(triggered.callStart)) < 0) { + return; + } + + PhEvm.ForkId memory fork = _postCall(triggered.callEnd); + if (!_controllerLoanExistsAt(user, fork)) { + return; + } + + require(_controllerHealthAt(user, false, fork) >= 0, "CurveUSD: healthy liquidation left unhealthy"); + } +} diff --git a/examples/curve/src/CurveUsdProtocol.sol b/examples/curve/src/CurveUsdProtocol.sol new file mode 100644 index 0000000..31a51c1 --- /dev/null +++ b/examples/curve/src/CurveUsdProtocol.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +interface ICurveUsdController { + function amm() external view returns (address); + function debt(address user) external view returns (uint256); + function loan_exists(address user) external view returns (bool); + function total_debt() external view returns (uint256); + function health(address user, bool full) external view returns (int256); + function loans(uint256 i) external view returns (address); + function loan_ix(address user) external view returns (uint256); + function n_loans() external view returns (uint256); +} + +interface ICurveUsdAMMLiquidity { + function has_liquidity(address user) external view returns (bool); +} + +library CurveUsdControllerSelectors { + bytes4 internal constant CREATE_LOAN = bytes4(keccak256("create_loan(uint256,uint256,uint256)")); + bytes4 internal constant CREATE_LOAN_FOR = bytes4(keccak256("create_loan(uint256,uint256,uint256,address)")); + bytes4 internal constant CREATE_LOAN_CALLBACK = + bytes4(keccak256("create_loan(uint256,uint256,uint256,address,address)")); + bytes4 internal constant CREATE_LOAN_CALLBACK_DATA = + bytes4(keccak256("create_loan(uint256,uint256,uint256,address,address,bytes)")); + + bytes4 internal constant BORROW_MORE = bytes4(keccak256("borrow_more(uint256,uint256)")); + bytes4 internal constant BORROW_MORE_FOR = bytes4(keccak256("borrow_more(uint256,uint256,address)")); + bytes4 internal constant BORROW_MORE_CALLBACK = bytes4(keccak256("borrow_more(uint256,uint256,address,address)")); + bytes4 internal constant BORROW_MORE_CALLBACK_DATA = + bytes4(keccak256("borrow_more(uint256,uint256,address,address,bytes)")); + + bytes4 internal constant REMOVE_COLLATERAL = bytes4(keccak256("remove_collateral(uint256)")); + bytes4 internal constant REMOVE_COLLATERAL_FOR = bytes4(keccak256("remove_collateral(uint256,address)")); + + bytes4 internal constant LIQUIDATE = bytes4(keccak256("liquidate(address,uint256)")); + bytes4 internal constant LIQUIDATE_FRAC = bytes4(keccak256("liquidate(address,uint256,uint256)")); + bytes4 internal constant LIQUIDATE_CALLBACK = bytes4(keccak256("liquidate(address,uint256,uint256,address)")); + bytes4 internal constant LIQUIDATE_CALLBACK_DATA = + bytes4(keccak256("liquidate(address,uint256,uint256,address,bytes)")); +} + +abstract contract CurveUsdControllerProtocolHelpers is Assertion { + address internal immutable controller; + address internal immutable amm; + uint256 internal immutable maxLoansToScan; + uint256 internal immutable debtTolerance; + + struct CurveUsdTriggeredCall { + bytes4 selector; + address caller; + bytes input; + uint256 callStart; + uint256 callEnd; + } + + /// @dev `amm_` is passed explicitly so the constructor never reads `controller_.amm()`. + /// The Credible Layer's assertion-deploy runtime is isolated from the adopter, and + /// live protocol reads would revert with EXTCODESIZE = 0 during construction. + constructor(address controller_, address amm_, uint256 maxLoansToScan_, uint256 debtTolerance_) { + controller = controller_; + amm = amm_; + maxLoansToScan = maxLoansToScan_; + debtTolerance = debtTolerance_; + } + + function _registerCurveUsdRiskIncreasingTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.CREATE_LOAN); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.CREATE_LOAN_FOR); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.CREATE_LOAN_CALLBACK); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.CREATE_LOAN_CALLBACK_DATA); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.BORROW_MORE); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.BORROW_MORE_FOR); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.BORROW_MORE_CALLBACK); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.BORROW_MORE_CALLBACK_DATA); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.REMOVE_COLLATERAL); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.REMOVE_COLLATERAL_FOR); + } + + function _registerCurveUsdLiquidationTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.LIQUIDATE); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.LIQUIDATE_FRAC); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.LIQUIDATE_CALLBACK); + registerFnCallTrigger(assertionSelector, CurveUsdControllerSelectors.LIQUIDATE_CALLBACK_DATA); + } + + function _resolveCurveUsdTriggeredCall() internal view returns (CurveUsdTriggeredCall memory triggered) { + PhEvm.TriggerContext memory context = ph.context(); + PhEvm.CallInputs[] memory calls = ph.getAllCallInputs(ph.getAssertionAdopter(), context.selector); + + for (uint256 i; i < calls.length; ++i) { + if (calls[i].id == context.callStart) { + return CurveUsdTriggeredCall({ + selector: context.selector, + caller: calls[i].caller, + input: calls[i].input, + callStart: context.callStart, + callEnd: context.callEnd + }); + } + } + + revert("CurveUSD: triggered call not found"); + } + + function _curveUsdAccountFromCall(CurveUsdTriggeredCall memory triggered) internal pure returns (address) { + bytes4 selector = triggered.selector; + + if (selector == CurveUsdControllerSelectors.CREATE_LOAN) { + return triggered.caller; + } + if ( + selector == CurveUsdControllerSelectors.CREATE_LOAN_FOR + || selector == CurveUsdControllerSelectors.CREATE_LOAN_CALLBACK + || selector == CurveUsdControllerSelectors.CREATE_LOAN_CALLBACK_DATA + ) { + return _addressArg(triggered.input, 3); + } + + if (selector == CurveUsdControllerSelectors.BORROW_MORE) { + return triggered.caller; + } + if ( + selector == CurveUsdControllerSelectors.BORROW_MORE_FOR + || selector == CurveUsdControllerSelectors.BORROW_MORE_CALLBACK + || selector == CurveUsdControllerSelectors.BORROW_MORE_CALLBACK_DATA + ) { + return _addressArg(triggered.input, 2); + } + + if (selector == CurveUsdControllerSelectors.REMOVE_COLLATERAL) { + return triggered.caller; + } + if (selector == CurveUsdControllerSelectors.REMOVE_COLLATERAL_FOR) { + return _addressArg(triggered.input, 1); + } + + if ( + selector == CurveUsdControllerSelectors.LIQUIDATE || selector == CurveUsdControllerSelectors.LIQUIDATE_FRAC + || selector == CurveUsdControllerSelectors.LIQUIDATE_CALLBACK + || selector == CurveUsdControllerSelectors.LIQUIDATE_CALLBACK_DATA + ) { + return _addressArg(triggered.input, 0); + } + + return address(0); + } + + function _controllerNLoansAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ICurveUsdController.n_loans, ()), fork); + } + + function _controllerLoanAt(uint256 i, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(controller, abi.encodeCall(ICurveUsdController.loans, (i)), fork); + } + + function _controllerLoanIndexAt(address user, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ICurveUsdController.loan_ix, (user)), fork); + } + + function _controllerLoanExistsAt(address user, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(controller, abi.encodeCall(ICurveUsdController.loan_exists, (user)), fork); + } + + function _controllerDebtAt(address user, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ICurveUsdController.debt, (user)), fork); + } + + function _controllerTotalDebtAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ICurveUsdController.total_debt, ()), fork); + } + + function _controllerHealthAt(address user, bool full, PhEvm.ForkId memory fork) internal view returns (int256) { + return abi.decode(_viewAt(controller, abi.encodeCall(ICurveUsdController.health, (user, full)), fork), (int256)); + } + + function _ammHasLiquidityAt(address user, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(amm, abi.encodeCall(ICurveUsdAMMLiquidity.has_liquidity, (user)), fork); + } + + function _addressArg(bytes memory input, uint256 argIndex) internal pure returns (address) { + return address(uint160(uint256(_wordArg(input, argIndex)))); + } + + function _wordArg(bytes memory input, uint256 argIndex) internal pure returns (bytes32 word) { + uint256 offset = 4 + argIndex * 32; + require(input.length >= offset + 32, "CurveUSD: calldata arg missing"); + assembly { + word := mload(add(add(input, 0x20), offset)) + } + } +} diff --git a/examples/curve/src/LlamaLendControllerAssertion.sol b/examples/curve/src/LlamaLendControllerAssertion.sol new file mode 100644 index 0000000..8210757 --- /dev/null +++ b/examples/curve/src/LlamaLendControllerAssertion.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {LlamaLendControllerProtocolHelpers} from "./LlamaLendProtocol.sol"; + +/// @title LlamaLendControllerAssertion +/// @notice Example LlamaLend controller checks for borrowed-token custody, borrow-cap enforcement, +/// and hard cumulative inflow/outflow circuit breakers. +contract LlamaLendControllerAssertion is LlamaLendControllerProtocolHelpers { + uint256 public constant INFLOW_THRESHOLD_BPS = 1_000; + uint256 public constant INFLOW_WINDOW_DURATION = 6 hours; + uint256 public constant OUTFLOW_THRESHOLD_BPS = 1_000; + uint256 public constant OUTFLOW_WINDOW_DURATION = 24 hours; + + constructor(address controller_, address borrowedToken_, uint256 availableBalanceTolerance_) + LlamaLendControllerProtocolHelpers(controller_, borrowedToken_, availableBalanceTolerance_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers checks over controller token custody, post-borrow debt caps, + /// and 10% borrowed-token inflow/outflow caps over rolling 6 hour / 24 hour windows. + function triggers() external view override { + watchCumulativeInflow( + borrowedToken, INFLOW_THRESHOLD_BPS, INFLOW_WINDOW_DURATION, this.assertCumulativeInflow.selector + ); + watchCumulativeOutflow( + borrowedToken, OUTFLOW_THRESHOLD_BPS, OUTFLOW_WINDOW_DURATION, this.assertCumulativeOutflow.selector + ); + registerTxEndTrigger(this.assertControllerCustodyCoversAvailableBalance.selector); + _registerLlamaLendDebtIncreasingTriggers(this.assertDebtIncreaseWithinBorrowCap.selector); + } + + /// @notice Hard circuit breaker that blocks transactions while cumulative inflow stays above threshold. + function assertCumulativeInflow() external pure { + revert("LlamaLend: cumulative inflow breaker tripped"); + } + + /// @notice Hard circuit breaker that blocks transactions while cumulative outflow stays above threshold. + function assertCumulativeOutflow() external pure { + revert("LlamaLend: cumulative outflow breaker tripped"); + } + + /// @notice Checks controller token custody covers `available_balance()`. + function assertControllerCustodyCoversAvailableBalance() external { + PhEvm.ForkId memory fork = _postTx(); + require( + _llamaControllerBorrowedBalanceAt(fork) + availableBalanceTolerance + >= _llamaControllerAvailableBalanceAt(fork), + "LlamaLend: borrowed custody below available balance" + ); + } + + /// @notice Checks debt-increasing actions leave `total_debt()` at or below `borrow_cap()`. + function assertDebtIncreaseWithinBorrowCap() external { + PhEvm.TriggerContext memory ctx = ph.context(); + uint256 preTotalDebt = _llamaControllerTotalDebtAt(_preCall(ctx.callStart)); + uint256 postTotalDebt = _llamaControllerTotalDebtAt(_postCall(ctx.callEnd)); + + if (postTotalDebt <= preTotalDebt) { + return; + } + + require(postTotalDebt <= _llamaControllerBorrowCapAt(_postCall(ctx.callEnd)), "LlamaLend: borrow cap exceeded"); + } +} diff --git a/examples/curve/src/LlamaLendProtocol.sol b/examples/curve/src/LlamaLendProtocol.sol new file mode 100644 index 0000000..d6300ef --- /dev/null +++ b/examples/curve/src/LlamaLendProtocol.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {ERC4626AssetFlowAssertion} from "credible-std/protection/vault/ERC4626AssetFlowAssertion.sol"; + +interface ILlamaLendVault { + function controller() external view returns (address); +} + +interface ILlamaLendController { + function borrowed_token() external view returns (address); + function available_balance() external view returns (uint256); + function total_debt() external view returns (uint256); + function admin_fees() external view returns (uint256); + function borrow_cap() external view returns (uint256); +} + +library LlamaLendControllerSelectors { + bytes4 internal constant CREATE_LOAN = bytes4(keccak256("create_loan(uint256,uint256,uint256)")); + bytes4 internal constant CREATE_LOAN_FOR = bytes4(keccak256("create_loan(uint256,uint256,uint256,address)")); + bytes4 internal constant CREATE_LOAN_CALLBACK = + bytes4(keccak256("create_loan(uint256,uint256,uint256,address,address)")); + bytes4 internal constant CREATE_LOAN_CALLBACK_DATA = + bytes4(keccak256("create_loan(uint256,uint256,uint256,address,address,bytes)")); + + bytes4 internal constant BORROW_MORE = bytes4(keccak256("borrow_more(uint256,uint256)")); + bytes4 internal constant BORROW_MORE_FOR = bytes4(keccak256("borrow_more(uint256,uint256,address)")); + bytes4 internal constant BORROW_MORE_CALLBACK = bytes4(keccak256("borrow_more(uint256,uint256,address,address)")); + bytes4 internal constant BORROW_MORE_CALLBACK_DATA = + bytes4(keccak256("borrow_more(uint256,uint256,address,address,bytes)")); +} + +abstract contract LlamaLendVaultProtocolHelpers is ERC4626AssetFlowAssertion { + address internal immutable controller; + + /// @dev `controller_` is passed explicitly so the constructor never reads `vault_.controller()`. + /// The Credible Layer's assertion-deploy runtime is isolated from the adopter; a live + /// protocol read during construction would revert with EXTCODESIZE = 0. + constructor(address controller_) { + controller = controller_; + } + + function _netAssetFlow() internal view virtual override returns (int256 netFlow) { + PhEvm.Erc20TransferData[] memory deltas = _reducedErc20BalanceDeltasAt(asset, _postTx()); + + for (uint256 i; i < deltas.length; ++i) { + if (deltas[i].to == controller) { + netFlow += int256(deltas[i].value); + } + if (deltas[i].from == controller) { + netFlow -= int256(deltas[i].value); + } + } + } + + function _llamaAvailableBalanceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ILlamaLendController.available_balance, ()), fork); + } + + function _llamaTotalDebtAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ILlamaLendController.total_debt, ()), fork); + } + + function _llamaAdminFeesAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ILlamaLendController.admin_fees, ()), fork); + } + + function _llamaExpectedTotalAssetsAt(PhEvm.ForkId memory fork) internal view returns (uint256 expectedAssets) { + uint256 grossAssets = _llamaAvailableBalanceAt(fork) + _llamaTotalDebtAt(fork); + uint256 adminFees = _llamaAdminFeesAt(fork); + require(grossAssets >= adminFees, "LlamaLend: admin fees exceed assets"); + expectedAssets = grossAssets - adminFees; + } + + function _llamaControllerAssetBalanceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _assetBalanceAt(controller, fork); + } +} + +abstract contract LlamaLendControllerProtocolHelpers is Assertion { + address internal immutable controller; + address internal immutable borrowedToken; + uint256 internal immutable availableBalanceTolerance; + + /// @dev `borrowedToken_` is passed explicitly so the constructor never reads + /// `controller_.borrowed_token()`. The Credible Layer's assertion-deploy runtime is + /// isolated from the adopter; a live protocol read during construction would revert + /// with EXTCODESIZE = 0. + constructor(address controller_, address borrowedToken_, uint256 availableBalanceTolerance_) { + controller = controller_; + borrowedToken = borrowedToken_; + availableBalanceTolerance = availableBalanceTolerance_; + } + + function _registerLlamaLendDebtIncreasingTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.CREATE_LOAN); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.CREATE_LOAN_FOR); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.CREATE_LOAN_CALLBACK); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.CREATE_LOAN_CALLBACK_DATA); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.BORROW_MORE); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.BORROW_MORE_FOR); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.BORROW_MORE_CALLBACK); + registerFnCallTrigger(assertionSelector, LlamaLendControllerSelectors.BORROW_MORE_CALLBACK_DATA); + } + + function _llamaControllerAvailableBalanceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ILlamaLendController.available_balance, ()), fork); + } + + function _llamaControllerTotalDebtAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ILlamaLendController.total_debt, ()), fork); + } + + function _llamaControllerBorrowCapAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(controller, abi.encodeCall(ILlamaLendController.borrow_cap, ()), fork); + } + + function _llamaControllerBorrowedBalanceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readBalanceAt(borrowedToken, controller, fork); + } +} diff --git a/examples/curve/src/LlamaLendVaultAssertion.sol b/examples/curve/src/LlamaLendVaultAssertion.sol new file mode 100644 index 0000000..2f3ed45 --- /dev/null +++ b/examples/curve/src/LlamaLendVaultAssertion.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {ERC4626BaseAssertion} from "credible-std/protection/vault/ERC4626BaseAssertion.sol"; +import {ERC4626PreviewAssertion} from "credible-std/protection/vault/ERC4626PreviewAssertion.sol"; +import {ERC4626SharePriceAssertion} from "credible-std/protection/vault/ERC4626SharePriceAssertion.sol"; +import {LlamaLendVaultProtocolHelpers} from "./LlamaLendProtocol.sol"; + +/// @title LlamaLendVaultAssertion +/// @notice Example LlamaLend vault checks for controller-backed accounting and borrowed-token custody. +contract LlamaLendVaultAssertion is ERC4626SharePriceAssertion, ERC4626PreviewAssertion, LlamaLendVaultProtocolHelpers { + constructor(address vault_, address asset_, address controller_, uint256 sharePriceToleranceBps_) + ERC4626BaseAssertion(vault_, asset_) + ERC4626SharePriceAssertion(sharePriceToleranceBps_) + LlamaLendVaultProtocolHelpers(controller_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers ERC4626-style checks plus controller-side accounting and custody checks. + function triggers() external view override { + _registerSharePriceTriggers(); + _registerPreviewTriggers(); + _registerAssetFlowTriggers(); + registerTxEndTrigger(this.assertTotalAssetsMatchesControllerAccounting.selector); + registerTxEndTrigger(this.assertControllerCustodyCoversAvailableBalance.selector); + } + + /// @notice Checks `totalAssets()` equals controller available balance plus debt minus admin fees. + function assertTotalAssetsMatchesControllerAccounting() external { + PhEvm.ForkId memory fork = _postTx(); + require(_totalAssetsAt(fork) == _llamaExpectedTotalAssetsAt(fork), "LlamaLend: totalAssets mismatch"); + } + + /// @notice Checks borrowed-token custody at the controller covers `available_balance()`. + function assertControllerCustodyCoversAvailableBalance() external { + PhEvm.ForkId memory fork = _postTx(); + require( + _llamaControllerAssetBalanceAt(fork) >= _llamaAvailableBalanceAt(fork), + "LlamaLend: controller custody below available balance" + ); + } +} diff --git a/examples/curve/src/StableSwapNGPoolAssertion.sol b/examples/curve/src/StableSwapNGPoolAssertion.sol new file mode 100644 index 0000000..faf1e68 --- /dev/null +++ b/examples/curve/src/StableSwapNGPoolAssertion.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {StableSwapNGProtocolHelpers} from "./StableSwapNGProtocol.sol"; + +/// @title StableSwapNGPoolAssertion +/// @notice Example StableSwap NG checks for custody, fees, oracles, metapool accounting, and virtual price. +contract StableSwapNGPoolAssertion is StableSwapNGProtocolHelpers { + constructor(address pool_, uint256 maxCoinsToScan_, uint256 dustTolerance_, uint256 virtualPriceTolerance_) + StableSwapNGProtocolHelpers(pool_, maxCoinsToScan_, dustTolerance_, virtualPriceTolerance_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers checks over pool custody, admin fees, fee caps, oracle bounds, metapool rates, and virtual price. + function triggers() external view override { + registerTxEndTrigger(this.assertPoolCustodyCoversAccounting.selector); + registerTxEndTrigger(this.assertAdminBalancesCovered.selector); + registerTxEndTrigger(this.assertFeeBounds.selector); + registerTxEndTrigger(this.assertOracleBounds.selector); + registerTxEndTrigger(this.assertMetapoolBaseLpAccounting.selector); + _registerStableSwapNGVirtualPriceTriggers(this.assertVirtualPriceNonDecreasing.selector); + } + + /// @notice Compares each coin balance with `balances(i) + admin_balances(i)`. + function assertPoolCustodyCoversAccounting() external { + PhEvm.ForkId memory fork = _postTx(); + uint256 coinCount = _stableSwapCoinCountAt(fork); + + for (uint256 i; i < coinCount; ++i) { + StableSwapNGCoinAccounting memory accounting = _stableSwapCoinAccountingAt(i, fork); + require(accounting.actual + dustTolerance >= accounting.accounted, "StableSwapNG: token custody shortfall"); + } + } + + /// @notice Checks each `admin_balances(i)` stays within actual coin custody. + function assertAdminBalancesCovered() external { + PhEvm.ForkId memory fork = _postTx(); + uint256 coinCount = _stableSwapCoinCountAt(fork); + + for (uint256 i; i < coinCount; ++i) { + StableSwapNGCoinAccounting memory accounting = _stableSwapCoinAccountingAt(i, fork); + require( + accounting.adminBalance <= accounting.actual + dustTolerance, + "StableSwapNG: admin balance exceeds actual" + ); + } + } + + /// @notice Checks `fee`, `offpeg_fee_multiplier`, and `dynamic_fee(i, j)` stay within NG caps. + function assertFeeBounds() external { + PhEvm.ForkId memory fork = _postTx(); + StableSwapNGFeeState memory feeState = _stableSwapFeeStateAt(fork); + uint256 coinCount = _stableSwapCoinCountAt(fork); + + require(_stableSwapFeeCapHolds(feeState.fee, feeState.offpegFeeMultiplier), "StableSwapNG: fee cap broken"); + + for (uint256 i; i < coinCount; ++i) { + for (uint256 j; j < coinCount; ++j) { + if (i == j) { + continue; + } + + uint256 dynamicFee = _stableSwapDynamicFeeAt(i, j, fork); + + require(dynamicFee >= feeState.fee, "StableSwapNG: dynamic fee below base fee"); + require(dynamicFee <= MAX_FEE, "StableSwapNG: dynamic fee above max"); + } + } + } + + /// @notice Checks `last_price`, `ema_price`, and `D_oracle` stay initialized and capped. + function assertOracleBounds() external { + PhEvm.ForkId memory fork = _postTx(); + uint256 coinCount = _stableSwapCoinCountAt(fork); + uint256 totalSupply = _stableSwapTotalSupplyAt(fork); + + for (uint256 i; i + 1 < coinCount; ++i) { + StableSwapNGOracleState memory oracleState = _stableSwapOracleStateAt(i, fork); + + require(oracleState.lastPrice <= ORACLE_PRICE_CAP, "StableSwapNG: last price cap broken"); + if (totalSupply > 0) { + require(oracleState.emaPrice > 0, "StableSwapNG: zero EMA price"); + } + } + + if (totalSupply > 0) { + uint256 dOracle = _stableSwapDOracleAt(fork); + require(dOracle > 0, "StableSwapNG: zero D oracle"); + } + } + + /// @notice Checks base-LP custody covers slot-1 accounting and the stored base rate tracks base-pool virtual price. + function assertMetapoolBaseLpAccounting() external { + PhEvm.ForkId memory fork = _postTx(); + (bool hasBasePool, address basePool) = _stableSwapBasePoolAt(fork); + if (!hasBasePool) { + return; + } + + StableSwapNGCoinAccounting memory baseLpAccounting = _stableSwapCoinAccountingAt(1, fork); + require( + baseLpAccounting.actual + dustTolerance >= baseLpAccounting.accounted, + "StableSwapNG: base LP custody shortfall" + ); + + uint256[] memory rates = _stableSwapStoredRatesAt(fork); + require(rates.length > 1, "StableSwapNG: missing base LP rate"); + + uint256 baseVirtualPrice = _stableSwapBasePoolVirtualPriceAt(basePool, fork); + require(rates[1] == baseVirtualPrice, "StableSwapNG: base LP rate mismatch"); + } + + /// @notice Checks guarded user actions do not lower `get_virtual_price()` from pre-call to post-call. + function assertVirtualPriceNonDecreasing() external { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + if (!_stableSwapCanCheckVirtualPrice(beforeFork, afterFork)) { + return; + } + + uint256 preVirtualPrice = _stableSwapVirtualPriceAt(beforeFork); + uint256 postVirtualPrice = _stableSwapVirtualPriceAt(afterFork); + + require(postVirtualPrice + virtualPriceTolerance >= preVirtualPrice, "StableSwapNG: virtual price decreased"); + } +} diff --git a/examples/curve/src/StableSwapNGProtocol.sol b/examples/curve/src/StableSwapNGProtocol.sol new file mode 100644 index 0000000..ac2313c --- /dev/null +++ b/examples/curve/src/StableSwapNGProtocol.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +interface IStableSwapNGPool { + function N_COINS() external view returns (uint256); + function coins(uint256 i) external view returns (address); + function balances(uint256 i) external view returns (uint256); + function admin_balances(uint256 i) external view returns (uint256); + function fee() external view returns (uint256); + function offpeg_fee_multiplier() external view returns (uint256); + function dynamic_fee(int128 i, int128 j) external view returns (uint256); + function totalSupply() external view returns (uint256); + function get_virtual_price() external view returns (uint256); + function stored_rates() external view returns (uint256[] memory); + function A_precise() external view returns (uint256); + function last_price(uint256 i) external view returns (uint256); + function ema_price(uint256 i) external view returns (uint256); + function D_oracle() external view returns (uint256); +} + +interface IStableSwapNGMetaPool { + function BASE_POOL() external view returns (address); +} + +library StableSwapNGSelectors { + bytes4 internal constant EXCHANGE = bytes4(keccak256("exchange(int128,int128,uint256,uint256)")); + bytes4 internal constant EXCHANGE_RECEIVER = bytes4(keccak256("exchange(int128,int128,uint256,uint256,address)")); + bytes4 internal constant EXCHANGE_RECEIVED = bytes4(keccak256("exchange_received(int128,int128,uint256,uint256)")); + bytes4 internal constant EXCHANGE_RECEIVED_RECEIVER = + bytes4(keccak256("exchange_received(int128,int128,uint256,uint256,address)")); + bytes4 internal constant EXCHANGE_UNDERLYING = + bytes4(keccak256("exchange_underlying(int128,int128,uint256,uint256)")); + bytes4 internal constant EXCHANGE_UNDERLYING_RECEIVER = + bytes4(keccak256("exchange_underlying(int128,int128,uint256,uint256,address)")); + + bytes4 internal constant ADD_LIQUIDITY_DYNAMIC = bytes4(keccak256("add_liquidity(uint256[],uint256)")); + bytes4 internal constant ADD_LIQUIDITY_DYNAMIC_RECEIVER = + bytes4(keccak256("add_liquidity(uint256[],uint256,address)")); + bytes4 internal constant ADD_LIQUIDITY_META = bytes4(keccak256("add_liquidity(uint256[2],uint256)")); + bytes4 internal constant ADD_LIQUIDITY_META_RECEIVER = + bytes4(keccak256("add_liquidity(uint256[2],uint256,address)")); + + bytes4 internal constant REMOVE_LIQUIDITY_DYNAMIC = bytes4(keccak256("remove_liquidity(uint256,uint256[])")); + bytes4 internal constant REMOVE_LIQUIDITY_DYNAMIC_RECEIVER = + bytes4(keccak256("remove_liquidity(uint256,uint256[],address)")); + bytes4 internal constant REMOVE_LIQUIDITY_DYNAMIC_RECEIVER_CLAIM = + bytes4(keccak256("remove_liquidity(uint256,uint256[],address,bool)")); + bytes4 internal constant REMOVE_LIQUIDITY_META = bytes4(keccak256("remove_liquidity(uint256,uint256[2])")); + bytes4 internal constant REMOVE_LIQUIDITY_META_RECEIVER = + bytes4(keccak256("remove_liquidity(uint256,uint256[2],address)")); + bytes4 internal constant REMOVE_LIQUIDITY_META_RECEIVER_CLAIM = + bytes4(keccak256("remove_liquidity(uint256,uint256[2],address,bool)")); + + bytes4 internal constant REMOVE_ONE = bytes4(keccak256("remove_liquidity_one_coin(uint256,int128,uint256)")); + bytes4 internal constant REMOVE_ONE_RECEIVER = + bytes4(keccak256("remove_liquidity_one_coin(uint256,int128,uint256,address)")); + + bytes4 internal constant REMOVE_IMBALANCE_DYNAMIC = + bytes4(keccak256("remove_liquidity_imbalance(uint256[],uint256)")); + bytes4 internal constant REMOVE_IMBALANCE_DYNAMIC_RECEIVER = + bytes4(keccak256("remove_liquidity_imbalance(uint256[],uint256,address)")); + bytes4 internal constant REMOVE_IMBALANCE_META = + bytes4(keccak256("remove_liquidity_imbalance(uint256[2],uint256)")); + bytes4 internal constant REMOVE_IMBALANCE_META_RECEIVER = + bytes4(keccak256("remove_liquidity_imbalance(uint256[2],uint256,address)")); +} + +abstract contract StableSwapNGProtocolHelpers is Assertion { + uint256 internal constant MAX_FEE = 5e9; + uint256 internal constant FEE_DENOMINATOR = 1e10; + uint256 internal constant ORACLE_PRICE_CAP = 2e18; + + address internal immutable pool; + uint256 internal immutable maxCoinsToScan; + uint256 internal immutable dustTolerance; + uint256 internal immutable virtualPriceTolerance; + + struct StableSwapNGCoinAccounting { + address coin; + uint256 lpBalance; + uint256 adminBalance; + uint256 accounted; + uint256 actual; + } + + struct StableSwapNGFeeState { + uint256 fee; + uint256 offpegFeeMultiplier; + } + + struct StableSwapNGOracleState { + uint256 lastPrice; + uint256 emaPrice; + } + + constructor(address pool_, uint256 maxCoinsToScan_, uint256 dustTolerance_, uint256 virtualPriceTolerance_) { + pool = pool_; + maxCoinsToScan = maxCoinsToScan_; + dustTolerance = dustTolerance_; + virtualPriceTolerance = virtualPriceTolerance_; + } + + function _registerStableSwapNGVirtualPriceTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.EXCHANGE); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.EXCHANGE_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.EXCHANGE_RECEIVED); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.EXCHANGE_RECEIVED_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.EXCHANGE_UNDERLYING); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.EXCHANGE_UNDERLYING_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.ADD_LIQUIDITY_DYNAMIC); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.ADD_LIQUIDITY_DYNAMIC_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.ADD_LIQUIDITY_META); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.ADD_LIQUIDITY_META_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_LIQUIDITY_DYNAMIC); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_LIQUIDITY_DYNAMIC_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_LIQUIDITY_DYNAMIC_RECEIVER_CLAIM); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_LIQUIDITY_META); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_LIQUIDITY_META_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_LIQUIDITY_META_RECEIVER_CLAIM); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_ONE); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_ONE_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_IMBALANCE_DYNAMIC); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_IMBALANCE_DYNAMIC_RECEIVER); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_IMBALANCE_META); + registerFnCallTrigger(assertionSelector, StableSwapNGSelectors.REMOVE_IMBALANCE_META_RECEIVER); + } + + function _stableSwapCoinCountAt(PhEvm.ForkId memory fork) internal view returns (uint256 coinCount) { + coinCount = _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.N_COINS, ()), fork); + require(coinCount <= maxCoinsToScan, "StableSwapNG: too many coins"); + } + + function _stableSwapCoinAt(uint256 i, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(pool, abi.encodeCall(IStableSwapNGPool.coins, (i)), fork); + } + + function _stableSwapCoinAccountingAt(uint256 i, PhEvm.ForkId memory fork) + internal + view + returns (StableSwapNGCoinAccounting memory accounting) + { + accounting.coin = _stableSwapCoinAt(i, fork); + accounting.lpBalance = _stableSwapBalanceAt(i, fork); + accounting.adminBalance = _stableSwapAdminBalanceAt(i, fork); + accounting.accounted = accounting.lpBalance + accounting.adminBalance; + accounting.actual = _readBalanceAt(accounting.coin, pool, fork); + } + + function _stableSwapBalanceAt(uint256 i, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.balances, (i)), fork); + } + + function _stableSwapAdminBalanceAt(uint256 i, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.admin_balances, (i)), fork); + } + + function _stableSwapTotalSupplyAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.totalSupply, ()), fork); + } + + function _stableSwapVirtualPriceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.get_virtual_price, ()), fork); + } + + function _stableSwapFeeStateAt(PhEvm.ForkId memory fork) + internal + view + returns (StableSwapNGFeeState memory feeState) + { + feeState.fee = _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.fee, ()), fork); + feeState.offpegFeeMultiplier = + _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.offpeg_fee_multiplier, ()), fork); + } + + function _stableSwapDynamicFeeAt(uint256 i, uint256 j, PhEvm.ForkId memory fork) internal view returns (uint256) { + return + _readUintAt( + pool, abi.encodeCall(IStableSwapNGPool.dynamic_fee, (int128(int256(i)), int128(int256(j)))), fork + ); + } + + function _stableSwapOracleStateAt(uint256 i, PhEvm.ForkId memory fork) + internal + view + returns (StableSwapNGOracleState memory oracleState) + { + oracleState.lastPrice = _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.last_price, (i)), fork); + oracleState.emaPrice = _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.ema_price, (i)), fork); + } + + function _stableSwapDOracleAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.D_oracle, ()), fork); + } + + function _stableSwapRatesHashAt(PhEvm.ForkId memory fork) internal view returns (bytes32) { + uint256[] memory rates = + abi.decode(_viewAt(pool, abi.encodeCall(IStableSwapNGPool.stored_rates, ()), fork), (uint256[])); + return keccak256(abi.encode(rates)); + } + + function _stableSwapAPreciseAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(IStableSwapNGPool.A_precise, ()), fork); + } + + function _stableSwapBasePoolAt(PhEvm.ForkId memory fork) internal view returns (bool ok, address basePool) { + PhEvm.StaticCallResult memory result = + ph.staticcallAt(pool, abi.encodeCall(IStableSwapNGMetaPool.BASE_POOL, ()), FORK_VIEW_GAS, fork); + if (result.ok && result.data.length >= 32) { + basePool = abi.decode(result.data, (address)); + ok = basePool != address(0); + } + } + + function _stableSwapStoredRatesAt(PhEvm.ForkId memory fork) internal view returns (uint256[] memory rates) { + rates = abi.decode(_viewAt(pool, abi.encodeCall(IStableSwapNGPool.stored_rates, ()), fork), (uint256[])); + } + + function _stableSwapBasePoolVirtualPriceAt(address basePool, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(basePool, abi.encodeCall(IStableSwapNGPool.get_virtual_price, ()), fork); + } + + function _stableSwapCanCheckVirtualPrice(PhEvm.ForkId memory beforeFork, PhEvm.ForkId memory afterFork) + internal + view + returns (bool) + { + if (_stableSwapTotalSupplyAt(beforeFork) == 0 || _stableSwapTotalSupplyAt(afterFork) == 0) { + return false; + } + if (_stableSwapRatesHashAt(beforeFork) != _stableSwapRatesHashAt(afterFork)) { + return false; + } + return _stableSwapAPreciseAt(beforeFork) == _stableSwapAPreciseAt(afterFork); + } + + function _stableSwapFeeCapHolds(uint256 fee, uint256 multiplier) internal pure returns (bool) { + if (fee > MAX_FEE) { + return false; + } + if (fee == 0) { + return true; + } + return multiplier <= (MAX_FEE * FEE_DENOMINATOR) / fee; + } +} diff --git a/examples/curve/src/TriCryptoNGPoolAssertion.sol b/examples/curve/src/TriCryptoNGPoolAssertion.sol new file mode 100644 index 0000000..e6a6959 --- /dev/null +++ b/examples/curve/src/TriCryptoNGPoolAssertion.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {TriCryptoNGProtocolHelpers} from "./TriCryptoNGProtocol.sol"; + +/// @title TriCryptoNGPoolAssertion +/// @notice Example TriCrypto NG checks for custody, fees, oracle initialization, profit counters, and virtual price. +contract TriCryptoNGPoolAssertion is TriCryptoNGProtocolHelpers { + constructor( + address pool_, + address wrappedNativeToken_, + uint256 dustTolerance_, + uint256 virtualPriceToleranceBps_, + uint256 profitTolerance_ + ) + TriCryptoNGProtocolHelpers( + pool_, wrappedNativeToken_, dustTolerance_, virtualPriceToleranceBps_, profitTolerance_ + ) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers checks over ERC20 custody, fee params, oracle state, profit counters, and virtual price. + function triggers() external view override { + registerTxEndTrigger(this.assertPoolCustodyCoversBalances.selector); + registerTxEndTrigger(this.assertFeeBounds.selector); + registerTxEndTrigger(this.assertOracleValuesInitialized.selector); + registerTxEndTrigger(this.assertProfitAndVirtualPriceBounds.selector); + _registerTriCryptoVirtualPriceTriggers(this.assertVirtualPriceNonDecreasing.selector); + } + + /// @notice Compares each non-native coin balance with the pool's internal `balances(i)`. + function assertPoolCustodyCoversBalances() external { + PhEvm.ForkId memory fork = _postTx(); + + for (uint256 i; i < N_COINS; ++i) { + TriCryptoNGCoinAccounting memory accounting = _triCryptoCoinAccountingAt(i, fork); + if (!accounting.shouldCheckCustody) { + continue; + } + + require(accounting.actual + dustTolerance >= accounting.accounted, "TriCryptoNG: token custody shortfall"); + } + } + + /// @notice Checks live fee parameters stay within the pool's configured TriCrypto bounds. + function assertFeeBounds() external { + PhEvm.ForkId memory fork = _postTx(); + TriCryptoNGFeeState memory feeState = _triCryptoFeeStateAt(fork); + + require(feeState.midFee >= MIN_FEE, "TriCryptoNG: mid fee too low"); + require(feeState.midFee <= feeState.outFee, "TriCryptoNG: mid fee above out fee"); + require(feeState.outFee <= MAX_FEE, "TriCryptoNG: out fee too high"); + require(feeState.fee >= feeState.midFee, "TriCryptoNG: fee below mid fee"); + require(feeState.fee <= feeState.outFee, "TriCryptoNG: fee above out fee"); + require(feeState.feeGamma > 0 && feeState.feeGamma <= WAD, "TriCryptoNG: fee gamma out of bounds"); + } + + /// @notice Checks `price_scale`, `price_oracle`, and `last_prices` stay initialized. + function assertOracleValuesInitialized() external { + PhEvm.ForkId memory fork = _postTx(); + uint256 totalSupply = _triCryptoTotalSupplyAt(fork); + + for (uint256 k; k < N_PRICE_PAIRS; ++k) { + TriCryptoNGOracleState memory oracleState = _triCryptoOracleStateAt(k, fork); + + require(oracleState.priceScale > 0, "TriCryptoNG: zero price scale"); + if (totalSupply > 0) { + require(oracleState.priceOracle > 0, "TriCryptoNG: zero price oracle"); + require(oracleState.lastPrice > 0, "TriCryptoNG: zero last price"); + } + } + } + + /// @notice Checks profit counters stay ordered and cached and live virtual prices stay initialized. + function assertProfitAndVirtualPriceBounds() external { + PhEvm.ForkId memory fork = _postTx(); + TriCryptoNGProfitState memory profitState = _triCryptoProfitStateAt(fork); + if (profitState.totalSupply == 0) { + return; + } + + require( + _gteWithAbsoluteTolerance(profitState.cachedVirtualPrice, WAD, profitTolerance), + "TriCryptoNG: cached VP below 1" + ); + require( + _gteWithAbsoluteTolerance(profitState.liveVirtualPrice, WAD, profitTolerance), + "TriCryptoNG: live VP below 1" + ); + require( + _gteWithAbsoluteTolerance(profitState.xcpProfit, WAD, profitTolerance), "TriCryptoNG: xcp profit below 1" + ); + require( + _gteWithAbsoluteTolerance(profitState.xcpProfitA, WAD, profitTolerance), "TriCryptoNG: xcp profit_a below 1" + ); + require( + _withinBps(profitState.cachedVirtualPrice, profitState.liveVirtualPrice, virtualPriceToleranceBps), + "TriCryptoNG: cached/live VP mismatch" + ); + + if (profitState.cachedVirtualPrice >= WAD && profitState.xcpProfit >= WAD) { + uint256 virtualProfit = profitState.cachedVirtualPrice - WAD; + uint256 xcpExcess = profitState.xcpProfit - WAD; + require(virtualProfit * 2 + profitTolerance >= xcpExcess, "TriCryptoNG: virtual profit below xcp"); + } + } + + /// @notice Checks guarded user actions do not lower live virtual price from pre-call to post-call. + function assertVirtualPriceNonDecreasing() external { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + if (!_triCryptoCanCheckVirtualPrice(beforeFork, afterFork)) { + return; + } + + uint256 preVirtualPrice = _triCryptoLiveVirtualPriceAt(beforeFork); + uint256 postVirtualPrice = _triCryptoLiveVirtualPriceAt(afterFork); + + require(postVirtualPrice + profitTolerance >= preVirtualPrice, "TriCryptoNG: virtual price decreased"); + } +} diff --git a/examples/curve/src/TriCryptoNGProtocol.sol b/examples/curve/src/TriCryptoNGProtocol.sol new file mode 100644 index 0000000..cb9e356 --- /dev/null +++ b/examples/curve/src/TriCryptoNGProtocol.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +interface ITriCryptoNGPool { + function coins(uint256 i) external view returns (address); + function balances(uint256 i) external view returns (uint256); + function totalSupply() external view returns (uint256); + function get_virtual_price() external view returns (uint256); + function virtual_price() external view returns (uint256); + function xcp_profit() external view returns (uint256); + function xcp_profit_a() external view returns (uint256); + function price_oracle(uint256 k) external view returns (uint256); + function last_prices(uint256 k) external view returns (uint256); + function price_scale(uint256 k) external view returns (uint256); + function fee() external view returns (uint256); + function mid_fee() external view returns (uint256); + function out_fee() external view returns (uint256); + function fee_gamma() external view returns (uint256); + function A() external view returns (uint256); + function gamma() external view returns (uint256); +} + +library TriCryptoNGSelectors { + bytes4 internal constant EXCHANGE = bytes4(keccak256("exchange(uint256,uint256,uint256,uint256)")); + bytes4 internal constant EXCHANGE_RECEIVER = bytes4(keccak256("exchange(uint256,uint256,uint256,uint256,address)")); + bytes4 internal constant WETH_EXCHANGE_USE_ETH = + bytes4(keccak256("exchange(uint256,uint256,uint256,uint256,bool)")); + bytes4 internal constant WETH_EXCHANGE_USE_ETH_RECEIVER = + bytes4(keccak256("exchange(uint256,uint256,uint256,uint256,bool,address)")); + bytes4 internal constant EXCHANGE_RECEIVED = + bytes4(keccak256("exchange_received(uint256,uint256,uint256,uint256)")); + bytes4 internal constant EXCHANGE_RECEIVED_RECEIVER = + bytes4(keccak256("exchange_received(uint256,uint256,uint256,uint256,address)")); + bytes4 internal constant EXCHANGE_UNDERLYING = + bytes4(keccak256("exchange_underlying(uint256,uint256,uint256,uint256)")); + bytes4 internal constant EXCHANGE_UNDERLYING_RECEIVER = + bytes4(keccak256("exchange_underlying(uint256,uint256,uint256,uint256,address)")); + bytes4 internal constant EXCHANGE_EXTENDED = + bytes4(keccak256("exchange_extended(uint256,uint256,uint256,uint256,bool,address,address,bytes32)")); + + bytes4 internal constant ADD_LIQUIDITY = bytes4(keccak256("add_liquidity(uint256[3],uint256)")); + bytes4 internal constant ADD_LIQUIDITY_RECEIVER = bytes4(keccak256("add_liquidity(uint256[3],uint256,address)")); + bytes4 internal constant WETH_ADD_LIQUIDITY_USE_ETH = bytes4(keccak256("add_liquidity(uint256[3],uint256,bool)")); + bytes4 internal constant WETH_ADD_LIQUIDITY_USE_ETH_RECEIVER = + bytes4(keccak256("add_liquidity(uint256[3],uint256,bool,address)")); + + bytes4 internal constant REMOVE_LIQUIDITY = bytes4(keccak256("remove_liquidity(uint256,uint256[3])")); + bytes4 internal constant REMOVE_LIQUIDITY_RECEIVER = + bytes4(keccak256("remove_liquidity(uint256,uint256[3],address)")); + bytes4 internal constant WETH_REMOVE_LIQUIDITY_USE_ETH = + bytes4(keccak256("remove_liquidity(uint256,uint256[3],bool)")); + bytes4 internal constant WETH_REMOVE_LIQUIDITY_USE_ETH_RECEIVER = + bytes4(keccak256("remove_liquidity(uint256,uint256[3],bool,address)")); + bytes4 internal constant WETH_REMOVE_LIQUIDITY_USE_ETH_RECEIVER_CLAIM = + bytes4(keccak256("remove_liquidity(uint256,uint256[3],bool,address,bool)")); + + bytes4 internal constant REMOVE_ONE = bytes4(keccak256("remove_liquidity_one_coin(uint256,uint256,uint256)")); + bytes4 internal constant REMOVE_ONE_RECEIVER = + bytes4(keccak256("remove_liquidity_one_coin(uint256,uint256,uint256,address)")); + bytes4 internal constant WETH_REMOVE_ONE_USE_ETH = + bytes4(keccak256("remove_liquidity_one_coin(uint256,uint256,uint256,bool)")); + bytes4 internal constant WETH_REMOVE_ONE_USE_ETH_RECEIVER = + bytes4(keccak256("remove_liquidity_one_coin(uint256,uint256,uint256,bool,address)")); +} + +abstract contract TriCryptoNGProtocolHelpers is Assertion { + uint256 internal constant N_COINS = 3; + uint256 internal constant N_PRICE_PAIRS = 2; + uint256 internal constant WAD = 1e18; + uint256 internal constant MIN_FEE = 5e5; + uint256 internal constant MAX_FEE = 1e10; + + address internal immutable pool; + address internal immutable wrappedNativeToken; + uint256 internal immutable dustTolerance; + uint256 internal immutable virtualPriceToleranceBps; + uint256 internal immutable profitTolerance; + + struct TriCryptoNGCoinAccounting { + address coin; + bool shouldCheckCustody; + uint256 accounted; + uint256 actual; + } + + struct TriCryptoNGFeeState { + uint256 fee; + uint256 midFee; + uint256 outFee; + uint256 feeGamma; + } + + struct TriCryptoNGOracleState { + uint256 priceScale; + uint256 priceOracle; + uint256 lastPrice; + } + + struct TriCryptoNGProfitState { + uint256 totalSupply; + uint256 cachedVirtualPrice; + uint256 liveVirtualPrice; + uint256 xcpProfit; + uint256 xcpProfitA; + } + + constructor( + address pool_, + address wrappedNativeToken_, + uint256 dustTolerance_, + uint256 virtualPriceToleranceBps_, + uint256 profitTolerance_ + ) { + pool = pool_; + wrappedNativeToken = wrappedNativeToken_; + dustTolerance = dustTolerance_; + virtualPriceToleranceBps = virtualPriceToleranceBps_; + profitTolerance = profitTolerance_; + } + + function _registerTriCryptoVirtualPriceTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_EXCHANGE_USE_ETH); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_EXCHANGE_USE_ETH_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE_RECEIVED); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE_RECEIVED_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE_UNDERLYING); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE_UNDERLYING_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.EXCHANGE_EXTENDED); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.ADD_LIQUIDITY); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.ADD_LIQUIDITY_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_ADD_LIQUIDITY_USE_ETH); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_ADD_LIQUIDITY_USE_ETH_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.REMOVE_LIQUIDITY); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.REMOVE_LIQUIDITY_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_REMOVE_LIQUIDITY_USE_ETH); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_REMOVE_LIQUIDITY_USE_ETH_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_REMOVE_LIQUIDITY_USE_ETH_RECEIVER_CLAIM); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.REMOVE_ONE); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.REMOVE_ONE_RECEIVER); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_REMOVE_ONE_USE_ETH); + registerFnCallTrigger(assertionSelector, TriCryptoNGSelectors.WETH_REMOVE_ONE_USE_ETH_RECEIVER); + } + + function _triCryptoCoinAt(uint256 i, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(pool, abi.encodeCall(ITriCryptoNGPool.coins, (i)), fork); + } + + function _triCryptoBalanceAt(uint256 i, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.balances, (i)), fork); + } + + function _triCryptoCoinAccountingAt(uint256 i, PhEvm.ForkId memory fork) + internal + view + returns (TriCryptoNGCoinAccounting memory accounting) + { + accounting.coin = _triCryptoCoinAt(i, fork); + accounting.shouldCheckCustody = accounting.coin != wrappedNativeToken || wrappedNativeToken == address(0); + accounting.accounted = _triCryptoBalanceAt(i, fork); + if (accounting.shouldCheckCustody) { + accounting.actual = _readBalanceAt(accounting.coin, pool, fork); + } + } + + function _triCryptoTotalSupplyAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.totalSupply, ()), fork); + } + + function _triCryptoLiveVirtualPriceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.get_virtual_price, ()), fork); + } + + function _triCryptoCachedVirtualPriceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.virtual_price, ()), fork); + } + + function _triCryptoXcpProfitAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.xcp_profit, ()), fork); + } + + function _triCryptoXcpProfitAAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.xcp_profit_a, ()), fork); + } + + function _triCryptoFeeStateAt(PhEvm.ForkId memory fork) + internal + view + returns (TriCryptoNGFeeState memory feeState) + { + feeState.fee = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.fee, ()), fork); + feeState.midFee = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.mid_fee, ()), fork); + feeState.outFee = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.out_fee, ()), fork); + feeState.feeGamma = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.fee_gamma, ()), fork); + } + + function _triCryptoOracleStateAt(uint256 k, PhEvm.ForkId memory fork) + internal + view + returns (TriCryptoNGOracleState memory oracleState) + { + oracleState.priceScale = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.price_scale, (k)), fork); + oracleState.priceOracle = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.price_oracle, (k)), fork); + oracleState.lastPrice = _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.last_prices, (k)), fork); + } + + function _triCryptoProfitStateAt(PhEvm.ForkId memory fork) + internal + view + returns (TriCryptoNGProfitState memory profitState) + { + profitState.totalSupply = _triCryptoTotalSupplyAt(fork); + profitState.cachedVirtualPrice = _triCryptoCachedVirtualPriceAt(fork); + profitState.liveVirtualPrice = _triCryptoLiveVirtualPriceAt(fork); + profitState.xcpProfit = _triCryptoXcpProfitAt(fork); + profitState.xcpProfitA = _triCryptoXcpProfitAAt(fork); + } + + function _triCryptoAAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.A, ()), fork); + } + + function _triCryptoGammaAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(pool, abi.encodeCall(ITriCryptoNGPool.gamma, ()), fork); + } + + function _triCryptoCanCheckVirtualPrice(PhEvm.ForkId memory beforeFork, PhEvm.ForkId memory afterFork) + internal + view + returns (bool) + { + if (_triCryptoTotalSupplyAt(beforeFork) == 0 || _triCryptoTotalSupplyAt(afterFork) == 0) { + return false; + } + if (_triCryptoXcpProfitAAt(beforeFork) != _triCryptoXcpProfitAAt(afterFork)) { + return false; + } + if (_triCryptoAAt(beforeFork) != _triCryptoAAt(afterFork)) { + return false; + } + return _triCryptoGammaAt(beforeFork) == _triCryptoGammaAt(afterFork); + } + + function _gteWithAbsoluteTolerance(uint256 value, uint256 floor, uint256 tolerance) internal pure returns (bool) { + return value + tolerance >= floor; + } + + function _withinBps(uint256 a, uint256 b, uint256 toleranceBps) internal view returns (bool) { + if (a == b) { + return true; + } + if (a == 0 || b == 0) { + return false; + } + + return ph.ratioGe(a, 1, b, 1, toleranceBps) && ph.ratioGe(b, 1, a, 1, toleranceBps); + } +} diff --git a/examples/denaria/README.md b/examples/denaria/README.md new file mode 100644 index 0000000..453b9d3 --- /dev/null +++ b/examples/denaria/README.md @@ -0,0 +1,15 @@ +# denaria examples + +Assertion examples and supporting helpers extracted from the `all-protection-suites` branch. + +## Build + +```sh +FOUNDRY_PROFILE=denaria forge build +``` + +## Files + +- DenariaHelpers.sol +- DenariaInterfaces.sol +- DenariaOperationSafety.sol diff --git a/examples/denaria/src/DenariaHelpers.sol b/examples/denaria/src/DenariaHelpers.sol new file mode 100644 index 0000000..bf723e5 --- /dev/null +++ b/examples/denaria/src/DenariaHelpers.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {PerpetualProtectionSuiteBase} from "credible-std/protection/perpetual/PerpetualBaseAssertion.sol"; +import {IDenariaPerpPairLike, IDenariaVaultLike} from "./DenariaInterfaces.sol"; + +/// @title DenariaHelpers +/// @author Phylax Systems +/// @notice Shared Denaria protocol reads, log decoders, and utility helpers for the example suite. +abstract contract DenariaHelpers is PerpetualProtectionSuiteBase { + struct DenariaAccountMetrics { + address account; + uint256 price; + uint256 collateral; + uint256 maintenanceThreshold; + uint256 maxLpLeverage; + uint256 balanceStable; + uint256 balanceAsset; + uint256 debtStable; + uint256 debtAsset; + uint256 storedFundingFee; + bool storedFundingFeeSign; + uint256 pendingFundingFee; + bool pendingFundingFeeSign; + int256 storedFundingContribution; + int256 pendingFundingContribution; + int256 totalFundingContribution; + uint256 lpStableBalance; + uint256 lpAssetBalance; + uint256 lpDebtStable; + uint256 lpDebtAsset; + uint256 openAssetExposure; + uint256 openNotional; + uint256 lpLeverageDebtValue; + int256 runtimePnl; + int256 runtimeEquity; + int256 markPnl; + int256 markEquity; + uint256 markMarginRatio; + bool lpLeverageHealthy; + } + + struct DenariaExecutedTradeLog { + address user; + bool direction; + uint256 tradeSize; + uint256 tradeReturn; + uint256 currentPrice; + uint256 leverage; + } + + struct DenariaLiquidationLog { + address user; + address liquidator; + uint256 fraction; + uint256 liquidationFee; + uint256 positionSize; + uint256 currentPrice; + int256 deltaPnl; + bool liquidationDirection; + } + + struct DenariaLiquidityMoveLog { + address user; + uint256 liquidityStable; + uint256 liquidityAsset; + uint256 stableShares; + uint256 assetShares; + uint256 feeValue; + bool added; + } + + bytes32 internal constant EXECUTED_TRADE_TOPIC0 = + keccak256("ExecutedTrade(address,bool,uint256,uint256,uint256,uint256)"); + bytes32 internal constant LIQUIDATED_USER_TOPIC0 = + keccak256("LiquidatedUser(address,address,uint256,uint256,uint256,uint256,int256,bool)"); + bytes32 internal constant LIQUIDITY_MOVED_TOPIC0 = + keccak256("LiquidityMoved(address,uint256,uint256,uint256,uint256,uint256,bool)"); + + uint256 internal constant ORACLE_PRICE_DECIMALS = 1e8; + uint256 internal constant MARGIN_RATIO_DECIMALS = 1e6; + + address internal immutable PERP_PAIR; + address internal immutable VAULT; + + constructor(address perpPair_, address vault_) { + PERP_PAIR = perpPair_; + VAULT = vault_; + } + + function _readAccountMetrics(address account, PhEvm.ForkId memory fork) + internal + view + returns (DenariaAccountMetrics memory metrics) + { + metrics.account = account; + metrics.price = _readPrice(fork); + metrics.collateral = _readCollateral(account, fork); + metrics.maintenanceThreshold = _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.MMR, ()), fork); + metrics.maxLpLeverage = _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.maxLpLeverage, ()), fork); + + ( + metrics.balanceStable, + metrics.balanceAsset, + metrics.debtStable, + metrics.debtAsset, + metrics.storedFundingFee, + metrics.storedFundingFeeSign,, + ) = + abi.decode( + _viewAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.userVirtualTraderPosition, (account)), fork), + (uint256, uint256, uint256, uint256, uint256, bool, uint256, bool) + ); + + (,, metrics.lpDebtStable, metrics.lpDebtAsset) = abi.decode( + _viewAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.liquidityPosition, (account)), fork), + (uint256, uint256, uint256, uint256) + ); + + (metrics.lpStableBalance, metrics.lpAssetBalance) = abi.decode( + _viewAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.getLpLiquidityBalance, (account)), fork), + (uint256, uint256) + ); + + (metrics.pendingFundingFee, metrics.pendingFundingFeeSign) = abi.decode( + _viewAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.computeFundingFee, (account)), fork), (uint256, bool) + ); + + (uint256 pnlMagnitude, bool pnlSign) = abi.decode( + _viewAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.calcPnL, (account, metrics.price)), fork), + (uint256, bool) + ); + + metrics.runtimePnl = _signedPnl(pnlMagnitude, pnlSign); + metrics.storedFundingContribution = + _signedFundingContribution(metrics.storedFundingFee, metrics.storedFundingFeeSign); + metrics.pendingFundingContribution = + _signedFundingContribution(metrics.pendingFundingFee, metrics.pendingFundingFeeSign); + metrics.totalFundingContribution = metrics.storedFundingContribution + metrics.pendingFundingContribution; + metrics.openAssetExposure = + _absoluteDifference(metrics.balanceAsset + metrics.lpAssetBalance, metrics.debtAsset + metrics.lpDebtAsset); + metrics.openNotional = ph.mulDivDown(metrics.openAssetExposure, metrics.price, ORACLE_PRICE_DECIMALS); + metrics.markPnl = _computeMarkPnl(metrics); + metrics.runtimeEquity = _toInt256(metrics.collateral) + metrics.runtimePnl; + metrics.markEquity = _toInt256(metrics.collateral) + metrics.markPnl; + metrics.markMarginRatio = _computeMarginRatio(metrics.markEquity, metrics.openNotional); + metrics.lpLeverageDebtValue = _computeLpLeverageDebtValue(metrics); + metrics.lpLeverageHealthy = _isLpLeverageHealthy(metrics); + } + + function _readPrice(PhEvm.ForkId memory fork) internal view returns (uint256 price) { + return _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.getPrice, ()), fork); + } + + function _readCollateral(address account, PhEvm.ForkId memory fork) internal view returns (uint256 collateral) { + return _readUintAt(VAULT, abi.encodeCall(IDenariaVaultLike.userCollateral, (account)), fork); + } + + function _readSignedInsuranceFund(PhEvm.ForkId memory fork) internal view returns (int256 signedInsuranceFund) { + uint256 amount = _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.insuranceFund, ()), fork); + bool sign = _readBoolAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.insuranceFundSign, ()), fork); + return sign ? _toInt256(amount) : -_toInt256(amount); + } + + function _getExecutedTrades(PhEvm.ForkId memory fork) + internal + view + returns (DenariaExecutedTradeLog[] memory trades) + { + PhEvm.Log[] memory logs = + ph.getLogsQuery(PhEvm.LogQuery({emitter: PERP_PAIR, signature: EXECUTED_TRADE_TOPIC0}), fork); + trades = new DenariaExecutedTradeLog[](logs.length); + + for (uint256 i; i < logs.length; ++i) { + trades[i].user = _topicAddress(logs[i].topics[1]); + ( + trades[i].direction, + trades[i].tradeSize, + trades[i].tradeReturn, + trades[i].currentPrice, + trades[i].leverage + ) = abi.decode(logs[i].data, (bool, uint256, uint256, uint256, uint256)); + } + } + + function _countTradesForAccount(DenariaExecutedTradeLog[] memory trades, address account) + internal + pure + returns (uint256 count) + { + for (uint256 i; i < trades.length; ++i) { + if (trades[i].user == account && trades[i].tradeSize != 0 && trades[i].tradeReturn != 0) { + ++count; + } + } + } + + function _getLastLiquidation(PhEvm.ForkId memory fork) + internal + view + returns (bool found, DenariaLiquidationLog memory liquidationLog) + { + PhEvm.Log[] memory logs = + ph.getLogsQuery(PhEvm.LogQuery({emitter: PERP_PAIR, signature: LIQUIDATED_USER_TOPIC0}), fork); + if (logs.length == 0) { + return (false, liquidationLog); + } + + PhEvm.Log memory log = logs[logs.length - 1]; + liquidationLog.user = _topicAddress(log.topics[1]); + ( + liquidationLog.liquidator, + liquidationLog.fraction, + liquidationLog.liquidationFee, + liquidationLog.positionSize, + liquidationLog.currentPrice, + liquidationLog.deltaPnl, + liquidationLog.liquidationDirection + ) = abi.decode(log.data, (address, uint256, uint256, uint256, uint256, int256, bool)); + return (true, liquidationLog); + } + + function _getLastLiquidityRemovalForAccount(address account, PhEvm.ForkId memory fork) + internal + view + returns (bool found, DenariaLiquidityMoveLog memory liquidityMove) + { + PhEvm.Log[] memory logs = + ph.getLogsQuery(PhEvm.LogQuery({emitter: PERP_PAIR, signature: LIQUIDITY_MOVED_TOPIC0}), fork); + + for (uint256 i = logs.length; i != 0; --i) { + PhEvm.Log memory log = logs[i - 1]; + liquidityMove.user = _topicAddress(log.topics[1]); + ( + liquidityMove.liquidityStable, + liquidityMove.liquidityAsset, + liquidityMove.stableShares, + liquidityMove.assetShares, + liquidityMove.feeValue, + liquidityMove.added + ) = abi.decode(log.data, (uint256, uint256, uint256, uint256, uint256, bool)); + + if (liquidityMove.user == account && !liquidityMove.added) { + return (true, liquidityMove); + } + } + } + + function _computeMarginRatio(int256 equity, uint256 openNotional) internal pure returns (uint256 marginRatio) { + if (equity < 0) { + return 0; + } + if (openNotional == 0) { + return MARGIN_RATIO_DECIMALS; + } + return _toUint256(equity) * MARGIN_RATIO_DECIMALS / openNotional; + } + + function _isLpLeverageHealthy(DenariaAccountMetrics memory metrics) internal pure returns (bool) { + if (metrics.lpStableBalance + metrics.lpAssetBalance == 0) { + return true; + } + + return metrics.collateral * metrics.maxLpLeverage >= metrics.lpLeverageDebtValue; + } + + function _hasTrackedState(DenariaAccountMetrics memory metrics) internal pure returns (bool) { + return metrics.balanceStable != 0 || metrics.balanceAsset != 0 || metrics.debtStable != 0 + || metrics.debtAsset != 0 || metrics.lpStableBalance != 0 || metrics.lpAssetBalance != 0 + || metrics.lpDebtStable != 0 || metrics.lpDebtAsset != 0; + } + + function _absoluteDifference(uint256 left, uint256 right) internal pure returns (uint256 difference) { + return left >= right ? left - right : right - left; + } + + function _signedPnl(uint256 magnitude, bool pnlSign) internal pure returns (int256 signedPnl) { + int256 signedMagnitude = _toInt256(magnitude); + return pnlSign ? signedMagnitude : -signedMagnitude; + } + + function _signedFundingContribution(uint256 magnitude, bool fundingFeeSign) + internal + pure + returns (int256 signedFunding) + { + int256 signedMagnitude = _toInt256(magnitude); + return fundingFeeSign ? -signedMagnitude : signedMagnitude; + } + + function _computeMarkPnl(DenariaAccountMetrics memory metrics) internal pure returns (int256 markPnl) { + int256 stableSide = _toInt256(metrics.balanceStable + metrics.lpStableBalance) + - _toInt256(metrics.debtStable + metrics.lpDebtStable); + int256 assetSide = _toInt256(metrics.balanceAsset + metrics.lpAssetBalance) + - _toInt256(metrics.debtAsset + metrics.lpDebtAsset); + + return stableSide + metrics.totalFundingContribution + _markAssetContribution(assetSide, metrics.price); + } + + function _computeLpLeverageDebtValue(DenariaAccountMetrics memory metrics) + internal + pure + returns (uint256 totalDebtValue) + { + if (metrics.lpStableBalance + metrics.lpAssetBalance == 0) { + return 0; + } + + uint256 traderNetStableDebt = + metrics.debtStable > metrics.balanceStable ? metrics.debtStable - metrics.balanceStable : 0; + uint256 traderNetAssetDebt = + metrics.debtAsset > metrics.balanceAsset ? metrics.debtAsset - metrics.balanceAsset : 0; + return traderNetStableDebt + metrics.lpDebtStable + + _mulDivDown(traderNetAssetDebt + metrics.lpDebtAsset, metrics.price, ORACLE_PRICE_DECIMALS); + } + + function _markAssetContribution(int256 assetDelta, uint256 price) internal pure returns (int256 contribution) { + if (assetDelta == 0) { + return 0; + } + + if (assetDelta > 0) { + return _toInt256(_toUint256(assetDelta) * price / ORACLE_PRICE_DECIMALS); + } + + return -_toInt256(_toUint256(-assetDelta) * price / ORACLE_PRICE_DECIMALS); + } + + function _lpLeverageCapacity(DenariaAccountMetrics memory metrics) internal pure returns (int256 capacity) { + return _toInt256(metrics.collateral * metrics.maxLpLeverage) - _toInt256(metrics.lpLeverageDebtValue); + } + + function _decreaseBetween(int256 beforeValue, int256 afterValue) internal pure returns (uint256 decrease) { + return afterValue < beforeValue ? _toUint256(beforeValue - afterValue) : 0; + } + + function _topicAddress(bytes32 topic) internal pure returns (address account) { + return address(uint160(uint256(topic))); + } + + function _toInt256(uint256 value) internal pure returns (int256 signedValue) { + if (value > uint256(type(int256).max)) { + return type(int256).max; + } + // forge-lint: disable-next-line(unsafe-typecast) + return int256(value); + } + + function _toUint256(int256 value) internal pure returns (uint256 unsignedValue) { + require(value >= 0, "negative int"); + // forge-lint: disable-next-line(unsafe-typecast) + return uint256(value); + } + + function _mulDivDown(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + return x * y / denominator; + } +} diff --git a/examples/denaria/src/DenariaInterfaces.sol b/examples/denaria/src/DenariaInterfaces.sol new file mode 100644 index 0000000..b90b30c --- /dev/null +++ b/examples/denaria/src/DenariaInterfaces.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Minimal Denaria perp-pair surface used by the example suite. +/// @dev Derived against `~/Documents/code/solidity/denaria-perp-main/`. +interface IDenariaPerpPairLike { + function trade( + bool direction, + uint256 size, + uint256 minTradeReturn, + uint256 initialGuess, + address frontendAddress, + uint8 leverage, + bytes memory unverifiedReport + ) external returns (uint256); + + function closeAndWithdraw( + uint256 maxSlippage, + uint256 maxLiqFee, + address frontendAddress, + bytes memory unverifiedReport + ) external; + + function addLiquidity( + uint256 liquidityStable, + uint256 liquidityAsset, + uint256 maxFeeValue, + bytes memory unverifiedReport + ) external; + + function removeLiquidity( + uint256 liquidityStableToRemove, + uint256 liquidityAssetToRemove, + uint256 maxFeeValue, + bytes memory unverifiedReport + ) external; + + function realizePnL(bytes calldata unverifiedReport) external returns (uint256, bool); + function liquidate(address user, uint256 liquidatedPositionSize, bytes memory unverifiedReport) external; + function getPrice() external view returns (uint256); + function calcPnL(address user, uint256 price) external view returns (uint256, bool); + function computeFundingFee(address user) external view returns (uint256, bool); + function getLpLiquidityBalance(address user) external view returns (uint256, uint256); + function MMR() external view returns (uint256); + function maxLpLeverage() external view returns (uint256); + function globalLiquidityStable() external view returns (uint256); + function globalLiquidityAsset() external view returns (uint256); + function insuranceFund() external view returns (uint256); + function insuranceFundSign() external view returns (bool); + + function userVirtualTraderPosition(address user) + external + view + returns ( + uint256 balanceStable, + uint256 balanceAsset, + uint256 debtStable, + uint256 debtAsset, + uint256 fundingFee, + bool fundingFeeSign, + uint256 initialFundingRate, + bool initialFundingRateSign + ); + + function liquidityPosition(address user) + external + view + returns (uint256 initialStableShares, uint256 initialAssetShares, uint256 debtStable, uint256 debtAsset); +} + +/// @notice Minimal Denaria vault surface used by the example suite. +interface IDenariaVaultLike { + function removeCollateral(uint256 amount, bytes memory unverifiedReport) external; + function removeAllCollateral(bytes memory unverifiedReport) external; + function userCollateral(address user) external view returns (uint256); +} diff --git a/examples/denaria/src/DenariaOperationSafety.sol b/examples/denaria/src/DenariaOperationSafety.sol new file mode 100644 index 0000000..2df4674 --- /dev/null +++ b/examples/denaria/src/DenariaOperationSafety.sol @@ -0,0 +1,862 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {IPerpetualProtectionSuite} from "credible-std/protection/perpetual/IPerpetualProtectionSuite.sol"; +import {PerpetualBaseAssertion} from "credible-std/protection/perpetual/PerpetualBaseAssertion.sol"; +import {DenariaHelpers} from "./DenariaHelpers.sol"; +import {IDenariaPerpPairLike, IDenariaVaultLike} from "./DenariaInterfaces.sol"; + +/// @title DenariaProtectionSuite +/// @author Phylax Systems +/// @notice Example `IPerpetualProtectionSuite` for the Denaria perpetual protocol. +/// @dev This example is intentionally written in the same style as the Aave v3 lending example: +/// - it is self-contained inside `credible-std` +/// - it defines the minimal Denaria interfaces it needs locally +/// - it exposes a single generic assertion bundle that can be registered against both the +/// `PerpPair` and the `Vault` +/// +/// The example focuses on the Denaria invariants that fit the generic perpetual suite well: +/// - non-liquidation operations must not create self bad debt +/// - non-liquidation operations must leave the account healthy at mark +/// - trade execution must be no better than the protocol mark price +/// - successful trades must stay backed by available side liquidity +/// - liquidation is only permitted from an unhealthy pre-state +/// - critical execution prices must stay oracle-anchored +contract DenariaProtectionSuite is DenariaHelpers { + bytes32 internal constant MARGIN_RATIO_METRIC = "MARGIN_RATIO"; + bytes32 internal constant EQUITY_METRIC = "EQUITY"; + bytes32 internal constant LP_LEVERAGE_CAPACITY_METRIC = "LP_LEVERAGE_CAPACITY"; + bytes32 internal constant TAKER_WORSE_THAN_MARK = "TAKER_WORSE_THAN_MARK"; + bytes32 internal constant TRADE_LIQUIDITY_COVERAGE = "TRADE_LIQUIDITY_COVERAGE"; + bytes32 internal constant LIQUIDATION_GATED_BY_MMR = "LIQUIDATION_GATED_BY_MMR"; + bytes32 internal constant RISK_MARK_ANCHORED = "RISK_MARK_ANCHORED"; + bytes32 internal constant EQUITY_CONSERVATION = "EQUITY_CONSERVATION"; + bytes32 internal constant LP_EXIT_DUST = "LP_EXIT_DUST"; + bytes32 internal constant LP_BALANCE_OVERFLOW = "LP_BALANCE_OVERFLOW"; + + /// @notice Maximum allowed rounding tolerance for accounting conservation checks (1e15 wei = 0.001 token). + uint256 internal constant ACCOUNTING_EPSILON = 1e15; + + constructor(address perpPair_, address vault_) DenariaHelpers(perpPair_, vault_) {} + + /// @notice Returns the Denaria selectors that feed the shared perpetual assertions. + /// @dev Register the bundled assertion against both the `PerpPair` and the `Vault` to cover the + /// full non-liquidation user surface that can affect perpetual risk. + function getMonitoredSelectors() external pure override returns (bytes4[] memory selectors) { + selectors = new bytes4[](8); + selectors[0] = IDenariaPerpPairLike.trade.selector; + selectors[1] = IDenariaPerpPairLike.closeAndWithdraw.selector; + selectors[2] = IDenariaPerpPairLike.addLiquidity.selector; + selectors[3] = IDenariaPerpPairLike.removeLiquidity.selector; + selectors[4] = IDenariaPerpPairLike.realizePnL.selector; + selectors[5] = IDenariaPerpPairLike.liquidate.selector; + selectors[6] = IDenariaVaultLike.removeCollateral.selector; + selectors[7] = IDenariaVaultLike.removeAllCollateral.selector; + } + + /// @notice Decodes Denaria PerpPair and Vault calls into the shared perpetual operation model. + function decodeOperation(TriggeredCall calldata triggered) + external + pure + override + returns (OperationContext memory operation) + { + operation.selector = triggered.selector; + operation.caller = triggered.caller; + + if (triggered.selector == IDenariaPerpPairLike.trade.selector) { + ( + bool direction, + uint256 size, + uint256 minTradeReturn, + uint256 initialGuess, + address frontendAddress, + uint8 leverage, + ) = abi.decode(triggered.input[4:], (bool, uint256, uint256, uint256, address, uint8, bytes)); + + operation.kind = OperationKind.IncreasePosition; + operation.account = triggered.caller; + operation.market = triggered.target; + operation.isLong = direction; + operation.sizeDelta = size; + operation.limitPrice = minTradeReturn; + operation.mutatesExposure = size != 0; + operation.reducesAccountSafety = true; + operation.metadata = abi.encode(initialGuess, frontendAddress, leverage); + return operation; + } + + if (triggered.selector == IDenariaPerpPairLike.closeAndWithdraw.selector) { + (uint256 maxSlippage, uint256 maxLiqFee, address frontendAddress,) = + abi.decode(triggered.input[4:], (uint256, uint256, address, bytes)); + + operation.kind = OperationKind.DecreasePosition; + operation.account = triggered.caller; + operation.market = triggered.target; + operation.limitPrice = maxSlippage; + operation.mutatesExposure = true; + operation.reducesAccountSafety = true; + operation.metadata = abi.encode(maxLiqFee, frontendAddress); + return operation; + } + + if (triggered.selector == IDenariaPerpPairLike.addLiquidity.selector) { + (uint256 liquidityStable, uint256 liquidityAsset, uint256 maxFeeValue,) = + abi.decode(triggered.input[4:], (uint256, uint256, uint256, bytes)); + + operation.kind = OperationKind.AddLiquidity; + operation.account = triggered.caller; + operation.market = triggered.target; + operation.sizeDelta = liquidityAsset; + operation.collateralDelta = _toInt256(liquidityStable); + operation.mutatesExposure = liquidityStable != 0 || liquidityAsset != 0; + operation.reducesAccountSafety = true; + operation.metadata = abi.encode(maxFeeValue); + return operation; + } + + if (triggered.selector == IDenariaPerpPairLike.removeLiquidity.selector) { + (uint256 liquidityStableToRemove, uint256 liquidityAssetToRemove, uint256 maxFeeValue,) = + abi.decode(triggered.input[4:], (uint256, uint256, uint256, bytes)); + + operation.kind = OperationKind.RemoveLiquidity; + operation.account = triggered.caller; + operation.market = triggered.target; + operation.sizeDelta = liquidityAssetToRemove; + operation.collateralDelta = -_toInt256(liquidityStableToRemove); + operation.mutatesExposure = liquidityStableToRemove != 0 || liquidityAssetToRemove != 0; + operation.reducesAccountSafety = true; + operation.metadata = abi.encode(maxFeeValue); + return operation; + } + + if (triggered.selector == IDenariaPerpPairLike.realizePnL.selector) { + operation.kind = OperationKind.RealizePnL; + operation.account = triggered.caller; + operation.market = triggered.target; + operation.reducesAccountSafety = true; + return operation; + } + + if (triggered.selector == IDenariaPerpPairLike.liquidate.selector) { + (address user, uint256 liquidatedPositionSize,) = abi.decode(triggered.input[4:], (address, uint256, bytes)); + + operation.kind = OperationKind.Liquidation; + operation.account = user; + operation.market = triggered.target; + operation.counterparty = triggered.caller; + operation.sizeDelta = liquidatedPositionSize; + operation.mutatesExposure = liquidatedPositionSize != 0; + operation.isLiquidation = true; + return operation; + } + + if (triggered.selector == IDenariaVaultLike.removeCollateral.selector) { + (uint256 amount,) = abi.decode(triggered.input[4:], (uint256, bytes)); + + operation.kind = OperationKind.WithdrawCollateral; + operation.account = triggered.caller; + operation.market = address(0); + operation.collateralAsset = triggered.target; + operation.collateralDelta = -_toInt256(amount); + operation.reducesAccountSafety = true; + return operation; + } + + if (triggered.selector == IDenariaVaultLike.removeAllCollateral.selector) { + operation.kind = OperationKind.WithdrawCollateral; + operation.account = triggered.caller; + operation.market = address(0); + operation.collateralAsset = triggered.target; + operation.reducesAccountSafety = true; + return operation; + } + } + + /// @notice Denaria non-liquidation mutations must preserve post-state safety. + function shouldCheckPostMutationRisk(OperationContext calldata operation) + external + pure + override + returns (bool shouldCheck) + { + return operation.account != address(0) && !operation.isLiquidation && operation.reducesAccountSafety; + } + + /// @notice Builds Denaria's taker-worse-than-mark execution check for direct `trade(...)` calls. + function getExecutionPriceChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view override returns (ExecutionPriceCheck[] memory checks) { + beforeFork; + + if ( + triggered.target != PERP_PAIR + || (triggered.selector != IDenariaPerpPairLike.trade.selector + && triggered.selector != IDenariaPerpPairLike.closeAndWithdraw.selector) + ) { + return new ExecutionPriceCheck[](0); + } + + DenariaExecutedTradeLog[] memory tradeLogs = _getExecutedTrades(afterFork); + uint256 relevantTrades = _countTradesForAccount(tradeLogs, operation.account); + if (relevantTrades == 0) { + return new ExecutionPriceCheck[](0); + } + + checks = new ExecutionPriceCheck[](relevantTrades); + uint256 checkIndex; + for (uint256 i; i < tradeLogs.length; ++i) { + DenariaExecutedTradeLog memory tradeLog = tradeLogs[i]; + if (tradeLog.user != operation.account || tradeLog.tradeSize == 0 || tradeLog.tradeReturn == 0) { + continue; + } + + uint256 executionPrice = tradeLog.direction + ? ph.mulDivDown(tradeLog.tradeSize, ORACLE_PRICE_DECIMALS, tradeLog.tradeReturn) + : ph.mulDivDown(tradeLog.tradeReturn, ORACLE_PRICE_DECIMALS, tradeLog.tradeSize); + + checks[checkIndex] = ExecutionPriceCheck({ + checkName: TAKER_WORSE_THAN_MARK, + account: operation.account, + market: PERP_PAIR, + executionPrice: executionPrice, + minExecutionPrice: tradeLog.direction ? tradeLog.currentPrice : 0, + maxExecutionPrice: tradeLog.direction ? type(uint256).max : tradeLog.currentPrice, + metadata: abi.encode( + triggered.selector, checkIndex, tradeLog.direction, tradeLog.tradeSize, tradeLog.tradeReturn + ) + }); + ++checkIndex; + } + } + + /// @notice Builds Denaria's side-liquidity coverage check for direct `trade(...)` calls. + function getLiquidityCoverageChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view override returns (LiquidityCoverageCheck[] memory checks) { + if ( + triggered.target != PERP_PAIR + || (triggered.selector != IDenariaPerpPairLike.trade.selector + && triggered.selector != IDenariaPerpPairLike.closeAndWithdraw.selector) + ) { + return new LiquidityCoverageCheck[](0); + } + + DenariaExecutedTradeLog[] memory tradeLogs = _getExecutedTrades(afterFork); + uint256 relevantTrades = _countTradesForAccount(tradeLogs, operation.account); + if (relevantTrades == 0) { + return new LiquidityCoverageCheck[](0); + } + + uint256 currentStableLiquidity = + _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.globalLiquidityStable, ()), beforeFork); + uint256 currentAssetLiquidity = + _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.globalLiquidityAsset, ()), beforeFork); + + if (triggered.selector == IDenariaPerpPairLike.closeAndWithdraw.selector) { + DenariaAccountMetrics memory beforeMetrics = _readAccountMetrics(operation.account, beforeFork); + (bool foundRemoval, DenariaLiquidityMoveLog memory removalLog) = + _getLastLiquidityRemovalForAccount(operation.account, afterFork); + + if (foundRemoval) { + bool feeDistributed = currentStableLiquidity > beforeMetrics.lpStableBalance + && currentAssetLiquidity > beforeMetrics.lpAssetBalance; + currentStableLiquidity = currentStableLiquidity > beforeMetrics.lpStableBalance + ? currentStableLiquidity - beforeMetrics.lpStableBalance + : 0; + if (feeDistributed) { + currentStableLiquidity += removalLog.feeValue; + } + currentAssetLiquidity = currentAssetLiquidity > beforeMetrics.lpAssetBalance + ? currentAssetLiquidity - beforeMetrics.lpAssetBalance + : 0; + } + } + + checks = new LiquidityCoverageCheck[](relevantTrades); + uint256 checkIndex; + for (uint256 i; i < tradeLogs.length; ++i) { + DenariaExecutedTradeLog memory tradeLog = tradeLogs[i]; + if (tradeLog.user != operation.account || tradeLog.tradeSize == 0 || tradeLog.tradeReturn == 0) { + continue; + } + + uint256 availableAmount = tradeLog.direction ? currentAssetLiquidity : currentStableLiquidity; + checks[checkIndex] = LiquidityCoverageCheck({ + checkName: TRADE_LIQUIDITY_COVERAGE, + market: PERP_PAIR, + accountingBucket: PERP_PAIR, + requiredAmount: tradeLog.tradeReturn, + availableAmount: availableAmount, + metadata: abi.encode(triggered.selector, checkIndex, tradeLog.direction, tradeLog.tradeSize) + }); + + if (!tradeLog.direction) { + currentAssetLiquidity += tradeLog.tradeSize; + } else { + currentAssetLiquidity = + currentAssetLiquidity > tradeLog.tradeReturn ? currentAssetLiquidity - tradeLog.tradeReturn : 0; + } + + ++checkIndex; + } + } + + /// @notice Builds a Denaria liquidation-gating check from the pre-state margin ratio. + /// @dev This example intentionally focuses on the liquidation gate itself. The returned metadata + /// still exposes the pre/post insurance-fund values for downstream debugging or stronger + /// protocol-specific assertions. + function getLiquidationChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view override returns (LiquidationCheck[] memory checks) { + if (triggered.target != PERP_PAIR || triggered.selector != IDenariaPerpPairLike.liquidate.selector) { + return new LiquidationCheck[](0); + } + + DenariaAccountMetrics memory beforeMetrics = _readAccountMetrics(operation.account, beforeFork); + uint256 collateralBefore = _readCollateral(operation.account, beforeFork); + uint256 collateralAfter = _readCollateral(operation.account, afterFork); + int256 insuranceBefore = _readSignedInsuranceFund(beforeFork); + int256 insuranceAfter = _readSignedInsuranceFund(afterFork); + uint256 collateralAbsorbed = _consumedBetween(collateralBefore, collateralAfter); + uint256 insuranceAbsorbed = _decreaseBetween(insuranceBefore, insuranceAfter); + uint256 realizedLoss = collateralAbsorbed + insuranceAbsorbed; + + checks = new LiquidationCheck[](1); + checks[0] = LiquidationCheck({ + checkName: LIQUIDATION_GATED_BY_MMR, + account: operation.account, + market: PERP_PAIR, + wasLiquidatableBefore: beforeMetrics.markMarginRatio <= beforeMetrics.maintenanceThreshold, + lossCreated: _toInt256(realizedLoss), + absorbedLoss: realizedLoss, + absorber: insuranceAbsorbed != 0 ? PERP_PAIR : VAULT, + metadata: abi.encode( + beforeMetrics.markMarginRatio, + beforeMetrics.maintenanceThreshold, + collateralAbsorbed, + insuranceBefore, + insuranceAfter + ) + }); + } + + /// @notice Builds exact-price oracle-anchor checks from the Denaria trade and liquidation logs. + function getOracleAnchorChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view override returns (OracleAnchorCheck[] memory checks) { + beforeFork; + + if ( + triggered.target == PERP_PAIR + && (triggered.selector == IDenariaPerpPairLike.trade.selector + || triggered.selector == IDenariaPerpPairLike.closeAndWithdraw.selector) + ) { + DenariaExecutedTradeLog[] memory tradeLogs = _getExecutedTrades(afterFork); + uint256 relevantTrades = _countTradesForAccount(tradeLogs, operation.account); + if (relevantTrades == 0) { + return new OracleAnchorCheck[](0); + } + + uint256 oraclePrice = _readPrice(afterFork); + checks = new OracleAnchorCheck[](relevantTrades); + uint256 checkIndex; + for (uint256 i; i < tradeLogs.length; ++i) { + DenariaExecutedTradeLog memory tradeLog = tradeLogs[i]; + if (tradeLog.user != operation.account || tradeLog.tradeSize == 0 || tradeLog.tradeReturn == 0) { + continue; + } + + checks[checkIndex] = OracleAnchorCheck({ + checkName: RISK_MARK_ANCHORED, + market: PERP_PAIR, + usedPrice: tradeLog.currentPrice, + minOraclePrice: oraclePrice, + maxOraclePrice: oraclePrice, + metadata: abi.encode(triggered.selector, checkIndex, tradeLog.direction, tradeLog.tradeSize) + }); + ++checkIndex; + } + return checks; + } + + if (triggered.target == PERP_PAIR && triggered.selector == IDenariaPerpPairLike.liquidate.selector) { + (bool found, DenariaLiquidationLog memory liquidationLog) = _getLastLiquidation(afterFork); + if (!found) { + return new OracleAnchorCheck[](0); + } + + uint256 oraclePrice = _readPrice(afterFork); + checks = new OracleAnchorCheck[](1); + checks[0] = OracleAnchorCheck({ + checkName: RISK_MARK_ANCHORED, + market: PERP_PAIR, + usedPrice: liquidationLog.currentPrice, + minOraclePrice: oraclePrice, + maxOraclePrice: oraclePrice, + metadata: abi.encode(liquidationLog.positionSize, liquidationLog.fraction) + }); + } + } + + /// @notice Builds accounting-conservation checks for Denaria settlement paths. + /// @dev Solvency-only checks (`_buildNoBadDebtRiskState`) are insufficient for Denaria because + /// stale LP share math in `getLpLiquidityBalance` / `calcPnL` can credit an account with + /// more value than it should receive during `removeLiquidity` or `closeAndWithdraw`. The + /// account never goes negative, so it passes the equity >= 0 gate, but it extracts + /// unjustified economic value from the pool. + /// + /// Three checks are applied: + /// + /// - EQUITY_CONSERVATION: runtime equity must not increase beyond rounding tolerance + /// across the triggered call. Catches intra-call accounting drift (e.g. a + /// removeLiquidity whose internal trade inflates equity in the same frame). + /// + /// - LP_EXIT_DUST: after a full LP exit, residual LP balances and debts must be zero + /// (within epsilon). Catches incomplete position teardown. + /// + /// - LP_BALANCE_OVERFLOW: an LP's balance must be strictly below the pool total. + /// Catches the March 2026 exploit where matrix rounding produces a negative int256, + /// the bare uint256() cast wraps it to near-max, and the cap at globalLiquidity + /// gives the attacker credit for the entire pool. Because the inflation happens + /// during a prior trade (different account), the equity delta across realizePnL + /// is near-zero — only this direct balance-vs-pool check detects the overflow. + function getAccountingConservationChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view override returns (AccountingConservationCheck[] memory checks) { + triggered; + + if ( + operation.kind != OperationKind.RemoveLiquidity && operation.kind != OperationKind.DecreasePosition + && operation.kind != OperationKind.RealizePnL + ) { + return new AccountingConservationCheck[](0); + } + + DenariaAccountMetrics memory beforeMetrics = _readAccountMetrics(operation.account, beforeFork); + DenariaAccountMetrics memory afterMetrics = _readAccountMetrics(operation.account, afterFork); + + int256 equityDelta = afterMetrics.runtimeEquity - beforeMetrics.runtimeEquity; + + bool fullLpExit = (beforeMetrics.lpStableBalance + beforeMetrics.lpAssetBalance > 0) + && (afterMetrics.lpStableBalance + afterMetrics.lpAssetBalance == 0); + + (bool lpOverflow, AccountingConservationCheck memory overflowCheck) = + _buildLpOverflowCheck(beforeMetrics, operation.account, beforeFork); + + uint256 checkCount = 1; + if (fullLpExit) ++checkCount; + if (lpOverflow) ++checkCount; + + checks = new AccountingConservationCheck[](checkCount); + + // Primary check: equity must not increase beyond rounding tolerance. + checks[0] = AccountingConservationCheck({ + checkName: EQUITY_CONSERVATION, + account: operation.account, + market: PERP_PAIR, + actualDelta: equityDelta, + minAllowedDelta: type(int256).min, + maxAllowedDelta: _toInt256(ACCOUNTING_EPSILON), + metadata: abi.encode( + operation.kind, + beforeMetrics.runtimeEquity, + afterMetrics.runtimeEquity, + beforeMetrics.collateral, + afterMetrics.collateral, + beforeMetrics.runtimePnl, + afterMetrics.runtimePnl + ) + }); + + uint256 nextIdx = 1; + + // Secondary check: after a full LP exit, no residual LP balances should remain. + if (fullLpExit) { + int256 residualLp = _toInt256( + afterMetrics.lpStableBalance + afterMetrics.lpAssetBalance + afterMetrics.lpDebtStable + + afterMetrics.lpDebtAsset + ); + checks[nextIdx] = AccountingConservationCheck({ + checkName: LP_EXIT_DUST, + account: operation.account, + market: PERP_PAIR, + actualDelta: residualLp, + minAllowedDelta: 0, + maxAllowedDelta: _toInt256(ACCOUNTING_EPSILON), + metadata: abi.encode( + afterMetrics.lpStableBalance, + afterMetrics.lpAssetBalance, + afterMetrics.lpDebtStable, + afterMetrics.lpDebtAsset + ) + }); + ++nextIdx; + } + + // Overflow cap check (delegated to _buildLpOverflowCheck). + if (lpOverflow) { + checks[nextIdx] = overflowCheck; + } + } + + /// @notice Detects the LP balance overflow cap — the fingerprint of the Denaria exploit. + /// @dev When getLpLiquidityBalance computes a negative int256 (due to matrix rounding), + /// the bare uint256() cast wraps it to near-max. The subsequent cap at + /// globalLiquidityAsset/Stable gives the attacker credit for the entire pool. + /// This helper returns true when an LP's balance equals the pool total on either side. + function _buildLpOverflowCheck( + DenariaAccountMetrics memory beforeMetrics, + address account, + PhEvm.ForkId memory beforeFork + ) internal view returns (bool found, AccountingConservationCheck memory check) { + if (beforeMetrics.lpAssetBalance == 0 && beforeMetrics.lpStableBalance == 0) { + return (false, check); + } + + uint256 globalLiqAsset = + _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.globalLiquidityAsset, ()), beforeFork); + uint256 globalLiqStable = + _readUintAt(PERP_PAIR, abi.encodeCall(IDenariaPerpPairLike.globalLiquidityStable, ()), beforeFork); + + bool assetAtCap = beforeMetrics.lpAssetBalance == globalLiqAsset && globalLiqAsset > 0; + bool stableAtCap = beforeMetrics.lpStableBalance == globalLiqStable && globalLiqStable > 0; + + if (!assetAtCap && !stableAtCap) { + return (false, check); + } + + // actualDelta = lpBalance - globalLiquidity = 0 when at cap. + // Allowed range [type(int256).min, -1] requires strictly negative (below cap). + int256 overflowDelta = assetAtCap + ? _toInt256(beforeMetrics.lpAssetBalance) - _toInt256(globalLiqAsset) + : _toInt256(beforeMetrics.lpStableBalance) - _toInt256(globalLiqStable); + + check = AccountingConservationCheck({ + checkName: LP_BALANCE_OVERFLOW, + account: account, + market: PERP_PAIR, + actualDelta: overflowDelta, + minAllowedDelta: type(int256).min, + maxAllowedDelta: -1, + metadata: abi.encode( + assetAtCap, + beforeMetrics.lpAssetBalance, + globalLiqAsset, + stableAtCap, + beforeMetrics.lpStableBalance, + globalLiqStable + ) + }); + + return (true, check); + } + + /// @notice Reads and normalizes Denaria's aggregate account state. + function getAccountState(address account, PhEvm.ForkId calldata fork) + external + view + override + returns (AccountState memory state) + { + return _buildAccountState(_readAccountMetrics(account, fork)); + } + + /// @notice Reads Denaria's single-market account position. + function getAccountPositions(address account, PhEvm.ForkId calldata fork) + external + view + override + returns (PositionState[] memory positions) + { + return _buildPositions(_readAccountMetrics(account, fork)); + } + + /// @notice Evaluates Denaria health from mark-to-market equity, margin ratio, and LP leverage. + function evaluateRisk(AccountState calldata state, PositionState[] calldata positions, PhEvm.ForkId calldata fork) + external + view + override + returns (RiskState memory risk) + { + positions; + risk = _buildGenericRiskState(_readAccountMetrics(state.account, fork)); + } + + /// @notice Optimized snapshot path that avoids rereading Denaria account state three times. + function getAccountSnapshot(address account, PhEvm.ForkId calldata fork) + external + view + override + returns (AccountSnapshot memory snapshot) + { + DenariaAccountMetrics memory metrics = _readAccountMetrics(account, fork); + snapshot.state = _buildAccountState(metrics); + snapshot.positions = _buildPositions(metrics); + snapshot.risk = _buildGenericRiskState(metrics); + } + + /// @notice Operation-aware post-mutation snapshot that mirrors Denaria's action-specific guards. + function getPostMutationSnapshot( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata fork + ) external view override returns (AccountSnapshot memory snapshot) { + triggered; + + DenariaAccountMetrics memory metrics = _readAccountMetrics(operation.account, fork); + snapshot.state = _buildAccountState(metrics); + snapshot.positions = _buildPositions(metrics); + snapshot.risk = _buildPostMutationRiskState(metrics, operation); + } + + function _buildAccountState(DenariaAccountMetrics memory metrics) + internal + pure + returns (AccountState memory state) + { + state.account = metrics.account; + state.collateralValue = metrics.collateral; + state.openNotional = metrics.openNotional; + state.unrealizedPnl = metrics.runtimePnl; + state.accruedFunding = metrics.totalFundingContribution; + state.hasOpenExposure = _hasTrackedState(metrics); + state.metadata = abi.encode( + metrics.price, + metrics.runtimeEquity, + metrics.markEquity, + metrics.markMarginRatio, + metrics.maintenanceThreshold, + metrics.lpLeverageHealthy, + metrics.maxLpLeverage, + metrics.lpLeverageDebtValue, + metrics.lpStableBalance, + metrics.lpAssetBalance, + metrics.lpDebtStable, + metrics.lpDebtAsset + ); + } + + function _buildPositions(DenariaAccountMetrics memory metrics) + internal + view + returns (PositionState[] memory positions) + { + if (!_hasTrackedState(metrics)) { + return new PositionState[](0); + } + + positions = new PositionState[](1); + positions[0] = PositionState({ + market: PERP_PAIR, + collateralAsset: VAULT, + isLong: metrics.balanceAsset + metrics.lpAssetBalance >= metrics.debtAsset + metrics.lpDebtAsset, + size: metrics.openAssetExposure, + openNotional: metrics.openNotional, + collateralValue: metrics.collateral, + pnl: metrics.runtimePnl, + accruedFunding: metrics.totalFundingContribution, + markPrice: metrics.price, + maintenanceRequirement: _mulDivDown( + metrics.openNotional, metrics.maintenanceThreshold, MARGIN_RATIO_DECIMALS + ), + metadata: abi.encode( + metrics.balanceStable, + metrics.balanceAsset, + metrics.debtStable, + metrics.debtAsset, + metrics.lpStableBalance, + metrics.lpAssetBalance, + metrics.lpDebtStable, + metrics.lpDebtAsset + ) + }); + } + + function _buildGenericRiskState(DenariaAccountMetrics memory metrics) + internal + pure + returns (RiskState memory risk) + { + risk.isHealthy = metrics.runtimeEquity >= 0 && metrics.markMarginRatio > metrics.maintenanceThreshold; + risk.hasBadDebt = metrics.runtimeEquity < 0; + risk.isLiquidatable = metrics.openNotional != 0 && metrics.markMarginRatio <= metrics.maintenanceThreshold; + risk.metricName = MARGIN_RATIO_METRIC; + risk.equity = metrics.runtimeEquity; + risk.metricValue = _toInt256(metrics.markMarginRatio); + risk.thresholdValue = _toInt256(metrics.maintenanceThreshold); + risk.comparison = ComparisonKind.Gt; + risk.metadata = abi.encode( + metrics.price, + metrics.collateral, + metrics.runtimePnl, + metrics.markPnl, + metrics.totalFundingContribution, + metrics.lpLeverageHealthy, + metrics.maxLpLeverage + ); + } + + function _buildPostMutationRiskState(DenariaAccountMetrics memory metrics, OperationContext calldata operation) + internal + pure + returns (RiskState memory risk) + { + if (operation.kind == OperationKind.IncreasePosition) { + return _buildMarginRatioRiskState(metrics, ComparisonKind.Gt, false); + } + + if (operation.kind == OperationKind.WithdrawCollateral) { + return _buildWithdrawCollateralRiskState(metrics); + } + + if (operation.kind == OperationKind.AddLiquidity) { + return _buildLpLeverageRiskState(metrics); + } + + if ( + operation.kind == OperationKind.DecreasePosition || operation.kind == OperationKind.RemoveLiquidity + || operation.kind == OperationKind.RealizePnL + ) { + return _buildNoBadDebtRiskState(metrics); + } + + return _buildGenericRiskState(metrics); + } + + function _buildMarginRatioRiskState( + DenariaAccountMetrics memory metrics, + ComparisonKind comparison, + bool includeLpLeverage + ) internal pure returns (RiskState memory risk) { + bool marginHealthy = comparison == ComparisonKind.Gt + ? metrics.markMarginRatio > metrics.maintenanceThreshold + : metrics.markMarginRatio >= metrics.maintenanceThreshold; + + risk.isHealthy = + metrics.runtimeEquity >= 0 && marginHealthy && (!includeLpLeverage || metrics.lpLeverageHealthy); + risk.hasBadDebt = metrics.runtimeEquity < 0; + risk.isLiquidatable = metrics.openNotional != 0 && metrics.markMarginRatio <= metrics.maintenanceThreshold; + risk.metricName = MARGIN_RATIO_METRIC; + risk.equity = metrics.runtimeEquity; + risk.metricValue = _toInt256(metrics.markMarginRatio); + risk.thresholdValue = _toInt256(metrics.maintenanceThreshold); + risk.comparison = comparison; + risk.metadata = abi.encode( + includeLpLeverage, + metrics.markEquity, + metrics.runtimePnl, + metrics.markPnl, + metrics.lpLeverageHealthy, + metrics.lpLeverageDebtValue + ); + } + + function _buildWithdrawCollateralRiskState(DenariaAccountMetrics memory metrics) + internal + pure + returns (RiskState memory risk) + { + // Vault._checkMR(...) uses stored funding only when validating collateral withdrawals. + int256 withdrawMarkPnl = metrics.markPnl - metrics.pendingFundingContribution; + int256 withdrawMarkEquity = _toInt256(metrics.collateral) + withdrawMarkPnl; + uint256 withdrawMarkMarginRatio = _computeMarginRatio(withdrawMarkEquity, metrics.openNotional); + + risk.isHealthy = metrics.runtimeEquity >= 0 && withdrawMarkMarginRatio >= metrics.maintenanceThreshold + && metrics.lpLeverageHealthy; + risk.hasBadDebt = metrics.runtimeEquity < 0; + risk.isLiquidatable = metrics.openNotional != 0 && withdrawMarkMarginRatio <= metrics.maintenanceThreshold; + risk.metricName = MARGIN_RATIO_METRIC; + risk.equity = metrics.runtimeEquity; + risk.metricValue = _toInt256(withdrawMarkMarginRatio); + risk.thresholdValue = _toInt256(metrics.maintenanceThreshold); + risk.comparison = ComparisonKind.Gte; + risk.metadata = abi.encode( + withdrawMarkEquity, + withdrawMarkPnl, + metrics.storedFundingContribution, + metrics.pendingFundingContribution, + metrics.lpLeverageHealthy, + metrics.lpLeverageDebtValue + ); + } + + function _buildLpLeverageRiskState(DenariaAccountMetrics memory metrics) + internal + pure + returns (RiskState memory risk) + { + risk.isHealthy = metrics.runtimeEquity >= 0 && metrics.lpLeverageHealthy; + risk.hasBadDebt = metrics.runtimeEquity < 0; + risk.isLiquidatable = metrics.openNotional != 0 && metrics.markMarginRatio <= metrics.maintenanceThreshold; + risk.metricName = LP_LEVERAGE_CAPACITY_METRIC; + risk.equity = metrics.runtimeEquity; + risk.metricValue = _lpLeverageCapacity(metrics); + risk.thresholdValue = 0; + risk.comparison = ComparisonKind.Gte; + risk.metadata = + abi.encode(metrics.lpLeverageDebtValue, metrics.maxLpLeverage, metrics.collateral, metrics.runtimePnl); + } + + function _buildNoBadDebtRiskState(DenariaAccountMetrics memory metrics) + internal + pure + returns (RiskState memory risk) + { + risk.isHealthy = metrics.runtimeEquity >= 0; + risk.hasBadDebt = metrics.runtimeEquity < 0; + risk.isLiquidatable = metrics.openNotional != 0 && metrics.markMarginRatio <= metrics.maintenanceThreshold; + risk.metricName = EQUITY_METRIC; + risk.equity = metrics.runtimeEquity; + risk.metricValue = metrics.runtimeEquity; + risk.thresholdValue = 0; + risk.comparison = ComparisonKind.Gte; + risk.metadata = abi.encode(metrics.runtimePnl, metrics.markMarginRatio, metrics.maintenanceThreshold); + } +} + +/// @title DenariaOperationSafetyAssertion +/// @author Phylax Systems +/// @notice Example single-entry assertion bundle for Denaria. +/// @dev Usage: +/// `cl.assertion({ adopter: denariaPerpPair, createData: abi.encodePacked(type(DenariaOperationSafetyAssertion).creationCode, abi.encode(denariaPerpPair, denariaVault)), fnSelector: DenariaOperationSafetyAssertion.assertOperationSafety.selector })` +/// `cl.assertion({ adopter: denariaVault, createData: abi.encodePacked(type(DenariaOperationSafetyAssertion).creationCode, abi.encode(denariaPerpPair, denariaVault)), fnSelector: DenariaOperationSafetyAssertion.assertOperationSafety.selector })` +/// +/// Register it once against `PerpPair` and once against `Vault` to cover Denaria's trade, +/// liquidity, PnL realization, liquidation, and collateral-removal paths with one bundle. +contract DenariaOperationSafetyAssertion is PerpetualBaseAssertion { + IPerpetualProtectionSuite internal immutable SUITE; + + constructor(address perpPair_, address vault_) { + SUITE = IPerpetualProtectionSuite(address(new DenariaProtectionSuite(perpPair_, vault_))); + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function _suite() internal view override returns (IPerpetualProtectionSuite) { + return SUITE; + } +} + +/// @title DenariaPostMutationRiskAssertion +/// @author Phylax Systems +/// @notice Compatibility alias for users who only care about the post-mutation risk gate. +contract DenariaPostMutationRiskAssertion is DenariaOperationSafetyAssertion { + constructor(address perpPair_, address vault_) DenariaOperationSafetyAssertion(perpPair_, vault_) {} +} diff --git a/examples/euler/README.md b/examples/euler/README.md new file mode 100644 index 0000000..0a72714 --- /dev/null +++ b/examples/euler/README.md @@ -0,0 +1,18 @@ +# euler examples + +Assertion examples and supporting helpers extracted from the `eulerv2` branch. + +## Build + +```sh +FOUNDRY_PROFILE=euler forge build +``` + +## Files + +- EulerEVaultAssertion.sol +- EulerEVaultCircuitBreakerAssertion.sol +- EulerEVaultHelpers.sol +- EulerEVaultInterfaces.sol +- EulerEVaultSandwichAssertion.sol +- EulerEVaultSandwichHelpers.sol diff --git a/examples/euler/src/EulerEVaultAssertion.sol b/examples/euler/src/EulerEVaultAssertion.sol new file mode 100644 index 0000000..6cf0646 --- /dev/null +++ b/examples/euler/src/EulerEVaultAssertion.sol @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {EulerEVaultCircuitBreakerMixin} from "./EulerEVaultCircuitBreakerAssertion.sol"; +import {EulerEVaultBase} from "./EulerEVaultHelpers.sol"; +import {IEulerEVaultLike} from "./EulerEVaultInterfaces.sol"; + +/// @title EulerUserStorageAccountingMixin +/// @author Phylax Systems +/// @notice Checks that modified EVK user storage remains consistent with public account views. +/// @dev Uses mapping tracing to find modified `vaultStorage.users[account]` entries without +/// decoding EVC batches or routers. This applies to Euler Vault Kit EVaults with the +/// storage layout documented in `EulerEVaultBase.USERS_MAPPING_SLOT`. +abstract contract EulerUserStorageAccountingMixin is EulerEVaultBase { + function _registerUserStorageAccounting() internal view { + registerTxEndTrigger(this.assertUserStorageMatchesAccountViews.selector); + } + + /// @notice Verifies changed EVK user share/debt fields against direct public account views. + /// @dev Runs once at tx end. A failure means EVK's packed user storage no longer agrees with + /// `balanceOf(account)` for shares or `debtOfExact(account)` for accounts whose debt slot + /// was refreshed during the transaction. + function assertUserStorageMatchesAccountViews() external view { + address vault = _vault(); + bytes[] memory keys = ph.changedMappingKeys(vault, USERS_MAPPING_SLOT); + + for (uint256 i; i < keys.length; ++i) { + _assertChangedUserState(vault, keys[i]); + } + } + + function _assertChangedUserState(address vault, bytes memory key) internal view { + address account = _keyToAddress(key); + + (bytes32 prePacked, bytes32 postPacked, bool dataChanged) = + ph.mappingValueDiff(vault, USERS_MAPPING_SLOT, key, 0); + (bytes32 preAcc, bytes32 postAcc, bool accumulatorChanged) = + ph.mappingValueDiff(vault, USERS_MAPPING_SLOT, key, 1); + + if (!dataChanged && !accumulatorChanged) { + return; + } + + _assertShareState(vault, account, postPacked, dataChanged); + _assertDebtState(vault, account, prePacked, postPacked, preAcc, postAcc, accumulatorChanged); + } + + function _assertShareState(address vault, address account, bytes32 postPacked, bool dataChanged) internal view { + if (!dataChanged) { + return; + } + + uint256 postBalance = _readBalanceAt(vault, account, _postTx()); + require(postBalance == _rawShares(postPacked), "EulerEVault: balanceOf != packed shares"); + } + + function _assertDebtState( + address vault, + address account, + bytes32 prePacked, + bytes32 postPacked, + bytes32 preAcc, + bytes32 postAcc, + bool accumulatorChanged + ) internal view { + uint256 preRawOwed = _rawOwed(prePacked); + uint256 postRawOwed = _rawOwed(postPacked); + if (preRawOwed == postRawOwed && !accumulatorChanged) { + return; + } + if (preRawOwed == postRawOwed && preAcc == postAcc) { + return; + } + + uint256 postDebtExact = _debtOfExactAt(vault, account, _postTx()); + require(postDebtExact == postRawOwed, "EulerEVault: debtOfExact != packed owed"); + } +} + +/// @title EulerPerCallSharePriceMixin +/// @author Phylax Systems +/// @notice Ensures each EVK mutating call does not cause unexplained virtual share-price loss. +/// @dev EVK can reduce share price when debt is socialized. This assertion allows that case only +/// when a `DebtSocialized` event is emitted in the same call and the amount explains the drop. +abstract contract EulerPerCallSharePriceMixin is EulerEVaultBase { + uint256 public immutable sharePriceToleranceBps; + + constructor(uint256 toleranceBps_) { + sharePriceToleranceBps = toleranceBps_; + } + + function _registerPerCallSharePrice() internal view { + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.deposit.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.mint.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.withdraw.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.redeem.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.skim.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.borrow.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.repay.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.repayWithShares.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.pullDebt.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.flashLoan.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.touch.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.liquidate.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.transfer.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.transferFrom.selector + ); + registerFnCallTrigger( + this.assertPerCallSharePriceDropOnlyFromSocialization.selector, IEulerEVaultLike.transferFromMax.selector + ); + } + + /// @notice Checks call-scoped EVK virtual share price before and after a single adopter call. + /// @dev A failure means one EVault call reduced `(totalAssets + 1e6) / (totalSupply + 1e6)` + /// beyond tolerance without same-call debt socialization explaining the loss. + function assertPerCallSharePriceDropOnlyFromSocialization() external view { + address vault = _vault(); + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + uint256 preAssets = _totalAssetsAt(vault, beforeFork) + VIRTUAL_DEPOSIT_AMOUNT; + uint256 preShares = _totalSupplyAt(vault, beforeFork) + VIRTUAL_DEPOSIT_AMOUNT; + uint256 postAssets = _totalAssetsAt(vault, afterFork) + VIRTUAL_DEPOSIT_AMOUNT; + uint256 postShares = _totalSupplyAt(vault, afterFork) + VIRTUAL_DEPOSIT_AMOUNT; + + if (ph.ratioGe(postAssets, postShares, preAssets, preShares, sharePriceToleranceBps)) { + return; + } + + uint256 socialized = _socializedDebtInCall(vault, ctx.callStart); + require(socialized != 0, "EulerEVault: share price dropped without debt socialization"); + + require( + ph.ratioGe(postAssets + socialized, postShares, preAssets, preShares, sharePriceToleranceBps), + "EulerEVault: share price drop exceeds socialized debt" + ); + } + + function _socializedDebtInCall(address vault, uint256 callId) internal view returns (uint256 socialized) { + PhEvm.LogQuery memory query = PhEvm.LogQuery({emitter: vault, signature: DEBT_SOCIALIZED_SIG}); + PhEvm.Log[] memory logs = ph.getLogsForCall(query, callId); + + for (uint256 i; i < logs.length; ++i) { + socialized += _eventAmount(logs[i].data); + } + } +} + +/// @title EulerLiquidationQuoteMixin +/// @author Phylax Systems +/// @notice Ensures each successful EVK liquidation respects the exact pre-call liquidation quote. +/// @dev Uses call-local input and event data plus `checkLiquidation()` at the pre-call fork, so +/// liquidations inside EVC batches are checked against the state immediately before liquidation. +abstract contract EulerLiquidationQuoteMixin is EulerEVaultBase { + struct LiquidationInput { + address violator; + address collateral; + uint256 requestedRepay; + uint256 minYieldBalance; + } + + struct LiquidationEventData { + bool found; + address liquidator; + address violator; + address collateral; + uint256 repayAssets; + uint256 yieldBalance; + } + + function _registerLiquidationQuote() internal view { + registerFnCallTrigger(this.assertLiquidationMatchesPreCallQuote.selector, IEulerEVaultLike.liquidate.selector); + } + + /// @notice Checks a successful EVK `liquidate` call against its pre-call quote and slippage guard. + /// @dev A failure means the emitted liquidation result exceeded `checkLiquidation()` from the + /// pre-call fork, mismatched calldata, or ignored `minYieldBalance`. + function assertLiquidationMatchesPreCallQuote() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _assertLiquidationCall(_vault(), ctx.callStart); + } + + function _assertLiquidationCall(address vault, uint256 callId) internal view { + LiquidationInput memory input = _liquidationInput(callId); + LiquidationEventData memory eventData = _liquidateEventForCall(vault, callId); + + require(eventData.found, "EulerEVault: missing Liquidate event"); + require(eventData.violator == input.violator, "EulerEVault: Liquidate violator mismatch"); + require(eventData.collateral == input.collateral, "EulerEVault: Liquidate collateral mismatch"); + + _assertLiquidationWithinQuote(vault, callId, input, eventData); + } + + function _liquidationInput(uint256 callId) internal view returns (LiquidationInput memory inputData) { + bytes memory input = ph.callinputAt(callId); + (inputData.violator, inputData.collateral, inputData.requestedRepay, inputData.minYieldBalance) = + abi.decode(_stripSelector(input), (address, address, uint256, uint256)); + } + + function _assertLiquidationWithinQuote( + address vault, + uint256 callId, + LiquidationInput memory inputData, + LiquidationEventData memory eventData + ) internal view { + (uint256 maxRepay, uint256 maxYield) = abi.decode( + _viewAt( + vault, + abi.encodeCall( + IEulerEVaultLike.checkLiquidation, (eventData.liquidator, inputData.violator, inputData.collateral) + ), + _preCall(callId) + ), + (uint256, uint256) + ); + + require(eventData.repayAssets <= maxRepay, "EulerEVault: liquidation repaid above pre-call quote"); + require(eventData.yieldBalance <= maxYield, "EulerEVault: liquidation yielded above pre-call quote"); + require(eventData.yieldBalance >= inputData.minYieldBalance, "EulerEVault: liquidation ignored min yield"); + + if (inputData.requestedRepay != type(uint256).max) { + require(eventData.repayAssets == inputData.requestedRepay, "EulerEVault: liquidation repay != requested"); + } + } + + function _liquidateEventForCall(address vault, uint256 callId) + internal + view + returns (LiquidationEventData memory eventData) + { + PhEvm.LogQuery memory query = PhEvm.LogQuery({emitter: vault, signature: LIQUIDATE_SIG}); + PhEvm.Log[] memory logs = ph.getLogsForCall(query, callId); + require(logs.length <= 1, "EulerEVault: multiple Liquidate events"); + if (logs.length == 0) { + return eventData; + } + + PhEvm.Log memory log = logs[0]; + require(log.topics.length >= 3, "EulerEVault: malformed Liquidate topics"); + eventData.found = true; + eventData.liquidator = _topicAddress(log.topics[1]); + eventData.violator = _topicAddress(log.topics[2]); + (eventData.collateral, eventData.repayAssets, eventData.yieldBalance) = + abi.decode(log.data, (address, uint256, uint256)); + } +} + +/// @title EulerEVaultAssertion +/// @author Phylax Systems +/// @notice Example assertion bundle for Euler Vault Kit EVaults. +/// @dev Covers five EVK-specific properties: +/// - changed user storage stays consistent with direct account views +/// - per-call virtual share price cannot drop except for same-call debt socialization +/// - liquidations stay within the exact pre-call `checkLiquidation()` quote +/// - cumulative underlying inflow hard-pauses after the threshold trips +/// - cumulative underlying outflow response: liquidation-only at 10%, full pause at 15% in 24h +contract EulerEVaultAssertion is + EulerUserStorageAccountingMixin, + EulerPerCallSharePriceMixin, + EulerLiquidationQuoteMixin, + EulerEVaultCircuitBreakerMixin +{ + /// @param asset_ Underlying asset of the EVault adopter, used by the flow watchers. + /// @param sharePriceToleranceBps_ Maximum tolerated call-level virtual share-price decrease. + /// @param inflowThresholdBps_ Rolling-window inflow cap as basis points of TVL. + /// @param inflowWindowDuration_ Rolling-window inflow duration in seconds. + constructor( + address asset_, + uint256 sharePriceToleranceBps_, + uint256 inflowThresholdBps_, + uint256 inflowWindowDuration_ + ) + EulerPerCallSharePriceMixin(sharePriceToleranceBps_) + EulerEVaultCircuitBreakerMixin(asset_, inflowThresholdBps_, inflowWindowDuration_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers all EVK example assertion triggers. + /// @dev Intended for factory-scoped installs where the assertion adopter is the concrete EVault. + function triggers() external view override { + _registerUserStorageAccounting(); + _registerPerCallSharePrice(); + _registerLiquidationQuote(); + _registerCircuitBreakers(); + } +} + +/// @title EulerUserStorageAccountingAssertion +/// @author Phylax Systems +/// @notice Standalone EVK user-storage/account-view consistency assertion for incremental rollout. +contract EulerUserStorageAccountingAssertion is EulerUserStorageAccountingMixin { + constructor() { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers the tx-end EVK user storage consistency assertion. + function triggers() external view override { + _registerUserStorageAccounting(); + } +} + +/// @title EulerPerCallSharePriceAssertion +/// @author Phylax Systems +/// @notice Standalone EVK per-call virtual share-price assertion for incremental rollout. +contract EulerPerCallSharePriceAssertion is EulerPerCallSharePriceMixin { + constructor(uint256 sharePriceToleranceBps_) EulerPerCallSharePriceMixin(sharePriceToleranceBps_) { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers EVK call-level share-price triggers. + function triggers() external view override { + _registerPerCallSharePrice(); + } +} + +/// @title EulerLiquidationQuoteAssertion +/// @author Phylax Systems +/// @notice Standalone EVK liquidation quote assertion for incremental rollout. +contract EulerLiquidationQuoteAssertion is EulerLiquidationQuoteMixin { + constructor() { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers EVK liquidation quote triggers. + function triggers() external view override { + _registerLiquidationQuote(); + } +} diff --git a/examples/euler/src/EulerEVaultCircuitBreakerAssertion.sol b/examples/euler/src/EulerEVaultCircuitBreakerAssertion.sol new file mode 100644 index 0000000..421f3a7 --- /dev/null +++ b/examples/euler/src/EulerEVaultCircuitBreakerAssertion.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {EulerEVaultBase} from "./EulerEVaultHelpers.sol"; +import {IEulerEVaultLike} from "./EulerEVaultInterfaces.sol"; + +/// @title EulerEVaultCircuitBreakerMixin +/// @author Phylax Systems +/// @notice Registers EVK asset-flow circuit breakers for the underlying asset. +/// @dev The policy is deliberately small: +/// - excessive inflow hard-blocks the transaction +/// - 10% cumulative outflow in 24h requires a successful liquidation +/// - 15% cumulative outflow in 24h hard-blocks the transaction +abstract contract EulerEVaultCircuitBreakerMixin is EulerEVaultBase { + uint256 public constant LIQUIDATION_ONLY_OUTFLOW_THRESHOLD_BPS = 1_000; + uint256 public constant FULL_PAUSE_OUTFLOW_THRESHOLD_BPS = 1_500; + uint256 public constant OUTFLOW_PAUSE_WINDOW_DURATION = 24 hours; + + address public immutable flowAsset; + uint256 public immutable inflowThresholdBps; + uint256 public immutable inflowWindowDuration; + + constructor(address asset_, uint256 inflowThresholdBps_, uint256 inflowWindowDuration_) { + flowAsset = asset_; + inflowThresholdBps = inflowThresholdBps_; + inflowWindowDuration = inflowWindowDuration_; + } + + function _registerCircuitBreakers() internal view { + watchCumulativeInflow( + flowAsset, inflowThresholdBps, inflowWindowDuration, this.assertPauseAfterExcessiveInflow.selector + ); + watchCumulativeOutflow( + flowAsset, + LIQUIDATION_ONLY_OUTFLOW_THRESHOLD_BPS, + OUTFLOW_PAUSE_WINDOW_DURATION, + this.assertLiquidationOnlyAfterLargeOutflow.selector + ); + watchCumulativeOutflow( + flowAsset, + FULL_PAUSE_OUTFLOW_THRESHOLD_BPS, + OUTFLOW_PAUSE_WINDOW_DURATION, + this.assertPauseAfterCriticalOutflow.selector + ); + } + + /// @notice Fully pauses the EVault when cumulative underlying inflow breaches the configured threshold. + /// @dev This hard breaker reverts every transaction that still breaches the inflow window. + function assertPauseAfterExcessiveInflow() external view { + PhEvm.InflowContext memory ctx = ph.inflowContext(); + require(ctx.token == flowAsset, "EulerEVault: wrong inflow token context"); + + revert("EulerEVault: excessive inflow pause"); + } + + /// @notice Enforces liquidation-only mode after 10% cumulative outflow in the rolling window. + /// @dev A failure means the transaction breached the 10% outflow tier without a successful + /// liquidation call. + function assertLiquidationOnlyAfterLargeOutflow() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + require(ctx.token == flowAsset, "EulerEVault: wrong outflow token context"); + + require( + _matchingCalls(_vault(), IEulerEVaultLike.liquidate.selector, 1).length != 0, + "EulerEVault: liquidation required" + ); + } + + /// @notice Fully pauses the EVault after 15% cumulative outflow in 24 hours. + /// @dev This hard breaker reverts any transaction that still breaches the critical outflow tier. + function assertPauseAfterCriticalOutflow() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + require(ctx.token == flowAsset, "EulerEVault: wrong critical outflow token"); + + revert("EulerEVault: critical outflow pause"); + } +} + +/// @title EulerEVaultCircuitBreakerAssertion +/// @author Phylax Systems +/// @notice Standalone EVK asset-flow circuit breaker for incremental rollout. +contract EulerEVaultCircuitBreakerAssertion is EulerEVaultCircuitBreakerMixin { + constructor(address asset_, uint256 inflowThresholdBps_, uint256 inflowWindowDuration_) + EulerEVaultCircuitBreakerMixin(asset_, inflowThresholdBps_, inflowWindowDuration_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers inflow hard-pause plus the two outflow response tiers. + function triggers() external view override { + _registerCircuitBreakers(); + } +} diff --git a/examples/euler/src/EulerEVaultHelpers.sol b/examples/euler/src/EulerEVaultHelpers.sol new file mode 100644 index 0000000..b6c1f04 --- /dev/null +++ b/examples/euler/src/EulerEVaultHelpers.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {IEulerEVaultLike} from "./EulerEVaultInterfaces.sol"; + +/// @title EulerEVaultBase +/// @author Phylax Systems +/// @notice Shared helpers for factory-scoped Euler Vault Kit EVault assertions. +/// @dev These examples are intended to be installed on each concrete EVault adopter. The +/// monitored vault is therefore read from `ph.getAssertionAdopter()` at assertion time. +abstract contract EulerEVaultBase is Assertion { + /// @notice EVK virtual deposit used by `ConversionHelpers.conversionTotals()`. + uint256 internal constant VIRTUAL_DEPOSIT_AMOUNT = 1e6; + + /// @notice Base slot for `vaultStorage.users` in the checked EVK layout. + /// @dev EVK storage is `initialized` at slot 0, `snapshot` at slot 1, and + /// `vaultStorage` at slot 2. `VaultStorage.users` is field 11, so the + /// mapping base slot is `2 + 11 = 13`. + bytes32 internal constant USERS_MAPPING_SLOT = bytes32(uint256(13)); + + bytes32 internal constant DEBT_SOCIALIZED_SIG = keccak256("DebtSocialized(address,uint256)"); + bytes32 internal constant LIQUIDATE_SIG = keccak256("Liquidate(address,address,address,uint256,uint256)"); + + uint256 internal constant SHARES_MASK = 0x000000000000000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFF; + uint256 internal constant OWED_MASK = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000000000000000000000000000; + uint256 internal constant OWED_OFFSET = 112; + + function _vault() internal view returns (address) { + return ph.getAssertionAdopter(); + } + + function _totalAssetsAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(IEulerEVaultLike.totalAssets, ()), fork); + } + + function _totalSupplyAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(IEulerEVaultLike.totalSupply, ()), fork); + } + + function _debtOfExactAt(address vault, address account, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(IEulerEVaultLike.debtOfExact, (account)), fork); + } + + function _topicAddress(bytes32 topic) internal pure returns (address) { + return address(uint160(uint256(topic))); + } + + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "EulerEVault: short calldata"); + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } + + function _eventAmount(bytes memory data) internal pure returns (uint256 amount) { + require(data.length >= 32, "EulerEVault: malformed event data"); + amount = abi.decode(data, (uint256)); + } + + function _rawShares(bytes32 packedUserData) internal pure returns (uint256) { + return uint256(packedUserData) & SHARES_MASK; + } + + function _rawOwed(bytes32 packedUserData) internal pure returns (uint256) { + return (uint256(packedUserData) & OWED_MASK) >> OWED_OFFSET; + } + + function _keyToAddress(bytes memory key) internal pure returns (address account) { + require(key.length == 32, "EulerEVault: non-address mapping key"); + account = abi.decode(key, (address)); + } +} diff --git a/examples/euler/src/EulerEVaultInterfaces.sol b/examples/euler/src/EulerEVaultInterfaces.sol new file mode 100644 index 0000000..22de528 --- /dev/null +++ b/examples/euler/src/EulerEVaultInterfaces.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @title IEulerEVaultLike +/// @author Phylax Systems +/// @notice Minimal Euler Vault Kit EVault surface needed by the example assertions. +/// @dev Selectors match the EVK share-token, ERC-4626, borrowing, and liquidation modules. +interface IEulerEVaultLike { + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function transferFromMax(address from, address to) external returns (bool); + + function asset() external view returns (address); + function totalAssets() external view returns (uint256); + function deposit(uint256 amount, address receiver) external returns (uint256); + function mint(uint256 amount, address receiver) external returns (uint256); + function withdraw(uint256 amount, address receiver, address owner) external returns (uint256); + function redeem(uint256 amount, address receiver, address owner) external returns (uint256); + function skim(uint256 amount, address receiver) external returns (uint256); + + function cash() external view returns (uint256); + function totalBorrows() external view returns (uint256); + function debtOf(address account) external view returns (uint256); + function debtOfExact(address account) external view returns (uint256); + function dToken() external view returns (address); + function borrow(uint256 amount, address receiver) external returns (uint256); + function repay(uint256 amount, address receiver) external returns (uint256); + function repayWithShares(uint256 amount, address receiver) external returns (uint256 shares, uint256 debt); + function pullDebt(uint256 amount, address from) external; + function flashLoan(uint256 amount, bytes calldata data) external; + function touch() external; + + function checkLiquidation(address liquidator, address violator, address collateral) + external + view + returns (uint256 maxRepay, uint256 maxYield); + function liquidate(address violator, address collateral, uint256 repayAssets, uint256 minYieldBalance) external; +} diff --git a/examples/euler/src/EulerEVaultSandwichAssertion.sol b/examples/euler/src/EulerEVaultSandwichAssertion.sol new file mode 100644 index 0000000..687ab1f --- /dev/null +++ b/examples/euler/src/EulerEVaultSandwichAssertion.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {IEulerEVaultLike} from "./EulerEVaultInterfaces.sol"; +import {EulerEVaultSandwichBase, IEulerEVaultSandwichLike} from "./EulerEVaultSandwichHelpers.sol"; + +/// @title EulerERC4626CallSandwichAssertion +/// @author Phylax Systems +/// @notice "sandwich" pattern for EVK ERC-4626 entry and exit calls. +/// @dev For each successful deposit/mint/withdraw/redeem, the assertion compares: +/// 1. calldata decoded from the exact triggered call, +/// 2. preview output from immediately before that same call, +/// 3. return data and logs emitted after execution of that same call frame. +/// This defends the intra-call expectation that the pre-call preview and post-call result +/// match; it does not claim to prevent unrelated state changes before the transaction lands. +contract EulerERC4626CallSandwichAssertion is EulerEVaultSandwichBase { + constructor() { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Run the same call-sandwich invariant for each ERC-4626 mutator. + /// @dev `assertErc4626CallWasHonest` once per successful matching EVault call, + /// with `ph.context()` pointing at that exact call frame. + function triggers() external view override { + registerFnCallTrigger(this.assertErc4626CallWasHonest.selector, IEulerEVaultLike.deposit.selector); + registerFnCallTrigger(this.assertErc4626CallWasHonest.selector, IEulerEVaultLike.mint.selector); + registerFnCallTrigger(this.assertErc4626CallWasHonest.selector, IEulerEVaultLike.withdraw.selector); + registerFnCallTrigger(this.assertErc4626CallWasHonest.selector, IEulerEVaultLike.redeem.selector); + } + + /// @notice Checks that the triggered ERC-4626 call matches its immediate pre-call preview and same-call event. + /// @dev A failure means the EVault return value diverged from its pre-call preview, or the event emitted for + /// the call frame did not agree with the operation's calldata and post-call return value. + function assertErc4626CallWasHonest() external view { + address vault = _vault(); + PhEvm.TriggerContext memory ctx = ph.context(); + + // Shared V2 call-frame reads: exact calldata and exact return data for the triggering call. + bytes memory input = ph.callinputAt(ctx.callStart); + uint256 actualReturn = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); + + if (ctx.selector == IEulerEVaultLike.deposit.selector) { + // deposit(assets, receiver): pre-call previewDeposit must match the post-call shares returned. + (uint256 assets,) = abi.decode(_stripSelector(input), (uint256, address)); + if (assets != type(uint256).max) { + uint256 expectedShares = _readUintAt( + vault, abi.encodeCall(IEulerEVaultSandwichLike.previewDeposit, (assets)), _preCall(ctx.callStart) + ); + require(actualReturn == expectedShares, "EulerEVault: deposit return != pre-call preview"); + } + + // Same-call Deposit event must report the requested assets and post-call returned shares. + _assertDepositLogForCall(vault, ctx.callStart, assets, actualReturn, assets == type(uint256).max); + return; + } + + if (ctx.selector == IEulerEVaultLike.mint.selector) { + // mint(shares, receiver): shares are the input, returned assets must match pre-call previewMint. + (uint256 shares,) = abi.decode(_stripSelector(input), (uint256, address)); + uint256 expectedAssets = _readUintAt( + vault, abi.encodeCall(IEulerEVaultSandwichLike.previewMint, (shares)), _preCall(ctx.callStart) + ); + require(actualReturn == expectedAssets, "EulerEVault: mint return != pre-call preview"); + + // mint must report returned assets and requested shares. + _assertDepositLogForCall(vault, ctx.callStart, actualReturn, shares, false); + return; + } + + if (ctx.selector == IEulerEVaultLike.withdraw.selector) { + // withdraw(assets, receiver, owner): assets are the input, returned shares match pre-call previewWithdraw. + (uint256 assets,,) = abi.decode(_stripSelector(input), (uint256, address, address)); + uint256 expectedShares = _readUintAt( + vault, abi.encodeCall(IEulerEVaultSandwichLike.previewWithdraw, (assets)), _preCall(ctx.callStart) + ); + require(actualReturn == expectedShares, "EulerEVault: withdraw return != pre-call preview"); + + // Same-call Withdraw event must report requested assets and returned burned shares. + _assertWithdrawLogForCall(vault, ctx.callStart, assets, actualReturn, false); + return; + } + + if (ctx.selector == IEulerEVaultLike.redeem.selector) { + // redeem(shares, receiver, owner): shares are the input, returned assets match pre-call previewRedeem. + (uint256 shares,,) = abi.decode(_stripSelector(input), (uint256, address, address)); + if (shares != type(uint256).max) { + uint256 expectedAssets = _readUintAt( + vault, abi.encodeCall(IEulerEVaultSandwichLike.previewRedeem, (shares)), _preCall(ctx.callStart) + ); + require(actualReturn == expectedAssets, "EulerEVault: redeem return != pre-call preview"); + } + + // Same-call Withdraw event must report returned assets and requested redeemed shares. + _assertWithdrawLogForCall(vault, ctx.callStart, actualReturn, shares, shares == type(uint256).max); + return; + } + + revert("EulerEVault: unsupported selector"); + } +} diff --git a/examples/euler/src/EulerEVaultSandwichHelpers.sol b/examples/euler/src/EulerEVaultSandwichHelpers.sol new file mode 100644 index 0000000..8e6ec5e --- /dev/null +++ b/examples/euler/src/EulerEVaultSandwichHelpers.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {EulerEVaultBase} from "./EulerEVaultHelpers.sol"; +import {IEulerEVaultLike} from "./EulerEVaultInterfaces.sol"; + +/// @notice Minimal ERC-4626 preview surface used by the Euler sandwich assertion. +interface IEulerEVaultSandwichLike is IEulerEVaultLike { + function previewDeposit(uint256 assets) external view returns (uint256); + function previewMint(uint256 shares) external view returns (uint256); + function previewWithdraw(uint256 assets) external view returns (uint256); + function previewRedeem(uint256 shares) external view returns (uint256); +} + +/// @title EulerEVaultSandwichBase +/// @author Phylax Systems +/// @notice Shared event-log helpers for EVK ERC-4626 call sandwich assertions. +abstract contract EulerEVaultSandwichBase is EulerEVaultBase { + bytes32 internal constant DEPOSIT_SIG = keccak256("Deposit(address,address,uint256,uint256)"); + bytes32 internal constant WITHDRAW_SIG = keccak256("Withdraw(address,address,address,uint256,uint256)"); + + function _assertDepositLogForCall( + address vault, + uint256 callId, + uint256 expectedAssets, + uint256 expectedShares, + bool assetAmountWasDynamic + ) internal view { + PhEvm.LogQuery memory query = PhEvm.LogQuery({emitter: vault, signature: DEPOSIT_SIG}); + PhEvm.Log[] memory logs = ph.getLogsForCall(query, callId); + + if (expectedShares == 0) { + require(logs.length == 0, "EulerEVault: zero deposit emitted event"); + return; + } + + require(logs.length == 1, "EulerEVault: expected one Deposit event"); + (uint256 assets, uint256 shares) = abi.decode(logs[0].data, (uint256, uint256)); + if (!assetAmountWasDynamic) { + require(assets == expectedAssets, "EulerEVault: Deposit assets mismatch"); + } + require(shares == expectedShares, "EulerEVault: Deposit shares mismatch"); + } + + function _assertWithdrawLogForCall( + address vault, + uint256 callId, + uint256 expectedAssets, + uint256 expectedShares, + bool shareAmountWasDynamic + ) internal view { + PhEvm.LogQuery memory query = PhEvm.LogQuery({emitter: vault, signature: WITHDRAW_SIG}); + PhEvm.Log[] memory logs = ph.getLogsForCall(query, callId); + + if (expectedAssets == 0 || expectedShares == 0) { + require(logs.length == 0, "EulerEVault: zero withdraw emitted event"); + return; + } + + require(logs.length == 1, "EulerEVault: expected one Withdraw event"); + (uint256 assets, uint256 shares) = abi.decode(logs[0].data, (uint256, uint256)); + require(assets == expectedAssets, "EulerEVault: Withdraw assets mismatch"); + if (!shareAmountWasDynamic) { + require(shares == expectedShares, "EulerEVault: Withdraw shares mismatch"); + } + } +} diff --git a/examples/nado/README.md b/examples/nado/README.md new file mode 100644 index 0000000..c4b3828 --- /dev/null +++ b/examples/nado/README.md @@ -0,0 +1,15 @@ +# nado examples + +Assertion examples and supporting helpers extracted from the `ink/assertions` branch. + +## Build + +```sh +FOUNDRY_PROFILE=nado forge build +``` + +## Files + +- NadoClearinghouseAssertion.sol +- NadoHelpers.sol +- NadoInterfaces.sol diff --git a/examples/nado/src/NadoClearinghouseAssertion.sol b/examples/nado/src/NadoClearinghouseAssertion.sol new file mode 100644 index 0000000..565a263 --- /dev/null +++ b/examples/nado/src/NadoClearinghouseAssertion.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {NadoHelpers} from "./NadoHelpers.sol"; +import {INadoClearinghouseLike, INadoEndpointLike} from "./NadoInterfaces.sol"; + +/// @title NadoClearinghouseAssertion +/// @author Phylax Systems +/// @notice Protects the Nado clearinghouse custody and collateral-accounting boundary. +/// @dev The bundle intentionally focuses on a small set of high-signal checks: +/// - successful Clearinghouse deposits must credit SpotEngine collateral by the scaled token amount +/// - successful Clearinghouse withdrawals must debit SpotEngine collateral and move exactly the native token amount +/// - quote-asset flow circuit breakers override protocol-level limits during abnormal daily flow windows +contract NadoClearinghouseAssertion is NadoHelpers { + uint256 public immutable quoteInflowPauseThresholdBps; + uint256 public immutable quoteOutflowWithdrawalOnlyThresholdBps; + uint256 public immutable quoteOutflowPauseThresholdBps; + uint256 public immutable flowWindowDuration; + + constructor( + address endpoint_, + address clearinghouse_, + address spotEngine_, + address quoteAsset_, + address withdrawPool_, + uint256 collateralDeltaToleranceX18_, + uint256 quoteInflowPauseThresholdBps_, + uint256 quoteOutflowWithdrawalOnlyThresholdBps_, + uint256 quoteOutflowPauseThresholdBps_, + uint256 flowWindowDuration_ + ) NadoHelpers(endpoint_, clearinghouse_, spotEngine_, quoteAsset_, withdrawPool_, collateralDeltaToleranceX18_) { + registerAssertionSpec(AssertionSpec.Reshiram); + + quoteInflowPauseThresholdBps = quoteInflowPauseThresholdBps_; + quoteOutflowWithdrawalOnlyThresholdBps = quoteOutflowWithdrawalOnlyThresholdBps_; + quoteOutflowPauseThresholdBps = quoteOutflowPauseThresholdBps_; + flowWindowDuration = flowWindowDuration_; + } + + /// @notice Registers Nado collateral-accounting checks and quote-asset flow breakers. + function triggers() external view override { + registerFnCallTrigger( + this.assertDepositCreditsSpotBalance.selector, INadoClearinghouseLike.depositCollateral.selector + ); + registerFnCallTrigger( + this.assertWithdrawalDebitsSpotBalance.selector, INadoClearinghouseLike.withdrawCollateral.selector + ); + registerFnCallTrigger( + this.assertRebalanceXWithdrawDebitsSpotBalance.selector, INadoClearinghouseLike.rebalanceXWithdraw.selector + ); + + watchCumulativeInflow( + quoteAsset, quoteInflowPauseThresholdBps, flowWindowDuration, this.assertQuoteInflowPaused.selector + ); + watchCumulativeOutflow( + quoteAsset, + quoteOutflowWithdrawalOnlyThresholdBps, + flowWindowDuration, + this.assertQuoteOutflowIsWithdrawalPath.selector + ); + watchCumulativeOutflow( + quoteAsset, quoteOutflowPauseThresholdBps, flowWindowDuration, this.assertQuoteOutflowPaused.selector + ); + } + + /// @notice Checks that a successful `Clearinghouse.depositCollateral` credits the sender's spot balance. + /// @dev A failure means the custody-to-ledger conversion credited too little, too much, or the wrong product. + function assertDepositCreditsSpotBalance() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + INadoClearinghouseLike.DepositCollateral memory txn = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (INadoClearinghouseLike.DepositCollateral)); + + address productToken = _productTokenAt(txn.productId, afterFork); + uint8 decimals = _tokenDecimalsAt(productToken, afterFork); + int256 expectedDelta = _realizedAmountX18(txn.amount, decimals); + int256 actualDelta = int256(_spotBalanceAt(txn.productId, txn.sender, afterFork)) + - int256(_spotBalanceAt(txn.productId, txn.sender, beforeFork)); + + _assertApproxEq(actualDelta, expectedDelta, collateralDeltaToleranceX18, "Nado: deposit spot credit mismatch"); + } + + /// @notice Checks that `Clearinghouse.withdrawCollateral` debits spot balance and releases exact custody. + /// @dev A failure means a withdrawal produced an accounting debit/token outflow mismatch. + function assertWithdrawalDebitsSpotBalance() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + (bytes32 sender, uint32 productId, uint128 amount,,) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (bytes32, uint32, uint128, address, uint64)); + + _assertSpotDebitAndCustodyOutflow(sender, productId, amount, beforeFork, afterFork); + } + + /// @notice Checks that `rebalanceXWithdraw` debits the X account and releases exact custody. + /// @dev This covers the public X-account withdrawal path because it calls `withdrawCollateral` internally. + function assertRebalanceXWithdrawDebitsSpotBalance() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + (bytes memory transaction,) = abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (bytes, uint64)); + INadoClearinghouseLike.RebalanceXWithdraw memory txn = + abi.decode(_stripTransactionType(transaction), (INadoClearinghouseLike.RebalanceXWithdraw)); + + _assertSpotDebitAndCustodyOutflow(X_ACCOUNT, txn.productId, txn.amount, beforeFork, afterFork); + } + + function _assertSpotDebitAndCustodyOutflow( + bytes32 sender, + uint32 productId, + uint128 amount, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + address productToken = _productTokenAt(productId, beforeFork); + uint8 decimals = _tokenDecimalsAt(productToken, beforeFork); + int256 expectedDelta = -_realizedAmountX18(amount, decimals); + int256 actualDelta = int256(_spotBalanceAt(productId, sender, afterFork)) + - int256(_spotBalanceAt(productId, sender, beforeFork)); + + _assertApproxEq(actualDelta, expectedDelta, collateralDeltaToleranceX18, "Nado: withdrawal spot debit mismatch"); + _assertTokenDelta( + _clearinghouseTokenBalanceAt(productToken, beforeFork), + _clearinghouseTokenBalanceAt(productToken, afterFork), + amount, + "Nado: withdrawal custody outflow mismatch" + ); + } + + /// @notice Hard-pauses the clearinghouse after abnormal quote inflow exceeds the configured window cap. + /// @dev This is intentionally stricter than protocol min-deposit checks: the breached flow window reverts. + function assertQuoteInflowPaused() external view { + PhEvm.InflowContext memory ctx = ph.inflowContext(); + require(ctx.token == quoteAsset, "Nado: wrong quote inflow context"); + + revert("Nado: quote inflow circuit breaker"); + } + + /// @notice Allows large quote outflow only when the transaction used an explicit Nado withdrawal path. + /// @dev A failure means the quote outflow exceeded the warning tier without a clearinghouse withdrawal action. + function assertQuoteOutflowIsWithdrawalPath() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + require(ctx.token == quoteAsset, "Nado: wrong quote outflow context"); + + require(_hasWithdrawalPathCall(), "Nado: quote outflow requires withdrawal path"); + } + + /// @notice Hard-pauses the clearinghouse after critical quote outflow exceeds the configured window cap. + /// @dev This overrides the protocol's normal withdrawal and fast-withdrawal limits during severe outflow. + function assertQuoteOutflowPaused() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + require(ctx.token == quoteAsset, "Nado: wrong critical quote outflow context"); + + revert("Nado: quote outflow circuit breaker"); + } + + function _hasWithdrawalPathCall() internal view returns (bool) { + return _matchingCalls(clearinghouse, INadoClearinghouseLike.withdrawCollateral.selector, 1).length != 0 + || _matchingCalls(clearinghouse, INadoClearinghouseLike.withdrawInsurance.selector, 1).length != 0 + || _matchingCalls(clearinghouse, INadoClearinghouseLike.rebalanceXWithdraw.selector, 1).length != 0 + || _matchingCalls(endpoint, INadoEndpointLike.submitSlowModeTransaction.selector, 1).length != 0; + } +} diff --git a/examples/nado/src/NadoHelpers.sol b/examples/nado/src/NadoHelpers.sol new file mode 100644 index 0000000..7b8a6fb --- /dev/null +++ b/examples/nado/src/NadoHelpers.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {INadoErc20MetadataLike, INadoSpotEngineLike} from "./NadoInterfaces.sol"; + +/// @title NadoHelpers +/// @author Phylax Systems +/// @notice Shared fork-aware reads and calldata decoding helpers for Nado assertions. +abstract contract NadoHelpers is Assertion { + bytes32 internal constant X_ACCOUNT = bytes32(uint256(1)); + uint256 internal constant MAX_DECIMALS = 18; + uint256 internal constant INT128_MAX = uint256(uint128(type(int128).max)); + + address public immutable endpoint; + address public immutable clearinghouse; + address public immutable spotEngine; + address public immutable quoteAsset; + address public immutable withdrawPool; + uint256 public immutable collateralDeltaToleranceX18; + + constructor( + address endpoint_, + address clearinghouse_, + address spotEngine_, + address quoteAsset_, + address withdrawPool_, + uint256 collateralDeltaToleranceX18_ + ) { + endpoint = endpoint_; + clearinghouse = clearinghouse_; + spotEngine = spotEngine_; + quoteAsset = quoteAsset_; + withdrawPool = withdrawPool_; + collateralDeltaToleranceX18 = collateralDeltaToleranceX18_; + } + + function _spotBalanceAt(uint32 productId, bytes32 subaccount, PhEvm.ForkId memory fork) + internal + view + returns (int128 amount) + { + INadoSpotEngineLike.Balance memory balance = abi.decode( + _viewAt(spotEngine, abi.encodeCall(INadoSpotEngineLike.getBalance, (productId, subaccount)), fork), + (INadoSpotEngineLike.Balance) + ); + return balance.amount; + } + + function _productTokenAt(uint32 productId, PhEvm.ForkId memory fork) internal view returns (address token) { + INadoSpotEngineLike.Config memory config = abi.decode( + _viewAt(spotEngine, abi.encodeCall(INadoSpotEngineLike.getConfig, (productId)), fork), + (INadoSpotEngineLike.Config) + ); + return config.token; + } + + function _tokenDecimalsAt(address token, PhEvm.ForkId memory fork) internal view returns (uint8 decimals) { + return _readUint8At(token, abi.encodeCall(INadoErc20MetadataLike.decimals, ()), fork); + } + + function _clearinghouseTokenBalanceAt(address token, PhEvm.ForkId memory fork) + internal + view + returns (uint256 balance) + { + return _readBalanceAt(token, clearinghouse, fork); + } + + function _realizedAmountX18(uint128 amount, uint8 decimals) internal pure returns (int256 realizedAmount) { + require(decimals <= MAX_DECIMALS, "Nado: unsupported token decimals"); + + uint256 scale = 10 ** (MAX_DECIMALS - decimals); + uint256 scaledAmount = uint256(amount) * scale; + require(scaledAmount <= INT128_MAX, "Nado: realized amount overflow"); + + // casting to int256 is safe because scaledAmount is bounded by INT128_MAX above. + // forge-lint: disable-next-line(unsafe-typecast) + return int256(scaledAmount); + } + + function _assertApproxEq(int256 actual, int256 expected, uint256 tolerance, string memory reason) internal pure { + require(tolerance <= INT128_MAX, "Nado: tolerance too large"); + + // casting to int256 is safe because tolerance is bounded by INT128_MAX above. + // forge-lint: disable-next-line(unsafe-typecast) + int256 signedTolerance = int256(tolerance); + int256 lower = expected - signedTolerance; + int256 upper = expected + signedTolerance; + require(actual >= lower && actual <= upper, reason); + } + + function _assertTokenDelta(uint256 preBalance, uint256 postBalance, uint128 amount, string memory reason) + internal + pure + { + require(preBalance >= postBalance, reason); + require(preBalance - postBalance == uint256(amount), reason); + } + + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "Nado: short calldata"); + + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } + + function _stripTransactionType(bytes memory transaction) internal pure returns (bytes memory args) { + require(transaction.length >= 1, "Nado: short transaction"); + + args = new bytes(transaction.length - 1); + for (uint256 i; i < args.length; ++i) { + args[i] = transaction[i + 1]; + } + } + + function _viewFailureMessage() internal pure override returns (string memory) { + return "Nado: staticcallAt failed"; + } +} diff --git a/examples/nado/src/NadoInterfaces.sol b/examples/nado/src/NadoInterfaces.sol new file mode 100644 index 0000000..fd70d1c --- /dev/null +++ b/examples/nado/src/NadoInterfaces.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +interface INadoEndpointLike { + function depositCollateral(bytes12 subaccountName, uint32 productId, uint128 amount) external; + + function depositCollateralWithReferral( + bytes32 subaccount, + uint32 productId, + uint128 amount, + string calldata referralCode + ) external; + + function submitSlowModeTransaction(bytes calldata transaction) external; +} + +interface INadoClearinghouseLike { + struct DepositCollateral { + bytes32 sender; + uint32 productId; + uint128 amount; + } + + struct RebalanceXWithdraw { + uint32 productId; + uint128 amount; + address sendTo; + } + + function depositCollateral(DepositCollateral calldata txn) external; + + function withdrawCollateral(bytes32 sender, uint32 productId, uint128 amount, address sendTo, uint64 idx) external; + + function withdrawInsurance(bytes calldata transaction, uint64 idx) external; + + function rebalanceXWithdraw(bytes calldata transaction, uint64 nSubmissions) external; + + function depositInsurance(bytes calldata transaction) external; + + function getEngineByType(uint8 engineType) external view returns (address); + + function getQuote() external view returns (address); + + function getWithdrawPool() external view returns (address); +} + +interface INadoSpotEngineLike { + struct Config { + address token; + int128 interestInflectionUtilX18; + int128 interestFloorX18; + int128 interestSmallCapX18; + int128 interestLargeCapX18; + int128 withdrawFeeX18; + int128 minDepositRateX18; + } + + struct State { + int128 cumulativeDepositsMultiplierX18; + int128 cumulativeBorrowsMultiplierX18; + int128 totalDepositsNormalized; + int128 totalBorrowsNormalized; + } + + struct Balance { + int128 amount; + } + + function getConfig(uint32 productId) external view returns (Config memory); + + function getBalance(uint32 productId, bytes32 subaccount) external view returns (Balance memory); +} + +interface INadoErc20MetadataLike { + function decimals() external view returns (uint8); +} diff --git a/examples/royco/README.md b/examples/royco/README.md new file mode 100644 index 0000000..1ac94bd --- /dev/null +++ b/examples/royco/README.md @@ -0,0 +1,19 @@ +# royco examples + +Assertion examples and supporting helpers extracted from the `royco-dawn` branch. + +## Build + +```sh +FOUNDRY_PROFILE=royco forge build +``` + +## Files + +- RoycoHelpers.sol +- RoycoKernelAccountingAssertion.sol +- RoycoKernelAssertion.sol +- RoycoKernelCumulativeFlowAssertion.sol +- RoycoKernelCumulativeOutflowAssertion.sol +- RoycoVaultTrancheAssertion.sol +- RoycoVaultTrancheOperationAssertion.sol diff --git a/examples/royco/src/RoycoHelpers.sol b/examples/royco/src/RoycoHelpers.sol new file mode 100644 index 0000000..f3495ea --- /dev/null +++ b/examples/royco/src/RoycoHelpers.sol @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +/// @notice Royco market states copied locally for ABI-compatible assertion reads. +enum RoycoMarketState { + PERPETUAL, + FIXED_TERM +} + +/// @notice Royco tranche identifiers copied locally for ABI-compatible assertion reads. +enum RoycoTrancheType { + SENIOR, + JUNIOR +} + +/// @notice ABI-compatible copy of Royco's AssetClaims struct. +struct RoycoAssetClaims { + uint256 stAssets; + uint256 jtAssets; + uint256 nav; +} + +/// @notice ABI-compatible copy of Royco's synced accounting state struct. +struct RoycoSyncedAccountingState { + RoycoMarketState marketState; + uint256 stRawNAV; + uint256 jtRawNAV; + uint256 stEffectiveNAV; + uint256 jtEffectiveNAV; + uint256 stImpermanentLoss; + uint256 jtImpermanentLoss; + uint256 stProtocolFeeAccrued; + uint256 jtProtocolFeeAccrued; + uint256 utilizationWAD; + uint32 fixedTermEndTimestamp; + uint256 coverageWAD; + uint256 betaWAD; + uint256 liquidationUtilizationWAD; +} + +/// @notice ABI-compatible copy of Royco's accountant storage struct. +struct RoycoAccountantState { + RoycoMarketState lastMarketState; + uint24 fixedTermDurationSeconds; + uint32 fixedTermEndTimestamp; + uint64 coverageWAD; + uint96 betaWAD; + uint64 stProtocolFeeWAD; + uint64 jtProtocolFeeWAD; + uint64 yieldShareProtocolFeeWAD; + uint256 liquidationUtilizationWAD; + address ydm; + uint256 lastSTRawNAV; + uint256 lastJTRawNAV; + uint256 lastSTEffectiveNAV; + uint256 lastJTEffectiveNAV; + uint256 lastSTImpermanentLoss; + uint256 lastJTImpermanentLoss; + uint192 twJTYieldShareAccruedWAD; + uint32 lastAccrualTimestamp; + uint32 lastDistributionTimestamp; + uint256 stNAVDustTolerance; + uint256 jtNAVDustTolerance; +} + +/// @notice ABI-compatible copy of Royco's kernel state view struct. +struct RoycoKernelStateView { + bool isBlacklistEnabled; + address protocolFeeRecipient; + uint64 stSelfLiquidationBonusWAD; + uint256 stOwnedYieldBearingAssets; + uint256 jtOwnedYieldBearingAssets; +} + +/// @title IRoycoAccountant +/// @author Phylax Systems +/// @notice Local Royco accountant surface needed by the protection suite. +interface IRoycoAccountant { + function getState() external pure returns (RoycoAccountantState memory state); + function previewSyncTrancheAccounting(uint256 stRawNAV, uint256 jtRawNAV) + external + view + returns (RoycoSyncedAccountingState memory state); +} + +/// @title IRoycoKernel +/// @author Phylax Systems +/// @notice Local Royco kernel surface needed by the protection suite. +interface IRoycoKernel { + function SENIOR_TRANCHE() external view returns (address seniorTranche); + function ST_ASSET() external view returns (address stAsset); + function JUNIOR_TRANCHE() external view returns (address juniorTranche); + function JT_ASSET() external view returns (address jtAsset); + function ACCOUNTANT() external view returns (address accountant); + + function getState() external view returns (RoycoKernelStateView memory state); + + function syncTrancheAccounting() external returns (RoycoSyncedAccountingState memory state); + function previewSyncTrancheAccounting(RoycoTrancheType trancheType) + external + view + returns (RoycoSyncedAccountingState memory state, RoycoAssetClaims memory claims, uint256 totalTrancheShares); + + function stPreviewDeposit(uint256 assets) + external + view + returns (RoycoSyncedAccountingState memory stateBeforeDeposit, uint256 valueAllocated); + function jtPreviewDeposit(uint256 assets) + external + view + returns (RoycoSyncedAccountingState memory stateBeforeDeposit, uint256 valueAllocated); + + function stPreviewRedeem(uint256 shares) external view returns (RoycoAssetClaims memory userClaim); + function jtPreviewRedeem(uint256 shares) external view returns (RoycoAssetClaims memory userClaim); + + function stConvertTrancheUnitsToNAVUnits(uint256 stAssets) external view returns (uint256 nav); + function jtConvertTrancheUnitsToNAVUnits(uint256 jtAssets) external view returns (uint256 nav); + + function stDeposit(uint256 assets) external returns (uint256 valueAllocated, uint256 navToMintSharesAt); + function stRedeem(uint256 shares, address receiver, bool bypassRedemptionRestrictions) + external + returns (RoycoAssetClaims memory userAssetClaims); + function jtDeposit(uint256 assets) external returns (uint256 valueAllocated, uint256 navToMintSharesAt); + function jtRedeem(uint256 shares, address receiver, bool bypassRedemptionRestrictions) + external + returns (RoycoAssetClaims memory userAssetClaims); +} + +/// @title IRoycoVaultTranche +/// @author Phylax Systems +/// @notice Local Royco tranche surface needed by the protection suite. +interface IRoycoVaultTranche { + function KERNEL() external view returns (address kernel); + function asset() external view returns (address asset_); + function TRANCHE_TYPE() external view returns (RoycoTrancheType trancheType); + + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + + function previewDeposit(uint256 assets) external view returns (uint256 shares); + function previewRedeem(uint256 shares) external view returns (RoycoAssetClaims memory claims); + function convertToAssets(uint256 shares) external view returns (RoycoAssetClaims memory claims); + function previewMintProtocolFeeShares(uint256 protocolFeeNAV, uint256 totalTrancheNAV) + external + view + returns (uint256 protocolFeeSharesMinted, uint256 totalTrancheShares); + + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + function redeem(uint256 shares, address receiver, address owner) external returns (RoycoAssetClaims memory claims); +} + +/// @title RoycoHelpers +/// @author Phylax Systems +/// @notice Shared Royco helper utilities used by the assertion contracts. +abstract contract RoycoHelpers is Assertion { + uint256 internal constant WAD = 1e18; + + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "Royco: input too short"); + + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } +} + +/// @title RoycoKernelHelpers +/// @author Phylax Systems +/// @notice Consolidated Royco kernel-side read and math helpers for assertions. +abstract contract RoycoKernelHelpers is RoycoHelpers { + address internal immutable kernel; + address internal immutable accountant; + address internal immutable seniorTranche; + address internal immutable juniorTranche; + address internal immutable stAsset; + address internal immutable jtAsset; + + /// @dev Accountant, tranche, and asset addresses are passed explicitly so the constructor + /// never reads from the kernel. The Credible Layer's assertion-deploy runtime is + /// isolated from the adopter; reads against the kernel during construction would + /// revert with EXTCODESIZE = 0. + constructor( + address kernel_, + address accountant_, + address seniorTranche_, + address stAsset_, + address juniorTranche_, + address jtAsset_ + ) { + kernel = kernel_; + accountant = accountant_; + seniorTranche = seniorTranche_; + stAsset = stAsset_; + juniorTranche = juniorTranche_; + jtAsset = jtAsset_; + } + + function _hasIdenticalAssets() internal view returns (bool) { + return stAsset == jtAsset; + } + + function _stAssetBalanceAt(address account, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readBalanceAt(stAsset, account, fork); + } + + function _jtAssetBalanceAt(address account, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readBalanceAt(jtAsset, account, fork); + } + + function _kernelStAssetBalanceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _stAssetBalanceAt(kernel, fork); + } + + function _kernelJtAssetBalanceAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _jtAssetBalanceAt(kernel, fork); + } + + function _accountantStateAt(PhEvm.ForkId memory fork) internal view returns (RoycoAccountantState memory state) { + return + abi.decode(_viewAt(accountant, abi.encodeCall(IRoycoAccountant.getState, ()), fork), (RoycoAccountantState)); + } + + function _previewSyncAt(PhEvm.ForkId memory fork) internal view returns (RoycoSyncedAccountingState memory state) { + (state,,) = abi.decode( + _viewAt(kernel, abi.encodeCall(IRoycoKernel.previewSyncTrancheAccounting, (RoycoTrancheType.SENIOR)), fork), + (RoycoSyncedAccountingState, RoycoAssetClaims, uint256) + ); + } + + function _previewSeniorTrancheStateAt(PhEvm.ForkId memory fork) + internal + view + returns (RoycoSyncedAccountingState memory state, RoycoAssetClaims memory claims, uint256 totalTrancheShares) + { + return abi.decode( + _viewAt(kernel, abi.encodeCall(IRoycoKernel.previewSyncTrancheAccounting, (RoycoTrancheType.SENIOR)), fork), + (RoycoSyncedAccountingState, RoycoAssetClaims, uint256) + ); + } + + function _stConvertTrancheUnitsToNAVAt(uint256 assets, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(kernel, abi.encodeCall(IRoycoKernel.stConvertTrancheUnitsToNAVUnits, (assets)), fork); + } + + function _jtConvertTrancheUnitsToNAVAt(uint256 assets, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(kernel, abi.encodeCall(IRoycoKernel.jtConvertTrancheUnitsToNAVUnits, (assets)), fork); + } + + function _scaleAssetClaims(RoycoAssetClaims memory claims, uint256 shares, uint256 totalShares) + internal + view + returns (RoycoAssetClaims memory scaled) + { + if (shares == 0 || totalShares == 0) { + return scaled; + } + + scaled.nav = ph.mulDivDown(claims.nav, shares, totalShares); + scaled.stAssets = ph.mulDivDown(claims.stAssets, shares, totalShares); + scaled.jtAssets = ph.mulDivDown(claims.jtAssets, shares, totalShares); + } + + function _decodeKernelRedeemInput(bytes memory input) + internal + pure + returns (uint256 shares, address receiver, bool bypassRedemptionRestrictions) + { + return abi.decode(_stripSelector(input), (uint256, address, bool)); + } + + function _computeMaxUtilizationNeutralBonus( + RoycoSyncedAccountingState memory state, + RoycoAssetClaims memory stUserClaims, + PhEvm.ForkId memory fork + ) internal view returns (uint256 maxUtilizationNeutralBonusNAV) { + uint256 jtEffectiveNAV = state.jtEffectiveNAV; + if (jtEffectiveNAV == 0) { + return 0; + } + + uint256 totalCoveredExposure = state.stRawNAV + ph.mulDivUp(state.jtRawNAV, state.betaWAD, WAD); + uint256 stUserWeightedClaimNAV = _stConvertTrancheUnitsToNAVAt(stUserClaims.stAssets, fork) + + ph.mulDivDown(_jtConvertTrancheUnitsToNAVAt(stUserClaims.jtAssets, fork), state.betaWAD, WAD); + if (stUserWeightedClaimNAV == 0) { + return 0; + } + + (, uint256 jtClaimOnSTRawNAV,) = _decomposeNAVClaims(state); + + uint256 stAssetSourcedDenominator = totalCoveredExposure - jtEffectiveNAV; + uint256 stAssetSourcedMaxBonusNAV = + ph.mulDivDown(stUserWeightedClaimNAV, jtEffectiveNAV, stAssetSourcedDenominator); + if (stAssetSourcedMaxBonusNAV <= jtClaimOnSTRawNAV) { + return stAssetSourcedMaxBonusNAV; + } + + uint256 weightedClaimWithSTSourceAdjustmentNAV = + stUserWeightedClaimNAV + ph.mulDivDown(jtClaimOnSTRawNAV, (WAD - state.betaWAD), WAD); + uint256 blendedDenominator = totalCoveredExposure - ph.mulDivDown(jtEffectiveNAV, state.betaWAD, WAD); + return ph.mulDivDown(weightedClaimWithSTSourceAdjustmentNAV, jtEffectiveNAV, blendedDenominator); + } + + function _decomposeNAVClaims(RoycoSyncedAccountingState memory state) + internal + pure + returns (uint256 stClaimOnJTRawNAV, uint256 jtClaimOnSTRawNAV, uint256 jtClaimOnSelfRawNAV) + { + stClaimOnJTRawNAV = _saturatingSub(state.stEffectiveNAV, state.stRawNAV); + jtClaimOnSTRawNAV = _saturatingSub(state.jtEffectiveNAV, state.jtRawNAV); + jtClaimOnSelfRawNAV = state.jtRawNAV - stClaimOnJTRawNAV; + } + + function _saturatingSub(uint256 lhs, uint256 rhs) internal pure returns (uint256) { + return lhs > rhs ? lhs - rhs : 0; + } +} + +/// @title RoycoVaultTrancheHelpers +/// @author Phylax Systems +/// @notice Consolidated Royco tranche-side read and decode helpers for assertions. +abstract contract RoycoVaultTrancheHelpers is RoycoHelpers { + address internal immutable tranche; + address internal immutable kernel; + RoycoTrancheType internal immutable trancheType; + + /// @dev `kernel_` and `trancheType_` are passed explicitly so the constructor never + /// reads from the tranche. The Credible Layer's assertion-deploy runtime is isolated + /// from the adopter; live tranche reads during construction would revert with + /// EXTCODESIZE = 0. + constructor(address tranche_, address kernel_, RoycoTrancheType trancheType_) { + tranche = tranche_; + kernel = kernel_; + trancheType = trancheType_; + } + + function _totalSupplyAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(tranche, abi.encodeCall(IRoycoVaultTranche.totalSupply, ()), fork); + } + + function _previewDepositAt(uint256 assets_, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(tranche, abi.encodeCall(IRoycoVaultTranche.previewDeposit, (assets_)), fork); + } + + function _previewRedeemAt(uint256 shares, PhEvm.ForkId memory fork) + internal + view + returns (RoycoAssetClaims memory claims) + { + return abi.decode( + _viewAt(tranche, abi.encodeCall(IRoycoVaultTranche.previewRedeem, (shares)), fork), (RoycoAssetClaims) + ); + } + + function _previewMintProtocolFeeSharesAt(uint256 protocolFeeNAV, uint256 totalTrancheNAV, PhEvm.ForkId memory fork) + internal + view + returns (uint256 protocolFeeSharesMinted, uint256 totalTrancheShares) + { + return abi.decode( + _viewAt( + tranche, + abi.encodeCall(IRoycoVaultTranche.previewMintProtocolFeeShares, (protocolFeeNAV, totalTrancheNAV)), + fork + ), + (uint256, uint256) + ); + } + + function _kernelPreviewDepositAt(uint256 assets_, PhEvm.ForkId memory fork) + internal + view + returns (RoycoSyncedAccountingState memory stateBeforeDeposit, uint256 valueAllocated) + { + if (trancheType == RoycoTrancheType.SENIOR) { + return abi.decode( + _viewAt(kernel, abi.encodeCall(IRoycoKernel.stPreviewDeposit, (assets_)), fork), + (RoycoSyncedAccountingState, uint256) + ); + } + + return abi.decode( + _viewAt(kernel, abi.encodeCall(IRoycoKernel.jtPreviewDeposit, (assets_)), fork), + (RoycoSyncedAccountingState, uint256) + ); + } + + function _kernelRedeemSelector() internal view returns (bytes4) { + return trancheType == RoycoTrancheType.SENIOR ? IRoycoKernel.stRedeem.selector : IRoycoKernel.jtRedeem.selector; + } + + function _decodeTrancheDepositInput(bytes memory input) internal pure returns (uint256 assets, address receiver) { + return abi.decode(_stripSelector(input), (uint256, address)); + } + + function _decodeTrancheRedeemInput(bytes memory input) + internal + pure + returns (uint256 shares, address receiver, address owner) + { + return abi.decode(_stripSelector(input), (uint256, address, address)); + } + + function _convertToSharesWithVirtualOffsets(uint256 assetsNAV, uint256 totalSupply_, uint256 totalAssetsNAV) + internal + view + returns (uint256 shares) + { + return ph.mulDivDown(totalSupply_ + 1, assetsNAV, totalAssetsNAV + 1); + } + + function _expectedDepositMathAt(uint256 assets_, PhEvm.ForkId memory fork, uint256 preSupply) + internal + view + returns (uint256 expectedFeeSharesMinted, uint256 expectedFormulaShares) + { + (RoycoSyncedAccountingState memory stateBeforeDeposit, uint256 valueAllocated) = + _kernelPreviewDepositAt(assets_, fork); + + uint256 feeAccrued = trancheType == RoycoTrancheType.SENIOR + ? stateBeforeDeposit.stProtocolFeeAccrued + : stateBeforeDeposit.jtProtocolFeeAccrued; + uint256 effectiveNAV = trancheType == RoycoTrancheType.SENIOR + ? stateBeforeDeposit.stEffectiveNAV + : stateBeforeDeposit.jtEffectiveNAV; + + (expectedFeeSharesMinted,) = _previewMintProtocolFeeSharesAt(feeAccrued, effectiveNAV, fork); + expectedFormulaShares = + _convertToSharesWithVirtualOffsets(valueAllocated, preSupply + expectedFeeSharesMinted, effectiveNAV); + } + + function _resolveTriggeredKernelRedeemCall(PhEvm.TriggerContext memory ctx, uint256 shares, address receiver) + internal + view + returns (PhEvm.TriggerCall memory redeemCall) + { + PhEvm.TriggerCall[] memory calls = _matchingCalls(kernel, _kernelRedeemSelector(), 32); + uint256 matchCount; + + for (uint256 i; i < calls.length; ++i) { + if (calls[i].caller != tranche || calls[i].callId <= ctx.callStart || calls[i].callId >= ctx.callEnd) { + continue; + } + + (uint256 kernelShares, address kernelReceiver, bool bypassRestrictions) = + abi.decode(_stripSelector(calls[i].input), (uint256, address, bool)); + if (kernelShares != shares || kernelReceiver != receiver || bypassRestrictions) { + continue; + } + + redeemCall = calls[i]; + ++matchCount; + } + + require(matchCount != 0, "Royco: redeem skipped kernel path"); + require(matchCount == 1, "Royco: redeem reentered kernel path"); + } +} diff --git a/examples/royco/src/RoycoKernelAccountingAssertion.sol b/examples/royco/src/RoycoKernelAccountingAssertion.sol new file mode 100644 index 0000000..7c4bbfa --- /dev/null +++ b/examples/royco/src/RoycoKernelAccountingAssertion.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import { + IRoycoKernel, + RoycoAssetClaims, + RoycoKernelHelpers, + RoycoMarketState, + RoycoSyncedAccountingState +} from "./RoycoHelpers.sol"; + +/// @title RoycoKernelAccountingAssertion +/// @author Phylax Systems +/// @notice Kernel-side Royco invariant checks for accounting conservation, coverage, recovery +/// ordering, self-liquidation deleveraging, and JT IL erasure on perpetual transitions. +/// @dev Adopt this on the Royco kernel. These checks intentionally read the accountant's synced +/// state rather than inferring accounting from raw token balance deltas. +abstract contract RoycoKernelAccountingAssertion is RoycoKernelHelpers { + /// @notice Registers the full kernel/accountant invariant set. + function _registerAccountingInvariantTriggers() internal view { + _registerKernelMutationTriggers(this.assertNavConservation.selector); + _registerKernelMutationTriggers(this.assertPerpetualHealth.selector); + _registerKernelMutationTriggers(this.assertRecoveryPriority.selector); + _registerKernelMutationTriggers(this.assertJtImpermanentLossErasure.selector); + + registerFnCallTrigger(this.assertCoverageFloor.selector, IRoycoKernel.stDeposit.selector); + registerFnCallTrigger(this.assertCoverageFloor.selector, IRoycoKernel.jtRedeem.selector); + registerFnCallTrigger(this.assertSelfLiquidationDeleveraging.selector, IRoycoKernel.stRedeem.selector); + } + + function _registerKernelMutationTriggers(bytes4 assertionSelector) internal view { + registerFnCallTrigger(assertionSelector, IRoycoKernel.syncTrancheAccounting.selector); + registerFnCallTrigger(assertionSelector, IRoycoKernel.stDeposit.selector); + registerFnCallTrigger(assertionSelector, IRoycoKernel.stRedeem.selector); + registerFnCallTrigger(assertionSelector, IRoycoKernel.jtDeposit.selector); + registerFnCallTrigger(assertionSelector, IRoycoKernel.jtRedeem.selector); + } + + /// @notice Persisted synced accountant state must remain zero-sum across tranches. + function assertNavConservation() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + RoycoSyncedAccountingState memory postState = _previewSyncAt(_postCall(ctx.callEnd)); + + require( + postState.stRawNAV + postState.jtRawNAV == postState.stEffectiveNAV + postState.jtEffectiveNAV, + "Royco: NAV conservation violated" + ); + } + + /// @notice Perpetual markets that are not already distressed must remain below the liquidation + /// utilization threshold after every successful kernel operation. + function assertPerpetualHealth() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + RoycoSyncedAccountingState memory postState = _previewSyncAt(_postCall(ctx.callEnd)); + + if (postState.marketState == RoycoMarketState.PERPETUAL && postState.stImpermanentLoss == 0) { + require( + postState.utilizationWAD <= postState.liquidationUtilizationWAD, + "Royco: perpetual state above liquidation threshold" + ); + } + } + + /// @notice ST deposits and JT redemptions are the two operations that explicitly enforce the + /// coverage floor. They must finish with utilization at or below 100%. + function assertCoverageFloor() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + RoycoSyncedAccountingState memory postState = _previewSyncAt(_postCall(ctx.callEnd)); + + require(postState.utilizationWAD <= WAD, "Royco: coverage floor violated"); + } + + /// @notice ST self-liquidation bonuses must be utilization-neutral or deleveraging. + function assertSelfLiquidationDeleveraging() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + + RoycoSyncedAccountingState memory preState = _previewSyncAt(preFork); + if (preState.utilizationWAD < preState.liquidationUtilizationWAD) { + return; + } + + bytes memory input = ph.callinputAt(ctx.callStart); + (uint256 shares,,) = _decodeKernelRedeemInput(input); + + (, RoycoAssetClaims memory stNotionalClaims, uint256 totalTrancheShares) = _previewSeniorTrancheStateAt(preFork); + RoycoAssetClaims memory baseClaims = _scaleAssetClaims(stNotionalClaims, shares, totalTrancheShares); + RoycoAssetClaims memory actualClaims = abi.decode(ph.callOutputAt(ctx.callStart), (RoycoAssetClaims)); + + uint256 actualBonusNAV = _saturatingSub(actualClaims.nav, baseClaims.nav); + uint256 maxBonusNAV = _computeMaxUtilizationNeutralBonus(preState, baseClaims, preFork); + RoycoSyncedAccountingState memory postState = _previewSyncAt(postFork); + + require(actualBonusNAV <= maxBonusNAV, "Royco: self-liquidation bonus exceeds neutral bound"); + require(postState.utilizationWAD <= preState.utilizationWAD, "Royco: self-liquidation increased utilization"); + } + + /// @notice JT IL cannot be repaid while ST IL still exists. The only allowed JT IL reduction + /// with nonzero ST IL is the explicit zeroing that happens on a perpetual transition. + function assertRecoveryPriority() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + RoycoSyncedAccountingState memory preState = _previewSyncAt(_preCall(ctx.callStart)); + RoycoSyncedAccountingState memory postState = _previewSyncAt(_postCall(ctx.callEnd)); + + if (preState.stImpermanentLoss == 0 || postState.jtImpermanentLoss >= preState.jtImpermanentLoss) { + return; + } + + require( + postState.marketState == RoycoMarketState.PERPETUAL && postState.jtImpermanentLoss == 0, + "Royco: JT IL repaid before ST IL cleared" + ); + } + + /// @notice Any successful transition from FIXED_TERM back to PERPETUAL must leave no JT IL. + function assertJtImpermanentLossErasure() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + RoycoSyncedAccountingState memory preState = _previewSyncAt(_preCall(ctx.callStart)); + RoycoSyncedAccountingState memory postState = _previewSyncAt(_postCall(ctx.callEnd)); + + if (preState.marketState == RoycoMarketState.FIXED_TERM && postState.marketState == RoycoMarketState.PERPETUAL) + { + require(postState.jtImpermanentLoss == 0, "Royco: JT IL not erased on perpetual transition"); + } + } +} diff --git a/examples/royco/src/RoycoKernelAssertion.sol b/examples/royco/src/RoycoKernelAssertion.sol new file mode 100644 index 0000000..6dea62e --- /dev/null +++ b/examples/royco/src/RoycoKernelAssertion.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {RoycoKernelHelpers} from "./RoycoHelpers.sol"; +import {RoycoKernelAccountingAssertion} from "./RoycoKernelAccountingAssertion.sol"; +import {RoycoKernelCumulativeFlowAssertion} from "./RoycoKernelCumulativeFlowAssertion.sol"; + +/// @title RoycoKernelAssertion +/// @author Phylax Systems +/// @notice Executive summary: this bundle watches Royco's kernel/accountant invariants that +/// should never be violated by a successful market operation. It enforces zero-sum NAV +/// accounting across tranches, blocks unhealthy perpetual states or coverage-breaking +/// flows, preserves recovery priority and JT-IL erasure rules, and adds inflow/outflow +/// circuit breakers on the kernel's custody balances. +/// @dev Adopt this assertion on the Royco kernel, not on either tranche ERC-20. +contract RoycoKernelAssertion is RoycoKernelAccountingAssertion, RoycoKernelCumulativeFlowAssertion { + constructor( + address kernel_, + address accountant_, + address seniorTranche_, + address stAsset_, + address juniorTranche_, + address jtAsset_, + uint256 outflowThresholdBps_, + uint256 outflowWindowDuration_, + uint256 inflowThresholdBps_, + uint256 inflowWindowDuration_ + ) + RoycoKernelHelpers(kernel_, accountant_, seniorTranche_, stAsset_, juniorTranche_, jtAsset_) + RoycoKernelCumulativeFlowAssertion( + outflowThresholdBps_, outflowWindowDuration_, inflowThresholdBps_, inflowWindowDuration_ + ) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function triggers() external view override { + _registerAccountingInvariantTriggers(); + _registerCumulativeFlowTriggers(); + } +} diff --git a/examples/royco/src/RoycoKernelCumulativeFlowAssertion.sol b/examples/royco/src/RoycoKernelCumulativeFlowAssertion.sol new file mode 100644 index 0000000..58aad50 --- /dev/null +++ b/examples/royco/src/RoycoKernelCumulativeFlowAssertion.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {RoycoKernelCumulativeOutflowAssertion} from "./RoycoKernelCumulativeOutflowAssertion.sol"; + +/// @title RoycoKernelCumulativeFlowAssertion +/// @author Phylax Systems +/// @notice Circuit breaker that trips on large cumulative inflows or outflows of Royco tranche +/// assets through the kernel within rolling windows. +/// @dev Royco LP deposits and redemptions both settle through the kernel, so the kernel's ST/JT +/// custody balances are the right surface for both inflow and outflow breakers. +abstract contract RoycoKernelCumulativeFlowAssertion is RoycoKernelCumulativeOutflowAssertion { + /// @notice Maximum cumulative inflow as basis points of the kernel-balance snapshot. + uint256 public immutable inflowThresholdBps; + + /// @notice Rolling inflow window length in seconds. + uint256 public immutable inflowWindowDuration; + + constructor( + uint256 outflowThresholdBps_, + uint256 outflowWindowDuration_, + uint256 inflowThresholdBps_, + uint256 inflowWindowDuration_ + ) RoycoKernelCumulativeOutflowAssertion(outflowThresholdBps_, outflowWindowDuration_) { + inflowThresholdBps = inflowThresholdBps_; + inflowWindowDuration = inflowWindowDuration_; + } + + /// @notice Registers both cumulative outflow and inflow triggers for Royco tranche assets. + function _registerCumulativeFlowTriggers() internal view { + _registerCumulativeOutflowTriggers(); + _registerCumulativeInflowTriggers(); + } + + /// @notice Registers the cumulative inflow triggers for Royco tranche assets. + function _registerCumulativeInflowTriggers() internal view { + watchCumulativeInflow(stAsset, inflowThresholdBps, inflowWindowDuration, this.assertCumulativeInflow.selector); + + if (!_hasIdenticalAssets()) { + watchCumulativeInflow( + jtAsset, inflowThresholdBps, inflowWindowDuration, this.assertCumulativeInflow.selector + ); + } + } + + /// @notice Called when the cumulative inflow breaker trips. + function assertCumulativeInflow() external virtual { + PhEvm.InflowContext memory ctx = ph.inflowContext(); + + if (_hasIdenticalAssets()) { + revert("Royco: cumulative tranche-asset inflow breaker tripped"); + } + + if (ctx.token == stAsset) { + revert("Royco: senior tranche asset inflow breaker tripped"); + } + + if (ctx.token == jtAsset) { + revert("Royco: junior tranche asset inflow breaker tripped"); + } + + revert("Royco: cumulative kernel inflow breaker tripped"); + } +} diff --git a/examples/royco/src/RoycoKernelCumulativeOutflowAssertion.sol b/examples/royco/src/RoycoKernelCumulativeOutflowAssertion.sol new file mode 100644 index 0000000..6580098 --- /dev/null +++ b/examples/royco/src/RoycoKernelCumulativeOutflowAssertion.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {RoycoKernelHelpers} from "./RoycoHelpers.sol"; + +/// @title RoycoKernelCumulativeOutflowAssertion +/// @author Phylax Systems +/// @notice Circuit breaker that trips when cumulative ERC-20 outflow of Royco tranche assets +/// from the kernel exceeds a configured threshold within a rolling window. +/// +/// Invariant covered: +/// - **Kernel custody outflow cap**: net outflow of the senior or junior tranche asset from +/// the Royco kernel must not exceed `outflowThresholdBps` of that asset's kernel balance +/// snapshot within `outflowWindowDuration`. +/// +/// @dev Royco routes user redemption flows through the kernel, which holds both tranche assets. +/// The trigger therefore watches the kernel's ST/JT custody balances instead of the tranche +/// ERC-20 share contracts. +/// +/// For identical-asset markets, the trigger is registered once to avoid double-counting the +/// same kernel-held token. +/// +/// Override `assertCumulativeOutflow` for smarter breaker behavior. The default +/// implementation is a hard breaker. +abstract contract RoycoKernelCumulativeOutflowAssertion is RoycoKernelHelpers { + /// @notice Maximum cumulative outflow as basis points of the kernel-balance snapshot. + uint256 public immutable outflowThresholdBps; + + /// @notice Rolling window length in seconds. + uint256 public immutable outflowWindowDuration; + + constructor(uint256 thresholdBps_, uint256 windowDuration_) { + outflowThresholdBps = thresholdBps_; + outflowWindowDuration = windowDuration_; + } + + /// @notice Registers the cumulative outflow triggers for Royco tranche assets. + function _registerCumulativeOutflowTriggers() internal view { + watchCumulativeOutflow( + stAsset, outflowThresholdBps, outflowWindowDuration, this.assertCumulativeOutflow.selector + ); + + if (!_hasIdenticalAssets()) { + watchCumulativeOutflow( + jtAsset, outflowThresholdBps, outflowWindowDuration, this.assertCumulativeOutflow.selector + ); + } + } + + /// @notice Called when the cumulative outflow breaker trips. + function assertCumulativeOutflow() external virtual { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + + if (_hasIdenticalAssets()) { + revert("Royco: cumulative tranche-asset outflow breaker tripped"); + } + + if (ctx.token == stAsset) { + revert("Royco: senior tranche asset outflow breaker tripped"); + } + + if (ctx.token == jtAsset) { + revert("Royco: junior tranche asset outflow breaker tripped"); + } + + revert("Royco: cumulative kernel outflow breaker tripped"); + } +} diff --git a/examples/royco/src/RoycoVaultTrancheAssertion.sol b/examples/royco/src/RoycoVaultTrancheAssertion.sol new file mode 100644 index 0000000..177d295 --- /dev/null +++ b/examples/royco/src/RoycoVaultTrancheAssertion.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {RoycoTrancheType, RoycoVaultTrancheHelpers} from "./RoycoHelpers.sol"; +import {RoycoVaultTrancheOperationAssertion} from "./RoycoVaultTrancheOperationAssertion.sol"; + +/// @title RoycoVaultTrancheAssertion +/// @author Phylax Systems +/// @notice Executive summary: this bundle checks the tranche-facing share mechanics and call +/// ordering that LPs rely on. It keeps deposit/redeem previews aligned with actual +/// execution, verifies protocol-fee and virtual-share math, and ensures redeem paths call +/// into the kernel before shares are burned. +/// @dev Adopt this on each Royco tranche you want to monitor. +contract RoycoVaultTrancheAssertion is RoycoVaultTrancheOperationAssertion { + constructor(address tranche_, address kernel_, RoycoTrancheType trancheType_) + RoycoVaultTrancheHelpers(tranche_, kernel_, trancheType_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function triggers() external view override { + _registerOperationInvariantTriggers(); + } +} diff --git a/examples/royco/src/RoycoVaultTrancheOperationAssertion.sol b/examples/royco/src/RoycoVaultTrancheOperationAssertion.sol new file mode 100644 index 0000000..26cfbaf --- /dev/null +++ b/examples/royco/src/RoycoVaultTrancheOperationAssertion.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {IRoycoVaultTranche, RoycoAssetClaims, RoycoVaultTrancheHelpers} from "./RoycoHelpers.sol"; + +/// @title RoycoVaultTrancheOperationAssertion +/// @author Phylax Systems +/// @notice Tranche-side Royco invariant checks for preview consistency, protocol-fee share +/// minting, virtual offset share math, and redeem-before-burn ordering. +abstract contract RoycoVaultTrancheOperationAssertion is RoycoVaultTrancheHelpers { + /// @notice Registers the default deposit/redeem invariant set for a Royco tranche. + function _registerOperationInvariantTriggers() internal view { + registerFnCallTrigger(this.assertDepositPreviewConsistency.selector, IRoycoVaultTranche.deposit.selector); + registerFnCallTrigger(this.assertRedeemPreviewConsistency.selector, IRoycoVaultTranche.redeem.selector); + registerFnCallTrigger(this.assertRedeemOrdering.selector, IRoycoVaultTranche.redeem.selector); + } + + /// @notice Previewed deposits must match actual user share minting, protocol-fee share + /// issuance, and the tranche's one-share/one-wei virtual offset formula. + function assertDepositPreviewConsistency() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + + bytes memory input = ph.callinputAt(ctx.callStart); + (uint256 assets_,) = _decodeTrancheDepositInput(input); + + uint256 expectedUserShares = _previewDepositAt(assets_, preFork); + uint256 actualUserShares = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); + require(actualUserShares == expectedUserShares, "Royco: deposit preview mismatch"); + + uint256 preSupply = _totalSupplyAt(preFork); + uint256 postSupply = _totalSupplyAt(postFork); + require(postSupply >= preSupply + actualUserShares, "Royco: deposit supply delta mismatch"); + + uint256 actualFeeSharesMinted = postSupply - preSupply - actualUserShares; + (uint256 expectedFeeSharesMinted, uint256 expectedFormulaShares) = + _expectedDepositMathAt(assets_, preFork, preSupply); + require(actualFeeSharesMinted == expectedFeeSharesMinted, "Royco: protocol fee share mint mismatch"); + require( + actualUserShares == expectedFormulaShares, "Royco: deposit share math drifted from virtual offset formula" + ); + } + + /// @notice Previewed redemptions must match the actual claim bundle returned to the caller. + function assertRedeemPreviewConsistency() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + + bytes memory input = ph.callinputAt(ctx.callStart); + (uint256 shares,,) = _decodeTrancheRedeemInput(input); + + RoycoAssetClaims memory previewClaims = _previewRedeemAt(shares, preFork); + RoycoAssetClaims memory actualClaims = abi.decode(ph.callOutputAt(ctx.callStart), (RoycoAssetClaims)); + + require(actualClaims.stAssets == previewClaims.stAssets, "Royco: redeem ST asset preview mismatch"); + require(actualClaims.jtAssets == previewClaims.jtAssets, "Royco: redeem JT asset preview mismatch"); + require(actualClaims.nav == previewClaims.nav, "Royco: redeem NAV preview mismatch"); + } + + /// @notice The tranche must enter the kernel redeem path before its own share burn executes. + /// @dev The kernel depends on pre-burn supply when scaling claims and fee-share dilution. The + /// matching logic intentionally scopes kernel calls to the outer redeem frame to catch + /// accidental duplicate redeem paths inside the same call. + function assertRedeemOrdering() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + bytes memory input = ph.callinputAt(ctx.callStart); + (uint256 shares, address receiver,) = _decodeTrancheRedeemInput(input); + + PhEvm.TriggerCall memory kernelRedeemCall = _resolveTriggeredKernelRedeemCall(ctx, shares, receiver); + uint256 preRedeemSupply = _totalSupplyAt(_preCall(ctx.callStart)); + uint256 supplyAtKernelEntry = _totalSupplyAt(_preCall(kernelRedeemCall.callId)); + + require(supplyAtKernelEntry == preRedeemSupply, "Royco: redeem burned shares before kernel call"); + } +} diff --git a/examples/safe/README.md b/examples/safe/README.md new file mode 100644 index 0000000..0af0c80 --- /dev/null +++ b/examples/safe/README.md @@ -0,0 +1,16 @@ +# safe examples + +Assertion examples and supporting helpers extracted from the `safe-protection-suite` branch. + +## Build + +```sh +FOUNDRY_PROFILE=safe forge build +``` + +## Files + +- SafeConfigLockAssertion.sol +- SafeConfigLockHelpers.sol +- SafeTxShapeAssertion.sol +- SafeTxShapeHelpers.sol diff --git a/examples/safe/src/SafeConfigLockAssertion.sol b/examples/safe/src/SafeConfigLockAssertion.sol new file mode 100644 index 0000000..ba04533 --- /dev/null +++ b/examples/safe/src/SafeConfigLockAssertion.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {SafeConfigLockHelpers} from "./SafeConfigLockHelpers.sol"; + +/// @title SafeConfigLockAssertion +/// @author Phylax Systems +/// @notice Locks the critical configuration envelope for a Safe multisig. +/// @dev The assertion checks the Safe after each monitored transaction: +/// - threshold and owner count stay above configured minimums; +/// - owner and module sets match one of the approved set hashes; +/// - transaction guard, module guard, and fallback handler match expected addresses. +/// +/// Address set hashes are computed by sorting addresses ascending and then hashing +/// `abi.encode(sortedAddresses)`. For modules, `bytes32(0)` in the approved hash list +/// is a sentinel meaning "modules must be disabled". +contract SafeConfigLockAssertion is SafeConfigLockHelpers { + uint256 public immutable minThreshold; + uint256 public immutable minOwners; + address public immutable expectedGuard; + address public immutable expectedModuleGuard; + address public immutable expectedFallbackHandler; + + bytes32[] public approvedOwnerSetHashes; + bytes32[] public approvedModuleSetHashes; + + constructor( + uint256 minThreshold_, + uint256 minOwners_, + bytes32[] memory approvedOwnerSetHashes_, + bytes32[] memory approvedModuleSetHashes_, + address expectedGuard_, + address expectedModuleGuard_, + address expectedFallbackHandler_ + ) { + require(approvedOwnerSetHashes_.length != 0, "SafeConfigLock: owner hashes empty"); + require(approvedModuleSetHashes_.length != 0, "SafeConfigLock: module hashes empty"); + + minThreshold = minThreshold_; + minOwners = minOwners_; + expectedGuard = expectedGuard_; + expectedModuleGuard = expectedModuleGuard_; + expectedFallbackHandler = expectedFallbackHandler_; + + for (uint256 i; i < approvedOwnerSetHashes_.length; ++i) { + approvedOwnerSetHashes.push(approvedOwnerSetHashes_[i]); + } + + for (uint256 i; i < approvedModuleSetHashes_.length; ++i) { + approvedModuleSetHashes.push(approvedModuleSetHashes_[i]); + } + + _registerReshiramSpec(); + } + + function triggers() external view override { + registerStorageChangeTrigger(this.assertSafeConfiguration.selector); + } + + /// @notice Checks the Safe config after the triggering transaction has completed. + /// @dev Fails when a Safe transaction leaves owners, modules, guards, or fallback handling + /// outside the deployment-time policy. A zero module-set hash in the approved list + /// only approves the empty module set. + function assertSafeConfiguration() external view { + address safe = ph.getAssertionAdopter(); + PhEvm.ForkId memory post = _postTx(); + + address[] memory owners = _ownersAt(safe, post); + uint256 threshold = _thresholdAt(safe, post); + + require(threshold >= minThreshold, "SafeConfigLock: threshold below minimum"); + require(owners.length >= minOwners, "SafeConfigLock: owner count below minimum"); + require( + _isApprovedHash(hashAddressSet(owners), approvedOwnerSetHashes, false), + "SafeConfigLock: owner set not approved" + ); + + address[] memory modules = _modulesAt(safe, post); + require( + _isApprovedHash(hashAddressSet(modules), approvedModuleSetHashes, modules.length == 0), + "SafeConfigLock: module set not approved" + ); + + require(_guardAt(safe, post) == expectedGuard, "SafeConfigLock: guard mismatch"); + require(_moduleGuardAt(safe, post) == expectedModuleGuard, "SafeConfigLock: module guard mismatch"); + require(_fallbackHandlerAt(safe, post) == expectedFallbackHandler, "SafeConfigLock: fallback handler mismatch"); + } + + function approvedOwnerSetHashCount() external view returns (uint256) { + return approvedOwnerSetHashes.length; + } + + function approvedModuleSetHashCount() external view returns (uint256) { + return approvedModuleSetHashes.length; + } +} diff --git a/examples/safe/src/SafeConfigLockHelpers.sol b/examples/safe/src/SafeConfigLockHelpers.sol new file mode 100644 index 0000000..ba9333c --- /dev/null +++ b/examples/safe/src/SafeConfigLockHelpers.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +interface ISafeConfigLockTarget { + function getThreshold() external view returns (uint256); + function getOwners() external view returns (address[] memory); + function getModulesPaginated(address start, uint256 pageSize) + external + view + returns (address[] memory array, address next); +} + +/// @title SafeConfigLockHelpers +/// @author Phylax Systems +/// @notice Shared constants and snapshot readers for Safe configuration assertions. +abstract contract SafeConfigLockHelpers is Assertion { + address internal constant SPEC_RECORDER = address(uint160(uint256(keccak256("SpecRecorder")))); + address internal constant SENTINEL_MODULES = address(0x1); + + uint256 internal constant MODULE_PAGE_SIZE = 256; + + bytes32 internal constant FALLBACK_HANDLER_STORAGE_SLOT = + 0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5; + bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + bytes32 internal constant MODULE_GUARD_STORAGE_SLOT = + 0xb104e0b93118902c651344349b610029d694cfdec91c589c91ebafbcd0289947; + + /// @notice Computes the deterministic hash used by owner and module allow lists. + /// @dev Sorts the provided addresses in memory before hashing, so Safe linked-list order + /// does not affect the resulting set hash. + function hashAddressSet(address[] memory accounts) public pure returns (bytes32) { + _sortAddresses(accounts); + return keccak256(abi.encode(accounts)); + } + + function _ownersAt(address safe, PhEvm.ForkId memory fork) internal view returns (address[] memory owners) { + owners = abi.decode(_viewAt(safe, abi.encodeCall(ISafeConfigLockTarget.getOwners, ()), fork), (address[])); + } + + function _thresholdAt(address safe, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(safe, abi.encodeCall(ISafeConfigLockTarget.getThreshold, ()), fork); + } + + function _modulesAt(address safe, PhEvm.ForkId memory fork) internal view returns (address[] memory modules) { + address next; + (modules, next) = abi.decode( + _viewAt( + safe, + abi.encodeCall(ISafeConfigLockTarget.getModulesPaginated, (SENTINEL_MODULES, MODULE_PAGE_SIZE)), + fork + ), + (address[], address) + ); + require(next == SENTINEL_MODULES, "SafeConfigLock: too many modules"); + } + + function _guardAt(address safe, PhEvm.ForkId memory fork) internal view returns (address) { + return _addressSlotAt(safe, GUARD_STORAGE_SLOT, fork); + } + + function _moduleGuardAt(address safe, PhEvm.ForkId memory fork) internal view returns (address) { + return _addressSlotAt(safe, MODULE_GUARD_STORAGE_SLOT, fork); + } + + function _fallbackHandlerAt(address safe, PhEvm.ForkId memory fork) internal view returns (address) { + return _addressSlotAt(safe, FALLBACK_HANDLER_STORAGE_SLOT, fork); + } + + function _addressSlotAt(address safe, bytes32 slot, PhEvm.ForkId memory fork) internal view returns (address) { + return address(uint160(uint256(ph.loadStateAt(safe, slot, fork)))); + } + + function _isApprovedHash(bytes32 actualHash, bytes32[] storage approvedHashes, bool emptySet) + internal + view + returns (bool) + { + for (uint256 i; i < approvedHashes.length; ++i) { + if (approvedHashes[i] == actualHash) { + return true; + } + + if (emptySet && approvedHashes[i] == bytes32(0)) { + return true; + } + } + + return false; + } + + function _sortAddresses(address[] memory accounts) internal pure { + for (uint256 i = 1; i < accounts.length; ++i) { + address current = accounts[i]; + uint256 j = i; + + while (j > 0 && uint160(accounts[j - 1]) > uint160(current)) { + accounts[j] = accounts[j - 1]; + --j; + } + + accounts[j] = current; + } + } + + function _viewFailureMessage() internal pure override returns (string memory) { + return "SafeConfigLock: safe view failed"; + } + + function _registerReshiramSpec() internal { + (bool ok,) = SPEC_RECORDER.call( + abi.encodeWithSelector(bytes4(keccak256("registerAssertionSpec(uint8)")), AssertionSpec.Reshiram) + ); + require(ok, "SafeConfigLock: spec registration failed"); + } +} diff --git a/examples/safe/src/SafeTxShapeAssertion.sol b/examples/safe/src/SafeTxShapeAssertion.sol new file mode 100644 index 0000000..5437597 --- /dev/null +++ b/examples/safe/src/SafeTxShapeAssertion.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {SafeTxShapeHelpers} from "./SafeTxShapeHelpers.sol"; + +/// @title SafeTxShapeAssertion +/// @author Phylax Systems +/// @notice Enforces direct Safe action-shape policy for owner and module executions. +/// @dev Validates the Safe transaction tuple before settlement: known targets, exact +/// selectors, delegatecall restrictions, approved MultiSend batch contents, and +/// token approval spender/operator policy. +contract SafeTxShapeAssertion is SafeTxShapeHelpers { + constructor( + TargetPolicy[] memory targetPolicies_, + SelectorPolicy[] memory selectorPolicies_, + BatchExecutorPolicy[] memory batchExecutorPolicies_, + ApprovalPolicy[] memory approvalPolicies_, + bool moduleExecutionEnabled_, + address[] memory allowedModules_ + ) + SafeTxShapeHelpers( + targetPolicies_, + selectorPolicies_, + batchExecutorPolicies_, + approvalPolicies_, + moduleExecutionEnabled_, + allowedModules_ + ) + {} + + function triggers() external view override { + registerFnCallTrigger(this.assertSafeModulePolicy.selector, EXEC_TRANSACTION_SELECTOR); + registerFnCallTrigger(this.assertSafeModulePolicy.selector, EXEC_TRANSACTION_FROM_MODULE_SELECTOR); + registerFnCallTrigger(this.assertSafeModulePolicy.selector, EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR); + + registerFnCallTrigger(this.assertSafeDelegateCallPolicy.selector, EXEC_TRANSACTION_SELECTOR); + registerFnCallTrigger(this.assertSafeDelegateCallPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_SELECTOR); + registerFnCallTrigger( + this.assertSafeDelegateCallPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR + ); + + registerFnCallTrigger(this.assertSafeTargetSelectorPolicy.selector, EXEC_TRANSACTION_SELECTOR); + registerFnCallTrigger(this.assertSafeTargetSelectorPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_SELECTOR); + registerFnCallTrigger( + this.assertSafeTargetSelectorPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR + ); + + registerFnCallTrigger(this.assertSafeBatchPolicy.selector, EXEC_TRANSACTION_SELECTOR); + registerFnCallTrigger(this.assertSafeBatchPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_SELECTOR); + registerFnCallTrigger(this.assertSafeBatchPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR); + + registerFnCallTrigger(this.assertSafeApprovalPolicy.selector, EXEC_TRANSACTION_SELECTOR); + registerFnCallTrigger(this.assertSafeApprovalPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_SELECTOR); + registerFnCallTrigger(this.assertSafeApprovalPolicy.selector, EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR); + } + + /// @notice Ensures module executions are disabled or sent by an allowlisted module. + function assertSafeModulePolicy() external view { + Action memory action = _triggeredAction(); + if (action.fromModule) _validateModuleCaller(action.module); + } + + /// @notice Blocks direct, module, and inner delegatecalls except configured top-level MultiSend execution. + function assertSafeDelegateCallPolicy() external view { + Action memory action = _triggeredAction(); + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + if (action.operation != OPERATION_DELEGATECALL) return; + + (bool isBatchExecutor, uint256 batchIndex) = + _batchPolicyForAction(action.target, action.data, action.dataOffset, action.dataLength); + if (isBatchExecutor) { + BatchExecutorPolicy storage batchPolicy = batchExecutorPolicies[batchIndex]; + if (!batchPolicy.allowDelegateCall) revert SafeTxShapeBatchDelegateCallNotAllowed(action.target); + _validateMultiSendDelegateCallPolicy(action, batchPolicy); + return; + } + + revert SafeTxShapeDelegateCallBlocked(action.target); + } + + /// @notice Ensures every non-batch action uses a known target and allowed selector. + function assertSafeTargetSelectorPolicy() external view { + Action memory action = _triggeredAction(); + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + + if (action.operation == OPERATION_DELEGATECALL) { + (bool isBatchExecutor, uint256 batchIndex) = + _batchPolicyForAction(action.target, action.data, action.dataOffset, action.dataLength); + if (isBatchExecutor) { + _validateMultiSendTargetSelectorPolicy(action, batchExecutorPolicies[batchIndex]); + } + return; + } + + _validateTargetAndSelector(action); + } + + /// @notice Strictly parses configured MultiSend batches and rejects malformed or nested batches. + function assertSafeBatchPolicy() external view { + Action memory action = _triggeredAction(); + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + if (action.operation != OPERATION_DELEGATECALL) return; + + (bool isBatchExecutor, uint256 batchIndex) = + _batchPolicyForAction(action.target, action.data, action.dataOffset, action.dataLength); + if (!isBatchExecutor) return; + + BatchExecutorPolicy storage batchPolicy = batchExecutorPolicies[batchIndex]; + if (!batchPolicy.allowDelegateCall) revert SafeTxShapeBatchDelegateCallNotAllowed(action.target); + _validateMultiSendBatchPolicy(action, batchPolicy); + } + + /// @notice Enforces spender/operator and amount limits for approval-like calls. + function assertSafeApprovalPolicy() external view { + Action memory action = _triggeredAction(); + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + + if (action.operation == OPERATION_DELEGATECALL) { + (bool isBatchExecutor, uint256 batchIndex) = + _batchPolicyForAction(action.target, action.data, action.dataOffset, action.dataLength); + if (isBatchExecutor) { + _validateMultiSendApprovalPolicy(action, batchExecutorPolicies[batchIndex]); + } + return; + } + + if (action.dataLength < 4) return; + _validateApproval(action, _selectorAt(action.data, action.dataOffset)); + } +} diff --git a/examples/safe/src/SafeTxShapeHelpers.sol b/examples/safe/src/SafeTxShapeHelpers.sol new file mode 100644 index 0000000..f03acdc --- /dev/null +++ b/examples/safe/src/SafeTxShapeHelpers.sol @@ -0,0 +1,836 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +/// @title SafeTxShapeHelpers +/// @author Phylax Systems +/// @notice Shared decoding and policy helpers for Safe transaction-shape assertions. +abstract contract SafeTxShapeHelpers is Assertion { + address internal constant SPEC_RECORDER = address(uint160(uint256(keccak256("SpecRecorder")))); + + uint8 internal constant OPERATION_CALL = 0; + uint8 internal constant OPERATION_DELEGATECALL = 1; + + uint8 public constant APPROVAL_KIND_ERC20_APPROVE = 1; + uint8 public constant APPROVAL_KIND_ERC20_INCREASE_ALLOWANCE = 2; + uint8 public constant APPROVAL_KIND_ERC721_APPROVE = 3; + uint8 public constant APPROVAL_KIND_ERC721_SET_APPROVAL_FOR_ALL = 4; + uint8 public constant APPROVAL_KIND_ERC1155_SET_APPROVAL_FOR_ALL = 5; + + bytes4 public constant EXEC_TRANSACTION_SELECTOR = + bytes4(keccak256("execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)")); + bytes4 public constant EXEC_TRANSACTION_FROM_MODULE_SELECTOR = + bytes4(keccak256("execTransactionFromModule(address,uint256,bytes,uint8)")); + bytes4 public constant EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR = + bytes4(keccak256("execTransactionFromModuleReturnData(address,uint256,bytes,uint8)")); + + bytes4 public constant MULTISEND_SELECTOR = bytes4(keccak256("multiSend(bytes)")); + bytes4 public constant APPROVE_SELECTOR = bytes4(keccak256("approve(address,uint256)")); + bytes4 public constant INCREASE_ALLOWANCE_SELECTOR = bytes4(keccak256("increaseAllowance(address,uint256)")); + bytes4 public constant SET_APPROVAL_FOR_ALL_SELECTOR = bytes4(keccak256("setApprovalForAll(address,bool)")); + + uint256 internal constant MULTISEND_HEADER_LENGTH = 85; + + struct TargetPolicy { + address target; + bool allowAnySelector; + bool allowEmptyCalldata; + bool allowFallbackCalldata; + bool allowNonzeroValue; + } + + struct SelectorPolicy { + address target; + bytes4 selector; + bool allowNonzeroValue; + } + + struct BatchExecutorPolicy { + address executor; + bytes4 selector; + bool allowDelegateCall; + uint256 maxActions; + bool allowNested; + } + + struct ApprovalPolicy { + address token; + address spender; + uint8 kind; + uint256 maxAmount; + bool allowUnlimited; + } + + struct TriggeredSafeCall { + bytes4 selector; + address caller; + bytes input; + uint256 callStart; + uint256 callEnd; + } + + struct OwnerTx { + address to; + uint256 value; + bytes data; + uint8 operation; + } + + struct ModuleTx { + address to; + uint256 value; + bytes data; + uint8 operation; + } + + struct Action { + address safe; + address module; + address target; + uint256 value; + bytes data; + uint256 dataOffset; + uint256 dataLength; + uint8 operation; + bool fromModule; + bool fromBatch; + } + + error SafeTxShapeDuplicateTarget(address target); + error SafeTxShapeDuplicateSelector(address target, bytes4 selector); + error SafeTxShapeDuplicateBatchExecutor(address executor, bytes4 selector); + error SafeTxShapeDuplicateApprovalPolicy(address token, address spender, uint8 kind); + error SafeTxShapeDuplicateModule(address module); + error SafeTxShapeInvalidPolicy(); + error SafeTxShapeTriggeredCallNotFound(bytes4 selector, uint256 callStart); + error SafeTxShapeUnsupportedEntrypoint(bytes4 selector); + error SafeTxShapeModuleExecutionDisabled(address module); + error SafeTxShapeModuleNotAllowed(address module); + error SafeTxShapeUnknownOperation(uint8 operation); + error SafeTxShapeDelegateCallBlocked(address target); + error SafeTxShapeInnerDelegateCallBlocked(address target); + error SafeTxShapeUnknownTarget(address target); + error SafeTxShapeSelectorNotAllowed(address target, bytes4 selector); + error SafeTxShapeCalldataTooShort(address target, uint256 length); + error SafeTxShapeEmptyCalldataBlocked(address target); + error SafeTxShapeFallbackCalldataBlocked(address target, uint256 length); + error SafeTxShapeNativeValueBlocked(address target, bytes4 selector, uint256 value); + error SafeTxShapeBatchDelegateCallNotAllowed(address executor); + error SafeTxShapeBatchPayloadMalformed(); + error SafeTxShapeBatchTooManyActions(uint256 maxActions); + error SafeTxShapeNestedBatchBlocked(address executor); + error SafeTxShapeApprovalMalformed(address token, bytes4 selector); + error SafeTxShapeApprovalTokenUnconfigured(address token, bytes4 selector); + error SafeTxShapeApprovalSpenderNotAllowed(address token, address spender, uint8 kind); + error SafeTxShapeApprovalUnlimitedBlocked(address token, address spender, uint8 kind); + error SafeTxShapeApprovalAmountAboveCap( + address token, address spender, uint8 kind, uint256 amount, uint256 maxAmount + ); + + TargetPolicy[] public targetPolicies; + SelectorPolicy[] public selectorPolicies; + BatchExecutorPolicy[] public batchExecutorPolicies; + ApprovalPolicy[] public approvalPolicies; + address[] public allowedModules; + + bool public immutable moduleExecutionEnabled; + + constructor( + TargetPolicy[] memory targetPolicies_, + SelectorPolicy[] memory selectorPolicies_, + BatchExecutorPolicy[] memory batchExecutorPolicies_, + ApprovalPolicy[] memory approvalPolicies_, + bool moduleExecutionEnabled_, + address[] memory allowedModules_ + ) { + if (targetPolicies_.length == 0) revert SafeTxShapeInvalidPolicy(); + if (moduleExecutionEnabled_ && allowedModules_.length == 0) revert SafeTxShapeInvalidPolicy(); + + moduleExecutionEnabled = moduleExecutionEnabled_; + + _storeTargetPolicies(targetPolicies_); + _storeSelectorPolicies(selectorPolicies_); + _storeBatchExecutorPolicies(batchExecutorPolicies_); + _storeApprovalPolicies(approvalPolicies_); + _storeAllowedModules(allowedModules_); + + _registerReshiramSpec(); + } + + function targetPolicyCount() external view returns (uint256) { + return targetPolicies.length; + } + + function selectorPolicyCount() external view returns (uint256) { + return selectorPolicies.length; + } + + function batchExecutorPolicyCount() external view returns (uint256) { + return batchExecutorPolicies.length; + } + + function approvalPolicyCount() external view returns (uint256) { + return approvalPolicies.length; + } + + function allowedModuleCount() external view returns (uint256) { + return allowedModules.length; + } + + function _triggeredAction() internal view returns (Action memory action) { + TriggeredSafeCall memory triggered = _resolveTriggeredSafeCall(); + address safe = ph.getAssertionAdopter(); + + if (triggered.selector == EXEC_TRANSACTION_SELECTOR) { + OwnerTx memory ownerTx = _decodeOwnerTx(triggered.input); + return Action({ + safe: safe, + module: address(0), + target: ownerTx.to, + value: ownerTx.value, + data: ownerTx.data, + dataOffset: 0, + dataLength: ownerTx.data.length, + operation: ownerTx.operation, + fromModule: false, + fromBatch: false + }); + } + + if ( + triggered.selector == EXEC_TRANSACTION_FROM_MODULE_SELECTOR + || triggered.selector == EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR + ) { + ModuleTx memory moduleTx = _decodeModuleTx(triggered.input); + return Action({ + safe: safe, + module: triggered.caller, + target: moduleTx.to, + value: moduleTx.value, + data: moduleTx.data, + dataOffset: 0, + dataLength: moduleTx.data.length, + operation: moduleTx.operation, + fromModule: true, + fromBatch: false + }); + } + + revert SafeTxShapeUnsupportedEntrypoint(triggered.selector); + } + + function _validateInnerDelegateCallPolicy(Action memory action) internal pure { + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + if (action.operation == OPERATION_DELEGATECALL) revert SafeTxShapeInnerDelegateCallBlocked(action.target); + } + + function _validateInnerTargetSelectorPolicy(Action memory action) internal view { + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + if (action.operation == OPERATION_DELEGATECALL) return; + + _validateTargetAndSelector(action); + } + + function _validateInnerApprovalPolicy(Action memory action) internal view { + if (action.operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(action.operation); + if (action.operation == OPERATION_DELEGATECALL) return; + + if (action.dataLength < 4) return; + _validateApproval(action, _selectorAt(action.data, action.dataOffset)); + } + + function _validateMultiSendDelegateCallPolicy(Action memory action, BatchExecutorPolicy storage batchPolicy) + internal + view + { + (uint256 transactionsOffset, uint256 transactionsLength) = _multiSendTransactions(action, batchPolicy); + uint256 offset; + while (offset < transactionsLength) { + (Action memory innerAction, uint256 nextOffset) = + _readMultiSendAction(action, transactionsOffset, transactionsLength, offset); + _validateInnerDelegateCallPolicy(innerAction); + offset = nextOffset; + } + } + + function _validateMultiSendTargetSelectorPolicy(Action memory action, BatchExecutorPolicy storage batchPolicy) + internal + view + { + (uint256 transactionsOffset, uint256 transactionsLength) = _multiSendTransactions(action, batchPolicy); + uint256 offset; + while (offset < transactionsLength) { + (Action memory innerAction, uint256 nextOffset) = + _readMultiSendAction(action, transactionsOffset, transactionsLength, offset); + _validateInnerTargetSelectorPolicy(innerAction); + offset = nextOffset; + } + } + + function _validateMultiSendBatchPolicy(Action memory action, BatchExecutorPolicy storage batchPolicy) + internal + view + { + (uint256 transactionsOffset, uint256 transactionsLength) = _multiSendTransactions(action, batchPolicy); + uint256 offset; + uint256 actionCount; + + while (offset < transactionsLength) { + (Action memory innerAction, uint256 nextOffset) = + _readMultiSendAction(action, transactionsOffset, transactionsLength, offset); + + ++actionCount; + if (actionCount > batchPolicy.maxActions) revert SafeTxShapeBatchTooManyActions(batchPolicy.maxActions); + if (innerAction.operation == OPERATION_DELEGATECALL) { + revert SafeTxShapeInnerDelegateCallBlocked(innerAction.target); + } + if (_isConfiguredBatchCall( + innerAction.target, innerAction.data, innerAction.dataOffset, innerAction.dataLength + )) { + revert SafeTxShapeNestedBatchBlocked(innerAction.target); + } + + offset = nextOffset; + } + } + + function _validateMultiSendApprovalPolicy(Action memory action, BatchExecutorPolicy storage batchPolicy) + internal + view + { + (uint256 transactionsOffset, uint256 transactionsLength) = _multiSendTransactions(action, batchPolicy); + uint256 offset; + while (offset < transactionsLength) { + (Action memory innerAction, uint256 nextOffset) = + _readMultiSendAction(action, transactionsOffset, transactionsLength, offset); + _validateInnerApprovalPolicy(innerAction); + offset = nextOffset; + } + } + + function _multiSendTransactions(Action memory action, BatchExecutorPolicy storage batchPolicy) + internal + view + returns (uint256 transactionsOffset, uint256 transactionsLength) + { + if (!batchPolicy.allowDelegateCall) revert SafeTxShapeBatchDelegateCallNotAllowed(action.target); + + return _decodeSingleBytesArgument(action.data, action.dataOffset, action.dataLength, batchPolicy.selector); + } + + function _readMultiSendAction( + Action memory parent, + uint256 transactionsOffset, + uint256 transactionsLength, + uint256 offset + ) internal pure returns (Action memory innerAction, uint256 nextOffset) { + if (offset > transactionsLength || transactionsLength - offset < MULTISEND_HEADER_LENGTH) { + revert SafeTxShapeBatchPayloadMalformed(); + } + + uint256 entryOffset = transactionsOffset + offset; + uint8 operation = uint8(parent.data[entryOffset]); + if (operation > OPERATION_DELEGATECALL) revert SafeTxShapeUnknownOperation(operation); + + address target = _readPackedAddress(parent.data, entryOffset + 1); + if (target == address(0)) target = parent.safe; + + uint256 value = _readUint256(parent.data, entryOffset + 21); + uint256 dataLength = _readUint256(parent.data, entryOffset + 53); + uint256 dataOffset = entryOffset + MULTISEND_HEADER_LENGTH; + uint256 transactionsEnd = transactionsOffset + transactionsLength; + if (dataOffset > transactionsEnd || dataLength > transactionsEnd - dataOffset) { + revert SafeTxShapeBatchPayloadMalformed(); + } + + innerAction = Action({ + safe: parent.safe, + module: parent.module, + target: target, + value: value, + data: parent.data, + dataOffset: dataOffset, + dataLength: dataLength, + operation: operation, + fromModule: parent.fromModule, + fromBatch: true + }); + nextOffset = offset + MULTISEND_HEADER_LENGTH + dataLength; + } + + function _validateTargetAndSelector(Action memory action) internal view returns (bytes4 selector) { + if (action.target == address(0)) revert SafeTxShapeUnknownTarget(action.target); + + (bool knownTarget, uint256 targetIndex) = _targetPolicyIndex(action.target); + if (!knownTarget) revert SafeTxShapeUnknownTarget(action.target); + + TargetPolicy storage targetPolicy = targetPolicies[targetIndex]; + + if (action.dataLength == 0) { + if (!targetPolicy.allowEmptyCalldata) revert SafeTxShapeEmptyCalldataBlocked(action.target); + if (action.value != 0 && !targetPolicy.allowNonzeroValue) { + revert SafeTxShapeNativeValueBlocked(action.target, bytes4(0), action.value); + } + return bytes4(0); + } + + if (action.dataLength < 4) { + if (!targetPolicy.allowFallbackCalldata) { + revert SafeTxShapeFallbackCalldataBlocked(action.target, action.dataLength); + } + if (action.value != 0 && !targetPolicy.allowNonzeroValue) { + revert SafeTxShapeNativeValueBlocked(action.target, bytes4(0), action.value); + } + return bytes4(0); + } + + selector = _selectorAt(action.data, action.dataOffset); + + if (targetPolicy.allowAnySelector) { + if (action.value != 0 && !targetPolicy.allowNonzeroValue) { + revert SafeTxShapeNativeValueBlocked(action.target, selector, action.value); + } + return selector; + } + + (bool selectorAllowed, uint256 selectorIndex) = _selectorPolicyIndex(action.target, selector); + if (!selectorAllowed) revert SafeTxShapeSelectorNotAllowed(action.target, selector); + + if (action.value != 0 && !selectorPolicies[selectorIndex].allowNonzeroValue) { + revert SafeTxShapeNativeValueBlocked(action.target, selector, action.value); + } + } + + function _validateApproval(Action memory action, bytes4 selector) internal view { + if (selector == APPROVE_SELECTOR) { + if (action.dataLength != 68) revert SafeTxShapeApprovalMalformed(action.target, selector); + + address spender = _readAbiAddress(action.data, action.dataOffset + 4); + uint256 amountOrTokenId = _readUint256(action.data, action.dataOffset + 36); + bool erc20 = _tokenHasApprovalKind(action.target, APPROVAL_KIND_ERC20_APPROVE); + bool erc721 = _tokenHasApprovalKind(action.target, APPROVAL_KIND_ERC721_APPROVE); + + if (erc20) { + if (amountOrTokenId == 0) return; + _validateNumericApproval(action.target, spender, APPROVAL_KIND_ERC20_APPROVE, amountOrTokenId); + return; + } + + if (erc721) { + if (spender == address(0)) return; + _validateOperatorApproval(action.target, spender, APPROVAL_KIND_ERC721_APPROVE); + return; + } + + revert SafeTxShapeApprovalTokenUnconfigured(action.target, selector); + } + + if (selector == INCREASE_ALLOWANCE_SELECTOR) { + if (action.dataLength != 68) revert SafeTxShapeApprovalMalformed(action.target, selector); + if (!_tokenHasApprovalKind(action.target, APPROVAL_KIND_ERC20_INCREASE_ALLOWANCE)) { + revert SafeTxShapeApprovalTokenUnconfigured(action.target, selector); + } + + address spender = _readAbiAddress(action.data, action.dataOffset + 4); + uint256 addedValue = _readUint256(action.data, action.dataOffset + 36); + if (addedValue == 0) return; + + _validateNumericApproval(action.target, spender, APPROVAL_KIND_ERC20_INCREASE_ALLOWANCE, addedValue); + return; + } + + if (selector == SET_APPROVAL_FOR_ALL_SELECTOR) { + if (action.dataLength != 68) revert SafeTxShapeApprovalMalformed(action.target, selector); + + bool erc721 = _tokenHasApprovalKind(action.target, APPROVAL_KIND_ERC721_SET_APPROVAL_FOR_ALL); + bool erc1155 = _tokenHasApprovalKind(action.target, APPROVAL_KIND_ERC1155_SET_APPROVAL_FOR_ALL); + if (!erc721 && !erc1155) revert SafeTxShapeApprovalTokenUnconfigured(action.target, selector); + + address operator = _readAbiAddress(action.data, action.dataOffset + 4); + bool approved = _readAbiBool(action.data, action.dataOffset + 36); + if (!approved) return; + + if (erc721 && _operatorApprovalAllowed(action.target, operator, APPROVAL_KIND_ERC721_SET_APPROVAL_FOR_ALL)) + { + return; + } + if ( + erc1155 && _operatorApprovalAllowed(action.target, operator, APPROVAL_KIND_ERC1155_SET_APPROVAL_FOR_ALL) + ) { + return; + } + + revert SafeTxShapeApprovalSpenderNotAllowed( + action.target, + operator, + erc721 ? APPROVAL_KIND_ERC721_SET_APPROVAL_FOR_ALL : APPROVAL_KIND_ERC1155_SET_APPROVAL_FOR_ALL + ); + } + } + + function _validateNumericApproval(address token, address spender, uint8 kind, uint256 amount) internal view { + for (uint256 i; i < approvalPolicies.length; ++i) { + ApprovalPolicy storage policy = approvalPolicies[i]; + if (policy.token == token && policy.spender == spender && policy.kind == kind) { + if (amount == type(uint256).max) { + if (!policy.allowUnlimited) revert SafeTxShapeApprovalUnlimitedBlocked(token, spender, kind); + return; + } + + if (amount > policy.maxAmount) { + revert SafeTxShapeApprovalAmountAboveCap(token, spender, kind, amount, policy.maxAmount); + } + return; + } + } + + revert SafeTxShapeApprovalSpenderNotAllowed(token, spender, kind); + } + + function _validateOperatorApproval(address token, address operator, uint8 kind) internal view { + if (!_operatorApprovalAllowed(token, operator, kind)) { + revert SafeTxShapeApprovalSpenderNotAllowed(token, operator, kind); + } + } + + function _operatorApprovalAllowed(address token, address operator, uint8 kind) internal view returns (bool) { + if (operator == address(0)) return false; + + for (uint256 i; i < approvalPolicies.length; ++i) { + ApprovalPolicy storage policy = approvalPolicies[i]; + if (policy.token == token && policy.spender == operator && policy.kind == kind) { + return true; + } + } + + return false; + } + + function _validateModuleCaller(address module) internal view { + if (!moduleExecutionEnabled) revert SafeTxShapeModuleExecutionDisabled(module); + + for (uint256 i; i < allowedModules.length; ++i) { + if (allowedModules[i] == module) return; + } + + revert SafeTxShapeModuleNotAllowed(module); + } + + function _resolveTriggeredSafeCall() internal view returns (TriggeredSafeCall memory triggered) { + address safe = ph.getAssertionAdopter(); + PhEvm.TriggerContext memory context = ph.context(); + PhEvm.CallInputs[] memory calls = ph.getAllCallInputs(safe, context.selector); + + for (uint256 i; i < calls.length; ++i) { + if (calls[i].id == context.callStart) { + return TriggeredSafeCall({ + selector: context.selector, + caller: calls[i].caller, + input: ph.callinputAt(context.callStart), + callStart: context.callStart, + callEnd: context.callEnd + }); + } + } + + revert SafeTxShapeTriggeredCallNotFound(context.selector, context.callStart); + } + + function _decodeOwnerTx(bytes memory input) internal pure returns (OwnerTx memory ownerTx) { + if (input.length < 324 || _selector(input) != EXEC_TRANSACTION_SELECTOR) { + revert SafeTxShapeBatchPayloadMalformed(); + } + + ownerTx.to = _readAbiAddress(input, 4); + ownerTx.value = _readUint256(input, 36); + ownerTx.data = _readDynamicBytes(input, 4, _readUint256(input, 68)); + ownerTx.operation = _readAbiUint8(input, 100); + } + + function _decodeModuleTx(bytes memory input) internal pure returns (ModuleTx memory moduleTx) { + if ( + input.length < 132 + || (_selector(input) != EXEC_TRANSACTION_FROM_MODULE_SELECTOR + && _selector(input) != EXEC_TRANSACTION_FROM_MODULE_RETURN_DATA_SELECTOR) + ) { + revert SafeTxShapeBatchPayloadMalformed(); + } + + moduleTx.to = _readAbiAddress(input, 4); + moduleTx.value = _readUint256(input, 36); + moduleTx.data = _readDynamicBytes(input, 4, _readUint256(input, 68)); + moduleTx.operation = _readAbiUint8(input, 100); + } + + function _decodeSingleBytesArgument( + bytes memory input, + uint256 inputOffset, + uint256 inputLength, + bytes4 expectedSelector + ) internal pure returns (uint256 argumentOffset, uint256 argumentLength) { + if (inputOffset > input.length || inputLength > input.length - inputOffset || inputLength < 68) { + revert SafeTxShapeBatchPayloadMalformed(); + } + if (_selectorAt(input, inputOffset) != expectedSelector) revert SafeTxShapeBatchPayloadMalformed(); + + uint256 offset = _readUint256(input, inputOffset + 4); + if (offset != 32) revert SafeTxShapeBatchPayloadMalformed(); + + uint256 inputEnd = inputOffset + inputLength; + uint256 lengthOffset = inputOffset + 4 + offset; + argumentLength = _readUint256(input, lengthOffset); + argumentOffset = lengthOffset + 32; + if (argumentOffset > inputEnd) revert SafeTxShapeBatchPayloadMalformed(); + if (argumentLength > inputEnd - argumentOffset) revert SafeTxShapeBatchPayloadMalformed(); + + uint256 paddedLength = argumentLength; + uint256 remainder = argumentLength % 32; + if (remainder != 0) paddedLength += 32 - remainder; + + uint256 paddedEnd = argumentOffset + paddedLength; + if (paddedEnd != inputEnd) revert SafeTxShapeBatchPayloadMalformed(); + for (uint256 i = argumentLength; i < paddedLength; ++i) { + if (input[argumentOffset + i] != 0) revert SafeTxShapeBatchPayloadMalformed(); + } + } + + function _batchPolicyForAction(address target, bytes memory data, uint256 dataOffset, uint256 dataLength) + internal + view + returns (bool found, uint256 index) + { + if (dataLength < 4) return (false, 0); + return _batchPolicyIndex(target, _selectorAt(data, dataOffset)); + } + + function _isConfiguredBatchCall(address target, bytes memory data, uint256 dataOffset, uint256 dataLength) + internal + view + returns (bool) + { + if (dataLength < 4) return false; + (bool found,) = _batchPolicyIndex(target, _selectorAt(data, dataOffset)); + return found; + } + + function _targetPolicyIndex(address target) internal view returns (bool found, uint256 index) { + for (uint256 i; i < targetPolicies.length; ++i) { + if (targetPolicies[i].target == target) return (true, i); + } + return (false, 0); + } + + function _selectorPolicyIndex(address target, bytes4 selector) internal view returns (bool found, uint256 index) { + for (uint256 i; i < selectorPolicies.length; ++i) { + if (selectorPolicies[i].target == target && selectorPolicies[i].selector == selector) return (true, i); + } + return (false, 0); + } + + function _batchPolicyIndex(address executor, bytes4 selector) internal view returns (bool found, uint256 index) { + for (uint256 i; i < batchExecutorPolicies.length; ++i) { + if (batchExecutorPolicies[i].executor == executor && batchExecutorPolicies[i].selector == selector) { + return (true, i); + } + } + return (false, 0); + } + + function _tokenHasApprovalKind(address token, uint8 kind) internal view returns (bool) { + for (uint256 i; i < approvalPolicies.length; ++i) { + if (approvalPolicies[i].token == token && approvalPolicies[i].kind == kind) return true; + } + return false; + } + + function _storeTargetPolicies(TargetPolicy[] memory policies) private { + for (uint256 i; i < policies.length; ++i) { + if (policies[i].target == address(0)) revert SafeTxShapeInvalidPolicy(); + + for (uint256 j; j < i; ++j) { + if (policies[j].target == policies[i].target) revert SafeTxShapeDuplicateTarget(policies[i].target); + } + + targetPolicies.push(policies[i]); + } + } + + function _storeSelectorPolicies(SelectorPolicy[] memory policies) private { + for (uint256 i; i < policies.length; ++i) { + if (policies[i].target == address(0) || policies[i].selector == bytes4(0)) { + revert SafeTxShapeInvalidPolicy(); + } + if (!_targetPolicyExistsInMemory(policies[i].target)) revert SafeTxShapeInvalidPolicy(); + + for (uint256 j; j < i; ++j) { + if (policies[j].target == policies[i].target && policies[j].selector == policies[i].selector) { + revert SafeTxShapeDuplicateSelector(policies[i].target, policies[i].selector); + } + } + + selectorPolicies.push(policies[i]); + } + } + + function _storeBatchExecutorPolicies(BatchExecutorPolicy[] memory policies) private { + for (uint256 i; i < policies.length; ++i) { + if ( + policies[i].executor == address(0) || policies[i].selector == bytes4(0) || policies[i].maxActions == 0 + || policies[i].allowNested + ) { + revert SafeTxShapeInvalidPolicy(); + } + + for (uint256 j; j < i; ++j) { + if (policies[j].executor == policies[i].executor && policies[j].selector == policies[i].selector) { + revert SafeTxShapeDuplicateBatchExecutor(policies[i].executor, policies[i].selector); + } + } + + batchExecutorPolicies.push(policies[i]); + } + } + + function _storeApprovalPolicies(ApprovalPolicy[] memory policies) private { + for (uint256 i; i < policies.length; ++i) { + if ( + policies[i].token == address(0) || policies[i].spender == address(0) + || !_isSupportedApprovalKind(policies[i].kind) + ) { + revert SafeTxShapeInvalidPolicy(); + } + + for (uint256 j; j < i; ++j) { + if ( + policies[j].token == policies[i].token && policies[j].spender == policies[i].spender + && policies[j].kind == policies[i].kind + ) { + revert SafeTxShapeDuplicateApprovalPolicy(policies[i].token, policies[i].spender, policies[i].kind); + } + + if ( + policies[j].token == policies[i].token + && ((policies[j].kind == APPROVAL_KIND_ERC20_APPROVE + && policies[i].kind == APPROVAL_KIND_ERC721_APPROVE) + || (policies[j].kind == APPROVAL_KIND_ERC721_APPROVE + && policies[i].kind == APPROVAL_KIND_ERC20_APPROVE)) + ) { + revert SafeTxShapeInvalidPolicy(); + } + } + + approvalPolicies.push(policies[i]); + } + } + + function _storeAllowedModules(address[] memory modules) private { + for (uint256 i; i < modules.length; ++i) { + if (modules[i] == address(0)) revert SafeTxShapeInvalidPolicy(); + + for (uint256 j; j < i; ++j) { + if (modules[j] == modules[i]) revert SafeTxShapeDuplicateModule(modules[i]); + } + + allowedModules.push(modules[i]); + } + } + + function _targetPolicyExistsInMemory(address target) private view returns (bool) { + for (uint256 i; i < targetPolicies.length; ++i) { + if (targetPolicies[i].target == target) return true; + } + return false; + } + + function _isSupportedApprovalKind(uint8 kind) private pure returns (bool) { + return kind == APPROVAL_KIND_ERC20_APPROVE || kind == APPROVAL_KIND_ERC20_INCREASE_ALLOWANCE + || kind == APPROVAL_KIND_ERC721_APPROVE || kind == APPROVAL_KIND_ERC721_SET_APPROVAL_FOR_ALL + || kind == APPROVAL_KIND_ERC1155_SET_APPROVAL_FOR_ALL; + } + + function _registerReshiramSpec() internal { + (bool ok,) = SPEC_RECORDER.call( + abi.encodeWithSelector(bytes4(keccak256("registerAssertionSpec(uint8)")), AssertionSpec.Reshiram) + ); + if (!ok) revert SafeTxShapeInvalidPolicy(); + } + + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + if (input.length < 4) revert SafeTxShapeCalldataTooShort(address(0), input.length); + args = _slice(input, 4, input.length - 4); + } + + function _selector(bytes memory input) internal pure returns (bytes4 selector) { + if (input.length < 4) revert SafeTxShapeCalldataTooShort(address(0), input.length); + selector = _selectorAt(input, 0); + } + + function _selectorAt(bytes memory input, uint256 offset) internal pure returns (bytes4 selector) { + if (offset > input.length || input.length - offset < 4) { + revert SafeTxShapeCalldataTooShort(address(0), input.length); + } + selector = bytes4( + (uint32(uint8(input[offset])) << 24) | (uint32(uint8(input[offset + 1])) << 16) + | (uint32(uint8(input[offset + 2])) << 8) | uint32(uint8(input[offset + 3])) + ); + } + + function _readAbiAddress(bytes memory data, uint256 offset) internal pure returns (address value) { + value = address(uint160(_readUint256(data, offset))); + } + + function _readAbiUint8(bytes memory data, uint256 offset) internal pure returns (uint8 value) { + uint256 raw = _readUint256(data, offset); + if (raw > type(uint8).max) revert SafeTxShapeBatchPayloadMalformed(); + value = uint8(raw); + } + + function _readAbiBool(bytes memory data, uint256 offset) internal pure returns (bool value) { + uint256 raw = _readUint256(data, offset); + if (raw > 1) revert SafeTxShapeApprovalMalformed(address(0), bytes4(0)); + value = raw == 1; + } + + function _readDynamicBytes(bytes memory data, uint256 headStart, uint256 relativeOffset) + internal + pure + returns (bytes memory value) + { + if (headStart > data.length || relativeOffset > data.length - headStart) { + revert SafeTxShapeBatchPayloadMalformed(); + } + uint256 lengthOffset = headStart + relativeOffset; + uint256 valueLength = _readUint256(data, lengthOffset); + uint256 valueOffset = lengthOffset + 32; + if (valueOffset > data.length) revert SafeTxShapeBatchPayloadMalformed(); + if (valueLength > data.length - valueOffset) revert SafeTxShapeBatchPayloadMalformed(); + value = _slice(data, valueOffset, valueLength); + } + + function _readPackedAddress(bytes memory data, uint256 offset) internal pure returns (address value) { + if (offset > data.length || data.length - offset < 20) revert SafeTxShapeBatchPayloadMalformed(); + uint160 result; + for (uint256 i; i < 20; ++i) { + result = (result << 8) | uint160(uint8(data[offset + i])); + } + value = address(result); + } + + function _readUint256(bytes memory data, uint256 offset) internal pure returns (uint256 value) { + if (offset > data.length || data.length - offset < 32) revert SafeTxShapeBatchPayloadMalformed(); + for (uint256 i; i < 32; ++i) { + value = (value << 8) | uint256(uint8(data[offset + i])); + } + } + + function _slice(bytes memory data, uint256 offset, uint256 length) internal pure returns (bytes memory out) { + if (offset > data.length || length > data.length - offset) revert SafeTxShapeBatchPayloadMalformed(); + out = new bytes(length); + for (uint256 i; i < length; ++i) { + out[i] = data[offset + i]; + } + } +} diff --git a/examples/spark/README.md b/examples/spark/README.md new file mode 100644 index 0000000..f36ea4a --- /dev/null +++ b/examples/spark/README.md @@ -0,0 +1,17 @@ +# spark examples + +Assertion examples and supporting helpers extracted from the `spark` branch. + +## Build + +```sh +FOUNDRY_PROFILE=spark forge build +``` + +## Files + +- SparkLendOraclePriceGuardAssertion.sol +- SparkSLLInflowStopgapAssertion.sol +- SparkVaultAssertion.sol +- SparkVaultHelpers.sol +- SparkVaultInterfaces.sol diff --git a/examples/spark/src/SparkLendOraclePriceGuardAssertion.sol b/examples/spark/src/SparkLendOraclePriceGuardAssertion.sol new file mode 100644 index 0000000..75862cb --- /dev/null +++ b/examples/spark/src/SparkLendOraclePriceGuardAssertion.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {AaveV3LikeTypes, IAaveV3LikeOracle, IAaveV3LikePool} from "credible-std/protection/lending/examples/AaveV3LikeInterfaces.sol"; + +/// @title SparkLendOraclePriceGuardAssertion +/// @author Phylax Systems +/// @notice Guards SparkLend risk-increasing calls against synthetic-oracle drift. +/// @dev The assertion compares SparkLend's AaveOracle price with a hypothetical +/// Credible Layer off-chain `marketPrice` reference. It is intentionally standalone +/// so it can be mounted next to +/// `SparkLendV1OperationSafetyAssertion` without changing that bundle. +/// +/// The guard is designed for wrapped or rate-bearing assets where the protocol +/// oracle reports an underlying peg or exchange rate while the reserve itself +/// can trade at a discount. When the market price is outside tolerance, risky +/// calls revert while unmonitored repay paths remain available. +contract SparkLendOraclePriceGuardAssertion is Assertion { + uint256 internal constant PRICE_SCALE = 1e18; + uint256 internal constant BPS = 10_000; + uint256 internal constant CALL_LOOKUP_LIMIT = 16; + + struct WatchEntry { + address asset; + address denomAsset; + uint256 toleranceBps; + } + + error SparkLendOraclePriceDeviation( + address asset, address denomAsset, uint256 reportedInDenom, uint256 market, uint256 toleranceBps + ); + + error SparkLendOraclePriceGuardInvalidEntry(address asset, uint256 toleranceBps); + error SparkLendOraclePriceGuardUnknownTrigger(bytes4 selector); + error SparkLendOraclePriceGuardTriggerCallNotFound(bytes4 selector, uint256 callStart); + + address public immutable pool; + address public immutable oracle; + address public immutable baseCurrency; + uint256 public immutable baseCurrencyUnit; + + WatchEntry[] internal watchEntries; + + /// @param pool_ SparkLend pool whose risk-increasing selectors are monitored. + /// @param oracle_ AaveOracle used by the pool to report asset prices. + /// @param baseCurrency_ Oracle base currency (matches `IAaveV3LikeOracle.BASE_CURRENCY`). + /// @param baseCurrencyUnit_ Oracle base-currency unit (matches `IAaveV3LikeOracle.BASE_CURRENCY_UNIT`). + /// @param watchEntries_ Per-asset market-pair and tolerance configuration. + /// @dev `baseCurrency_` and `baseCurrencyUnit_` are passed explicitly so the constructor + /// never reads from the oracle. The Credible Layer's assertion-deploy runtime is + /// isolated from the adopter; live oracle reads during construction would revert + /// with EXTCODESIZE = 0. + constructor( + address pool_, + address oracle_, + address baseCurrency_, + uint256 baseCurrencyUnit_, + WatchEntry[] memory watchEntries_ + ) { + require(pool_ != address(0), "SparkLendOracle: zero pool"); + require(oracle_ != address(0), "SparkLendOracle: zero oracle"); + require(baseCurrencyUnit_ != 0, "SparkLendOracle: zero base unit"); + + pool = pool_; + oracle = oracle_; + baseCurrency = baseCurrency_; + baseCurrencyUnit = baseCurrencyUnit_; + + for (uint256 i; i < watchEntries_.length; ++i) { + if (watchEntries_[i].asset == address(0) || watchEntries_[i].toleranceBps == 0) { + revert SparkLendOraclePriceGuardInvalidEntry(watchEntries_[i].asset, watchEntries_[i].toleranceBps); + } + + watchEntries.push(watchEntries_[i]); + } + + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers the SparkLend selectors that consume oracle prices on risky paths. + /// @dev Supply and repay are deliberately not registered. This leaves debt repayment + /// open when a watched wrapped asset depegs and risky calls enter reduce-only mode. + function triggers() external view override { + registerFnCallTrigger(this.assertOraclePricesTrackMarket.selector, IAaveV3LikePool.borrow.selector); + registerFnCallTrigger(this.assertOraclePricesTrackMarket.selector, IAaveV3LikePool.withdraw.selector); + registerFnCallTrigger(this.assertOraclePricesTrackMarket.selector, IAaveV3LikePool.liquidationCall.selector); + registerFnCallTrigger(this.assertOraclePricesTrackMarket.selector, IAaveV3LikePool.setUserEMode.selector); + } + + /// @notice Checks touched watched assets against off-chain market reference prices. + /// @dev Uses the matched call's pre-call fork for both AaveOracle reads and + /// user-configuration reads. Borrow, withdraw, and eMode calls also inspect + /// the user's active watched collateral/debt bits because Aave health-factor + /// calculations consume account-wide oracle prices, not only the calldata asset. + function assertOraclePricesTrackMarket() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + bytes memory input = ph.callinputAt(ctx.callStart); + PhEvm.ForkId memory fork = _preCall(ctx.callStart); + + if (ctx.selector == IAaveV3LikePool.borrow.selector) { + (address asset,,,, address onBehalfOf) = + abi.decode(_stripSelector(input), (address, uint256, uint256, uint16, address)); + _checkIfWatched(asset, fork); + _checkWatchedAccountPositions(onBehalfOf, fork, asset); + return; + } + + if (ctx.selector == IAaveV3LikePool.withdraw.selector) { + (address asset,,) = abi.decode(_stripSelector(input), (address, uint256, address)); + address caller = _triggerCaller(ctx); + _checkIfWatched(asset, fork); + _checkWatchedAccountPositions(caller, fork, asset); + return; + } + + if (ctx.selector == IAaveV3LikePool.liquidationCall.selector) { + (address collateralAsset, address debtAsset,,,) = + abi.decode(_stripSelector(input), (address, address, address, uint256, bool)); + _checkIfWatched(collateralAsset, fork); + _checkIfWatched(debtAsset, fork); + return; + } + + if (ctx.selector == IAaveV3LikePool.setUserEMode.selector) { + _checkWatchedAccountPositions(_triggerCaller(ctx), fork, address(0)); + return; + } + + revert SparkLendOraclePriceGuardUnknownTrigger(ctx.selector); + } + + /// @notice Returns the configured number of watched assets. + function watchEntryCount() external view returns (uint256) { + return watchEntries.length; + } + + /// @notice Returns one watched asset entry by index. + function watchEntry(uint256 index) external view returns (WatchEntry memory) { + return watchEntries[index]; + } + + function _checkIfWatched(address asset, PhEvm.ForkId memory fork) internal view { + (bool found, WatchEntry memory entry) = _findWatchEntry(asset); + if (!found) { + return; + } + + _checkEntry(entry, fork); + } + + function _checkWatchedAccountPositions(address account, PhEvm.ForkId memory fork, address alreadyChecked) + internal + view + { + if (account == address(0)) { + return; + } + + AaveV3LikeTypes.UserConfigurationMap memory userConfig = abi.decode( + _viewAt(pool, abi.encodeCall(IAaveV3LikePool.getUserConfiguration, (account)), fork), + (AaveV3LikeTypes.UserConfigurationMap) + ); + + for (uint256 i; i < watchEntries.length; ++i) { + WatchEntry memory entry = watchEntries[i]; + if (entry.asset == alreadyChecked) { + continue; + } + + (bool initialized, AaveV3LikeTypes.ReserveData memory reserveData) = _tryGetReserveData(entry.asset, fork); + if (initialized && _isUsingReserve(userConfig.data, reserveData.id)) { + _checkEntry(entry, fork); + } + } + } + + function _checkEntry(WatchEntry memory entry, PhEvm.ForkId memory fork) internal view { + uint256 reportedInDenom = _reportedPriceInDenom(entry.asset, entry.denomAsset, fork); + uint256 market = ph.marketPrice(entry.asset, entry.denomAsset); + require(market != 0, "SparkLendOracle: zero market price"); + + bool aboveLowerBound = ph.ratioGe(reportedInDenom, 1, market, 1, entry.toleranceBps); + bool belowUpperBound = ph.ratioGe(market, BPS, reportedInDenom, BPS + entry.toleranceBps, 0); + + if (!aboveLowerBound || !belowUpperBound) { + revert SparkLendOraclePriceDeviation( + entry.asset, entry.denomAsset, reportedInDenom, market, entry.toleranceBps + ); + } + } + + function _reportedPriceInDenom(address asset, address denomAsset, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + uint256 reportedBase = _oraclePriceAt(asset, fork); + require(reportedBase != 0, "SparkLendOracle: zero reported price"); + + if (denomAsset == address(0)) { + return ph.mulDivDown(reportedBase, PRICE_SCALE, baseCurrencyUnit); + } + + uint256 denomBase = _oraclePriceAt(denomAsset, fork); + require(denomBase != 0, "SparkLendOracle: zero denom price"); + + return ph.mulDivDown(reportedBase, PRICE_SCALE, denomBase); + } + + function _oraclePriceAt(address asset, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(oracle, abi.encodeCall(IAaveV3LikeOracle.getAssetPrice, (asset)), fork); + } + + function _triggerCaller(PhEvm.TriggerContext memory ctx) internal view returns (address) { + PhEvm.TriggerCall[] memory calls = _matchingCalls(pool, ctx.selector, CALL_LOOKUP_LIMIT); + + for (uint256 i; i < calls.length; ++i) { + if (calls[i].callId == ctx.callStart) { + return calls[i].caller; + } + } + + revert SparkLendOraclePriceGuardTriggerCallNotFound(ctx.selector, ctx.callStart); + } + + function _tryGetReserveData(address asset, PhEvm.ForkId memory fork) + internal + view + returns (bool initialized, AaveV3LikeTypes.ReserveData memory reserveData) + { + PhEvm.StaticCallResult memory result = + ph.staticcallAt(pool, abi.encodeCall(IAaveV3LikePool.getReserveData, (asset)), FORK_VIEW_GAS, fork); + + if (!result.ok || result.data.length == 0) { + return (false, reserveData); + } + + reserveData = abi.decode(result.data, (AaveV3LikeTypes.ReserveData)); + initialized = reserveData.aTokenAddress != address(0); + } + + function _findWatchEntry(address asset) internal view returns (bool found, WatchEntry memory entry) { + for (uint256 i; i < watchEntries.length; ++i) { + if (watchEntries[i].asset == asset) { + return (true, watchEntries[i]); + } + } + } + + function _isUsingReserve(uint256 userConfigData, uint256 reserveId) internal pure returns (bool) { + return ((userConfigData >> (reserveId * 2)) & 3) != 0; + } + + /// @notice Strip the 4-byte selector from raw call input bytes. + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "SparkLendOracle: input too short"); + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } +} + +/// @title SparkLendOraclePriceGuardMainnetConfig +/// @notice Canonical starting watch list for SparkLend mainnet synthetic-oracle markets. +/// @dev Operators should tune tolerances from backtests and governance risk appetite. +/// DAI, USDC, and USDS are intentionally omitted by default; they can be added +/// if an adopter wants to spend gas on those lower-signal fixed-price reserves. +library SparkLendOraclePriceGuardMainnetConfig { + address internal constant USD = address(0); + + address internal constant CBBTC = 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf; + address internal constant LBTC = 0x8236a87084f8B84306f72007F36F2618A5634494; + address internal constant TBTC = 0x18084fbA666a33d37592fA2633fD49a74DD93a88; + address internal constant WBTC = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599; + + address internal constant EZETH = 0xbf5495Efe5DB9ce00f80364C8B423567e58d2110; + address internal constant RSETH = 0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7; + address internal constant WEETH = 0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee; + + address internal constant RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; + address internal constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + address internal constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + address internal constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA; + address internal constant SUSDE = 0x9D39A5DE30e57443BfF2A8307A4256c8797A3497; + address internal constant SUSDS = 0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD; + address internal constant USDS = 0xdC035D45d973E3EC169d2276DDab16f1e407384F; + address internal constant USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; + + function watchList() internal pure returns (SparkLendOraclePriceGuardAssertion.WatchEntry[] memory entries) { + entries = new SparkLendOraclePriceGuardAssertion.WatchEntry[](13); + + entries[0] = SparkLendOraclePriceGuardAssertion.WatchEntry(CBBTC, WBTC, 75); + entries[1] = SparkLendOraclePriceGuardAssertion.WatchEntry(LBTC, WBTC, 75); + entries[2] = SparkLendOraclePriceGuardAssertion.WatchEntry(TBTC, WBTC, 75); + entries[3] = SparkLendOraclePriceGuardAssertion.WatchEntry(WBTC, USD, 75); + + entries[4] = SparkLendOraclePriceGuardAssertion.WatchEntry(WEETH, WETH, 125); + entries[5] = SparkLendOraclePriceGuardAssertion.WatchEntry(EZETH, WETH, 125); + entries[6] = SparkLendOraclePriceGuardAssertion.WatchEntry(RSETH, WETH, 125); + + entries[7] = SparkLendOraclePriceGuardAssertion.WatchEntry(WSTETH, WETH, 50); + entries[8] = SparkLendOraclePriceGuardAssertion.WatchEntry(RETH, WETH, 50); + + entries[9] = SparkLendOraclePriceGuardAssertion.WatchEntry(SDAI, DAI, 50); + entries[10] = SparkLendOraclePriceGuardAssertion.WatchEntry(SUSDS, USDS, 50); + entries[11] = SparkLendOraclePriceGuardAssertion.WatchEntry(SUSDE, USD, 75); + entries[12] = SparkLendOraclePriceGuardAssertion.WatchEntry(USDT, USD, 75); + } +} diff --git a/examples/spark/src/SparkSLLInflowStopgapAssertion.sol b/examples/spark/src/SparkSLLInflowStopgapAssertion.sol new file mode 100644 index 0000000..58f7a54 --- /dev/null +++ b/examples/spark/src/SparkSLLInflowStopgapAssertion.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +/// @title SparkSLLInflowStopgapAssertion +/// @author Phylax Systems +/// @notice Example Spark Liquidity Layer circuit breaker for venue inflows. +/// @dev Mount this assertion on the SLL custody contract, e.g. Spark's `ALMProxy`, +/// once per watched underlying asset. From the SLL perspective the protected +/// action is an ERC-20 outflow from custody; from the destination venue's +/// perspective the same movement is an inflow that consumes SLL rate limits. +/// +/// Spark ALM controller references: +/// - `ALMProxy` is the custody account and routes controller calls through `doCall`. +/// - `depositAave(aToken, amount)` consumes `LIMIT_AAVE_DEPOSIT`, approves the +/// aToken's underlying asset, then supplies it through the Aave/SparkLend pool. +/// - `RateLimits` refills linearly from `(maxAmount, slope)`. +/// +/// The intended stopgap is to hard-revert once the rolling 6-hour custody outflow +/// exceeds the risk-budget threshold. That lets SLL slope be sized for legitimate +/// planner throughput while this assertion enforces the emergency loss envelope. +contract SparkSLLInflowStopgapAssertion is Assertion { + /// @notice Spark's published 6-hour loss-bound default: 0.02% of TVL. + uint256 public constant DEFAULT_THRESHOLD_BPS = 2; + + /// @notice Spark's published response-window default used for the stopgap. + uint256 public constant DEFAULT_WINDOW_DURATION = 6 hours; + + /// @notice Underlying ERC-20 asset leaving SLL custody. + address public immutable watchedAsset; + + /// @notice Maximum cumulative outflow as bps of the executor's TVL snapshot. + uint256 public immutable thresholdBps; + + /// @notice Rolling window length in seconds. + uint256 public immutable windowDuration; + + /// @param watchedAsset_ Underlying asset to monitor on the assertion adopter. + /// @param thresholdBps_ Maximum cumulative asset outflow in basis points of TVL. + /// @param windowDuration_ Rolling window, in seconds, used by the outflow trigger. + constructor(address watchedAsset_, uint256 thresholdBps_, uint256 windowDuration_) { + require(watchedAsset_ != address(0), "SparkSLL: zero asset"); + require(thresholdBps_ != 0, "SparkSLL: zero threshold"); + require(windowDuration_ != 0, "SparkSLL: zero window"); + + watchedAsset = watchedAsset_; + thresholdBps = thresholdBps_; + windowDuration = windowDuration_; + + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Registers the SLL asset outflow breaker. + /// @dev For the proposed Spark stopgap, deploy with `(asset, 2, 6 hours)` and + /// adopt this assertion on the ALMProxy that holds that asset. + function triggers() external view override { + watchCumulativeOutflow(watchedAsset, thresholdBps, windowDuration, this.assertHalt6hInflowBreach.selector); + } + + /// @notice Reverts when the rolling SLL asset outflow limit has been breached. + /// @dev The executor invokes this only after `watchCumulativeOutflow` determines + /// that the watched asset exceeded the configured window budget. The context + /// check prevents accidental reuse with the wrong token registration. + function assertHalt6hInflowBreach() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + require(ctx.token == watchedAsset, "SparkSLL: wrong asset context"); + + revert("SparkSLL: 6h venue inflow cap exceeded"); + } +} diff --git a/examples/spark/src/SparkVaultAssertion.sol b/examples/spark/src/SparkVaultAssertion.sol new file mode 100644 index 0000000..cc39713 --- /dev/null +++ b/examples/spark/src/SparkVaultAssertion.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {ERC4626BaseAssertion} from "credible-std/protection/vault/ERC4626BaseAssertion.sol"; +import {ERC4626CumulativeOutflowAssertion} from "credible-std/protection/vault/ERC4626CumulativeOutflowAssertion.sol"; +import {IERC4626} from "credible-std/protection/vault/IERC4626.sol"; +import {ERC4626PreviewAssertion} from "credible-std/protection/vault/ERC4626PreviewAssertion.sol"; +import {ERC4626SharePriceAssertion} from "credible-std/protection/vault/ERC4626SharePriceAssertion.sol"; + +import {ISparkVaultLiquidityLike, ISparkVaultRateLike, ISparkVaultReferralLike} from "./SparkVaultInterfaces.sol"; +import {SparkVaultHelpers} from "./SparkVaultHelpers.sol"; + +/// @title SparkVaultAssertion +/// @author Phylax Systems +/// @notice Example assertion bundle for Spark vaults. +/// @dev Spark's managed-liquidity model uses `take()` to move assets out of the vault while +/// keeping `totalAssets()` based on share liabilities, so this example intentionally does +/// not inherit `ERC4626AssetFlowAssertion`. +/// +/// Spark also exposes referral overloads for `deposit` and `mint`. Their first arguments +/// match the standard ERC-4626 forms, so the existing preview/share-price assertion +/// functions can be reused by registering the overload selectors explicitly. +/// +/// Beyond ERC-4626, Spark's savings-rate model requires mutating accrual paths to fully +/// settle pending `chi` growth for the current block, while `take()` must only move +/// liquidity and `assetsOutstanding()` without changing liabilities or rate state. +contract SparkVaultAssertion is + ERC4626SharePriceAssertion, + ERC4626PreviewAssertion, + ERC4626CumulativeOutflowAssertion, + SparkVaultHelpers +{ + /// @param vault_ Spark vault instance whose selectors this bundle will monitor. + /// @param sharePriceToleranceBps_ Max per-call share-price drift tolerated by + /// `ERC4626SharePriceAssertion` (basis points of the pre-call price). + /// @param outflowThresholdBps_ Cumulative net-outflow limit as bps of TVL enforced + /// by `ERC4626CumulativeOutflowAssertion` over the rolling window. + /// @param outflowWindowDuration_ Rolling window (seconds) the outflow assertion uses. + constructor( + address vault_, + address asset_, + uint256 sharePriceToleranceBps_, + uint256 outflowThresholdBps_, + uint256 outflowWindowDuration_ + ) + ERC4626BaseAssertion(vault_, asset_) + ERC4626SharePriceAssertion(sharePriceToleranceBps_) + ERC4626CumulativeOutflowAssertion(outflowThresholdBps_, outflowWindowDuration_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Entry point the Credible executor calls once during setup to wire + /// assertion functions to the vault selectors that should trigger them. + /// @dev Every `registerFnCallTrigger(assertionFn, targetFn)` (invoked inside the + /// helpers below) tells the executor: "whenever a transaction calls `targetFn` + /// on the configured vault, run `assertionFn` against the pre- and post-call + /// state forks." The inherited `_register*Triggers()` cover the standard + /// ERC-4626 invariants; the `_registerSpark*Triggers()` helpers below extend + /// that wiring to Spark's non-standard surfaces. + function triggers() external view override { + _registerSharePriceTriggers(); + _registerPreviewTriggers(); + _registerCumulativeOutflowTriggers(); + _registerSparkReferralOverloadTriggers(); + _registerSparkRateAccumulationTriggers(); + _registerSparkManagedLiquidityTriggers(); + } + + /// @notice Reuses the inherited share-price and preview assertions against Spark's + /// referral-enabled `deposit`/`mint` overloads. + /// @dev The referral forms share the leading `(assets, receiver)` / `(shares, receiver)` + /// calldata layout of the standard ERC-4626 entrypoints, so the same assertion + /// logic applies — we just have to register the extra selectors by hand since + /// the parent contracts only know about the canonical ERC-4626 ones. + function _registerSparkReferralOverloadTriggers() internal view { + registerFnCallTrigger(this.assertPerCallSharePrice.selector, ISparkVaultReferralLike.deposit.selector); + registerFnCallTrigger(this.assertPerCallSharePrice.selector, ISparkVaultReferralLike.mint.selector); + + registerFnCallTrigger(this.assertDepositPreview.selector, ISparkVaultReferralLike.deposit.selector); + registerFnCallTrigger(this.assertMintPreview.selector, ISparkVaultReferralLike.mint.selector); + } + + /// @notice Fires `assertSparkAccrualSettled` after every vault path that Spark's + /// rate-accrual design requires to fully settle pending `chi` growth. + /// @dev Covers the four standard ERC-4626 mutators, their referral overloads, the + /// explicit `drip()` accrual, and `setVsr()` (which must drip the old rate + /// before applying the new one). Any call matching one of these selectors + /// must leave `nowChi() == chi()` after execution — see the assertion below. + function _registerSparkRateAccumulationTriggers() internal view { + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, IERC4626.deposit.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, IERC4626.mint.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, IERC4626.withdraw.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, IERC4626.redeem.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, ISparkVaultReferralLike.deposit.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, ISparkVaultReferralLike.mint.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, ISparkVaultRateLike.drip.selector); + registerFnCallTrigger(this.assertSparkAccrualSettled.selector, ISparkVaultRateLike.setVsr.selector); + } + + /// @notice Fires `assertSparkTakeAccounting` whenever `take()` moves vault liquidity + /// into Spark's managed-assets bucket. + /// @dev `take()` is the only selector that shifts underlying out of the vault without + /// minting or burning shares, so it gets its own assertion to verify the + /// liability side (shares, `totalAssets`, rate state) is untouched by the move. + function _registerSparkManagedLiquidityTriggers() internal view { + registerFnCallTrigger(this.assertSparkTakeAccounting.selector, ISparkVaultLiquidityLike.take.selector); + } + + /// @notice Spark mutating accrual paths must fully realize pending growth into `chi`. + /// @dev Spark's ERC-4626 mutators, `drip`, and `setVsr` all settle the previous block's + /// accrued value before finishing. After the call there should be no additional + /// same-block accrual left in `nowChi()`. + /// + /// `ph.context()` returns the trigger context for the call that matched one of + /// the registered selectors; `_preCall`/`_postCall` give us read-only state + /// forks pinned to the moments immediately before and after that call so we can + /// diff any storage the vault exposes via view functions. + function assertSparkAccrualSettled() external { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + uint256 preChi = _sparkChiAt(beforeFork); + uint256 preRho = _sparkRhoAt(beforeFork); + uint256 preNowChi = _sparkNowChiAt(beforeFork); + + uint256 postChi = _sparkChiAt(afterFork); + uint256 postRho = _sparkRhoAt(afterFork); + uint256 postNowChi = _sparkNowChiAt(afterFork); + + require(postChi >= preChi, "SparkVault: chi decreased"); + require(postRho >= preRho, "SparkVault: rho decreased"); + require(postChi == preNowChi, "SparkVault: accrued chi not realized"); + require(postNowChi == postChi, "SparkVault: pending accrual left after call"); + } + + /// @notice `take()` must only move vault liquidity into Spark's outstanding-assets bucket. + /// @dev Spark liabilities are based on shares and `nowChi()`, not on-hand ERC-20 balance. + /// A successful `take()` therefore leaves `totalAssets`, `totalSupply`, and the rate + /// accumulator untouched while reducing vault liquidity and increasing + /// `assetsOutstanding()` by the same amount. + function assertSparkTakeAccounting() external { + PhEvm.TriggerContext memory ctx = ph.context(); + bytes memory input = ph.callinputAt(ctx.callStart); + (uint256 value) = abi.decode(_stripSelector(input), (uint256)); + + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + uint256 preTotalAssets = _totalAssetsAt(beforeFork); + uint256 postTotalAssets = _totalAssetsAt(afterFork); + uint256 preTotalSupply = _totalSupplyAt(beforeFork); + uint256 postTotalSupply = _totalSupplyAt(afterFork); + uint256 preLiquidity = _assetBalanceAt(vault, beforeFork); + uint256 postLiquidity = _assetBalanceAt(vault, afterFork); + uint256 preOutstanding = _sparkAssetsOutstandingAt(beforeFork); + uint256 postOutstanding = _sparkAssetsOutstandingAt(afterFork); + + require(postTotalAssets == preTotalAssets, "SparkVault: take changed totalAssets"); + require(postTotalSupply == preTotalSupply, "SparkVault: take changed totalSupply"); + require(_sparkChiAt(afterFork) == _sparkChiAt(beforeFork), "SparkVault: take changed chi"); + require(_sparkRhoAt(afterFork) == _sparkRhoAt(beforeFork), "SparkVault: take changed rho"); + require(_sparkVsrAt(afterFork) == _sparkVsrAt(beforeFork), "SparkVault: take changed vsr"); + + require(preLiquidity >= postLiquidity, "SparkVault: take increased liquidity"); + require(preLiquidity - postLiquidity == value, "SparkVault: take liquidity delta mismatch"); + require(postOutstanding >= preOutstanding, "SparkVault: take decreased assetsOutstanding"); + require(postOutstanding - preOutstanding == value, "SparkVault: take outstanding delta mismatch"); + } +} diff --git a/examples/spark/src/SparkVaultHelpers.sol b/examples/spark/src/SparkVaultHelpers.sol new file mode 100644 index 0000000..33ea2aa --- /dev/null +++ b/examples/spark/src/SparkVaultHelpers.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {ERC4626BaseAssertion} from "credible-std/protection/vault/ERC4626BaseAssertion.sol"; + +import {ISparkVaultLiquidityLike, ISparkVaultRateLike} from "./SparkVaultInterfaces.sol"; + +/// @title SparkVaultHelpers +/// @author Phylax Systems +/// @notice Shared Spark vault state accessors for the example assertion bundle. +abstract contract SparkVaultHelpers is ERC4626BaseAssertion { + function _sparkChiAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISparkVaultRateLike.chi, ()), fork); + } + + function _sparkRhoAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISparkVaultRateLike.rho, ()), fork); + } + + function _sparkVsrAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISparkVaultRateLike.vsr, ()), fork); + } + + function _sparkNowChiAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISparkVaultRateLike.nowChi, ()), fork); + } + + function _sparkAssetsOutstandingAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISparkVaultLiquidityLike.assetsOutstanding, ()), fork); + } +} diff --git a/examples/spark/src/SparkVaultInterfaces.sol b/examples/spark/src/SparkVaultInterfaces.sol new file mode 100644 index 0000000..2c63fa9 --- /dev/null +++ b/examples/spark/src/SparkVaultInterfaces.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @title ISparkVaultReferralLike +/// @author Phylax Systems +/// @notice Minimal Spark vault extension surface needed by the example assertion bundle. +/// @dev Spark adds referral overloads for deposit/mint, so the example registers their selectors +/// explicitly in addition to the standard ERC-4626 entrypoints. +interface ISparkVaultReferralLike { + function deposit(uint256 assets, address receiver, uint16 referral) external returns (uint256 shares); + function mint(uint256 shares, address receiver, uint16 referral) external returns (uint256 assets); +} + +/// @title ISparkVaultRateLike +/// @author Phylax Systems +/// @notice Minimal Spark rate-accumulator surface needed by the example assertion bundle. +interface ISparkVaultRateLike { + function chi() external view returns (uint192); + function rho() external view returns (uint64); + function vsr() external view returns (uint256); + function nowChi() external view returns (uint256); + + function drip() external returns (uint256 nChi); + function setVsr(uint256 newVsr) external; +} + +/// @title ISparkVaultLiquidityLike +/// @author Phylax Systems +/// @notice Minimal Spark managed-liquidity surface needed by the example assertion bundle. +interface ISparkVaultLiquidityLike { + function take(uint256 value) external; + function assetsOutstanding() external view returns (uint256); +} diff --git a/examples/symbiotic/README.md b/examples/symbiotic/README.md new file mode 100644 index 0000000..b7d7e05 --- /dev/null +++ b/examples/symbiotic/README.md @@ -0,0 +1,21 @@ +# symbiotic examples + +Assertion examples and supporting helpers extracted from the `symbiotic` branch. + +## Build + +```sh +FOUNDRY_PROFILE=symbiotic forge build +``` + +## Files + +- SymbioticHelpers.sol +- SymbioticInterfaces.sol +- SymbioticRelayAssertion.sol +- SymbioticVaultAssertion.sol +- SymbioticVaultBaseAssertion.sol +- SymbioticVaultCircuitBreakerAssertion.sol +- SymbioticVaultConfigAssertion.sol +- SymbioticVaultFlowAssertion.sol +- SymbioticVaultFlowHelpers.sol diff --git a/examples/symbiotic/src/SymbioticHelpers.sol b/examples/symbiotic/src/SymbioticHelpers.sol new file mode 100644 index 0000000..23c0084 --- /dev/null +++ b/examples/symbiotic/src/SymbioticHelpers.sol @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import { + ISymbioticBaseSlasherLike, + ISymbioticDelegatorLike, + ISymbioticOpNetVaultAutoDeployLike, + ISymbioticVetoSlasherLike, + ISymbioticVaultLike, + ISymbioticVotingPowerProviderLike +} from "./SymbioticInterfaces.sol"; + +/// @title SymbioticHelpers +/// @author Phylax Systems +/// @notice Shared reads and small utilities used by Symbiotic relay- and vault-side assertions. +abstract contract SymbioticHelpers is Assertion { + function _burnerAt(address vault, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(vault, abi.encodeCall(ISymbioticVaultLike.burner, ()), fork); + } + + function _delegatorAddressAt(address vault, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(vault, abi.encodeCall(ISymbioticVaultLike.delegator, ()), fork); + } + + function _slasherAddressAt(address vault, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(vault, abi.encodeCall(ISymbioticVaultLike.slasher, ()), fork); + } + + function _isInitializedAt(address vault, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.isInitialized, ()), fork); + } + + function _isDelegatorInitializedAt(address vault, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.isDelegatorInitialized, ()), fork); + } + + function _isSlasherInitializedAt(address vault, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.isSlasherInitialized, ()), fork); + } + + function _epochDurationAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.epochDuration, ()), fork); + } + + function _currentEpochAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.currentEpoch, ()), fork); + } + + function _depositWhitelistAt(address vault, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.depositWhitelist, ()), fork); + } + + function _isDepositorWhitelistedAt(address vault, address account, PhEvm.ForkId memory fork) + internal + view + returns (bool) + { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.isDepositorWhitelisted, (account)), fork); + } + + function _isDepositLimitAt(address vault, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.isDepositLimit, ()), fork); + } + + function _depositLimitAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.depositLimit, ()), fork); + } + + function _activeStakeAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.activeStake, ()), fork); + } + + function _activeSharesAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.activeShares, ()), fork); + } + + function _activeSharesOfAt(address vault, address account, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.activeSharesOf, (account)), fork); + } + + function _withdrawalsAt(address vault, uint256 epoch, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.withdrawals, (epoch)), fork); + } + + function _withdrawalSharesAt(address vault, uint256 epoch, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.withdrawalShares, (epoch)), fork); + } + + function _withdrawalSharesOfAt(address vault, uint256 epoch, address account, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.withdrawalSharesOf, (epoch, account)), fork); + } + + function _isWithdrawalsClaimedAt(address vault, uint256 epoch, address account, PhEvm.ForkId memory fork) + internal + view + returns (bool) + { + return _readBoolAt(vault, abi.encodeCall(ISymbioticVaultLike.isWithdrawalsClaimed, (epoch, account)), fork); + } + + function _totalStakeAt(address vault, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(ISymbioticVaultLike.totalStake, ()), fork); + } + + function _delegatorVaultAt(address delegator, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(delegator, abi.encodeCall(ISymbioticDelegatorLike.vault, ()), fork); + } + + function _slasherVaultAt(address slasher, PhEvm.ForkId memory fork) internal view returns (address) { + return _readAddressAt(slasher, abi.encodeCall(ISymbioticBaseSlasherLike.vault, ()), fork); + } + + function _slasherIsBurnerHookAt(address slasher, PhEvm.ForkId memory fork) internal view returns (bool) { + return _readBoolAt(slasher, abi.encodeCall(ISymbioticBaseSlasherLike.isBurnerHook, ()), fork); + } + + function _tryVetoDurationAt(address slasher, PhEvm.ForkId memory fork) + internal + view + returns (bool ok, uint256 value) + { + return _tryReadUintAt(slasher, abi.encodeCall(ISymbioticVetoSlasherLike.vetoDuration, ()), fork); + } + + function _tryResolverSetEpochsDelayAt(address slasher, PhEvm.ForkId memory fork) + internal + view + returns (bool ok, uint256 value) + { + return _tryReadUintAt(slasher, abi.encodeCall(ISymbioticVetoSlasherLike.resolverSetEpochsDelay, ()), fork); + } + + function _asProvider(address provider) internal pure returns (ISymbioticVotingPowerProviderLike) { + return ISymbioticVotingPowerProviderLike(provider); + } + + function _asAutoDeploy(address provider) internal pure returns (ISymbioticOpNetVaultAutoDeployLike) { + return ISymbioticOpNetVaultAutoDeployLike(provider); + } + + /// @notice Returns the trigger call matching the current `TriggerContext` for `target`. + function _currentTriggerCall(address target, PhEvm.TriggerContext memory ctx) + internal + view + returns (PhEvm.TriggerCall memory) + { + PhEvm.TriggerCall[] memory calls = _matchingCalls(target, ctx.selector, 256); + for (uint256 i; i < calls.length; ++i) { + if (calls[i].callId == ctx.callStart) { + return calls[i]; + } + } + revert("SymbioticHelpers: missing trigger call"); + } + + /// @notice Drops the 4-byte selector prefix from ABI-encoded calldata. + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "SymbioticHelpers: input too short"); + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } + + function _containsAddress(address[] memory values, address needle) internal pure returns (bool) { + for (uint256 i; i < values.length; ++i) { + if (values[i] == needle) { + return true; + } + } + return false; + } + + function _findVaultValue(ISymbioticVotingPowerProviderLike.VaultValue[] memory values, address vault_) + internal + pure + returns (bool found, uint256 value) + { + for (uint256 i; i < values.length; ++i) { + if (values[i].vault == vault_) { + return (true, values[i].value); + } + } + return (false, 0); + } + + function _tryViewAt(address target, bytes memory data, PhEvm.ForkId memory fork) + internal + view + returns (bool ok, bytes memory resultData) + { + PhEvm.StaticCallResult memory result = ph.staticcallAt(target, data, FORK_VIEW_GAS, fork); + return (result.ok, result.data); + } + + function _tryReadUintAt(address target, bytes memory data, PhEvm.ForkId memory fork) + internal + view + returns (bool ok, uint256 value) + { + bytes memory resultData; + (ok, resultData) = _tryViewAt(target, data, fork); + if (!ok) { + return (false, 0); + } + value = abi.decode(resultData, (uint256)); + } +} diff --git a/examples/symbiotic/src/SymbioticInterfaces.sol b/examples/symbiotic/src/SymbioticInterfaces.sol new file mode 100644 index 0000000..05c53a6 --- /dev/null +++ b/examples/symbiotic/src/SymbioticInterfaces.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +interface ISymbioticVaultLike { + function collateral() external view returns (address); + function burner() external view returns (address); + function delegator() external view returns (address); + function slasher() external view returns (address); + function isInitialized() external view returns (bool); + function isDelegatorInitialized() external view returns (bool); + function isSlasherInitialized() external view returns (bool); + function currentEpoch() external view returns (uint256); + function epochDuration() external view returns (uint48); + function depositWhitelist() external view returns (bool); + function isDepositorWhitelisted(address account) external view returns (bool); + function isDepositLimit() external view returns (bool); + function depositLimit() external view returns (uint256); + function activeStake() external view returns (uint256); + function activeShares() external view returns (uint256); + function activeSharesOf(address account) external view returns (uint256); + function withdrawals(uint256 epoch) external view returns (uint256); + function withdrawalShares(uint256 epoch) external view returns (uint256); + function withdrawalSharesOf(uint256 epoch, address account) external view returns (uint256); + function isWithdrawalsClaimed(uint256 epoch, address account) external view returns (bool); + function totalStake() external view returns (uint256); + + function deposit(address onBehalfOf, uint256 amount) + external + returns (uint256 depositedAmount, uint256 mintedShares); + function withdraw(address claimer, uint256 amount) external returns (uint256 burnedShares, uint256 mintedShares); + function redeem(address claimer, uint256 shares) external returns (uint256 withdrawnAssets, uint256 mintedShares); + function claim(address recipient, uint256 epoch) external returns (uint256 amount); + function claimBatch(address recipient, uint256[] calldata epochs) external returns (uint256 amount); +} + +interface ISymbioticDelegatorLike { + function vault() external view returns (address); + function stake(bytes32 subnetwork, address operator) external view returns (uint256); + function maxNetworkLimit(bytes32 subnetwork) external view returns (uint256); +} + +interface ISymbioticOperatorNetworkSpecificDelegatorLike is ISymbioticDelegatorLike { + function network() external view returns (address); + function operator() external view returns (address); +} + +interface ISymbioticVotingPowerProviderLike { + struct VaultValue { + address vault; + uint256 value; + } + + function isTokenRegistered(address token) external view returns (bool); + function getOperators() external view returns (address[] memory); + function isOperatorVaultRegistered(address operator, address vault) external view returns (bool); + function getOperatorVaults(address operator) external view returns (address[] memory); + function getOperatorStakes(address operator) external view returns (VaultValue[] memory); + function getOperatorVotingPowers(address operator, bytes memory extraData) + external + view + returns (VaultValue[] memory); +} + +interface ISymbioticOpNetVaultAutoDeployLike is ISymbioticVotingPowerProviderLike { + function getAutoDeployedVault(address operator) external view returns (address); + function isSetMaxNetworkLimitHookEnabled() external view returns (bool); +} + +interface ISymbioticBaseSlasherLike { + function vault() external view returns (address); + function isBurnerHook() external view returns (bool); +} + +interface ISymbioticVetoSlasherLike is ISymbioticBaseSlasherLike { + function vetoDuration() external view returns (uint48); + function resolverSetEpochsDelay() external view returns (uint256); +} diff --git a/examples/symbiotic/src/SymbioticRelayAssertion.sol b/examples/symbiotic/src/SymbioticRelayAssertion.sol new file mode 100644 index 0000000..299bf4d --- /dev/null +++ b/examples/symbiotic/src/SymbioticRelayAssertion.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {SymbioticHelpers} from "./SymbioticHelpers.sol"; +import { + ISymbioticVaultLike, + ISymbioticDelegatorLike, + ISymbioticVotingPowerProviderLike +} from "./SymbioticInterfaces.sol"; + +/// @title SymbioticRelayAssertion +/// @author Phylax Systems +/// @notice Relay-side assertions for operator vault registration, collateral coherence, +/// equal-stake voting power, and the optional auto-deploy network-limit hook. +/// @dev Register this against the relay VotingPowerProvider / OpNetVaultAutoDeploy contract. +/// +/// - protects against stale relay pointers to unregistered or missing operator vaults; +/// - protects against voting power being sourced from unsupported collateral; +/// - protects against drift between Symbiotic stake and relay voting power under equal-stake math; +/// - protects against auto-deploy hooks silently failing to open the intended subnetwork limit. +contract SymbioticRelayAssertion is SymbioticHelpers { + address internal immutable provider; + bytes32 internal immutable subnetwork; + bytes internal operatorVotingPowerExtraData; + + constructor(address provider_, bytes32 subnetwork_, bytes memory operatorVotingPowerExtraData_) { + provider = provider_; + subnetwork = subnetwork_; + operatorVotingPowerExtraData = operatorVotingPowerExtraData_; + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires the relay-side tx-end checks. + /// @dev Relay risk is mostly global consistency risk, so these assertions run once after the + /// transaction to inspect the final operator/vault/voting-power view. + function triggers() external view override { + registerTxEndTrigger(this.assertRegisteredOperatorVaultsUseRegisteredCollateral.selector); + registerTxEndTrigger(this.assertAutoDeployedVaultRegistrationCoherence.selector); + registerTxEndTrigger(this.assertEqualStakeVotingPower.selector); + registerTxEndTrigger(this.assertAutoDeployMaxNetworkLimitHook.selector); + } + + /// @notice Every registered operator vault should still use a relay-registered collateral token. + /// @dev Protects against the relay sourcing voting power from stale vault lists or unsupported assets. + /// After the transaction, every listed operator vault should still be both registered and collateral-valid. + function assertRegisteredOperatorVaultsUseRegisteredCollateral() external view { + address[] memory operators = _asProvider(provider).getOperators(); + for (uint256 i; i < operators.length; ++i) { + address[] memory vaults = _asProvider(provider).getOperatorVaults(operators[i]); + for (uint256 j; j < vaults.length; ++j) { + // The relay's operator->vault list should agree with its registration check. + require( + _asProvider(provider).isOperatorVaultRegistered(operators[i], vaults[j]), + "SymbioticRelay: listed operator vault is not registered" + ); + // Relay voting power assumptions only make sense if the vault collateral is still accepted. + require( + _asProvider(provider).isTokenRegistered(ISymbioticVaultLike(vaults[j]).collateral()), + "SymbioticRelay: operator vault collateral is not registered" + ); + } + } + } + + /// @notice Auto-deployed vault pointers must agree with the operator vault registry. + /// @dev Protects against stale auto-deploy pointers that no longer correspond to the relay's actual registry view. + /// After the transaction, every nonzero auto-deployed vault pointer should still be registered and enumerable. + function assertAutoDeployedVaultRegistrationCoherence() external view { + address[] memory operators = _asProvider(provider).getOperators(); + for (uint256 i; i < operators.length; ++i) { + address autoVault = _asAutoDeploy(provider).getAutoDeployedVault(operators[i]); + if (autoVault == address(0)) { + continue; + } + + // Auto-deploy should not leave behind a stale pointer to an unregistered vault. + require( + _asProvider(provider).isOperatorVaultRegistered(operators[i], autoVault), + "SymbioticRelay: auto-deployed vault is not registered for operator" + ); + require( + _containsAddress(_asProvider(provider).getOperatorVaults(operators[i]), autoVault), + "SymbioticRelay: auto-deployed vault missing from operator vault set" + ); + } + } + + /// @notice Under EqualStakeVPCalc, voting power should match stake for registered-collateral vaults. + /// @dev Protects against the relay drifting away from its "1 stake = 1 voting power" assumption. + /// After the transaction, each registered-collateral vault should report the same stake and voting power. + function assertEqualStakeVotingPower() external view { + address[] memory operators = _asProvider(provider).getOperators(); + for (uint256 i; i < operators.length; ++i) { + ISymbioticVotingPowerProviderLike.VaultValue[] memory stakes = + _asProvider(provider).getOperatorStakes(operators[i]); + ISymbioticVotingPowerProviderLike.VaultValue[] memory votingPowers = + _asProvider(provider).getOperatorVotingPowers(operators[i], operatorVotingPowerExtraData); + + for (uint256 j; j < stakes.length; ++j) { + address collateral = ISymbioticVaultLike(stakes[j].vault).collateral(); + if (!_asProvider(provider).isTokenRegistered(collateral)) { + continue; + } + + // With EqualStakeVPCalc, "stake" and "voting power" should be the same number. + (bool found, uint256 votingPower) = _findVaultValue(votingPowers, stakes[j].vault); + require(found, "SymbioticRelay: missing voting power entry for registered stake"); + require(votingPower == stakes[j].value, "SymbioticRelay: equal-stake voting power mismatch"); + } + } + } + + /// @notice When the max-network-limit hook is enabled, auto-deployed vaults should expose full subnetwork availability. + /// @dev Protects against deployments that think they opened full subnetwork capacity but left the delegator capped. + /// After the transaction, the hook-enabled path should expose `type(uint256).max` for the target subnetwork. + function assertAutoDeployMaxNetworkLimitHook() external view { + if (!_asAutoDeploy(provider).isSetMaxNetworkLimitHookEnabled()) { + return; + } + + address[] memory operators = _asProvider(provider).getOperators(); + for (uint256 i; i < operators.length; ++i) { + address autoVault = _asAutoDeploy(provider).getAutoDeployedVault(operators[i]); + if (autoVault == address(0) || !_asProvider(provider).isOperatorVaultRegistered(operators[i], autoVault)) { + continue; + } + + address delegator = ISymbioticVaultLike(autoVault).delegator(); + // When this hook is on, newly auto-deployed vaults should be fully available to the relay subnetwork. + require( + ISymbioticDelegatorLike(delegator).maxNetworkLimit(subnetwork) == type(uint256).max, + "SymbioticRelay: max network limit hook did not set full availability" + ); + } + } +} diff --git a/examples/symbiotic/src/SymbioticVaultAssertion.sol b/examples/symbiotic/src/SymbioticVaultAssertion.sol new file mode 100644 index 0000000..df2167e --- /dev/null +++ b/examples/symbiotic/src/SymbioticVaultAssertion.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {SymbioticVaultBaseAssertion} from "./SymbioticVaultBaseAssertion.sol"; +import {SymbioticVaultCircuitBreakerAssertion} from "./SymbioticVaultCircuitBreakerAssertion.sol"; +import {SymbioticVaultConfigAssertion} from "./SymbioticVaultConfigAssertion.sol"; +import {SymbioticVaultFlowAssertion} from "./SymbioticVaultFlowAssertion.sol"; + +/// @title SymbioticVaultAssertion +/// @author Phylax Systems +/// @notice Spark-style concrete assertion bundle for Symbiotic vaults. +/// @dev Compose the abstract flow, config, and circuit-breaker modules behind one entrypoint. +/// This matches the `origin/spark` pattern where small reusable assertion contracts inherit +/// a shared base, and the top-level assertion only wires constructors and triggers. +/// +/// - flow assertions protect against mis-accounted deposits, premature withdrawals, +/// underpaid claims, and broken stake bucket accounting; +/// - config assertions protect against half-initialized or economically unsafe vault setup; +/// - circuit breakers protect against abnormal collateral flight while still allowing +/// liquidation and healing paths. +contract SymbioticVaultAssertion is + SymbioticVaultFlowAssertion, + SymbioticVaultConfigAssertion, + SymbioticVaultCircuitBreakerAssertion +{ + constructor( + address vault_, + address asset_, + VaultConfigPolicy memory policy_, + LiquidationRoute[] memory liquidationRoutes_ + ) + SymbioticVaultBaseAssertion(vault_, asset_) + SymbioticVaultConfigAssertion(policy_) + SymbioticVaultCircuitBreakerAssertion(liquidationRoutes_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires the full Symbiotic vault protection suite. + /// @dev Per-call triggers watch user-facing vault mutations, tx-end checks catch config/state + /// drift that may emerge across a whole transaction, and cumulative-outflow watchers + /// provide rolling-window circuit breakers on the vault collateral. + function triggers() external view override { + _registerVaultFlowTriggers(); + _registerVaultConfigTriggers(); + _registerCircuitBreakerTriggers(); + } +} diff --git a/examples/symbiotic/src/SymbioticVaultBaseAssertion.sol b/examples/symbiotic/src/SymbioticVaultBaseAssertion.sol new file mode 100644 index 0000000..a51f1ac --- /dev/null +++ b/examples/symbiotic/src/SymbioticVaultBaseAssertion.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {SymbioticHelpers} from "./SymbioticHelpers.sol"; + +/// @title SymbioticVaultBaseAssertion +/// @author Phylax Systems +/// @notice Shared base contract for Symbiotic vault assertions. +/// @dev Mirrors the `origin/spark` pattern: small abstract invariant modules inherit this base, +/// then a concrete bundle contract composes them and implements `triggers()`. +abstract contract SymbioticVaultBaseAssertion is SymbioticHelpers { + /// @notice The Symbiotic vault being monitored. + address internal immutable vault; + + /// @notice The ERC-20 collateral backing the vault. + address internal immutable asset; + + /// @dev `asset_` is passed explicitly so the constructor never reads `vault_.collateral()`. + /// The Credible Layer's assertion-deploy runtime is isolated from the adopter; live + /// protocol reads during construction would revert with EXTCODESIZE = 0. + constructor(address vault_, address asset_) { + require(vault_ != address(0), "SymbioticVaultBase: vault is zero"); + vault = vault_; + asset = asset_; + } +} diff --git a/examples/symbiotic/src/SymbioticVaultCircuitBreakerAssertion.sol b/examples/symbiotic/src/SymbioticVaultCircuitBreakerAssertion.sol new file mode 100644 index 0000000..306b5df --- /dev/null +++ b/examples/symbiotic/src/SymbioticVaultCircuitBreakerAssertion.sol @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {ISymbioticVaultLike} from "./SymbioticInterfaces.sol"; +import {SymbioticVaultBaseAssertion} from "./SymbioticVaultBaseAssertion.sol"; + +/// @title SymbioticVaultCircuitBreakerAssertion +/// @author Phylax Systems +/// @notice Two-tier cumulative outflow circuit breaker for a Symbiotic vault's collateral. +/// @dev This uses `watchCumulativeOutflow`, so once the soft threshold is breached the assertion +/// keeps firing on every later transaction that touches the vault until enough collateral +/// flows back in to bring the rolling window back under the limit. +/// +/// The soft tier is "liquidation-aware": +/// - if the current transaction does not create new net collateral outflow, allow it so +/// deposits or other healing flows can continue; +/// - if the current transaction does create new net outflow, allow it only when the tx +/// includes one of the configured liquidation calls; +/// - otherwise revert. +/// +/// The hard tier is a full stop: once the larger rolling-window threshold is breached, all +/// later touching transactions revert until the outflow state recovers below the threshold. +/// - protects against rapid collateral flight that may indicate a bank-run or exploit; +/// - keeps the smaller breaker liquidation-aware so bad debt can still be resolved; +/// - preserves healing paths by allowing deposits or net-neutral transactions during stress; +/// - escalates to a hard stop when outflows reach a much larger 24-hour threshold. +abstract contract SymbioticVaultCircuitBreakerAssertion is SymbioticVaultBaseAssertion { + struct LiquidationRoute { + address target; + bytes4 selector; + } + + /// @notice Hourly soft tier: 10% of the vault collateral TVL snapshot. + uint256 public constant HOURLY_THRESHOLD_BPS = 1_000; + uint256 public constant HOURLY_WINDOW_DURATION = 1 hours; + + /// @notice Daily hard tier: 30% of the vault collateral TVL snapshot. + uint256 public constant DAILY_THRESHOLD_BPS = 3_000; + uint256 public constant DAILY_WINDOW_DURATION = 24 hours; + + /// @notice Allowlisted liquidation entry points that may legitimately increase outflow. + LiquidationRoute[] public liquidationRoutes; + + constructor(LiquidationRoute[] memory liquidationRoutes_) { + require(liquidationRoutes_.length != 0, "SymbioticCircuitBreaker: missing liquidation routes"); + + for (uint256 i; i < liquidationRoutes_.length; ++i) { + require(liquidationRoutes_[i].target != address(0), "SymbioticCircuitBreaker: liquidation target is zero"); + require( + liquidationRoutes_[i].selector != bytes4(0), "SymbioticCircuitBreaker: liquidation selector is zero" + ); + liquidationRoutes.push(liquidationRoutes_[i]); + } + } + + /// @notice Register both cumulative outflow tiers on the vault collateral. + /// @dev These are rolling-window watchers rather than selector-based triggers because the + /// risk is cumulative asset flight, not misuse of one particular function. + function _registerCircuitBreakerTriggers() internal view { + watchCumulativeOutflow( + asset, + HOURLY_THRESHOLD_BPS, + HOURLY_WINDOW_DURATION, + this.assertHourlyLiquidationAwareCircuitBreaker.selector + ); + watchCumulativeOutflow( + asset, DAILY_THRESHOLD_BPS, DAILY_WINDOW_DURATION, this.assertDailyHardStopCircuitBreaker.selector + ); + } + + /// @notice Soft circuit breaker for the 10% / 1h tier. + /// @dev The important subtlety from `watchCumulativeOutflow` is that once breached, this + /// function runs on every later tx that touches the vault. Because of that, we should + /// not blindly revert: deposits and other healing flows must still be able to proceed. + function assertHourlyLiquidationAwareCircuitBreaker() external view { + PhEvm.OutflowContext memory ctx = ph.outflowContext(); + + require(ctx.token == asset, "SymbioticCircuitBreaker: unexpected outflow token"); + + // When the vault is already in a stressed outflow state, ordinary user exits should stop + // even if the current tx is only queueing a future withdrawal. + require(!_hasBlockedUserExitCall(), "SymbioticCircuitBreaker: user exits blocked during hourly breach"); + + // If this tx does not worsen net collateral outflow, let it through so the vault can heal. + if (_currentTxNetOutflow() == 0) { + return; + } + + // Net new outflow is only acceptable here when it comes from a known liquidation path. + require( + _hasApprovedLiquidationCall(), "SymbioticCircuitBreaker: hourly outflow breach without approved liquidation" + ); + } + + /// @notice Hard circuit breaker for the 30% / 24h tier. + /// @dev This is an unconditional stop by design. + function assertDailyHardStopCircuitBreaker() external pure { + revert("SymbioticCircuitBreaker: daily hard outflow breaker tripped"); + } + + /// @notice Returns true when the transaction contains an allowlisted liquidation call. + function _hasApprovedLiquidationCall() internal view returns (bool) { + for (uint256 i; i < liquidationRoutes.length; ++i) { + if (_matchingCalls(liquidationRoutes[i].target, liquidationRoutes[i].selector, 1).length != 0) { + return true; + } + } + return false; + } + + /// @notice Returns true when the transaction contains a normal vault exit path. + /// @dev These are blocked during the soft breach window so the breaker behaves like + /// a liquidation-and-healing-only mode rather than a blanket pass for all activity. + function _hasBlockedUserExitCall() internal view returns (bool) { + return _matchingCalls(vault, ISymbioticVaultLike.withdraw.selector, 1).length != 0 + || _matchingCalls(vault, ISymbioticVaultLike.redeem.selector, 1).length != 0 + || _matchingCalls(vault, ISymbioticVaultLike.claim.selector, 1).length != 0 + || _matchingCalls(vault, ISymbioticVaultLike.claimBatch.selector, 1).length != 0; + } + + /// @notice Computes the current transaction's net outflow from the vault for the monitored asset. + /// @dev This is tx-local, not the rolling-window value from `ph.outflowContext()`. + function _currentTxNetOutflow() internal view returns (uint256 netOutflow) { + PhEvm.Erc20TransferData[] memory deltas = _reducedErc20BalanceDeltasAt(asset, _postTx()); + uint256 totalOutflow; + uint256 totalInflow; + + // Look only at the vault's point of view: transfers out increase pressure, transfers in heal it. + for (uint256 i; i < deltas.length; ++i) { + if (deltas[i].from == vault) { + totalOutflow += deltas[i].value; + } + if (deltas[i].to == vault) { + totalInflow += deltas[i].value; + } + } + + return _consumedBetween(totalOutflow, totalInflow); + } +} + +/// @title SymbioticVaultCircuitBreakerProtection +/// @notice Ready-to-use bundle for the Symbiotic vault liquidation-aware circuit breaker. +/// @dev Use this when you only want the rolling outflow breakers without the flow or config checks. +contract SymbioticVaultCircuitBreakerProtection is SymbioticVaultCircuitBreakerAssertion { + constructor(address vault_, address asset_, LiquidationRoute[] memory liquidationRoutes_) + SymbioticVaultBaseAssertion(vault_, asset_) + SymbioticVaultCircuitBreakerAssertion(liquidationRoutes_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires only the cumulative-outflow circuit-breaker triggers. + /// @dev This bundle protects against abnormal asset drains while still allowing approved + /// liquidation routes and balance-healing transactions during the soft breach window. + function triggers() external view override { + _registerCircuitBreakerTriggers(); + } +} diff --git a/examples/symbiotic/src/SymbioticVaultConfigAssertion.sol b/examples/symbiotic/src/SymbioticVaultConfigAssertion.sol new file mode 100644 index 0000000..14809c4 --- /dev/null +++ b/examples/symbiotic/src/SymbioticVaultConfigAssertion.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {SymbioticVaultBaseAssertion} from "./SymbioticVaultBaseAssertion.sol"; + +/// @title SymbioticVaultConfigAssertion +/// @author Phylax Systems +/// @notice Configuration assertions for newly deployed Symbiotic vaults. +/// @dev This layer complements the deposit/withdraw flow assertions by checking that a vault is +/// wired up sanely for delegation and slashing. Some checks are hard protocol sanity, while +/// others are optional policy opinions derived from the docs and can be turned on or off. +/// +/// - protects against deploying a vault that is still only partially initialized; +/// - protects against accidentally launching with missing or miswired delegator/slasher links; +/// - protects against unsafe timing parameters such as absurd epochs or veto windows; +/// - protects against burner-hook slashers that cannot actually route slashed funds. +abstract contract SymbioticVaultConfigAssertion is SymbioticVaultBaseAssertion { + struct VaultConfigState { + bool isInitialized; + bool isDelegatorInitialized; + bool isSlasherInitialized; + address delegator; + address slasher; + address burner; + uint256 epochDuration; + } + + /// @notice Policy knobs for the config assertion. + /// @dev Keep the hard protocol facts enabled, and only opt into the stronger recommendations + /// when they match the deployment's intended risk model. + struct VaultConfigPolicy { + bool requireCompleteInitialization; + bool requireSlasher; + bool requireDelegatorVaultMatch; + bool requireSlasherVaultMatch; + bool requireBurnerWhenSlasherHooked; + uint48 minEpochDuration; + uint48 maxEpochDuration; + uint48 minVetoExecutionWindow; + uint256 minResolverSetEpochsDelay; + } + + VaultConfigPolicy internal policy; + + constructor(VaultConfigPolicy memory policy_) { + policy = policy_; + } + + /// @notice Register the standard tx-end trigger for vault configuration checks. + /// @dev Config safety is a whole-state property, so we check it once after the transaction + /// rather than tying it to a single mutator selector. + function _registerVaultConfigTriggers() internal view { + registerTxEndTrigger(this.assertVaultConfiguration.selector); + } + + /// @notice Checks that the vault is fully wired and respects the selected config policy. + /// @dev Protects against deployment/setup footguns that leave the vault only partially usable + /// or economically weaker than intended. After the transaction, the vault wiring and + /// timing parameters should satisfy both Symbiotic's hard constraints and the chosen policy. + function assertVaultConfiguration() external view { + PhEvm.ForkId memory postTx = _postTx(); + VaultConfigState memory state = _vaultConfigStateAt(postTx); + + _assertInitializationConsistency(state); + _assertRequiredInitialization(state); + _assertVaultLinkage(state, postTx); + _assertEpochDurationBounds(state); + + if (state.slasher == address(0)) { + return; + } + + _assertSlasherConfiguration(state, postTx); + _assertBurnerConfiguration(state, postTx); + _assertVetoConfiguration(state, postTx); + } + + function _vaultConfigStateAt(PhEvm.ForkId memory fork) internal view returns (VaultConfigState memory state) { + state = VaultConfigState({ + isInitialized: _isInitializedAt(vault, fork), + isDelegatorInitialized: _isDelegatorInitializedAt(vault, fork), + isSlasherInitialized: _isSlasherInitializedAt(vault, fork), + delegator: _delegatorAddressAt(vault, fork), + slasher: _slasherAddressAt(vault, fork), + burner: _burnerAt(vault, fork), + epochDuration: _epochDurationAt(vault, fork) + }); + } + + function _assertInitializationConsistency(VaultConfigState memory state) internal pure { + require( + state.isInitialized == (state.isDelegatorInitialized && state.isSlasherInitialized), + "SymbioticConfig: vault init flags are inconsistent" + ); + } + + function _assertRequiredInitialization(VaultConfigState memory state) internal view { + if (!policy.requireCompleteInitialization) { + return; + } + + require(state.isInitialized, "SymbioticConfig: vault is not fully initialized"); + require(state.delegator != address(0), "SymbioticConfig: delegator missing after initialization"); + + if (policy.requireSlasher) { + require(state.slasher != address(0), "SymbioticConfig: slashable vault expected but slasher is zero"); + require(state.isSlasherInitialized, "SymbioticConfig: slasher expected but not initialized"); + } + } + + function _assertVaultLinkage(VaultConfigState memory state, PhEvm.ForkId memory postTx) internal view { + if (policy.requireDelegatorVaultMatch && state.delegator != address(0)) { + require( + _delegatorVaultAt(state.delegator, postTx) == vault, + "SymbioticConfig: delegator points at a different vault" + ); + } + + if (policy.requireSlasher && !policy.requireCompleteInitialization) { + require(state.slasher != address(0), "SymbioticConfig: slashable vault expected but slasher is zero"); + require(state.isSlasherInitialized, "SymbioticConfig: slasher expected but not initialized"); + } + } + + function _assertEpochDurationBounds(VaultConfigState memory state) internal view { + if (policy.minEpochDuration != 0) { + require(state.epochDuration >= policy.minEpochDuration, "SymbioticConfig: epoch duration is too short"); + } + if (policy.maxEpochDuration != 0) { + require(state.epochDuration <= policy.maxEpochDuration, "SymbioticConfig: epoch duration is too long"); + } + } + + function _assertSlasherConfiguration(VaultConfigState memory state, PhEvm.ForkId memory postTx) internal view { + if (policy.requireSlasherVaultMatch) { + require( + _slasherVaultAt(state.slasher, postTx) == vault, "SymbioticConfig: slasher points at a different vault" + ); + } + + require( + state.epochDuration <= block.timestamp, "SymbioticConfig: epoch duration exceeds timestamp-safe bound" + ); + } + + function _assertBurnerConfiguration(VaultConfigState memory state, PhEvm.ForkId memory postTx) internal view { + if (policy.requireBurnerWhenSlasherHooked && _slasherIsBurnerHookAt(state.slasher, postTx)) { + require(state.burner != address(0), "SymbioticConfig: burner hook enabled but burner is zero"); + } + } + + function _assertVetoConfiguration(VaultConfigState memory state, PhEvm.ForkId memory postTx) internal view { + (bool isVetoSlasher, uint256 vetoDuration) = _tryVetoDurationAt(state.slasher, postTx); + if (!isVetoSlasher) { + return; + } + + require(vetoDuration < state.epochDuration, "SymbioticConfig: veto duration must be less than epoch duration"); + + if (policy.minVetoExecutionWindow != 0) { + require( + state.epochDuration - vetoDuration >= policy.minVetoExecutionWindow, + "SymbioticConfig: veto window leaves too little execution buffer" + ); + } + + if (policy.minResolverSetEpochsDelay != 0) { + (bool hasResolverDelay, uint256 resolverSetEpochsDelay) = + _tryResolverSetEpochsDelayAt(state.slasher, postTx); + require(hasResolverDelay, "SymbioticConfig: veto slasher missing resolver delay getter"); + require( + resolverSetEpochsDelay >= policy.minResolverSetEpochsDelay, + "SymbioticConfig: resolver delay is too short" + ); + } + } +} + +/// @title SymbioticVaultConfigProtection +/// @notice Ready-to-use bundle for Symbiotic vault configuration assertions with custom policy. +/// @dev Use this when you want deployment/config sanity checks without the vault-flow or +/// circuit-breaker layers. +contract SymbioticVaultConfigProtection is SymbioticVaultConfigAssertion { + constructor(address vault_, address asset_, VaultConfigPolicy memory policy_) + SymbioticVaultBaseAssertion(vault_, asset_) + SymbioticVaultConfigAssertion(policy_) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires only the tx-end configuration trigger. + /// @dev This bundle protects against half-configured or economically dangerous vault setup. + function triggers() external view override { + _registerVaultConfigTriggers(); + } +} + +/// @title SymbioticVaultRecommendedConfigProtection +/// @notice Convenience bundle using docs-inspired defaults without forcing a slashable vault. +/// @dev This is the opinionated version of `SymbioticVaultConfigProtection`: it keeps the +/// documentation-backed safety defaults while still permitting an intentional no-slasher vault. +contract SymbioticVaultRecommendedConfigProtection is SymbioticVaultConfigAssertion { + constructor(address vault_, address asset_) + SymbioticVaultBaseAssertion(vault_, asset_) + SymbioticVaultConfigAssertion(VaultConfigPolicy({ + requireCompleteInitialization: true, + requireSlasher: false, + requireDelegatorVaultMatch: true, + requireSlasherVaultMatch: true, + requireBurnerWhenSlasherHooked: true, + minEpochDuration: 1 days, + maxEpochDuration: 30 days, + minVetoExecutionWindow: 0, + minResolverSetEpochsDelay: 3 + })) + { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires the recommended tx-end configuration policy. + /// @dev This bundle is meant to catch the common Symbiotic deployment footguns from the docs. + function triggers() external view override { + _registerVaultConfigTriggers(); + } +} diff --git a/examples/symbiotic/src/SymbioticVaultFlowAssertion.sol b/examples/symbiotic/src/SymbioticVaultFlowAssertion.sol new file mode 100644 index 0000000..d9f0f5b --- /dev/null +++ b/examples/symbiotic/src/SymbioticVaultFlowAssertion.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; +import {ISymbioticVaultLike} from "./SymbioticInterfaces.sol"; +import {SymbioticVaultBaseAssertion} from "./SymbioticVaultBaseAssertion.sol"; +import {SymbioticVaultFlowHelpers} from "./SymbioticVaultFlowHelpers.sol"; + +/// @title SymbioticVaultFlowAssertion +/// @author Phylax Systems +/// @notice Assertions for Symbiotic Core vault deposit, withdraw, redeem, and claim flow. +/// @dev These checks target the base Symbiotic vault flow used by relay auto-deployed vaults. +/// +/// - protects against deposits minting the wrong amount of stake or shares; +/// - protects against withdraw/redeem paying out immediately instead of queueing; +/// - protects against claims for immature or already-claimed epochs; +/// - protects against drift between `totalStake` and the vault's internal stake buckets. +abstract contract SymbioticVaultFlowAssertion is SymbioticVaultFlowHelpers { + /// @notice Register the standard Symbiotic vault flow triggers. + /// @dev Use per-call triggers for user operations where we need precise pre/post-call deltas, + /// and a tx-end trigger for the global `totalStake` bucket identity. + function _registerVaultFlowTriggers() internal view { + registerFnCallTrigger(this.assertDepositAccounting.selector, ISymbioticVaultLike.deposit.selector); + registerFnCallTrigger(this.assertWithdrawScheduling.selector, ISymbioticVaultLike.withdraw.selector); + registerFnCallTrigger(this.assertRedeemScheduling.selector, ISymbioticVaultLike.redeem.selector); + registerFnCallTrigger(this.assertClaimFlow.selector, ISymbioticVaultLike.claim.selector); + registerFnCallTrigger(this.assertClaimBatchFlow.selector, ISymbioticVaultLike.claimBatch.selector); + registerTxEndTrigger(this.assertTotalStakeIdentity.selector); + } + + /// @notice Successful deposits must match token/accounting deltas and vault policy. + /// @dev Protects against deposits that move collateral but mis-account stake or shares. + /// After a successful deposit, the reported return values must match the observed deltas. + function assertDepositAccounting() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.TriggerCall memory call_ = _currentTriggerCall(vault, ctx); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + (address onBehalfOf,) = abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, uint256)); + (uint256 depositedAmount, uint256 mintedShares) = abi.decode(ph.callOutputAt(ctx.callStart), (uint256, uint256)); + + DepositDeltas memory deltas = _depositDeltasAt(onBehalfOf, preFork, postFork); + uint256 postActiveStake = _activeStakeAt(vault, postFork); + + require(deltas.assetDelta == depositedAmount, "SymbioticVault: deposit asset delta mismatch"); + require(deltas.activeStakeDelta == depositedAmount, "SymbioticVault: deposit activeStake mismatch"); + require(deltas.activeSharesDelta == mintedShares, "SymbioticVault: deposit activeShares mismatch"); + require(deltas.beneficiarySharesDelta == mintedShares, "SymbioticVault: deposit beneficiary shares mismatch"); + + _assertDepositPolicy(call_.caller, preFork, postFork, postActiveStake); + } + + /// @notice Withdrawals must queue assets into next epoch without moving collateral immediately. + /// @dev Protects against a vault paying collateral out too early or minting the wrong queued claim state. + /// After a successful withdraw, active stake/shares should go down and next-epoch claims should go up. + function assertWithdrawScheduling() external { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + (address claimer, uint256 amount) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, uint256)); + (uint256 burnedShares, uint256 mintedWithdrawalShares) = + abi.decode(ph.callOutputAt(ctx.callStart), (uint256, uint256)); + + uint256 nextEpoch = _currentEpochAt(vault, preFork) + 1; + + QueueDeltas memory deltas = _queueDeltasAt(claimer, nextEpoch, preFork, postFork); + + _assertNoImmediateCollateralOutflow( + preFork, postFork, "SymbioticVault: withdraw moved collateral immediately" + ); + require(deltas.activeStakeReduction == amount, "SymbioticVault: withdraw activeStake mismatch"); + require(deltas.activeSharesReduction == burnedShares, "SymbioticVault: withdraw burned shares mismatch"); + require(deltas.queuedAssetsIncrease == amount, "SymbioticVault: withdraw next-epoch withdrawals mismatch"); + require(deltas.queuedSharesIncrease == mintedWithdrawalShares, "SymbioticVault: withdraw epoch share mint mismatch"); + require( + deltas.claimerQueuedSharesIncrease == mintedWithdrawalShares, + "SymbioticVault: withdraw claimer share mint mismatch" + ); + } + + /// @notice Redeems must mirror withdraw scheduling and avoid immediate asset outflow. + /// @dev Protects against share-based exits bypassing the normal withdrawal queue. + /// After a successful redeem, the vault should only reshuffle internal buckets for the next epoch. + function assertRedeemScheduling() external { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + (address claimer, uint256 shares) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, uint256)); + (uint256 withdrawnAssets, uint256 mintedWithdrawalShares) = + abi.decode(ph.callOutputAt(ctx.callStart), (uint256, uint256)); + + uint256 nextEpoch = _currentEpochAt(vault, preFork) + 1; + + QueueDeltas memory deltas = _queueDeltasAt(claimer, nextEpoch, preFork, postFork); + + _assertNoImmediateCollateralOutflow(preFork, postFork, "SymbioticVault: redeem moved collateral immediately"); + require(deltas.activeSharesReduction == shares, "SymbioticVault: redeem activeShares mismatch"); + require(deltas.activeStakeReduction == withdrawnAssets, "SymbioticVault: redeem withdrawn assets mismatch"); + require(deltas.queuedAssetsIncrease == withdrawnAssets, "SymbioticVault: redeem withdrawals mismatch"); + require(deltas.queuedSharesIncrease == mintedWithdrawalShares, "SymbioticVault: redeem epoch share mint mismatch"); + require( + deltas.claimerQueuedSharesIncrease == mintedWithdrawalShares, + "SymbioticVault: redeem claimer share mint mismatch" + ); + } + + /// @notice Mature claims must pay exactly the amount reported by the vault and mark the epoch claimed. + /// @dev Protects against early, duplicate, underpaid, or untracked claims. + /// After a successful claim, one mature epoch should be paid exactly once and marked claimed. + function assertClaimFlow() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.TriggerCall memory call_ = _currentTriggerCall(vault, ctx); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + (address recipient, uint256 epoch) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, uint256)); + uint256 claimedAmount = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); + ClaimDeltas memory deltas = _claimDeltasAt(recipient, preFork, postFork); + + require(epoch < _currentEpochAt(vault, preFork), "SymbioticVault: claim succeeded for immature epoch"); + require(deltas.vaultOutflow == claimedAmount, "SymbioticVault: claim vault outflow mismatch"); + require(deltas.recipientInflow == claimedAmount, "SymbioticVault: claim recipient inflow mismatch"); + _assertClaimStateTransition(epoch, call_.caller, preFork, postFork, false); + } + + /// @notice Batch claims must only include mature epochs and pay exact collateral. + /// @dev Protects against a batch claim sneaking in immature epochs or failing to mark epochs as consumed. + /// After a successful batch claim, every epoch in the batch must be mature, newly claimed, and fully paid. + function assertClaimBatchFlow() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + PhEvm.TriggerCall memory call_ = _currentTriggerCall(vault, ctx); + PhEvm.ForkId memory preFork = _preCall(ctx.callStart); + PhEvm.ForkId memory postFork = _postCall(ctx.callEnd); + (address recipient, uint256[] memory epochs) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, uint256[])); + uint256 claimedAmount = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); + ClaimDeltas memory deltas = _claimDeltasAt(recipient, preFork, postFork); + uint256 currentEpoch = _currentEpochAt(vault, preFork); + + require(deltas.vaultOutflow == claimedAmount, "SymbioticVault: claimBatch vault outflow mismatch"); + require(deltas.recipientInflow == claimedAmount, "SymbioticVault: claimBatch recipient inflow mismatch"); + + for (uint256 i; i < epochs.length; ++i) { + require(epochs[i] < currentEpoch, "SymbioticVault: claimBatch succeeded for immature epoch"); + _assertClaimStateTransition(epochs[i], call_.caller, preFork, postFork, true); + } + } + + /// @notice Symbiotic vault total stake must equal active stake plus current and next epoch withdrawals. + /// @dev Protects against the vault's aggregate stake drifting away from its three storage buckets. + /// After any transaction, all stake should live in exactly one of: active, current queued, next queued. + function assertTotalStakeIdentity() external view { + PhEvm.ForkId memory postTx = _postTx(); + uint256 epoch = _currentEpochAt(vault, postTx); + uint256 totalStake = _totalStakeAt(vault, postTx); + uint256 activeStake = _activeStakeAt(vault, postTx); + uint256 currentWithdrawals = _withdrawalsAt(vault, epoch, postTx); + uint256 nextWithdrawals = _withdrawalsAt(vault, epoch + 1, postTx); + + // In the base Symbiotic vault flow, stake lives in three buckets: + // active now, queued for the current epoch, and queued for the next epoch. + require( + totalStake == activeStake + currentWithdrawals + nextWithdrawals, + "SymbioticVault: totalStake identity broken" + ); + } + +} + +/// @title SymbioticVaultProtection +/// @notice Ready-to-use bundle for Symbiotic Core vault-flow assertions. +/// @dev Use this when you only want deposit/withdraw/claim accounting checks without the +/// config-policy or circuit-breaker layers. +contract SymbioticVaultProtection is SymbioticVaultFlowAssertion { + constructor(address vault_, address asset_) SymbioticVaultBaseAssertion(vault_, asset_) { + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires only the vault-flow triggers. + /// @dev This bundle protects the happy-path accounting surface: deposit, withdraw, redeem, + /// claim, claimBatch, and the tx-wide total-stake identity. + function triggers() external view override { + _registerVaultFlowTriggers(); + } +} diff --git a/examples/symbiotic/src/SymbioticVaultFlowHelpers.sol b/examples/symbiotic/src/SymbioticVaultFlowHelpers.sol new file mode 100644 index 0000000..3cfe8f1 --- /dev/null +++ b/examples/symbiotic/src/SymbioticVaultFlowHelpers.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {SymbioticVaultBaseAssertion} from "./SymbioticVaultBaseAssertion.sol"; + +/// @title SymbioticVaultFlowHelpers +/// @author Phylax Systems +/// @notice Shared flow-specific structs, delta calculators, and helper checks for Symbiotic vault assertions. +/// @dev Keeps the flow assertion focused on the invariant checks while centralizing the +/// repetitive pre/post accounting reads and small flow-only validation helpers. +abstract contract SymbioticVaultFlowHelpers is SymbioticVaultBaseAssertion { + struct DepositDeltas { + uint256 assetDelta; + uint256 activeStakeDelta; + uint256 activeSharesDelta; + uint256 beneficiarySharesDelta; + } + + struct QueueDeltas { + uint256 activeStakeReduction; + uint256 activeSharesReduction; + uint256 queuedAssetsIncrease; + uint256 queuedSharesIncrease; + uint256 claimerQueuedSharesIncrease; + } + + struct ClaimDeltas { + uint256 vaultOutflow; + uint256 recipientInflow; + } + + function _depositDeltasAt(address onBehalfOf, PhEvm.ForkId memory preFork, PhEvm.ForkId memory postFork) + internal + view + returns (DepositDeltas memory deltas) + { + uint256 preAssetBalance = _readBalanceAt(asset, vault, preFork); + uint256 postAssetBalance = _readBalanceAt(asset, vault, postFork); + uint256 preActiveStake = _activeStakeAt(vault, preFork); + uint256 postActiveStake = _activeStakeAt(vault, postFork); + uint256 preActiveShares = _activeSharesAt(vault, preFork); + uint256 postActiveShares = _activeSharesAt(vault, postFork); + uint256 preBeneficiaryShares = _activeSharesOfAt(vault, onBehalfOf, preFork); + uint256 postBeneficiaryShares = _activeSharesOfAt(vault, onBehalfOf, postFork); + + deltas = DepositDeltas({ + assetDelta: postAssetBalance - preAssetBalance, + activeStakeDelta: postActiveStake - preActiveStake, + activeSharesDelta: postActiveShares - preActiveShares, + beneficiarySharesDelta: postBeneficiaryShares - preBeneficiaryShares + }); + } + + function _queueDeltasAt(address claimer, uint256 nextEpoch, PhEvm.ForkId memory preFork, PhEvm.ForkId memory postFork) + internal + view + returns (QueueDeltas memory deltas) + { + deltas = QueueDeltas({ + activeStakeReduction: _activeStakeAt(vault, preFork) - _activeStakeAt(vault, postFork), + activeSharesReduction: _activeSharesAt(vault, preFork) - _activeSharesAt(vault, postFork), + queuedAssetsIncrease: _withdrawalsAt(vault, nextEpoch, postFork) - _withdrawalsAt(vault, nextEpoch, preFork), + queuedSharesIncrease: _withdrawalSharesAt(vault, nextEpoch, postFork) + - _withdrawalSharesAt(vault, nextEpoch, preFork), + claimerQueuedSharesIncrease: _withdrawalSharesOfAt(vault, nextEpoch, claimer, postFork) + - _withdrawalSharesOfAt(vault, nextEpoch, claimer, preFork) + }); + } + + function _claimDeltasAt(address recipient, PhEvm.ForkId memory preFork, PhEvm.ForkId memory postFork) + internal + view + returns (ClaimDeltas memory deltas) + { + deltas = ClaimDeltas({ + vaultOutflow: _readBalanceAt(asset, vault, preFork) - _readBalanceAt(asset, vault, postFork), + recipientInflow: _readBalanceAt(asset, recipient, postFork) - _readBalanceAt(asset, recipient, preFork) + }); + } + + function _assertDepositPolicy(address caller, PhEvm.ForkId memory preFork, PhEvm.ForkId memory postFork, uint256 postActiveStake) + internal + view + { + if (_depositWhitelistAt(vault, preFork)) { + require( + _isDepositorWhitelistedAt(vault, caller, preFork), + "SymbioticVault: successful deposit by non-whitelisted caller" + ); + } + + if (_isDepositLimitAt(vault, postFork)) { + require( + postActiveStake <= _depositLimitAt(vault, postFork), + "SymbioticVault: deposit limit exceeded after deposit" + ); + } + } + + function _assertNoImmediateCollateralOutflow( + PhEvm.ForkId memory preFork, + PhEvm.ForkId memory postFork, + string memory err + ) internal { + require(ph.conserveBalance(preFork, postFork, asset, vault), err); + } + + function _assertClaimStateTransition( + uint256 epoch, + address claimant, + PhEvm.ForkId memory preFork, + PhEvm.ForkId memory postFork, + bool isBatch + ) internal view { + if (isBatch) { + require( + !_isWithdrawalsClaimedAt(vault, epoch, claimant, preFork), + "SymbioticVault: claimBatch epoch was already claimed before call" + ); + require( + _isWithdrawalsClaimedAt(vault, epoch, claimant, postFork), + "SymbioticVault: claimBatch epoch not marked claimed" + ); + return; + } + + require( + !_isWithdrawalsClaimedAt(vault, epoch, claimant, preFork), + "SymbioticVault: claim was already marked claimed before call" + ); + require( + _isWithdrawalsClaimedAt(vault, epoch, claimant, postFork), + "SymbioticVault: claim did not mark epoch claimed" + ); + } +} diff --git a/examples/tydro/README.md b/examples/tydro/README.md new file mode 100644 index 0000000..ff8b302 --- /dev/null +++ b/examples/tydro/README.md @@ -0,0 +1,13 @@ +# tydro examples + +Assertion examples and supporting helpers extracted from the `ink/assertions` branch. + +## Build + +```sh +FOUNDRY_PROFILE=tydro forge build +``` + +## Files + +- TydroOperationSafety.sol diff --git a/examples/tydro/src/TydroOperationSafety.sol b/examples/tydro/src/TydroOperationSafety.sol new file mode 100644 index 0000000..c0456a1 --- /dev/null +++ b/examples/tydro/src/TydroOperationSafety.sol @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ILendingProtectionSuite} from "credible-std/protection/lending/ILendingProtectionSuite.sol"; +import {AaveV3LikeOperationSafetyAssertionBase, AaveV3LikeProtectionSuite} from "credible-std/protection/lending/examples/AaveV3LikeOperationSafety.sol"; +import {IAaveV3LikePool} from "credible-std/protection/lending/examples/AaveV3LikeInterfaces.sol"; + +/// @notice Compact L2 Pool surface exposed by Tydro's Ink deployment. +interface ITydroL2Pool { + function borrow(bytes32 args) external; + function withdraw(bytes32 args) external returns (uint256); + function liquidationCall(bytes32 args1, bytes32 args2) external; + function setUserUseReserveAsCollateral(bytes32 args) external; +} + +/// @title TydroProtectionSuite +/// @author Phylax Systems +/// @notice Aave v3-like lending suite for Tydro on Ink. +/// @dev Tydro's Pool keeps the normal Aave v3-compatible ABI and also exposes the calldata- +/// compressed L2Pool entrypoints. This suite reuses the shared Aave v3-like accounting +/// checks and adds selector/decode support for compact L2 user operations. +contract TydroProtectionSuite is AaveV3LikeProtectionSuite { + uint256 internal constant L2_ASSET_ID_MASK = type(uint16).max; + uint256 internal constant L2_SHORTENED_AMOUNT_MASK = type(uint128).max; + uint256 internal constant L2_MAX_AMOUNT = type(uint128).max; + + constructor(address pool_, address addressesProvider_) AaveV3LikeProtectionSuite(pool_, addressesProvider_) {} + + /// @notice Returns standard Aave v3 selectors plus Tydro's compact L2 operation selectors. + function getMonitoredSelectors() external pure override returns (bytes4[] memory selectors) { + selectors = new bytes4[](10); + selectors[0] = IAaveV3LikePool.borrow.selector; + selectors[1] = IAaveV3LikePool.withdraw.selector; + selectors[2] = IAaveV3LikePool.liquidationCall.selector; + selectors[3] = IAaveV3LikePool.setUserUseReserveAsCollateral.selector; + selectors[4] = IAaveV3LikePool.finalizeTransfer.selector; + selectors[5] = IAaveV3LikePool.setUserEMode.selector; + selectors[6] = ITydroL2Pool.borrow.selector; + selectors[7] = ITydroL2Pool.withdraw.selector; + selectors[8] = ITydroL2Pool.liquidationCall.selector; + selectors[9] = ITydroL2Pool.setUserUseReserveAsCollateral.selector; + } + + /// @notice Decodes standard Aave v3 operations and Tydro's compact L2 operation wrappers. + function decodeOperation(TriggeredCall calldata triggered) + external + view + override + returns (OperationContext memory operation) + { + if ( + triggered.selector != ITydroL2Pool.borrow.selector && triggered.selector != ITydroL2Pool.withdraw.selector + && triggered.selector != ITydroL2Pool.liquidationCall.selector + && triggered.selector != ITydroL2Pool.setUserUseReserveAsCollateral.selector + ) { + return _decodeAaveV3Operation(triggered); + } + + operation.selector = triggered.selector; + operation.caller = triggered.caller; + + if (triggered.selector == ITydroL2Pool.borrow.selector) { + bytes32 args = abi.decode(triggered.input[4:], (bytes32)); + + operation.kind = OperationKind.Borrow; + operation.account = triggered.caller; + operation.asset = _assetByL2Id(args); + operation.amount = _decodeL2Amount(args); + operation.increasesDebt = operation.amount != 0; + return operation; + } + + if (triggered.selector == ITydroL2Pool.withdraw.selector) { + bytes32 args = abi.decode(triggered.input[4:], (bytes32)); + + operation.kind = OperationKind.WithdrawCollateral; + operation.account = triggered.caller; + operation.asset = _assetByL2Id(args); + operation.counterparty = triggered.caller; + operation.amount = _decodeL2Amount(args); + operation.reducesEffectiveCollateral = operation.amount != 0; + return operation; + } + + if (triggered.selector == ITydroL2Pool.liquidationCall.selector) { + (bytes32 args1, bytes32 args2) = abi.decode(triggered.input[4:], (bytes32, bytes32)); + + operation.kind = OperationKind.Liquidation; + operation.account = address(uint160(uint256(args1 >> 32))); + operation.asset = _assetByL2Id(args1 >> 16); + operation.relatedAsset = _assetByL2Id(args1); + operation.counterparty = triggered.caller; + operation.amount = _decodeL2Amount(args2); + operation.metadata = abi.encode(((uint256(args2) >> 128) & 1) == 0); + return operation; + } + + if (triggered.selector == ITydroL2Pool.setUserUseReserveAsCollateral.selector) { + bytes32 args = abi.decode(triggered.input[4:], (bytes32)); + bool disableCollateral = ((uint256(args) >> 16) & 1) != 0; + + if (disableCollateral) { + operation.kind = OperationKind.DisableCollateral; + operation.account = triggered.caller; + operation.asset = _assetByL2Id(args); + operation.reducesEffectiveCollateral = true; + } + } + } + + /// @notice Internal standard ABI decoder kept overridable for the Tydro L2 extension. + function _decodeAaveV3Operation(TriggeredCall calldata triggered) + internal + pure + returns (OperationContext memory operation) + { + operation.selector = triggered.selector; + operation.caller = triggered.caller; + + if (triggered.selector == IAaveV3LikePool.borrow.selector) { + (address asset, uint256 amount,,, address onBehalfOf) = + abi.decode(triggered.input[4:], (address, uint256, uint256, uint16, address)); + + operation.kind = OperationKind.Borrow; + operation.account = onBehalfOf; + operation.asset = asset; + operation.amount = amount; + operation.increasesDebt = amount != 0; + return operation; + } + + if (triggered.selector == IAaveV3LikePool.withdraw.selector) { + (address asset, uint256 amount, address to) = abi.decode(triggered.input[4:], (address, uint256, address)); + + operation.kind = OperationKind.WithdrawCollateral; + operation.account = triggered.caller; + operation.asset = asset; + operation.counterparty = to; + operation.amount = amount; + operation.reducesEffectiveCollateral = amount != 0; + return operation; + } + + if (triggered.selector == IAaveV3LikePool.liquidationCall.selector) { + (address collateralAsset, address debtAsset, address user, uint256 debtToCover, bool receiveAToken) = + abi.decode(triggered.input[4:], (address, address, address, uint256, bool)); + + operation.kind = OperationKind.Liquidation; + operation.account = user; + operation.asset = debtAsset; + operation.relatedAsset = collateralAsset; + operation.counterparty = triggered.caller; + operation.amount = debtToCover; + operation.metadata = abi.encode(receiveAToken); + return operation; + } + + if (triggered.selector == IAaveV3LikePool.setUserUseReserveAsCollateral.selector) { + (address asset, bool useAsCollateral) = abi.decode(triggered.input[4:], (address, bool)); + + if (!useAsCollateral) { + operation.kind = OperationKind.DisableCollateral; + operation.account = triggered.caller; + operation.asset = asset; + operation.reducesEffectiveCollateral = true; + } + + return operation; + } + + if (triggered.selector == IAaveV3LikePool.finalizeTransfer.selector) { + (address asset, address from, address to, uint256 amount,,) = + abi.decode(triggered.input[4:], (address, address, address, uint256, uint256, uint256)); + + operation.kind = OperationKind.TransferCollateral; + operation.account = from; + operation.asset = asset; + operation.counterparty = to; + operation.amount = amount; + operation.reducesEffectiveCollateral = from != to && amount != 0; + return operation; + } + + if (triggered.selector == IAaveV3LikePool.setUserEMode.selector) { + (uint8 categoryId) = abi.decode(triggered.input[4:], (uint8)); + + operation.kind = OperationKind.SetEMode; + operation.account = triggered.caller; + operation.amount = uint256(categoryId); + operation.metadata = abi.encode(categoryId); + return operation; + } + } + + /// @notice Resolves the reserve address encoded by a compact L2 asset id. + function _assetByL2Id(bytes32 args) internal view returns (address) { + address[] memory reserves = IAaveV3LikePool(POOL).getReservesList(); + uint256 assetId = uint256(args) & L2_ASSET_ID_MASK; + return reserves[assetId]; + } + + /// @notice Decodes Aave/Tydro's compact uint128 amount, including the max sentinel. + function _decodeL2Amount(bytes32 args) internal pure returns (uint256 amount) { + amount = (uint256(args) >> 16) & L2_SHORTENED_AMOUNT_MASK; + if (amount == L2_MAX_AMOUNT) { + return type(uint256).max; + } + } +} + +/// @title TydroOperationSafetyAssertion +/// @author Phylax Systems +/// @notice Single-entry assertion bundle for Tydro on Ink. +/// @dev Covers both the normal Aave v3-compatible Pool ABI and Tydro's compact L2Pool entrypoints. +contract TydroOperationSafetyAssertion is AaveV3LikeOperationSafetyAssertionBase { + constructor(address pool_, address addressesProvider_) + AaveV3LikeOperationSafetyAssertionBase(new TydroProtectionSuite(pool_, addressesProvider_)) + {} +} diff --git a/examples/uniswap/README.md b/examples/uniswap/README.md new file mode 100644 index 0000000..e285e1f --- /dev/null +++ b/examples/uniswap/README.md @@ -0,0 +1,15 @@ +# uniswap examples + +Assertion examples and supporting helpers extracted from the `0x` branch. + +## Build + +```sh +FOUNDRY_PROFILE=uniswap forge build +``` + +## Files + +- UniswapV3PoolAssertion.sol +- UniswapV3PoolHelpers.sol +- UniswapV3PoolInterfaces.sol diff --git a/examples/uniswap/src/UniswapV3PoolAssertion.sol b/examples/uniswap/src/UniswapV3PoolAssertion.sol new file mode 100644 index 0000000..efb4e40 --- /dev/null +++ b/examples/uniswap/src/UniswapV3PoolAssertion.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {UniswapV3PoolHelpers} from "./UniswapV3PoolHelpers.sol"; +import {IUniswapV3PoolLike} from "./UniswapV3PoolInterfaces.sol"; + +/// @title UniswapV3PoolAssertion +/// @author Phylax Systems +/// @notice Example assertion bundle for Uniswap v3 pools. +/// @dev Protects the pool's core AMM invariants: +/// - swaps move price only in the requested direction and never past the caller's price limit; +/// - mint/burn calls update active liquidity exactly when the position range contains the current tick; +/// - oracle observation cardinality and indexes remain internally consistent; +/// - protocol-fee accounting stays covered by pool token custody. +contract UniswapV3PoolAssertion is UniswapV3PoolHelpers { + constructor(address pool_, address token0_, address token1_) UniswapV3PoolHelpers(pool_, token0_, token1_) {} + + /// @notice Registers Uniswap v3 pool selectors against their protection assertions. + /// @dev The pool is the assertion adopter. Call-scoped triggers compare the exact + /// pre-call and post-call snapshots for the matched pool operation. + function triggers() external view override { + _registerLiquidityAccountingTriggers(); + _registerOracleAccountingTriggers(); + _registerProtocolFeeCustodyTriggers(); + + registerFnCallTrigger(this.assertSwapPriceMovement.selector, IUniswapV3PoolLike.swap.selector); + registerFnCallTrigger( + this.assertCollectProtocolPreservesPoolState.selector, IUniswapV3PoolLike.collectProtocol.selector + ); + } + + function _registerLiquidityAccountingTriggers() internal view { + registerFnCallTrigger(this.assertActiveLiquidityAccounting.selector, IUniswapV3PoolLike.mint.selector); + registerFnCallTrigger(this.assertActiveLiquidityAccounting.selector, IUniswapV3PoolLike.burn.selector); + } + + function _registerOracleAccountingTriggers() internal view { + registerFnCallTrigger(this.assertOracleStateConsistent.selector, IUniswapV3PoolLike.initialize.selector); + registerFnCallTrigger(this.assertOracleStateConsistent.selector, IUniswapV3PoolLike.mint.selector); + registerFnCallTrigger(this.assertOracleStateConsistent.selector, IUniswapV3PoolLike.burn.selector); + registerFnCallTrigger(this.assertOracleStateConsistent.selector, IUniswapV3PoolLike.swap.selector); + registerFnCallTrigger( + this.assertOracleStateConsistent.selector, IUniswapV3PoolLike.increaseObservationCardinalityNext.selector + ); + } + + function _registerProtocolFeeCustodyTriggers() internal view { + registerFnCallTrigger(this.assertProtocolFeesCoveredByCustody.selector, IUniswapV3PoolLike.collect.selector); + registerFnCallTrigger(this.assertProtocolFeesCoveredByCustody.selector, IUniswapV3PoolLike.swap.selector); + registerFnCallTrigger(this.assertProtocolFeesCoveredByCustody.selector, IUniswapV3PoolLike.flash.selector); + registerFnCallTrigger( + this.assertProtocolFeesCoveredByCustody.selector, IUniswapV3PoolLike.collectProtocol.selector + ); + } + + /// @notice A successful swap must respect direction and caller-supplied price limits. + /// @dev For token0-to-token1 swaps `sqrtPriceX96` can only decrease; for token1-to-token0 + /// swaps it can only increase. A failure means swap execution moved price the wrong way + /// or crossed the explicit limit that bounds user execution. + function assertSwapPriceMovement() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + PoolSnapshot memory pre = _snapshotAt(_preCall(ctx.callStart)); + PoolSnapshot memory post = _snapshotAt(_postCall(ctx.callEnd)); + (, bool zeroForOne,, uint160 sqrtPriceLimitX96,) = _swapArgs(ph.callinputAt(ctx.callStart)); + + require(post.slot0.unlocked, "UniswapV3Pool: pool left locked"); + require(post.slot0.sqrtPriceX96 >= MIN_SQRT_RATIO, "UniswapV3Pool: price below min"); + require(post.slot0.sqrtPriceX96 < MAX_SQRT_RATIO, "UniswapV3Pool: price above max"); + + if (zeroForOne) { + require(post.slot0.sqrtPriceX96 <= pre.slot0.sqrtPriceX96, "UniswapV3Pool: zeroForOne price increased"); + require(post.slot0.sqrtPriceX96 >= sqrtPriceLimitX96, "UniswapV3Pool: zeroForOne crossed limit"); + } else { + require(post.slot0.sqrtPriceX96 >= pre.slot0.sqrtPriceX96, "UniswapV3Pool: oneForZero price decreased"); + require(post.slot0.sqrtPriceX96 <= sqrtPriceLimitX96, "UniswapV3Pool: oneForZero crossed limit"); + } + } + + /// @notice Mint and burn must update active liquidity exactly for in-range positions. + /// @dev Uniswap v3's global `liquidity` is only the currently active liquidity. A successful + /// mint/burn whose range excludes the pre-call tick must leave it unchanged; an in-range + /// mint/burn must add/subtract the called amount. A failure means active liquidity no + /// longer reflects the position range that the swap engine will use. + function assertActiveLiquidityAccounting() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + PoolSnapshot memory pre = _snapshotAt(_preCall(ctx.callStart)); + PoolSnapshot memory post = _snapshotAt(_postCall(ctx.callEnd)); + int24 tickLower; + int24 tickUpper; + uint128 amount; + + if (ctx.selector == IUniswapV3PoolLike.mint.selector) { + (, tickLower, tickUpper, amount,) = _mintArgs(ph.callinputAt(ctx.callStart)); + if (_inRange(pre.slot0.tick, tickLower, tickUpper)) { + require(post.liquidity == pre.liquidity + amount, "UniswapV3Pool: mint active liquidity mismatch"); + } else { + require(post.liquidity == pre.liquidity, "UniswapV3Pool: out-of-range mint changed liquidity"); + } + } else { + (tickLower, tickUpper, amount) = _burnArgs(ph.callinputAt(ctx.callStart)); + if (_inRange(pre.slot0.tick, tickLower, tickUpper)) { + require(post.liquidity + amount == pre.liquidity, "UniswapV3Pool: burn active liquidity mismatch"); + } else { + require(post.liquidity == pre.liquidity, "UniswapV3Pool: out-of-range burn changed liquidity"); + } + } + + require(post.slot0.sqrtPriceX96 == pre.slot0.sqrtPriceX96, "UniswapV3Pool: liquidity op changed price"); + require(post.slot0.tick == pre.slot0.tick, "UniswapV3Pool: liquidity op changed tick"); + require(post.slot0.unlocked, "UniswapV3Pool: pool left locked"); + } + + /// @notice Oracle observation indexes and cardinality must move forward consistently. + /// @dev Initialization, liquidity mutations, swaps, and cardinality growth can touch oracle + /// state. The active cardinality and next cardinality must never decrease, and initialized + /// pools must keep the latest observation index inside the active ring buffer. + function assertOracleStateConsistent() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + Slot0Snapshot memory pre = _slot0At(_preCall(ctx.callStart)); + Slot0Snapshot memory post = _slot0At(_postCall(ctx.callEnd)); + + require(post.observationCardinality >= pre.observationCardinality, "UniswapV3Pool: cardinality decreased"); + require( + post.observationCardinalityNext >= pre.observationCardinalityNext, + "UniswapV3Pool: cardinalityNext decreased" + ); + + if (post.sqrtPriceX96 != 0) { + require(post.unlocked, "UniswapV3Pool: pool left locked"); + require(post.observationCardinality > 0, "UniswapV3Pool: initialized pool has no observations"); + require( + post.observationCardinalityNext >= post.observationCardinality, + "UniswapV3Pool: next cardinality below active" + ); + require( + post.observationIndex < post.observationCardinality, "UniswapV3Pool: observation index out of bounds" + ); + } + } + + /// @notice Accrued protocol fees must remain backed by the pool's token balances. + /// @dev Swaps and flashes can accrue protocol fees, while collect and collectProtocol transfer + /// tokens out. A failure means protocol-fee accounting claims more token0 or token1 than + /// the pool still holds after the triggering operation. + function assertProtocolFeesCoveredByCustody() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + PoolSnapshot memory post = _snapshotAt(_postCall(ctx.callEnd)); + + require(post.balance0 >= post.protocolFees0, "UniswapV3Pool: token0 protocol fees uncovered"); + require(post.balance1 >= post.protocolFees1, "UniswapV3Pool: token1 protocol fees uncovered"); + require(post.slot0.unlocked, "UniswapV3Pool: pool left locked"); + } + + /// @notice Protocol-fee collection must not mutate swap-critical pool state. + /// @dev `collectProtocol` may only reduce protocol-fee accounting and transfer the matching + /// token custody. A failure means owner fee collection changed price, tick, active + /// liquidity, oracle shape, fee growth, or increased protocol-fee liabilities. + function assertCollectProtocolPreservesPoolState() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredPoolIsAdopter(); + PoolSnapshot memory pre = _snapshotAt(_preCall(ctx.callStart)); + PoolSnapshot memory post = _snapshotAt(_postCall(ctx.callEnd)); + + require(post.slot0.sqrtPriceX96 == pre.slot0.sqrtPriceX96, "UniswapV3Pool: collectProtocol changed price"); + require(post.slot0.tick == pre.slot0.tick, "UniswapV3Pool: collectProtocol changed tick"); + require( + post.slot0.observationIndex == pre.slot0.observationIndex, + "UniswapV3Pool: collectProtocol changed observation index" + ); + require( + post.slot0.observationCardinality == pre.slot0.observationCardinality, + "UniswapV3Pool: collectProtocol changed cardinality" + ); + require( + post.slot0.observationCardinalityNext == pre.slot0.observationCardinalityNext, + "UniswapV3Pool: collectProtocol changed cardinalityNext" + ); + require(post.slot0.feeProtocol == pre.slot0.feeProtocol, "UniswapV3Pool: collectProtocol changed feeProtocol"); + require(post.liquidity == pre.liquidity, "UniswapV3Pool: collectProtocol changed liquidity"); + require( + post.feeGrowthGlobal0X128 == pre.feeGrowthGlobal0X128, "UniswapV3Pool: collectProtocol changed feeGrowth0" + ); + require( + post.feeGrowthGlobal1X128 == pre.feeGrowthGlobal1X128, "UniswapV3Pool: collectProtocol changed feeGrowth1" + ); + _requireCollectProtocolCustodyMatchesFees(pre, post); + require(post.slot0.unlocked, "UniswapV3Pool: pool left locked"); + } +} diff --git a/examples/uniswap/src/UniswapV3PoolHelpers.sol b/examples/uniswap/src/UniswapV3PoolHelpers.sol new file mode 100644 index 0000000..669e223 --- /dev/null +++ b/examples/uniswap/src/UniswapV3PoolHelpers.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {IUniswapV3PoolLike} from "./UniswapV3PoolInterfaces.sol"; + +/// @title UniswapV3PoolHelpers +/// @author Phylax Systems +/// @notice Fork-aware Uniswap v3 pool state helpers used by the example assertions. +abstract contract UniswapV3PoolHelpers is Assertion { + uint160 internal constant MIN_SQRT_RATIO = 4_295_128_739; + uint160 internal constant MAX_SQRT_RATIO = 1_461_446_703_485_210_103_287_273_052_203_988_822_378_723_970_342; + + address internal immutable POOL; + address internal immutable TOKEN0; + address internal immutable TOKEN1; + + struct Slot0Snapshot { + uint160 sqrtPriceX96; + int24 tick; + uint16 observationIndex; + uint16 observationCardinality; + uint16 observationCardinalityNext; + uint8 feeProtocol; + bool unlocked; + } + + struct PoolSnapshot { + Slot0Snapshot slot0; + uint128 liquidity; + uint128 protocolFees0; + uint128 protocolFees1; + uint256 feeGrowthGlobal0X128; + uint256 feeGrowthGlobal1X128; + uint256 balance0; + uint256 balance1; + } + + /// @dev Accepts pool tokens explicitly so the constructor never reads from the adopter. The + /// Credible Layer's assertion-deploy runtime is isolated from the calling state, so a + /// `pool.token0()` call in the constructor would revert with EXTCODESIZE = 0. + constructor(address pool_, address token0_, address token1_) { + POOL = pool_; + TOKEN0 = token0_; + TOKEN1 = token1_; + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function _snapshotAt(PhEvm.ForkId memory fork) internal view returns (PoolSnapshot memory snapshot) { + snapshot.slot0 = _slot0At(fork); + snapshot.liquidity = _liquidityAt(fork); + (snapshot.protocolFees0, snapshot.protocolFees1) = _protocolFeesAt(fork); + snapshot.feeGrowthGlobal0X128 = + _readUintAt(POOL, abi.encodeCall(IUniswapV3PoolLike.feeGrowthGlobal0X128, ()), fork); + snapshot.feeGrowthGlobal1X128 = + _readUintAt(POOL, abi.encodeCall(IUniswapV3PoolLike.feeGrowthGlobal1X128, ()), fork); + snapshot.balance0 = _readBalanceAt(TOKEN0, POOL, fork); + snapshot.balance1 = _readBalanceAt(TOKEN1, POOL, fork); + } + + function _slot0At(PhEvm.ForkId memory fork) internal view returns (Slot0Snapshot memory slot0) { + ( + slot0.sqrtPriceX96, + slot0.tick, + slot0.observationIndex, + slot0.observationCardinality, + slot0.observationCardinalityNext, + slot0.feeProtocol, + slot0.unlocked + ) = + abi.decode( + _viewAt(POOL, abi.encodeCall(IUniswapV3PoolLike.slot0, ()), fork), + (uint160, int24, uint16, uint16, uint16, uint8, bool) + ); + } + + function _liquidityAt(PhEvm.ForkId memory fork) internal view returns (uint128 liquidity) { + return abi.decode(_viewAt(POOL, abi.encodeCall(IUniswapV3PoolLike.liquidity, ()), fork), (uint128)); + } + + function _protocolFeesAt(PhEvm.ForkId memory fork) + internal + view + returns (uint128 protocolFees0, uint128 protocolFees1) + { + return abi.decode(_viewAt(POOL, abi.encodeCall(IUniswapV3PoolLike.protocolFees, ()), fork), (uint128, uint128)); + } + + function _mintArgs(bytes memory input) + internal + pure + returns (address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes memory data) + { + return abi.decode(_args(input), (address, int24, int24, uint128, bytes)); + } + + function _burnArgs(bytes memory input) internal pure returns (int24 tickLower, int24 tickUpper, uint128 amount) { + return abi.decode(_args(input), (int24, int24, uint128)); + } + + function _swapArgs(bytes memory input) + internal + pure + returns ( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes memory data + ) + { + return abi.decode(_args(input), (address, bool, int256, uint160, bytes)); + } + + function _inRange(int24 currentTick, int24 tickLower, int24 tickUpper) internal pure returns (bool) { + return tickLower <= currentTick && currentTick < tickUpper; + } + + function _requireConfiguredPoolIsAdopter() internal view { + require(ph.getAssertionAdopter() == POOL, "UniswapV3Pool: configured pool is not adopter"); + } + + function _requireCollectProtocolCustodyMatchesFees(PoolSnapshot memory pre, PoolSnapshot memory post) + internal + pure + { + require(post.protocolFees0 <= pre.protocolFees0, "UniswapV3Pool: collectProtocol increased protocolFees0"); + require(post.protocolFees1 <= pre.protocolFees1, "UniswapV3Pool: collectProtocol increased protocolFees1"); + require(post.balance0 <= pre.balance0, "UniswapV3Pool: collectProtocol increased balance0"); + require(post.balance1 <= pre.balance1, "UniswapV3Pool: collectProtocol increased balance1"); + require( + pre.balance0 - post.balance0 == pre.protocolFees0 - post.protocolFees0, + "UniswapV3Pool: collectProtocol token0 custody mismatch" + ); + require( + pre.balance1 - post.balance1 == pre.protocolFees1 - post.protocolFees1, + "UniswapV3Pool: collectProtocol token1 custody mismatch" + ); + } + + function _args(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "UniswapV3Pool: short calldata"); + + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } +} diff --git a/examples/uniswap/src/UniswapV3PoolInterfaces.sol b/examples/uniswap/src/UniswapV3PoolInterfaces.sol new file mode 100644 index 0000000..cbfbb26 --- /dev/null +++ b/examples/uniswap/src/UniswapV3PoolInterfaces.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @title IUniswapV3PoolLike +/// @author Phylax Systems +/// @notice Minimal Uniswap v3 pool surface needed by the example assertion bundle. +interface IUniswapV3PoolLike { + function token0() external view returns (address); + function token1() external view returns (address); + + function slot0() + external + view + returns ( + uint160 sqrtPriceX96, + int24 tick, + uint16 observationIndex, + uint16 observationCardinality, + uint16 observationCardinalityNext, + uint8 feeProtocol, + bool unlocked + ); + + function protocolFees() external view returns (uint128 token0, uint128 token1); + function liquidity() external view returns (uint128); + function feeGrowthGlobal0X128() external view returns (uint256); + function feeGrowthGlobal1X128() external view returns (uint256); + + function initialize(uint160 sqrtPriceX96) external; + + function mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data) + external + returns (uint256 amount0, uint256 amount1); + + function collect( + address recipient, + int24 tickLower, + int24 tickUpper, + uint128 amount0Requested, + uint128 amount1Requested + ) external returns (uint128 amount0, uint128 amount1); + + function burn(int24 tickLower, int24 tickUpper, uint128 amount) external returns (uint256 amount0, uint256 amount1); + + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); + + function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external; + function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external; + function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external; + function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) + external + returns (uint128 amount0, uint128 amount1); +} diff --git a/examples/veda/README.md b/examples/veda/README.md new file mode 100644 index 0000000..71f6fd3 --- /dev/null +++ b/examples/veda/README.md @@ -0,0 +1,15 @@ +# veda examples + +Assertion examples and supporting helpers extracted from the `ink/assertions` branch. + +## Build + +```sh +FOUNDRY_PROFILE=veda forge build +``` + +## Files + +- BoringVaultAssertion.sol +- BoringVaultHelpers.sol +- BoringVaultInterfaces.sol diff --git a/examples/veda/src/BoringVaultAssertion.sol b/examples/veda/src/BoringVaultAssertion.sol new file mode 100644 index 0000000..90f3527 --- /dev/null +++ b/examples/veda/src/BoringVaultAssertion.sol @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import {BoringVaultHelpers} from "./BoringVaultHelpers.sol"; +import {IBoringVaultLike} from "./BoringVaultInterfaces.sol"; + +/// @title BoringVaultAssertion +/// @author Phylax Systems +/// @notice Example assertion bundle for Veda Boring Vault deployments. +/// @dev Intended to be applied to the BoringVault contract, not the Teller. +/// +/// The bundle focuses on a small set of high-signal protections: +/// - `enter` cannot mint shares beyond the accountant value of the asset entering +/// the vault, and the share/token deltas must match the call arguments. +/// - `exit` must burn exactly the requested shares and move exactly the requested +/// asset amount out of vault custody. +/// - cumulative inflow/outflow breakers monitor actual ERC-20 balance deltas of +/// the vault, so they supersede teller-side deposit caps and withdrawal limits +/// by catching direct `enter`/`exit`, `manage`, and other balance-moving paths. +contract BoringVaultAssertion is BoringVaultHelpers { + /// @notice Constructor value that disables the exit price-bound check. + uint256 public constant DISABLE_EXIT_RATE_BOUND = type(uint256).max; + + /// @notice Extra share mint tolerance above accountant pricing. 100 = 1%. + uint256 public immutable maxShareMintPremiumBps; + + /// @notice Extra asset-out tolerance above accountant pricing. 100 = 1%. + /// @dev Set to `DISABLE_EXIT_RATE_BOUND` to support refund flows that intentionally + /// return original assets while still enforcing exact burn/custody accounting. + uint256 public immutable maxExitAssetsPremiumBps; + + /// @notice Hard cumulative inflow breaker threshold. Zero disables inflow breaker registration. + uint256 public immutable cumulativeInflowThresholdBps; + + /// @notice Hard cumulative outflow breaker threshold. Zero disables outflow breaker registration. + uint256 public immutable cumulativeOutflowThresholdBps; + + /// @notice Rolling window, in seconds, used by cumulative flow breakers. + uint256 public immutable flowWindowDuration; + + /// @notice ERC-20 assets whose vault balance should be protected by hard flow breakers. + address[] public monitoredAssets; + + constructor( + address vault_, + address accountant_, + uint8 vaultDecimals_, + address[] memory monitoredAssets_, + uint256 maxShareMintPremiumBps_, + uint256 maxExitAssetsPremiumBps_, + uint256 cumulativeInflowThresholdBps_, + uint256 cumulativeOutflowThresholdBps_, + uint256 flowWindowDuration_ + ) BoringVaultHelpers(vault_, accountant_, vaultDecimals_) { + require(maxShareMintPremiumBps_ <= 10_000, "BoringVault: mint premium too large"); + require( + maxExitAssetsPremiumBps_ == DISABLE_EXIT_RATE_BOUND || maxExitAssetsPremiumBps_ <= 10_000, + "BoringVault: exit premium too large" + ); + require(monitoredAssets_.length > 0, "BoringVault: no monitored assets"); + require( + flowWindowDuration_ > 0 || (cumulativeInflowThresholdBps_ == 0 && cumulativeOutflowThresholdBps_ == 0), + "BoringVault: zero flow window" + ); + + maxShareMintPremiumBps = maxShareMintPremiumBps_; + maxExitAssetsPremiumBps = maxExitAssetsPremiumBps_; + cumulativeInflowThresholdBps = cumulativeInflowThresholdBps_; + cumulativeOutflowThresholdBps = cumulativeOutflowThresholdBps_; + flowWindowDuration = flowWindowDuration_; + + for (uint256 i; i < monitoredAssets_.length; ++i) { + require(monitoredAssets_[i] != address(0), "BoringVault: zero monitored asset"); + monitoredAssets.push(monitoredAssets_[i]); + } + + registerAssertionSpec(AssertionSpec.Reshiram); + } + + /// @notice Wires call-scoped accounting checks and hard cumulative flow breakers. + /// @dev `registerFnCallTrigger` catches successful calls to the BoringVault adopter at + /// any call depth. The cumulative flow triggers monitor actual token balance deltas + /// of the adopter over a rolling window, independent of teller-level limits. + function triggers() external view override { + registerFnCallTrigger(this.assertEnterAccounting.selector, IBoringVaultLike.enter.selector); + registerFnCallTrigger(this.assertExitAccounting.selector, IBoringVaultLike.exit.selector); + + for (uint256 i; i < monitoredAssets.length; ++i) { + address asset = monitoredAssets[i]; + if (cumulativeInflowThresholdBps > 0) { + watchCumulativeInflow( + asset, cumulativeInflowThresholdBps, flowWindowDuration, this.assertCumulativeInflowBreaker.selector + ); + } + if (cumulativeOutflowThresholdBps > 0) { + watchCumulativeOutflow( + asset, + cumulativeOutflowThresholdBps, + flowWindowDuration, + this.assertCumulativeOutflowBreaker.selector + ); + } + } + } + + /// @notice Checks that a BoringVault `enter` call is collateralized and share accounting is exact. + /// @dev The trigger is the vault's `enter(from, asset, assetAmount, to, shareAmount)`. + /// A failure means the minter minted too many shares for the accountant rate, minted + /// without the advertised asset inflow, or changed supply/balances inconsistently. + function assertEnterAccounting() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + (, address asset, uint256 assetAmount, address to, uint256 shareAmount) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, address, uint256, address, uint256)); + + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + uint256 preSupply = _totalSupplyAt(beforeFork); + uint256 postSupply = _totalSupplyAt(afterFork); + uint256 preReceiverShares = _shareBalanceAt(to, beforeFork); + uint256 postReceiverShares = _shareBalanceAt(to, afterFork); + uint256 preVaultAssets = _assetBalanceAt(asset, vault, beforeFork); + uint256 postVaultAssets = _assetBalanceAt(asset, vault, afterFork); + + require(postSupply == preSupply + shareAmount, "BoringVault: enter supply delta mismatch"); + require(postReceiverShares == preReceiverShares + shareAmount, "BoringVault: enter share delta mismatch"); + require(postVaultAssets == preVaultAssets + assetAmount, "BoringVault: enter asset delta mismatch"); + + uint256 maxShares = _maxSharesForDepositAt(asset, assetAmount, beforeFork); + uint256 maxSharesWithTolerance = maxShares + ph.mulDivUp(maxShares, maxShareMintPremiumBps, 10_000); + require(shareAmount <= maxSharesWithTolerance, "BoringVault: enter over-minted shares"); + } + + /// @notice Checks that a BoringVault `exit` call burns shares and moves assets consistently. + /// @dev The trigger is the vault's `exit(to, asset, assetAmount, from, shareAmount)`. + /// A failure means the burner extracted too many assets for the burned shares or + /// custody/share balances did not move exactly as the vault event arguments claim. + function assertExitAccounting() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + (, address asset, uint256 assetAmount, address from, uint256 shareAmount) = + abi.decode(_stripSelector(ph.callinputAt(ctx.callStart)), (address, address, uint256, address, uint256)); + + PhEvm.ForkId memory beforeFork = _preCall(ctx.callStart); + PhEvm.ForkId memory afterFork = _postCall(ctx.callEnd); + + uint256 preSupply = _totalSupplyAt(beforeFork); + uint256 postSupply = _totalSupplyAt(afterFork); + uint256 preOwnerShares = _shareBalanceAt(from, beforeFork); + uint256 postOwnerShares = _shareBalanceAt(from, afterFork); + uint256 preVaultAssets = _assetBalanceAt(asset, vault, beforeFork); + uint256 postVaultAssets = _assetBalanceAt(asset, vault, afterFork); + + require(preSupply >= shareAmount, "BoringVault: exit burns too many shares"); + require(preOwnerShares >= shareAmount, "BoringVault: exit owner share underflow"); + require(preVaultAssets >= assetAmount, "BoringVault: exit asset underflow"); + require(postSupply == preSupply - shareAmount, "BoringVault: exit supply delta mismatch"); + require(postOwnerShares == preOwnerShares - shareAmount, "BoringVault: exit share delta mismatch"); + require(postVaultAssets == preVaultAssets - assetAmount, "BoringVault: exit asset delta mismatch"); + + if (maxExitAssetsPremiumBps != DISABLE_EXIT_RATE_BOUND) { + uint256 maxAssets = _maxAssetsForExitAt(asset, shareAmount, beforeFork); + uint256 maxAssetsWithTolerance = maxAssets + ph.mulDivUp(maxAssets, maxExitAssetsPremiumBps, 10_000); + require(assetAmount <= maxAssetsWithTolerance, "BoringVault: exit overpaid assets"); + } + } + + /// @notice Hard breaker for cumulative token inflows into vault custody. + /// @dev Fires only after `watchCumulativeInflow` reports a monitored asset breached + /// the configured rolling-window threshold. This blocks deposits or manager flows + /// that would bypass or overwhelm the teller's share-denominated deposit cap. + function assertCumulativeInflowBreaker() external pure { + revert("BoringVault: cumulative inflow breaker tripped"); + } + + /// @notice Hard breaker for cumulative token outflows from vault custody. + /// @dev Fires only after `watchCumulativeOutflow` reports a monitored asset breached + /// the configured rolling-window threshold. This blocks withdrawals, refunds, + /// manager calls, or direct balance-moving paths that exceed the external breaker. + function assertCumulativeOutflowBreaker() external pure { + revert("BoringVault: cumulative outflow breaker tripped"); + } +} diff --git a/examples/veda/src/BoringVaultHelpers.sol b/examples/veda/src/BoringVaultHelpers.sol new file mode 100644 index 0000000..2da3b07 --- /dev/null +++ b/examples/veda/src/BoringVaultHelpers.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {IBoringAccountantLike, IBoringVaultLike} from "./BoringVaultInterfaces.sol"; + +/// @title BoringVaultHelpers +/// @author Phylax Systems +/// @notice Shared fork-aware state accessors for Boring Vault example assertions. +abstract contract BoringVaultHelpers is Assertion { + /// @notice BoringVault share token and custody contract being monitored. + address internal immutable vault; + + /// @notice Accountant used by the teller to price deposits and withdrawals. + address internal immutable accountant; + + /// @notice One full vault share, scaled to the vault share-token decimals. + uint256 internal immutable ONE_SHARE; + + constructor(address vault_, address accountant_, uint8 vaultDecimals_) { + vault = vault_; + accountant = accountant_; + ONE_SHARE = 10 ** uint256(vaultDecimals_); + } + + function _totalSupplyAt(PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(vault, abi.encodeCall(IBoringVaultLike.totalSupply, ()), fork); + } + + function _shareBalanceAt(address account, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readBalanceAt(vault, account, fork); + } + + function _assetBalanceAt(address asset, address account, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readBalanceAt(asset, account, fork); + } + + function _rateInQuoteAt(address quote, PhEvm.ForkId memory fork) internal view returns (uint256) { + return _readUintAt(accountant, abi.encodeCall(IBoringAccountantLike.getRateInQuote, (quote)), fork); + } + + function _maxSharesForDepositAt(address asset, uint256 assetAmount, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + uint256 rate = _rateInQuoteAt(asset, fork); + require(rate > 0, "BoringVault: zero quote rate"); + return ph.mulDivDown(assetAmount, ONE_SHARE, rate); + } + + function _maxAssetsForExitAt(address asset, uint256 shareAmount, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + uint256 rate = _rateInQuoteAt(asset, fork); + require(rate > 0, "BoringVault: zero quote rate"); + return ph.mulDivDown(shareAmount, rate, ONE_SHARE); + } + + /// @notice Strip the 4-byte selector from raw call input bytes. + function _stripSelector(bytes memory input) internal pure returns (bytes memory args) { + require(input.length >= 4, "BoringVault: input too short"); + args = new bytes(input.length - 4); + for (uint256 i; i < args.length; ++i) { + args[i] = input[i + 4]; + } + } +} diff --git a/examples/veda/src/BoringVaultInterfaces.sol b/examples/veda/src/BoringVaultInterfaces.sol new file mode 100644 index 0000000..94ba645 --- /dev/null +++ b/examples/veda/src/BoringVaultInterfaces.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Minimal BoringVault surface needed by the example assertion bundle. +interface IBoringVaultLike { + function enter(address from, address asset, uint256 assetAmount, address to, uint256 shareAmount) external; + + function exit(address to, address asset, uint256 assetAmount, address from, uint256 shareAmount) external; + + function totalSupply() external view returns (uint256); + + function balanceOf(address account) external view returns (uint256); +} + +/// @notice Minimal AccountantWithRateProviders surface needed by the example assertions. +interface IBoringAccountantLike { + function getRateInQuote(address quote) external view returns (uint256 rateInQuote); +} diff --git a/examples/zeroex/README.md b/examples/zeroex/README.md new file mode 100644 index 0000000..ba488f3 --- /dev/null +++ b/examples/zeroex/README.md @@ -0,0 +1,15 @@ +# zeroex examples + +Assertion examples and supporting helpers extracted from the `0x` branch. + +## Build + +```sh +FOUNDRY_PROFILE=zeroex forge build +``` + +## Files + +- ZeroExSettlerAssertion.sol +- ZeroExSettlerHelpers.sol +- ZeroExSettlerInterfaces.sol diff --git a/examples/zeroex/src/ZeroExSettlerAssertion.sol b/examples/zeroex/src/ZeroExSettlerAssertion.sol new file mode 100644 index 0000000..66bf81f --- /dev/null +++ b/examples/zeroex/src/ZeroExSettlerAssertion.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "credible-std/PhEvm.sol"; + +import {ZeroExSettlerHelpers} from "./ZeroExSettlerHelpers.sol"; +import { + IZeroExBridgeSettlerLike, + IZeroExSettlerLike, + IZeroExSettlerMetaTxnLike, + ZeroExSettlerSlippage +} from "./ZeroExSettlerInterfaces.sol"; + +/// @title ZeroExSettlerAssertion +/// @author Phylax Systems +/// @notice Example assertion bundle for 0x Settler deployments. +/// @dev Protects router-level settlement invariants that are awkward or expensive to enforce in +/// every production call: +/// - live executions must target the registry current or previous Settler for the feature; +/// - ERC20 buy-token settlements must increase the declared recipient balance by at least +/// the signed minimum output, catching fee-on-transfer or malicious-token edge cases. +/// - settlement must not move ERC20 tokens from a source that pre-approved the Settler, +/// catching allowance-drain paths that bypass the intended permit or action flow. +contract ZeroExSettlerAssertion is ZeroExSettlerHelpers { + constructor(address settler_, address registry_, uint128 featureId_) + ZeroExSettlerHelpers(settler_, registry_, featureId_) + {} + + /// @notice Registers all supported 0x Settler settlement entry points. + /// @dev Each assertion uses call-scoped fork reads so checks are bound to the exact execution + /// that the adopter accepted. + function triggers() external view override { + registerFnCallTrigger(this.assertSettlerRegistered.selector, IZeroExSettlerLike.execute.selector); + registerFnCallTrigger(this.assertSettlerRegistered.selector, IZeroExSettlerLike.executeWithPermit.selector); + registerFnCallTrigger(this.assertSettlerRegistered.selector, IZeroExSettlerMetaTxnLike.executeMetaTxn.selector); + + registerFnCallTrigger( + this.assertRecipientReceivesMinimumBuyAmount.selector, IZeroExSettlerLike.execute.selector + ); + registerFnCallTrigger( + this.assertRecipientReceivesMinimumBuyAmount.selector, IZeroExSettlerLike.executeWithPermit.selector + ); + registerFnCallTrigger( + this.assertRecipientReceivesMinimumBuyAmount.selector, IZeroExSettlerMetaTxnLike.executeMetaTxn.selector + ); + + registerFnCallTrigger(this.assertNoPreApprovedTransferSource.selector, IZeroExSettlerLike.execute.selector); + registerFnCallTrigger( + this.assertNoPreApprovedTransferSource.selector, IZeroExSettlerLike.executeWithPermit.selector + ); + registerFnCallTrigger( + this.assertNoPreApprovedTransferSource.selector, IZeroExSettlerMetaTxnLike.executeMetaTxn.selector + ); + registerFnCallTrigger( + this.assertNoPreApprovedTransferSource.selector, IZeroExBridgeSettlerLike.execute.selector + ); + } + + /// @notice A called Settler must still be the registry's current or previous deployment. + /// @dev 0x integrations are expected to resolve Settler through the deployer/registry because + /// old instances can be removed or paused. A failure means the transaction used a router + /// address that the registry no longer recognizes for the configured feature. + function assertSettlerRegistered() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredSettlerIsAdopter(); + _requireRegisteredSettlerAt(_preCall(ctx.callStart)); + _requireRegisteredSettlerAt(_postCall(ctx.callEnd)); + } + + /// @notice ERC20 settlement must credit the declared recipient by at least `minAmountOut`. + /// @dev Settler checks its own token balance before transferring, but only a fork-aware + /// recipient balance comparison can catch tokens whose transfer succeeds while crediting + /// less than the user-signed minimum. Native ETH payouts are intentionally out of scope + /// because this assertion surface currently reads ERC20 balances only. + function assertRecipientReceivesMinimumBuyAmount() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredSettlerIsAdopter(); + + ZeroExSettlerSlippage memory slippage = _slippageFromCallInput(ph.callinputAt(ctx.callStart)); + if (slippage.minAmountOut == 0 || slippage.buyToken == ETH_SENTINEL) { + return; + } + + uint256 beforeBalance = _readBalanceAt(slippage.buyToken, slippage.recipient, _preCall(ctx.callStart)); + uint256 afterBalance = _readBalanceAt(slippage.buyToken, slippage.recipient, _postCall(ctx.callEnd)); + + require(afterBalance >= beforeBalance, "0xSettler: recipient balance decreased"); + require(afterBalance - beforeBalance >= slippage.minAmountOut, "0xSettler: recipient credited below minimum"); + } + + /// @notice Rejects ERC20 transfers sourced from accounts that pre-approved this Settler. + /// @dev The check is call-scoped: it inspects ERC20 Transfer logs emitted during the triggered + /// Settler call, then reads `allowance(from, settler)` at the pre-call fork. A failure means + /// the settlement moved tokens from an address that already exposed spend authority. + function assertNoPreApprovedTransferSource() external view { + PhEvm.TriggerContext memory ctx = ph.context(); + _requireConfiguredSettlerIsAdopter(); + _assertNoPreCallAllowanceForTransferLogs(ctx.callStart); + } +} diff --git a/examples/zeroex/src/ZeroExSettlerHelpers.sol b/examples/zeroex/src/ZeroExSettlerHelpers.sol new file mode 100644 index 0000000..284ba53 --- /dev/null +++ b/examples/zeroex/src/ZeroExSettlerHelpers.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "credible-std/Assertion.sol"; +import {PhEvm} from "credible-std/PhEvm.sol"; +import {AssertionSpec} from "credible-std/SpecRecorder.sol"; + +import { + IERC20AllowanceReaderLike, + IZeroExSettlerRegistryLike, + ZeroExSettlerSlippage +} from "./ZeroExSettlerInterfaces.sol"; + +/// @title ZeroExSettlerHelpers +/// @author Phylax Systems +/// @notice Fork-aware helpers for 0x Settler router assertions. +abstract contract ZeroExSettlerHelpers is Assertion { + address internal constant ETH_SENTINEL = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + bytes32 internal constant ERC20_TRANSFER_SIG = keccak256("Transfer(address,address,uint256)"); + + address internal immutable SETTLER; + address internal immutable REGISTRY; + uint128 internal immutable FEATURE_ID; + + constructor(address settler_, address registry_, uint128 featureId_) { + SETTLER = settler_; + REGISTRY = registry_; + FEATURE_ID = featureId_; + registerAssertionSpec(AssertionSpec.Reshiram); + } + + function _viewFailureMessage() internal pure override returns (string memory) { + return "0xSettler: fork read failed"; + } + + function _requireConfiguredSettlerIsAdopter() internal view { + require(ph.getAssertionAdopter() == SETTLER, "0xSettler: configured settler is not adopter"); + } + + function _requireRegisteredSettlerAt(PhEvm.ForkId memory fork) internal view { + address current = + _readAddressAt(REGISTRY, abi.encodeCall(IZeroExSettlerRegistryLike.ownerOf, (FEATURE_ID)), fork); + + if (current == SETTLER) { + return; + } + + address previous = _readAddressAt(REGISTRY, abi.encodeCall(IZeroExSettlerRegistryLike.prev, (FEATURE_ID)), fork); + require(previous == SETTLER, "0xSettler: unregistered settler"); + } + + function _slippageFromCallInput(bytes memory input) internal pure returns (ZeroExSettlerSlippage memory slippage) { + require(input.length >= 100, "0xSettler: short calldata"); + + assembly { + slippage := mload(0x40) + mstore(0x40, add(slippage, 0x60)) + mstore(slippage, and(mload(add(input, 0x24)), 0xffffffffffffffffffffffffffffffffffffffff)) + mstore(add(slippage, 0x20), and(mload(add(input, 0x44)), 0xffffffffffffffffffffffffffffffffffffffff)) + mstore(add(slippage, 0x40), mload(add(input, 0x64))) + } + } + + function _assertNoPreCallAllowanceForTransferLogs(uint256 callId) internal view { + PhEvm.LogQuery memory query = PhEvm.LogQuery({emitter: address(0), signature: ERC20_TRANSFER_SIG}); + PhEvm.Log[] memory logs = ph.getLogsForCall(query, callId); + PhEvm.ForkId memory beforeFork = _preCall(callId); + + for (uint256 i; i < logs.length; ++i) { + if (!_isErc20Transfer(logs[i])) { + continue; + } + + address from = _topicAddress(logs[i].topics[1]); + uint256 amount = abi.decode(logs[i].data, (uint256)); + if (amount == 0) { + continue; + } + + uint256 allowance = _allowanceAt(logs[i].emitter, from, SETTLER, beforeFork); + require(allowance == 0, "0xSettler: transfer source pre-approved settler"); + } + } + + function _allowanceAt(address token, address owner, address spender, PhEvm.ForkId memory fork) + internal + view + returns (uint256 allowance) + { + PhEvm.StaticCallResult memory result = ph.staticcallAt( + token, abi.encodeCall(IERC20AllowanceReaderLike.allowance, (owner, spender)), FORK_VIEW_GAS, fork + ); + require(result.ok, "0xSettler: allowance read failed"); + return abi.decode(result.data, (uint256)); + } + + function _isErc20Transfer(PhEvm.Log memory log) internal pure returns (bool) { + return log.topics.length == 3 && log.topics[0] == ERC20_TRANSFER_SIG && log.data.length >= 32; + } + + function _topicAddress(bytes32 topic) internal pure returns (address) { + return address(uint160(uint256(topic))); + } +} diff --git a/examples/zeroex/src/ZeroExSettlerInterfaces.sol b/examples/zeroex/src/ZeroExSettlerInterfaces.sol new file mode 100644 index 0000000..04b1685 --- /dev/null +++ b/examples/zeroex/src/ZeroExSettlerInterfaces.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// @notice Minimal 0x Settler slippage tuple used by the public settlement entry points. +struct ZeroExSettlerSlippage { + address payable recipient; + address buyToken; + uint256 minAmountOut; +} + +/// @notice Public taker-submitted 0x Settler entry points protected by the example assertions. +interface IZeroExSettlerLike { + function execute(ZeroExSettlerSlippage calldata slippage, bytes[] calldata actions, bytes32 zidAndAffiliate) + external + payable + returns (bool); + + function executeWithPermit( + ZeroExSettlerSlippage calldata slippage, + bytes[] calldata actions, + bytes32 zidAndAffiliate, + bytes calldata permitData + ) external payable returns (bool); +} + +/// @notice Public meta-transaction 0x Settler entry point protected by the example assertions. +interface IZeroExSettlerMetaTxnLike { + function executeMetaTxn( + ZeroExSettlerSlippage calldata slippage, + bytes[] calldata actions, + bytes32 zidAndAffiliate, + address msgSender, + bytes calldata sig + ) external returns (bool); +} + +/// @notice Bridge-flavored 0x Settler entry point protected by authorization assertions. +interface IZeroExBridgeSettlerLike { + function execute(bytes[] calldata actions, bytes32 zidAndAffiliate) external payable returns (bool); +} + +/// @notice Minimal ERC20 allowance surface used for pre-call authorization checks. +interface IERC20AllowanceReaderLike { + function allowance(address owner, address spender) external view returns (uint256); +} + +/// @notice Minimal 0x Settler deployer/registry view surface. +interface IZeroExSettlerRegistryLike { + function ownerOf(uint256 tokenId) external view returns (address); + function prev(uint128 featureId) external view returns (address); +} diff --git a/foundry.toml b/foundry.toml index 3fc106e..b58e4fc 100644 --- a/foundry.toml +++ b/foundry.toml @@ -42,6 +42,115 @@ remappings = [ "credible-std/=src/" ] +# Per-protocol example profiles. +# Each profile compiles examples//src/ standalone, sharing root +# credible-std (src/) and root lib/ via the credible-std remapping. + +[profile.aave] +src = "examples/aave/src" +out = "examples/aave/out" +cache_path = "examples/aave/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.aerodrome] +src = "examples/aerodrome/src" +out = "examples/aerodrome/out" +cache_path = "examples/aerodrome/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.cap] +src = "examples/cap/src" +out = "examples/cap/out" +cache_path = "examples/cap/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.curve] +src = "examples/curve/src" +out = "examples/curve/out" +cache_path = "examples/curve/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.denaria] +src = "examples/denaria/src" +out = "examples/denaria/out" +cache_path = "examples/denaria/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.euler] +src = "examples/euler/src" +out = "examples/euler/out" +cache_path = "examples/euler/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.nado] +src = "examples/nado/src" +out = "examples/nado/out" +cache_path = "examples/nado/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.royco] +src = "examples/royco/src" +out = "examples/royco/out" +cache_path = "examples/royco/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.safe] +src = "examples/safe/src" +out = "examples/safe/out" +cache_path = "examples/safe/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.spark] +src = "examples/spark/src" +out = "examples/spark/out" +cache_path = "examples/spark/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.symbiotic] +src = "examples/symbiotic/src" +out = "examples/symbiotic/out" +cache_path = "examples/symbiotic/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.tydro] +src = "examples/tydro/src" +out = "examples/tydro/out" +cache_path = "examples/tydro/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.uniswap] +src = "examples/uniswap/src" +out = "examples/uniswap/out" +cache_path = "examples/uniswap/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.veda] +src = "examples/veda/src" +out = "examples/veda/out" +cache_path = "examples/veda/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + +[profile.zeroex] +src = "examples/zeroex/src" +out = "examples/zeroex/out" +cache_path = "examples/zeroex/cache" +libs = ["lib"] +remappings = ["credible-std/=src/"] + [doc] title = "Credible Standard Library" repository = "https://github.com/phylaxsystems/credible-std" diff --git a/src/PhEvm.sol b/src/PhEvm.sol index a6a3678..069a961 100644 --- a/src/PhEvm.sol +++ b/src/PhEvm.sol @@ -528,4 +528,10 @@ interface PhEvm { external pure returns (bool); + + /// @notice Returns the market price of `asset` quoted in `denomAsset`, normalized to 18 decimals. + /// @param asset The asset whose price is requested. + /// @param denomAsset The quote asset, or address(0) for USD. + /// @return price The 18-decimal market price of `asset` in `denomAsset`. + function marketPrice(address asset, address denomAsset) external view returns (uint256 price); } diff --git a/src/protection/access_control/AccessControlBaseAssertion.sol b/src/protection/access_control/AccessControlBaseAssertion.sol new file mode 100644 index 0000000..05b2bc6 --- /dev/null +++ b/src/protection/access_control/AccessControlBaseAssertion.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "../../Assertion.sol"; + +/// @title AccessControlBaseAssertion +/// @author Phylax Systems +/// @notice Base contract for access-control assertions (V2 syntax). +/// @dev Provides the protected target address and shared helpers for the access-control suite. +/// Inherit from this (and one or more invariant contracts), then implement `triggers()`. +/// +/// Example -- combine slot protection and balance conservation: +/// ```solidity +/// contract MyProtocolGuard is SlotProtectionAssertion, BalanceConservationAssertion { +/// constructor(address _target) +/// AccessControlBaseAssertion(_target) +/// {} +/// +/// function _protectedSlots() internal pure override returns (bytes32[] memory) { ... } +/// function _conservedBalances() internal view override returns (ConservedBalance[] memory) { ... } +/// +/// function triggers() external view override { +/// _registerSlotProtectionTriggers(); +/// _registerBalanceConservationTriggers(); +/// } +/// } +/// ``` +abstract contract AccessControlBaseAssertion is Assertion { + /// @notice The contract whose access control is being protected (assertion adopter). + address internal immutable target; + + constructor(address _target) { + target = _target; + } +} diff --git a/src/protection/access_control/BalanceConservationAssertion.sol b/src/protection/access_control/BalanceConservationAssertion.sol new file mode 100644 index 0000000..46af958 --- /dev/null +++ b/src/protection/access_control/BalanceConservationAssertion.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "../../PhEvm.sol"; +import {AccessControlBaseAssertion} from "./AccessControlBaseAssertion.sol"; + +/// @title BalanceConservationAssertion +/// @author Phylax Systems +/// @notice Asserts that specified account token balances do not change during the transaction, +/// catching unauthorized balance changes for accounts that should remain untouched. +/// +/// Invariants covered: +/// - **Treasury / reserve protection**: treasury or reserve account balances remain unchanged +/// unless an authorized governance path is followed. +/// - **Escrow protection**: escrow contract balances are conserved across the transaction. +/// - **Unauthorized transfer detection**: catches privilege misuse, reentrancy side effects, +/// or unexpected execution paths that drain protected accounts. +/// - **Custodial leg verification**: for RWA protocols, outflows only along known +/// subscription/redemption legs. +/// +/// @dev Uses the V2 `conserveBalance(fork0, fork1, token, account)` precompile to compare +/// `balanceOf(account)` at PreTx vs PostTx for each protected (token, account) pair. +/// +/// Implementers must override `_conservedBalances()` to declare which (token, account) +/// pairs to protect. For selector-aware outflow caps (e.g., looser limits on approved +/// withdraw/redeem selectors), use per-function triggers and custom assertion logic +/// instead of this mixin. +abstract contract BalanceConservationAssertion is AccessControlBaseAssertion { + /// @notice A (token, account) pair whose balance must be conserved across the transaction. + struct ConservedBalance { + /// @notice The ERC20 token address. + address token; + /// @notice The account whose balance should remain unchanged. + address account; + } + + /// @notice Returns the (token, account) pairs whose balances must not change. + /// @dev Override to declare the protocol-specific protected balances. Common targets: + /// - Protocol treasury / DAO treasury USDC/ETH balances + /// - Pool cash reserves (Maple pool, Centrifuge escrow) + /// - Vault reserves and fee-leftover accounts (Lido stVault) + /// - Collateral and burner flows (Symbiotic vault) + /// - RWAHub / subscription-redemption custodial balances (Ondo) + /// @return balances Array of (token, account) pairs to conserve. + function _conservedBalances() internal view virtual returns (ConservedBalance[] memory balances); + + /// @notice Register the default trigger set for balance conservation. + /// @dev Uses registerTxEndTrigger so the check fires once after the transaction completes. + /// Call this inside your `triggers()`. + function _registerBalanceConservationTriggers() internal view { + registerTxEndTrigger(this.assertBalanceConservation.selector); + } + + /// @notice Verifies that all protected account balances are unchanged across the transaction. + /// @dev Checks each (token, account) pair using the `conserveBalance` precompile at + /// PreTx vs PostTx. Reverts on the first pair whose balance changed. + function assertBalanceConservation() external { + ConservedBalance[] memory balances = _conservedBalances(); + PhEvm.ForkId memory pre = _preTx(); + PhEvm.ForkId memory post = _postTx(); + + for (uint256 i = 0; i < balances.length; i++) { + require( + ph.conserveBalance(pre, post, balances[i].token, balances[i].account), + "AccessControl: protected balance changed" + ); + } + } +} diff --git a/src/protection/access_control/SharePriceAssertion.sol b/src/protection/access_control/SharePriceAssertion.sol new file mode 100644 index 0000000..9fb0349 --- /dev/null +++ b/src/protection/access_control/SharePriceAssertion.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AccessControlBaseAssertion} from "./AccessControlBaseAssertion.sol"; + +/// @title SharePriceAssertion +/// @author Phylax Systems +/// @notice Asserts that ERC-4626 vault share prices do not deviate beyond a configurable +/// tolerance, protecting against admin-driven share price manipulation. +/// +/// Invariants covered: +/// - **Share price stability**: the ratio totalAssets / totalSupply must not shift more +/// than `toleranceBps` across any fork point in the transaction. +/// - **Donation attack prevention**: catches inflated totalAssets without proportional +/// share minting. +/// - **First-depositor exploit prevention**: detects exchange rate manipulation with +/// tiny initial deposits followed by large donations. +/// - **Flash-loan manipulation**: flags temporary share price distortion within a +/// transaction. +/// +/// @dev Uses the V2 `assetsMatchSharePrice` precompile for a comprehensive all-forks check. +/// This is a simpler mixin than the full ERC4626SharePriceAssertion -- it omits per-call +/// triggers and focuses on tx-wide share price envelope protection. Use this when the +/// access-control concern is preventing admin manipulation of share prices, rather than +/// enforcing full ERC-4626 compliance. +/// +/// Implementers must override `_protectedVaults()` to declare which vault addresses and +/// tolerances to check. +abstract contract SharePriceAssertion is AccessControlBaseAssertion { + /// @notice A vault and its maximum acceptable share-price deviation. + struct ProtectedVault { + /// @notice The ERC-4626 vault address. + address vault; + /// @notice Maximum allowed share price deviation in basis points. 100 = 1%. + uint256 toleranceBps; + } + + /// @notice Returns the vaults whose share prices must remain stable across the transaction. + /// @dev Override to declare the protocol-specific vault addresses and tolerances. + /// Tighter tolerances (e.g., 10 bps) are appropriate for stablecoin vaults; + /// wider tolerances (e.g., 50-100 bps) may be needed for volatile-asset vaults + /// or vaults with rebasing underlying assets. + /// @return vaults Array of (vault, toleranceBps) pairs to protect. + function _protectedVaults() internal view virtual returns (ProtectedVault[] memory vaults); + + /// @notice Register the default trigger set for share price protection. + /// @dev Uses registerTxEndTrigger so the check fires once after the transaction completes. + /// Call this inside your `triggers()`. + function _registerSharePriceTriggers() internal view { + registerTxEndTrigger(this.assertSharePrice.selector); + } + + /// @notice Verifies that all protected vault share prices are stable across the transaction. + /// @dev Uses `ph.assetsMatchSharePrice()` for each vault, which reads totalAssets() and + /// totalSupply() at every fork point and checks for deviation beyond the tolerance. + /// Reverts on the first vault whose share price moved beyond tolerance. + function assertSharePrice() external { + ProtectedVault[] memory vaults = _protectedVaults(); + + for (uint256 i = 0; i < vaults.length; i++) { + require( + ph.assetsMatchSharePrice(vaults[i].vault, vaults[i].toleranceBps), + "AccessControl: vault share price drift exceeds tolerance" + ); + } + } +} diff --git a/src/protection/access_control/SlotProtectionAssertion.sol b/src/protection/access_control/SlotProtectionAssertion.sol new file mode 100644 index 0000000..9319dd1 --- /dev/null +++ b/src/protection/access_control/SlotProtectionAssertion.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {AccessControlBaseAssertion} from "./AccessControlBaseAssertion.sol"; + +/// @title SlotProtectionAssertion +/// @author Phylax Systems +/// @notice Asserts that critical storage slots on the assertion adopter are not modified +/// during the transaction. +/// +/// Invariants covered: +/// - **Ownership immutability**: proxy admin, owner, sentinel, and implementation slots +/// cannot be changed outside expected governance choreography. +/// - **Timelock integrity**: delay values, proposer/executor/canceller role slots are +/// frozen so an attacker cannot shorten a delay before exploiting a governance path. +/// - **Role stability**: admin roles, operator roles, manager roles remain unchanged +/// unless an authorized governance action modifies them. +/// - **Configuration guards**: fee parameters, oracle addresses, whitelist/blocklist +/// settings, and other safety-critical configuration slots. +/// +/// @dev Uses the V2 `forbidChangeForSlots` precompile which checks the transaction journal +/// for any SSTORE to the specified slots. A write is flagged even if it sets the same +/// value (conservative -- a write is suspicious regardless of whether the value changed). +/// Writes inside reverted internal calls are rolled back in the journal and do not +/// trigger a violation. +/// +/// Implementers must override `_protectedSlots()` to declare which slots to protect. +/// For mapping entries (e.g., `roles[account]`), compute the slot off-chain via +/// `keccak256(abi.encode(key, mappingSlot))`. +/// +/// The policy enforced is: **fail by default** on any watched-slot mutation. Protocols +/// that need conditional slot changes should use per-function triggers instead and +/// verify the change follows the expected governance path. +abstract contract SlotProtectionAssertion is AccessControlBaseAssertion { + /// @notice Returns the storage slots that must not be modified during the transaction. + /// @dev Override to declare the protocol-specific critical slots. Common examples: + /// - EIP-1967 admin slot: `0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103` + /// - EIP-1967 implementation slot: `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc` + /// - Gnosis Safe owner sentinel (slot 2), threshold (slot 4), singleton (slot 0) + /// - Timelock delay parameters, proposer/executor/canceller roles + /// - Protocol-specific admin, fee, oracle, and permission level slots + /// @return slots Array of storage slot identifiers to protect. + function _protectedSlots() internal pure virtual returns (bytes32[] memory slots); + + /// @notice Register the default trigger set for slot protection. + /// @dev Uses registerTxEndTrigger so the check fires once after the transaction completes. + /// Call this inside your `triggers()`. + function _registerSlotProtectionTriggers() internal view { + registerTxEndTrigger(this.assertSlotProtection.selector); + } + + /// @notice Verifies that none of the protected storage slots were written to during the tx. + /// @dev Uses `ph.forbidChangeForSlots()` for a single precompile call covering all slots. + /// Reverts if any protected slot was modified. + function assertSlotProtection() external { + bytes32[] memory slots = _protectedSlots(); + require(ph.forbidChangeForSlots(slots), "AccessControl: protected slot was modified"); + } +} diff --git a/src/protection/lending/examples/AaveV3LikeInterfaces.sol b/src/protection/lending/examples/AaveV3LikeInterfaces.sol index 4b6c61d..5cb4a70 100644 --- a/src/protection/lending/examples/AaveV3LikeInterfaces.sol +++ b/src/protection/lending/examples/AaveV3LikeInterfaces.sol @@ -87,4 +87,6 @@ interface IAaveV3LikeAddressesProvider { interface IAaveV3LikeOracle { function getAssetPrice(address asset) external view returns (uint256); + function BASE_CURRENCY() external view returns (address); + function BASE_CURRENCY_UNIT() external view returns (uint256); } diff --git a/src/protection/lending/examples/AaveV3LikeOperationSafety.sol b/src/protection/lending/examples/AaveV3LikeOperationSafety.sol index 4748517..a8c2135 100644 --- a/src/protection/lending/examples/AaveV3LikeOperationSafety.sol +++ b/src/protection/lending/examples/AaveV3LikeOperationSafety.sol @@ -1,18 +1,471 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; +import {PhEvm} from "../../../PhEvm.sol"; import {ILendingProtectionSuite} from "../ILendingProtectionSuite.sol"; -import {LendingBaseAssertion} from "../LendingBaseAssertion.sol"; +import {LendingBaseAssertion, LendingProtectionSuiteBase} from "../LendingBaseAssertion.sol"; +import { + AaveV3LikeTypes, + IAaveV3LikeAddressesProvider, + IAaveV3LikeOracle, + IAaveV3LikePool, + IERC20MetadataLike +} from "./AaveV3LikeInterfaces.sol"; + +/// @title AaveV3LikeProtectionSuite +/// @author Phylax Systems +/// @notice Shared `ILendingProtectionSuite` implementation for Aave v3-compatible lending forks. +/// @dev This adapter matches the interface and accounting model used by forks such as the local +/// Aave v3 Horizon deployment and SparkLend v1. +contract AaveV3LikeProtectionSuite is LendingProtectionSuiteBase { + /// @notice Extra aggregate Aave v3-like metrics kept in `AccountState.metadata`. + struct AaveAccountMetrics { + uint256 availableBorrowsBase; + uint256 currentLiquidationThreshold; + uint256 ltv; + uint256 healthFactor; + } + + bytes32 internal constant HEALTH_FACTOR_METRIC = 0x4845414c54485f464143544f5200000000000000000000000000000000000000; + bytes32 internal constant WITHDRAW_CLAIM_CHECK = "WITHDRAW_CLAIM"; + bytes32 internal constant LIQUIDATION_DEBT_CHECK = "LIQUIDATION_DEBT"; + bytes32 internal constant LIQUIDATION_COLLATERAL_CHECK = "LIQUIDATION_COLLATERAL"; + uint256 internal constant HEALTH_FACTOR_THRESHOLD = 1e18; + int256 internal constant HEALTH_FACTOR_THRESHOLD_INT = 1e18; + + address internal immutable POOL; + address internal immutable ADDRESSES_PROVIDER; + + /// @notice Creates an Aave v3-like suite bound to a specific pool. + /// @param pool_ Pool address whose accounting and selectors this suite targets. + /// @param addressesProvider_ The pool's `ADDRESSES_PROVIDER`. Passed in explicitly because + /// assertions are deployed against an empty state where calling the pool would fail. + constructor(address pool_, address addressesProvider_) { + POOL = pool_; + ADDRESSES_PROVIDER = addressesProvider_; + } + + /// @notice Returns the Aave v3-like pool selectors relevant to the shared lending invariants. + function getMonitoredSelectors() external pure virtual override returns (bytes4[] memory selectors) { + selectors = new bytes4[](6); + selectors[0] = IAaveV3LikePool.borrow.selector; + selectors[1] = IAaveV3LikePool.withdraw.selector; + selectors[2] = IAaveV3LikePool.liquidationCall.selector; + selectors[3] = IAaveV3LikePool.setUserUseReserveAsCollateral.selector; + selectors[4] = IAaveV3LikePool.finalizeTransfer.selector; + selectors[5] = IAaveV3LikePool.setUserEMode.selector; + } + + /// @notice Decodes an Aave v3-like pool call into the shared lending operation model. + function decodeOperation(TriggeredCall calldata triggered) + external + view + virtual + override + returns (OperationContext memory operation) + { + operation.selector = triggered.selector; + operation.caller = triggered.caller; + + if (triggered.selector == IAaveV3LikePool.borrow.selector) { + (address asset, uint256 amount,,, address onBehalfOf) = + abi.decode(triggered.input[4:], (address, uint256, uint256, uint16, address)); + + operation.kind = OperationKind.Borrow; + operation.account = onBehalfOf; + operation.asset = asset; + operation.amount = amount; + operation.increasesDebt = amount != 0; + return operation; + } + + if (triggered.selector == IAaveV3LikePool.withdraw.selector) { + (address asset, uint256 amount, address to) = abi.decode(triggered.input[4:], (address, uint256, address)); + + operation.kind = OperationKind.WithdrawCollateral; + operation.account = triggered.caller; + operation.asset = asset; + operation.counterparty = to; + operation.amount = amount; + operation.reducesEffectiveCollateral = amount != 0; + return operation; + } + + if (triggered.selector == IAaveV3LikePool.liquidationCall.selector) { + (address collateralAsset, address debtAsset, address user, uint256 debtToCover, bool receiveAToken) = + abi.decode(triggered.input[4:], (address, address, address, uint256, bool)); + + operation.kind = OperationKind.Liquidation; + operation.account = user; + operation.asset = debtAsset; + operation.relatedAsset = collateralAsset; + operation.counterparty = triggered.caller; + operation.amount = debtToCover; + operation.metadata = abi.encode(receiveAToken); + return operation; + } + + if (triggered.selector == IAaveV3LikePool.setUserUseReserveAsCollateral.selector) { + (address asset, bool useAsCollateral) = abi.decode(triggered.input[4:], (address, bool)); + + if (!useAsCollateral) { + operation.kind = OperationKind.DisableCollateral; + operation.account = triggered.caller; + operation.asset = asset; + operation.reducesEffectiveCollateral = true; + } + + return operation; + } + + if (triggered.selector == IAaveV3LikePool.finalizeTransfer.selector) { + (address asset, address from, address to, uint256 amount,,) = + abi.decode(triggered.input[4:], (address, address, address, uint256, uint256, uint256)); + + operation.kind = OperationKind.TransferCollateral; + operation.account = from; + operation.asset = asset; + operation.counterparty = to; + operation.amount = amount; + operation.reducesEffectiveCollateral = from != to && amount != 0; + return operation; + } + + if (triggered.selector == IAaveV3LikePool.setUserEMode.selector) { + (uint8 categoryId) = abi.decode(triggered.input[4:], (uint8)); + + operation.kind = OperationKind.SetEMode; + operation.account = triggered.caller; + operation.amount = uint256(categoryId); + operation.metadata = abi.encode(categoryId); + return operation; + } + } + + /// @notice Filters decoded Aave v3-like operations down to the ones that must preserve solvency. + function shouldCheckPostOperationSolvency(OperationContext calldata operation) + external + pure + override + returns (bool shouldCheck) + { + return operation.account != address(0) + && (operation.increasesDebt + || operation.reducesEffectiveCollateral + || operation.kind == OperationKind.SetEMode); + } + + /// @notice Returns the bounded-consumption checks implied by the decoded Aave v3-like operation. + function getConsumptionChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view override returns (ConsumptionCheck[] memory checks) { + if (operation.kind == OperationKind.WithdrawCollateral) { + checks = new ConsumptionCheck[](1); + checks[0] = _getWithdrawClaimCheck(triggered, operation, beforeFork); + return checks; + } + + if (operation.kind == OperationKind.Liquidation) { + checks = new ConsumptionCheck[](2); + checks[0] = _getLiquidationDebtCheck(operation, beforeFork, afterFork); + checks[1] = _getLiquidationCollateralCheck(operation, beforeFork, afterFork); + } + } + + /// @notice Reads the post-call snapshot needed by the health-factor solvency invariant. + function getAccountSnapshot(address account, PhEvm.ForkId calldata fork) + external + view + virtual + override + returns (AccountSnapshot memory snapshot) + { + snapshot.state = _getAccountState(account, fork); + snapshot.solvency = _evaluateHealthFactor(snapshot.state); + } + + /// @notice Reads aggregate account metrics from `Pool.getUserAccountData(...)`. + function getAccountState(address account, PhEvm.ForkId calldata fork) + external + view + override + returns (AccountState memory state) + { + return _getAccountState(account, fork); + } + + /// @notice Enumerates reserve balances for the account. + function getAccountBalances(address account, PhEvm.ForkId calldata fork) + external + view + override + returns (AccountBalance[] memory balances) + { + return _getAccountBalances(account, fork); + } + + /// @notice Evaluates solvency from aggregate account state. + function evaluateSolvency( + AccountState calldata state, + AccountBalance[] calldata balances, + PhEvm.ForkId calldata fork + ) external pure override returns (SolvencyState memory solvency) { + balances; + fork; + return _evaluateHealthFactor(state); + } + + /// @notice Internal helper that reads and normalizes aggregate account data. + function _getAccountState(address account, PhEvm.ForkId memory fork) + internal + view + returns (AccountState memory state) + { + ( + uint256 totalCollateralBase, + uint256 totalDebtBase, + uint256 availableBorrowsBase, + uint256 currentLiquidationThreshold, + uint256 ltv, + uint256 healthFactor + ) = abi.decode( + _viewAt(POOL, abi.encodeCall(IAaveV3LikePool.getUserAccountData, (account)), fork), + (uint256, uint256, uint256, uint256, uint256, uint256) + ); + + state.account = account; + state.totalCollateralValue = totalCollateralBase; + state.totalDebtValue = totalDebtBase; + state.hasDebt = totalDebtBase != 0; + state.metadata = abi.encode( + AaveAccountMetrics({ + availableBorrowsBase: availableBorrowsBase, + currentLiquidationThreshold: currentLiquidationThreshold, + ltv: ltv, + healthFactor: healthFactor + }) + ); + } + + /// @notice Internal helper that expands the account into reserve-level balances and values. + function _getAccountBalances(address account, PhEvm.ForkId memory fork) + internal + view + returns (AccountBalance[] memory balances) + { + address[] memory reserves = + abi.decode(_viewAt(POOL, abi.encodeCall(IAaveV3LikePool.getReservesList, ()), fork), (address[])); + AaveV3LikeTypes.UserConfigurationMap memory userConfig = abi.decode( + _viewAt(POOL, abi.encodeCall(IAaveV3LikePool.getUserConfiguration, (account)), fork), + (AaveV3LikeTypes.UserConfigurationMap) + ); + + address oracle = + _readAddressAt(ADDRESSES_PROVIDER, abi.encodeCall(IAaveV3LikeAddressesProvider.getPriceOracle, ()), fork); + + balances = new AccountBalance[](reserves.length); + uint256 count; + + for (uint256 i; i < reserves.length; ++i) { + (bool include, AccountBalance memory balance) = + _buildAccountBalance(reserves[i], account, userConfig.data, oracle, fork); + + if (!include) { + continue; + } + + balances[count++] = balance; + } + + assembly { + mstore(balances, count) + } + } + + /// @notice Builds the withdraw bounded-consumption check from call output and pre-state. + function _getWithdrawClaimCheck( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork + ) internal view returns (ConsumptionCheck memory check) { + AaveV3LikeTypes.ReserveData memory reserveData = _getReserveData(operation.asset, beforeFork); + uint256 availableBefore = _readBalanceAt(reserveData.aTokenAddress, operation.account, beforeFork); + uint256 consumed = abi.decode(ph.callOutputAt(triggered.callStart), (uint256)); + + check = ConsumptionCheck({ + checkName: WITHDRAW_CLAIM_CHECK, + account: operation.account, + asset: operation.asset, + availableBefore: availableBefore, + consumed: consumed, + metadata: abi.encode(reserveData.aTokenAddress) + }); + } + + /// @notice Builds the liquidation debt-consumption check from actual debt-asset transfers. + function _getLiquidationDebtCheck( + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) internal view returns (ConsumptionCheck memory check) { + AaveV3LikeTypes.ReserveData memory reserveData = _getReserveData(operation.asset, beforeFork); + uint256 debtBefore = _getUserReserveDebt(operation.asset, operation.account, beforeFork); + uint256 repaidEffective = + _transferredValueAt(operation.asset, operation.counterparty, reserveData.aTokenAddress, afterFork); + + check = ConsumptionCheck({ + checkName: LIQUIDATION_DEBT_CHECK, + account: operation.account, + asset: operation.asset, + availableBefore: debtBefore, + consumed: repaidEffective, + metadata: abi.encode(reserveData.aTokenAddress, operation.counterparty) + }); + } + + /// @notice Builds the liquidation collateral-consumption check from actual collateral transfers. + function _getLiquidationCollateralCheck( + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) internal view returns (ConsumptionCheck memory check) { + // bug: different decimals break return asset accounting + bool receiveAToken = abi.decode(operation.metadata, (bool)); + AaveV3LikeTypes.ReserveData memory reserveData = _getReserveData(operation.relatedAsset, beforeFork); + uint256 collateralBefore = _readBalanceAt(reserveData.aTokenAddress, operation.account, beforeFork); + address seizedToken = receiveAToken ? reserveData.aTokenAddress : operation.relatedAsset; + address transferSender = receiveAToken ? operation.account : reserveData.aTokenAddress; + uint256 seizedEffective = _transferredValueAt(seizedToken, transferSender, operation.counterparty, afterFork); + + check = ConsumptionCheck({ + checkName: LIQUIDATION_COLLATERAL_CHECK, + account: operation.account, + asset: operation.relatedAsset, + availableBefore: collateralBefore, + consumed: seizedEffective, + metadata: abi.encode(reserveData.aTokenAddress, seizedToken, transferSender, receiveAToken) + }); + } + + /// @notice Converts aggregate metrics into the common solvency representation. + function _evaluateHealthFactor(AccountState memory state) internal pure returns (SolvencyState memory solvency) { + AaveAccountMetrics memory metrics = abi.decode(state.metadata, (AaveAccountMetrics)); + + solvency.isSolvent = !state.hasDebt || metrics.healthFactor >= HEALTH_FACTOR_THRESHOLD; + solvency.isLiquidatable = state.hasDebt && metrics.healthFactor < HEALTH_FACTOR_THRESHOLD; + solvency.metricName = HEALTH_FACTOR_METRIC; + solvency.metric = _toInt256(metrics.healthFactor); + solvency.threshold = HEALTH_FACTOR_THRESHOLD_INT; + solvency.comparison = ComparisonKind.Gte; + solvency.metadata = abi.encode(metrics.availableBorrowsBase, metrics.currentLiquidationThreshold, metrics.ltv); + } + + /// @notice Builds a single reserve-level balance entry for the account. + function _buildAccountBalance( + address asset, + address account, + uint256 userConfigData, + address oracle, + PhEvm.ForkId memory fork + ) internal view returns (bool include, AccountBalance memory balance) { + AaveV3LikeTypes.ReserveData memory reserveData = abi.decode( + _viewAt(POOL, abi.encodeCall(IAaveV3LikePool.getReserveData, (asset)), fork), (AaveV3LikeTypes.ReserveData) + ); + + uint256 collateralBalance = + _readUintAt(reserveData.aTokenAddress, abi.encodeCall(IERC20MetadataLike.balanceOf, (account)), fork); + uint256 debtBalance = _readUintAt( + reserveData.variableDebtTokenAddress, abi.encodeCall(IERC20MetadataLike.balanceOf, (account)), fork + ); + + if (collateralBalance == 0 && debtBalance == 0) { + return (false, balance); + } + + bool countsAsCollateral = collateralBalance != 0 && _isUsingAsCollateral(userConfigData, reserveData.id); + + balance = AccountBalance({ + asset: asset, + collateralBalance: collateralBalance, + debtBalance: debtBalance, + collateralValue: countsAsCollateral ? _valueInBase(oracle, asset, collateralBalance, fork) : 0, + debtValue: debtBalance == 0 ? 0 : _valueInBase(oracle, asset, debtBalance, fork), + countsAsCollateral: countsAsCollateral, + metadata: abi.encode(reserveData.id) + }); + + return (true, balance); + } + + /// @notice Reads reserve metadata for a single asset at the requested snapshot fork. + function _getReserveData(address asset, PhEvm.ForkId memory fork) + internal + view + returns (AaveV3LikeTypes.ReserveData memory reserveData) + { + reserveData = abi.decode( + _viewAt(POOL, abi.encodeCall(IAaveV3LikePool.getReserveData, (asset)), fork), (AaveV3LikeTypes.ReserveData) + ); + } + + /// @notice Reads the user's total debt for one reserve from the debt-token balances. + function _getUserReserveDebt(address asset, address account, PhEvm.ForkId memory fork) + internal + view + returns (uint256 debtBalance) + { + AaveV3LikeTypes.ReserveData memory reserveData = _getReserveData(asset, fork); + debtBalance = _readOptionalBalance(reserveData.stableDebtTokenAddress, account, fork) + + _readOptionalBalance(reserveData.variableDebtTokenAddress, account, fork); + } + + /// @notice Reads a token balance when the token address may be unset. + function _readOptionalBalance(address token, address account, PhEvm.ForkId memory fork) + internal + view + returns (uint256 balance) + { + if (token == address(0)) { + return 0; + } + + return _readBalanceAt(token, account, fork); + } + + /// @notice Converts an asset-denominated balance into the pool base currency. + function _valueInBase(address oracle, address asset, uint256 balance, PhEvm.ForkId memory fork) + internal + view + returns (uint256) + { + uint256 price = _readUintAt(oracle, abi.encodeCall(IAaveV3LikeOracle.getAssetPrice, (asset)), fork); + uint8 decimals = _readUint8At(asset, abi.encodeCall(IERC20MetadataLike.decimals, ()), fork); + return ph.mulDivDown(balance, price, 10 ** uint256(decimals)); + } + + /// @notice Returns whether the reserve is enabled as collateral in the user config bitset. + function _isUsingAsCollateral(uint256 userConfigData, uint256 reserveId) internal pure returns (bool) { + return ((userConfigData >> (reserveId * 2)) & 1) != 0; + } + + /// @notice Safely casts a `uint256` metric to `int256` for `SolvencyState`. + function _toInt256(uint256 value) internal pure returns (int256) { + if (value > uint256(type(int256).max)) { + return type(int256).max; + } + + // forge-lint: disable-next-line(unsafe-typecast) + return int256(value); + } +} /// @title AaveV3LikeOperationSafetyAssertionBase /// @author Phylax Systems /// @notice Shared assertion wrapper for Aave v3-like lending suites. -/// @dev The assertion holds the suite as an immutable reference rather than inheriting its bytecode. -/// Concrete bundles construct the protocol-specific suite in their own constructor and pass it -/// in. Keeping the suite in a separate contract preserves the single-`createData` deployment UX -/// while keeping the assertion runtime well below the EIP-170 size limit enforced by CI. abstract contract AaveV3LikeOperationSafetyAssertionBase is LendingBaseAssertion { - /// @notice Protocol-specific suite deployed alongside the assertion bundle. ILendingProtectionSuite internal immutable SUITE; constructor(ILendingProtectionSuite suite_) { diff --git a/src/protection/perpetual/IPerpetualProtectionSuite.sol b/src/protection/perpetual/IPerpetualProtectionSuite.sol new file mode 100644 index 0000000..108f579 --- /dev/null +++ b/src/protection/perpetual/IPerpetualProtectionSuite.sol @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {PhEvm} from "../../PhEvm.sol"; + +/// @title IPerpetualProtectionSuite +/// @author Phylax Systems +/// @notice Step-oriented interface for protocol-specific perpetual protection adapters. +/// @dev Implementations expose the protocol-specific plumbing needed to assert a shared family of +/// perpetual-protocol invariants: +/// - any non-liquidation user mutation must not create self-bad-debt and must leave the +/// affected account healthy under the protocol's own mark-to-market risk rule +/// - taker execution must stay at or worse than the protocol's externally anchored mark +/// - open exposure must remain backed by explicit liquidity or liability accounting +/// - funding settlement must be derived from cumulative-state deltas rather than ad hoc values +/// - liquidation is the only path that may realize deficit, and it must be gated by pre-state +/// unhealthiness while routing any realized loss into an explicit absorber +/// - risk-critical transitions must stay anchored to an external oracle or equivalent mark +/// +/// Expected assertion flow: +/// 1. Read monitored selectors from `getMonitoredSelectors()`. +/// 2. Resolve the caller-aware `TriggeredCall`. +/// 3. Decode the triggered call with `decodeOperation(...)`. +/// 4. Read and enforce any suite-provided execution, liquidity, funding, liquidation, and +/// oracle-anchor checks for the successful call. +/// 5. Filter to non-liquidation risk-preserving operations with +/// `shouldCheckPostMutationRisk(...)`. +/// 6. Read the post-mutation snapshot with `getPostMutationSnapshot(...)`. +/// 7. Require `snapshot.risk.equity >= 0`, `snapshot.risk.hasBadDebt == false`, and +/// `snapshot.risk.isHealthy == true`. +interface IPerpetualProtectionSuite { + /// @notice Resolved information about the exact adopter call that triggered the assertion. + struct TriggeredCall { + /// @notice Function selector invoked on the adopter. + bytes4 selector; + /// @notice Immediate caller of the adopter frame. + address caller; + /// @notice Adopter target address that was called. + address target; + /// @notice Raw calldata for the adopter frame. + bytes input; + /// @notice Call identifier used to construct a PreCall snapshot. + uint256 callStart; + /// @notice Call identifier used to construct a PostCall snapshot. + uint256 callEnd; + } + + /// @notice The perpetual action being inspected for shared post-operation safety checks. + enum OperationKind { + Unknown, + IncreasePosition, + DecreasePosition, + DepositCollateral, + WithdrawCollateral, + AddLiquidity, + RemoveLiquidity, + SettleFunding, + RealizePnL, + Liquidation + } + + /// @notice Comparison rule for the protocol-defined post-mutation risk metric. + enum ComparisonKind { + Unknown, + Gte, + Gt + } + + /// @notice Protocol-decoded context for a monitored perpetual call. + struct OperationContext { + /// @notice The adopter selector that produced this operation context. + bytes4 selector; + /// @notice The high-level action kind. + OperationKind kind; + /// @notice The immediate caller of the adopter frame. + address caller; + /// @notice The primary account whose risk state should be checked after the operation. + address account; + /// @notice The market, product, or pair being mutated, if any. + address market; + /// @notice Primary collateral or settlement asset involved in the action, if any. + address collateralAsset; + /// @notice Secondary account involved in the action, if any. + address counterparty; + /// @notice Position direction when the operation is market-directional. + bool isLong; + /// @notice Absolute exposure delta requested or realized by the action. + uint256 sizeDelta; + /// @notice Signed collateral delta in protocol-defined units, when known. + int256 collateralDelta; + /// @notice User-specified price bound or execution hint, if any. + uint256 limitPrice; + /// @notice True when the action mutates open exposure. + bool mutatesExposure; + /// @notice True when the action can reduce the account's post-state safety margin. + bool reducesAccountSafety; + /// @notice True when the action is a liquidation or other exceptional bad-debt path. + bool isLiquidation; + /// @notice Extension point for protocol-specific metadata. + bytes metadata; + } + + /// @notice Protocol-normalized aggregate mark-to-market state for an account. + struct AccountState { + /// @notice The account whose state was read. + address account; + /// @notice Total collateral or margin value in protocol-defined accounting units. + uint256 collateralValue; + /// @notice Total open notional or equivalent exposure measure. + uint256 openNotional; + /// @notice Aggregate unrealized PnL at the protocol's mark price. + int256 unrealizedPnl; + /// @notice Aggregate unsettled or accrued funding at the protocol's mark state. + int256 accruedFunding; + /// @notice Whether the account currently has any open exposure. + bool hasOpenExposure; + /// @notice Extension point for protocol-specific aggregate data. + bytes metadata; + } + + /// @notice Protocol-normalized per-market position data for an account. + struct PositionState { + /// @notice The market, product, or pair represented by this entry. + address market; + /// @notice Collateral or settlement asset associated with the position. + address collateralAsset; + /// @notice Direction of the position when applicable. + bool isLong; + /// @notice Position size in protocol-defined units. + uint256 size; + /// @notice Position notional in protocol-defined units. + uint256 openNotional; + /// @notice Position-level collateral or margin allocation. + uint256 collateralValue; + /// @notice Position PnL at the protocol's mark price. + int256 pnl; + /// @notice Position-level accrued funding at the snapshot. + int256 accruedFunding; + /// @notice Position mark price used by the protocol's risk engine. + uint256 markPrice; + /// @notice Position maintenance requirement in protocol-defined units. + uint256 maintenanceRequirement; + /// @notice Extension point for protocol-specific per-position metadata. + bytes metadata; + } + + /// @notice Protocol-defined post-operation risk output for an account at a snapshot fork. + struct RiskState { + /// @notice Whether the account is healthy under the protocol's own post-state rules. + bool isHealthy; + /// @notice Whether the account has entered a self-bad-debt state. + bool hasBadDebt; + /// @notice Whether the protocol would consider the account liquidatable at this snapshot. + bool isLiquidatable; + /// @notice Identifier for the protocol's primary risk metric, e.g. "MARGIN_RATIO". + bytes32 metricName; + /// @notice Mark-to-market account equity after collateral, PnL, and funding. + int256 equity; + /// @notice Protocol-normalized post-state safety metric or equivalent. + int256 metricValue; + /// @notice Threshold compared against `metricValue`. + int256 thresholdValue; + /// @notice Comparison rule used to interpret `metricValue` vs `thresholdValue`. + ComparisonKind comparison; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice Full post-operation snapshot for a monitored account. + struct AccountSnapshot { + /// @notice Aggregate state for the monitored account. + AccountState state; + /// @notice Per-market positions. Implementations may return an empty array on the hot path. + PositionState[] positions; + /// @notice Protocol-defined post-operation risk decision. + RiskState risk; + } + + /// @notice One concrete taker-price bound that must hold for a successful operation. + struct ExecutionPriceCheck { + /// @notice Identifier for the bound being asserted, e.g. "TAKER_WORSE_THAN_MARK". + bytes32 checkName; + /// @notice The account whose trade is being bounded. + address account; + /// @notice The market whose execution is being inspected. + address market; + /// @notice Actual execution price in protocol-defined price units. + uint256 executionPrice; + /// @notice Inclusive lower bound on the allowed execution price. + uint256 minExecutionPrice; + /// @notice Inclusive upper bound on the allowed execution price. + uint256 maxExecutionPrice; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice One concrete liquidity or liability coverage bound that must hold after a mutation. + struct LiquidityCoverageCheck { + /// @notice Identifier for the bound being asserted, e.g. "RESERVE_COVERAGE". + bytes32 checkName; + /// @notice The market whose liquidity bucket is being checked. + address market; + /// @notice The pool, vault, insurance fund, or liability bucket used as the source of coverage. + address accountingBucket; + /// @notice Required amount implied by the post-state exposure or liability. + uint256 requiredAmount; + /// @notice Available amount in the backing bucket. + uint256 availableAmount; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice One concrete funding-settlement check derived from cumulative funding state. + struct FundingDeltaCheck { + /// @notice Identifier for the bound being asserted, e.g. "FUNDING_SETTLEMENT". + bytes32 checkName; + /// @notice The account whose funding is being inspected. + address account; + /// @notice The market whose funding state is being inspected. + address market; + /// @notice Actual funding charged or credited by the successful operation. + int256 actualFunding; + /// @notice Inclusive lower bound implied by cumulative funding deltas. + int256 minExpectedFunding; + /// @notice Inclusive upper bound implied by cumulative funding deltas. + int256 maxExpectedFunding; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice One concrete liquidation-path check for exceptional bad-debt handling. + struct LiquidationCheck { + /// @notice Identifier for the bound being asserted, e.g. "ONLY_UNHEALTHY_LIQUIDATABLE". + bytes32 checkName; + /// @notice The account being liquidated. + address account; + /// @notice The market whose liquidation path is being inspected. + address market; + /// @notice Whether the account was unsafe before the liquidation executed. + bool wasLiquidatableBefore; + /// @notice Positive realized deficit that the liquidation created, if any. + int256 lossCreated; + /// @notice Amount explicitly absorbed by the loss-bearing account or bucket. + uint256 absorbedLoss; + /// @notice Loss-bearing account or bucket, if any. + address absorber; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice One concrete accounting-conservation bound for a non-liquidation settlement path. + /// @dev Catches exploit families where an operation creates unjustified economic gain through + /// stale LP share math, double-counted PnL, or accounting drift — scenarios that pass a + /// solvency-only check because the account never goes negative. + struct AccountingConservationCheck { + /// @notice Identifier for the bound being asserted, e.g. "EQUITY_CONSERVATION". + bytes32 checkName; + /// @notice The account whose accounting is being inspected. + address account; + /// @notice The market whose accounting path is being inspected. + address market; + /// @notice Actual economic delta observed across the operation (e.g. post-equity minus pre-equity). + int256 actualDelta; + /// @notice Inclusive lower bound on the allowed economic delta. + int256 minAllowedDelta; + /// @notice Inclusive upper bound on the allowed economic delta. + int256 maxAllowedDelta; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice One concrete oracle-anchor bound for a risk-critical transition. + struct OracleAnchorCheck { + /// @notice Identifier for the bound being asserted, e.g. "RISK_MARK_ANCHORED". + bytes32 checkName; + /// @notice The market whose oracle anchoring is being inspected. + address market; + /// @notice Actual price used by the protocol for the checked path. + uint256 usedPrice; + /// @notice Inclusive lower bound implied by the external oracle or mark source. + uint256 minOraclePrice; + /// @notice Inclusive upper bound implied by the external oracle or mark source. + uint256 maxOraclePrice; + /// @notice Extension point for protocol-specific evidence or decoded fields. + bytes metadata; + } + + /// @notice Returns the adopter selectors that can participate in the shared perpetual invariants. + /// @return selectors Selectors that should trigger the generic perpetual operation-safety assertion. + function getMonitoredSelectors() external view returns (bytes4[] memory selectors); + + /// @notice Decodes the triggered adopter call into a protocol-normalized operation context. + /// @param triggered The exact adopter frame that caused the assertion to run. + /// @return operation Protocol-normalized context used by downstream filtering and checks. + function decodeOperation(TriggeredCall calldata triggered) external view returns (OperationContext memory operation); + + /// @notice Returns whether the decoded action must preserve post-mutation health. + /// @dev This should normally be true for non-liquidation user actions that can reduce effective + /// account safety, such as increasing leverage, withdrawing collateral, realizing losses, + /// or removing LP capital against an LP leverage bound. + /// @param operation The decoded operation context returned by `decodeOperation(...)`. + /// @return shouldCheck True when the assertion should read state and enforce the post-state risk rule. + function shouldCheckPostMutationRisk(OperationContext calldata operation) external view returns (bool shouldCheck); + + /// @notice Returns suite-provided execution-price bounds for the decoded operation. + function getExecutionPriceChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view returns (ExecutionPriceCheck[] memory checks); + + /// @notice Returns suite-provided liquidity or liability coverage bounds for the decoded operation. + function getLiquidityCoverageChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view returns (LiquidityCoverageCheck[] memory checks); + + /// @notice Returns suite-provided cumulative-funding settlement bounds for the decoded operation. + function getFundingDeltaChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view returns (FundingDeltaCheck[] memory checks); + + /// @notice Returns suite-provided liquidation-path bounds for the decoded operation. + function getLiquidationChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view returns (LiquidationCheck[] memory checks); + + /// @notice Returns suite-provided oracle-anchor bounds for the decoded operation. + function getOracleAnchorChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view returns (OracleAnchorCheck[] memory checks); + + /// @notice Returns suite-provided accounting-conservation bounds for the decoded operation. + /// @dev These checks catch exploit families where a non-liquidation settlement or + /// liquidity-removal path creates unjustified economic gain through LP accounting + /// drift, stale share math, or double-counted PnL. + function getAccountingConservationChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view returns (AccountingConservationCheck[] memory checks); + + /// @notice Reads the operation-aware snapshot used by the post-mutation risk assertion. + /// @dev Implementations may return the same result as `getAccountSnapshot(...)` when their + /// post-state rule depends only on the account and fork. Protocols with action-specific + /// postconditions may override this to select the appropriate metric for the decoded + /// operation. + function getPostMutationSnapshot( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata fork + ) external view returns (AccountSnapshot memory snapshot); + + /// @notice Reads the account snapshot used by the post-mutation risk assertion. + /// @param account The account whose post-operation risk state is being checked. + /// @param fork The post-call snapshot fork that should be queried. + /// @return snapshot Aggregate state, optional per-market positions, and the final risk result. + function getAccountSnapshot(address account, PhEvm.ForkId calldata fork) + external + view + returns (AccountSnapshot memory snapshot); + + /// @notice Reads protocol-normalized aggregate account state at a given snapshot fork. + function getAccountState(address account, PhEvm.ForkId calldata fork) + external + view + returns (AccountState memory state); + + /// @notice Reads protocol-normalized per-market positions for an account at a snapshot fork. + function getAccountPositions(address account, PhEvm.ForkId calldata fork) + external + view + returns (PositionState[] memory positions); + + /// @notice Evaluates the protocol's post-state risk rule from the decoded account snapshot. + function evaluateRisk(AccountState calldata state, PositionState[] calldata positions, PhEvm.ForkId calldata fork) + external + view + returns (RiskState memory risk); +} diff --git a/src/protection/perpetual/PerpetualBaseAssertion.sol b/src/protection/perpetual/PerpetualBaseAssertion.sol new file mode 100644 index 0000000..61da95d --- /dev/null +++ b/src/protection/perpetual/PerpetualBaseAssertion.sol @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Assertion} from "../../Assertion.sol"; +import {PhEvm} from "../../PhEvm.sol"; +import {ForkUtils} from "../../utils/ForkUtils.sol"; +import {IPerpetualProtectionSuite} from "./IPerpetualProtectionSuite.sol"; + +/// @title PerpetualProtectionSuiteBase +/// @author Phylax Systems +/// @notice Shared default implementations for perpetual protection suites. +abstract contract PerpetualProtectionSuiteBase is ForkUtils, IPerpetualProtectionSuite { + /// @notice Default execution-price implementation for suites with no shared execution check. + function getExecutionPriceChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view virtual override returns (ExecutionPriceCheck[] memory checks) { + triggered; + operation; + beforeFork; + afterFork; + checks = new ExecutionPriceCheck[](0); + } + + /// @notice Default liquidity-coverage implementation for suites with no shared coverage check. + function getLiquidityCoverageChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view virtual override returns (LiquidityCoverageCheck[] memory checks) { + triggered; + operation; + beforeFork; + afterFork; + checks = new LiquidityCoverageCheck[](0); + } + + /// @notice Default funding-delta implementation for suites with no shared funding check. + function getFundingDeltaChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view virtual override returns (FundingDeltaCheck[] memory checks) { + triggered; + operation; + beforeFork; + afterFork; + checks = new FundingDeltaCheck[](0); + } + + /// @notice Default liquidation implementation for suites with no shared liquidation checks. + function getLiquidationChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view virtual override returns (LiquidationCheck[] memory checks) { + triggered; + operation; + beforeFork; + afterFork; + checks = new LiquidationCheck[](0); + } + + /// @notice Default oracle-anchor implementation for suites with no shared oracle check. + function getOracleAnchorChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view virtual override returns (OracleAnchorCheck[] memory checks) { + triggered; + operation; + beforeFork; + afterFork; + checks = new OracleAnchorCheck[](0); + } + + /// @notice Default accounting-conservation implementation for suites with no shared accounting check. + function getAccountingConservationChecks( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata beforeFork, + PhEvm.ForkId calldata afterFork + ) external view virtual override returns (AccountingConservationCheck[] memory checks) { + triggered; + operation; + beforeFork; + afterFork; + checks = new AccountingConservationCheck[](0); + } + + /// @notice Default post-mutation snapshot implementation for suites with account-only risk reads. + function getPostMutationSnapshot( + TriggeredCall calldata triggered, + OperationContext calldata operation, + PhEvm.ForkId calldata fork + ) external view virtual override returns (AccountSnapshot memory snapshot) { + triggered; + snapshot = this.getAccountSnapshot(operation.account, fork); + } + + /// @notice Composes a full account snapshot from the step-oriented suite functions. + function getAccountSnapshot(address account, PhEvm.ForkId calldata fork) + external + view + virtual + override + returns (AccountSnapshot memory snapshot) + { + snapshot.state = this.getAccountState(account, fork); + snapshot.positions = this.getAccountPositions(account, fork); + snapshot.risk = this.evaluateRisk(snapshot.state, snapshot.positions, fork); + } + + /// @notice Returns the suite-specific revert string for failed fork-time static calls. + function _viewFailureMessage() internal pure virtual override returns (string memory) { + return "perpetual suite staticcall failed"; + } +} + +/// @title PerpetualBaseAssertion +/// @author Phylax Systems +/// @notice Generic operation-safety assertion for perpetual protocols. +/// @dev Inherit this together with a concrete `IPerpetualProtectionSuite` implementation. The base +/// contract handles one decode pass per triggered call, then enforces any suite-provided +/// execution, oracle, funding, liquidity, and liquidation checks before applying the shared +/// post-mutation risk gate for non-liquidation operations. +abstract contract PerpetualBaseAssertion is Assertion { + error PerpetualTriggeredCallNotFound(bytes4 selector, uint256 callStart); + error PerpetualOperationAccountMissing(bytes4 selector); + error PerpetualExecutionPriceViolated( + address account, + bytes4 selector, + IPerpetualProtectionSuite.OperationKind kind, + bytes32 checkName, + address market, + uint256 executionPrice, + uint256 minExecutionPrice, + uint256 maxExecutionPrice + ); + error PerpetualLiquidityCoverageViolated( + bytes4 selector, + IPerpetualProtectionSuite.OperationKind kind, + bytes32 checkName, + address market, + uint256 requiredAmount, + uint256 availableAmount + ); + error PerpetualFundingDeltaViolated( + address account, + bytes4 selector, + IPerpetualProtectionSuite.OperationKind kind, + bytes32 checkName, + address market, + int256 actualFunding, + int256 minExpectedFunding, + int256 maxExpectedFunding + ); + error PerpetualLiquidationViolated( + address account, + bytes4 selector, + bytes32 checkName, + address market, + bool wasLiquidatableBefore, + int256 lossCreated, + uint256 absorbedLoss + ); + error PerpetualOracleAnchorViolated( + bytes4 selector, + IPerpetualProtectionSuite.OperationKind kind, + bytes32 checkName, + address market, + uint256 usedPrice, + uint256 minOraclePrice, + uint256 maxOraclePrice + ); + error PerpetualAccountingConservationViolated( + address account, + bytes4 selector, + IPerpetualProtectionSuite.OperationKind kind, + bytes32 checkName, + address market, + int256 actualDelta, + int256 minAllowedDelta, + int256 maxAllowedDelta + ); + error PerpetualSelfBadDebtCreated( + address account, bytes4 selector, IPerpetualProtectionSuite.OperationKind kind, int256 equity + ); + error PerpetualPostMutationRiskViolated( + address account, + bytes4 selector, + IPerpetualProtectionSuite.OperationKind kind, + bytes32 metricName, + int256 metricValue, + int256 thresholdValue + ); + + /// @notice Returns the protocol-specific perpetual suite that powers this assertion. + function _suite() internal view virtual returns (IPerpetualProtectionSuite); + + /// @notice Registers one generic perpetual operation-safety check for every monitored selector. + function triggers() external view virtual override { + bytes4[] memory selectors = _suite().getMonitoredSelectors(); + for (uint256 i; i < selectors.length; ++i) { + registerFnCallTrigger(this.assertOperationSafety.selector, selectors[i]); + } + } + + /// @notice Enforces the shared perpetual operation-safety invariants for a successful call. + function assertOperationSafety() external view { + _assertOperationSafety(); + } + + /// @notice Backwards-compatible alias for integrations that only reference the risk-gate name. + function assertPostMutationRisk() external view { + _assertOperationSafety(); + } + + /// @notice Internal implementation shared by the public perpetual assertion entrypoints. + function _assertOperationSafety() internal view { + IPerpetualProtectionSuite suite = _suite(); + IPerpetualProtectionSuite.TriggeredCall memory triggered = _resolveTriggeredCall(); + IPerpetualProtectionSuite.OperationContext memory operation = suite.decodeOperation(triggered); + PhEvm.ForkId memory beforeFork = _preCall(triggered.callStart); + PhEvm.ForkId memory afterFork = _postCall(triggered.callEnd); + + _assertExecutionPriceChecks(suite, triggered, operation, beforeFork, afterFork); + _assertLiquidityCoverageChecks(suite, triggered, operation, beforeFork, afterFork); + _assertFundingDeltaChecks(suite, triggered, operation, beforeFork, afterFork); + _assertLiquidationChecks(suite, triggered, operation, beforeFork, afterFork); + _assertOracleAnchorChecks(suite, triggered, operation, beforeFork, afterFork); + _assertAccountingConservationChecks(suite, triggered, operation, beforeFork, afterFork); + _assertPostMutationRisk(suite, triggered, operation, afterFork); + } + + /// @notice Enforces suite-provided taker execution bounds for the triggered operation. + function _assertExecutionPriceChecks( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + IPerpetualProtectionSuite.ExecutionPriceCheck[] memory checks = + suite.getExecutionPriceChecks(triggered, operation, beforeFork, afterFork); + + for (uint256 i; i < checks.length; ++i) { + if (!_isWithinUintRange(checks[i].executionPrice, checks[i].minExecutionPrice, checks[i].maxExecutionPrice)) + { + revert PerpetualExecutionPriceViolated( + checks[i].account == address(0) ? operation.account : checks[i].account, + operation.selector, + operation.kind, + checks[i].checkName, + checks[i].market, + checks[i].executionPrice, + checks[i].minExecutionPrice, + checks[i].maxExecutionPrice + ); + } + } + } + + /// @notice Enforces suite-provided liquidity and liability coverage bounds. + function _assertLiquidityCoverageChecks( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + IPerpetualProtectionSuite.LiquidityCoverageCheck[] memory checks = + suite.getLiquidityCoverageChecks(triggered, operation, beforeFork, afterFork); + + for (uint256 i; i < checks.length; ++i) { + if (checks[i].requiredAmount > checks[i].availableAmount) { + revert PerpetualLiquidityCoverageViolated( + operation.selector, + operation.kind, + checks[i].checkName, + checks[i].market, + checks[i].requiredAmount, + checks[i].availableAmount + ); + } + } + } + + /// @notice Enforces suite-provided cumulative-funding settlement bounds. + function _assertFundingDeltaChecks( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + IPerpetualProtectionSuite.FundingDeltaCheck[] memory checks = + suite.getFundingDeltaChecks(triggered, operation, beforeFork, afterFork); + + for (uint256 i; i < checks.length; ++i) { + if (!_isWithinIntRange(checks[i].actualFunding, checks[i].minExpectedFunding, checks[i].maxExpectedFunding)) + { + revert PerpetualFundingDeltaViolated( + checks[i].account == address(0) ? operation.account : checks[i].account, + operation.selector, + operation.kind, + checks[i].checkName, + checks[i].market, + checks[i].actualFunding, + checks[i].minExpectedFunding, + checks[i].maxExpectedFunding + ); + } + } + } + + /// @notice Enforces suite-provided liquidation gating and loss-accounting bounds. + function _assertLiquidationChecks( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + IPerpetualProtectionSuite.LiquidationCheck[] memory checks = + suite.getLiquidationChecks(triggered, operation, beforeFork, afterFork); + + for (uint256 i; i < checks.length; ++i) { + uint256 requiredAbsorption = _positivePart(checks[i].lossCreated); + if ( + !checks[i].wasLiquidatableBefore || requiredAbsorption > checks[i].absorbedLoss + || (requiredAbsorption != 0 && checks[i].absorber == address(0)) + ) { + revert PerpetualLiquidationViolated( + checks[i].account == address(0) ? operation.account : checks[i].account, + operation.selector, + checks[i].checkName, + checks[i].market, + checks[i].wasLiquidatableBefore, + checks[i].lossCreated, + checks[i].absorbedLoss + ); + } + } + } + + /// @notice Enforces suite-provided oracle-anchor bounds for risk-critical transitions. + function _assertOracleAnchorChecks( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + IPerpetualProtectionSuite.OracleAnchorCheck[] memory checks = + suite.getOracleAnchorChecks(triggered, operation, beforeFork, afterFork); + + for (uint256 i; i < checks.length; ++i) { + if (!_isWithinUintRange(checks[i].usedPrice, checks[i].minOraclePrice, checks[i].maxOraclePrice)) { + revert PerpetualOracleAnchorViolated( + operation.selector, + operation.kind, + checks[i].checkName, + checks[i].market, + checks[i].usedPrice, + checks[i].minOraclePrice, + checks[i].maxOraclePrice + ); + } + } + } + + /// @notice Enforces suite-provided accounting-conservation bounds for settlement paths. + /// @dev Solvency-only checks are insufficient for exploit families where stale LP share math, + /// double-counted PnL, or accounting drift create unjustified economic gain while the + /// account remains non-negative. These checks compare the actual economic delta of an + /// operation against protocol-derived bounds. + function _assertAccountingConservationChecks( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory beforeFork, + PhEvm.ForkId memory afterFork + ) internal view { + IPerpetualProtectionSuite.AccountingConservationCheck[] memory checks = + suite.getAccountingConservationChecks(triggered, operation, beforeFork, afterFork); + + for (uint256 i; i < checks.length; ++i) { + if (!_isWithinIntRange(checks[i].actualDelta, checks[i].minAllowedDelta, checks[i].maxAllowedDelta)) { + revert PerpetualAccountingConservationViolated( + checks[i].account == address(0) ? operation.account : checks[i].account, + operation.selector, + operation.kind, + checks[i].checkName, + checks[i].market, + checks[i].actualDelta, + checks[i].minAllowedDelta, + checks[i].maxAllowedDelta + ); + } + } + } + + /// @notice Enforces the shared post-mutation risk gate for non-liquidation operations. + function _assertPostMutationRisk( + IPerpetualProtectionSuite suite, + IPerpetualProtectionSuite.TriggeredCall memory triggered, + IPerpetualProtectionSuite.OperationContext memory operation, + PhEvm.ForkId memory afterFork + ) internal view { + if (!suite.shouldCheckPostMutationRisk(operation)) { + return; + } + + if (operation.account == address(0)) { + revert PerpetualOperationAccountMissing(triggered.selector); + } + + IPerpetualProtectionSuite.AccountSnapshot memory snapshot = + suite.getPostMutationSnapshot(triggered, operation, afterFork); + + if (snapshot.risk.hasBadDebt || snapshot.risk.equity < 0) { + revert PerpetualSelfBadDebtCreated( + operation.account, operation.selector, operation.kind, snapshot.risk.equity + ); + } + + if (!snapshot.risk.isHealthy) { + revert PerpetualPostMutationRiskViolated( + operation.account, + operation.selector, + operation.kind, + snapshot.risk.metricName, + snapshot.risk.metricValue, + snapshot.risk.thresholdValue + ); + } + } + + /// @notice Resolves the exact adopter frame that caused the current assertion execution. + function _resolveTriggeredCall() internal view returns (IPerpetualProtectionSuite.TriggeredCall memory triggered) { + address adopter = ph.getAssertionAdopter(); + PhEvm.TriggerContext memory context = ph.context(); + PhEvm.CallInputs[] memory calls = ph.getAllCallInputs(adopter, context.selector); + + for (uint256 i; i < calls.length; ++i) { + if (calls[i].id == context.callStart) { + return IPerpetualProtectionSuite.TriggeredCall({ + selector: context.selector, + caller: calls[i].caller, + target: calls[i].target_address, + input: calls[i].input, + callStart: context.callStart, + callEnd: context.callEnd + }); + } + } + + revert PerpetualTriggeredCallNotFound(context.selector, context.callStart); + } + + /// @notice Returns whether `value` lies within the inclusive `[minimum, maximum]` range. + function _isWithinUintRange(uint256 value, uint256 minimum, uint256 maximum) internal pure returns (bool) { + return value >= minimum && value <= maximum; + } + + /// @notice Returns whether `value` lies within the inclusive `[minimum, maximum]` range. + function _isWithinIntRange(int256 value, int256 minimum, int256 maximum) internal pure returns (bool) { + return value >= minimum && value <= maximum; + } + + /// @notice Returns the non-negative component of a signed deficit. + function _positivePart(int256 value) internal pure returns (uint256) { + if (value <= 0) { + return 0; + } + + // forge-lint: disable-next-line(unsafe-typecast) + return uint256(value); + } +}