diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9ecbda --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# Sovryn Perimeter Fee — deployment inputs for scripts 01–04. +# +# cp .env.example .env && $EDITOR .env && source .env +# +# `.env` is gitignored; this template is not. It holds no secrets: the signing +# key stays in the Foundry keystore and is selected with `--account `. +# +# Every variable below is REQUIRED. The scripts read them with vm.envAddress / +# vm.envUint / vm.envBool, which revert on an absent variable — there are no +# silent defaults, so a missing input stops the deploy instead of guessing. + +# --- RPC ------------------------------------------------------------------- +# foundry.toml exposes rsk_mainnet as "${RSK_MAINNET_RPC}"; the hardhat repos +# configure the same endpoint for rskSovrynMainnet. Pass it explicitly on every +# command (--rpc-url $RSK_RPC) rather than relying on a tool default. +export RSK_RPC=https://mainnet-dev.sovryn.app/rpc +export RSK_MAINNET_RPC=$RSK_RPC +# If a run stalls: https://mainnet.sovryn.app is the proven fallback. A stall is +# not a failed deploy — check the chain before doing anything, and never re-run +# a deploy script blind. + +# Broadcasting waits longer than the default; an RPC hiccup otherwise looks like +# a dropped transaction when the transaction actually landed. +export ETH_TIMEOUT=300 + +# --- Ownership and administration ------------------------------------------ +# All four are the Exchequer Multisig at launch. The vault and controller are +# deployed owned by the deployer, then handed over via Ownable2Step: the +# bootstrap scripts queue the transfer and the multisig accepts it separately. +export EXIT_FEE_VAULT_ADMIN=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 +export EXIT_FEE_VAULT_RECIPIENT=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 +export EXIT_FEE_CONTROLLER_ADMIN=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 +export EXIT_FEE_OPERATIONAL_ADMIN=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 + +# --- Launch rates, in basis points ----------------------------------------- +# 10 bps = 0.10% on each charging surface. The AMM surface is written inactive +# by the bootstrap and takes no rate: nothing consumes it in this release. +export PERIMETER_LENDING_LENDER_BPS=10 +export PERIMETER_LENDING_BORROWER_BPS=10 +export PERIMETER_ZERO_WITHDRAW_COLL_BPS=10 +export PERIMETER_ZERO_CLAIM_SURPLUS_BPS=10 + +# --- Charging switch -------------------------------------------------------- +# The release invariant: deploy disabled. Charging is enabled only after +# governance executes and every checkpoint is green. Setting this true at +# deploy time would charge users before the vote. +export PERIMETER_ENABLE_AT_DEPLOY=false + +# --- Filled in mid-deploy --------------------------------------------------- +# Not knowable up front: script 01 deploys the vault, and script 04 needs its +# proxy address to wire the controller's fee receiver. Export it after 01 and +# before 04 — script 04 reverts without it rather than bootstrapping a +# controller that could never pay a fee anywhere. +# export EXIT_FEE_VAULT_PROXY=0x... diff --git a/README.md b/README.md index ae7c068..1716813 100644 --- a/README.md +++ b/README.md @@ -138,18 +138,18 @@ export EXIT_FEE_VAULT_PROXY= # Rates for the four surfaces that ship ON. All REQUIRED — a missing one reverts # the script rather than shipping a rate nobody chose. 0 does NOT mean "skip": # the surface is still written, active and free. -export COLFEE_LENDING_LENDER_BPS= -export COLFEE_LENDING_BORROWER_BPS= -export COLFEE_ZERO_WITHDRAW_COLL_BPS= -export COLFEE_ZERO_CLAIM_SURPLUS_BPS= -# SURFACE_AMM_REMOVE_LIQUIDITY has no consumer in this release and takes no env +export PERIMETER_LENDING_LENDER_BPS= +export PERIMETER_LENDING_BORROWER_BPS= +export PERIMETER_ZERO_WITHDRAW_COLL_BPS= +export PERIMETER_ZERO_CLAIM_SURPLUS_BPS= +# PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY has no consumer in this release and takes no env # var: the script writes it as (active=false, 0). Turning it on later is a single # setSurfacePolicy call from the owner. # MAINNET: keep this false. Enabling at deploy would turn the # system on while the deployer EOA still owns the proxies — enable via the governance # Safe only after the ownership handoff and the release gates in SIP-0094. # =true is for local/test chains only. -export COLFEE_ENABLE_AT_DEPLOY=false +export PERIMETER_ENABLE_AT_DEPLOY=false forge script script/04_BootstrapController.s.sol \ --rpc-url $RSK_RPC --broadcast --account deployer \ --sig "run(uint256)" @@ -259,17 +259,17 @@ The upgrade-safety check deliberately does **not** verify candidate-bytecode-vs- ## Home-repo integration -Perimeter Fee is consumed by three product repos. Each one copies the `IExitFeeController` interface file into its own tree on a `private/colfee` branch — **no git submodule** (the file-copy approach avoids submodule-pointer churn during private-branch development and audit): +Perimeter Fee is consumed by three product repos. Each one copies the `IExitFeeController` interface file into its own tree on a `private/perimeter` branch — **no git submodule** (the file-copy approach avoids submodule-pointer churn during private-branch development and audit): | Home repo | Pragma | Interface copy | Hook location | | ------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `Sovryn-smart-contracts` | 0.5.17 | `contracts/external/colfee/IExitFeeController.sol` ← copy of `src/interfaces/IExitFeeController.sol` | lending: `LoanTokenLogicShared` · loan/margin: `ModuleCommonFunctionalities` + `LoanClosingsShared` | +| `Sovryn-smart-contracts` | 0.5.17 | `contracts/external/perimeter/IExitFeeController.sol` ← copy of `src/interfaces/IExitFeeController.sol` | lending: `LoanTokenLogicShared` · loan/margin: `ModuleCommonFunctionalities` + `LoanClosingsShared` | | `zero-contracts` | 0.6.11 | same path ← copy of `src/interfaces/IExitFeeController.sol` | `BorrowerOperations` | | `oracle-based-amm` _(deferred to Phase 6)_ | 0.4.26 | same path ← copy of `src/interfaces/v0_4/IExitFeeController.sol` | `ConverterBase` | The 0.5+/0.6+/0.8 range pragma on the unified interface means Sovryn-smart-contracts and zero-contracts copy the same file; only AMM needs the structurally-different `v0_4/` outlier. -When the interface changes here, each home repo re-copies its respective file (with a provenance header pinning the colfee SHA) and runs `tools/check-abi-equivalence.sh` against the colfee source to confirm the v0_4 outlier still matches. +When the interface changes here, each home repo re-copies its respective file (with a provenance header pinning the perimeter SHA) and runs `tools/check-abi-equivalence.sh` against the perimeter source to confirm the v0_4 outlier still matches. --- @@ -288,7 +288,7 @@ When the interface changes here, each home repo re-copies its respective file (w **Phase 1 complete**: shared Perimeter Fee contracts (controller + vault) + interfaces + deploy/upgrade tooling. 99/99 tests passing (95 unit + 4 invariant). ABI-equivalence guard green across four compilers. The local/EOA deploy → finalize → upgrade-safety flow is smoke-tested; production Safe execution requires the Safe-aware artifact-refresh step noted above. -**Next**: Phase 2 (lending hooks in `Sovryn-smart-contracts-colfee`), Phase 3 (loan/margin hooks in same repo), Phase 4 (Zero hooks in `zero-contracts-colfee`). Phase 6 (AMM) deferred until proof gates pass. +**Next**: Phase 2 (lending hooks in `Sovryn-smart-contracts-perimeter`), Phase 3 (loan/margin hooks in same repo), Phase 4 (Zero hooks in `zero-contracts-perimeter`). Phase 6 (AMM) deferred until proof gates pass. --- diff --git a/broadcast/01_DeployVault.s.sol/30/run-1787574212713.json b/broadcast/01_DeployVault.s.sol/30/run-1787574212713.json new file mode 100644 index 0000000..929c83b --- /dev/null +++ b/broadcast/01_DeployVault.s.sol/30/run-1787574212713.json @@ -0,0 +1,164 @@ +{ + "transactions": [ + { + "hash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionType": "CREATE", + "contractName": "ExitFeeVault", + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", + "function": null, + "arguments": null, + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x1c1077", + "value": "0x0", + "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b608051611829610118600039600081816103350152818161037e0152818161041d0152818161045d015261061a01526118296000f3fe6080604052600436106101025760003560e01c8063715018a611610095578063c4d66de811610064578063c4d66de81461028c578063e30c3978146102ac578063e6d11999146102ca578063f2fde38b146102ea578063f851a4401461030a57600080fd5b8063715018a61461022457806379ba5097146102395780637b1307cd1461024e5780638da5cb5b1461026e57600080fd5b8063503690d1116100d1578063503690d1146101a157806352d1902d146101c1578063557f1473146101e4578063704b6c021461020457600080fd5b80630b5e8df91461010e5780633659cfe61461014c5780634f1ef2861461016e5780634f7a6c3c1461018157600080fd5b3661010957005b600080fd5b34801561011a57600080fd5b5061012d5461012f906001600160a01b031681565b6040516001600160a01b0390911681526020015b60405180910390f35b34801561015857600080fd5b5061016c61016736600461149e565b61032b565b005b61016c61017c3660046114d1565b610413565b34801561018d57600080fd5b5061016c61019c36600461149e565b6104e3565b3480156101ad57600080fd5b5061016c6101bc366004611595565b6105a4565b3480156101cd57600080fd5b506101d661060d565b604051908152602001610143565b3480156101f057600080fd5b5061016c6101ff3660046115d6565b6106c0565b34801561021057600080fd5b5061016c61021f36600461149e565b61074f565b34801561023057600080fd5b5061016c6107c9565b34801561024557600080fd5b5061016c6107e2565b34801561025a57600080fd5b5061016c6102693660046115ef565b610859565b34801561027a57600080fd5b506097546001600160a01b031661012f565b34801561029857600080fd5b5061016c6102a736600461149e565b6108bc565b3480156102b857600080fd5b5060c9546001600160a01b031661012f565b3480156102d657600080fd5b5061016c6102e53660046115ef565b610a14565b3480156102f657600080fd5b5061016c61030536600461149e565b610aa4565b34801561031657600080fd5b5061012e5461012f906001600160a01b031681565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361037c5760405162461bcd60e51b81526004016103739061161b565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166103c56000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146103eb5760405162461bcd60e51b815260040161037390611667565b6103f481610b15565b6040805160008082526020820190925261041091839190610b44565b50565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361045b5760405162461bcd60e51b81526004016103739061161b565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166104a46000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146104ca5760405162461bcd60e51b815260040161037390611667565b6104d382610b15565b6104df82826001610b44565b5050565b61012e546001600160a01b0316331480159061050a57506097546001600160a01b03163314155b1561052a5760405163fade6b7760e01b8152336004820152602401610373565b6001600160a01b03811661055157604051630acd20ff60e21b815260040160405180910390fd5b61012d80546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf90600090a35050565b6105ac610caf565b61012e546001600160a01b031633148015906105d357506097546001600160a01b03163314155b156105f35760405163fade6b7760e01b8152336004820152602401610373565b6105fe838383610d08565b610608600160fb55565b505050565b6000306001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016146106ad5760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c00000000000000006064820152608401610373565b506000805160206117ad83398151915290565b6106c8610caf565b61012e546001600160a01b031633148015906106ef57506097546001600160a01b03163314155b1561070f5760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b03168061073a5760405163d3e94d1160e01b815260040160405180910390fd5b6107448183610d9c565b50610410600160fb55565b610757610e7f565b6001600160a01b03811661077e5760405163b325f76760e01b815260040160405180910390fd5b61012e80546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040516317d5c96560e11b815260040160405180910390fd5b60c95433906001600160a01b031681146108505760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b6064820152608401610373565b61041081610edb565b610861610caf565b61012e546001600160a01b0316331480159061088857506097546001600160a01b03163314155b156108a85760405163fade6b7760e01b8152336004820152602401610373565b6108b28282610d9c565b6104df600160fb55565b600054610100900460ff16158080156108dc5750600054600160ff909116105b806108f65750303b1580156108f6575060005460ff166001145b6109595760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b6064820152608401610373565b6000805460ff19166001179055801561097c576000805461ff0019166101001790555b610984610ef4565b61098c610ef4565b610994610f23565b61099c610f52565b6001600160a01b038216158015906109bd57506001600160a01b0382163314155b156109cb576109cb82610edb565b80156104df576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610a1c610caf565b61012e546001600160a01b03163314801590610a4357506097546001600160a01b03163314155b15610a635760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b031680610a8e5760405163d3e94d1160e01b815260040160405180910390fd5b610a99838284610d08565b506104df600160fb55565b610aac610e7f565b60c980546001600160a01b0383166001600160a01b03199091168117909155610add6097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b610b1d610e7f565b6001600160a01b0381166104105760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff1615610b775761060883610f79565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa925050508015610bd1575060408051601f3d908101601f19168201909252610bce918101906116b3565b60015b610c345760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b6064820152608401610373565b6000805160206117ad8339815191528114610ca35760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b6064820152608401610373565b50610608838383611015565b600260fb5403610d015760405162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152606401610373565b600260fb55565b6001600160a01b038216610d2f576040516351eb292760e11b815260040160405180910390fd5b610d436001600160a01b0384168383611040565b816001600160a01b0316836001600160a01b03167f7b09c29f9106defeccc9ac3b823f3aad0b470d120e5df7aed033b5c43a4bf71883604051610d8891815260200190565b60405180910390a3505050565b600160fb55565b6001600160a01b038216610dc3576040516351eb292760e11b815260040160405180910390fd5b6000826001600160a01b03168260405160006040518083038185875af1925050503d8060008114610e10576040519150601f19603f3d011682016040523d82523d6000602084013e610e15565b606091505b5050905080610e3757604051632072c05760e11b815260040160405180910390fd5b826001600160a01b03167fcfdf0683c37c9963bcb0bf5b183df58de6f1eaa52c40da8b1a85df1e7c6397eb83604051610e7291815260200190565b60405180910390a2505050565b6097546001600160a01b03163314610ed95760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e65726044820152606401610373565b565b60c980546001600160a01b031916905561041081611092565b600054610100900460ff16610f1b5760405162461bcd60e51b8152600401610373906116cc565b610ed96110e4565b600054610100900460ff16610f4a5760405162461bcd60e51b8152600401610373906116cc565b610ed9611114565b600054610100900460ff16610ed95760405162461bcd60e51b8152600401610373906116cc565b6001600160a01b0381163b610fe65760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b6064820152608401610373565b6000805160206117ad83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61101e8361113b565b60008251118061102b5750805b156106085761103a838361117b565b50505050565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b1790526106089084906111a7565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff1661110b5760405162461bcd60e51b8152600401610373906116cc565b610ed933610edb565b600054610100900460ff16610d955760405162461bcd60e51b8152600401610373906116cc565b61114481610f79565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606111a083836040518060600160405280602781526020016117cd6027913961127c565b9392505050565b60006111fc826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166112f49092919063ffffffff16565b905080516000148061121d57508080602001905181019061121d9190611717565b6106085760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610373565b6060600080856001600160a01b031685604051611299919061175d565b600060405180830381855af49150503d80600081146112d4576040519150601f19603f3d011682016040523d82523d6000602084013e6112d9565b606091505b50915091506112ea8683838761130b565b9695505050505050565b60606113038484600085611384565b949350505050565b6060831561137a578251600003611373576001600160a01b0385163b6113735760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610373565b5081611303565b611303838361145f565b6060824710156113e55760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610373565b600080866001600160a01b03168587604051611401919061175d565b60006040518083038185875af1925050503d806000811461143e576040519150601f19603f3d011682016040523d82523d6000602084013e611443565b606091505b50915091506114548783838761130b565b979650505050505050565b81511561146f5781518083602001fd5b8060405162461bcd60e51b81526004016103739190611779565b6001600160a01b038116811461041057600080fd5b6000602082840312156114b057600080fd5b81356111a081611489565b634e487b7160e01b600052604160045260246000fd5b600080604083850312156114e457600080fd5b82356114ef81611489565b9150602083013567ffffffffffffffff8082111561150c57600080fd5b818501915085601f83011261152057600080fd5b813581811115611532576115326114bb565b604051601f8201601f19908116603f0116810190838211818310171561155a5761155a6114bb565b8160405282815288602084870101111561157357600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b6000806000606084860312156115aa57600080fd5b83356115b581611489565b925060208401356115c581611489565b929592945050506040919091013590565b6000602082840312156115e857600080fd5b5035919050565b6000806040838503121561160257600080fd5b823561160d81611489565b946020939093013593505050565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b6000602082840312156116c557600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b60006020828403121561172957600080fd5b815180151581146111a057600080fd5b60005b8381101561175457818101518382015260200161173c565b50506000910152565b6000825161176f818460208701611739565b9190910192915050565b6020815260008251806020840152611798816040850160208701611739565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212200d4b37ce3f8ab7b0176cd2b969ee847bd86382d9e32b0e2ebeb0d91d6f9aa53264736f6c63430008140033", + "nonce": "0x1e", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionType": "CREATE", + "contractName": "ERC1967Proxy", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": null, + "arguments": [ + "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4", + "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x43736", + "value": "0x0", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c65640000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x1f", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x159175", + "logs": [ + { + "address": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000002000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", + "gasUsed": "0x159175", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4" + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x459a9", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x0000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb4" + ], + "data": "0x", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + }, + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x3", + "removed": false + }, + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x4", + "removed": false + }, + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x5", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000002008002000001000000000000000000000000000000000000020000000000000080000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000080000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "gasUsed": "0x37aac", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b" + } + ], + "libraries": [], + "pending": [], + "returns": { + "proxy": { + "internal_type": "address", + "value": "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" + }, + "impl": { + "internal_type": "address", + "value": "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4" + } + }, + "timestamp": 1787574212713, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/01_DeployVault.s.sol/30/run-latest.json b/broadcast/01_DeployVault.s.sol/30/run-latest.json index 78871c1..929c83b 100644 --- a/broadcast/01_DeployVault.s.sol/30/run-latest.json +++ b/broadcast/01_DeployVault.s.sol/30/run-latest.json @@ -1,10 +1,10 @@ { "transactions": [ { - "hash": "0x1c022377d73c722793ec3a2f71c16c86354e7c584792b04760bb9785ee674ea7", + "hash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", "transactionType": "CREATE", "contractName": "ExitFeeVault", - "contractAddress": "0xab3761d0800c4310414e75fd8b545ce094c20e26", + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", "function": null, "arguments": null, "transaction": { @@ -12,28 +12,28 @@ "gas": "0x1c1077", "value": "0x0", "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b608051611829610118600039600081816103350152818161037e0152818161041d0152818161045d015261061a01526118296000f3fe6080604052600436106101025760003560e01c8063715018a611610095578063c4d66de811610064578063c4d66de81461028c578063e30c3978146102ac578063e6d11999146102ca578063f2fde38b146102ea578063f851a4401461030a57600080fd5b8063715018a61461022457806379ba5097146102395780637b1307cd1461024e5780638da5cb5b1461026e57600080fd5b8063503690d1116100d1578063503690d1146101a157806352d1902d146101c1578063557f1473146101e4578063704b6c021461020457600080fd5b80630b5e8df91461010e5780633659cfe61461014c5780634f1ef2861461016e5780634f7a6c3c1461018157600080fd5b3661010957005b600080fd5b34801561011a57600080fd5b5061012d5461012f906001600160a01b031681565b6040516001600160a01b0390911681526020015b60405180910390f35b34801561015857600080fd5b5061016c61016736600461149e565b61032b565b005b61016c61017c3660046114d1565b610413565b34801561018d57600080fd5b5061016c61019c36600461149e565b6104e3565b3480156101ad57600080fd5b5061016c6101bc366004611595565b6105a4565b3480156101cd57600080fd5b506101d661060d565b604051908152602001610143565b3480156101f057600080fd5b5061016c6101ff3660046115d6565b6106c0565b34801561021057600080fd5b5061016c61021f36600461149e565b61074f565b34801561023057600080fd5b5061016c6107c9565b34801561024557600080fd5b5061016c6107e2565b34801561025a57600080fd5b5061016c6102693660046115ef565b610859565b34801561027a57600080fd5b506097546001600160a01b031661012f565b34801561029857600080fd5b5061016c6102a736600461149e565b6108bc565b3480156102b857600080fd5b5060c9546001600160a01b031661012f565b3480156102d657600080fd5b5061016c6102e53660046115ef565b610a14565b3480156102f657600080fd5b5061016c61030536600461149e565b610aa4565b34801561031657600080fd5b5061012e5461012f906001600160a01b031681565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361037c5760405162461bcd60e51b81526004016103739061161b565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166103c56000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146103eb5760405162461bcd60e51b815260040161037390611667565b6103f481610b15565b6040805160008082526020820190925261041091839190610b44565b50565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361045b5760405162461bcd60e51b81526004016103739061161b565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166104a46000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146104ca5760405162461bcd60e51b815260040161037390611667565b6104d382610b15565b6104df82826001610b44565b5050565b61012e546001600160a01b0316331480159061050a57506097546001600160a01b03163314155b1561052a5760405163fade6b7760e01b8152336004820152602401610373565b6001600160a01b03811661055157604051630acd20ff60e21b815260040160405180910390fd5b61012d80546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf90600090a35050565b6105ac610caf565b61012e546001600160a01b031633148015906105d357506097546001600160a01b03163314155b156105f35760405163fade6b7760e01b8152336004820152602401610373565b6105fe838383610d08565b610608600160fb55565b505050565b6000306001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016146106ad5760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c00000000000000006064820152608401610373565b506000805160206117ad83398151915290565b6106c8610caf565b61012e546001600160a01b031633148015906106ef57506097546001600160a01b03163314155b1561070f5760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b03168061073a5760405163d3e94d1160e01b815260040160405180910390fd5b6107448183610d9c565b50610410600160fb55565b610757610e7f565b6001600160a01b03811661077e5760405163b325f76760e01b815260040160405180910390fd5b61012e80546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040516317d5c96560e11b815260040160405180910390fd5b60c95433906001600160a01b031681146108505760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b6064820152608401610373565b61041081610edb565b610861610caf565b61012e546001600160a01b0316331480159061088857506097546001600160a01b03163314155b156108a85760405163fade6b7760e01b8152336004820152602401610373565b6108b28282610d9c565b6104df600160fb55565b600054610100900460ff16158080156108dc5750600054600160ff909116105b806108f65750303b1580156108f6575060005460ff166001145b6109595760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b6064820152608401610373565b6000805460ff19166001179055801561097c576000805461ff0019166101001790555b610984610ef4565b61098c610ef4565b610994610f23565b61099c610f52565b6001600160a01b038216158015906109bd57506001600160a01b0382163314155b156109cb576109cb82610edb565b80156104df576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610a1c610caf565b61012e546001600160a01b03163314801590610a4357506097546001600160a01b03163314155b15610a635760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b031680610a8e5760405163d3e94d1160e01b815260040160405180910390fd5b610a99838284610d08565b506104df600160fb55565b610aac610e7f565b60c980546001600160a01b0383166001600160a01b03199091168117909155610add6097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b610b1d610e7f565b6001600160a01b0381166104105760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff1615610b775761060883610f79565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa925050508015610bd1575060408051601f3d908101601f19168201909252610bce918101906116b3565b60015b610c345760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b6064820152608401610373565b6000805160206117ad8339815191528114610ca35760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b6064820152608401610373565b50610608838383611015565b600260fb5403610d015760405162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152606401610373565b600260fb55565b6001600160a01b038216610d2f576040516351eb292760e11b815260040160405180910390fd5b610d436001600160a01b0384168383611040565b816001600160a01b0316836001600160a01b03167f7b09c29f9106defeccc9ac3b823f3aad0b470d120e5df7aed033b5c43a4bf71883604051610d8891815260200190565b60405180910390a3505050565b600160fb55565b6001600160a01b038216610dc3576040516351eb292760e11b815260040160405180910390fd5b6000826001600160a01b03168260405160006040518083038185875af1925050503d8060008114610e10576040519150601f19603f3d011682016040523d82523d6000602084013e610e15565b606091505b5050905080610e3757604051632072c05760e11b815260040160405180910390fd5b826001600160a01b03167fcfdf0683c37c9963bcb0bf5b183df58de6f1eaa52c40da8b1a85df1e7c6397eb83604051610e7291815260200190565b60405180910390a2505050565b6097546001600160a01b03163314610ed95760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e65726044820152606401610373565b565b60c980546001600160a01b031916905561041081611092565b600054610100900460ff16610f1b5760405162461bcd60e51b8152600401610373906116cc565b610ed96110e4565b600054610100900460ff16610f4a5760405162461bcd60e51b8152600401610373906116cc565b610ed9611114565b600054610100900460ff16610ed95760405162461bcd60e51b8152600401610373906116cc565b6001600160a01b0381163b610fe65760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b6064820152608401610373565b6000805160206117ad83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61101e8361113b565b60008251118061102b5750805b156106085761103a838361117b565b50505050565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b1790526106089084906111a7565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff1661110b5760405162461bcd60e51b8152600401610373906116cc565b610ed933610edb565b600054610100900460ff16610d955760405162461bcd60e51b8152600401610373906116cc565b61114481610f79565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606111a083836040518060600160405280602781526020016117cd6027913961127c565b9392505050565b60006111fc826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166112f49092919063ffffffff16565b905080516000148061121d57508080602001905181019061121d9190611717565b6106085760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610373565b6060600080856001600160a01b031685604051611299919061175d565b600060405180830381855af49150503d80600081146112d4576040519150601f19603f3d011682016040523d82523d6000602084013e6112d9565b606091505b50915091506112ea8683838761130b565b9695505050505050565b60606113038484600085611384565b949350505050565b6060831561137a578251600003611373576001600160a01b0385163b6113735760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610373565b5081611303565b611303838361145f565b6060824710156113e55760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610373565b600080866001600160a01b03168587604051611401919061175d565b60006040518083038185875af1925050503d806000811461143e576040519150601f19603f3d011682016040523d82523d6000602084013e611443565b606091505b50915091506114548783838761130b565b979650505050505050565b81511561146f5781518083602001fd5b8060405162461bcd60e51b81526004016103739190611779565b6001600160a01b038116811461041057600080fd5b6000602082840312156114b057600080fd5b81356111a081611489565b634e487b7160e01b600052604160045260246000fd5b600080604083850312156114e457600080fd5b82356114ef81611489565b9150602083013567ffffffffffffffff8082111561150c57600080fd5b818501915085601f83011261152057600080fd5b813581811115611532576115326114bb565b604051601f8201601f19908116603f0116810190838211818310171561155a5761155a6114bb565b8160405282815288602084870101111561157357600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b6000806000606084860312156115aa57600080fd5b83356115b581611489565b925060208401356115c581611489565b929592945050506040919091013590565b6000602082840312156115e857600080fd5b5035919050565b6000806040838503121561160257600080fd5b823561160d81611489565b946020939093013593505050565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b6000602082840312156116c557600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b60006020828403121561172957600080fd5b815180151581146111a057600080fd5b60005b8381101561175457818101518382015260200161173c565b50506000910152565b6000825161176f818460208701611739565b9190910192915050565b6020815260008251806020840152611798816040850160208701611739565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212200d4b37ce3f8ab7b0176cd2b969ee847bd86382d9e32b0e2ebeb0d91d6f9aa53264736f6c63430008140033", - "nonce": "0x2", + "nonce": "0x1e", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", + "hash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", "transactionType": "CREATE", "contractName": "ERC1967Proxy", - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": null, "arguments": [ - "0xab3761D0800C4310414e75Fd8b545cE094C20e26", + "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4", "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "gas": "0x43736", "value": "0x0", - "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564000000000000000000000000ab3761d0800c4310414e75fd8b545ce094c20e2600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", - "nonce": "0x3", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c65640000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x1f", "chainId": "0x1e" }, "additionalContracts": [], @@ -43,122 +43,122 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0x177067", + "cumulativeGasUsed": "0x159175", "logs": [ { - "address": "0xab3761d0800c4310414e75fd8b545ce094c20e26", + "address": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", - "blockHash": "0xb507102ee8ba234405a69ce47d02551794e923f9604233ca095a6dd9e9df3311", - "blockNumber": "0x8b8d9a", - "transactionHash": "0x1c022377d73c722793ec3a2f71c16c86354e7c584792b04760bb9785ee674ea7", - "transactionIndex": "0x1", - "logIndex": "0x1", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000002000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x1c022377d73c722793ec3a2f71c16c86354e7c584792b04760bb9785ee674ea7", - "transactionIndex": "0x1", - "blockHash": "0xb507102ee8ba234405a69ce47d02551794e923f9604233ca095a6dd9e9df3311", - "blockNumber": "0x8b8d9a", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", "gasUsed": "0x159175", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0xab3761d0800c4310414e75fd8b545ce094c20e26" + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4" }, { "status": "0x1", - "cumulativeGasUsed": "0x6d292", + "cumulativeGasUsed": "0x459a9", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", - "0x000000000000000000000000ab3761d0800c4310414e75fd8b545ce094c20e26" + "0x0000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb4" ], "data": "0x", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x5", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x2", "removed": false }, { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x6", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x3", "removed": false }, { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x7", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x4", "removed": false }, { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x8", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x5", "removed": false } ], - "logsBloom": "0x00000000000000000000000000002000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000001000000000000000000000000000000000001020000000000004000000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000004000000000020000000000030000000000000000000060000000000000000000000000000000020000000020000000001000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000002008002000001000000000000000000000000000000000000020000000000000080000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000080000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", "gasUsed": "0x37aac", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08" + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b" } ], "libraries": [], "pending": [], "returns": { - "impl": { + "proxy": { "internal_type": "address", - "value": "0xab3761D0800C4310414e75Fd8b545cE094C20e26" + "value": "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" }, - "proxy": { + "impl": { "internal_type": "address", - "value": "0x2ba389B021fA4A5F50cc1758EFD23Ca066d0Be08" + "value": "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4" } }, - "timestamp": 1786565025004, + "timestamp": 1787574212713, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json b/broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json new file mode 100644 index 0000000..5a69cb9 --- /dev/null +++ b/broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json @@ -0,0 +1,170 @@ +{ + "transactions": [ + { + "hash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": "setDefaultRecipient(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "gas": "0x12767", + "value": "0x0", + "input": "0x4f7a6c3c000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x20", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": "setAdmin(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "gas": "0x12a51", + "value": "0x0", + "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x21", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": "transferOwnership(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "gas": "0x11c78", + "value": "0x0", + "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x22", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x123d0", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000020000000000000000000800000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000008000000020000000000000000000000000000000000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", + "gasUsed": "0xb0c3", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x1bc1b", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", + "gasUsed": "0xae4b", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0xb0a8", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", + "gasUsed": "0xb0a8", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1787576893913, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/02_BootstrapVault.s.sol/30/run-latest.json b/broadcast/02_BootstrapVault.s.sol/30/run-latest.json index 4222bac..5a69cb9 100644 --- a/broadcast/02_BootstrapVault.s.sol/30/run-latest.json +++ b/broadcast/02_BootstrapVault.s.sol/30/run-latest.json @@ -1,63 +1,63 @@ { "transactions": [ { - "hash": "0x513f5e5378f482cfceee996355c0ddd340561f550ab3bb5c09846361f704d6d4", + "hash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": "setDefaultRecipient(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "gas": "0x12767", "value": "0x0", "input": "0x4f7a6c3c000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x4", + "nonce": "0x20", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xf230593c4ea325a3aa257b8269f43c96d26d18daf0e7bc0940471ddf432a5d91", + "hash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": "setAdmin(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "gas": "0x12a51", "value": "0x0", "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x5", + "nonce": "0x21", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x8e76efc7a0f57095ca51e5508a87ae55b6262c9e03d2154bb573e3211f0b5018", + "hash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": "transferOwnership(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "gas": "0x11c78", "value": "0x0", "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x6", + "nonce": "0x22", "chainId": "0x1e" }, "additionalContracts": [], @@ -67,104 +67,104 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0x26b5c", + "cumulativeGasUsed": "0x123d0", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x26bde7abb5498d99745d287576590af0aa182c3344fda6bdf3f7b2a7661ff4f8", - "blockNumber": "0x8b8dbd", - "transactionHash": "0x513f5e5378f482cfceee996355c0ddd340561f550ab3bb5c09846361f704d6d4", - "transactionIndex": "0x2", - "logIndex": "0x4", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001020000000000004000000800000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000010000000000000000000000000100000000000000000008000000020000000000000000000000000000000000000002000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000020000000000000000000800000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000008000000020000000000000000000000000000000000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0x513f5e5378f482cfceee996355c0ddd340561f550ab3bb5c09846361f704d6d4", - "transactionIndex": "0x2", - "blockHash": "0x26bde7abb5498d99745d287576590af0aa182c3344fda6bdf3f7b2a7661ff4f8", - "blockNumber": "0x8b8dbd", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", "gasUsed": "0xb0c3", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x2d8a8", + "cumulativeGasUsed": "0x1bc1b", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x1ab1a6f771546bbaf761f64687f14b4a3668addf60febc4692921a1c1b0e06bd", - "blockNumber": "0x8b8dbf", - "transactionHash": "0xf230593c4ea325a3aa257b8269f43c96d26d18daf0e7bc0940471ddf432a5d91", - "transactionIndex": "0x3", - "logIndex": "0x5", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080400000000001000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000010000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0xf230593c4ea325a3aa257b8269f43c96d26d18daf0e7bc0940471ddf432a5d91", - "transactionIndex": "0x3", - "blockHash": "0x1ab1a6f771546bbaf761f64687f14b4a3668addf60febc4692921a1c1b0e06bd", - "blockNumber": "0x8b8dbf", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", "gasUsed": "0xae4b", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x19474", + "cumulativeGasUsed": "0xb0a8", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x75acca46b89fa1f9512f5e86e10c8eeda4838db4a3050234c2d112c10afedd45", - "blockNumber": "0x8b8dc0", - "transactionHash": "0x8e76efc7a0f57095ca51e5508a87ae55b6262c9e03d2154bb573e3211f0b5018", - "transactionIndex": "0x2", - "logIndex": "0x1", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000004000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002010000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0x8e76efc7a0f57095ca51e5508a87ae55b6262c9e03d2154bb573e3211f0b5018", - "transactionIndex": "0x2", - "blockHash": "0x75acca46b89fa1f9512f5e86e10c8eeda4838db4a3050234c2d112c10afedd45", - "blockNumber": "0x8b8dc0", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", "gasUsed": "0xb0a8", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "contractAddress": null } ], "libraries": [], "pending": [], "returns": {}, - "timestamp": 1786566135969, + "timestamp": 1787576893913, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/broadcast/03_DeployController.s.sol/30/run-1787577343971.json b/broadcast/03_DeployController.s.sol/30/run-1787577343971.json new file mode 100644 index 0000000..bd0de1d --- /dev/null +++ b/broadcast/03_DeployController.s.sol/30/run-1787577343971.json @@ -0,0 +1,164 @@ +{ + "transactions": [ + { + "hash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", + "transactionType": "CREATE", + "contractName": "ExitFeeController", + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", + "function": null, + "arguments": null, + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x292425", + "value": "0x0", + "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b60805161241862000119600039600081816107a2015281816107eb015281816108a2015281816108e2015261097501526124186000f3fe6080604052600436106101d75760003560e01c80638da5cb5b11610102578063d131f85511610095578063f2fde38b11610064578063f2fde38b1461060c578063f851a4401461062c578063fd967f471461064d578063ff78c4361461067657600080fd5b8063d131f8551461058e578063e30c3978146105ae578063eeb57de7146105cc578063efdcd974146105ec57600080fd5b8063baf2785d116100d1578063baf2785d1461050e578063bda0b1bf1461052e578063c4d66de81461054e578063c5906c671461056e57600080fd5b80638da5cb5b14610434578063a48efa1c14610466578063af65b179146104c9578063b3f00674146104e957600080fd5b806364a972c71161017a578063762dc30611610149578063762dc306146103b257806379ba5097146103df57806380569670146103f4578063809c30131461041457600080fd5b806364a972c7146102e4578063704b6c021461030457806370db067514610324578063715018a61461039d57600080fd5b80633659cfe6116101b65780633659cfe61461026e578063418ecb561461028e5780634f1ef286146102ae57806352d1902d146102c157600080fd5b8062ee5a76146101dc5780630f8886951461020b578063292630781461024c575b600080fd5b3480156101e857600080fd5b5060fb546101f69060ff1681565b60405190151581526020015b60405180910390f35b34801561021757600080fd5b5061022b610226366004611d65565b6106e7565b6040805182511515815260209283015161ffff169281019290925201610202565b34801561025857600080fd5b5061026c610267366004611ddd565b61073d565b005b34801561027a57600080fd5b5061026c610289366004611e29565b610798565b34801561029a57600080fd5b5061026c6102a9366004611e5c565b610880565b61026c6102bc366004611eaf565b610898565b3480156102cd57600080fd5b506102d6610968565b604051908152602001610202565b3480156102f057600080fd5b5061026c6102ff366004611ddd565b610a1b565b34801561031057600080fd5b5061026c61031f366004611e29565b610a6f565b34801561033057600080fd5b5061034461033f366004611f71565b610ae9565b604051610202919081511515815260208083015161ffff169082015260408083015190820152606080830151908201526080808301516001600160a01b03169082015260a09182015160ff169181019190915260c00190565b3480156103a957600080fd5b5061026c610c16565b3480156103be57600080fd5b506103d26103cd366004611fb5565b610c2f565b6040516102029190611fce565b3480156103eb57600080fd5b5061026c610c49565b34801561040057600080fd5b5061026c61040f366004612029565b610cc0565b34801561042057600080fd5b5061026c61042f366004612046565b610d4e565b34801561044057600080fd5b506097546001600160a01b03165b6040516001600160a01b039091168152602001610202565b34801561047257600080fd5b5061022b610481366004611fb5565b604080518082019091526000808252602082015250600090815260fc602090815260409182902082518084019093525460ff811615158352610100900461ffff169082015290565b3480156104d557600080fd5b5061026c6104e4366004611d65565b610de6565b3480156104f557600080fd5b5060fb5461044e9061010090046001600160a01b031681565b34801561051a57600080fd5b506103d2610529366004611fb5565b610df8565b34801561053a57600080fd5b5061026c610549366004612046565b610e13565b34801561055a57600080fd5b5061026c610569366004611e29565b610ea2565b34801561057a57600080fd5b5061026c610589366004611d65565b610ff2565b34801561059a57600080fd5b5061026c6105a93660046120ed565b611004565b3480156105ba57600080fd5b5060c9546001600160a01b031661044e565b3480156105d857600080fd5b5061026c6105e7366004611e5c565b6110d6565b3480156105f857600080fd5b5061026c610607366004611e29565b6110e9565b34801561061857600080fd5b5061026c610627366004611e29565b6111a9565b34801561063857600080fd5b506101015461044e906001600160a01b031681565b34801561065957600080fd5b5061066361271081565b60405161ffff9091168152602001610202565b34801561068257600080fd5b5061022b610691366004611d65565b6040805180820182526000808252602091820181905293845260fe81528184206001600160a01b039390931684529182529182902082518084019093525460ff811615158352610100900461ffff169082015290565b6040805180820182526000808252602091820181905284815260fd82528281206001600160a01b0385168252825282902082518084019093525460ff811615158352610100900461ffff16908201525b92915050565b61074561121a565b8060005b81811015610791576107818585858481811061076757610767612111565b905060200201602081019061077c9190611e29565b611276565b61078a8161213d565b9050610749565b5050505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107e95760405162461bcd60e51b81526004016107e090612156565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661083260008051602061239c833981519152546001600160a01b031690565b6001600160a01b0316146108585760405162461bcd60e51b81526004016107e0906121a2565b61086181611314565b6040805160008082526020820190925261087d91839190611343565b50565b61088861121a565b6108938383836114ae565b505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036108e05760405162461bcd60e51b81526004016107e090612156565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661092960008051602061239c833981519152546001600160a01b031690565b6001600160a01b03161461094f5760405162461bcd60e51b81526004016107e0906121a2565b61095882611314565b61096482826001611343565b5050565b6000306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614610a085760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c000000000000000060648201526084016107e0565b5060008051602061239c83398151915290565b610a2361121a565b8060005b8181101561079157610a5f85858584818110610a4557610a45612111565b9050602002016020810190610a5a9190611e29565b6115b6565b610a688161213d565b9050610a27565b610a7761121a565b6001600160a01b038116610a9e5760405163b325f76760e01b815260040160405180910390fd5b61010180546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040805160c08101825260008082526020820181905291810182905260a081019190915260fb5461010081046001600160a01b031660808301526060820183905260ff16610b415760015b60ff1660a0820152610c0e565b60fb5461010090046001600160a01b0316610b5d576002610b34565b6000610b6a868686611655565b8051909150610b845760025b60ff1660a083015250610c0e565b610b926127106000196121ee565b831115610ba0576003610b76565b602081015160009061271090610bba9061ffff1686612210565b610bc491906121ee565b905083811115610bdc575050600360a0820152610c0e565b6001835260208083015161ffff169084015260408301819052610bff8185612227565b60608401525050600060a08201525b949350505050565b6040516317d5c96560e11b815260040160405180910390fd5b600081815260ff602052604090206060906107379061176a565b60c95433906001600160a01b03168114610cb75760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084016107e0565b61087d81611777565b610101546001600160a01b03163314801590610ce757506097546001600160a01b03163314155b15610d075760405163fade6b7760e01b81523360048201526024016107e0565b60fb805460ff19168215159081179091556040519081527f4afba55e68999eb85b2692f998f6eea3e471317ba84d323f927b08ee304937dc9060200160405180910390a150565b610d5661121a565b82818114610d7a576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610dcd87878784818110610d9b57610d9b612111565b9050602002016020810190610db09190611e29565b868685818110610dc257610dc2612111565b9050604002016114ae565b610dd68161213d565b9050610d7d565b50505050505050565b610dee61121a565b6109648282611276565b6000818152610100602052604090206060906107379061176a565b610e1b61121a565b82818114610e3f576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610e9287878784818110610e6057610e60612111565b9050602002016020810190610e759190611e29565b868685818110610e8757610e87612111565b905060400201611790565b610e9b8161213d565b9050610e42565b600054610100900460ff1615808015610ec25750600054600160ff909116105b80610edc5750303b158015610edc575060005460ff166001145b610f3f5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016107e0565b6000805460ff191660011790558015610f62576000805461ff0019166101001790555b610f6a611867565b610f72611867565b610f7a611896565b6001600160a01b03821615801590610f9b57506001600160a01b0382163314155b15610fa957610fa982611777565b8015610964576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610ffa61121a565b61096482826115b6565b61100c61121a565b61271061101f604083016020840161224a565b61ffff16111561105957611039604082016020830161224a565b60405163e476e19360e01b815261ffff90911660048201526024016107e0565b600082815260fc6020526040902081906110738282612267565b508290507f9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e46110a56020840184612029565b6110b5604085016020860161224a565b60408051921515835261ffff90911660208301520160405180910390a25050565b6110de61121a565b610893838383611790565b610101546001600160a01b0316331480159061111057506097546001600160a01b03163314155b156111305760405163fade6b7760e01b81523360048201526024016107e0565b6001600160a01b03811661115757604051630665334760e11b815260040160405180910390fd5b60fb8054610100600160a81b0319166101006001600160a01b038416908102919091179091556040517fbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d90600090a250565b6111b161121a565b60c980546001600160a01b0383166001600160a01b031990911681179091556111e26097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b6097546001600160a01b031633146112745760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107e0565b565b6001600160a01b03811661129d5760405163862f9ab160e01b815260040160405180910390fd5b600082815260ff602052604090206112b590826118bd565b1561096457600082815260fd602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917faafabd0077e7cf783725deb87142797a6a87e42f2afff85325d1b6912eb71d419190a35050565b61131c61121a565b6001600160a01b03811661087d5760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff161561137657610893836118d2565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa9250505080156113d0575060408051601f3d908101601f191682019092526113cd918101906122ae565b60015b6114335760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b60648201526084016107e0565b60008051602061239c83398151915281146114a25760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b60648201526084016107e0565b5061089383838361196e565b6001600160a01b0382166114d55760405163862f9ab160e01b815260040160405180910390fd5b6127106114e8604083016020840161224a565b61ffff16111561150257611039604082016020830161224a565b600083815260fd602090815260408083206001600160a01b0386168452909152902081906115308282612267565b5050600083815260ff6020526040902061154a9083611999565b506001600160a01b038216837f48d8c51d6d2a265f877d8d087866ac5804efe2d6770ba1dd2d196eeb23630c906115846020850185612029565b611594604086016020870161224a565b60408051921515835261ffff90911660208301520160405180910390a3505050565b6001600160a01b0381166115dd5760405163069018bf60e11b815260040160405180910390fd5b6000828152610100602052604090206115f690826118bd565b1561096457600082815260fe602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917f8a7413f0bcdbe90171aa0fcf0ebeb97f0c2dcee8e972861cb6b54a412041c82c9190a35050565b6040805180820190915260008082526020820152600084815260fc602090815260409182902082518084019093525460ff8116151580845261010090910461ffff16918301919091526116a9579050611763565b600085815260fe602090815260408083206001600160a01b038716845282529182902082518084019093525460ff8116158015845261010090910461ffff16918301919091526116fc5791506117639050565b6001600160a01b0385161561175f5750600085815260fd602090815260408083206001600160a01b038816845282529182902082518084019093525460ff8116158015845261010090910461ffff169183019190915261175f5791506117639050565b5090505b9392505050565b60606000611763836119ae565b60c980546001600160a01b031916905561087d81611a0a565b6001600160a01b0382166117b75760405163069018bf60e11b815260040160405180910390fd5b6127106117ca604083016020840161224a565b61ffff1611156117e457611039604082016020830161224a565b600083815260fe602090815260408083206001600160a01b0386168452909152902081906118128282612267565b505060008381526101006020526040902061182d9083611999565b506001600160a01b038216837fb5938f0c738d351d053db88baaeda5f2b265cd07e3b930e9182ebebd5f701d476115846020850185612029565b600054610100900460ff1661188e5760405162461bcd60e51b81526004016107e0906122c7565b611274611a5c565b600054610100900460ff166112745760405162461bcd60e51b81526004016107e0906122c7565b6000611763836001600160a01b038416611a8c565b6001600160a01b0381163b61193f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016107e0565b60008051602061239c83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61197783611b7f565b6000825111806119845750805b15610893576119938383611bbf565b50505050565b6000611763836001600160a01b038416611be4565b6060816000018054806020026020016040519081016040528092919081815260200182805480156119fe57602002820191906000526020600020905b8154815260200190600101908083116119ea575b50505050509050919050565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff16611a835760405162461bcd60e51b81526004016107e0906122c7565b61127433611777565b60008181526001830160205260408120548015611b75576000611ab0600183612227565b8554909150600090611ac490600190612227565b9050818114611b29576000866000018281548110611ae457611ae4612111565b9060005260206000200154905080876000018481548110611b0757611b07612111565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080611b3a57611b3a612312565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610737565b6000915050610737565b611b88816118d2565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061176383836040518060600160405280602781526020016123bc60279139611c33565b6000818152600183016020526040812054611c2b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610737565b506000610737565b6060600080856001600160a01b031685604051611c50919061234c565b600060405180830381855af49150503d8060008114611c8b576040519150601f19603f3d011682016040523d82523d6000602084013e611c90565b606091505b5091509150611ca186838387611cab565b9695505050505050565b60608315611d1a578251600003611d13576001600160a01b0385163b611d135760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e747261637400000060448201526064016107e0565b5081610c0e565b610c0e8383815115611d2f5781518083602001fd5b8060405162461bcd60e51b81526004016107e09190612368565b80356001600160a01b0381168114611d6057600080fd5b919050565b60008060408385031215611d7857600080fd5b82359150611d8860208401611d49565b90509250929050565b60008083601f840112611da357600080fd5b50813567ffffffffffffffff811115611dbb57600080fd5b6020830191508360208260051b8501011115611dd657600080fd5b9250929050565b600080600060408486031215611df257600080fd5b83359250602084013567ffffffffffffffff811115611e1057600080fd5b611e1c86828701611d91565b9497909650939450505050565b600060208284031215611e3b57600080fd5b61176382611d49565b600060408284031215611e5657600080fd5b50919050565b600080600060808486031215611e7157600080fd5b83359250611e8160208501611d49565b9150611e908560408601611e44565b90509250925092565b634e487b7160e01b600052604160045260246000fd5b60008060408385031215611ec257600080fd5b611ecb83611d49565b9150602083013567ffffffffffffffff80821115611ee857600080fd5b818501915085601f830112611efc57600080fd5b813581811115611f0e57611f0e611e99565b604051601f8201601f19908116603f01168101908382118183101715611f3657611f36611e99565b81604052828152886020848701011115611f4f57600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b60008060008060808587031215611f8757600080fd5b84359350611f9760208601611d49565b9250611fa560408601611d49565b9396929550929360600135925050565b600060208284031215611fc757600080fd5b5035919050565b6020808252825182820181905260009190848201906040850190845b8181101561200f5783516001600160a01b031683529284019291840191600101611fea565b50909695505050505050565b801515811461087d57600080fd5b60006020828403121561203b57600080fd5b81356117638161201b565b60008060008060006060868803121561205e57600080fd5b85359450602086013567ffffffffffffffff8082111561207d57600080fd5b61208989838a01611d91565b909650945060408801359150808211156120a257600080fd5b818801915088601f8301126120b657600080fd5b8135818111156120c557600080fd5b8960208260061b85010111156120da57600080fd5b9699959850939650602001949392505050565b6000806060838503121561210057600080fd5b82359150611d888460208501611e44565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b60006001820161214f5761214f612127565b5060010190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b60008261220b57634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761073757610737612127565b8181038181111561073757610737612127565b61ffff8116811461087d57600080fd5b60006020828403121561225c57600080fd5b81356117638161223a565b81356122728161201b565b815460ff19811691151560ff16918217835560208401356122928161223a565b62ffff008160081b168362ffffff198416171784555050505050565b6000602082840312156122c057600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b634e487b7160e01b600052603160045260246000fd5b60005b8381101561234357818101518382015260200161232b565b50506000910152565b6000825161235e818460208701612328565b9190910192915050565b6020815260008251806020840152612387816040850160208701612328565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a2646970667358221220da9ed0469f75959621b7a20bdba0cd03e50cb0089e2b7c8010bf46a16479d97764736f6c63430008140033", + "nonce": "0x23", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionType": "CREATE", + "contractName": "ERC1967Proxy", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": null, + "arguments": [ + "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d", + "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x3c5b8", + "value": "0x0", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c656400000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x24", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x20f9c8", + "logs": [ + { + "address": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000008000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", + "transactionIndex": "0x1", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", + "gasUsed": "0x1fa157", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d" + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x5fa9b", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x00000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d" + ], + "data": "0x", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + }, + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + }, + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x3", + "removed": false + }, + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x4", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100400000000000000000800000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000002000001000000000000000000000000000000100000024000000000000000000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001001000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "gasUsed": "0x32acb", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e" + } + ], + "libraries": [], + "pending": [], + "returns": { + "impl": { + "internal_type": "address", + "value": "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d" + }, + "proxy": { + "internal_type": "address", + "value": "0x99994b4522483DE17F31a5bC010c5901AdD3440E" + } + }, + "timestamp": 1787577343971, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/03_DeployController.s.sol/30/run-latest.json b/broadcast/03_DeployController.s.sol/30/run-latest.json index d9bc861..bd0de1d 100644 --- a/broadcast/03_DeployController.s.sol/30/run-latest.json +++ b/broadcast/03_DeployController.s.sol/30/run-latest.json @@ -1,39 +1,39 @@ { "transactions": [ { - "hash": "0x1b03d0897c0141c2741423c956d0e0e19ee075e4e1fa23249e58fdcad633f21e", + "hash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", "transactionType": "CREATE", "contractName": "ExitFeeController", - "contractAddress": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604", + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", "function": null, "arguments": null, "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "gas": "0x292425", "value": "0x0", - "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b60805161241862000119600039600081816107a2015281816107eb015281816108a2015281816108e2015261097501526124186000f3fe6080604052600436106101d75760003560e01c80638da5cb5b11610102578063d131f85511610095578063f2fde38b11610064578063f2fde38b1461060c578063f851a4401461062c578063fd967f471461064d578063ff78c4361461067657600080fd5b8063d131f8551461058e578063e30c3978146105ae578063eeb57de7146105cc578063efdcd974146105ec57600080fd5b8063baf2785d116100d1578063baf2785d1461050e578063bda0b1bf1461052e578063c4d66de81461054e578063c5906c671461056e57600080fd5b80638da5cb5b14610434578063a48efa1c14610466578063af65b179146104c9578063b3f00674146104e957600080fd5b806364a972c71161017a578063762dc30611610149578063762dc306146103b257806379ba5097146103df57806380569670146103f4578063809c30131461041457600080fd5b806364a972c7146102e4578063704b6c021461030457806370db067514610324578063715018a61461039d57600080fd5b80633659cfe6116101b65780633659cfe61461026e578063418ecb561461028e5780634f1ef286146102ae57806352d1902d146102c157600080fd5b8062ee5a76146101dc5780630f8886951461020b578063292630781461024c575b600080fd5b3480156101e857600080fd5b5060fb546101f69060ff1681565b60405190151581526020015b60405180910390f35b34801561021757600080fd5b5061022b610226366004611d65565b6106e7565b6040805182511515815260209283015161ffff169281019290925201610202565b34801561025857600080fd5b5061026c610267366004611ddd565b61073d565b005b34801561027a57600080fd5b5061026c610289366004611e29565b610798565b34801561029a57600080fd5b5061026c6102a9366004611e5c565b610880565b61026c6102bc366004611eaf565b610898565b3480156102cd57600080fd5b506102d6610968565b604051908152602001610202565b3480156102f057600080fd5b5061026c6102ff366004611ddd565b610a1b565b34801561031057600080fd5b5061026c61031f366004611e29565b610a6f565b34801561033057600080fd5b5061034461033f366004611f71565b610ae9565b604051610202919081511515815260208083015161ffff169082015260408083015190820152606080830151908201526080808301516001600160a01b03169082015260a09182015160ff169181019190915260c00190565b3480156103a957600080fd5b5061026c610c16565b3480156103be57600080fd5b506103d26103cd366004611fb5565b610c2f565b6040516102029190611fce565b3480156103eb57600080fd5b5061026c610c49565b34801561040057600080fd5b5061026c61040f366004612029565b610cc0565b34801561042057600080fd5b5061026c61042f366004612046565b610d4e565b34801561044057600080fd5b506097546001600160a01b03165b6040516001600160a01b039091168152602001610202565b34801561047257600080fd5b5061022b610481366004611fb5565b604080518082019091526000808252602082015250600090815260fc602090815260409182902082518084019093525460ff811615158352610100900461ffff169082015290565b3480156104d557600080fd5b5061026c6104e4366004611d65565b610de6565b3480156104f557600080fd5b5060fb5461044e9061010090046001600160a01b031681565b34801561051a57600080fd5b506103d2610529366004611fb5565b610df8565b34801561053a57600080fd5b5061026c610549366004612046565b610e13565b34801561055a57600080fd5b5061026c610569366004611e29565b610ea2565b34801561057a57600080fd5b5061026c610589366004611d65565b610ff2565b34801561059a57600080fd5b5061026c6105a93660046120ed565b611004565b3480156105ba57600080fd5b5060c9546001600160a01b031661044e565b3480156105d857600080fd5b5061026c6105e7366004611e5c565b6110d6565b3480156105f857600080fd5b5061026c610607366004611e29565b6110e9565b34801561061857600080fd5b5061026c610627366004611e29565b6111a9565b34801561063857600080fd5b506101015461044e906001600160a01b031681565b34801561065957600080fd5b5061066361271081565b60405161ffff9091168152602001610202565b34801561068257600080fd5b5061022b610691366004611d65565b6040805180820182526000808252602091820181905293845260fe81528184206001600160a01b039390931684529182529182902082518084019093525460ff811615158352610100900461ffff169082015290565b6040805180820182526000808252602091820181905284815260fd82528281206001600160a01b0385168252825282902082518084019093525460ff811615158352610100900461ffff16908201525b92915050565b61074561121a565b8060005b81811015610791576107818585858481811061076757610767612111565b905060200201602081019061077c9190611e29565b611276565b61078a8161213d565b9050610749565b5050505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107e95760405162461bcd60e51b81526004016107e090612156565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661083260008051602061239c833981519152546001600160a01b031690565b6001600160a01b0316146108585760405162461bcd60e51b81526004016107e0906121a2565b61086181611314565b6040805160008082526020820190925261087d91839190611343565b50565b61088861121a565b6108938383836114ae565b505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036108e05760405162461bcd60e51b81526004016107e090612156565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661092960008051602061239c833981519152546001600160a01b031690565b6001600160a01b03161461094f5760405162461bcd60e51b81526004016107e0906121a2565b61095882611314565b61096482826001611343565b5050565b6000306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614610a085760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c000000000000000060648201526084016107e0565b5060008051602061239c83398151915290565b610a2361121a565b8060005b8181101561079157610a5f85858584818110610a4557610a45612111565b9050602002016020810190610a5a9190611e29565b6115b6565b610a688161213d565b9050610a27565b610a7761121a565b6001600160a01b038116610a9e5760405163b325f76760e01b815260040160405180910390fd5b61010180546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040805160c08101825260008082526020820181905291810182905260a081019190915260fb5461010081046001600160a01b031660808301526060820183905260ff16610b415760015b60ff1660a0820152610c0e565b60fb5461010090046001600160a01b0316610b5d576002610b34565b6000610b6a868686611655565b8051909150610b845760025b60ff1660a083015250610c0e565b610b926127106000196121ee565b831115610ba0576003610b76565b602081015160009061271090610bba9061ffff1686612210565b610bc491906121ee565b905083811115610bdc575050600360a0820152610c0e565b6001835260208083015161ffff169084015260408301819052610bff8185612227565b60608401525050600060a08201525b949350505050565b6040516317d5c96560e11b815260040160405180910390fd5b600081815260ff602052604090206060906107379061176a565b60c95433906001600160a01b03168114610cb75760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084016107e0565b61087d81611777565b610101546001600160a01b03163314801590610ce757506097546001600160a01b03163314155b15610d075760405163fade6b7760e01b81523360048201526024016107e0565b60fb805460ff19168215159081179091556040519081527f4afba55e68999eb85b2692f998f6eea3e471317ba84d323f927b08ee304937dc9060200160405180910390a150565b610d5661121a565b82818114610d7a576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610dcd87878784818110610d9b57610d9b612111565b9050602002016020810190610db09190611e29565b868685818110610dc257610dc2612111565b9050604002016114ae565b610dd68161213d565b9050610d7d565b50505050505050565b610dee61121a565b6109648282611276565b6000818152610100602052604090206060906107379061176a565b610e1b61121a565b82818114610e3f576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610e9287878784818110610e6057610e60612111565b9050602002016020810190610e759190611e29565b868685818110610e8757610e87612111565b905060400201611790565b610e9b8161213d565b9050610e42565b600054610100900460ff1615808015610ec25750600054600160ff909116105b80610edc5750303b158015610edc575060005460ff166001145b610f3f5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016107e0565b6000805460ff191660011790558015610f62576000805461ff0019166101001790555b610f6a611867565b610f72611867565b610f7a611896565b6001600160a01b03821615801590610f9b57506001600160a01b0382163314155b15610fa957610fa982611777565b8015610964576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610ffa61121a565b61096482826115b6565b61100c61121a565b61271061101f604083016020840161224a565b61ffff16111561105957611039604082016020830161224a565b60405163e476e19360e01b815261ffff90911660048201526024016107e0565b600082815260fc6020526040902081906110738282612267565b508290507f9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e46110a56020840184612029565b6110b5604085016020860161224a565b60408051921515835261ffff90911660208301520160405180910390a25050565b6110de61121a565b610893838383611790565b610101546001600160a01b0316331480159061111057506097546001600160a01b03163314155b156111305760405163fade6b7760e01b81523360048201526024016107e0565b6001600160a01b03811661115757604051630665334760e11b815260040160405180910390fd5b60fb8054610100600160a81b0319166101006001600160a01b038416908102919091179091556040517fbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d90600090a250565b6111b161121a565b60c980546001600160a01b0383166001600160a01b031990911681179091556111e26097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b6097546001600160a01b031633146112745760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107e0565b565b6001600160a01b03811661129d5760405163862f9ab160e01b815260040160405180910390fd5b600082815260ff602052604090206112b590826118bd565b1561096457600082815260fd602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917faafabd0077e7cf783725deb87142797a6a87e42f2afff85325d1b6912eb71d419190a35050565b61131c61121a565b6001600160a01b03811661087d5760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff161561137657610893836118d2565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa9250505080156113d0575060408051601f3d908101601f191682019092526113cd918101906122ae565b60015b6114335760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b60648201526084016107e0565b60008051602061239c83398151915281146114a25760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b60648201526084016107e0565b5061089383838361196e565b6001600160a01b0382166114d55760405163862f9ab160e01b815260040160405180910390fd5b6127106114e8604083016020840161224a565b61ffff16111561150257611039604082016020830161224a565b600083815260fd602090815260408083206001600160a01b0386168452909152902081906115308282612267565b5050600083815260ff6020526040902061154a9083611999565b506001600160a01b038216837f48d8c51d6d2a265f877d8d087866ac5804efe2d6770ba1dd2d196eeb23630c906115846020850185612029565b611594604086016020870161224a565b60408051921515835261ffff90911660208301520160405180910390a3505050565b6001600160a01b0381166115dd5760405163069018bf60e11b815260040160405180910390fd5b6000828152610100602052604090206115f690826118bd565b1561096457600082815260fe602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917f8a7413f0bcdbe90171aa0fcf0ebeb97f0c2dcee8e972861cb6b54a412041c82c9190a35050565b6040805180820190915260008082526020820152600084815260fc602090815260409182902082518084019093525460ff8116151580845261010090910461ffff16918301919091526116a9579050611763565b600085815260fe602090815260408083206001600160a01b038716845282529182902082518084019093525460ff8116158015845261010090910461ffff16918301919091526116fc5791506117639050565b6001600160a01b0385161561175f5750600085815260fd602090815260408083206001600160a01b038816845282529182902082518084019093525460ff8116158015845261010090910461ffff169183019190915261175f5791506117639050565b5090505b9392505050565b60606000611763836119ae565b60c980546001600160a01b031916905561087d81611a0a565b6001600160a01b0382166117b75760405163069018bf60e11b815260040160405180910390fd5b6127106117ca604083016020840161224a565b61ffff1611156117e457611039604082016020830161224a565b600083815260fe602090815260408083206001600160a01b0386168452909152902081906118128282612267565b505060008381526101006020526040902061182d9083611999565b506001600160a01b038216837fb5938f0c738d351d053db88baaeda5f2b265cd07e3b930e9182ebebd5f701d476115846020850185612029565b600054610100900460ff1661188e5760405162461bcd60e51b81526004016107e0906122c7565b611274611a5c565b600054610100900460ff166112745760405162461bcd60e51b81526004016107e0906122c7565b6000611763836001600160a01b038416611a8c565b6001600160a01b0381163b61193f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016107e0565b60008051602061239c83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61197783611b7f565b6000825111806119845750805b15610893576119938383611bbf565b50505050565b6000611763836001600160a01b038416611be4565b6060816000018054806020026020016040519081016040528092919081815260200182805480156119fe57602002820191906000526020600020905b8154815260200190600101908083116119ea575b50505050509050919050565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff16611a835760405162461bcd60e51b81526004016107e0906122c7565b61127433611777565b60008181526001830160205260408120548015611b75576000611ab0600183612227565b8554909150600090611ac490600190612227565b9050818114611b29576000866000018281548110611ae457611ae4612111565b9060005260206000200154905080876000018481548110611b0757611b07612111565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080611b3a57611b3a612312565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610737565b6000915050610737565b611b88816118d2565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061176383836040518060600160405280602781526020016123bc60279139611c33565b6000818152600183016020526040812054611c2b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610737565b506000610737565b6060600080856001600160a01b031685604051611c50919061234c565b600060405180830381855af49150503d8060008114611c8b576040519150601f19603f3d011682016040523d82523d6000602084013e611c90565b606091505b5091509150611ca186838387611cab565b9695505050505050565b60608315611d1a578251600003611d13576001600160a01b0385163b611d135760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e747261637400000060448201526064016107e0565b5081610c0e565b610c0e8383815115611d2f5781518083602001fd5b8060405162461bcd60e51b81526004016107e09190612368565b80356001600160a01b0381168114611d6057600080fd5b919050565b60008060408385031215611d7857600080fd5b82359150611d8860208401611d49565b90509250929050565b60008083601f840112611da357600080fd5b50813567ffffffffffffffff811115611dbb57600080fd5b6020830191508360208260051b8501011115611dd657600080fd5b9250929050565b600080600060408486031215611df257600080fd5b83359250602084013567ffffffffffffffff811115611e1057600080fd5b611e1c86828701611d91565b9497909650939450505050565b600060208284031215611e3b57600080fd5b61176382611d49565b600060408284031215611e5657600080fd5b50919050565b600080600060808486031215611e7157600080fd5b83359250611e8160208501611d49565b9150611e908560408601611e44565b90509250925092565b634e487b7160e01b600052604160045260246000fd5b60008060408385031215611ec257600080fd5b611ecb83611d49565b9150602083013567ffffffffffffffff80821115611ee857600080fd5b818501915085601f830112611efc57600080fd5b813581811115611f0e57611f0e611e99565b604051601f8201601f19908116603f01168101908382118183101715611f3657611f36611e99565b81604052828152886020848701011115611f4f57600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b60008060008060808587031215611f8757600080fd5b84359350611f9760208601611d49565b9250611fa560408601611d49565b9396929550929360600135925050565b600060208284031215611fc757600080fd5b5035919050565b6020808252825182820181905260009190848201906040850190845b8181101561200f5783516001600160a01b031683529284019291840191600101611fea565b50909695505050505050565b801515811461087d57600080fd5b60006020828403121561203b57600080fd5b81356117638161201b565b60008060008060006060868803121561205e57600080fd5b85359450602086013567ffffffffffffffff8082111561207d57600080fd5b61208989838a01611d91565b909650945060408801359150808211156120a257600080fd5b818801915088601f8301126120b657600080fd5b8135818111156120c557600080fd5b8960208260061b85010111156120da57600080fd5b9699959850939650602001949392505050565b6000806060838503121561210057600080fd5b82359150611d888460208501611e44565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b60006001820161214f5761214f612127565b5060010190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b60008261220b57634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761073757610737612127565b8181038181111561073757610737612127565b61ffff8116811461087d57600080fd5b60006020828403121561225c57600080fd5b81356117638161223a565b81356122728161201b565b815460ff19811691151560ff16918217835560208401356122928161223a565b62ffff008160081b168362ffffff198416171784555050505050565b6000602082840312156122c057600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b634e487b7160e01b600052603160045260246000fd5b60005b8381101561234357818101518382015260200161232b565b50506000910152565b6000825161235e818460208701612328565b9190910192915050565b6020815260008251806020840152612387816040850160208701612328565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212208cb15c168403fa3e55f3ae6f16bf941b0eb503902d97afc4a8482f6de492250c64736f6c63430008140033", - "nonce": "0x7", + "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b60805161241862000119600039600081816107a2015281816107eb015281816108a2015281816108e2015261097501526124186000f3fe6080604052600436106101d75760003560e01c80638da5cb5b11610102578063d131f85511610095578063f2fde38b11610064578063f2fde38b1461060c578063f851a4401461062c578063fd967f471461064d578063ff78c4361461067657600080fd5b8063d131f8551461058e578063e30c3978146105ae578063eeb57de7146105cc578063efdcd974146105ec57600080fd5b8063baf2785d116100d1578063baf2785d1461050e578063bda0b1bf1461052e578063c4d66de81461054e578063c5906c671461056e57600080fd5b80638da5cb5b14610434578063a48efa1c14610466578063af65b179146104c9578063b3f00674146104e957600080fd5b806364a972c71161017a578063762dc30611610149578063762dc306146103b257806379ba5097146103df57806380569670146103f4578063809c30131461041457600080fd5b806364a972c7146102e4578063704b6c021461030457806370db067514610324578063715018a61461039d57600080fd5b80633659cfe6116101b65780633659cfe61461026e578063418ecb561461028e5780634f1ef286146102ae57806352d1902d146102c157600080fd5b8062ee5a76146101dc5780630f8886951461020b578063292630781461024c575b600080fd5b3480156101e857600080fd5b5060fb546101f69060ff1681565b60405190151581526020015b60405180910390f35b34801561021757600080fd5b5061022b610226366004611d65565b6106e7565b6040805182511515815260209283015161ffff169281019290925201610202565b34801561025857600080fd5b5061026c610267366004611ddd565b61073d565b005b34801561027a57600080fd5b5061026c610289366004611e29565b610798565b34801561029a57600080fd5b5061026c6102a9366004611e5c565b610880565b61026c6102bc366004611eaf565b610898565b3480156102cd57600080fd5b506102d6610968565b604051908152602001610202565b3480156102f057600080fd5b5061026c6102ff366004611ddd565b610a1b565b34801561031057600080fd5b5061026c61031f366004611e29565b610a6f565b34801561033057600080fd5b5061034461033f366004611f71565b610ae9565b604051610202919081511515815260208083015161ffff169082015260408083015190820152606080830151908201526080808301516001600160a01b03169082015260a09182015160ff169181019190915260c00190565b3480156103a957600080fd5b5061026c610c16565b3480156103be57600080fd5b506103d26103cd366004611fb5565b610c2f565b6040516102029190611fce565b3480156103eb57600080fd5b5061026c610c49565b34801561040057600080fd5b5061026c61040f366004612029565b610cc0565b34801561042057600080fd5b5061026c61042f366004612046565b610d4e565b34801561044057600080fd5b506097546001600160a01b03165b6040516001600160a01b039091168152602001610202565b34801561047257600080fd5b5061022b610481366004611fb5565b604080518082019091526000808252602082015250600090815260fc602090815260409182902082518084019093525460ff811615158352610100900461ffff169082015290565b3480156104d557600080fd5b5061026c6104e4366004611d65565b610de6565b3480156104f557600080fd5b5060fb5461044e9061010090046001600160a01b031681565b34801561051a57600080fd5b506103d2610529366004611fb5565b610df8565b34801561053a57600080fd5b5061026c610549366004612046565b610e13565b34801561055a57600080fd5b5061026c610569366004611e29565b610ea2565b34801561057a57600080fd5b5061026c610589366004611d65565b610ff2565b34801561059a57600080fd5b5061026c6105a93660046120ed565b611004565b3480156105ba57600080fd5b5060c9546001600160a01b031661044e565b3480156105d857600080fd5b5061026c6105e7366004611e5c565b6110d6565b3480156105f857600080fd5b5061026c610607366004611e29565b6110e9565b34801561061857600080fd5b5061026c610627366004611e29565b6111a9565b34801561063857600080fd5b506101015461044e906001600160a01b031681565b34801561065957600080fd5b5061066361271081565b60405161ffff9091168152602001610202565b34801561068257600080fd5b5061022b610691366004611d65565b6040805180820182526000808252602091820181905293845260fe81528184206001600160a01b039390931684529182529182902082518084019093525460ff811615158352610100900461ffff169082015290565b6040805180820182526000808252602091820181905284815260fd82528281206001600160a01b0385168252825282902082518084019093525460ff811615158352610100900461ffff16908201525b92915050565b61074561121a565b8060005b81811015610791576107818585858481811061076757610767612111565b905060200201602081019061077c9190611e29565b611276565b61078a8161213d565b9050610749565b5050505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107e95760405162461bcd60e51b81526004016107e090612156565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661083260008051602061239c833981519152546001600160a01b031690565b6001600160a01b0316146108585760405162461bcd60e51b81526004016107e0906121a2565b61086181611314565b6040805160008082526020820190925261087d91839190611343565b50565b61088861121a565b6108938383836114ae565b505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036108e05760405162461bcd60e51b81526004016107e090612156565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661092960008051602061239c833981519152546001600160a01b031690565b6001600160a01b03161461094f5760405162461bcd60e51b81526004016107e0906121a2565b61095882611314565b61096482826001611343565b5050565b6000306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614610a085760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c000000000000000060648201526084016107e0565b5060008051602061239c83398151915290565b610a2361121a565b8060005b8181101561079157610a5f85858584818110610a4557610a45612111565b9050602002016020810190610a5a9190611e29565b6115b6565b610a688161213d565b9050610a27565b610a7761121a565b6001600160a01b038116610a9e5760405163b325f76760e01b815260040160405180910390fd5b61010180546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040805160c08101825260008082526020820181905291810182905260a081019190915260fb5461010081046001600160a01b031660808301526060820183905260ff16610b415760015b60ff1660a0820152610c0e565b60fb5461010090046001600160a01b0316610b5d576002610b34565b6000610b6a868686611655565b8051909150610b845760025b60ff1660a083015250610c0e565b610b926127106000196121ee565b831115610ba0576003610b76565b602081015160009061271090610bba9061ffff1686612210565b610bc491906121ee565b905083811115610bdc575050600360a0820152610c0e565b6001835260208083015161ffff169084015260408301819052610bff8185612227565b60608401525050600060a08201525b949350505050565b6040516317d5c96560e11b815260040160405180910390fd5b600081815260ff602052604090206060906107379061176a565b60c95433906001600160a01b03168114610cb75760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084016107e0565b61087d81611777565b610101546001600160a01b03163314801590610ce757506097546001600160a01b03163314155b15610d075760405163fade6b7760e01b81523360048201526024016107e0565b60fb805460ff19168215159081179091556040519081527f4afba55e68999eb85b2692f998f6eea3e471317ba84d323f927b08ee304937dc9060200160405180910390a150565b610d5661121a565b82818114610d7a576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610dcd87878784818110610d9b57610d9b612111565b9050602002016020810190610db09190611e29565b868685818110610dc257610dc2612111565b9050604002016114ae565b610dd68161213d565b9050610d7d565b50505050505050565b610dee61121a565b6109648282611276565b6000818152610100602052604090206060906107379061176a565b610e1b61121a565b82818114610e3f576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610e9287878784818110610e6057610e60612111565b9050602002016020810190610e759190611e29565b868685818110610e8757610e87612111565b905060400201611790565b610e9b8161213d565b9050610e42565b600054610100900460ff1615808015610ec25750600054600160ff909116105b80610edc5750303b158015610edc575060005460ff166001145b610f3f5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016107e0565b6000805460ff191660011790558015610f62576000805461ff0019166101001790555b610f6a611867565b610f72611867565b610f7a611896565b6001600160a01b03821615801590610f9b57506001600160a01b0382163314155b15610fa957610fa982611777565b8015610964576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610ffa61121a565b61096482826115b6565b61100c61121a565b61271061101f604083016020840161224a565b61ffff16111561105957611039604082016020830161224a565b60405163e476e19360e01b815261ffff90911660048201526024016107e0565b600082815260fc6020526040902081906110738282612267565b508290507f9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e46110a56020840184612029565b6110b5604085016020860161224a565b60408051921515835261ffff90911660208301520160405180910390a25050565b6110de61121a565b610893838383611790565b610101546001600160a01b0316331480159061111057506097546001600160a01b03163314155b156111305760405163fade6b7760e01b81523360048201526024016107e0565b6001600160a01b03811661115757604051630665334760e11b815260040160405180910390fd5b60fb8054610100600160a81b0319166101006001600160a01b038416908102919091179091556040517fbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d90600090a250565b6111b161121a565b60c980546001600160a01b0383166001600160a01b031990911681179091556111e26097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b6097546001600160a01b031633146112745760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107e0565b565b6001600160a01b03811661129d5760405163862f9ab160e01b815260040160405180910390fd5b600082815260ff602052604090206112b590826118bd565b1561096457600082815260fd602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917faafabd0077e7cf783725deb87142797a6a87e42f2afff85325d1b6912eb71d419190a35050565b61131c61121a565b6001600160a01b03811661087d5760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff161561137657610893836118d2565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa9250505080156113d0575060408051601f3d908101601f191682019092526113cd918101906122ae565b60015b6114335760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b60648201526084016107e0565b60008051602061239c83398151915281146114a25760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b60648201526084016107e0565b5061089383838361196e565b6001600160a01b0382166114d55760405163862f9ab160e01b815260040160405180910390fd5b6127106114e8604083016020840161224a565b61ffff16111561150257611039604082016020830161224a565b600083815260fd602090815260408083206001600160a01b0386168452909152902081906115308282612267565b5050600083815260ff6020526040902061154a9083611999565b506001600160a01b038216837f48d8c51d6d2a265f877d8d087866ac5804efe2d6770ba1dd2d196eeb23630c906115846020850185612029565b611594604086016020870161224a565b60408051921515835261ffff90911660208301520160405180910390a3505050565b6001600160a01b0381166115dd5760405163069018bf60e11b815260040160405180910390fd5b6000828152610100602052604090206115f690826118bd565b1561096457600082815260fe602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917f8a7413f0bcdbe90171aa0fcf0ebeb97f0c2dcee8e972861cb6b54a412041c82c9190a35050565b6040805180820190915260008082526020820152600084815260fc602090815260409182902082518084019093525460ff8116151580845261010090910461ffff16918301919091526116a9579050611763565b600085815260fe602090815260408083206001600160a01b038716845282529182902082518084019093525460ff8116158015845261010090910461ffff16918301919091526116fc5791506117639050565b6001600160a01b0385161561175f5750600085815260fd602090815260408083206001600160a01b038816845282529182902082518084019093525460ff8116158015845261010090910461ffff169183019190915261175f5791506117639050565b5090505b9392505050565b60606000611763836119ae565b60c980546001600160a01b031916905561087d81611a0a565b6001600160a01b0382166117b75760405163069018bf60e11b815260040160405180910390fd5b6127106117ca604083016020840161224a565b61ffff1611156117e457611039604082016020830161224a565b600083815260fe602090815260408083206001600160a01b0386168452909152902081906118128282612267565b505060008381526101006020526040902061182d9083611999565b506001600160a01b038216837fb5938f0c738d351d053db88baaeda5f2b265cd07e3b930e9182ebebd5f701d476115846020850185612029565b600054610100900460ff1661188e5760405162461bcd60e51b81526004016107e0906122c7565b611274611a5c565b600054610100900460ff166112745760405162461bcd60e51b81526004016107e0906122c7565b6000611763836001600160a01b038416611a8c565b6001600160a01b0381163b61193f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016107e0565b60008051602061239c83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61197783611b7f565b6000825111806119845750805b15610893576119938383611bbf565b50505050565b6000611763836001600160a01b038416611be4565b6060816000018054806020026020016040519081016040528092919081815260200182805480156119fe57602002820191906000526020600020905b8154815260200190600101908083116119ea575b50505050509050919050565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff16611a835760405162461bcd60e51b81526004016107e0906122c7565b61127433611777565b60008181526001830160205260408120548015611b75576000611ab0600183612227565b8554909150600090611ac490600190612227565b9050818114611b29576000866000018281548110611ae457611ae4612111565b9060005260206000200154905080876000018481548110611b0757611b07612111565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080611b3a57611b3a612312565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610737565b6000915050610737565b611b88816118d2565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061176383836040518060600160405280602781526020016123bc60279139611c33565b6000818152600183016020526040812054611c2b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610737565b506000610737565b6060600080856001600160a01b031685604051611c50919061234c565b600060405180830381855af49150503d8060008114611c8b576040519150601f19603f3d011682016040523d82523d6000602084013e611c90565b606091505b5091509150611ca186838387611cab565b9695505050505050565b60608315611d1a578251600003611d13576001600160a01b0385163b611d135760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e747261637400000060448201526064016107e0565b5081610c0e565b610c0e8383815115611d2f5781518083602001fd5b8060405162461bcd60e51b81526004016107e09190612368565b80356001600160a01b0381168114611d6057600080fd5b919050565b60008060408385031215611d7857600080fd5b82359150611d8860208401611d49565b90509250929050565b60008083601f840112611da357600080fd5b50813567ffffffffffffffff811115611dbb57600080fd5b6020830191508360208260051b8501011115611dd657600080fd5b9250929050565b600080600060408486031215611df257600080fd5b83359250602084013567ffffffffffffffff811115611e1057600080fd5b611e1c86828701611d91565b9497909650939450505050565b600060208284031215611e3b57600080fd5b61176382611d49565b600060408284031215611e5657600080fd5b50919050565b600080600060808486031215611e7157600080fd5b83359250611e8160208501611d49565b9150611e908560408601611e44565b90509250925092565b634e487b7160e01b600052604160045260246000fd5b60008060408385031215611ec257600080fd5b611ecb83611d49565b9150602083013567ffffffffffffffff80821115611ee857600080fd5b818501915085601f830112611efc57600080fd5b813581811115611f0e57611f0e611e99565b604051601f8201601f19908116603f01168101908382118183101715611f3657611f36611e99565b81604052828152886020848701011115611f4f57600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b60008060008060808587031215611f8757600080fd5b84359350611f9760208601611d49565b9250611fa560408601611d49565b9396929550929360600135925050565b600060208284031215611fc757600080fd5b5035919050565b6020808252825182820181905260009190848201906040850190845b8181101561200f5783516001600160a01b031683529284019291840191600101611fea565b50909695505050505050565b801515811461087d57600080fd5b60006020828403121561203b57600080fd5b81356117638161201b565b60008060008060006060868803121561205e57600080fd5b85359450602086013567ffffffffffffffff8082111561207d57600080fd5b61208989838a01611d91565b909650945060408801359150808211156120a257600080fd5b818801915088601f8301126120b657600080fd5b8135818111156120c557600080fd5b8960208260061b85010111156120da57600080fd5b9699959850939650602001949392505050565b6000806060838503121561210057600080fd5b82359150611d888460208501611e44565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b60006001820161214f5761214f612127565b5060010190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b60008261220b57634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761073757610737612127565b8181038181111561073757610737612127565b61ffff8116811461087d57600080fd5b60006020828403121561225c57600080fd5b81356117638161223a565b81356122728161201b565b815460ff19811691151560ff16918217835560208401356122928161223a565b62ffff008160081b168362ffffff198416171784555050505050565b6000602082840312156122c057600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b634e487b7160e01b600052603160045260246000fd5b60005b8381101561234357818101518382015260200161232b565b50506000910152565b6000825161235e818460208701612328565b9190910192915050565b6020815260008251806020840152612387816040850160208701612328565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a2646970667358221220da9ed0469f75959621b7a20bdba0cd03e50cb0089e2b7c8010bf46a16479d97764736f6c63430008140033", + "nonce": "0x23", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", + "hash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", "transactionType": "CREATE", "contractName": "ERC1967Proxy", - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": null, "arguments": [ - "0x33EF630510Ba4d5e13Cfc3a49Ad35BEf5c9c2604", + "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d", "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "gas": "0x3c5b8", "value": "0x0", - "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c656400000000000000000000000033ef630510ba4d5e13cfc3a49ad35bef5c9c260400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", - "nonce": "0x8", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c656400000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x24", "chainId": "0x1e" }, "additionalContracts": [], @@ -43,122 +43,122 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0x1ffc8d", + "cumulativeGasUsed": "0x20f9c8", "logs": [ { - "address": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604", + "address": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", - "blockHash": "0xcccca4d450248072417853097c3b0acd7be96aa9b34b4dc75791f0963809814a", - "blockNumber": "0x8b8e14", - "transactionHash": "0x1b03d0897c0141c2741423c956d0e0e19ee075e4e1fa23249e58fdcad633f21e", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", "transactionIndex": "0x1", - "logIndex": "0x1", + "logIndex": "0x2", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000080000000000000000000000000000000000000000000000400200000000000000000000000000000000000000000200000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000008000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x1b03d0897c0141c2741423c956d0e0e19ee075e4e1fa23249e58fdcad633f21e", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", "transactionIndex": "0x1", - "blockHash": "0xcccca4d450248072417853097c3b0acd7be96aa9b34b4dc75791f0963809814a", - "blockNumber": "0x8b8e14", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", "gasUsed": "0x1fa157", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604" + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d" }, { "status": "0x1", - "cumulativeGasUsed": "0x55ef0", + "cumulativeGasUsed": "0x5fa9b", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", - "0x00000000000000000000000033ef630510ba4d5e13cfc3a49ad35bef5c9c2604" + "0x00000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d" ], "data": "0x", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x4", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false }, { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x5", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x2", "removed": false }, { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x6", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x3", "removed": false }, { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x7", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x4", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000400000000000000200804000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000002000001080000000000000000000000000000000000020000000000000000000800000000000000000040000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001000000000000000000000000000000000000000020000000", + "logsBloom": "0x00000000000000000000000000000100400000000000000000800000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000002000001000000000000000000000000000000100000024000000000000000000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001001000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", "gasUsed": "0x32acb", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563" + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e" } ], "libraries": [], "pending": [], "returns": { - "proxy": { + "impl": { "internal_type": "address", - "value": "0x8C1abf364Bf214E41221562693BD9Fb26D6Fa563" + "value": "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d" }, - "impl": { + "proxy": { "internal_type": "address", - "value": "0x33EF630510Ba4d5e13Cfc3a49Ad35BEf5c9c2604" + "value": "0x99994b4522483DE17F31a5bC010c5901AdD3440E" } }, - "timestamp": 1786568797047, + "timestamp": 1787577343971, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json b/broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json new file mode 100644 index 0000000..e72ed44 --- /dev/null +++ b/broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json @@ -0,0 +1,434 @@ +{ + "transactions": [ + { + "hash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setFeeReceiver(address)", + "arguments": [ + "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x1257e", + "value": "0x0", + "input": "0xefdcd974000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b", + "nonce": "0x25", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x12379", + "value": "0x0", + "input": "0xd131f855d4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x26", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x134b0", + "value": "0x0", + "input": "0xd131f855fa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b0180000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x27", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x134b0", + "value": "0x0", + "input": "0xd131f855fb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb50000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x28", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x134b0", + "value": "0x0", + "input": "0xd131f85544224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x29", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2", + "(false, 0)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0xb809", + "value": "0x0", + "input": "0xd131f855785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x2a", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setAdmin(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x119a9", + "value": "0x0", + "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x2b", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "transferOwnership(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x11c43", + "value": "0x0", + "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x2c", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x26d5b", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0xbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d", + "0x000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b" + ], + "data": "0x", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "logIndex": "0x4", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000400000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000004000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000400000008000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", + "gasUsed": "0xaf61", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x1465c", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040100000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "gasUsed": "0xc87a", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x47275", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "logIndex": "0x4", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000008000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", + "gasUsed": "0xc886", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x220d3", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", + "gasUsed": "0xc886", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x15d99", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000020000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", + "gasUsed": "0xc886", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x8dd6", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", + "gasUsed": "0x8dd6", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x5b534", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "logIndex": "0xd", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", + "gasUsed": "0xae3c", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x537b0", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "logIndex": "0x5", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", + "gasUsed": "0xb082", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1787578025992, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/04_BootstrapController.s.sol/30/run-latest.json b/broadcast/04_BootstrapController.s.sol/30/run-latest.json index 1d915fb..e72ed44 100644 --- a/broadcast/04_BootstrapController.s.sol/30/run-latest.json +++ b/broadcast/04_BootstrapController.s.sol/30/run-latest.json @@ -1,173 +1,173 @@ { "transactions": [ { - "hash": "0x8413b99eb01cded2788d60bdd5aa45b7fc92ad572bcf4792adb754dc0309500f", + "hash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setFeeReceiver(address)", "arguments": [ - "0x2ba389B021fA4A5F50cc1758EFD23Ca066d0Be08" + "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x1257e", "value": "0x0", - "input": "0xefdcd9740000000000000000000000002ba389b021fa4a5f50cc1758efd23ca066d0be08", - "nonce": "0x9", + "input": "0xefdcd974000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b", + "nonce": "0x25", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x6a5819eac0c56c338c3c2076c82cb70140d94cab3490b5d3ebdc753118a8e636", + "hash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x3d0383a7986bf042db59f806aef31f95d28262f3280554c8541c299fa8e2ffb3", + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", - "gas": "0x134b0", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x12379", "value": "0x0", - "input": "0xd131f8553d0383a7986bf042db59f806aef31f95d28262f3280554c8541c299fa8e2ffb30000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xa", + "input": "0xd131f855d4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x26", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x3a9a4d52e633af19c23335f96d0c8d360384d53024e3b814dfa56d41a1f6e215", + "hash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x5c408ce1df6222b56e2084e292cdc734b880e9adbb4df2331d304431936967f7", + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x134b0", "value": "0x0", - "input": "0xd131f8555c408ce1df6222b56e2084e292cdc734b880e9adbb4df2331d304431936967f70000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xb", + "input": "0xd131f855fa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b0180000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x27", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xe93a002c704f359a387dea9a72396e6091bf20db15f8dc81b59858f855834e15", + "hash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x2e0728b133b5607b74783aaf68be7f195d5ea71afa5bfbd21c35a6776773376d", + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x134b0", "value": "0x0", - "input": "0xd131f8552e0728b133b5607b74783aaf68be7f195d5ea71afa5bfbd21c35a6776773376d0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xc", + "input": "0xd131f855fb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb50000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x28", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xb704bca705c54f9eec867cc33b98713d3f4ed57ee2cad99406a0e9ed1422f6cd", + "hash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0xdd1d6592d9143b113f128998b830887d87bf784969f0bdeda154f2a49ca302e0", + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x134b0", "value": "0x0", - "input": "0xd131f855dd1d6592d9143b113f128998b830887d87bf784969f0bdeda154f2a49ca302e00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xd", + "input": "0xd131f85544224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x29", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xd86788ee8b32203313beea2244fbdad6e55c84a31b5dbb099ff2dd619b9304d9", + "hash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x1605494559c1ed30a971cad4be28853f4641084ed881e4694f503b415dd07f9f", + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2", "(false, 0)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0xb809", "value": "0x0", - "input": "0xd131f8551605494559c1ed30a971cad4be28853f4641084ed881e4694f503b415dd07f9f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "nonce": "0xe", + "input": "0xd131f855785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x2a", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xbe835db4545574e2d2b74618f93af172f5340ee29d7c89ff7ca619de5a7a06bb", + "hash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setAdmin(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x119a9", "value": "0x0", "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0xf", + "nonce": "0x2b", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xf92d489da031439c20346bc8ff2752c088aec18866543b693f1ebd939906a66b", + "hash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "transferOwnership(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x11c43", "value": "0x0", "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x10", + "nonce": "0x2c", "chainId": "0x1e" }, "additionalContracts": [], @@ -177,258 +177,258 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0xaf61", + "cumulativeGasUsed": "0x26d5b", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0xbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d", - "0x0000000000000000000000002ba389b021fa4a5f50cc1758efd23ca066d0be08" + "0x000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b" ], "data": "0x", - "blockHash": "0xb02156cf1eec5d59b57281176caafaa59a19764e6fd6f145fc0a209a8e5382db", - "blockNumber": "0x8b8e30", - "transactionHash": "0x8413b99eb01cded2788d60bdd5aa45b7fc92ad572bcf4792adb754dc0309500f", - "transactionIndex": "0x0", - "logIndex": "0x0", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "logIndex": "0x4", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000040000000000000000000000000000000000000000000000000000000400000000000000000000000000000000200000000000000000000000000000008000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000400000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000004000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000400000008000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x8413b99eb01cded2788d60bdd5aa45b7fc92ad572bcf4792adb754dc0309500f", - "transactionIndex": "0x0", - "blockHash": "0xb02156cf1eec5d59b57281176caafaa59a19764e6fd6f145fc0a209a8e5382db", - "blockNumber": "0x8b8e30", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", "gasUsed": "0xaf61", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0xc886", + "cumulativeGasUsed": "0x1465c", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x3d0383a7986bf042db59f806aef31f95d28262f3280554c8541c299fa8e2ffb3" + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0x38af953639c4095a9ea745088ef6f012753e8af3d037f7a8e486262c3c662a94", - "blockNumber": "0x8b8e32", - "transactionHash": "0x6a5819eac0c56c338c3c2076c82cb70140d94cab3490b5d3ebdc753118a8e636", - "transactionIndex": "0x0", - "logIndex": "0x0", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000002000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000800000000000000000000000000000200000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040100000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x6a5819eac0c56c338c3c2076c82cb70140d94cab3490b5d3ebdc753118a8e636", - "transactionIndex": "0x0", - "blockHash": "0x38af953639c4095a9ea745088ef6f012753e8af3d037f7a8e486262c3c662a94", - "blockNumber": "0x8b8e32", - "gasUsed": "0xc886", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "gasUsed": "0xc87a", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x39438", + "cumulativeGasUsed": "0x47275", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x5c408ce1df6222b56e2084e292cdc734b880e9adbb4df2331d304431936967f7" + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0xbb07fe842cdc5309eaeabb807ad66292612f5790a1cb812dfa4c85e149f55d9c", - "blockNumber": "0x8b8e34", - "transactionHash": "0x3a9a4d52e633af19c23335f96d0c8d360384d53024e3b814dfa56d41a1f6e215", - "transactionIndex": "0x3", - "logIndex": "0x5", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "logIndex": "0x4", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000002000000000002020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000200000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000008000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x3a9a4d52e633af19c23335f96d0c8d360384d53024e3b814dfa56d41a1f6e215", - "transactionIndex": "0x3", - "blockHash": "0xbb07fe842cdc5309eaeabb807ad66292612f5790a1cb812dfa4c85e149f55d9c", - "blockNumber": "0x8b8e34", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", "gasUsed": "0xc886", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x27fd6", + "cumulativeGasUsed": "0x220d3", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x2e0728b133b5607b74783aaf68be7f195d5ea71afa5bfbd21c35a6776773376d" + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0x8e5d97d4df0376ab600dc3050110efb48b1848809799b1d9bb98569866835fb5", - "blockNumber": "0x8b8e35", - "transactionHash": "0xe93a002c704f359a387dea9a72396e6091bf20db15f8dc81b59858f855834e15", - "transactionIndex": "0x2", - "logIndex": "0x4", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "logIndex": "0x2", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004100000000000000000000000000002000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xe93a002c704f359a387dea9a72396e6091bf20db15f8dc81b59858f855834e15", - "transactionIndex": "0x2", - "blockHash": "0x8e5d97d4df0376ab600dc3050110efb48b1848809799b1d9bb98569866835fb5", - "blockNumber": "0x8b8e35", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", "gasUsed": "0xc886", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x44b6c", + "cumulativeGasUsed": "0x15d99", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0xdd1d6592d9143b113f128998b830887d87bf784969f0bdeda154f2a49ca302e0" + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0x063c52e91329cb7ac0e1d60f7887ff6bb566850584fadcc21b2def7b2693620d", - "blockNumber": "0x8b8e36", - "transactionHash": "0xb704bca705c54f9eec867cc33b98713d3f4ed57ee2cad99406a0e9ed1422f6cd", - "transactionIndex": "0x4", - "logIndex": "0x7", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000008000000000000000000002000000000000020000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000020000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xb704bca705c54f9eec867cc33b98713d3f4ed57ee2cad99406a0e9ed1422f6cd", - "transactionIndex": "0x4", - "blockHash": "0x063c52e91329cb7ac0e1d60f7887ff6bb566850584fadcc21b2def7b2693620d", - "blockNumber": "0x8b8e36", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", "gasUsed": "0xc886", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x5e579", + "cumulativeGasUsed": "0x8dd6", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x1605494559c1ed30a971cad4be28853f4641084ed881e4694f503b415dd07f9f" + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2" ], "data": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "blockHash": "0x27489b503a3a03b2fd408cab910dd88352d9c0b0f474b4d6da80a9f97ce36e28", - "blockNumber": "0x8b8e38", - "transactionHash": "0xd86788ee8b32203313beea2244fbdad6e55c84a31b5dbb099ff2dd619b9304d9", - "transactionIndex": "0x5", - "logIndex": "0x6", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004002000000000000000000000000002000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000010000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xd86788ee8b32203313beea2244fbdad6e55c84a31b5dbb099ff2dd619b9304d9", - "transactionIndex": "0x5", - "blockHash": "0x27489b503a3a03b2fd408cab910dd88352d9c0b0f474b4d6da80a9f97ce36e28", - "blockNumber": "0x8b8e38", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", "gasUsed": "0x8dd6", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x7cd3c", + "cumulativeGasUsed": "0x5b534", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x2d4627b50d9673d0ed4586f2745692cb04c971be4df882791f8c930e640c7ad0", - "blockNumber": "0x8b8e3a", - "transactionHash": "0xbe835db4545574e2d2b74618f93af172f5340ee29d7c89ff7ca619de5a7a06bb", - "transactionIndex": "0x7", - "logIndex": "0xa", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "logIndex": "0xd", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080400000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0xbe835db4545574e2d2b74618f93af172f5340ee29d7c89ff7ca619de5a7a06bb", - "transactionIndex": "0x7", - "blockHash": "0x2d4627b50d9673d0ed4586f2745692cb04c971be4df882791f8c930e640c7ad0", - "blockNumber": "0x8b8e3a", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", "gasUsed": "0xae3c", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x4ab29", + "cumulativeGasUsed": "0x537b0", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x51f098c68d03f1103195fde0859816d0bbf8cad153aa1a29de455f5303b0fe15", - "blockNumber": "0x8b8e3b", - "transactionHash": "0xf92d489da031439c20346bc8ff2752c088aec18866543b693f1ebd939906a66b", - "transactionIndex": "0x2", - "logIndex": "0x6", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "logIndex": "0x5", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0xf92d489da031439c20346bc8ff2752c088aec18866543b693f1ebd939906a66b", - "transactionIndex": "0x2", - "blockHash": "0x51f098c68d03f1103195fde0859816d0bbf8cad153aa1a29de455f5303b0fe15", - "blockNumber": "0x8b8e3b", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", "gasUsed": "0xb082", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null } ], "libraries": [], "pending": [], "returns": {}, - "timestamp": 1786570159460, + "timestamp": 1787578025992, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/deployments/30/ExitFeeController.json b/deployments/30/ExitFeeController.json index f3c76a2..c8c238a 100644 --- a/deployments/30/ExitFeeController.json +++ b/deployments/30/ExitFeeController.json @@ -1,13 +1,13 @@ { "contractName": "ExitFeeController", "chainId": 30, - "proxyAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", - "implAddress": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604", - "implBytecodeHash": "0x694d8028a8ec9c45c5bb96a73e43d8d06fe6f0c064c544c8e2fe21932e16f56e", - "deploymentBlock": 9145878, - "deploymentTx": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "timestamp": 1786568797047, - "gitSha": "881c74d", + "proxyAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "implAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", + "implBytecodeHash": "0x114d739562bd85d57815ddee02c575a110258cc9750e1cc56e35195717ca039c", + "deploymentBlock": 9179224, + "deploymentTx": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "timestamp": 1787577343971, + "gitSha": "2c7afb4", "abi": [ { "type": "constructor", @@ -985,7 +985,7 @@ "storageLayout": { "storage": [ { - "astId": 36764, + "astId": 41142, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_initialized", "offset": 0, @@ -993,7 +993,7 @@ "type": "t_uint8" }, { - "astId": 36767, + "astId": 41145, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_initializing", "offset": 1, @@ -1001,7 +1001,7 @@ "type": "t_bool" }, { - "astId": 36746, + "astId": 41124, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1009,7 +1009,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37062, + "astId": 41440, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1017,7 +1017,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37528, + "astId": 41906, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1025,7 +1025,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 36270, + "astId": 40648, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_owner", "offset": 0, @@ -1033,7 +1033,7 @@ "type": "t_address" }, { - "astId": 36390, + "astId": 40768, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1041,7 +1041,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 36163, + "astId": 40541, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_pendingOwner", "offset": 0, @@ -1049,7 +1049,7 @@ "type": "t_address" }, { - "astId": 36257, + "astId": 40635, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1057,7 +1057,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 39780, + "astId": 46123, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "exitFeeEnabled", "offset": 0, @@ -1065,7 +1065,7 @@ "type": "t_bool" }, { - "astId": 39783, + "astId": 46126, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "feeReceiver", "offset": 1, @@ -1073,47 +1073,47 @@ "type": "t_address" }, { - "astId": 39789, + "astId": 46132, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_surfacePolicy", "offset": 0, "slot": "252", - "type": "t_mapping(t_bytes32,t_struct(RatePolicy)41221_storage)" + "type": "t_mapping(t_bytes32,t_struct(RatePolicy)47564_storage)" }, { - "astId": 39797, + "astId": 46140, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_subProductPolicy", "offset": 0, "slot": "253", - "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)41221_storage))" + "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)47564_storage))" }, { - "astId": 39805, + "astId": 46148, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_actorPolicy", "offset": 0, "slot": "254", - "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)41221_storage))" + "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)47564_storage))" }, { - "astId": 39811, + "astId": 46154, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_subProductKeys", "offset": 0, "slot": "255", - "type": "t_mapping(t_bytes32,t_struct(AddressSet)39329_storage)" + "type": "t_mapping(t_bytes32,t_struct(AddressSet)44349_storage)" }, { - "astId": 39816, + "astId": 46159, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_actorKeys", "offset": 0, "slot": "256", - "type": "t_mapping(t_bytes32,t_struct(AddressSet)39329_storage)" + "type": "t_mapping(t_bytes32,t_struct(AddressSet)44349_storage)" }, { - "astId": 39819, + "astId": 46162, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "admin", "offset": 0, @@ -1121,7 +1121,7 @@ "type": "t_address" }, { - "astId": 39823, + "astId": 46166, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1169,33 +1169,33 @@ "label": "bytes32", "numberOfBytes": "32" }, - "t_mapping(t_address,t_struct(RatePolicy)41221_storage)": { + "t_mapping(t_address,t_struct(RatePolicy)47564_storage)": { "encoding": "mapping", "key": "t_address", "label": "mapping(address => struct IExitFeeController.RatePolicy)", "numberOfBytes": "32", - "value": "t_struct(RatePolicy)41221_storage" + "value": "t_struct(RatePolicy)47564_storage" }, - "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)41221_storage))": { + "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)47564_storage))": { "encoding": "mapping", "key": "t_bytes32", "label": "mapping(bytes32 => mapping(address => struct IExitFeeController.RatePolicy))", "numberOfBytes": "32", - "value": "t_mapping(t_address,t_struct(RatePolicy)41221_storage)" + "value": "t_mapping(t_address,t_struct(RatePolicy)47564_storage)" }, - "t_mapping(t_bytes32,t_struct(AddressSet)39329_storage)": { + "t_mapping(t_bytes32,t_struct(AddressSet)44349_storage)": { "encoding": "mapping", "key": "t_bytes32", "label": "mapping(bytes32 => struct EnumerableSet.AddressSet)", "numberOfBytes": "32", - "value": "t_struct(AddressSet)39329_storage" + "value": "t_struct(AddressSet)44349_storage" }, - "t_mapping(t_bytes32,t_struct(RatePolicy)41221_storage)": { + "t_mapping(t_bytes32,t_struct(RatePolicy)47564_storage)": { "encoding": "mapping", "key": "t_bytes32", "label": "mapping(bytes32 => struct IExitFeeController.RatePolicy)", "numberOfBytes": "32", - "value": "t_struct(RatePolicy)41221_storage" + "value": "t_struct(RatePolicy)47564_storage" }, "t_mapping(t_bytes32,t_uint256)": { "encoding": "mapping", @@ -1204,28 +1204,28 @@ "numberOfBytes": "32", "value": "t_uint256" }, - "t_struct(AddressSet)39329_storage": { + "t_struct(AddressSet)44349_storage": { "encoding": "inplace", "label": "struct EnumerableSet.AddressSet", "numberOfBytes": "64", "members": [ { - "astId": 39328, + "astId": 44348, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_inner", "offset": 0, "slot": "0", - "type": "t_struct(Set)39014_storage" + "type": "t_struct(Set)44034_storage" } ] }, - "t_struct(RatePolicy)41221_storage": { + "t_struct(RatePolicy)47564_storage": { "encoding": "inplace", "label": "struct IExitFeeController.RatePolicy", "numberOfBytes": "32", "members": [ { - "astId": 41218, + "astId": 47561, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "active", "offset": 0, @@ -1233,7 +1233,7 @@ "type": "t_bool" }, { - "astId": 41220, + "astId": 47563, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "rateBps", "offset": 1, @@ -1242,13 +1242,13 @@ } ] }, - "t_struct(Set)39014_storage": { + "t_struct(Set)44034_storage": { "encoding": "inplace", "label": "struct EnumerableSet.Set", "numberOfBytes": "64", "members": [ { - "astId": 39009, + "astId": 44029, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_values", "offset": 0, @@ -1256,7 +1256,7 @@ "type": "t_array(t_bytes32)dyn_storage" }, { - "astId": 39013, + "astId": 44033, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_indexes", "offset": 0, diff --git a/deployments/30/ExitFeeVault.json b/deployments/30/ExitFeeVault.json index 51c629f..8a72b16 100644 --- a/deployments/30/ExitFeeVault.json +++ b/deployments/30/ExitFeeVault.json @@ -1,13 +1,13 @@ { "contractName": "ExitFeeVault", "chainId": 30, - "proxyAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", - "implAddress": "0xab3761d0800c4310414e75fd8b545ce094c20e26", - "implBytecodeHash": "0xcd2b99395b1146da04dd1cdcc66973cfbb232200a7d49ff68128724d7c217aa0", - "deploymentBlock": 9145756, - "deploymentTx": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "timestamp": 1786565025004, - "gitSha": "881c74d", + "proxyAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "implAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", + "implBytecodeHash": "0x270528aa2181e4eb17c3ee850bdab3a5f7a13b68aebf2238c1cdfa58675a0c69", + "deploymentBlock": 9179117, + "deploymentTx": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "timestamp": 1787574212713, + "gitSha": "2c7afb4", "abi": [ { "type": "constructor", @@ -474,7 +474,7 @@ "storageLayout": { "storage": [ { - "astId": 36764, + "astId": 41142, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_initialized", "offset": 0, @@ -482,7 +482,7 @@ "type": "t_uint8" }, { - "astId": 36767, + "astId": 41145, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_initializing", "offset": 1, @@ -490,7 +490,7 @@ "type": "t_bool" }, { - "astId": 36746, + "astId": 41124, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -498,7 +498,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37062, + "astId": 41440, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -506,7 +506,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37528, + "astId": 41906, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -514,7 +514,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 36270, + "astId": 40648, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_owner", "offset": 0, @@ -522,7 +522,7 @@ "type": "t_address" }, { - "astId": 36390, + "astId": 40768, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -530,7 +530,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 36163, + "astId": 40541, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_pendingOwner", "offset": 0, @@ -538,7 +538,7 @@ "type": "t_address" }, { - "astId": 36257, + "astId": 40635, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -546,7 +546,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 37078, + "astId": 41456, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_status", "offset": 0, @@ -554,7 +554,7 @@ "type": "t_uint256" }, { - "astId": 37147, + "astId": 41525, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -562,7 +562,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 40842, + "astId": 47185, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "defaultRecipient", "offset": 0, @@ -570,7 +570,7 @@ "type": "t_address_payable" }, { - "astId": 40845, + "astId": 47188, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "admin", "offset": 0, @@ -578,7 +578,7 @@ "type": "t_address" }, { - "astId": 40849, + "astId": 47192, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, diff --git a/foundry.toml b/foundry.toml index 80679fb..6ba579a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -29,6 +29,20 @@ fs_permissions = [ # the actual emitted warning code, and add it below. Do not guess the value. # ignored_error_codes = [] +[profile.default.invariant] +# Bounded so the stateful ExitDelayQueue suite (15 handler selectors, per-step +# index-consistency assertions that page the active sets) completes in CI time. +# Raise runs/depth for a deeper campaign; the defaults (256×500) are too heavy +# for the O(n) index paging on every step. +runs = 64 +depth = 60 +# the handlers try/catch every EXPECTED queue revert and the non-queue +# ops (token/native transfers off a huge pre-funded balance, force-sends, +# vm.warp) are revert-free by construction, so the suite is revert-free and we +# assert it. The prior `(aSeed+1)%3` receiver index was fixed to `(aSeed%3+1)%3` +# (no more 0x11 overflow-panic at a max seed) so nothing leaks past the catches. +fail_on_revert = true + [profile.default.fmt] line_length = 110 diff --git a/script/04_BootstrapController.s.sol b/script/04_BootstrapController.s.sol index cd619ef..1a597b9 100644 --- a/script/04_BootstrapController.s.sol +++ b/script/04_BootstrapController.s.sol @@ -36,17 +36,17 @@ import {IExitFeeController} from "../src/interfaces/IExitFeeController.sol"; /// export EXIT_FEE_CONTROLLER_ADMIN=0x... # final owner /// export EXIT_FEE_OPERATIONAL_ADMIN=0x... # operational admin role /// export EXIT_FEE_VAULT_PROXY=0x... # fee receiver -/// export COLFEE_LENDING_LENDER_BPS=10 -/// export COLFEE_LENDING_BORROWER_BPS=10 -/// export COLFEE_ZERO_WITHDRAW_COLL_BPS=10 -/// export COLFEE_ZERO_CLAIM_SURPLUS_BPS=10 -/// export COLFEE_ENABLE_AT_DEPLOY=false # mainnet: false +/// export PERIMETER_LENDING_LENDER_BPS=10 +/// export PERIMETER_LENDING_BORROWER_BPS=10 +/// export PERIMETER_ZERO_WITHDRAW_COLL_BPS=10 +/// export PERIMETER_ZERO_CLAIM_SURPLUS_BPS=10 +/// export PERIMETER_ENABLE_AT_DEPLOY=false # mainnet: false /// /// forge script script/04_BootstrapController.s.sol \ /// --rpc-url $RSK_RPC --broadcast --account deployer \ /// --sig "run(uint256)" /// -/// Surface IDs are derived as `keccak256("COLFEE:")` per +/// Surface IDs are derived as `keccak256("")` per /// `docs/SURFACE_REGISTRY.md`. /// /// ALL five registered surfaces are written here — the deploy @@ -90,11 +90,11 @@ contract BootstrapController is Script { // so a forgotten value stops the deploy instead of shipping a zero. address vaultProxy = vm.envAddress("EXIT_FEE_VAULT_PROXY"); require(vaultProxy != address(0), "EXIT_FEE_VAULT_PROXY must be set"); - uint256 lenderBps = vm.envUint("COLFEE_LENDING_LENDER_BPS"); - uint256 borrowerBps = vm.envUint("COLFEE_LENDING_BORROWER_BPS"); - uint256 zeroBps = vm.envUint("COLFEE_ZERO_WITHDRAW_COLL_BPS"); - uint256 surplusBps = vm.envUint("COLFEE_ZERO_CLAIM_SURPLUS_BPS"); - bool enableNow = vm.envBool("COLFEE_ENABLE_AT_DEPLOY"); + uint256 lenderBps = vm.envUint("PERIMETER_LENDING_LENDER_BPS"); + uint256 borrowerBps = vm.envUint("PERIMETER_LENDING_BORROWER_BPS"); + uint256 zeroBps = vm.envUint("PERIMETER_ZERO_WITHDRAW_COLL_BPS"); + uint256 surplusBps = vm.envUint("PERIMETER_ZERO_CLAIM_SURPLUS_BPS"); + bool enableNow = vm.envBool("PERIMETER_ENABLE_AT_DEPLOY"); console2.log("ExitFeeController @", proxy); console2.log(" chainId: ", chainId); @@ -113,20 +113,20 @@ contract BootstrapController is Script { // Writing every surface leaves the deploy transaction as a complete // statement of intent: no surface can be silently forgotten, and // "absent" is never mistaken for "deliberate". - _setSurface(controller, "SURFACE_LENDING_LENDER_WITHDRAW", lenderBps, true); - _setSurface(controller, "SURFACE_LENDING_BORROWER_WITHDRAW", borrowerBps, true); - _setSurface(controller, "SURFACE_ZERO_WITHDRAW_COLL", zeroBps, true); - _setSurface(controller, "SURFACE_ZERO_CLAIM_SURPLUS", surplusBps, true); + _setSurface(controller, "PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW", lenderBps, true); + _setSurface(controller, "PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW", borrowerBps, true); + _setSurface(controller, "PERIMETER_SURFACE_ZERO_WITHDRAW_COLL", zeroBps, true); + _setSurface(controller, "PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS", surplusBps, true); // AMM has no consumer in this release: written explicitly OFF so the // deploy log carries the decision rather than an absence. - _setSurface(controller, "SURFACE_AMM_REMOVE_LIQUIDITY", 0, false); + _setSurface(controller, "PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY", 0, false); // ─── 7. Global switch ──────────────────────────────────────────── if (enableNow) { controller.setExitFeeEnabled(true); console2.log("setExitFeeEnabled: true"); } else { - console2.log("setExitFeeEnabled: NOT enabled (COLFEE_ENABLE_AT_DEPLOY=false)"); + console2.log("setExitFeeEnabled: NOT enabled (PERIMETER_ENABLE_AT_DEPLOY=false)"); } // ─── 8. Appoint operational admin (BEFORE the handoff) ─────────── @@ -155,7 +155,7 @@ contract BootstrapController is Script { /// whether anything is charged. function _setSurface(ExitFeeController c, string memory name, uint256 rateBps, bool active) internal { require(rateBps <= MAX_BPS, "rateBps > MAX_BPS"); - bytes32 id = keccak256(abi.encodePacked("COLFEE:", name)); + bytes32 id = keccak256(bytes(name)); c.setSurfacePolicy(id, IExitFeeController.RatePolicy({active: active, rateBps: uint16(rateBps)})); console2.log( string.concat("setSurfacePolicy ", name, active ? " (active)" : " (OFF - no consumer)"), rateBps diff --git a/script/05_DeployQueueAndWire.s.sol b/script/05_DeployQueueAndWire.s.sol new file mode 100644 index 0000000..83d830c --- /dev/null +++ b/script/05_DeployQueueAndWire.s.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../src/interfaces/IExitDelayQueueHost.sol"; + +/// @title Deploy ExitDelayQueue + wire the product hosts (activation step 1) +/// @notice activation ordering STEP 1: deploy the `ExitDelayQueue` impl + +/// ERC1967Proxy alongside the already-deployed `ExitFeeController` (from +/// `03_DeployController`), then WIRE the queue pointer into each product +/// host via `setExitDelayQueue`: the `sovrynProtocol` +/// singleton (lending + loan/margin) and the Zero `BorrowerOperations` +/// proxy. +/// +/// IMPORTANT — this script performs ONLY the deploy + host-wire (step 1). +/// It DELIBERATELY does NOT configure the security-critical controller +/// state (`setAdmin`, `setGlobalDelaySeconds`) — those are activation steps 4–5 +/// OWNER actions, executed as a SIP / Safe tx, NOT by this deployer script +/// (roles-not-actors: the deployer must not silently hold guardian/delay +/// authority). It also does NOT enable the perimeter (step 8). +/// +/// The go-live GATES — (`controller.admin == queue.admin`) +/// and (`controller.globalDelaySeconds >= queue.minimumDelaySeconds`) +/// — are NOT asserted here. They CANNOT be: this script runs at step 1, +/// BEFORE the Owner has configured the controller's admin + global delay +/// (steps 4–5), so `controller.admin() == 0` and `globalDelaySeconds() == 0` +/// at this point. A correctly-ordered first deploy would therefore ALWAYS +/// revert if the assertions lived here (the ordering constraint below). +/// Instead they live in the dedicated READ-ONLY `06_VerifyActivation.s.sol` +/// verify script, run LAST as the step-7 go-live gate — AFTER the Owner +/// has configured admin + globalDelaySeconds. +/// +/// The controller never reads or calls the queue at RUNTIME (kill-switch +/// queue-independence). The host wiring here is the ONLY +/// on-chain coupling and it is one-directional (host → queue pointer). +/// +/// @dev Usage: +/// +/// export EXIT_DELAY_QUEUE_OWNER=0x... # queue Owner (governance Safe / timelock) +/// export EXIT_DELAY_QUEUE_ADMIN=0x... # queue Admin (must != Owner; == controller.admin, set later) +/// export WRBTC_ADDRESS=0x... # canonical wrapped-RBTC ERC20 +/// export QUEUE_MIN_DELAY_SECONDS=3600 # per-request delay floor +/// export QUEUE_ALLOWED_SOURCES=0xA,0xB # comma-separated hooked sources (optional; activation step 2) +/// # Product hosts to wire (activation step 1). BOTH are REQUIRED non-zero UNLESS you +/// # set DEFER_HOSTS=true. A missing/misspelled host env var must ABORT the deploy +/// # (C2, no silent address(0) no-op wire) — deferral must be EXPLICIT, never +/// # implicit-via-blank. When DEFER_HOSTS=true, an unset/zero host is skipped so +/// # its wiring can be moved to a later per-host Owner SIP/Safe tx. +/// export SOVRYN_PROTOCOL_HOST=0x... # sovrynProtocol singleton (lending + loan/margin) +/// export ZERO_BORROWER_OPERATIONS_HOST=0x... # Zero BorrowerOperations proxy +/// export DEFER_HOSTS=true # OPTIONAL explicit opt-in to defer a zero host +/// +/// forge script script/05_DeployQueueAndWire.s.sol \ +/// --rpc-url $RSK_RPC --broadcast --account deployer \ +/// --sig "run(uint256)" +/// +/// Reads `deployments//ExitFeeController.json` for the controller proxy. +/// After this script: +/// 1. tools/finalize-deployment.sh ExitDelayQueue 05_DeployQueueAndWire +/// 2. activation steps 2–6 (allowed sources, routes, setGlobalDelaySeconds, +/// setAdmin×2, bypass) — Owner SIP / Safe txs. +/// 3. `06_VerifyActivation.s.sol` — the step-7 read-only go-live gate. +/// 4. controller.setSecurityPerimeterEnabled(true) — step 8. +contract DeployQueueAndWire is Script { + using stdJson for string; + + /// @dev Validated deploy configuration, parsed from env by `_readConfig`. Held + /// in a struct so `run()` keeps only ONE live local across the broadcast + /// (avoids a stack-too-deep on the non-viaIR Paris build). + struct DeployConfig { + address queueOwner; + address queueAdmin; + address wrbtc; + uint32 minDelay; + address[] allowedSources; + address sovrynHost; + address zeroHost; + } + + /// @param chainId the target chain (selects the deployments artifact dir) + /// @return queueProxy the deployed ExitDelayQueue proxy + /// @return queueImpl the deployed ExitDelayQueue implementation + function run(uint256 chainId) external returns (address queueProxy, address queueImpl) { + ExitFeeController controller = _controller(chainId); + DeployConfig memory cfg = _readConfig(); + + console2.log("ExitFeeController @", address(controller)); + console2.log(" controller.admin (pre-config, expect 0):", controller.admin()); + console2.log( + " controller.globalDelaySeconds (pre-config, expect 0):", + uint256(controller.globalDelaySeconds()) + ); + console2.log(" queue owner: ", cfg.queueOwner); + console2.log(" queue admin: ", cfg.queueAdmin); + console2.log(" queue minimumDelaySeconds: ", uint256(cfg.minDelay)); + console2.log(" sovrynProtocol host: ", cfg.sovrynHost); + console2.log(" Zero BorrowerOperations host: ", cfg.zeroHost); + console2.log(""); + + uint256 wiredCount; + vm.startBroadcast(); + + // ── Deploy the queue behind its proxy (initialize sets owner/admin/floor). ── + queueImpl = address(new ExitDelayQueue()); + queueProxy = address( + new ERC1967Proxy( + queueImpl, + abi.encodeCall( + ExitDelayQueue.initialize, + (cfg.queueOwner, cfg.queueAdmin, cfg.wrbtc, cfg.minDelay, cfg.allowedSources) + ) + ) + ); + + // ── WIRE the queue pointer into each supplied product host. ── + // setExitDelayQueue is host-Owner/Timelock-gated; the broadcast wallet + // must hold that authority for the wire to land (else the host reverts). + // A zero host is skipped (only reachable under DEFER_HOSTS=true — the + // require()s in _readConfig already aborted a blank host otherwise). + wiredCount = wireHosts(queueProxy, cfg.sovrynHost, cfg.zeroHost); + + vm.stopBroadcast(); + + console2.log("ExitDelayQueue impl deployed at: ", queueImpl); + console2.log("ExitDelayQueue proxy deployed at: ", queueProxy); + console2.log(""); + // (C2) The "hosts wired" success line prints ONLY when a host was actually + // wired — a deferred-all deploy says so explicitly instead of claiming + // a wire that never happened. + if (wiredCount > 0) { + console2.log( + unicode"OK: queue deployed + hosts wired (activation step 1). hosts wired:", wiredCount + ); + } else { + console2.log( + unicode"OK: queue deployed, NO hosts wired (DEFER_HOSTS) — wire each host via a later Owner SIP (activation step 1)." + ); + } + console2.log(unicode"NOTE: admin + globalDelaySeconds are Owner steps 4-5 (NOT set here)."); + console2.log(unicode"Verify with 06_VerifyActivation.s.sol (step-7 gate) AFTER steps 4-5,"); + console2.log(unicode"then controller.setSecurityPerimeterEnabled(true) (step 8)."); + console2.log("Next: tools/finalize-deployment.sh ExitDelayQueue 05_DeployQueueAndWire "); + } + + /// @dev Locate the already-deployed controller from its deployment artifact. + function _controller(uint256 chainId) internal view returns (ExitFeeController) { + string memory ctrlArtifact = + vm.readFile(string.concat("deployments/", vm.toString(chainId), "/ExitFeeController.json")); + return ExitFeeController(ctrlArtifact.readAddress(".proxyAddress")); + } + + /// @dev Parse + VALIDATE the deploy config from env (C1/C2: no silent + /// address(0) defaults). A missing/misspelled critical env var ABORTS the + /// deploy here rather than defaulting to address(0) on-chain. In particular + /// EXIT_DELAY_QUEUE_OWNER is required (C1): ExitDelayQueue.initialize + /// resolves a zero `owner_` to msg.sender, which would silently leave the + /// deployer EOA holding UUPS/upgrade/source-registry/recovery/sweep + /// authority. Product hosts are REQUIRED non-zero unless DEFER_HOSTS=true + /// (C2: a blank host must ABORT, never no-op-wire a fail-open surface; + /// deferral must be EXPLICIT). + function _readConfig() internal view returns (DeployConfig memory cfg) { + cfg.queueOwner = vm.envAddress("EXIT_DELAY_QUEUE_OWNER"); + cfg.queueAdmin = vm.envAddress("EXIT_DELAY_QUEUE_ADMIN"); + cfg.wrbtc = vm.envAddress("WRBTC_ADDRESS"); + uint256 minDelay = vm.envUint("QUEUE_MIN_DELAY_SECONDS"); + require(minDelay <= type(uint32).max, "05: QUEUE_MIN_DELAY_SECONDS overflows uint32"); + cfg.minDelay = uint32(minDelay); + + cfg.allowedSources = vm.envOr("QUEUE_ALLOWED_SOURCES", ",", new address[](0)); + + cfg.sovrynHost = vm.envOr("SOVRYN_PROTOCOL_HOST", address(0)); + cfg.zeroHost = vm.envOr("ZERO_BORROWER_OPERATIONS_HOST", address(0)); + + // The address/host safety checks are factored into a PURE validator so a + // unit test can drive every abort branch on an in-memory struct — NO + // vm.setEnv (which mutates process-global env forge does not isolate + // between parallel test functions, a genuine race across env-driven tests). + validateConfig(cfg, vm.envOr("DEFER_HOSTS", false)); + } + + /// @notice Validate the deploy config (C1/C2 no-silent-blanks safety). Reverts + /// with a DISTINCT message per missing/misspelled critical input; + /// returns silently when the config is deployable. Pure so it is + /// driveable in a unit test with an in-memory struct, race-free. + /// @param cfg the parsed config + /// @param deferHosts the explicit DEFER_HOSTS opt-in (true ⇒ a zero host is + /// an intentional deferral, not an error) + function validateConfig(DeployConfig memory cfg, bool deferHosts) public pure { + // C1 — no silent address(0) for the security-critical queue inputs. A zero + // owner would let ExitDelayQueue.initialize resolve owner_ to msg.sender, + // silently leaving the deployer EOA with queue authority — so abort loud. + require( + cfg.queueOwner != address(0), + "05: EXIT_DELAY_QUEUE_OWNER must be set (C1: zero owner => deployer EOA holds queue authority)" + ); + require(cfg.queueAdmin != address(0), "05: EXIT_DELAY_QUEUE_ADMIN must be set"); + // No admin-vs-owner separation is enforced: the launch shape has the + // governance Safe holding both roles, and the authority split becomes + // meaningful only once ownership later moves while the admin stays put. + require(cfg.wrbtc != address(0), "05: WRBTC_ADDRESS must be set"); + + // C2 — a blank host must ABORT (never no-op-wire a fail-open zero-delay + // surface) UNLESS deferral is EXPLICIT via DEFER_HOSTS=true. + if (!deferHosts) { + require( + cfg.sovrynHost != address(0), + "05: SOVRYN_PROTOCOL_HOST must be set (or set DEFER_HOSTS=true to defer)" + ); + require( + cfg.zeroHost != address(0), + "05: ZERO_BORROWER_OPERATIONS_HOST must be set (or set DEFER_HOSTS=true to defer)" + ); + } + + // (C2 duplicate-host guard) A non-zero duplicate pair (both spec-named host + // vars pointing at the SAME address — a copy-paste footgun) would wire one + // surface twice and leave the OTHER silently unwired at zero-delay. Reject + // it. The `== address(0)` clause preserves the both-zero DEFER_HOSTS path. + require( + cfg.sovrynHost != cfg.zeroHost || cfg.sovrynHost == address(0), + "05: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)" + ); + } + + /// @notice Wire the queue pointer into the two product hosts, + /// skipping any zero host (wiring deferred to a per-host SIP/Safe tx). + /// Public so a unit test can drive the wire step directly WITHOUT the + /// env-parsing broadcast path (whose `vm.setEnv` mutates process-global + /// env that forge does not isolate between parallel test functions). + /// Must be called from a context authorized to set each host pointer. + /// @return wiredCount how many hosts were actually wired (a zero host is not + /// counted) — drives the conditional "hosts wired" success line (C2). + function wireHosts(address queue, address sovrynHost, address zeroHost) + public + returns (uint256 wiredCount) + { + if (_wireHost(sovrynHost, queue, "sovrynProtocol")) wiredCount++; + if (_wireHost(zeroHost, queue, "Zero BorrowerOperations")) wiredCount++; + } + + /// @dev Wire one product host's queue pointer, verifying the write took. + /// A zero host is a no-op (wiring deferred to a later per-host SIP/Safe tx). + /// @return wired true iff the host was non-zero and its pointer was written. + function _wireHost(address host, address queue, string memory label) internal returns (bool wired) { + if (host == address(0)) { + console2.log(string.concat(" wire SKIPPED (host unset): "), label); + return false; + } + IExitDelayQueueHost(host).setExitDelayQueue(queue); + require( + IExitDelayQueueHost(host).exitDelayQueue() == queue, "05: setExitDelayQueue did not take on host" + ); + console2.log(string.concat(" wired queue -> host: "), label); + return true; + } +} diff --git a/script/06_VerifyActivation.s.sol b/script/06_VerifyActivation.s.sol new file mode 100644 index 0000000..7388629 --- /dev/null +++ b/script/06_VerifyActivation.s.sol @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +import {ExitFeeController} from "../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../src/interfaces/IExitDelayQueueHost.sol"; + +/// @title Verify Activation — activation step-7 go-live gate (C1/C2) +/// @notice READ-ONLY. Run LAST, AFTER the Owner has configured the controller's +/// `admin` + `globalDelaySeconds` (activation steps 4–5), the guardian on both +/// contracts (step 5), the allowed-sources + host wiring (steps 1–2), and +/// BEFORE `setSecurityPerimeterEnabled(true)` (step 8): a fat-fingered +/// security-contract deploy must fail LOUD here rather than silently ship +/// fail-open (unwired host = zero-delay direct-pay) or fail-closed +/// (mis-owned / wired-but-not-allowed = bricked). +/// +/// SCOPE (honest): this gate verifies the two +/// STORAGE HOSTS are wired + allowed-source, plus guardian/floor/ownership. +/// It does NOT enumerate the actual record CALLERS (iToken proxies for +/// lender burn/burnToBTC; the ActivePool native pusher) — those live in the +/// separate step-2 source SIP (QUEUE_ALLOWED_SOURCES + setNativePusher) and +/// are confirmed there. A missing record-caller source is fail-CLOSED +/// (records revert UnregisteredSource, Owner-remediable via addAllowedSource), +/// so it is out of this gate's scope by design, not an unguarded fail-open. +/// +/// Asserts, each with a DISTINCT revert message: +/// controller.admin() == queue.admin(), both non-zero -- guardian +/// controller.globalDelaySeconds() > 0 AND +/// >= queue.minimumDelaySeconds() -- delay floor +/// (C2: the `> 0` gates the ACTIVE global delay — a +/// zero global delay leaves the perimeter inert. The queue's +/// `minimumDelaySeconds` FLOOR itself MAY be 0; that only leaves +/// the per-request DelayBelowFloor backstop inactive — Owner- +/// remediable, NOT a go-live blocker.) +/// (C1) queue.owner() == governanceOwner != deployer +/// controller.owner() == governanceOwner != deployer -- ownership +/// (C2) for each intended host: +/// host.exitDelayQueue() == queue -- wired +/// queue.isAllowedSource(host) -- allowed-source +/// +/// C1 host-input safety: both spec-named product hosts are +/// REQUIRED inputs read via `vm.envAddress` (which REVERTS on an unset or +/// typo'd var) — a fresh-shell / mistyped run must NOT silently yield an +/// empty intended-host list and vacuously "PASS" while a surface ships +/// unwired at zero-delay (fail-open). Skipping a host is allowed ONLY via an +/// EXPLICIT, LOUD `VERIFY_DEFER_HOSTS=true` opt-in; each deferred host prints +/// a warning line and the success banner is downgraded to the qualified +/// (warned) variant. The unqualified "safe to run step 8" banner is emitted +/// ONLY when BOTH spec-named hosts were actually checked. +/// +/// The assertions live HERE — NOT inside `05_DeployQueueAndWire`'s broadcast +/// (step 1, BEFORE config) — so the documented activation order does not +/// self-abort. +/// +/// Read-only: no `vm.startBroadcast()`, no state change. verify() does NOT +/// mutate (roles-not-actors: setAdmin/setGlobalDelaySeconds stay Owner +/// actions run at steps 4–5, not here). +/// +/// @dev Usage: +/// +/// export EXIT_DELAY_GOVERNANCE_OWNER=0x... # intended Owner (governance Safe/timelock) +/// export EXIT_DELAY_DEPLOYER=0x... # the broadcast EOA that ran the deploy +/// export SOVRYN_PROTOCOL_HOST=0x... # intended host (REQUIRED unless VERIFY_DEFER_HOSTS=true) +/// export ZERO_BORROWER_OPERATIONS_HOST=0x... # intended host (REQUIRED unless VERIFY_DEFER_HOSTS=true) +/// # export VERIFY_DEFER_HOSTS=true # ONLY to intentionally defer a host's wiring to a later SIP +/// +/// forge script script/06_VerifyActivation.s.sol \ +/// --rpc-url $RSK_RPC --sig "run(uint256)" +/// +/// Reads BOTH `deployments//ExitFeeController.json` and +/// `deployments//ExitDelayQueue.json` for the two proxy addresses, and +/// the intended-host list from the same env vars the deploy script wires from. +contract VerifyActivation is Script { + using stdJson for string; + + /// @param chainId the target chain (selects the deployments artifact dir) + function run(uint256 chainId) external view { + (ExitFeeController controller, ExitDelayQueue queue) = _loadProxies(chainId); + + // ── Intended governance Owner + deployer EOA (C1). Both REQUIRED non-zero + // so a blank never trivially satisfies "owner == governanceOwner" or the + // "owner != deployer" check. ── + address governanceOwner = vm.envAddress("EXIT_DELAY_GOVERNANCE_OWNER"); + address deployer = vm.envAddress("EXIT_DELAY_DEPLOYER"); + + // ── Intended product hosts (C1 host-input safety). BOTH spec- + // named hosts are REQUIRED via vm.envAddress (reverts on unset/typo) so a + // fresh-shell run can NEVER produce an empty list that vacuously PASSes + // while a surface ships unwired (fail-open zero-delay). A host may be + // skipped ONLY via an EXPLICIT `VERIFY_DEFER_HOSTS=true`. ── + bool deferHosts = vm.envOr("VERIFY_DEFER_HOSTS", false); + (address sovrynHost, address zeroHost) = _readHostEnv(deferHosts); + address[] memory hosts = _resolveHosts(deferHosts, sovrynHost, zeroHost); + + console2.log("ExitFeeController @", address(controller)); + console2.log("ExitDelayQueue @", address(queue)); + console2.log("governance owner @", governanceOwner); + console2.log("deployer EOA @", deployer); + console2.log("intended hosts :", hosts.length); + console2.log(""); + + verify(controller, queue, governanceOwner, deployer, hosts, deferHosts); + + // (C2) The banner is keyed off the RESOLVED FACT — whether BOTH + // spec-named hosts were actually C2-checked — NOT the raw VERIFY_DEFER_HOSTS + // flag: with defer=true but both hosts present, all surfaces ARE certified + // and the unqualified banner is correct; the qualified/warned banner is for + // an ACTUAL deferral (a spec-named host dropped from the checked list). + bool anyHostDeferred = hosts.length < _EXPECTED_HOST_COUNT; + _reportPass(anyHostDeferred); + } + + /// @dev The number of spec-named product hosts (activation step-7 scope: the + /// `sovrynProtocol` singleton + the Zero `BorrowerOperations` proxy). A + /// resolved list shorter than this means at least one host was deferred. + uint256 private constant _EXPECTED_HOST_COUNT = 2; + + /// @dev Read the two spec-named host env vars. When NOT deferring, use + /// `vm.envAddress` (REQUIRED — reverts LOUD on an unset or typo'd var, + /// exactly like 05_DeployQueueAndWire's non-zero rule) so a fresh-shell or + /// mistyped run cannot silently yield an empty intended-host list and + /// vacuously "PASS". When deferring, fall back to `vm.envOr(..., 0)` so an + /// operator can leave a host unset ON PURPOSE. + function _readHostEnv(bool deferHosts) internal view returns (address sovrynHost, address zeroHost) { + if (deferHosts) { + sovrynHost = vm.envOr("SOVRYN_PROTOCOL_HOST", address(0)); + zeroHost = vm.envOr("ZERO_BORROWER_OPERATIONS_HOST", address(0)); + } else { + // REQUIRED: reverts on unset/typo — no silent empty list. + sovrynHost = vm.envAddress("SOVRYN_PROTOCOL_HOST"); + zeroHost = vm.envAddress("ZERO_BORROWER_OPERATIONS_HOST"); + } + } + + /// @dev Read the controller + queue proxy addresses from the deployment + /// artifacts. Split out of `run` to keep its live-local count low + /// (avoids a stack-too-deep on the non-viaIR Paris build). + function _loadProxies(uint256 chainId) + internal + view + returns (ExitFeeController controller, ExitDelayQueue queue) + { + string memory dir = string.concat("deployments/", vm.toString(chainId), "/"); + controller = ExitFeeController( + vm.readFile(string.concat(dir, "ExitFeeController.json")).readAddress(".proxyAddress") + ); + queue = ExitDelayQueue( + payable(vm.readFile(string.concat(dir, "ExitDelayQueue.json")).readAddress(".proxyAddress")) + ); + } + + /// @notice Build the intended-host list from the two spec-named hosts, enforcing + /// the C1 host-input safety rule and emitting a LOUD per-host warning for + /// each explicitly-deferred (zero) host. Public + pure-of-env so a unit + /// test drives every branch on in-memory addresses WITHOUT `vm.setEnv` + /// (which mutates process-global env forge does not isolate between + /// parallel test functions — a genuine race). + /// + /// Contract: + /// - deferHosts == false: BOTH hosts MUST be non-zero (the caller's + /// `vm.envAddress` already reverts on unset; this is the belt-and- + /// suspenders check for a caller-supplied zero). A zero host here is + /// a hard REVERT — a surface would ship unwired at zero-delay. + /// - deferHosts == true: a zero host is an INTENTIONAL deferral; it is + /// dropped from the list and a LOUD warning is printed naming the + /// surface whose delay is NOT yet active. + /// + /// @param deferHosts the explicit VERIFY_DEFER_HOSTS opt-in + /// @param sovrynHost SOVRYN_PROTOCOL_HOST (lending + loan/margin surfaces) + /// @param zeroHost ZERO_BORROWER_OPERATIONS_HOST (Zero surface) + /// @return hosts the tightly-sized list of hosts that WILL be C2-checked + function _resolveHosts(bool deferHosts, address sovrynHost, address zeroHost) + internal + view + returns (address[] memory hosts) + { + // (C2 duplicate-host guard) A non-zero duplicate pair (both spec-named vars + // pointing at the SAME host — a copy-paste footgun) would C2-check one + // surface twice and leave the OTHER silently unchecked/unwired. Reject it. + // The `== address(0)` clause preserves the both-zero EXPLICIT-defer path + // (deferHosts==true, both hosts intentionally unset): that is not a dup. + require( + sovrynHost != zeroHost || sovrynHost == address(0), + "C1: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)" + ); + + bool sovrynIn = _includeHost(deferHosts, sovrynHost, "sovrynProtocol (SOVRYN_PROTOCOL_HOST)"); + bool zeroIn = + _includeHost(deferHosts, zeroHost, "Zero BorrowerOperations (ZERO_BORROWER_OPERATIONS_HOST)"); + + uint256 n; + if (sovrynIn) n++; + if (zeroIn) n++; + + hosts = new address[](n); + uint256 i; + if (sovrynIn) hosts[i++] = sovrynHost; + if (zeroIn) hosts[i++] = zeroHost; + } + + /// @dev Decide whether one spec-named host is included in the C2-checked list. + /// A non-zero host is always included. A zero host is a hard REVERT unless + /// deferral is EXPLICIT (VERIFY_DEFER_HOSTS=true), in which case it is + /// dropped with a LOUD warning naming the un-delayed surface. + function _includeHost(bool deferHosts, address host, string memory label) + internal + view + returns (bool included) + { + if (host != address(0)) return true; + // host == 0 + require( + deferHosts, + string.concat( + "C1: intended host ", + label, + " == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + console2.log(unicode"⚠ perimeter enabling with host UNWIRED -- that surface has NO delay:"); + console2.log(string.concat(unicode" ", label)); + return false; + } + + /// @dev Emit the go-live success banner. The UNQUALIFIED "safe to run step 8" + /// banner is emitted ONLY when BOTH spec-named hosts were actually checked + /// (`anyHostDeferred == false`). When at least one spec-named host was + /// ACTUALLY dropped from the checked list (a real deferral) the banner is + /// DOWNGRADED to the qualified/warned variant so a deferred surface is never + /// implicitly certified. (C2: keyed off the resolved fact, NOT the raw + /// VERIFY_DEFER_HOSTS flag — defer=true with both hosts present certifies + /// everything and earns the unqualified banner.) + /// @param anyHostDeferred true iff fewer than the two spec-named hosts were + /// actually C2-checked (a real deferral happened) + function _reportPass(bool anyHostDeferred) internal view { + if (anyHostDeferred) { + console2.log(unicode"OK (QUALIFIED): activation step-7 gate PASSED for the CHECKED hosts."); + console2.log(unicode" guardian + delay floor + ownership + wiring for wired hosts."); + console2.log( + unicode"⚠ VERIFY_DEFER_HOSTS=true -- one or more surfaces are UNWIRED (see warnings above)." + ); + console2.log( + unicode" Do NOT treat this as a full go-live: each deferred host has NO delay until wired + re-verified." + ); + } else { + console2.log( + unicode"OK: activation step-7 gate PASSED for: guardian + delay floor + ownership + host wiring." + ); + console2.log( + unicode"NOTE (scope): this gate verifies the two STORAGE HOSTS are wired + allowed-source. It does" + ); + console2.log( + unicode" NOT enumerate the actual record CALLERS (iToken proxies for lender burn/burnToBTC; ActivePool" + ); + console2.log( + unicode" native pusher) — those are registered/verified by the step-2 source SIP (QUEUE_ALLOWED_SOURCES" + ); + console2.log( + unicode" + setNativePusher). A missing one is fail-CLOSED (records revert UnregisteredSource, Owner-remediable)." + ); + console2.log( + unicode"Safe to run step 8 (setSecurityPerimeterEnabled(true)) ONCE the step-2 source set is confirmed." + ); + } + } + + /// @notice The step-7 COMPREHENSIVE go-live gate as a public read-only entry, so + /// a unit test — and an operator running a dry-run against live proxies — + /// can drive the EXACT assertions the `run` path runs. Reverts on any + /// unmet invariant with a DISTINCT message; returns silently when the + /// whole deploy is correctly configured. Read-only (`view`), never + /// mutates (setAdmin/setGlobalDelaySeconds are Owner steps 4–5, not this). + /// + /// Revert taxonomy (distinct messages; "not yet configured" variants + /// first within each check so an early run points the operator back at + /// the missing step rather than at a false mis-config): + /// - admin unset -> "...(unconfigured): controller.admin==0..." + /// - admin mismatch -> "...: controller.admin() != queue.admin()..." + /// - globalDelaySeconds==0 -> "...(unconfigured): controller.globalDelaySeconds==0..." + /// - sub-floor delay -> "...: controller.globalDelaySeconds() < ...floor..." + /// - owner still deployer -> "...(C1): queue/controller.owner() == deployer..." + /// - owner != governance -> "...(C1): queue/controller.owner() != governance owner..." + /// - host not wired -> "...(C2): host not wired..." + /// - host not allowed-src -> "...(C2): host not allowed-source..." + /// + /// @param controller the deployed ExitFeeController proxy + /// @param queue the deployed ExitDelayQueue proxy + /// @param governanceOwner the intended governance Owner (must own BOTH; != deployer) + /// @param deployer the broadcast EOA that ran the deploy (must own NEITHER) + /// @param hosts the intended product hosts (each must be wired + allowed-source) + /// @param deferHosts the explicit VERIFY_DEFER_HOSTS opt-in. An EMPTY host + /// list is a vacuous wiring PASS (fail-open: a surface + /// could ship unwired at zero-delay while this gate + /// certifies "safe to run step 8"). The dry-run entry + /// REFUSES an empty list unless the caller EXPLICITLY + /// asserts deferral via `deferHosts == true` (C1, + /// "refuse vacuous PASS"). The + /// legitimate defer-both `run()` path still passes. + function verify( + ExitFeeController controller, + ExitDelayQueue queue, + address governanceOwner, + address deployer, + address[] memory hosts, + bool deferHosts + ) public view { + // (C1) Refuse a vacuous wiring PASS: an empty intended-host list means the + // C2 wiring loop asserts NOTHING, so the gate would certify go-live while a + // surface ships unwired (fail-open zero-delay). Only an EXPLICIT deferral + // opt-in may legitimately produce an empty list. + require( + hosts.length != 0 || deferHosts, + "empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" + ); + _verifyGuardian(controller, queue); + _verifyFloor(controller, queue); + _verifyNotPaused(queue); + _verifyOwnership(controller, queue, governanceOwner, deployer); + _verifyWiring(queue, hosts); + } + + // ── The queue must not be left paused. A paused queue keeps escrowing + // ingress but blocks every executeExit/recoverStuckExit, so enabling the + // perimeter against it freezes every withdrawal system-wide. A pause set + // during setup drills must be cleared before go-live. ── + function _verifyNotPaused(ExitDelayQueue queue) internal view { + require( + !queue.securityPerimeterPaused(), + "queue is paused -- unpause before enabling the perimeter (step 8)" + ); + } + + // ── single guardian. "not yet configured" (admin==0) is distinct from a + // genuine guardian mismatch. ── + function _verifyGuardian(ExitFeeController controller, ExitDelayQueue queue) internal view { + address ctrlAdmin = controller.admin(); + address queueAdmin = queue.admin(); + require( + ctrlAdmin != address(0), + "guardian unconfigured: controller.admin()==0 -- run step 5 (setAdmin) first" + ); + require( + ctrlAdmin == queueAdmin, + "single guardian violated: controller.admin() != queue.admin() -- single guardian violated" + ); + } + + // ── delay-floor liveness. globalDelaySeconds==0 (step 4 not run) is + // distinct from a configured-but-sub-floor delay. The queue's own per-request + // `require(d >= minimumDelaySeconds)` is the SAFETY enforcement; this is the + // LIVENESS check that a legit global delay does not sit below the floor and + // self-brick every non-bypassed exit. + // + // C2: the `> 0` guard is on the ACTIVE globalDelaySeconds — a + // zero global delay = inert perimeter, rejected here at go-live. The queue's + // `minimumDelaySeconds` FLOOR itself MAY be 0 (the per-request DelayBelowFloor + // backstop is then inactive, Owner-remediable, NOT a go-live blocker) — so + // there is deliberately NO `minimumDelaySeconds != 0` assertion. ── + function _verifyFloor(ExitFeeController controller, ExitDelayQueue queue) internal view { + uint256 globalDelay = uint256(controller.globalDelaySeconds()); + uint256 floor = uint256(queue.minimumDelaySeconds()); + require( + globalDelay != 0, + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ); + require( + globalDelay >= floor, + "sub-floor delay: controller.globalDelaySeconds() < queue.minimumDelaySeconds() -- sub-floor delay self-bricks exits" + ); + } + + // ── C1: ownership. BOTH the queue and the controller must be owned by the + // intended governance Owner and NOT by the deployer EOA — a blank/zero owner + // that silently left the deployer in control (ExitDelayQueue.initialize + // resolves a zero owner_ to msg.sender) MUST NOT pass go-live. The + // "owner == deployer" check is reported BEFORE the "== governanceOwner" + // check so the still-deployer case gets the most actionable message. ── + function _verifyOwnership( + ExitFeeController controller, + ExitDelayQueue queue, + address governanceOwner, + address deployer + ) internal view { + require( + governanceOwner != address(0), + "C1: governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" + ); + require( + deployer != address(0), + "C1: deployer arg == 0 -- set EXIT_DELAY_DEPLOYER" + ); + require( + governanceOwner != deployer, + "C1: governance owner == deployer -- they must differ" + ); + + address queueOwner = queue.owner(); + address ctrlOwner = controller.owner(); + + // still-deployer first (the silent-blank-owner footgun this gate exists for) + require( + queueOwner != deployer, + "C1: queue.owner() == deployer EOA -- ownership not handed to governance" + ); + require( + ctrlOwner != deployer, + "C1: controller.owner() == deployer EOA -- ownership not handed to governance" + ); + require(queueOwner == governanceOwner, "C1: queue.owner() != governance owner"); + require(ctrlOwner == governanceOwner, "C1: controller.owner() != governance owner"); + } + + // ── C2: wiring. Every intended host must (i) point its queue pointer at THIS + // queue (else that surface pays direct at zero-delay = fail-OPEN) AND (ii) be + // a registered allowed-source (else its records revert UnregisteredSource = + // bricked fail-CLOSED). Both mis-states must be caught before enabling. The + // revert names the specific host address for a fast fix. ── + function _verifyWiring(ExitDelayQueue queue, address[] memory hosts) internal view { + for (uint256 i; i < hosts.length; i++) { + address host = hosts[i]; + // (defensive: a zero host is never an intended host — _resolveHosts + // filters/reverts them — but guard so a caller-supplied list cannot + // slip a 0.) + require(host != address(0), "C2: intended host == 0"); + + require( + IExitDelayQueueHost(host).exitDelayQueue() == address(queue), + string.concat( + "C2: host ", + vm.toString(host), + " not wired -- host.exitDelayQueue() != queue (fail-open zero-delay)" + ) + ); + require( + queue.isAllowedSource(host), + string.concat( + "C2: host ", + vm.toString(host), + " not allowed-source -- queue.isAllowedSource(host)==false (bricked fail-closed)" + ) + ); + } + } +} diff --git a/script/07_BlockExits.s.sol b/script/07_BlockExits.s.sol new file mode 100644 index 0000000..d931a94 --- /dev/null +++ b/script/07_BlockExits.s.sol @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; + +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; +import {ExitFeeController} from "../src/ExitFeeController.sol"; +import {IExitDelayQueue} from "../src/interfaces/IExitDelayQueue.sol"; + +/// @title Block Exits - emergency stop for queued withdrawals +/// @notice READ-ONLY. Resolves who would be blocked, checks the call would not +/// revert, prints the exact calldata to submit from the Admin Safe, and +/// prints the command that verifies the result afterwards. It never +/// broadcasts: both block levers are `onlyAdminOrOwner`, and neither the +/// Admin nor the Owner is an EOA this script could sign for. +/// +/// TWO LEVERS, DELIBERATELY DIFFERENT IN BLAST RADIUS: +/// +/// `freeze` / `blacklist` - per-actor. A blocked originator, owner or +/// receiver cannot `executeExit` and cannot `recoverStuckExit`, so the +/// funds stay escrowed in the queue until the block is cleared or the +/// Owner resolves the request away. Everyone else keeps exiting +/// normally. `freeze` is the reversible hold (cleared by `unfreeze`); +/// `blacklist` is the confirmed state (cleared only by `unblacklist`). +/// A frozen address escalates to blacklisted in place; a blacklisted +/// address never downgrades to frozen. +/// +/// `pause` - global. Halts `executeExit` and `recoverStuckExit` for +/// everyone. It does NOT stop new exits entering the queue: escrow still +/// records normally, so the products keep working and nothing leaves. +/// Use it when the target is not yet identified; use the per-actor lever +/// once it is. +/// +/// WHAT NEITHER LEVER DOES: they do not stop a withdrawal from being +/// initiated on the product. Exits keep escrowing; what stops is the +/// payout at the far end of the delay. Disabling the perimeter itself +/// (`setSecurityPerimeterEnabled(false)` on the controller) is the +/// OPPOSITE action - it makes exits pay out directly, with no delay and +/// no queue. It is a liveness escape, not an incident lever. +/// +/// BY-REQUEST MODE resolves each request's `originator` and `owner` (and +/// its `receiver` when `BLOCK_FREEZE_RECEIVER=true`) and blocks all of +/// them in one transaction. That is the speed lever: given a list of +/// malicious request ids from the watcher, it blocks every party behind +/// them without the operator transcribing addresses under pressure. The +/// on-chain batch is atomic - one unknown id reverts the whole call - so +/// unknown ids are rejected here rather than after signatures are +/// collected. +/// +/// @dev Usage: +/// +/// export EXIT_DELAY_QUEUE=0x... # the queue (required) +/// export EXIT_FEE_CONTROLLER=0x... # controller; only the two kill-switch actions need it +/// export BLOCK_ACTION=freeze # freeze|blacklist|unfreeze|unblacklist|pause|unpause|verify +/// # |disable-perimeter|enable-perimeter (controller kill switch) +/// +/// # exactly one of these two for freeze/blacklist; actors only for the clears: +/// export BLOCK_ACTORS=0xaaa,0xbbb # addresses to block or clear +/// export BLOCK_REQUEST_IDS=41,42,43 # resolve the parties behind these requests +/// +/// export BLOCK_FREEZE_RECEIVER=true # by-request only; also block the payout address +/// export BLOCK_REASON="incident 2026-08-20" # by-request only; hashed into the event +/// +/// forge script script/07_BlockExits.s.sol --rpc-url $RPC +/// +/// Then submit the printed calldata to the queue from the Admin Safe, and +/// re-run with BLOCK_ACTION=verify to confirm the resulting states. +contract BlockExits is Script { + ExitDelayQueue internal queue; + ExitFeeController internal controller; + + function run() external { + _init(vm.envAddress("EXIT_DELAY_QUEUE")); + address ctrl = vm.envOr("EXIT_FEE_CONTROLLER", address(0)); + if (ctrl != address(0)) { + _initController(ctrl); + } + _dispatch( + vm.envString("BLOCK_ACTION"), + vm.envOr("BLOCK_ACTORS", ",", new address[](0)), + vm.envOr("BLOCK_REQUEST_IDS", ",", new uint256[](0)), + vm.envOr("BLOCK_FREEZE_RECEIVER", false), + vm.envOr("BLOCK_REASON", string("")) + ); + } + + function _init(address q) internal { + queue = ExitDelayQueue(payable(q)); + } + + function _initController(address c) internal { + controller = ExitFeeController(c); + } + + /// @dev Every input is an explicit argument so the decision logic is + /// driveable without process-global env, which forge does not isolate + /// between parallel tests. + function _dispatch( + string memory action, + address[] memory actors, + uint256[] memory ids, + bool freezeReceiver, + string memory reason + ) internal view { + console2.log("=== Perimeter: block exits ==="); + console2.log("queue :", address(queue)); + console2.log("admin :", queue.admin()); + console2.log("owner :", queue.owner()); + console2.log("perimeter paused :", queue.securityPerimeterPaused()); + console2.log("action :", action); + console2.log(""); + + bytes32 a = keccak256(bytes(action)); + if (a == keccak256("pause")) { + _pause(true); + } else if (a == keccak256("unpause")) { + _pause(false); + } else if (a == keccak256("freeze")) { + _blockActors(true, actors, ids, freezeReceiver, reason); + } else if (a == keccak256("blacklist")) { + _blockActors(false, actors, ids, freezeReceiver, reason); + } else if (a == keccak256("unfreeze")) { + _clear(true, actors, ids); + } else if (a == keccak256("unblacklist")) { + _clear(false, actors, ids); + } else if (a == keccak256("verify")) { + _verify(actors, ids, freezeReceiver); + } else if (a == keccak256("disable-perimeter")) { + _killSwitch(false); + } else if (a == keccak256("enable-perimeter")) { + _killSwitch(true); + } else { + revert( + "BLOCK_ACTION must be one of: freeze, blacklist, unfreeze, unblacklist, pause, unpause, verify, disable-perimeter, enable-perimeter" + ); + } + } + + // --- Global pause --------------------------------------------------- + + function _pause(bool on) internal view { + if (queue.securityPerimeterPaused() == on) { + console2.log("ALREADY in the requested state - nothing to submit."); + return; + } + console2.log( + on + ? "Halts executeExit and recoverStuckExit for EVERYONE. New exits keep escrowing." + : "Resumes executeExit and recoverStuckExit. Per-actor blocks are unaffected." + ); + _emitCalldata(address(queue), abi.encodeCall(IExitDelayQueue.setSecurityPerimeterPaused, (on))); + } + + // --- Controller kill switch ----------------------------------------- + + /// @dev The OPPOSITE lever to everything else in this script: disabling + /// the perimeter makes every charged exit pay straight out - no fee, + /// no delay, nothing escrows. It is a LIVENESS escape for a broken + /// perimeter, not an incident response; during an attack it is the + /// last thing to touch. Funds already escrowed are NOT released by + /// it - they stay in the queue behind their own holds and blocks. + function _killSwitch(bool enabled) internal view { + require( + address(controller) != address(0), + "set EXIT_FEE_CONTROLLER for disable-perimeter / enable-perimeter" + ); + console2.log("controller :", address(controller)); + console2.log("controller admin :", controller.admin()); + console2.log("controller owner :", controller.owner()); + bool current = controller.securityPerimeterEnabled(); + console2.log("perimeter enabled :", current); + if (current == enabled) { + console2.log("ALREADY in the requested state - nothing to submit."); + return; + } + console2.log( + enabled + ? "Re-arms the perimeter: every active surface charges and escrows again." + : "LIVENESS ESCAPE: every charged exit pays straight out - no fee, no delay, nothing escrows. Already-escrowed funds stay held in the queue." + ); + _emitCalldata( + address(controller), + abi.encodeCall(ExitFeeController.setSecurityPerimeterEnabled, (enabled)) + ); + } + + // --- Per-actor block ------------------------------------------------ + + /// @dev `freezing == false` means blacklist. Actor mode and by-request mode + /// build different calls; both are previewed the same way. + function _blockActors( + bool freezing, + address[] memory actorsIn, + uint256[] memory ids, + bool freezeReceiver, + string memory reason + ) internal view { + address[] memory actors; + bytes memory data; + + if (ids.length > 0) { + require( + actorsIn.length == 0, + "set BLOCK_ACTORS or BLOCK_REQUEST_IDS, not both - they build different calls" + ); + bytes32 reasonHash = _reasonHash(reason); + actors = _partiesBehind(ids, freezeReceiver); + data = freezing + ? abi.encodeWithSignature( + "freezeFromRequest(uint256[],bool,bytes32)", ids, freezeReceiver, reasonHash + ) + : abi.encodeWithSignature( + "blacklistFromRequest(uint256[],bool,bytes32)", ids, freezeReceiver, reasonHash + ); + console2.log("freezeReceiver :", freezeReceiver); + console2.log("reason hash :", vm.toString(reasonHash)); + console2.log(""); + } else { + actors = actorsIn; + require(actors.length > 0, "set BLOCK_ACTORS or BLOCK_REQUEST_IDS"); + data = freezing + ? abi.encodeWithSignature("freeze(address[])", actors) + : abi.encodeWithSignature("blacklist(address[])", actors); + } + + IExitDelayQueue.BlockState target = + freezing ? IExitDelayQueue.BlockState.Frozen : IExitDelayQueue.BlockState.Blacklisted; + + uint256 changing; + for (uint256 i = 0; i < actors.length; ++i) { + IExitDelayQueue.BlockState from = queue.blockStateOf(actors[i]); + IExitDelayQueue.BlockState to = target; + string memory note; + if (from == target) { + note = "already in this state (evidence refreshed, no state change)"; + } else if ( + from == IExitDelayQueue.BlockState.Blacklisted && target == IExitDelayQueue.BlockState.Frozen + ) { + note = "already blacklisted - freeze will NOT downgrade it"; + to = from; + } else { + note = "WILL CHANGE"; + ++changing; + } + console2.log( + string.concat( + " ", vm.toString(actors[i]), " ", _stateName(from), " -> ", _stateName(to), " ", note + ) + ); + } + console2.log(""); + console2.log("actors resolved :", actors.length); + console2.log("state changes :", changing); + if (changing == 0) { + console2.log("NOTE: no state changes. The call still succeeds and re-emits AccountBlocked."); + } + _emitCalldata(address(queue), data); + } + + // --- Per-actor clear ------------------------------------------------ + + /// @dev `unfreezing == false` means unblacklist. The contract reverts when + /// the current state does not match the clear being run, and the batch + /// is atomic, so one wrong address would waste a whole Safe round. Every + /// address is checked here first. + function _clear(bool unfreezing, address[] memory actors, uint256[] memory ids) internal view { + require(actors.length > 0, "set BLOCK_ACTORS"); + require(ids.length == 0, "clears are by address only - the queue has no by-request clear"); + + IExitDelayQueue.BlockState required = + unfreezing ? IExitDelayQueue.BlockState.Frozen : IExitDelayQueue.BlockState.Blacklisted; + + bool wouldRevert; + for (uint256 i = 0; i < actors.length; ++i) { + IExitDelayQueue.BlockState from = queue.blockStateOf(actors[i]); + bool ok = from == required; + if (!ok) wouldRevert = true; + console2.log( + string.concat( + " ", + vm.toString(actors[i]), + " ", + _stateName(from), + ok ? " -> None" : " WOULD REVERT (wrong clear for this state)" + ) + ); + } + console2.log(""); + require( + !wouldRevert, + unfreezing + ? "unfreeze requires every address to be Frozen - use unblacklist for blacklisted ones" + : "unblacklist requires every address to be Blacklisted - use unfreeze for frozen ones" + ); + + _emitCalldata( + address(queue), + unfreezing + ? abi.encodeWithSignature("unfreeze(address[])", actors) + : abi.encodeWithSignature("unblacklist(address[])", actors) + ); + } + + // --- Verify --------------------------------------------------------- + + /// @dev Run after the Safe transaction executes. Prints the live state of + /// every address named directly, plus the parties behind the request + /// ids, so the operator confirms the outcome rather than assuming it + /// from a successful transaction. + function _verify(address[] memory actorsIn, uint256[] memory ids, bool freezeReceiver) internal view { + address[] memory actors = ids.length > 0 ? _partiesBehind(ids, freezeReceiver) : actorsIn; + require(actors.length > 0, "set BLOCK_ACTORS or BLOCK_REQUEST_IDS"); + + for (uint256 i = 0; i < actors.length; ++i) { + console2.log( + string.concat( + " ", + vm.toString(actors[i]), + " ", + _stateName(queue.blockStateOf(actors[i])), + " trigger request #", + vm.toString(queue.blockTrigger(actors[i])) + ) + ); + } + } + + // --- Helpers -------------------------------------------------------- + + /// @dev Resolve every party the on-chain batch would block, with the same + /// rules the contract uses: originator always, owner when distinct, + /// receiver only when flagged. Rejects unknown ids so the Safe never + /// signs a call that reverts on-chain. + function _partiesBehind(uint256[] memory ids, bool freezeReceiver) + internal + view + returns (address[] memory) + { + address[] memory buf = new address[](ids.length * 3); + uint256 n; + for (uint256 i = 0; i < ids.length; ++i) { + IExitDelayQueue.ExitRequest memory r = queue.getRequest(ids[i]); + require( + r.status != IExitDelayQueue.ExitStatus.None, + string.concat("unknown request id ", vm.toString(ids[i]), " - the batch is atomic") + ); + console2.log( + string.concat( + " request #", + vm.toString(ids[i]), + " status ", + vm.toString(uint256(uint8(r.status))), + " amount ", + vm.toString(uint256(r.amount)), + " token ", + vm.toString(r.token), + " unlockAt ", + vm.toString(uint256(r.unlockAt)) + ) + ); + n = _push(buf, n, r.originator); + if (r.owner != r.originator) n = _push(buf, n, r.owner); + if (freezeReceiver) n = _push(buf, n, r.receiver); + } + address[] memory out = new address[](n); + for (uint256 i = 0; i < n; ++i) { + out[i] = buf[i]; + } + console2.log(""); + return out; + } + + /// @dev Append unless already present - the same address behind several + /// requests should be listed once in the preview. + function _push(address[] memory buf, uint256 n, address a) internal pure returns (uint256) { + for (uint256 i = 0; i < n; ++i) { + if (buf[i] == a) return n; + } + buf[n] = a; + return n + 1; + } + + /// @dev The reason is hashed, not stored: the event carries the digest and + /// the incident record carries the text. An empty reason is allowed but + /// called out, because the digest is the only on-chain link to why. + function _reasonHash(string memory reason) internal view returns (bytes32) { + if (bytes(reason).length == 0) { + console2.log("WARNING: BLOCK_REASON is empty - the event will carry a zero digest."); + return bytes32(0); + } + return keccak256(bytes(reason)); + } + + function _emitCalldata(address to, bytes memory data) internal view { + console2.log("--- submit from the Admin multisig ---"); + console2.log("to :", to); + console2.log("value: 0"); + console2.log("data :", vm.toString(data)); + console2.log(""); + console2.log("Submit via the multisig's Read/Write contract tab on Blockscout:"); + console2.log( + " https://rootstock.blockscout.com/address/?tab=read_write_contract" + ); + console2.log(" method 20. submitTransaction: destination = `to` above, value = 0,"); + console2.log(" data = the hex above. Simulate first, then Write. Further owners"); + console2.log(" confirm the emitted transactionId via method 4. confirmTransaction;"); + console2.log(" the threshold confirmation executes the call in the same transaction."); + console2.log(""); + console2.log("--- then confirm the result ---"); + console2.log("BLOCK_ACTION=verify forge script script/07_BlockExits.s.sol --rpc-url $RPC"); + } + + function _stateName(IExitDelayQueue.BlockState s) internal pure returns (string memory) { + if (s == IExitDelayQueue.BlockState.Frozen) return "Frozen"; + if (s == IExitDelayQueue.BlockState.Blacklisted) return "Blacklisted"; + return "None"; + } +} diff --git a/script/99_UpgradeProxy.s.sol b/script/99_UpgradeProxy.s.sol index 9122064..53bbdab 100644 --- a/script/99_UpgradeProxy.s.sol +++ b/script/99_UpgradeProxy.s.sol @@ -6,7 +6,7 @@ import {console2} from "forge-std/console2.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; /// @title Upgrade a UUPS proxy -/// @notice Generic upgrade script for the ColFee proxies. Takes the proxy +/// @notice Generic upgrade script for the Perimeter proxies. Takes the proxy /// address and the new implementation address from env, calls /// `upgradeTo(newImpl)`, and emits the impl pointer change to the /// broadcast log. diff --git a/script/InspectController.s.sol b/script/InspectController.s.sol index 40a36ef..d5e9fb9 100644 --- a/script/InspectController.s.sol +++ b/script/InspectController.s.sol @@ -7,6 +7,7 @@ import {stdJson} from "forge-std/StdJson.sol"; import {ExitFeeController} from "../src/ExitFeeController.sol"; import {IExitFeeController} from "../src/interfaces/IExitFeeController.sol"; +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; /// @title Inspect ExitFeeController /// @notice Prints the live state of a deployed `ExitFeeController` proxy: @@ -27,14 +28,14 @@ contract InspectController is Script { // Every canonical surface, including the ones that ship off -- an // inspector that skipped them would report "off" as silence. Keep - // aligned with docs/SURFACE_REGISTRY.md and the set that + // aligned with the registered surface names and the set that // 04_BootstrapController.s.sol writes. string[5] internal surfaceNames = [ - "SURFACE_LENDING_LENDER_WITHDRAW", - "SURFACE_LENDING_BORROWER_WITHDRAW", - "SURFACE_ZERO_WITHDRAW_COLL", - "SURFACE_ZERO_CLAIM_SURPLUS", - "SURFACE_AMM_REMOVE_LIQUIDITY" + "PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW", + "PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW", + "PERIMETER_SURFACE_ZERO_WITHDRAW_COLL", + "PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS", + "PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY" ]; // EIP-1967 implementation storage slot. @@ -72,14 +73,203 @@ contract InspectController is Script { console2.log(" MAX_BPS: ", uint256(c.MAX_BPS())); console2.log(""); - console2.log(unicode"── Surface policies ──────────────────────────────────"); + console2.log(unicode"── Delay perimeter (global) ──────────────────────────"); + console2.log(" securityPerimeterEnabled:", c.securityPerimeterEnabled()); + console2.log(" globalDelaySeconds: ", uint256(c.globalDelaySeconds())); + console2.log(" admin: ", c.admin()); + console2.log(""); + + // ── single-guardian identity assertion. The controller keeps its + // OWN local `admin` (used ONLY by the kill switch) and NEVER reads + // queue.admin() at runtime (that would couple the kill switch to queue + // liveness and break). Instead this tooling asserts, at + // deploy/inspect time, that the two guardians are ONE identity so a + // single Safe bundle rotates both. Off unless the queue artifact exists. + _assertSingleGuardian(c, chainId); + + console2.log(unicode"── Surface policies + delay bypass ───────────────────"); for (uint256 i = 0; i < surfaceNames.length; i++) { _printSurface(c, surfaceNames[i]); } + + // ── dump EVERY delay-bypass + passthrough entry via the + // on-chain enumeration getters — NOT the hardcoded surface list above. + // A bypass / passthrough under an arbitrary surfaceId (never registered + // as a named fee surface) is still fully surfaced here. + _printDelayBypassRegistry(c); + _printPassthroughRegistry(c); + } + + /// @dev single-guardian assertion. Reads the queue proxy from its + /// deployment artifact (if present) and requires `controller.admin == + /// queue.admin`. This is a DEPLOY/INSPECT-time identity check ONLY — the + /// controller never reads the queue at runtime (kill-switch + /// queue-independence). Skipped with a notice when the queue + /// artifact is absent (e.g. controller-only inspection). + function _assertSingleGuardian(ExitFeeController c, uint256 chainId) internal view { + string memory path = string.concat("deployments/", vm.toString(chainId), "/ExitDelayQueue.json"); + try vm.readFile(path) returns (string memory queueArtifact) { + address queueProxy = queueArtifact.readAddress(".proxyAddress"); + address queueAdmin = ExitDelayQueue(payable(queueProxy)).admin(); + address ctrlAdmin = c.admin(); + console2.log(unicode"── Single-guardian identity ──────────────────"); + console2.log(" controller.admin:", ctrlAdmin); + console2.log(" queue.admin: ", queueAdmin); + require(ctrlAdmin == queueAdmin, "controller.admin != queue.admin (single guardian violated)"); + console2.log(" OK: single guardian identity holds"); + console2.log(""); + } catch { + console2.log(unicode"── Single-guardian identity ──────────────────"); + console2.log(" queue artifact absent -- skipping controller.admin==queue.admin check"); + console2.log(""); + } + } + + /// @dev the complete surfaceId probe set that drives BOTH the + /// bypass and passthrough dumps — + /// `bypassSurfaceIds()` ∪ `passthroughSurfaceIds()` ∪ the named surfaces. + /// The `bypassSurfaceIds()` / `passthroughSurfaceIds()` master sets are + /// ANY-TIER-TOUCHED: a surface carrying ONLY a sub-product- or actor-tier + /// bypass (or ONLY a passthrough entry) is present here even though it was + /// never passed to `setSurfaceBypass`. The named fee surfaces are folded in + /// so the human-readable rows always render, and duplicates are collapsed. + /// Root cause of the old gap (`surfaceBypassKeys()`-only driver): the master + /// id-set was populated solely by `setSurfaceBypass`, so an actor-only bypass + /// or a passthrough-only entry under an arbitrary surfaceId was undiscoverable. + function _probeSurfaceIds(ExitFeeController c) internal view returns (bytes32[] memory) { + bytes32[] memory bypassIds = c.bypassSurfaceIds(); + bytes32[] memory passIds = c.passthroughSurfaceIds(); + + // Upper bound on the union size; trim to the deduped count below. + bytes32[] memory acc = new bytes32[](bypassIds.length + passIds.length + surfaceNames.length); + uint256 n = 0; + + // Named fee surfaces first (stable ordering; keeps human rows at the top). + for (uint256 i = 0; i < surfaceNames.length; i++) { + n = _pushUnique(acc, n, keccak256(bytes(surfaceNames[i]))); + } + for (uint256 i = 0; i < bypassIds.length; i++) { + n = _pushUnique(acc, n, bypassIds[i]); + } + for (uint256 i = 0; i < passIds.length; i++) { + n = _pushUnique(acc, n, passIds[i]); + } + + bytes32[] memory out = new bytes32[](n); + for (uint256 i = 0; i < n; i++) { + out[i] = acc[i]; + } + return out; + } + + /// @dev Append `id` to `acc[0..n)` iff not already present; returns the new n. + function _pushUnique(bytes32[] memory acc, uint256 n, bytes32 id) internal pure returns (uint256) { + for (uint256 i = 0; i < n; i++) { + if (acc[i] == id) return n; + } + acc[n] = id; + return n + 1; + } + + /// @dev Human-readable label for a surfaceId: the canonical name if it is a + /// named fee surface, else the raw bytes32. + function _labelFor(bytes32 id) internal view returns (string memory) { + for (uint256 i = 0; i < surfaceNames.length; i++) { + if (id == keccak256(bytes(surfaceNames[i]))) { + return surfaceNames[i]; + } + } + return vm.toString(id); + } + + /// @dev Is `id` in the surface-bypass key index? A soft-retired surface + /// entry ({false,false}) is indistinguishable from never-set by its + /// policy alone; the key set is the source of truth for presence. + function _surfaceBypassKeyPresent(ExitFeeController c, bytes32 id) internal view returns (bool) { + bytes32[] memory keys = c.surfaceBypassKeys(); + for (uint256 i = 0; i < keys.length; i++) { + if (keys[i] == id) return true; + } + return false; + } + + /// @dev dump every surface / sub-product / actor delay-bypass + /// entry, driven by the ANY-TIER-TOUCHED probe set (`bypassSurfaceIds()` ∪ + /// `passthroughSurfaceIds()` ∪ named surfaces) so a sub-product- or + /// actor-only bypass under an arbitrary surfaceId is never missed. A probed + /// surfaceId with no live bypass entry at any tier prints nothing. + function _printDelayBypassRegistry(ExitFeeController c) internal view { + console2.log(unicode"── Delay-bypass registry (enumerated) ────────────────"); + bytes32[] memory ids = _probeSurfaceIds(c); + bool anyPrinted = false; + for (uint256 i = 0; i < ids.length; i++) { + bytes32 id = ids[i]; + + IExitFeeController.DelayBypassPolicy memory sb = c.surfaceBypass(id); + address[] memory subBp = c.subProductBypassKeys(id); + address[] memory actorBp = c.actorBypassKeys(id); + // A surface-tier entry set then soft-retired reads {false,false} but + // still lives in the key index; it must stay visible, since the + // documented way to disable-while-auditable is exactly that state. + bool surfaceEntryPresent = _surfaceBypassKeyPresent(c, id); + + // Skip a probed id that carries no bypass entry at any tier (e.g. a + // named fee surface or a passthrough-only surface never given one). + if (!surfaceEntryPresent && subBp.length == 0 && actorBp.length == 0) { + continue; + } + anyPrinted = true; + + console2.log(string.concat(" surface ", _labelFor(id), " ", _fmtBypass(sb))); + for (uint256 j = 0; j < subBp.length; j++) { + IExitFeeController.DelayBypassPolicy memory p = c.subProductBypass(id, subBp[j]); + console2.log(string.concat(" sub-product ", vm.toString(subBp[j]), " ", _fmtBypass(p))); + } + for (uint256 j = 0; j < actorBp.length; j++) { + IExitFeeController.DelayBypassPolicy memory p = c.actorBypass(id, actorBp[j]); + console2.log(string.concat(" actor ", vm.toString(actorBp[j]), " ", _fmtBypass(p))); + } + } + if (!anyPrinted) { + console2.log(" (no delay bypasses configured at any tier)"); + } + console2.log(""); + } + + /// @dev dump the surface-scoped passthrough registry via + /// `passthroughKeys(surfaceId)`, driven by the SAME any-tier-touched probe + /// set as the bypass dump (`bypassSurfaceIds()` ∪ `passthroughSurfaceIds()` + /// ∪ named surfaces). A passthrough-only surface (no bypass entry, not a + /// named fee surface) is discoverable via `passthroughSurfaceIds()`. + function _printPassthroughRegistry(ExitFeeController c) internal view { + console2.log(unicode"── Passthrough registry (enumerated) ─────────────────"); + bytes32[] memory ids = _probeSurfaceIds(c); + bool anyPrinted = false; + for (uint256 i = 0; i < ids.length; i++) { + anyPrinted = _printPassthroughFor(c, ids[i], _labelFor(ids[i])) || anyPrinted; + } + if (!anyPrinted) { + console2.log(" (no passthrough actors configured)"); + } + console2.log(""); + } + + function _printPassthroughFor(ExitFeeController c, bytes32 id, string memory label) + internal + view + returns (bool) + { + address[] memory pks = c.passthroughKeys(id); + if (pks.length == 0) return false; + console2.log(string.concat(" ", label, ":")); + for (uint256 j = 0; j < pks.length; j++) { + console2.log(string.concat(" ", vm.toString(pks[j]))); + } + return true; } function _printSurface(ExitFeeController c, string memory name) internal view { - bytes32 id = keccak256(abi.encodePacked("COLFEE:", name)); + bytes32 id = keccak256(bytes(name)); console2.log(name); console2.log(" id: ", vm.toString(id)); @@ -104,6 +294,15 @@ contract InspectController is Script { console2.log(string.concat(" ", vm.toString(actors[j]), " ", _fmtPolicy(p))); } } + // NOTE: delay-bypass + passthrough tiers are dumped separately in + // `_printDelayBypassRegistry` / `_printPassthroughRegistry`, driven by the + // ANY-TIER-TOUCHED master sets `bypassSurfaceIds()` ∪ `passthroughSurfaceIds()` + // ∪ the named surfaces (NOT this hardcoded surface list, and NOT the + // surface-tier-only `surfaceBypassKeys()`). Because those + // master sets are recorded by EVERY bypass writer (surface / sub-product / + // actor) and by passthrough registration, an entry under an arbitrary + // surfaceId — including a sub-product- or actor-ONLY bypass or a + // passthrough-only entry — is discovered there. console2.log(""); } @@ -112,4 +311,14 @@ contract InspectController is Script { "(active=", p.active ? "true" : "false", ", rateBps=", vm.toString(uint256(p.rateBps)), ")" ); } + + function _fmtBypass(IExitFeeController.DelayBypassPolicy memory p) + internal + pure + returns (string memory) + { + return string.concat( + "(active=", p.active ? "true" : "false", ", bypass=", p.bypass ? "true" : "false", ")" + ); + } } diff --git a/src/ExitDelayQueue.sol b/src/ExitDelayQueue.sol new file mode 100644 index 0000000..bdf051a --- /dev/null +++ b/src/ExitDelayQueue.sol @@ -0,0 +1,1163 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from + "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IExitDelayQueue} from "./interfaces/IExitDelayQueue.sol"; + +/// @notice Minimal WRBTC (wrapped-RBTC) surface. `unwrapOnDelivery` requests +/// hold WRBTC and unwrap to native RBTC at `executeExit` (Option B). +interface IWRBTC { + function withdraw(uint256 amount) external; +} + +/// @title ExitDelayQueue +/// @notice Per-request escrow for the *user* leg of an exit. Each exit becomes +/// an immutable request with its own monotonic id; execution targets +/// explicit ids (no cursor, no ordering assumption). During the delay, +/// a detected theft can be Frozen/Blacklisted and disposed via the +/// three recovery legs. UUPS-upgradeable, mirroring the +/// `ExitFeeVault` skeleton. +/// +/// Authoritative build spec: +/// The invariants are enumerated with the properties below and +/// pinned by the forge invariant suite. +/// +/// Two-principal authority: `Owner` (Ownable2Step) holds UUPS +/// upgrade + all security-critical CONFIG; `Admin` (a single stored +/// address, `onlyAdminOrOwner`) is the fast guardian — freeze/blacklist, +/// pause, Leg-1 release, Leg-2 along Owner-approved routes. The two roles +/// MAY be the same address; the authority split constrains the Admin only +/// once ownership moves to a separate holder. +// +// The queue custodies escrowed RBTC/ERC20/WRBTC by design; the only value-in +// path is the gated ingress (record*/receive()), and value-out is CEI-ordered +// behind nonReentrant. aderyn's "contract-locks-ether" is intentional here. +// aderyn-ignore-next-line(contract-locks-ether) +contract ExitDelayQueue is + IExitDelayQueue, + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable, + ReentrancyGuardUpgradeable +{ + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.UintSet; + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + + // ─── Storage layout ────────────────────────────────────────── + // Verified via `forge inspect ExitDelayQueue storageLayout`. Inherited-OZ + // namespaces occupy proxy slots 0..300 (Initializable/Context/Ownable/ + // Ownable2Step/ReentrancyGuard, each with a 50-slot __gap; UUPS/Context add + // no fields). This contract's own slot 0 sits at proxy slot 301: + // + // 301 admin (address; the Admin guardian — 1 slot, no OZ AccessControl) + // 302 lastRequestId (uint256) + // 303 _requests (mapping head) + // 304 _activeByParty (mapping head) + // 305 _recoveryRoutes (mapping head) + // 306 _recoveryRouteIds._values (Bytes32Set array head) ┐ 2 slots + // 307 _recoveryRouteIds._indexes (mapping head) ┘ + // 308 _topUpFeasible (mapping head) + // 309 _blockState (mapping head) + // 310 _blockedAccounts._values (AddressSet array head) ┐ 2 slots + // 311 _blockedAccounts._indexes (mapping head) ┘ + // 312 _blockTrigger (mapping head) + // 313 _allowedSource (mapping head) + // 314 _allowedSources._values (AddressSet array head) ┐ 2 slots + // 315 _allowedSources._indexes (mapping head) ┘ + // 316 _totalEscrowed (mapping head) + // 317 nativePusher (address) ┐ address(20) + uint32(4) + bool(1) + // minimumDelaySeconds (uint32) │ = 25 bytes → PACKED into one slot + // securityPerimeterPaused (bool) ┘ + // 318 wrbtc (address; canonical WRBTC — own slot) + // 319 .. 350 __gap[32] (50 − 18 own slots) + // + // own_slots = 18 (admin, lastRequestId, 8 mapping heads, 3 EnumerableSet ×2, + // the packed nativePusher+minimumDelaySeconds+securityPerimeterPaused slot, + // and wrbtc). __gap = 50 − 18 = 32. Re-derive at build time with + // `forge inspect ExitDelayQueue storageLayout` before any deployment and + // set __gap accordingly. Upgrades adding storage MUST consume from __gap. + // + // Stuck-exit recovery redesign the + // markPayoutFailed / _payoutFailed recovery marker AND the earlier + // executeExit(id, altReceiver) redirect overload were BOTH removed. A bouncing + // honest recipient is handled self-service by {originator, owner} via the + // dedicated recoverStuckExit(id, altReceiver) leg — which attempts the STORED + // receiver FIRST and pays altReceiver only on a genuine bounce (verify-by- + // attempting; a healthy exit is NEVER redirected). No stored failure flag, no + // admin/Owner recovery path, no request re-targeting. The all-four-actor + // block gate covers the STORED receiver so a blocked original receiver + // refuses recovery and falls to Leg-3. The freed marker slot returns to __gap + // (restoring the pre-marker layout, still 32). + + /// @notice Fast operational guardian. Not an OZ AccessControl role — + /// a single stored address checked by `onlyAdminOrOwner`. MAY equal + /// the Owner; the authority bounds between the two roles bind only + /// once ownership moves to a separate holder. + address public admin; + + /// @notice Monotonic id source; ids are never reused. The first + /// recorded id is 1 (0 is reserved as "no request" / arbitrary block). + uint256 public lastRequestId; + + mapping(uint256 => ExitRequest) internal _requests; + + /// @dev Executor party (originator, owner) → status==Queued request ids. + /// A freeze HOLDS but does not remove (Frozen is an address state, not + /// a request status). Dual-key when originator != owner. No + /// on-chain path iterates the full set — only O(1) add/remove and the + /// paginated `getActive` view touch it. + mapping(address => EnumerableSet.UintSet) internal _activeByParty; + + mapping(bytes32 => RecoveryRoute) internal _recoveryRoutes; + EnumerableSet.Bytes32Set internal _recoveryRouteIds; + + /// @dev surfaceId → may a topUpPool=true route be registered? Owner-set; + /// true only for lending-lender/borrower surfaces at launch. + mapping(bytes32 => bool) internal _topUpFeasible; + + /// @dev Execution & recovery treat Frozen and Blacklisted alike as "blocked"; + /// recovery-away distinguishes them. + mapping(address => BlockState) internal _blockState; + EnumerableSet.AddressSet internal _blockedAccounts; // Frozen ∪ Blacklisted, for getters + + /// @dev addr → exit-request id that triggered the block (0 = arbitrary block). + mapping(address => uint256) internal _blockTrigger; + + mapping(address => bool) internal _allowedSource; + EnumerableSet.AddressSet internal _allowedSources; + + /// @dev token => Σ amounts of Queued requests (solvency). + /// address(0) = native RBTC. Exposed via the `totalEscrowed(address)` + /// view (not a public auto-getter so the interface signature is exact). + mapping(address => uint256) internal _totalEscrowed; + + /// @notice The single registered native pusher (Zero `ActivePool`), Owner-set. + /// NOTE: `receive()` is now UNCONDITIONAL and no longer reads + /// this slot — the pusher is no longer a `receive()`-time gate. The + /// field + setter are RETAINED (unchanged ABI/packing, so `__gap` + /// stays 32) as documented provenance of the intended native source; + /// `_allowedSource` still gates the record CALLER at ingress. + address public nativePusher; + + /// @notice F1 floor enforced PER-REQUEST at ingress. Packs with + /// nativePusher + securityPerimeterPaused. + uint32 public minimumDelaySeconds; + + /// @notice Pauses executeExit(s) ONLY; ingress + recovery stay live. + /// Packs with nativePusher (address) + minimumDelaySeconds (uint32). + bool public securityPerimeterPaused; + + /// @notice Canonical WRBTC address. Set at initialize; used only to + /// guard the `unwrapOnDelivery` flag and to unwrap at delivery. A + /// full own slot (slot 318 in the layout note above), so __gap is + /// 50 − 18 = 32. Re-derive via `forge inspect` before deployment. + address public wrbtc; + + // aderyn-ignore-next-line(unused-state-variable) + uint256[32] private __gap; + + /// @notice Max page size for the paginated `getActive` / `blockedAccounts` + /// views. Public so paging is + /// self-describing on-chain — a caller can read the cap instead of + /// hard-coding 500 and discovering the clamp empirically. + uint256 public constant MAX_GET_ACTIVE_PAGE = 500; + + // ─── Modifiers ────────────────────────────────────────────────────── + + modifier onlyAdminOrOwner() { + if (msg.sender != admin && msg.sender != owner()) revert NotAdminOrOwner(msg.sender); + _; + } + + modifier onlyAllowedSource() { + if (!_allowedSource[msg.sender]) revert UnregisteredSource(msg.sender); + _; + } + + error NotAdminOrOwner(address caller); + error OwnershipCannotBeRenounced(); + error UpgradeImplZero(); + error InsufficientGasForRecovery(); + error InvalidDestination(address destination); + + // ─── Construction / initialization ────────────────────────────────── + + constructor() { + _disableInitializers(); + } + + /// @notice Initialize the proxy. Deployer becomes the initial Owner; the + /// bootstrap flow then `transferOwnership` → the governance Owner. + /// @param owner_ Owner principal (0 or msg.sender ⇒ deployer stays owner). + /// @param admin_ Fast guardian; non-zero. MAY equal the owner ( + /// retired, deliberate: launch shape is admin = owner + /// = governance Safe; the split becomes a real authority bound only + /// when ownership later moves to Bitocracy). + /// @param wrbtc_ Canonical WRBTC token. Must be non-zero. + /// @param minimumDelaySeconds_ Per-request delay floor. + /// @param initialAllowedSources Hooked source contracts. + function initialize( + address owner_, + address admin_, + address wrbtc_, + uint32 minimumDelaySeconds_, + address[] calldata initialAllowedSources + ) external initializer { + __Ownable_init(); + __Ownable2Step_init(); + __ReentrancyGuard_init(); + __UUPSUpgradeable_init(); + + if (wrbtc_ == address(0) || admin_ == address(0)) revert ZeroAddress(); + wrbtc = wrbtc_; + + admin = admin_; + + minimumDelaySeconds = minimumDelaySeconds_; + + for (uint256 i = 0; i < initialAllowedSources.length; ++i) { + address src = initialAllowedSources[i]; + if (src == address(0)) revert ZeroAddress(); + if (_allowedSources.add(src)) { + _allowedSource[src] = true; + emit AllowedSourceSet(src, true); + } + } + + if (owner_ != address(0) && owner_ != msg.sender) { + _transferOwnership(owner_); + } + } + + // ─── Ingress ────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function recordERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrapOnDelivery + ) external nonReentrant onlyAllowedSource returns (uint256 id) { + // guard: the delivery-time unwrap can only be set on WRBTC escrow. + if (unwrapOnDelivery && token != wrbtc) revert UnwrapNonWrbtc(); + _validateIngress(token, amount, delaySeconds, effOrig, effOwner, receiver); + + // ERC20 pull with receipt proof (High-3): measure before/after so a + // fee-on-transfer/rebasing token cannot silently mis-escrow. + uint256 before = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + uint256 received = IERC20(token).balanceOf(address(this)) - before; + if (received != amount) revert ReceivedAmountMismatch(token, received, amount); + + id = _record( + token, amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, unwrapOnDelivery + ); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Measured-delta path (0.5.x): the source pushes `amount` and records + /// in the SAME outer tx. We measure the current non-backing surplus + /// `delta = balanceOf − totalEscrowed` and require `delta >= amount`: + /// the record CONSUMES exactly `amount` into + /// totalEscrowed; any excess (pre-existing dust, a force-sent/donated + /// 1-wei, another source's surplus) stays as non-backing surplus for + /// sweepSurplus and is NEVER mis-credited. The earlier `== amount` exact + /// rule was donation-griefable — a 1-wei force-send permanently reverted + /// every subsequent record. Because push and record are atomic in one + /// outer tx, there is no interleaved-push residual to protect against, + /// and a donation only RAISES the surplus, so `>= amount` still passes. + function recordReceivedERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external nonReentrant onlyAllowedSource returns (uint256 id) { + _validateIngress(token, amount, delaySeconds, effOrig, effOwner, receiver); + + // Non-backing surplus = (current backing balance) − (already-escrowed). + // Require it covers `amount` (delta >= amount). Revert only when + // the push under-delivered (delta < amount ⇒ ReceivedAmountMismatch). + // Excess over `amount` is left as sweepable surplus (never mis-credited). + uint256 backing = IERC20(token).balanceOf(address(this)); + uint256 escrowed = _totalEscrowed[token]; + uint256 delta = backing > escrowed ? backing - escrowed : 0; + if (delta < amount) revert ReceivedAmountMismatch(token, delta, amount); + + id = _record(token, amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, false); + } + + /// @inheritdoc IExitDelayQueue + function recordNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external payable nonReentrant onlyAllowedSource returns (uint256 id) { + if (msg.value != amount) revert AmountMismatch(msg.value, amount); + _validateIngress(address(0), amount, delaySeconds, effOrig, effOwner, receiver); + id = _record( + address(0), amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, false + ); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Native measured-receipt (Zero): `ActivePool.sendETH(queue, amount)` + /// pushes value (via `receive()`) BEFORE this record in the same outer + /// tx; a record revert rolls the push back (fail-closed). Same + /// rule as the ERC20 measured path: require the non-backing + /// surplus `delta >= amount` and credit exactly `amount`. The + /// `receive()` gate cannot stop a `selfdestruct` force-send, which is + /// exactly why crediting must tolerate surplus (`>= amount`) rather than + /// require an exact balance — a force-sent 1-wei must not + /// permanently brick every subsequent Zero exit record. + function recordReceivedNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external nonReentrant onlyAllowedSource returns (uint256 id) { + _validateIngress(address(0), amount, delaySeconds, effOrig, effOwner, receiver); + uint256 backing = address(this).balance; + uint256 escrowed = _totalEscrowed[address(0)]; + uint256 delta = backing > escrowed ? backing - escrowed : 0; + if (delta < amount) revert ReceivedAmountMismatch(address(0), delta, amount); + id = _record( + address(0), amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, false + ); + } + + /// @notice UNCONDITIONAL native-RBTC sink. Accepts + /// native from ANYONE with **no storage reads and no sender gate**. + /// + /// Why unconditional: the real Rootstock WRBTC `withdraw()` returns + /// native via a 2300-gas `transfer` stipend. Any storage-slot sender + /// check here (SLOAD ≥ 2100 cold under EIP-2929/Paris) exceeds that + /// stipend, so a sender-gated `receive()` `OutOfGas`-bricks every + /// `unwrapOnDelivery` (native `burnToBTC`) payout after unlock — + /// empirically reproduced (`ExitDelayQueueUnwrapStipend`). Dropping + /// the gate is SAFE because the already neutralizes stray or + /// donated native: the two measured-receipt ingress paths credit + /// EXACTLY `amount` when the non-backing surplus `>= amount` and never + /// mis-credit, so unsolicited RBTC (including a `selfdestruct` + /// force-send the old gate could not stop anyway) only accrues as + /// `sweepSurplus`-able surplus. Accepted trade-off: the queue no + /// longer asserts "only ActivePool pays in native" — defense-in-depth + /// the made redundant. + receive() external payable virtual {} + + // ─── Ingress helpers ──────────────────────────────────────────────── + + function _validateIngress( + address, /*token — reserved for future per-token gating*/ + uint128 amount, + uint32 delaySeconds, + address effOrig, + address effOwner, + address receiver + ) internal view { + if (amount == 0) revert ZeroAmount(); + // AmountTooLarge: the record* ABI takes `amount` as uint128 + // deliberately (keeps ExitRequest word-1 packing). The uint256→ + // uint128 narrowing therefore happens in the CALLER (the Perimeter hook / + // 0.5.x product host), which MUST `require(userAmount <= type(uint128).max) + // else AmountTooLarge` BEFORE narrowing — that check is the live + // queue-boundary guard, in the caller's pragma, against silent truncation + // (IExitDelayQueue NatSpec). The queue re-asserting it on an already- + // uint128 arg would be a compile-time tautology, so the guard is kept at + // the boundary where a uint256 actually exists, not duplicated as dead code + // here. We still reject a below-floor delay and zero-address parties. + if (delaySeconds < minimumDelaySeconds) revert DelayBelowFloor(delaySeconds, minimumDelaySeconds); + if (effOrig == address(0) || effOwner == address(0) || receiver == address(0)) revert ZeroAddress(); + } + + function _record( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrapOnDelivery + ) internal returns (uint256 id) { + id = ++lastRequestId; + + // Write field-by-field into storage to keep the stack shallow (a struct + // literal with 11 members + the 9-arg event blows the 0.8.20 stack + // without via-ir; this repo pins non-via-ir to match the deployed + // Perimeter bytecode profile). + ExitRequest storage r = _requests[id]; + r.amount = amount; + r.createdAt = uint64(block.timestamp); + r.unlockAt = uint64(block.timestamp + delaySeconds); + r.originator = effOrig; + r.owner = effOwner; + r.receiver = receiver; + r.token = token; + r.surfaceId = surfaceId; + r.subProduct = subProduct; + r.status = ExitStatus.Queued; + r.unwrapOnDelivery = unwrapOnDelivery; + + // Dual-key insert; EnumerableSet dedups when originator == owner. + _activeByParty[effOrig].add(id); + _activeByParty[effOwner].add(id); + + _totalEscrowed[token] += amount; + + emit ExitQueued(id, effOrig, effOwner, receiver, token, amount, r.unlockAt, surfaceId, subProduct); + } + + // ─── Execution ─────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + /// @dev Pays the request's immutable `receiver`. A reverting receiver rolls + /// the whole call back (fail-closed) — the request stays Queued and + /// the rightful parties retry via `executeExit` (once the recipient is + /// fixed) or `recoverStuckExit` (redirect leg). No redirect here. + function executeExit(uint256 requestId) external nonReentrant { + _executeOne(requestId); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Strict array order; atomic (any revert rolls the whole batch back). + /// A duplicate id flips to Executed on the first pass, then + /// hits AlreadyTerminal on the second — never double-pays. Always pays + /// the immutable receiver (no redirect in the batch path). + function executeExits(uint256[] calldata ids) external nonReentrant { + if (ids.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < ids.length; ++i) { + _executeOne(ids[i]); + } + } + + /// @dev Shared execution core — always pays the immutable `receiver`. + /// Block gate covers `{originator, owner, receiver}`. CEI: terminal + /// status + escrow decrement + set removal ALL before the external transfer + /// (`nonReentrant`). + function _executeOne(uint256 requestId) internal { + if (securityPerimeterPaused) revert QueuePaused(); + ExitRequest storage r = _requests[requestId]; + if (r.status == ExitStatus.None) revert UnknownRequest(requestId); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(requestId); + if (block.timestamp < r.unlockAt) revert NotUnlocked(requestId, r.unlockAt); + if (msg.sender != r.originator && msg.sender != r.owner) revert NotExecutor(msg.sender); + + _requireNotBlocked(r.originator); + _requireNotBlocked(r.owner); + _requireNotBlocked(r.receiver); + + address token = r.token; + uint128 amount = r.amount; + address receiver = r.receiver; + bool unwrap = r.unwrapOnDelivery; + + r.status = ExitStatus.Executed; + _removeActive(requestId, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + emit ExitExecuted(requestId, receiver, token, amount); + _payout(token, receiver, amount, unwrap); + _requireSolventAfterPayout(token, unwrap); + } + + // ─── Stuck-exit recovery — verify-by-attempting redirect leg ── + + /// @inheritdoc IExitDelayQueue + /// @dev Stuck-exit recovery redesign. + /// A bouncing honest recipient is not a perimeter-specific problem (the + /// same withdrawal would bounce without the delay), so recovery is + /// SELF-SERVICE by the frozen-metadata `{originator, owner}` set (the + /// receiver is NEVER an executor) — no admin/Owner path, NO stored failure + /// flag, and the stored request is NEVER re-targeted (`altReceiver` is a + /// payout-time destination only, so + hold). + /// + /// VERIFY-BY-ATTEMPTING: after CEI (status → Executed, escrow decremented, + /// sets pruned), the STORED-receiver payout is attempted FIRST. If it + /// SUCCEEDS, that is the payout and `altReceiver` is unused — a HEALTHY + /// exit is NEVER redirected (no arbitrary redirect; Model-B stays + /// rejected). Only if the stored-receiver payout genuinely REVERTS do we + /// pay `altReceiver`; if `altReceiver` also fails, the whole call reverts + /// and the funds stay Queued (CEI rollback). + /// + /// ALL-FOUR-ACTOR block gate: none of `{originator, owner, STORED + /// receiver, altReceiver}` may be Frozen/Blacklisted. Gating the STORED + /// receiver is the must-fix — a blocked/hacked original receiver refuses + /// recovery entirely (this leg can never move its funds), keeping the + /// blacklist-trap and the / receiver-only dead-end intact (that case + /// is Leg-3's). `altReceiver` is guarded: not 0/this/token/wrbtc. + function recoverStuckExit(uint256 id, address altReceiver) external nonReentrant { + if (securityPerimeterPaused) revert QueuePaused(); + ExitRequest storage r = _requests[id]; + if (r.status == ExitStatus.None) revert UnknownRequest(id); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); + if (block.timestamp < r.unlockAt) revert NotUnlocked(id, r.unlockAt); + if (msg.sender != r.originator && msg.sender != r.owner) revert NotExecutor(msg.sender); + + address token = r.token; + address receiver = r.receiver; + + // altReceiver guard: a payout-time destination, never the zero + // address, this contract (would trap escrow), the escrowed token, or WRBTC + // (a wrapped-token destination would silently swallow an unwrap payout). + if ( + altReceiver == address(0) || altReceiver == address(this) || altReceiver == token + || altReceiver == wrbtc + ) revert InvalidAltReceiver(altReceiver); + + // All-four-actor block gate: originator, owner, the STORED receiver, + // and altReceiver must all be unblocked. Gating the STORED receiver means a + // blocked/hacked original receiver refuses recovery here (→ Leg-3). + _requireNotBlocked(r.originator); + _requireNotBlocked(r.owner); + _requireNotBlocked(receiver); + _requireNotBlocked(altReceiver); + + // CEI: terminal status + escrow decrement + set removal BEFORE + // any external transfer, so a failed attempt or reentry cannot double-spend. + uint128 amount = r.amount; + bool unwrap = r.unwrapOnDelivery; + + r.status = ExitStatus.Executed; + _removeActive(id, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + // Attempt the STORED-receiver payout first (a healthy exit pays here and + // altReceiver is never used, even when altReceiver == receiver). Only on a + // GENUINE bounce do we fall through to altReceiver — tracked by an explicit + // bool, NOT an address compare, so a request whose stored receiver equals + // altReceiver is paid exactly ONCE. + address paid; + if (_tryPayout(token, receiver, amount, unwrap)) { + paid = receiver; + } else { + // Original bounced — pay altReceiver; if THIS also fails, the whole call + // reverts (CEI rollback leaves the request Queued). + _payout(token, altReceiver, amount, unwrap); + paid = altReceiver; + } + // Outside the try/catch above on purpose: a solvency failure must end + // the call, not be mistaken for the stored receiver bouncing. + _requireSolventAfterPayout(token, unwrap); + emit ExitExecuted(id, paid, token, amount); + } + + /// @notice Gas budget the stored-receiver payout attempt is given, and the + /// floor the caller must leave beyond it. A genuine receiver revert + /// returns control with the floor intact; only a receiver that needs + /// MORE than the budget is read as a bounce. Fixing the budget and + /// requiring the frame to hold it means the fall-through to + /// `altReceiver` cannot be reached by starving the attempt: a caller + /// who supplies too little gas reverts the whole call instead of + /// redirecting a healthy exit. The budget covers any realistic ERC20 + /// transfer or WRBTC unwrap-and-send with wide margin. + uint256 public constant RECOVER_PAYOUT_GAS = 3_000_000; + uint256 internal constant RECOVER_GAS_FLOOR = 200_000; + + /// @dev Catchable single-payout attempt for `recoverStuckExit`. Routes the + /// transfer through an EXTERNAL self-call so a reverting recipient is + /// caught (Solidity cannot catch a low-level revert inline) and the leg + /// can fall through to `altReceiver`. Returns false on a genuine bounce. + /// Self-only (`msg.sender == address(this)`); NOT `nonReentrant` — it runs + /// inside `recoverStuckExit`'s guard, and CEI already made the state safe. + /// + /// The explicit gas cap plus the pre-check are the guard against a false + /// bounce: without a fixed budget, EIP-150's 63/64 rule lets a caller + /// pick a gas limit that out-of-gases the attempt (caught here as a + /// "bounce") while the retained 1/64 still completes the `altReceiver` + /// payout — redirecting a healthy exit. Reserving the budget and + /// reverting when the frame cannot cover it closes that path. + function _tryPayout(address token, address to, uint128 amount, bool unwrap) internal returns (bool) { + if (gasleft() < RECOVER_PAYOUT_GAS + RECOVER_GAS_FLOOR) revert InsufficientGasForRecovery(); + try this.payoutExternal{gas: RECOVER_PAYOUT_GAS}(token, to, amount, unwrap) { + return true; + } catch { + return false; + } + } + + /// @notice Internal payout trampoline — only callable by the contract itself + /// (via `_tryPayout`). Reverts on a bouncing recipient so the caller's + /// try/catch can fall through. Not part of the external ABI surface for + /// any other caller (the `SelfOnly` guard makes a direct call revert). + function payoutExternal(address token, address to, uint128 amount, bool unwrap) external { + if (msg.sender != address(this)) revert SelfOnly(); + _payout(token, to, amount, unwrap); + } + + // ─── Block model ────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function freezeFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + _blockFromRequest(requestId, freezeReceiver, reasonHash, BlockState.Frozen); + } + + /// @inheritdoc IExitDelayQueue + function blacklistFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + _blockFromRequest(requestId, freezeReceiver, reasonHash, BlockState.Blacklisted); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Batch by-request-id. Resolves each + /// request's `{originator, owner}` (+ `receiver` if `freezeReceiver`) and + /// blocks all in ONE tx. Whole-batch ATOMIC: one unknown id reverts the + /// whole batch (like `executeExits`). Last-write-wins trigger/reason per + /// The emergency speed lever: block every owner + delegate behind a + /// set of known-malicious requests in a single Admin-multisig call. + function freezeFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + if (requestIds.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < requestIds.length; ++i) { + _blockFromRequest(requestIds[i], freezeReceiver, reasonHash, BlockState.Frozen); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev Batch by-request-id blacklist. Same atomicity + last-write- + /// wins semantics as the batch freeze above; a Frozen→Blacklisted + /// escalation within a batch is handled by `_setBlock`. + function blacklistFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + if (requestIds.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < requestIds.length; ++i) { + _blockFromRequest(requestIds[i], freezeReceiver, reasonHash, BlockState.Blacklisted); + } + } + + function _blockFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash, BlockState to) + internal + { + ExitRequest storage r = _requests[requestId]; + if (r.status == ExitStatus.None) revert UnknownRequest(requestId); + // originator always; owner too iff distinct; receiver only if flagged. + _setBlock(r.originator, to, requestId, reasonHash); + if (r.owner != r.originator) _setBlock(r.owner, to, requestId, reasonHash); + if (freezeReceiver) _setBlock(r.receiver, to, requestId, reasonHash); + } + + /// @inheritdoc IExitDelayQueue + function freeze(address a) external onlyAdminOrOwner { + _setBlock(a, BlockState.Frozen, 0, bytes32(0)); + } + + /// @inheritdoc IExitDelayQueue + function blacklist(address a) external onlyAdminOrOwner { + _setBlock(a, BlockState.Blacklisted, 0, bytes32(0)); + } + + /// @inheritdoc IExitDelayQueue + function unfreeze(address a) external onlyAdminOrOwner { + _clearBlock(a, BlockState.Frozen); + } + + /// @inheritdoc IExitDelayQueue + function unblacklist(address a) external onlyAdminOrOwner { + _clearBlock(a, BlockState.Blacklisted); + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard: an empty array is a caller mistake, not a + /// silent no-op — for consistency with the by-id batch variants. + function freeze(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _setBlock(a[i], BlockState.Frozen, 0, bytes32(0)); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard. + function blacklist(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _setBlock(a[i], BlockState.Blacklisted, 0, bytes32(0)); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard. + function unfreeze(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _clearBlock(a[i], BlockState.Frozen); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard. + function unblacklist(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _clearBlock(a[i], BlockState.Blacklisted); + } + } + + /// @dev Set (or escalate) a block. Frozen→Blacklisted is atomic (no unfreeze + /// first). Re-block on an already-`to` state is a state no-op that + /// refreshes trigger + reason (last-write-wins). + function _setBlock(address a, BlockState to, uint256 triggerId, bytes32 reasonHash) internal { + if (a == address(0)) revert ZeroAddress(); + // No Blacklisted → Frozen downgrade: a blacklist is only cleared by + // unblacklist. A `freeze` on an already-Blacklisted address is a no-op + // that still refreshes trigger/reason (does not downgrade). + BlockState from = _blockState[a]; + if (to == BlockState.Frozen && from == BlockState.Blacklisted) { + // Hold the stronger state. A freeze carrying no evidence (the plain + // `freeze(address)` passes triggerId 0 / reason 0) must not erase the + // blacklist's recorded trigger and reason; only a by-request freeze + // that carries real evidence refreshes them. + if (triggerId != 0 || reasonHash != bytes32(0)) { + _blockTrigger[a] = triggerId; + emit AccountBlocked(a, from, triggerId, reasonHash); + } + return; + } + if (from == BlockState.None) { + _blockedAccounts.add(a); + } + _blockState[a] = to; + _blockTrigger[a] = triggerId; + emit AccountBlocked(a, to, triggerId, reasonHash); + } + + /// @dev Clear a block. `expected` selects which removal fn ran: unfreeze + /// requires Frozen, unblacklist requires Blacklisted (wrong-removal + /// reverts — footgun guard). Absent (`None`) always reverts. + function _clearBlock(address a, BlockState expected) internal { + BlockState from = _blockState[a]; + if (expected == BlockState.Frozen) { + if (from != BlockState.Frozen) revert NotFrozen(a); + } else { + // expected == Blacklisted + if (from != BlockState.Blacklisted) revert NotBlacklisted(a); + } + _blockState[a] = BlockState.None; + _blockTrigger[a] = 0; + _blockedAccounts.remove(a); + emit AccountUnblocked(a, from); + } + + function _requireNotBlocked(address a) internal view { + BlockState s = _blockState[a]; + if (s != BlockState.None) revert ActorBlocked(a, s); + } + + // ─── Pause ─────────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function setSecurityPerimeterPaused(bool p) external onlyAdminOrOwner { + securityPerimeterPaused = p; + emit SecurityPerimeterPausedSet(p); + } + + // ─── Recovery ──────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + /// @dev Leg-2: recover once `isBlacklisted(originator) || isBlacklisted(owner)` + /// (OR predicate); exact provenance match to an active route; a + /// mixed batch reverts wholesale. + function resolveToProtocol(uint256[] calldata ids, bytes32 routeId) + external + nonReentrant + onlyAdminOrOwner + { + if (ids.length == 0) revert EmptyIds(); + RecoveryRoute storage route = _recoveryRoutes[routeId]; + if (!route.active) revert RouteInactive(routeId); + for (uint256 i = 0; i < ids.length; ++i) { + uint256 id = ids[i]; + ExitRequest storage r = _requests[id]; + if (r.status == ExitStatus.None) revert UnknownRequest(id); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); + + // OR-blacklist authorization over source parties; receiver-only + // block never authorizes Leg-2. + bool authorized = _blockState[r.originator] == BlockState.Blacklisted + || _blockState[r.owner] == BlockState.Blacklisted; + if (!authorized) revert SourceNotBlacklisted(r.originator); + + // Exact provenance: surface, subProduct, token must match. + if (r.surfaceId != route.surfaceId || r.subProduct != route.subProduct || r.token != route.token) + { + revert RouteProvenanceMismatch(id, routeId); + } + + address token = r.token; + uint128 amount = r.amount; + r.status = ExitStatus.ResolvedToProtocol; + _removeActive(id, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + emit ExitResolvedToProtocol(id, routeId, route.destination, amount); + // topUpPool routes are a plain token top-up to the pool + // (destination == subProduct); both branches use the same primitive. + _payout(token, route.destination, amount, false); + _requireSolventAfterPayout(token, false); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev Leg-3: Owner catch-all, bounded to a request that is blocked, paused, + /// or still inside its delay window. A request past its unlock time with + /// no party blocked and the queue unpaused is out of reach here — the + /// delay window is deliberately in scope, because holding a withdrawal + /// until it unlocks so a detected theft can be resolved away is the whole + /// point of the queue. + function resolveBySIP(uint256[] calldata ids, address destination) external nonReentrant onlyOwner { + if (ids.length == 0) revert EmptyIds(); + // Same destination guard as recoverStuckExit's altReceiver: never the + // zero address, this contract (would trap the escrow), or WRBTC (a + // wrapped-token destination silently swallows an unwrap payout). The + // per-request `destination == token` case is rejected inside the loop. + if (destination == address(0) || destination == address(this) || destination == wrbtc) { + revert InvalidDestination(destination); + } + for (uint256 i = 0; i < ids.length; ++i) { + uint256 id = ids[i]; + ExitRequest storage r = _requests[id]; + if (r.status == ExitStatus.None) revert UnknownRequest(id); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); + + // Bounded predicate: blocked | paused | locked. A fully-unblocked, + // unlocked, unpaused request is out of reach. A bouncing (but + // unblocked) recipient is NOT admitted here — it is handled + // self-service via recoverStuckExit(id, altReceiver). + bool resolvable = _isBlocked(r.originator) || _isBlocked(r.owner) || _isBlocked(r.receiver) + || securityPerimeterPaused || block.timestamp < r.unlockAt; + if (!resolvable) revert NotResolvableBySIP(id); + + address token = r.token; + if (destination == token) revert InvalidDestination(destination); + uint128 amount = r.amount; + r.status = ExitStatus.ResolvedBySIP; + _removeActive(id, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + emit ExitResolvedBySIP(id, destination, amount); + _payout(token, destination, amount, false); + _requireSolventAfterPayout(token, false); + } + } + + function _isBlocked(address a) internal view returns (bool) { + return _blockState[a] != BlockState.None; + } + + /// @inheritdoc IExitDelayQueue + function setRecoveryRoute(RecoveryRoute calldata route) external onlyOwner returns (bytes32 routeId) { + // Destination guard, same as the resolve legs: never the zero address, + // this contract (would trap escrow), the escrowed token, or WRBTC (a + // wrapped-token destination swallows an unwrap payout). A topUpPool route + // is pinned tighter still, to route.subProduct, below. + if ( + route.destination == address(0) || route.destination == address(this) + || route.destination == route.token || route.destination == wrbtc + ) revert InvalidDestination(route.destination); + // topUpPool routes restricted on-chain to feasible surfaces and + // to non-native tokens (a native request can never be Leg-2a). + if (route.topUpPool) { + if (!_topUpFeasible[route.surfaceId]) revert TopUpInfeasibleSurface(route.surfaceId); + if (route.token == address(0)) revert TopUpInfeasibleSurface(route.surfaceId); + // A top-up route means exactly one thing: restore the pool the exit + // came from. Without this, a route registered as `topUpPool` could + // name any destination, and a Leg-2 recovery would carry a blocked + // user's escrow there under the top-up label. + if (route.destination != route.subProduct) { + revert TopUpDestinationMismatch(route.destination, route.subProduct); + } + } + routeId = keccak256(abi.encode(route.surfaceId, route.subProduct, route.token, route.destination)); + _recoveryRoutes[routeId] = route; + _recoveryRouteIds.add(routeId); + emit RecoveryRouteSet( + routeId, route.surfaceId, route.subProduct, route.token, route.destination, route.topUpPool + ); + } + + /// @inheritdoc IExitDelayQueue + function removeRecoveryRoute(bytes32 routeId) external onlyOwner { + delete _recoveryRoutes[routeId]; + _recoveryRouteIds.remove(routeId); + emit RecoveryRouteRemoved(routeId); + } + + /// @inheritdoc IExitDelayQueue + function setTopUpFeasible(bytes32 surfaceId, bool feasible) external onlyOwner { + _topUpFeasible[surfaceId] = feasible; + emit TopUpFeasibleSet(surfaceId, feasible); + } + + // ─── Config ────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function addAllowedSource(address src) external onlyOwner { + if (src == address(0)) revert ZeroAddress(); + if (_allowedSources.add(src)) { + _allowedSource[src] = true; + emit AllowedSourceSet(src, true); + } + } + + /// @inheritdoc IExitDelayQueue + function removeAllowedSource(address src) external onlyOwner { + if (_allowedSources.remove(src)) { + _allowedSource[src] = false; + emit AllowedSourceSet(src, false); + } + } + + /// @inheritdoc IExitDelayQueue + function setNativePusher(address pusher) external onlyOwner { + nativePusher = pusher; + emit NativePusherSet(pusher); + } + + /// @notice Rotate the Admin guardian. Non-zero; MAY equal the Owner. + function setAdmin(address newAdmin) external onlyOwner { + if (newAdmin == address(0)) revert ZeroAddress(); + admin = newAdmin; + emit AdminSet(newAdmin); + } + + event AdminSet(address indexed admin); + + /// @inheritdoc IExitDelayQueue + function setMinimumDelaySeconds(uint32 s) external onlyOwner { + minimumDelaySeconds = s; + emit MinimumDelaySet(s); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Moves EXACTLY the non-backing surplus (balanceOf − totalEscrowed); + /// provably never touches escrowed backing via the post-sweep solvency + /// require. Unlocks the equality form of /. + function sweepSurplus(address token, address to) external nonReentrant onlyOwner { + if (to == address(0)) revert SweepToZero(); + uint256 escrowed = _totalEscrowed[token]; + if (token == address(0)) { + uint256 bal = address(this).balance; + uint256 surplus = bal > escrowed ? bal - escrowed : 0; + emit SurplusSwept(token, to, surplus); + if (surplus > 0) Address.sendValue(payable(to), surplus); + if (address(this).balance < escrowed) revert SolvencyViolated(); + } else { + uint256 bal = IERC20(token).balanceOf(address(this)); + uint256 surplus = bal > escrowed ? bal - escrowed : 0; + emit SurplusSwept(token, to, surplus); + if (surplus > 0) IERC20(token).safeTransfer(to, surplus); + if (IERC20(token).balanceOf(address(this)) < escrowed) revert SolvencyViolated(); + } + } + + // ─── Payout primitive ─────────────────────────────────────────────── + + /// @dev ERC20 safeTransfer / native sendValue / WRBTC-unwrap-then-sendValue. + /// No fail-open: a reverting receiver rolls the whole call back + /// (status already terminal → held until Leg-3 redirects). + function _payout(address token, address to, uint128 amount, bool unwrap) internal { + if (token == address(0)) { + Address.sendValue(payable(to), amount); + } else if (unwrap) { + // WRBTC-escrowed: unwrap to native, then send native (Option B). + IWRBTC(token).withdraw(amount); + Address.sendValue(payable(to), amount); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + /// @dev Every request still queued must remain fully backed after value + /// leaves. A successful `safeTransfer` proves only that the token did + /// not revert: a token that charges the SENDER on transfer — including + /// an upgradeable one that only starts doing so after funds are + /// escrowed — moves more than `amount` out while `_totalEscrowed` drops + /// by exactly `amount`, leaving later exits short. `sweepSurplus` + /// already enforces this after its own transfer; the payout paths are + /// the other way value leaves, and must hold the same invariant. + /// + /// Asserted by the CALLER, after the payout, and never inside `_payout` + /// itself. `recoverStuckExit` attempts the stored receiver inside a + /// try/catch whose bare `catch` reads ANY revert as that receiver + /// bouncing: a solvency failure raised in there would be taken for a + /// bounce and redirect the funds to the caller-selected alternate + /// receiver, bypassing the immutable one and hiding the alarm. Raised + /// out here it cannot be caught. + function _requireSolvent(address token) internal view { + uint256 balance = token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this)); + if (balance < _totalEscrowed[token]) revert SolvencyViolated(); + } + + /// @dev Solvency for whichever balances a payout of `token` could move. The + /// unwrap path spends WRBTC and sends native, so it touches both. + function _requireSolventAfterPayout(address token, bool unwrap) internal view { + _requireSolvent(token); + if (unwrap && token != address(0)) _requireSolvent(address(0)); + } + + // ─── Active-index maintenance ─────────────────────────────────────── + + /// @dev Remove an id from both party sets; single removal when equal. + function _removeActive(uint256 id, address originator, address owner_) internal { + _activeByParty[originator].remove(id); + if (owner_ != originator) _activeByParty[owner_].remove(id); + } + + // ─── Views ─────────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function getRequest(uint256 id) external view returns (ExitRequest memory) { + return _requests[id]; + } + + /// @inheritdoc IExitDelayQueue + /// @dev Best-effort over a mutating set: a concurrent removal can + /// skip/repeat; the ExitQueued/ExitExecuted events are the authoritative + /// reconstruction source. `nextCursor == 0` signals end. + function getActive(address party, uint256 cursor, uint256 n) + external + view + returns (uint256[] memory ids, uint256 nextCursor) + { + if (n > MAX_GET_ACTIVE_PAGE) n = MAX_GET_ACTIVE_PAGE; + EnumerableSet.UintSet storage set = _activeByParty[party]; + uint256 len = set.length(); + if (cursor >= len || n == 0) { + return (new uint256[](0), 0); + } + uint256 end = cursor + n; + if (end > len) end = len; + ids = new uint256[](end - cursor); + for (uint256 i = cursor; i < end; ++i) { + ids[i - cursor] = set.at(i); + } + nextCursor = end >= len ? 0 : end; + } + + /// @inheritdoc IExitDelayQueue + function blockStateOf(address a) external view returns (BlockState) { + return _blockState[a]; + } + + /// @inheritdoc IExitDelayQueue + function blockedAccounts(uint256 offset, uint256 limit) + external + view + returns (address[] memory page, uint256 total) + { + // Return the FULL blocked-set size `total` alongside the clamped `page`: + // a monitor/sanctions integrator paging + // this view knows the exact range ("showing offset..offset+page.length of + // total") and can never silently undercount past the 500 cap. `total` is + // the true EnumerableSet length regardless of offset/limit. + // + // Page clamp/cap (retained): cap the page size at + // MAX_GET_ACTIVE_PAGE and derive `end` from a bounded `limit` so + // `offset + limit` can never overflow-revert (a griefy caller passing a + // near-max offset/limit). + uint256 len = _blockedAccounts.length(); + total = len; + if (limit > MAX_GET_ACTIVE_PAGE) limit = MAX_GET_ACTIVE_PAGE; + if (offset >= len || limit == 0) return (new address[](0), total); + uint256 end = offset + limit; + if (end > len) end = len; + page = new address[](end - offset); + for (uint256 i = offset; i < end; ++i) { + page[i - offset] = _blockedAccounts.at(i); + } + } + + /// @inheritdoc IExitDelayQueue + function blockTrigger(address a) external view returns (uint256) { + return _blockTrigger[a]; + } + + /// @inheritdoc IExitDelayQueue + function totalEscrowed(address token) external view returns (uint256) { + return _totalEscrowed[token]; + } + + /// @inheritdoc IExitDelayQueue + function getRecoveryRoute(bytes32 routeId) external view returns (RecoveryRoute memory) { + return _recoveryRoutes[routeId]; + } + + /// @inheritdoc IExitDelayQueue + function allowedSources() external view returns (address[] memory) { + return _allowedSources.values(); + } + + /// @notice Enumerate registered recovery route ids (owner tooling). + function recoveryRouteIds() external view returns (bytes32[] memory) { + return _recoveryRouteIds.values(); + } + + /// @notice Whether a topUpPool route may be registered for `surfaceId`. + function topUpFeasible(bytes32 surfaceId) external view returns (bool) { + return _topUpFeasible[surfaceId]; + } + + /// @notice Whether `src` is a registered ingress source. + function isAllowedSource(address src) external view returns (bool) { + return _allowedSource[src]; + } + + // ─── Upgrade authorization (UUPS) ─────────────────────────────────── + + // aderyn-ignore-next-line(centralization-risk) + function _authorizeUpgrade(address newImplementation) internal view override onlyOwner { + if (newImplementation == address(0)) revert UpgradeImplZero(); + } + + /// @dev Disabled: an ownerless custody contract would lock escrowed funds + /// forever (no execute-side auth changes, but no upgrade / no config / + /// no recovery-config). The 2-step transfer is the only admin move. + function renounceOwnership() public pure override { + revert OwnershipCannotBeRenounced(); + } + + // NOTE the + // `_transferOwnership` chokepoint override (Admin != Owner enforced on + // the ownership side) was REMOVED together with the initialize/setAdmin + // owner-equality checks — admin == owner is a supported shape (the + // governance Safe holds both roles at launch). Consequence, accepted: + // while the roles coincide, the Leg-2/Leg-3 authority split and + // the bounds are vacuous; they become real when ownership moves to + // Bitocracy. +} diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index aae1f13..100813c 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -9,7 +9,7 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet import {IExitFeeController} from "./interfaces/IExitFeeController.sol"; /// @title ExitFeeController -/// @notice Governance-owned resolver for ExitFee (ColFee) policy. Three +/// @notice Governance-owned resolver for ExitFee (Perimeter) policy. Three /// RatePolicy tiers per surface: actor → sub-product → surface. /// Most-specific *active* entry wins; the surface itself gates the /// surface (if its `active` flag is false, overrides do not apply). @@ -33,7 +33,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // A `surfaceId` is an opaque `bytes32` naming an operation kind. The // controller stores it as-is and never inspects or decodes it -- the // contract is product/asset-agnostic. The off-chain naming convention - // is `keccak256("COLFEE:")`; nothing on-chain enforces or depends + // is `keccak256("")`; nothing on-chain enforces or depends // on it. Adding a surface is therefore an owner-only // `setSurfacePolicy(newId, policy)` call -- never a contract upgrade. @@ -57,16 +57,39 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // 254 _actorPolicy mapping head // 255 _subProductKeys mapping head (enumeration index) // 256 _actorKeys mapping head (enumeration index) - // 257 ExitFeeController.admin (address, 20 bytes; slot offset 0, - // 12 bytes free). - // 258 .. 300 __gap[43] -- preserves the OZ-style 50-slot namespace - // (50 - 7 own slots used). // - // Upgrades that add storage to THIS contract MUST consume from __gap - // and reduce its length by exactly the number of slots added. They - // MUST NOT reorder, insert, or change the type of any preceding slot. + // 257 admin (20 bytes, offset 0) -- alone; the remaining + // 12 bytes of the slot are intentionally unused. + // + // 258 _surfaceBypass mapping head + // 259 _subProductBypass mapping head + // 260 _actorBypass mapping head + // 261 _surfaceBypassKeys._values (Bytes32Set array head) ┐ 2 slots + // 262 _surfaceBypassKeys._indexes (mapping head) ┘ + // 263 _subProductBypassKeys mapping head (enumeration index) + // 264 _actorBypassKeys mapping head (enumeration index) + // 265 _passthroughActor nested-mapping head (surface-scoped) + // 266 _passthroughKeys mapping head (enumeration index) + // 267 _bypassSurfaceIds._values (Bytes32Set array head) ┐ 2 slots + // 268 _bypassSurfaceIds._indexes (mapping head) ┘ + // 269 _passthroughSurfaceIds._values (Bytes32Set array head) ┐ 2 slots + // 270 _passthroughSurfaceIds._indexes (mapping head) ┘ + // 271 securityPerimeterEnabled (1 byte) + globalDelaySeconds + // (4 bytes) -- PACKED; 27 bytes of the slot are unused. + // 272 .. 300 __gap[29] -- preserves the OZ-style 50-slot namespace + // (50 - 21 own slots used). + // + // Own slots: 251 + 252..256 + 257 + 258..271 = 21, so __gap = 50 - 21 = 29 + // and the namespace ends at slot 300. + // + // Upgrades that add storage to THIS contract MUST consume from __gap and + // reduce its length by exactly the number of slots added. They MUST NOT + // reorder, insert, or change the type of any preceding slot. In + // particular, nothing may be declared before `admin`, and nothing may be + // packed into the free bytes of its slot. using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; /// @notice Global enablement flag. When false, every `quoteExitFee` /// call returns `INACTIVE` regardless of per-surface configuration. @@ -130,16 +153,116 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// @notice Fast operational guardian. A SINGLE stored address checked by /// `onlyAdminOrOwner` -- NOT an OZ AccessControl role (the /// controller stays single-Owner for configuration). It authorizes - /// only the operational levers `setExitFeeEnabled` and - /// `setFeeReceiver`; policy setters, removals, `setAdmin` itself, - /// and UUPS upgrades stay `onlyOwner`. MAY equal the owner -- - /// nothing requires the two authorities to be distinct. Unset - /// (`address(0)`) until the owner appoints one; while unset, - /// `onlyAdminOrOwner` admits only the owner. + /// the perimeter kill switch and the operational levers + /// `setExitFeeEnabled` and `setFeeReceiver`; policy setters, + /// removals, `setAdmin` itself, and UUPS upgrades stay `onlyOwner`. + /// MAY equal the owner -- nothing requires the two authorities to + /// be distinct. Unset (`address(0)`) until the owner appoints one; + /// while unset, `onlyAdminOrOwner` admits only the owner. + /// @dev Occupies slot 257 at offset 0, alone. Its position is fixed: + /// nothing may be declared before it, and nothing may be packed + /// into the free bytes beside it. address public admin; + // ─── Delay policy state ───────────────────────────────────────────── + + /// @dev Surface-tier delay bypass. Key: `surfaceId`. Value: + /// `DelayBypassPolicy {active, bypass}`. Mirrors `_surfacePolicy`'s + /// shape but is INDEPENDENT of it — the delay resolver never + /// reads the fee `surfacePolicy.active`. + mapping(bytes32 => IExitFeeController.DelayBypassPolicy) internal _surfaceBypass; + + /// @dev Sub-product-tier delay bypass. Outer key `surfaceId`, inner key + /// `subProduct` (non-zero). Wins over the surface tier when active. + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _subProductBypass; + + /// @dev Actor-tier delay bypass (most specific). Outer key `surfaceId`, + /// inner key `actor` (non-zero) — evaluated on `effOrig` ONLY (there is + /// NO global actor bypass). Wins over sub-product and surface. + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _actorBypass; + + /// @dev Enumeration index for the SURFACE-tier bypass. A single + /// flat `Bytes32Set` of every `surfaceId` with a configured surface bypass + /// — surface bypasses are keyed by `surfaceId` alone and are NOT scoped by + /// a parent surface, so `surfaceBypassKeys()` takes NO argument. Backed on + /// chain so `InspectController` / monitoring can dump every surface bypass + /// (no unenumerable zero-delay state). Retains soft-retired entries + /// (`active=false`); `removeSurfaceBypass` hard-removes. + EnumerableSet.Bytes32Set internal _surfaceBypassKeys; + + /// @dev Enumeration indexes for the sub-product / actor bypass tiers (mirror + /// `_subProductKeys` / `_actorKeys`). Entries are retained on + /// soft-retire (`active=false`); use `removeSubProductBypass` / + /// `removeActorBypass` for hard removal. Consumed by `InspectController`. + mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductBypassKeys; + mapping(bytes32 => EnumerableSet.AddressSet) internal _actorBypassKeys; + + /// @dev Surface-scoped passthrough-actor registry. A passthrough + /// registered for `surfaceId` resolves to the `receiver` in + /// `effectiveActor`; it NEVER collapses identities globally — the + /// wrapper is registered only under `SURFACE_LENDING_LENDER_WITHDRAW`, + /// so margin/Zero (no entry) keep `effOrig = raw`, `effOwner = owner`. + /// Co-located here (not in the escrow queue) so the hook normalizes + /// WITHOUT touching the queue, keeping the kill switch queue-independent + /// . + mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; + + /// @dev Enumeration index for the surface-scoped passthrough registry. + /// Outer key `surfaceId`, values = every passthrough + /// address registered under it. Unlike the bypass key-sets this is kept + /// exact-to-live: an address is added on register and DROPPED on + /// deregister (`setPassthroughActor(.., false)`), because a passthrough is + /// a boolean membership with no soft-retire state to preserve. Gives the + /// passthrough registry the same enumerability as the bypass tiers (no + /// events-only blind spot on a security-critical registry). + mapping(bytes32 => EnumerableSet.AddressSet) internal _passthroughKeys; + + /// @dev ANY-TIER-TOUCHED master surface-id set for delay bypasses. + /// Every bypass WRITER — `_writeSurfaceBypass` (via + /// `setSurfaceBypass`), `_writeSubProductBypass`, `_writeActorBypass` — + /// records its `surfaceId` here, so a surface that carries ONLY a + /// sub-product- or actor-tier bypass (the most common exemption shape, + /// actor tier) is discoverable even though it was never passed to + /// `setSurfaceBypass`. This is the root-cause fix: the per-tier key-sets + /// (`_surfaceBypassKeys` / `_subProductBypassKeys` / `_actorBypassKeys`) + /// only tell you WHICH keys exist UNDER a known surfaceId — they cannot + /// by themselves enumerate the surfaceIds. `bypassSurfaceIds()` closes + /// that gap so NO zero-delay / identity config is invisible to + /// `InspectController`. Entries are NEVER dropped (soft-retire retention), + /// so a `removeSurfaceBypass` while sub/actor entries remain live does not + /// remove the id from discovery — the inspector still probes every tier. + EnumerableSet.Bytes32Set internal _bypassSurfaceIds; + + /// @dev ANY-TIER-TOUCHED master surface-id set for the passthrough registry. + /// `setPassthroughActor(surfaceId, .., true)` records + /// the `surfaceId` here, so a passthrough-only surface (no bypass entry, + /// not a named fee surface) is still enumerable. Like the passthrough + /// key-set it is retention-only at the SURFACE level: a surfaceId stays + /// recorded even after every passthrough under it is deregistered (the + /// per-surface `_passthroughKeys` set going empty is the live signal; + /// keeping the surfaceId costs one slot and guarantees the inspector never + /// loses the probe point). `passthroughSurfaceIds()` exposes it. + EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; + + /// @notice Global kill switch for the DELAY perimeter. Independent of + /// `exitFeeEnabled`: turning fees off does NOT disable the + /// perimeter, and a fee-inactive surface can still be delay-active. + /// When false, `quoteExitDelayFor` short-circuits to + /// `(0, raw, owner)` without consulting the bypass tiers, the + /// passthrough registry, or the queue. + bool public securityPerimeterEnabled; + + /// @notice One delay for EVERY surface (uint32 gives ~136 years of head + /// room). There is no per-surface delay *duration* -- only the + /// per-tier bypass toggles above exempt a surface, sub-product or + /// actor. The `>= queue.minimumDelaySeconds` relationship is a + /// liveness invariant enforced PER-REQUEST in the queue, not a + /// cross-contract setter guard here: the controller never reads or + /// calls the queue. + uint32 public globalDelaySeconds; + // aderyn-ignore-next-line(unused-state-variable) - uint256[43] private __gap; + uint256[29] private __gap; // ─── Custom errors ────────────────────────────────────────────────── @@ -150,24 +273,17 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable error LengthMismatch(); error OwnershipCannotBeRenounced(); error UpgradeImplZero(); + + // ─── Custom errors (admin role) ───────────────────────────────────── error NotAdminOrOwner(address caller); // onlyAdminOrOwner gate error AdminZero(); // setAdmin(address(0)) - // ─── Events (admin role) ──────────────────────────────────────────── - // - // Declared on the implementation rather than in IExitFeeController: - // that file is the cross-pragma interface consumed by product code and - // mirrored by the v0_4 variant, and the two must stay ABI-identical. - // The admin role is an owner-side surface that product code never - // touches, so the shared interface stays untouched. - - event AdminSet(address indexed admin); - // ─── Modifiers ────────────────────────────────────────────────────── - /// @dev The `admin` guardian OR the `Ownable2Step` owner. Gates only - /// the operational levers (`setExitFeeEnabled`, `setFeeReceiver`); - /// every other setter stays `onlyOwner`. + /// @dev The `Admin` guardian OR the `Ownable2Step` owner. Gates the + /// perimeter kill switch and — since the core + /// merge — the fee levers `setExitFeeEnabled` / + /// `setFeeReceiver`. All other setters are `onlyOwner`. modifier onlyAdminOrOwner() { if (msg.sender != admin && msg.sender != owner()) revert NotAdminOrOwner(msg.sender); _; @@ -217,7 +333,12 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable revert OwnershipCannotBeRenounced(); } - // ─── Admin: global state ──────────────────────────────────────────── + // NOTE the + // `_transferOwnership` chokepoint override (Admin != Owner enforced on + // the ownership side) was REMOVED together with `setAdmin`'s + // owner-equality check — admin == owner is a supported shape (the + // governance Safe holds both roles at launch). The queue's counterpart + // was removed in the same change. /// @notice Appoint (or rotate) the operational guardian checked by /// `onlyAdminOrOwner`. Owner-only. `address(0)` is rejected -- @@ -232,6 +353,8 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable emit AdminSet(newAdmin); } + // ─── Admin: global state ──────────────────────────────────────────── + /// @notice Flip the global kill switch. While `false`, every /// `quoteExitFee` returns `INACTIVE` and product hooks no-op. /// All other configuration is preserved across flips. @@ -256,10 +379,41 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable emit FeeReceiverSet(newReceiver); } + // ─── Admin: delay global state ─────────────── + + /// @notice Flip the DELAY perimeter kill switch. `onlyAdminOrOwner` + /// (the fee levers joined this gate in) — both + /// directions, for sub-minute incident response. Independent of + /// `exitFeeEnabled`: + /// disabling fees does NOT disable the perimeter and vice-versa. + /// Accepted residual: a rogue Admin can DISABLE the perimeter (a + /// protocol-wide fail-OPEN delay removal) — the deliberate tradeoff. + /// @param enabled Target state for the perimeter. + // aderyn-ignore-next-line(centralization-risk) + function setSecurityPerimeterEnabled(bool enabled) external onlyAdminOrOwner { + securityPerimeterEnabled = enabled; + emit SecurityPerimeterEnabledSet(enabled); + } + + /// @notice Set the single global delay applied whenever a delay is imposed. + /// Owner-only. The `>= queue.minimumDelaySeconds` floor + /// is a documented liveness invariant enforced PER-REQUEST in the + /// queue and by a deploy-script assertion — NOT a + /// cross-contract setter guard, so the controller never reads or + /// calls the queue (kill-switch queue-independence). A value + /// below the floor would self-brick every non-bypassed exit + /// (fail-closed) but can NEVER rush a request below the floor. + /// @param seconds_ Delay in seconds (uint32; 0 is allowed and disables the + /// delay for all non-bypassed exits, equivalent to a global bypass). + function setGlobalDelaySeconds(uint32 seconds_) external onlyOwner { + globalDelaySeconds = seconds_; + emit GlobalDelaySet(seconds_); + } + // ─── Admin: policy setters ────────────────────────────────────────── // // `surfaceId` is an opaque operation-kind identifier. The off-chain - // naming convention is `keccak256("COLFEE:")`; the controller + // naming convention is `keccak256("")`; the controller // stores the bytes32 as-is and never inspects the name. /// @notice Configure (or update) the surface tier for `surfaceId`. @@ -267,7 +421,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// the whole surface is off and sub-product / actor overrides /// are ignored. Overwriting is idempotent. /// @param surfaceId Opaque operation-kind identifier; - /// `keccak256("COLFEE:")` by convention. + /// `keccak256("")` by convention. /// @param policy Active flag + rate in basis points (`rateBps <= MAX_BPS`). function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external onlyOwner { if (policy.rateBps > MAX_BPS) revert RateExceedsMaxBps(policy.rateBps); @@ -435,6 +589,216 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable } } + // ─── Admin: delay bypass tiers ──────────────────── + // + // Mirrors the fee-tier setters but on `DelayBypassPolicy`. A + // `{active:true, bypass:true}` entry EXEMPTS the surface/subProduct/actor + // (d = 0, instant); a `{active:true, bypass:false}` entry FORCES the global + // delay (overriding a broader bypass). `{active:false}` falls through. The + // enumeration keys retain soft-retired entries; use the removers for hard + // removal. Precedence and the truth table live in `_resolveDelay` below. + + /// @notice Configure (or update) the surface-tier delay bypass for + /// `surfaceId`. Overwriting is idempotent. + function setSurfaceBypass(bytes32 surfaceId, IExitFeeController.DelayBypassPolicy calldata policy) + external + onlyOwner + { + _writeSurfaceBypass(surfaceId, policy); + } + + function _writeSurfaceBypass(bytes32 surfaceId, IExitFeeController.DelayBypassPolicy calldata policy) + internal + { + _surfaceBypass[surfaceId] = policy; + // Per-tier enumeration index. Idempotent: add returns + // false if already present. Retained on soft-retire (`active=false`). + // aderyn-ignore-next-line(unchecked-return) + _surfaceBypassKeys.add(surfaceId); + // ANY-TIER-TOUCHED master set: record the surfaceId so the + // inspector discovers it regardless of which tier configured it. + // aderyn-ignore-next-line(unchecked-return) + _bypassSurfaceIds.add(surfaceId); + emit SurfaceBypassSet(surfaceId, policy.active, policy.bypass); + } + + /// @notice Hard-remove a surface-tier delay bypass: clears the stored policy + /// and drops the surfaceId from the enumeration index. Idempotent -- + /// a remove for a surfaceId not currently in the index is a successful + /// no-op (no event, no revert). To temporarily disable while keeping it + /// visible in the inspector, use `setSurfaceBypass(id, {false, false})`. + function removeSurfaceBypass(bytes32 surfaceId) external onlyOwner { + if (_surfaceBypassKeys.remove(surfaceId)) { + delete _surfaceBypass[surfaceId]; + emit SurfaceBypassRemoved(surfaceId); + } + } + + /// @notice Configure (or update) a sub-product-tier delay bypass. Records + /// the address in the enumeration index. `subProduct` non-zero. + function setSubProductBypass( + bytes32 surfaceId, + address subProduct, + IExitFeeController.DelayBypassPolicy calldata policy + ) external onlyOwner { + _writeSubProductBypass(surfaceId, subProduct, policy); + } + + /// @notice Batch variant of `setSubProductBypass`. Reverts on length + /// mismatch or a zero address; a revert mid-batch undoes the batch. + function setSubProductBypasses( + bytes32 surfaceId, + address[] calldata subProducts, + IExitFeeController.DelayBypassPolicy[] calldata policies + ) external onlyOwner { + uint256 len = subProducts.length; + if (len != policies.length) revert LengthMismatch(); + for (uint256 i = 0; i < len; ++i) { + _writeSubProductBypass(surfaceId, subProducts[i], policies[i]); + } + } + + /// @notice Configure (or update) an actor-tier delay bypass (most specific; + /// evaluated on `effOrig`). `actor` non-zero. + function setActorBypass( + bytes32 surfaceId, + address actor, + IExitFeeController.DelayBypassPolicy calldata policy + ) external onlyOwner { + _writeActorBypass(surfaceId, actor, policy); + } + + /// @notice Batch variant of `setActorBypass`. Same revert semantics as + /// `setSubProductBypasses`. + function setActorBypasses( + bytes32 surfaceId, + address[] calldata actors, + IExitFeeController.DelayBypassPolicy[] calldata policies + ) external onlyOwner { + uint256 len = actors.length; + if (len != policies.length) revert LengthMismatch(); + for (uint256 i = 0; i < len; ++i) { + _writeActorBypass(surfaceId, actors[i], policies[i]); + } + } + + function _writeSubProductBypass( + bytes32 surfaceId, + address subProduct, + IExitFeeController.DelayBypassPolicy calldata policy + ) internal { + if (subProduct == address(0)) revert SubProductZero(); + _subProductBypass[surfaceId][subProduct] = policy; + // aderyn-ignore-next-line(unchecked-return) + _subProductBypassKeys[surfaceId].add(subProduct); + // ANY-TIER-TOUCHED master set: a sub-product-only bypass + // under an arbitrary surfaceId is otherwise undiscoverable — record it. + // aderyn-ignore-next-line(unchecked-return) + _bypassSurfaceIds.add(surfaceId); + emit SubProductBypassSet(surfaceId, subProduct, policy.active, policy.bypass); + } + + function _writeActorBypass( + bytes32 surfaceId, + address actor, + IExitFeeController.DelayBypassPolicy calldata policy + ) internal { + if (actor == address(0)) revert ActorZero(); + _actorBypass[surfaceId][actor] = policy; + // aderyn-ignore-next-line(unchecked-return) + _actorBypassKeys[surfaceId].add(actor); + // ANY-TIER-TOUCHED master set: the actor tier is the MOST + // COMMON exemption shape — an actor-only bypass with no prior + // setSurfaceBypass MUST still surface the id to the inspector. + // aderyn-ignore-next-line(unchecked-return) + _bypassSurfaceIds.add(surfaceId); + emit ActorBypassSet(surfaceId, actor, policy.active, policy.bypass); + } + + /// @notice Hard-remove a sub-product-tier delay bypass: clears the stored + /// policy and drops it from the enumeration index. Idempotent. + function removeSubProductBypass(bytes32 surfaceId, address subProduct) external onlyOwner { + _removeSubProductBypass(surfaceId, subProduct); + } + + /// @notice Batch variant of `removeSubProductBypass`. + function removeSubProductBypasses(bytes32 surfaceId, address[] calldata subProducts) external onlyOwner { + uint256 len = subProducts.length; + for (uint256 i = 0; i < len; ++i) { + _removeSubProductBypass(surfaceId, subProducts[i]); + } + } + + /// @notice Hard-remove an actor-tier delay bypass. Idempotent. + function removeActorBypass(bytes32 surfaceId, address actor) external onlyOwner { + _removeActorBypass(surfaceId, actor); + } + + /// @notice Batch variant of `removeActorBypass`. + function removeActorBypasses(bytes32 surfaceId, address[] calldata actors) external onlyOwner { + uint256 len = actors.length; + for (uint256 i = 0; i < len; ++i) { + _removeActorBypass(surfaceId, actors[i]); + } + } + + function _removeSubProductBypass(bytes32 surfaceId, address subProduct) internal { + if (subProduct == address(0)) revert SubProductZero(); + if (_subProductBypassKeys[surfaceId].remove(subProduct)) { + delete _subProductBypass[surfaceId][subProduct]; + emit SubProductBypassRemoved(surfaceId, subProduct); + } + } + + function _removeActorBypass(bytes32 surfaceId, address actor) internal { + if (actor == address(0)) revert ActorZero(); + if (_actorBypassKeys[surfaceId].remove(actor)) { + delete _actorBypass[surfaceId][actor]; + emit ActorBypassRemoved(surfaceId, actor); + } + } + + // ─── Admin: surface-scoped passthrough registry ─────────────── + + /// @notice Register/deregister a surface-scoped passthrough actor. + /// A passthrough registered for `surfaceId` normalizes to the + /// `receiver` in `effectiveActor` / `quoteExitDelayFor`. Owner-only + /// and — because `bypass=true` is equivalent to zero delay and a + /// passthrough rewrites the block key — as security-critical as the + /// source-registry. NEVER collapses identities globally: the + /// wrapper is registered ONLY under `SURFACE_LENDING_LENDER_WITHDRAW`, + /// so margin/Zero keep their raw identities. + /// @param surfaceId Operation-kind identifier. + /// @param a Passthrough contract (e.g. the RBTCWrapperProxy). + /// @param isPassthrough True to register, false to deregister. + // aderyn-ignore-next-line(centralization-risk) + function setPassthroughActor(bytes32 surfaceId, address a, bool isPassthrough) external onlyOwner { + if (a == address(0)) revert ActorZero(); + _passthroughActor[surfaceId][a] = isPassthrough; + // Keep the enumeration index exact-to-live: a passthrough is + // a boolean membership with no soft-retire state, so add on register and + // drop on deregister. add/remove return values are intentionally unchecked + // (idempotent — a repeat register or a deregister of an absent entry is a + // successful no-op that still emits, matching the setter's overwrite + // semantics). + if (isPassthrough) { + // aderyn-ignore-next-line(unchecked-return) + _passthroughKeys[surfaceId].add(a); + // ANY-TIER-TOUCHED master set: record the surfaceId on + // register so a passthrough-only surface (no bypass entry, not a named + // fee surface) is still a probe point for the inspector. Surface-level + // retention: the id stays even after every passthrough under it is + // deregistered — the per-surface `_passthroughKeys` set going empty is + // the live signal, and keeping the id guarantees the probe point. + // aderyn-ignore-next-line(unchecked-return) + _passthroughSurfaceIds.add(surfaceId); + } else { + // aderyn-ignore-next-line(unchecked-return) + _passthroughKeys[surfaceId].remove(a); + } + emit PassthroughActorSet(surfaceId, a, isPassthrough); + } + // ─── Policy views ─────────────────────────────────────────────────── /// @notice Surface tier (gate + default rate) for `surfaceId`. Returns @@ -488,6 +852,103 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable return _actorKeys[surfaceId].values(); } + // ─── Delay views ────────────────────────────────── + + /// @notice Surface-tier delay bypass for `surfaceId`. Zero-initialised if + /// never configured (`{active:false, bypass:false}`). + function surfaceBypass(bytes32 surfaceId) + external + view + returns (IExitFeeController.DelayBypassPolicy memory) + { + return _surfaceBypass[surfaceId]; + } + + /// @notice Sub-product-tier delay bypass for `(surfaceId, subProduct)`. + function subProductBypass(bytes32 surfaceId, address subProduct) + external + view + returns (IExitFeeController.DelayBypassPolicy memory) + { + return _subProductBypass[surfaceId][subProduct]; + } + + /// @notice Actor-tier delay bypass for `(surfaceId, actor)`. + function actorBypass(bytes32 surfaceId, address actor) + external + view + returns (IExitFeeController.DelayBypassPolicy memory) + { + return _actorBypass[surfaceId][actor]; + } + + /// @notice Every surfaceId configured in the surface-tier delay-bypass index. + /// Takes NO argument — surface bypasses are keyed by + /// `surfaceId` alone, NOT scoped by a parent surface. Entries are not + /// removed when a bypass is set inactive; pair each id with + /// `surfaceBypass(id)` to see the live state, or use + /// `removeSurfaceBypass(id)` for hard removal. Intended for + /// `InspectController` / monitoring so there is no unenumerable + /// zero-delay state. + /// @return Snapshot of every configured surface-bypass surfaceId. + function surfaceBypassKeys() external view returns (bytes32[] memory) { + return _surfaceBypassKeys.values(); + } + + /// @notice Every sub-product configured under `surfaceId` in the delay + /// bypass index. Same retention semantics as `subProductKeys`. + function subProductBypassKeys(bytes32 surfaceId) external view returns (address[] memory) { + return _subProductBypassKeys[surfaceId].values(); + } + + /// @notice Every actor configured under `surfaceId` in the delay bypass + /// index. Same retention semantics as `actorKeys`. + function actorBypassKeys(bytes32 surfaceId) external view returns (address[] memory) { + return _actorBypassKeys[surfaceId].values(); + } + + /// @notice ANY-TIER-TOUCHED master set: EVERY surfaceId that has a bypass + /// entry at ANY tier — surface, sub-product, OR actor. + /// This is the discovery driver for `InspectController`: unlike + /// `surfaceBypassKeys()` (surface-tier writes only), this records the + /// surfaceId from `setSurfaceBypass`, `setSubProductBypass`, AND + /// `setActorBypass`, so a surface carrying ONLY a sub-product- or + /// actor-tier bypass (the most common exemption shape) is never missed. + /// Retention-only: entries are never dropped, so a `removeSurfaceBypass` + /// while sub/actor entries remain live keeps the id in discovery — the + /// inspector re-probes every tier per id and shows the live state. + /// @return Snapshot of every surfaceId touched by any bypass tier. + function bypassSurfaceIds() external view returns (bytes32[] memory) { + return _bypassSurfaceIds.values(); + } + + /// @notice ANY-TIER-TOUCHED master set for the passthrough registry: every + /// surfaceId under which a passthrough has been registered. + /// Drives `InspectController`'s passthrough dump so a + /// passthrough-only surface (no bypass entry, not a named fee surface) + /// is still enumerable. Surface-level retention: an id stays recorded + /// even after every passthrough under it is deregistered. + /// @return Snapshot of every surfaceId touched by the passthrough registry. + function passthroughSurfaceIds() external view returns (bytes32[] memory) { + return _passthroughSurfaceIds.values(); + } + + /// @notice Every passthrough address registered under `surfaceId`. + /// Exact-to-live: an address enters on `setPassthroughActor(.., true)` + /// and is dropped on `setPassthroughActor(.., false)`. Gives the + /// security-critical passthrough registry the same on-chain + /// enumerability as the bypass tiers (no events-only blind spot). + /// @param surfaceId See `setSurfacePolicy`. + /// @return Snapshot of every live passthrough address under `surfaceId`. + function passthroughKeys(bytes32 surfaceId) external view returns (address[] memory) { + return _passthroughKeys[surfaceId].values(); + } + + /// @notice Whether `a` is a surface-scoped passthrough for `surfaceId`. + function passthroughActor(bytes32 surfaceId, address a) external view returns (bool) { + return _passthroughActor[surfaceId][a]; + } + // ─── Quote ────────────────────────────────────────────────────────── /// @inheritdoc IExitFeeController @@ -578,4 +1039,87 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // Surface fallback (always active here, since we checked above). return surface; } + + // ─── Delay quote + resolution ───────────────────── + + /// @inheritdoc IExitFeeController + function effectiveActor(bytes32 surfaceId, address raw, address receiver) public view returns (address) { + // A passthrough registered FOR THIS SURFACE resolves to the receiver; + // otherwise identity. Never collapses identities globally — a + // surface with no passthrough entry returns `raw` unchanged, so margin + // and Zero keep their real originator/owner. + return _passthroughActor[surfaceId][raw] ? receiver : raw; + } + + /// @inheritdoc IExitFeeController + function quoteExitDelayFor( + address rawOriginator, + address owner_, + address receiver, + bytes32 surfaceId, + address subProduct + ) external view returns (uint32 d, address effOrig, address effOwner) { + // KILL-SWITCH SHORT-CIRCUIT FIRST: a disabled perimeter pays + // direct WITHOUT consulting the passthrough registry or the queue — the + // liveness escape. Returns RAW identities; the hook must ignore them and + // pay direct whenever d == 0, so the raw identities never record. + if (!securityPerimeterEnabled) { + return (0, rawOriginator, owner_); + } + + // Resolve the surface-scoped effective identities, then quote on + // effOrig, so the quote and the record share ONE identity. + effOrig = effectiveActor(surfaceId, rawOriginator, receiver); + effOwner = effectiveActor(surfaceId, owner_, receiver); + d = _resolveDelay(surfaceId, subProduct, effOrig); + } + + /// @inheritdoc IExitFeeController + function quoteExitDelay(bytes32 surfaceId, address subProduct, address effectiveActor_) + external + view + returns (uint32) + { + // Handles the disabled-perimeter case identically (returns 0 when the + // perimeter is off) so an off-chain caller of the inner view never gets + // a non-zero delay while the perimeter is disabled. Callers pass an + // ALREADY-effective actor (never a raw wrapper). + if (!securityPerimeterEnabled) return 0; + return _resolveDelay(surfaceId, subProduct, effectiveActor_); + } + + /// @dev The 3-tier delay resolver (truth table). Evaluated on + /// the EFFECTIVE originator. Precedence mirrors the fee resolver — + /// actor > subProduct > surface, most-specific-*active*-wins — but the + /// SOLE gate is `securityPerimeterEnabled` (checked by the callers + /// above): the delay resolver is INDEPENDENT of the fee + /// `surfacePolicy.active` and `exitFeeEnabled`. A `{active:false}` tier + /// falls through; an `{active:true}` tier decides — `bypass ? 0 : + /// globalDelaySeconds`. With no active bypass tier the default is + /// `globalDelaySeconds` (delayed). An active `{bypass:false}` tier at a + /// more-specific level overrides a broader bypass. + function _resolveDelay(bytes32 surfaceId, address subProduct, address effOrig) + internal + view + returns (uint32) + { + // Actor tier (most specific). No global actor bypass — always keyed + // [surfaceId][effOrig]. + IExitFeeController.DelayBypassPolicy memory a = _actorBypass[surfaceId][effOrig]; + if (a.active) return a.bypass ? 0 : globalDelaySeconds; + + // Sub-product tier. address(0) means "no per-instance dimension" (e.g. + // Zero) — skip the lookup so a sibling sub-product cannot leak in. + if (subProduct != address(0)) { + IExitFeeController.DelayBypassPolicy memory s = _subProductBypass[surfaceId][subProduct]; + if (s.active) return s.bypass ? 0 : globalDelaySeconds; + } + + // Surface tier. + IExitFeeController.DelayBypassPolicy memory f = _surfaceBypass[surfaceId]; + if (f.active) return f.bypass ? 0 : globalDelaySeconds; + + // No active bypass tier: default is the global delay (delayed). + return globalDelaySeconds; + } } diff --git a/src/interfaces/IExitDelayQueue.sol b/src/interfaces/IExitDelayQueue.sol new file mode 100644 index 0000000..28be59a --- /dev/null +++ b/src/interfaces/IExitDelayQueue.sol @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/// @title IExitDelayQueue +/// @notice External ABI + type/event/error catalog for `ExitDelayQueue`, the +/// per-request escrow that holds the *user* leg of an exit for a +/// configurable delay so a detected theft can be blocked (frozen or +/// blacklisted) and routed to recovery before the funds leave. +/// +/// Authoritative build spec: +/// This interface mirrors the +/// complete function catalog and the event/error catalog; the +/// types mirror +/// +/// Types (enums/structs) are declared here so cross-pragma callers +/// and off-chain tooling share one source of truth. The queue itself +/// is 0.8.20 UUPS; the four `record*` ingress fns are consumed from +/// 0.5.x / 0.6.x product hosts via a minimal interface stub. +interface IExitDelayQueue { + // ─── Types ─────────────────────────────────────────────────── + + /// @notice Per-request lifecycle. `None` is the zero value (never stored + /// for a live id); the three terminal states are mutually + /// exclusive and a request leaves `Queued` at most once. + enum ExitStatus { + None, // 0 — never recorded + Queued, // 1 — escrowed, awaiting execute / recovery + Executed, // 2 — paid to receiver (terminal) + ResolvedToProtocol, // 3 — Leg-2 recovery-away (terminal) + ResolvedBySIP // 4 — Leg-3 DAO catch-all (terminal) + + } + + /// @notice Per-address block state. `Frozen` = temporary (investigating); + /// `Blacklisted` = confirmed hack. Execution treats both as + /// "blocked"; recovery-away distinguishes them. + enum BlockState { + None, // 0 + Frozen, // 1 — temporary, cleared by unfreeze + Blacklisted // 2 — confirmed, cleared only by unblacklist + + } + + /// @notice An immutable exit request. Every field except `status` is + /// frozen at record time. Packed into 7 words. + struct ExitRequest { + // word 1 (128 + 64 + 64 = 256 bits): + uint128 amount; // narrowed from the uint256 Perimeter amount at record + uint64 createdAt; // audit/analytics; emitted in ExitQueued + uint64 unlockAt; // COMPUTED by the queue = createdAt + delaySeconds + // words 2-5: + address originator; // withdrawal caller (effective, post-normalization) — block key + executor + address owner; // position owner — MANDATORY block key + executor + address receiver; // immutable payout destination — block key iff freezeReceiver; NOT an executor + address token; // address(0) = native RBTC + // word 6: + bytes32 surfaceId; // provenance: recovery-route key + // word 7 (160 + 8 + 8 = 176 bits): + address subProduct; // provenance: iToken / converter / address(0) + ExitStatus status; // uint8 + bool unwrapOnDelivery; // Option B: queue holds WRBTC, executeExit unwraps → native RBTC + } + + /// @notice A pre-approved Leg-2 recovery route. `routeId` is + /// `keccak256(abi.encode(surfaceId, subProduct, token, destination))`. + struct RecoveryRoute { + bool active; + bytes32 surfaceId; + address subProduct; + address token; + address destination; + bool topUpPool; // 2a: plain top-up of the originating pool (destination == subProduct) + } + + // ─── Events ────────────────────────────────────────────────── + + event ExitQueued( + uint256 indexed id, + address indexed originator, + address indexed owner, + address receiver, + address token, + uint128 amount, + uint64 unlockAt, + bytes32 surfaceId, + address subProduct + ); + event ExitExecuted(uint256 indexed id, address indexed receiver, address token, uint128 amount); + event ExitResolvedToProtocol( + uint256 indexed id, bytes32 indexed routeId, address destination, uint128 amount + ); + event ExitResolvedBySIP(uint256 indexed id, address indexed destination, uint128 amount); + event AccountBlocked( + address indexed account, BlockState state, uint256 indexed triggerRequestId, bytes32 reasonHash + ); + event AccountUnblocked(address indexed account, BlockState fromState); + event RecoveryRouteSet( + bytes32 indexed routeId, + bytes32 surfaceId, + address subProduct, + address token, + address destination, + bool topUpPool + ); + event RecoveryRouteRemoved(bytes32 indexed routeId); + event AllowedSourceSet(address indexed source, bool allowed); + event TopUpFeasibleSet(bytes32 indexed surfaceId, bool feasible); + event MinimumDelaySet(uint32 seconds_); + event SecurityPerimeterPausedSet(bool paused); + event NativePusherSet(address indexed pusher); + event SurplusSwept(address indexed token, address indexed to, uint256 amount); // + + // ─── Custom errors ─────────────────────────────────────────── + + error UnregisteredSource(address caller); // onlyAllowedSource — DISTINCT record-path halt selector + error ActorBlocked(address actor, BlockState state); // execution-gate revert (event: AccountBlocked) + error NotExecutor(address caller); // msg.sender ∉ {originator, owner} + error NotUnlocked(uint256 id, uint64 unlockAt); + error QueuePaused(); + error AlreadyTerminal(uint256 id); // status != Queued at a transition (also duplicate-batch-id) + error UnknownRequest(uint256 id); + error DelayBelowFloor(uint32 delay, uint32 floor); + error AmountTooLarge(uint256 amount); // uint256→uint128 narrowing guard + error AmountMismatch(uint256 msgValue, uint256 amount); // native value-carrying + error ReceivedAmountMismatch(address token, uint256 have, uint256 want); // pull / measured-delta proof + error ZeroAmount(); + error RouteInactive(bytes32 routeId); + error RouteProvenanceMismatch(uint256 id, bytes32 routeId); + error TopUpInfeasibleSurface(bytes32 surfaceId); // setRecoveryRoute topUpPool guard + error TopUpDestinationMismatch(address destination, address subProduct); // top-up must pay its own pool + error SourceNotBlacklisted(address src); // Leg-2 OR-predicate not satisfied + error NotBlacklisted(address a); // unblacklist on a non-Blacklisted address + error NotFrozen(address a); // unfreeze on a non-Frozen address + error NotResolvableBySIP(uint256 id); // Leg-3 bounded predicate not satisfied + error UnwrapNonWrbtc(); // unwrapOnDelivery set on a non-WRBTC token (guard) + error InvalidAltReceiver(address altReceiver); // recoverStuckExit altReceiver ∈ {0,this,token,wrbtc} + error SelfOnly(); // payoutExternal trampoline is self-call-only + error ZeroAddress(); + error EmptyIds(); + error SweepToZero(); + error SolvencyViolated(); // post-transfer balance < totalEscrowed + + // ─── Ingress ────────────────────────────────────────── + + /// @dev CALLER-SIDE NARROWING PRECONDITION. Every + /// `record*` takes `amount` as a **`uint128`**, deliberately NOT widened + /// to `uint256`. The Perimeter hook computes the user leg as a `uint256` and + /// MUST narrow it (`uint128(userAmount)`) at the call site; that narrowing + /// is the caller's responsibility and MUST be preceded by the caller's own + /// `require(userAmount <= type(uint128).max)` (`AmountTooLarge`) so a value + /// that would silently truncate is rejected UPSTREAM, before any escrow + /// accounting. The queue itself CANNOT re-assert this on an + /// already-`uint128` argument — the `AmountTooLarge` error is declared for + /// that CALLER-side hook boundary, not re-checked here. Keeping + /// the ABI at `uint128` also packs `amount` into `ExitRequest` word 1 + /// — widening would cost a whole extra storage word per request. + + function recordERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrapOnDelivery + ) external returns (uint256 id); + + function recordReceivedERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256 id); + + function recordNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external payable returns (uint256 id); + + function recordReceivedNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256 id); + + // ─── Execution ─────────────────────────────────────────────── + + function executeExit(uint256 requestId) external; + + function executeExits(uint256[] calldata ids) external; + + /// @notice Verify-by-attempting stuck-exit recovery. Callable ONLY by the + /// frozen-metadata `{originator, owner}` set (same as `executeExit`; the + /// receiver is NEVER an executor). Requires the request Queued, unlocked, + /// and the queue not paused. + /// + /// Attempts the STORED-receiver payout FIRST; pays `altReceiver` ONLY if + /// the stored-receiver payout genuinely bounces — so a HEALTHY exit is + /// never redirected (no arbitrary redirect; Model-B stays rejected) + /// and there is NO stored failure flag. If `altReceiver` also fails, the + /// whole call reverts (funds stay Queued). + /// + /// Block gate covers ALL FOUR actors — `{originator, owner, STORED + /// receiver, altReceiver}`: a blocked/hacked original receiver refuses + /// recovery entirely (→ Leg-3), preserving the blacklist-trap and + /// the / dead-end. `altReceiver` is guarded: reverts if it is + /// `0`, this contract, the request token, or WRBTC. The stored request is + /// NEVER re-targeted (`altReceiver` is a payout-time destination only), so + /// request immutability and the block gate still hold. + function recoverStuckExit(uint256 id, address altReceiver) external; + + // ─── Block model ───────────────────────────────────────────── + + function freezeFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) external; + function blacklistFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) external; + + // Batch by-request-id — whole-batch atomic (one bad + // id reverts all, like executeExits); last-write-wins trigger/reason per + function freezeFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external; + function blacklistFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external; + + function freeze(address a) external; + function blacklist(address a) external; + function unfreeze(address a) external; + function unblacklist(address a) external; + + // Batch by-address: each reverts `EmptyIds()` on + // an empty array, for API consistency with the by-id batch variants + // (`executeExits` / batch `freezeFromRequest` / `resolveToProtocol` / + // `resolveBySIP`) — an empty batch is a caller mistake, never a silent no-op. + function freeze(address[] calldata a) external; + function blacklist(address[] calldata a) external; + function unfreeze(address[] calldata a) external; + function unblacklist(address[] calldata a) external; + + // ─── Pause ─────────────────────────────────────────────────── + + function setSecurityPerimeterPaused(bool p) external; + + // ─── Recovery ──────────────────────────────────────────────── + + function resolveToProtocol(uint256[] calldata ids, bytes32 routeId) external; + function resolveBySIP(uint256[] calldata ids, address destination) external; + + function setRecoveryRoute(RecoveryRoute calldata route) external returns (bytes32 routeId); + function removeRecoveryRoute(bytes32 routeId) external; + function setTopUpFeasible(bytes32 surfaceId, bool feasible) external; + + // ─── Config ────────────────────────────────────────── + + function addAllowedSource(address src) external; + function removeAllowedSource(address src) external; + function setNativePusher(address pusher) external; + function setMinimumDelaySeconds(uint32 s) external; + function sweepSurplus(address token, address to) external; + + // ─── Views ─────────────────────────────────────────────────── + + function getRequest(uint256 id) external view returns (ExitRequest memory); + function getActive(address party, uint256 cursor, uint256 n) + external + view + returns (uint256[] memory ids, uint256 nextCursor); + function blockStateOf(address a) external view returns (BlockState); + + /// @notice Paginate the blocked set (Frozen ∪ Blacklisted). + /// @param offset First index into the blocked set to return. + /// @param limit Requested page size; clamped to `MAX_GET_ACTIVE_PAGE` (500). + /// @return page The clamped slice `[offset, offset + page.length)` of the + /// blocked set (empty when `offset >= total` or `limit == 0`). + /// @return total The FULL blocked-set size (EnumerableSet length), independent + /// of `offset`/`limit` — so a caller/monitor knows the whole + /// range ("showing offset..offset+page.length of total") and + /// never silently undercounts past the 500-entry page cap. + function blockedAccounts(uint256 offset, uint256 limit) + external + view + returns (address[] memory page, uint256 total); + function blockTrigger(address a) external view returns (uint256); + + function totalEscrowed(address token) external view returns (uint256); + function getRecoveryRoute(bytes32 routeId) external view returns (RecoveryRoute memory); + function allowedSources() external view returns (address[] memory); + + /// @notice Max page size for the paginated `getActive` / `blockedAccounts` + /// views. Public constant, so paging is + /// self-describing on-chain (500). + function MAX_GET_ACTIVE_PAGE() external view returns (uint256); +} diff --git a/src/interfaces/IExitDelayQueueHost.sol b/src/interfaces/IExitDelayQueueHost.sol new file mode 100644 index 0000000..652f3c5 --- /dev/null +++ b/src/interfaces/IExitDelayQueueHost.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/// @title IExitDelayQueueHost — the queue-pointer surface a product host exposes +/// @notice Minimal interface for the `setExitDelayQueue` pointer. +/// Each product storage host — the `sovrynProtocol` singleton (lending + +/// loan/margin) and the Zero `BorrowerOperations` proxy — holds its OWN +/// queue address in a NEW EIP-1967-style unstructured slot +/// `keccak256("sovryn.perimeterExitDelayQueue") - 1`, set by `setExitDelayQueue` +/// (gated by the SAME host admin as `setExitFeeController`, Owner/Timelock, +/// and rotatable) and read back via `exitDelayQueue()`. +/// +/// @dev The host CONTRACTS live in the product repos (Sovryn-smart-contracts-perimeter +/// / zero-contracts-perimeter) on the 0.5.x / 0.6.x pragmas; this 0.8.20 stub +/// exists ONLY so the perimeter deploy/wire script (05) can call the pointer +/// setter and read it back over the cross-pragma ABI when the host addresses +/// are supplied. It intentionally declares nothing else — the queue itself +/// is never a host and never implements this. +interface IExitDelayQueueHost { + /// @notice Point this host's exit-delay reroute at `queue` (wire/rotate). + /// Owner/Timelock-gated on the host; a no-op in perimeter (hosts are external). + function setExitDelayQueue(address queue) external; + + /// @notice The host's current queue pointer (0 = reroute unwired ⇒ direct pay). + function exitDelayQueue() external view returns (address); +} diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index 05c142b..ee29110 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -18,15 +18,15 @@ pragma solidity >=0.5.17 <0.9.0; pragma experimental ABIEncoderV2; /// @title IExitFeeController -/// @notice Cross-pragma interface for the ExitFee (ColFee) controller. One +/// @notice Cross-pragma interface for the ExitFee (Perimeter) controller. One /// file for every consumer on 0.5.17, 0.6.11, and 0.8.20. Consumers /// on 0.4.26 use the structurally-different variant in `v0_4/`, /// which must stay ABI-identical to this file. interface IExitFeeController { // ─── Types ──────────────────────────────────────────────────────────── - /// @notice Reason a `ColFeeSkipped` event was emitted instead of an - /// `ColFeeApplied`. NONE covers honest paths (positive charge, + /// @notice Reason a `ExitFeeSkipped` event was emitted instead of an + /// `ExitFeeApplied`. NONE covers honest paths (positive charge, /// dust, or actor-exemption); the rest cover off-state outcomes. enum SkipReason { NONE, // Controller computed an honest quote (charge / dust / zero-rate). @@ -45,6 +45,22 @@ interface IExitFeeController { uint16 rateBps; } + /// @notice A single delay-bypass entry (the DELAY feature). Lives + /// at each of the three bypass tiers (actor → sub-product → surface), + /// mirroring `RatePolicy`'s shape. + /// + /// `active == false` ⇒ the tier is not configured; resolution falls + /// through to the next-broader tier. `active == true` ⇒ this tier + /// decides: `bypass == true` exempts the exit (`d = 0`, instant), + /// `bypass == false` FORCES the global delay (overriding a broader + /// bypass). It is a bypass/exemption toggle only — there is NO + /// per-instance delay *duration* (a single `globalDelaySeconds` + /// applies whenever a delay is imposed). + struct DelayBypassPolicy { + bool active; + bool bypass; + } + /// @notice Quote returned by `quoteExitFee`. `reason` carries the precise /// off-state code; `active` is the resolved policy state (true iff /// a RatePolicy.active entry was used and reason ∈ {NONE}). @@ -69,6 +85,21 @@ interface IExitFeeController { event SubProductPolicyRemoved(bytes32 indexed surfaceId, address indexed subProduct); event ActorPolicyRemoved(bytes32 indexed surfaceId, address indexed actor); + // ─── Events (delay extension) ───────────────────────────────────── + + event AdminSet(address indexed admin); + event SecurityPerimeterEnabledSet(bool enabled); + event GlobalDelaySet(uint32 seconds_); + event SurfaceBypassSet(bytes32 indexed surfaceId, bool active, bool bypass); + event SurfaceBypassRemoved(bytes32 indexed surfaceId); + event SubProductBypassSet( + bytes32 indexed surfaceId, address indexed subProduct, bool active, bool bypass + ); + event ActorBypassSet(bytes32 indexed surfaceId, address indexed actor, bool active, bool bypass); + event SubProductBypassRemoved(bytes32 indexed surfaceId, address indexed subProduct); + event ActorBypassRemoved(bytes32 indexed surfaceId, address indexed actor); + event PassthroughActorSet(bytes32 indexed surfaceId, address indexed actor, bool isPassthrough); + // ─── Quote ──────────────────────────────────────────────────────────── /// @notice Resolve the fee policy for `(surfaceId, subProduct, actor)` and @@ -81,6 +112,55 @@ interface IExitFeeController { view returns (ExitFeeQuote memory); + // ─── Delay quote (hook entry) ───────────────────────────────────── + + /// @notice The delay hook's SINGLE hot-path entry. Short-circuits + /// the kill switch FIRST: when `securityPerimeterEnabled == false` it + /// returns `(0, rawOriginator, owner)` WITHOUT consulting the + /// passthrough registry or the escrow queue (the liveness escape). + /// Otherwise it resolves the surface-scoped effective actors + /// (`effOrig`/`effOwner`) — a registered passthrough for `surfaceId` + /// resolves to `receiver` — quotes the delay on `effOrig`, and + /// returns all three so the quote and the record share ONE identity + /// . The hook MUST ignore `effOrig`/`effOwner` and pay + /// direct whenever `d == 0`. + /// @param rawOriginator The withdrawal caller (pre-normalization). + /// @param owner The position owner (iToken holder / borrower / trove). + /// @param receiver The immutable payout destination. + /// @param surfaceId Operation-kind identifier (see fee tiers). + /// @param subProduct Per-instance address (iToken / converter / 0). + /// @return d Delay seconds to escrow for (0 ⇒ off / inactive / bypassed). + /// @return effOrig Effective originator (raw or passthrough→receiver). + /// @return effOwner Effective owner (raw or passthrough→receiver). + function quoteExitDelayFor( + address rawOriginator, + address owner, + address receiver, + bytes32 surfaceId, + address subProduct + ) external view returns (uint32 d, address effOrig, address effOwner); + + /// @notice Inner per-actor delay view (off / inactive / bypass ⇒ 0, else + /// `globalDelaySeconds`), evaluated on an ALREADY-effective actor. + /// Handles the disabled-perimeter case identically (returns 0 when + /// the perimeter is off). Hot-path callers use `quoteExitDelayFor`; + /// this is for off-chain quoting and the inner resolver. + /// @param surfaceId Operation-kind identifier. + /// @param subProduct Per-instance address (iToken / converter / 0). + /// @param effectiveActor The already-normalized actor (never a raw wrapper). + /// @return The delay seconds resolved by the 3-tier bypass resolver. + function quoteExitDelay(bytes32 surfaceId, address subProduct, address effectiveActor) + external + view + returns (uint32); + + /// @notice Resolve a surface-scoped passthrough: a passthrough registered + /// for `surfaceId` resolves `raw` to `receiver`, else identity. + function effectiveActor(bytes32 surfaceId, address raw, address receiver) + external + view + returns (address); + // ─── State views ────────────────────────────────────────────────────── function exitFeeEnabled() external view returns (bool); @@ -94,8 +174,62 @@ interface IExitFeeController { function subProductKeys(bytes32 surfaceId) external view returns (address[] memory); function actorKeys(bytes32 surfaceId) external view returns (address[] memory); + // ─── State views (delay extension) ────────────────────────────────────── + + function admin() external view returns (address); + function securityPerimeterEnabled() external view returns (bool); + function globalDelaySeconds() external view returns (uint32); + function surfaceBypass(bytes32 surfaceId) external view returns (DelayBypassPolicy memory); + function subProductBypass(bytes32 surfaceId, address subProduct) + external + view + returns (DelayBypassPolicy memory); + function actorBypass(bytes32 surfaceId, address actor) external view returns (DelayBypassPolicy memory); + + /// @notice Every surfaceId ever configured in the surface-tier delay-bypass + /// index. NOTE: this getter takes NO argument — surface + /// bypasses are keyed by `surfaceId` alone and are NOT scoped by a + /// parent surface. Backed by an `EnumerableSet.Bytes32Set` so + /// `InspectController` / monitoring can dump every surface bypass with + /// no unenumerable zero-delay state. Same soft-retire retention as the + /// other key-sets: entries persist on `{active:false}`; use + /// `removeSurfaceBypass` for hard removal. + function surfaceBypassKeys() external view returns (bytes32[] memory); + function subProductBypassKeys(bytes32 surfaceId) external view returns (address[] memory); + function actorBypassKeys(bytes32 surfaceId) external view returns (address[] memory); + + /// @notice ANY-TIER-TOUCHED master set: every surfaceId that + /// carries a delay-bypass entry at ANY tier — surface, sub-product, OR + /// actor. Recorded from all three writers, so a surface with ONLY a + /// sub-product- or actor-tier bypass (the most common exemption shape) + /// is enumerable even though it was never passed to + /// `setSurfaceBypass`. This is the discovery driver `InspectController` + /// uses so NO zero-delay config under an arbitrary surfaceId is + /// invisible. Retention-only (entries never dropped). + function bypassSurfaceIds() external view returns (bytes32[] memory); + + /// @notice ANY-TIER-TOUCHED master set for the passthrough registry: + /// every surfaceId under which a passthrough has been + /// registered. Lets `InspectController` discover a passthrough-only + /// surface (no bypass entry, not a named fee surface). Surface-level + /// retention (the id stays after every passthrough under it is dropped). + function passthroughSurfaceIds() external view returns (bytes32[] memory); + + /// @notice Every passthrough address ever registered under `surfaceId`. + /// Backed by an `EnumerableSet.AddressSet` so the + /// surface-scoped passthrough registry — as security-critical as the + /// bypass tiers — has no events-only blind spot. Entries are dropped + /// from the index when deregistered (`setPassthroughActor(.., false)`). + function passthroughKeys(bytes32 surfaceId) external view returns (address[] memory); + + function passthroughActor(bytes32 surfaceId, address a) external view returns (bool); + // ─── Admin ──────────────────────────────────────────────────────────── + /// @notice `onlyAdminOrOwner`: + /// the fee kill switch and receiver re-point are operational + /// levers shared with the Admin guardian. Every other setter in + /// this section is Owner-only. function setExitFeeEnabled(bool enabled) external; function setFeeReceiver(address newReceiver) external; function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external; @@ -113,4 +247,59 @@ interface IExitFeeController { function removeSubProductPolicies(bytes32 surfaceId, address[] calldata subProducts) external; function removeActorPolicy(bytes32 surfaceId, address actor) external; function removeActorPolicies(bytes32 surfaceId, address[] calldata actors) external; + + // ─── Admin (delay extension) ──────────────────────────────────────────── + + /// @notice Rotate the fast operational guardian (`Admin`). Owner-only. + /// MAY equal the Owner -- nothing requires the two to be distinct. + /// The only delay principal on the controller; there is no OZ + /// AccessControl role. + function setAdmin(address newAdmin) external; + + /// @notice Flip the global delay kill switch. `onlyAdminOrOwner` in both + /// directions, for sub-minute incident response. Independent of + /// `exitFeeEnabled`. + function setSecurityPerimeterEnabled(bool enabled) external; + + /// @notice Set the single global delay applied whenever a delay is imposed. + /// Owner-only. The `>= minimumDelaySeconds` floor is a liveness + /// invariant enforced PER-REQUEST in the queue, not here -- the + /// controller never reads or calls the queue. + function setGlobalDelaySeconds(uint32 seconds_) external; + + function setSurfaceBypass(bytes32 surfaceId, DelayBypassPolicy calldata policy) external; + + /// @notice Hard-remove a surface-tier delay bypass: clears the stored policy + /// and drops the surfaceId from the enumeration index. Idempotent. + function removeSurfaceBypass(bytes32 surfaceId) external; + + function setSubProductBypass(bytes32 surfaceId, address subProduct, DelayBypassPolicy calldata policy) + external; + + function setSubProductBypasses( + bytes32 surfaceId, + address[] calldata subProducts, + DelayBypassPolicy[] calldata policies + ) external; + + function setActorBypass(bytes32 surfaceId, address actor, DelayBypassPolicy calldata policy) external; + + function setActorBypasses( + bytes32 surfaceId, + address[] calldata actors, + DelayBypassPolicy[] calldata policies + ) external; + + function removeSubProductBypass(bytes32 surfaceId, address subProduct) external; + + function removeSubProductBypasses(bytes32 surfaceId, address[] calldata subProducts) external; + + function removeActorBypass(bytes32 surfaceId, address actor) external; + + function removeActorBypasses(bytes32 surfaceId, address[] calldata actors) external; + + /// @notice Register/deregister a surface-scoped passthrough actor. A + /// passthrough registered for `surfaceId` normalizes to `receiver` + /// in `effectiveActor` / `quoteExitDelayFor`. Owner-only. + function setPassthroughActor(bytes32 surfaceId, address a, bool isPassthrough) external; } diff --git a/test/echidna/EchidnaExitDelayQueue.sol b/test/echidna/EchidnaExitDelayQueue.sol new file mode 100644 index 0000000..3942edc --- /dev/null +++ b/test/echidna/EchidnaExitDelayQueue.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +contract EchMockERC20 is ERC20 { + constructor() ERC20("Ech", "ECH") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract EchWRBTC { + function withdraw(uint256) external {} + receive() external payable {} +} + +/// @dev Self-contained Echidna harness (property mode, no cheatcodes). +/// The harness is contract owner + allowed source + native pusher and is +/// itself one of the request parties so it can execute. Ghost accounting +/// mirrors test/invariant/ExitDelayQueueHandler.sol: conservation +/// (/ solvency), id monotonicity, and single terminal +/// transition. +contract EchidnaExitDelayQueue { + ExitDelayQueue internal queue; + EchMockERC20 internal token; + EchWRBTC internal wrbtc; + + address internal constant ADMIN = address(0xAD01); + uint32 internal constant MIN_DELAY = 1 hours; + + address[3] internal actors; + + // ghost accounting + uint256 internal ghostQueuedErc20; + uint256 internal ghostQueuedNative; + uint256 internal totalRecorded; + uint256 internal totalTerminal; + + uint256[] internal liveIds; + + constructor() payable { + actors[0] = address(this); + actors[1] = address(0xA1); + actors[2] = address(0xA2); + + wrbtc = new EchWRBTC(); + token = new EchMockERC20(); + + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](1); + sources[0] = address(this); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, address(this), ADMIN, address(wrbtc), MIN_DELAY, sources + ); + queue = ExitDelayQueue(payable(address(new ERC1967Proxy(address(impl), init)))); + queue.setNativePusher(address(this)); + + token.mint(address(this), type(uint128).max); + } + + receive() external payable {} + + // ── actions ── + + function recordErc20(uint128 amountSeed, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + uint128 amount = uint128(1 + (uint256(amountSeed) % 1e24)); + uint32 d = MIN_DELAY + (extraDelay % uint32(2 days)); + token.approve(address(queue), amount); + try queue.recordERC20Exit( + address(token), + amount, + d, + keccak256("S"), + address(0xBEEF), + actors[aSeed % 3], + actors[bSeed % 3], + actors[(aSeed + 1) % 3], + false + ) returns (uint256 id) { + liveIds.push(id); + ghostQueuedErc20 += amount; + totalRecorded++; + } catch {} + } + + function recordNative(uint128 amountSeed, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + uint128 amount = uint128(1 + (uint256(amountSeed) % 1e20)); + if (address(this).balance < amount) return; + uint32 d = MIN_DELAY + (extraDelay % uint32(2 days)); + try queue.recordNativeExit{value: amount}( + amount, + d, + keccak256("Z"), + address(0), + actors[aSeed % 3], + actors[bSeed % 3], + actors[(bSeed + 1) % 3] + ) returns (uint256 id) { + liveIds.push(id); + ghostQueuedNative += amount; + totalRecorded++; + } catch {} + } + + function execute(uint256 idSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + try queue.executeExit(id) { + _onTerminal(r); + } catch {} + } + + /// @dev Gate-5 redirect leg (mirrors ExitDelayQueueHandler.recoverStuck). + /// recoverStuckExit(id, altReceiver) shares execute's {originator, owner} + /// authorization and on SUCCESS (stored-receiver or altReceiver branch) is + /// a TERMINAL transition, so ghost accounting decrements exactly like + /// execute. altReceiver is one of the plain EOA actors — never 0/this/ + /// token/wrbtc — so the guard doesn't mask the leg (actors[0] is this + /// harness, hence the 1 + altSeed % 2 pick). Lock/block/pause/terminal + /// failures revert and are caught (no state change). Property under test: + /// recovery never breaks solvency or double-spends, whichever branch runs. + function recoverStuck(uint256 idSeed, uint256 altSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + address altReceiver = actors[1 + (altSeed % 2)]; + try queue.recoverStuckExit(id, altReceiver) { + _onTerminal(r); + } catch {} + } + + function resolveBySIP(uint256 idSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + uint256[] memory ids = new uint256[](1); + ids[0] = id; + try queue.resolveBySIP(ids, address(0xD00D)) { + _onTerminal(r); + } catch {} + } + + function freeze(uint256 actorSeed) external { + try queue.freeze(actors[actorSeed % 3]) {} catch {} + } + + function blacklist(uint256 actorSeed) external { + try queue.blacklist(actors[actorSeed % 3]) {} catch {} + } + + function unfreeze(uint256 actorSeed) external { + try queue.unfreeze(actors[actorSeed % 3]) {} catch {} + } + + function unblacklist(uint256 actorSeed) external { + try queue.unblacklist(actors[actorSeed % 3]) {} catch {} + } + + function pause(bool p) external { + try queue.setSecurityPerimeterPaused(p) {} catch {} + } + + function sweep(bool native) external { + try queue.sweepSurplus(native ? address(0) : address(token), address(0x5EE)) {} catch {} + } + + function _onTerminal(IExitDelayQueue.ExitRequest memory r) internal { + totalTerminal++; + if (r.token == address(0)) { + ghostQueuedNative -= r.amount; + } else { + ghostQueuedErc20 -= r.amount; + } + } + + // ── properties ── + + function echidna_solvency_erc20() external view returns (bool) { + uint256 esc = queue.totalEscrowed(address(token)); + return esc == ghostQueuedErc20 && esc <= token.balanceOf(address(queue)); + } + + function echidna_solvency_native() external view returns (bool) { + uint256 esc = queue.totalEscrowed(address(0)); + return esc == ghostQueuedNative && esc <= address(queue).balance; + } + + function echidna_id_monotonic() external view returns (bool) { + return queue.lastRequestId() == totalRecorded; + } + + function echidna_no_double_terminal() external view returns (bool) { + return totalTerminal <= totalRecorded; + } +} diff --git a/test/echidna/echidna.yaml b/test/echidna/echidna.yaml new file mode 100644 index 0000000..ae38e71 --- /dev/null +++ b/test/echidna/echidna.yaml @@ -0,0 +1,10 @@ +# Echidna config for EchidnaExitDelayQueue (property mode, no cheatcodes). +# Run from the repo root: +# echidna . --contract EchidnaExitDelayQueue --config test/echidna/echidna.yaml \ +# --crytic-args --foundry-compile-all +# balanceContract funds the payable harness constructor so recordNative has +# native balance to escrow. +testMode: property +testLimit: 50000 +timeout: 300 +balanceContract: 100000000000000000000000 diff --git a/test/fixtures/BadV3.sol b/test/fixtures/BadV3.sol index 332fe4d..d879d03 100644 --- a/test/fixtures/BadV3.sol +++ b/test/fixtures/BadV3.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IExitFeeController} from "../../src/ExitFeeController.sol"; /// @dev Test fixture ONLY. NOT a real upgrade candidate. /// @@ -20,10 +21,25 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet /// RatePolicy entry after upgrade. /// /// tools/diff-storage-layouts.py MUST reject this layout against -/// a saved ExitFeeController.json. +/// a saved ExitFeeController.json with "shifted or changed shape". +/// +/// HOW TO REGENERATE (do this whenever ExitFeeController's storage +/// changes): mirror `forge inspect ExitFeeController storageLayout` +/// EXACTLY (slots 251..271 today, __gap unchanged at [29]) — the +/// ONLY intentional deviation is the LOCAL RatePolicy below whose +/// two members are swapped. Everything else must match byte-for-byte +/// so the tool rejects for the struct reorder and NOT for a missing +/// or moved variable. DelayBypassPolicy is imported from +/// IExitFeeController so it stays identical; only RatePolicy is +/// redefined locally to carry the reorder. contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + // Same struct NAME, different member ORDER. (active before rateBps in the - // real ExitFeeController; here we swap them.) + // real ExitFeeController; here we swap them.) Both orderings occupy one + // 32-byte slot, so the OUTER layout is byte-identical — the reorder is + // only visible by deep-comparing the struct's member list. struct RatePolicy { uint16 rateBps; bool active; @@ -31,18 +47,39 @@ contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { uint16 public constant MAX_BPS = 10_000; + // ── Mirror of ExitFeeController own storage, slots 251..270 ────────── + + // slot 251 (packed: bool@0, address@1) bool public exitFeeEnabled; address public feeReceiver; + // slots 252..256 — use the LOCAL (reordered) RatePolicy. mapping(bytes32 => RatePolicy) internal _surfacePolicy; mapping(bytes32 => mapping(address => RatePolicy)) internal _subProductPolicy; mapping(bytes32 => mapping(address => RatePolicy)) internal _actorPolicy; mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; + // slot 257 (packed: bool@0, uint32@1, address@5) — DO NOT reorder. address public admin; - uint256[43] private __gap; + // slots 258..270 — DelayBypassPolicy imported so it stays identical. + mapping(bytes32 => IExitFeeController.DelayBypassPolicy) internal _surfaceBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _subProductBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _actorBypass; + EnumerableSet.Bytes32Set internal _surfaceBypassKeys; // slots 261..262 (2 slots) + mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductBypassKeys; + mapping(bytes32 => EnumerableSet.AddressSet) internal _actorBypassKeys; + mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; + mapping(bytes32 => EnumerableSet.AddressSet) internal _passthroughKeys; + EnumerableSet.Bytes32Set internal _bypassSurfaceIds; // slots 267..268 (2 slots) + EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) + + // __gap unchanged — this fixture adds NO storage; it only reorders a struct. + bool public securityPerimeterEnabled; + uint32 public globalDelaySeconds; + + uint256[29] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } diff --git a/test/fixtures/GoodPackedV2.sol b/test/fixtures/GoodPackedV2.sol index d425b14..e46c974 100644 --- a/test/fixtures/GoodPackedV2.sol +++ b/test/fixtures/GoodPackedV2.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IExitFeeController} from "../../src/ExitFeeController.sol"; /// @dev Test fixture ONLY. NOT a real upgrade candidate. /// @@ -18,39 +19,67 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet /// "2 new entries, span 1 each = 2 slots reclaimed" and reject /// this as inconsistent with a 1-slot gap shrinkage. The correct /// accounting (UNION of slot ranges) sees both entries at slot -/// 258 covering [258, 259) = 1 slot. +/// 271 covering [271, 272) = 1 slot. /// /// tools/diff-storage-layouts.py MUST accept this layout as /// upgrade-safe. +/// +/// HOW TO REGENERATE (do this whenever ExitFeeController's storage +/// changes): mirror `forge inspect ExitFeeController storageLayout` +/// EXACTLY — every own variable (slots 251..271 today), in the same +/// order, with the same struct types (imported from +/// IExitFeeController so the type definitions are byte-identical) — +/// then place the two packed uint128 fields at the FIRST still-unused +/// __gap slot and shrink __gap by 1 (29 -> 28). The mirror below is +/// current as of the security-perimeter delay extension (admin alone in +/// its shipped slot, bypass tiers, passthrough registry, enumeration +/// sets, then the two delay scalars in a reclaimed gap slot). contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - struct RatePolicy { - bool active; - uint16 rateBps; - } + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; uint16 public constant MAX_BPS = 10_000; - // Mirrors the live ExitFeeController layout exactly through slot 257. + // ── Mirror of ExitFeeController own storage, slots 251..270 ────────── + + // slot 251 (packed: bool@0, address@1) bool public exitFeeEnabled; address public feeReceiver; - mapping(bytes32 => RatePolicy) internal _surfacePolicy; - mapping(bytes32 => mapping(address => RatePolicy)) internal _subProductPolicy; - mapping(bytes32 => mapping(address => RatePolicy)) internal _actorPolicy; + // slots 252..256 + mapping(bytes32 => IExitFeeController.RatePolicy) internal _surfacePolicy; + mapping(bytes32 => mapping(address => IExitFeeController.RatePolicy)) internal _subProductPolicy; + mapping(bytes32 => mapping(address => IExitFeeController.RatePolicy)) internal _actorPolicy; mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; + // slot 257 — `admin` alone. address public admin; - // Two packed uint128 fields. Both go at slot 258 (the first slot - // previously inside __gap[43]). Solidity puts them at offset 0 and - // offset 16 of the SAME slot. The gap should shrink to __gap[42]. + // slots 258..270 + mapping(bytes32 => IExitFeeController.DelayBypassPolicy) internal _surfaceBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _subProductBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _actorBypass; + EnumerableSet.Bytes32Set internal _surfaceBypassKeys; // slots 261..262 (2 slots) + mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductBypassKeys; + mapping(bytes32 => EnumerableSet.AddressSet) internal _actorBypassKeys; + mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; + mapping(bytes32 => EnumerableSet.AddressSet) internal _passthroughKeys; + EnumerableSet.Bytes32Set internal _bypassSurfaceIds; // slots 267..268 (2 slots) + EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) + + // slot 271 (packed: bool@0, uint32@1) — the delay scalars. + bool public securityPerimeterEnabled; + uint32 public globalDelaySeconds; + + // A full-slot field at 272, then two uint128 packed into slot 273 at + // offset 0 and offset 16. The gap shrinks by two slots, to __gap[27]. + uint256 public newFull; uint128 public newA; uint128 public newB; - // __gap shrinks by exactly 1 slot (one slot reclaimed for the two - // packed uint128 fields). - uint256[42] private __gap; + // __gap shrinks by exactly 2 slots: 29 -> 27. + uint256[27] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } diff --git a/test/invariant/ControllerHandler.sol b/test/invariant/ControllerHandler.sol index 73fd25c..b8589b7 100644 --- a/test/invariant/ControllerHandler.sol +++ b/test/invariant/ControllerHandler.sol @@ -90,10 +90,10 @@ contract ControllerHandler { constructor(ExitFeeController controller_) { controller = controller_; - _surfaces[0] = keccak256("COLFEE:SURFACE_LENDING_LENDER_WITHDRAW"); - _surfaces[1] = keccak256("COLFEE:SURFACE_LENDING_BORROWER_WITHDRAW"); - _surfaces[2] = keccak256("COLFEE:SURFACE_ZERO_WITHDRAW_COLL"); - _surfaces[3] = keccak256("COLFEE:SURFACE_AMM_REMOVE_LIQUIDITY"); + _surfaces[0] = keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + _surfaces[1] = keccak256("PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW"); + _surfaces[2] = keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); + _surfaces[3] = keccak256("PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY"); for (uint256 i = 0; i < 8; ++i) { _addrs[i] = address(uint160(0xA00 + i + 1)); // all non-zero diff --git a/test/invariant/ExitDelayQueue.invariant.t.sol b/test/invariant/ExitDelayQueue.invariant.t.sol new file mode 100644 index 0000000..a4ccf4f --- /dev/null +++ b/test/invariant/ExitDelayQueue.invariant.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; +import {ExitDelayQueueHandler, InvMockERC20} from "./ExitDelayQueueHandler.sol"; + +/// @dev Minimal native-backed WRBTC for the invariant run (unused by the +/// handler's ingress but required by initialize()). +contract InvWRBTC { + function withdraw(uint256) external {} + receive() external payable {} +} + +/// @notice Stateful invariant suite for `ExitDelayQueue` covering its +/// invariants. The handler (owner + source + pusher) fuzzes ingress, +/// execution, blocks, recovery, pause, sweep and time. Assertions read +/// ghost accounting + on-chain state. +contract ExitDelayQueueInvariant is Test { + ExitDelayQueue queue; + InvMockERC20 token; + InvWRBTC wrbtc; + ExitDelayQueueHandler handler; + + address constant ADMIN = address(0xAD); + + function setUp() public { + wrbtc = new InvWRBTC(); + token = new InvMockERC20(); + + ExitDelayQueue impl = new ExitDelayQueue(); + // The handler will be the owner; deploy with the test as temp owner then + // hand off. Sources include the handler; admin is a distinct dummy. + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, address(this), ADMIN, address(wrbtc), uint32(1 hours), sources + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + queue = ExitDelayQueue(payable(address(proxy))); + + handler = new ExitDelayQueueHandler(queue, token, address(wrbtc)); + + // Wire the handler as an allowed source + native pusher, then transfer + // ownership to it so it can also exercise owner/admin-gated fns. + queue.addAllowedSource(address(handler)); + queue.setNativePusher(address(handler)); + queue.transferOwnership(address(handler)); + vm.prank(address(handler)); + queue.acceptOwnership(); + + // target only the handler + targetContract(address(handler)); + + bytes4[] memory selectors = new bytes4[](17); + selectors[0] = handler.recordErc20.selector; + selectors[1] = handler.recordNative.selector; + selectors[2] = handler.execute.selector; + selectors[3] = handler.freeze.selector; + selectors[4] = handler.blacklist.selector; + selectors[5] = handler.unfreeze.selector; + selectors[6] = handler.unblacklist.selector; + selectors[7] = handler.pause.selector; + selectors[8] = handler.resolveBySIP.selector; + selectors[9] = handler.sweep.selector; + selectors[10] = handler.warp.selector; + // coverage: measured-delta ingress + donation/force-send surplus. + selectors[11] = handler.recordReceivedErc20.selector; + selectors[12] = handler.recordReceivedNative.selector; + selectors[13] = handler.donateErc20.selector; + selectors[14] = handler.donateNative.selector; + // (C3): fuzz floor raises/lowers so the creation-time invariant is + // exercised against live short requests without falsely tripping. + selectors[15] = handler.setMinDelay.selector; + // Gate-5 stuck-exit recovery: recoverStuckExit(id, altReceiver) must never + // break solvency / double-spend, whichever branch it takes (stored-receiver + // pay, altReceiver pay on a bounce, or whole-call revert on block/lock/pause). + selectors[16] = handler.recoverStuck.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + // Per-token solvency — totalEscrowed == Σ Queued amounts, and + // ≤ backing balance. Ghost sum tracks Σ Queued. + function invariant_solvency_erc20() public view { + assertEq(queue.totalEscrowed(address(token)), handler.ghostQueuedErc20()); + assertLe(queue.totalEscrowed(address(token)), token.balanceOf(address(queue))); + } + + function invariant_solvency_native() public view { + assertEq(queue.totalEscrowed(address(0)), handler.ghostQueuedNative()); + assertLe(queue.totalEscrowed(address(0)), address(queue).balance); + } + + // ids monotonic and never reused (lastRequestId only grows; every + // recorded id ≤ lastRequestId and unique by construction of ++lastRequestId). + function invariant_id_monotonic() public view { + assertEq(queue.lastRequestId(), handler.totalRecorded()); + } + + // A request leaves Queued at most once. Every id is in exactly + // one of {Queued, terminal}; terminal count never exceeds recorded count. + function invariant_no_double_terminal() public view { + assertLe(handler.totalTerminal(), handler.totalRecorded()); + } + + // + metadata immutable + status monotonic across all live ids. + // Also active-index membership biconditional (Queued ⇔ in set). + function invariant_status_and_index_consistency() public view { + uint256 n = handler.liveIdCount(); + for (uint256 i = 0; i < n; ++i) { + uint256 id = handler.liveIdAt(i); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + // (creation-time, C3): every request satisfies + // `unlockAt − createdAt ≥ the minimumDelaySeconds in effect at ITS OWN + // createdAt` (the floor is enforced once, at record time). We check + // against the handler's recorded floor-at-creation, NOT the current + // live floor — a later setMinimumDelaySeconds raise applies only to NEW + // requests and must never retroactively extend an already-Queued exit, + // so asserting against the live floor would falsely trip after a raise. + assertGe(uint256(r.unlockAt) - uint256(r.createdAt), uint256(handler.floorAtCreation(id))); + // for the originator, id ∈ active iff Queued. + bool inSet = _inActive(r.originator, id); + if (r.status == IExitDelayQueue.ExitStatus.Queued) { + assertTrue(inSet, "queued id must be in originator active set"); + } else { + assertFalse(inSet, "terminal id must NOT be in originator active set"); + assertFalse(_inActive(r.owner, id), "terminal id must NOT be in owner active set"); + } + } + } + + function _inActive(address party, uint256 id) internal view returns (bool) { + // page through getActive (bounded live sets in the run) + uint256 cursor = 0; + for (uint256 guard = 0; guard < 50; ++guard) { + (uint256[] memory ids, uint256 next) = queue.getActive(party, cursor, 100); + for (uint256 j = 0; j < ids.length; ++j) { + if (ids[j] == id) return true; + } + if (next == 0) break; + cursor = next; + } + return false; + } +} diff --git a/test/invariant/ExitDelayQueueHandler.sol b/test/invariant/ExitDelayQueueHandler.sol new file mode 100644 index 0000000..c42111f --- /dev/null +++ b/test/invariant/ExitDelayQueueHandler.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +contract InvMockERC20 is ERC20 { + constructor() ERC20("Inv", "INV") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Force-sends native RBTC via selfdestruct — bypasses the queue's +/// `receive` gate, exactly the donation-grief vector the tolerates. +contract ForceSender { + constructor(address payable target) payable { + selfdestruct(target); + } +} + +/// @dev Stateful handler that drives the queue through its whole surface for the +/// forge invariant runner. It is itself an `_allowedSource` and the +/// `nativePusher`, so it can exercise every ingress variant. It tracks a +/// ghost sum of Queued amounts per token to check solvency and +/// records executed ids to check monotonicity / no-double-spend. +/// Bounded actor set keeps blocks meaningful. +contract ExitDelayQueueHandler is Test { + ExitDelayQueue public queue; + InvMockERC20 public token; + address public wrbtc; // canonical WRBTC (native-backed mock in the test) + + // three fixed actors → freeze/execute interplay is meaningful + address[3] public actors = [address(0xA1), address(0xA2), address(0xA3)]; + + uint32 public constant MIN_DELAY = 1 hours; + + // ghost accounting + uint256 public ghostQueuedErc20; // Σ Queued amounts, token + uint256 public ghostQueuedNative; // Σ Queued amounts, native + uint256 public totalRecorded; // # of record* calls that succeeded + uint256 public totalTerminal; // # of terminal transitions + + // track live ids for targeted execute/resolve + uint256[] public liveIds; + + /// @dev (creation-time, C3): the minimumDelaySeconds floor in effect at + /// each request's OWN createdAt. Recorded at the moment of a successful + /// record so the invariant can check `unlockAt − createdAt >= floor-at-its- + /// creation` — NOT the current live floor. A later setMinimumDelaySeconds + /// raise must apply only to NEW requests and never retroactively extend an + /// already-Queued exit; this ghost is what lets the invariant assert that. + mapping(uint256 => uint32) public floorAtCreation; + + constructor(ExitDelayQueue q, InvMockERC20 t, address wrbtc_) { + queue = q; + token = t; + wrbtc = wrbtc_; + token.mint(address(this), type(uint128).max); + vm.deal(address(this), type(uint128).max); + } + + receive() external payable {} + + // ── ingress ── + + /// @dev Overflow-safe actor trio. The receiver index is + /// `(seed%3 + 1) % 3` — computed here off the caller's stack (keeping the + /// record* frames under the non-via-ir stack limit) and, crucially, on the + /// ALREADY-reduced `%3` value so a max seed can never 0x11-overflow the + /// way the prior `(seed+1)%3` did. `orig`/`ownr` use the raw `%3` seeds so + /// the freeze/execute interplay still spans the whole actor set. + function _trio(uint256 aSeed, uint256 bSeed) + internal + view + returns (address orig, address ownr, address recvA, address recvB) + { + orig = actors[aSeed % 3]; + ownr = actors[bSeed % 3]; + recvA = actors[(aSeed % 3 + 1) % 3]; + recvB = actors[(bSeed % 3 + 1) % 3]; + } + + function recordErc20(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e24)); + // Delay is bounded above the live floor so a floor RAISE (see setMinDelay) + // never bricks ingress here; the request's floor-at-creation is captured + // below regardless of the delay actually chosen. + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr, address recv,) = _trio(aSeed, bSeed); + token.approve(address(queue), amount); + try queue.recordERC20Exit( + address(token), amount, d, keccak256("S"), address(0xBEEF), orig, ownr, recv, false + ) returns (uint256 id) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedErc20 += amount; + totalRecorded++; + } catch {} + } + + function recordNative(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e21)); + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr,, address recv) = _trio(aSeed, bSeed); + try queue.recordNativeExit{value: amount}(amount, d, keccak256("Z"), address(0), orig, ownr, recv) + returns (uint256 id) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedNative += amount; + totalRecorded++; + } catch {} + } + + // ── measured-delta ingress: push then record in one tx. Credit is + // exactly `amount` when the non-backing surplus delta >= amount, so a + // donation (see donate*) must NOT brick these and must NOT break solvency. + + function recordReceivedErc20(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e24)); + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr, address recv,) = _trio(aSeed, bSeed); + token.transfer(address(queue), amount); // push in the same tx + try queue.recordReceivedERC20Exit( + address(token), amount, d, keccak256("S"), address(0xBEEF), orig, ownr, recv + ) returns (uint256 id) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedErc20 += amount; + totalRecorded++; + } catch {} + } + + function recordReceivedNative(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e21)); + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr,, address recv) = _trio(aSeed, bSeed); + // The handler is the registered nativePusher, so this push clears receive(). + (bool ok,) = payable(address(queue)).call{value: amount}(""); + if (!ok) return; + try queue.recordReceivedNativeExit(amount, d, keccak256("Z"), address(0), orig, ownr, recv) returns ( + uint256 id + ) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedNative += amount; + totalRecorded++; + } catch {} + } + + // ── donation / force-send: creates non-backing surplus. The measured-delta + // credit-exactly-amount rule must keep / intact and never let a + // donation get mis-credited into totalEscrowed (grief resistance). + + function donateErc20(uint128 amount) external { + amount = uint128(bound(amount, 1, 1e18)); + token.transfer(address(queue), amount); // ghost NOT updated: pure surplus + } + + function donateNative(uint128 amount) external { + amount = uint128(bound(amount, 1, 1e18)); + // selfdestruct force-send bypasses the receive() gate entirely. + new ForceSender{value: amount}(payable(address(queue))); + } + + // ── execution ── + + function execute(uint256 idSeed, uint256 actorSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + // jump past unlock sometimes + if (actorSeed % 2 == 0 && block.timestamp < r.unlockAt) { + vm.warp(r.unlockAt); + } + address caller = actors[actorSeed % 3]; + vm.prank(caller); + try queue.executeExit(id) { + _onTerminal(r); + } catch {} + } + + // ── block model ── + + function freeze(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.freeze(a) {} catch {} + } + + function blacklist(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.blacklist(a) {} catch {} + } + + function unfreeze(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.unfreeze(a) {} catch {} + } + + function unblacklist(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.unblacklist(a) {} catch {} + } + + function pause(bool p) external { + try queue.setSecurityPerimeterPaused(p) {} catch {} + } + + /// @dev (C3): fuzz the per-request floor across a RANGE that spans both + /// below and (crucially) ABOVE the delays of already-Queued requests. A + /// raise here must NOT retroactively extend any live request's unlockAt — + /// the creation-time invariant checks each request against its own + /// floorAtCreation, never the live floor, so a raise while short requests + /// sit Queued must not trip invariant_status_and_index_consistency. + function setMinDelay(uint32 newFloor) external { + // Cap well above the max ingress delay (floor + 10 days) so raises that + // exceed live requests' (unlockAt − createdAt) are reachable and exercised. + newFloor = uint32(bound(newFloor, 0, 30 days)); + try queue.setMinimumDelaySeconds(newFloor) {} catch {} + } + + // ── stuck-exit recovery — verify-by-attempting redirect leg ── + + /// @dev recoverStuckExit(id, altReceiver): same {originator, owner} authorization + /// as execute. Attempts the STORED receiver first and pays altReceiver only + /// on a genuine bounce (verify-by-attempting) — but ON SUCCESS (either + /// branch) it is a TERMINAL transition, so ghost accounting must decrement, + /// exactly like execute. On a block/lock/pause/terminal/guard it reverts and + /// is caught (no state change). altReceiver is one of the plain handler + /// actors (never 0/this/token/wrbtc), so the guard never trips here. The + /// invariant property: recovery NEVER breaks solvency or double-spends, + /// whichever branch it takes. + function recoverStuck(uint256 idSeed, uint256 actorSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + if (actorSeed % 2 == 0 && block.timestamp < r.unlockAt) { + vm.warp(r.unlockAt); + } + address caller = actors[actorSeed % 3]; + address altReceiver = actors[(actorSeed / 3) % 3]; + vm.prank(caller); + try queue.recoverStuckExit(id, altReceiver) { + _onTerminal(r); + } catch {} + } + + // ── recovery ── + + function resolveBySIP(uint256 idSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + uint256[] memory ids = new uint256[](1); + ids[0] = id; + try queue.resolveBySIP(ids, address(0xD00D)) { + _onTerminal(r); + } catch {} + } + + // ── sweep (surplus removal keeps equality-form solvency reachable) ── + + function sweep(bool native) external { + try queue.sweepSurplus(native ? address(0) : address(token), address(0x5EE)) {} catch {} + } + + function warp(uint32 dt) external { + vm.warp(block.timestamp + bound(dt, 1, 5 days)); + } + + function _onTerminal(IExitDelayQueue.ExitRequest memory r) internal { + totalTerminal++; + if (r.token == address(0)) { + ghostQueuedNative -= r.amount; + } else { + ghostQueuedErc20 -= r.amount; + } + } + + function liveIdCount() external view returns (uint256) { + return liveIds.length; + } + + function liveIdAt(uint256 i) external view returns (uint256) { + return liveIds[i]; + } +} diff --git a/test/unit/BlockExits.t.sol b/test/unit/BlockExits.t.sol new file mode 100644 index 0000000..74f54da --- /dev/null +++ b/test/unit/BlockExits.t.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; +import {BlockExits} from "../../script/07_BlockExits.s.sol"; + +/// @dev Minimal WRBTC stand-in for the queue's `wrbtc_` init param. +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + receive() external payable {} +} + +contract MockERC20 is ERC20 { + constructor() ERC20("Mock", "MOCK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Allowed-source stand-in so real requests can be recorded, giving the +/// by-request resolution something to resolve. +contract SourceHarness { + ExitDelayQueue public queue; + + constructor(ExitDelayQueue q) { + queue = q; + } + + function record( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256) { + ERC20(token).approve(address(queue), amount); + return + queue.recordERC20Exit(token, amount, d, surfaceId, address(0), effOrig, effOwner, receiver, false); + } +} + +/// @dev Exposes the script's decision logic with explicit arguments, so it is +/// driveable without `vm.setEnv` (process-global, not isolated between +/// parallel tests). Also surfaces the resolved party list, which is the +/// part an operator relies on and the part that must not drift from the +/// contract's own `_blockFromRequest` rules. +contract BlockExitsHarness is BlockExits { + function init(address q) external { + _init(q); + } + + function initController(address c) external { + _initController(c); + } + + function dispatch( + string memory action, + address[] memory actors, + uint256[] memory ids, + bool freezeReceiver, + string memory reason + ) external view { + _dispatch(action, actors, ids, freezeReceiver, reason); + } + + function partiesBehind(uint256[] memory ids, bool freezeReceiver) + external + view + returns (address[] memory) + { + return _partiesBehind(ids, freezeReceiver); + } +} + +/// @title Emergency block preview - 07_BlockExits +/// @notice Proves the operator script refuses, up front, every call that would +/// revert on-chain after the Safe had already collected signatures, and +/// that its by-request party resolution matches the parties the queue +/// itself blocks. +contract BlockExitsTest is Test { + address constant OWNER = address(0x0E7E7); + address constant ADMIN = address(0x6DA12D); + address constant ORIG = address(0x0121); + address constant OWNR = address(0x0233); + address constant RCVR = address(0x0455); + address constant STRANGER = address(0x5747); + + bytes32 constant SURFACE = keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + uint32 constant MIN_DELAY = 60; + uint32 constant DELAY = 3600; + + ExitDelayQueue queue; + MockWRBTC wrbtc; + MockERC20 token; + SourceHarness source; + BlockExitsHarness script; + + function setUp() public { + wrbtc = new MockWRBTC(); + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](0); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ) + ); + queue = ExitDelayQueue(payable(address(proxy))); + + token = new MockERC20(); + source = new SourceHarness(queue); + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + token.mint(address(source), 1_000_000 ether); + + script = new BlockExitsHarness(); + script.init(address(queue)); + } + + // --- helpers -------------------------------------------------------- + + function _record(address orig, address ownr, address rcvr) internal returns (uint256) { + return source.record(address(token), 1 ether, DELAY, SURFACE, orig, ownr, rcvr); + } + + function _addrs(address a) internal pure returns (address[] memory out) { + out = new address[](1); + out[0] = a; + } + + function _ids(uint256 a) internal pure returns (uint256[] memory out) { + out = new uint256[](1); + out[0] = a; + } + + function _noAddrs() internal pure returns (address[] memory) { + return new address[](0); + } + + function _noIds() internal pure returns (uint256[] memory) { + return new uint256[](0); + } + + // --- by-request resolution matches the contract --------------------- + + function test_partiesBehind_matches_contract_rules() public { + uint256 id = _record(ORIG, OWNR, RCVR); + + address[] memory without = script.partiesBehind(_ids(id), false); + assertEq(without.length, 2, "originator + owner"); + assertEq(without[0], ORIG); + assertEq(without[1], OWNR); + + address[] memory withReceiver = script.partiesBehind(_ids(id), true); + assertEq(withReceiver.length, 3, "originator + owner + receiver"); + assertEq(withReceiver[2], RCVR); + + // The queue itself blocks exactly this set - proven by executing the + // real batch and reading the resulting states back. + vm.prank(ADMIN); + queue.freezeFromRequest(_ids(id), true, keccak256("drill")); + for (uint256 i = 0; i < withReceiver.length; ++i) { + assertEq( + uint256(queue.blockStateOf(withReceiver[i])), + uint256(IExitDelayQueue.BlockState.Frozen), + "party resolved by the script was not blocked by the contract" + ); + } + } + + /// @dev originator == owner must collapse to one entry, as the contract's + /// `if (r.owner != r.originator)` does. + function test_partiesBehind_collapses_identical_parties() public { + uint256 id = _record(ORIG, ORIG, RCVR); + address[] memory parties = script.partiesBehind(_ids(id), false); + assertEq(parties.length, 1); + assertEq(parties[0], ORIG); + } + + /// @dev The same address behind two requests appears once in the preview, + /// so the operator does not read a doubled blast radius. + function test_partiesBehind_dedupes_across_requests() public { + uint256 a = _record(ORIG, OWNR, RCVR); + uint256 b = _record(ORIG, OWNR, RCVR); + uint256[] memory ids = new uint256[](2); + ids[0] = a; + ids[1] = b; + assertEq(script.partiesBehind(ids, true).length, 3); + } + + /// @dev The on-chain batch is atomic, so an unknown id must be rejected + /// before signatures are collected, not after. + function test_unknown_request_id_is_rejected_up_front() public { + vm.expectRevert(bytes("unknown request id 99 - the batch is atomic")); + script.partiesBehind(_ids(99), false); + } + + // --- clears --------------------------------------------------------- + + function test_unfreeze_refuses_when_an_address_is_not_frozen() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + + address[] memory two = new address[](2); + two[0] = ORIG; // Frozen - fine + two[1] = STRANGER; // None - would revert on-chain + vm.expectRevert( + bytes("unfreeze requires every address to be Frozen - use unblacklist for blacklisted ones") + ); + script.dispatch("unfreeze", two, _noIds(), false, ""); + } + + function test_unfreeze_refuses_a_blacklisted_address() public { + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.expectRevert( + bytes("unfreeze requires every address to be Frozen - use unblacklist for blacklisted ones") + ); + script.dispatch("unfreeze", _addrs(ORIG), _noIds(), false, ""); + } + + function test_unblacklist_refuses_a_frozen_address() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.expectRevert( + bytes("unblacklist requires every address to be Blacklisted - use unfreeze for frozen ones") + ); + script.dispatch("unblacklist", _addrs(ORIG), _noIds(), false, ""); + } + + function test_unfreeze_accepts_a_frozen_address() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + script.dispatch("unfreeze", _addrs(ORIG), _noIds(), false, ""); + } + + function test_clear_rejects_request_ids() public { + uint256 id = _record(ORIG, OWNR, RCVR); + vm.expectRevert(bytes("clears are by address only - the queue has no by-request clear")); + script.dispatch("unfreeze", _addrs(ORIG), _ids(id), false, ""); + } + + // --- input discipline ----------------------------------------------- + + function test_freeze_rejects_both_input_modes_at_once() public { + uint256 id = _record(ORIG, OWNR, RCVR); + vm.expectRevert(bytes("set BLOCK_ACTORS or BLOCK_REQUEST_IDS, not both - they build different calls")); + script.dispatch("freeze", _addrs(STRANGER), _ids(id), false, ""); + } + + function test_freeze_requires_some_input() public { + vm.expectRevert(bytes("set BLOCK_ACTORS or BLOCK_REQUEST_IDS")); + script.dispatch("freeze", _noAddrs(), _noIds(), false, ""); + } + + function test_unknown_action_is_rejected() public { + vm.expectRevert( + bytes( + "BLOCK_ACTION must be one of: freeze, blacklist, unfreeze, unblacklist, pause, unpause, verify, disable-perimeter, enable-perimeter" + ) + ); + script.dispatch("halt", _addrs(ORIG), _noIds(), false, ""); + } + + // --- happy paths ---------------------------------------------------- + + function test_freeze_and_blacklist_preview_by_actor() public view { + script.dispatch("freeze", _addrs(ORIG), _noIds(), false, ""); + script.dispatch("blacklist", _addrs(ORIG), _noIds(), false, ""); + } + + function test_block_preview_by_request() public { + uint256 id = _record(ORIG, OWNR, RCVR); + script.dispatch("freeze", _noAddrs(), _ids(id), true, "incident drill"); + script.dispatch("blacklist", _noAddrs(), _ids(id), false, ""); + } + + function test_pause_and_unpause_preview() public { + script.dispatch("pause", _noAddrs(), _noIds(), false, ""); + // Already unpaused: the script reports nothing to submit rather than + // emitting calldata for a no-op Safe transaction. + script.dispatch("unpause", _noAddrs(), _noIds(), false, ""); + + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + script.dispatch("unpause", _noAddrs(), _noIds(), false, ""); + } + + function test_verify_reads_live_state() public { + uint256 id = _record(ORIG, OWNR, RCVR); + vm.prank(ADMIN); + queue.freezeFromRequest(_ids(id), true, keccak256("drill")); + script.dispatch("verify", _noAddrs(), _ids(id), true, ""); + script.dispatch("verify", _addrs(ORIG), _noIds(), false, ""); + } + + function test_verify_requires_input() public { + vm.expectRevert(bytes("set BLOCK_ACTORS or BLOCK_REQUEST_IDS")); + script.dispatch("verify", _noAddrs(), _noIds(), false, ""); + } + + // --- controller kill switch ----------------------------------------- + + function _deployController() internal returns (ExitFeeController ctrl) { + ExitFeeController impl = new ExitFeeController(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), abi.encodeWithSelector(ExitFeeController.initialize.selector, address(0)) + ); + ctrl = ExitFeeController(address(proxy)); + } + + function test_kill_switch_requires_the_controller_address() public { + vm.expectRevert(bytes("set EXIT_FEE_CONTROLLER for disable-perimeter / enable-perimeter")); + script.dispatch("disable-perimeter", _noAddrs(), _noIds(), false, ""); + } + + function test_disable_perimeter_previews_when_enabled() public { + ExitFeeController ctrl = _deployController(); + ctrl.setSecurityPerimeterEnabled(true); + script.initController(address(ctrl)); + // preview only - the on-chain state must be untouched afterwards + script.dispatch("disable-perimeter", _noAddrs(), _noIds(), false, ""); + assertTrue(ctrl.securityPerimeterEnabled(), "preview must not change state"); + } + + function test_disable_perimeter_refuses_nothing_to_submit_silently() public { + ExitFeeController ctrl = _deployController(); + script.initController(address(ctrl)); + // already disabled: the preview reports ALREADY and emits no calldata, + // and state stays untouched + script.dispatch("disable-perimeter", _noAddrs(), _noIds(), false, ""); + assertFalse(ctrl.securityPerimeterEnabled()); + } + + function test_enable_perimeter_previews_when_disabled() public { + ExitFeeController ctrl = _deployController(); + script.initController(address(ctrl)); + script.dispatch("enable-perimeter", _noAddrs(), _noIds(), false, ""); + assertFalse(ctrl.securityPerimeterEnabled(), "preview must not change state"); + } +} diff --git a/test/unit/DeployQueueAndWire.t.sol b/test/unit/DeployQueueAndWire.t.sol new file mode 100644 index 0000000..d3bdf1d --- /dev/null +++ b/test/unit/DeployQueueAndWire.t.sol @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../../src/interfaces/IExitDelayQueueHost.sol"; +import {DeployQueueAndWire} from "../../script/05_DeployQueueAndWire.s.sol"; + +/// @dev Minimal WRBTC stand-in for the queue's `wrbtc_` init param (only its +/// address matters for the deploy path). +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + receive() external payable {} +} + +/// @dev Minimal product-host stand-in implementing the `setExitDelayQueue` +/// pointer. Mirrors the real host's unstructured-slot pointer +/// + Owner-gated setter well enough for the wire step to be exercised +/// end-to-end. Reverts writes from a non-owner (the real host is +/// Owner/Timelock-gated). +contract MockProductHost is IExitDelayQueueHost { + address public owner; + address public exitDelayQueue; + + constructor(address owner_) { + owner = owner_; + } + + function setExitDelayQueue(address queue) external override { + require(msg.sender == owner, "MockProductHost: not owner"); + exitDelayQueue = queue; + } +} + +/// @title SP2-CTRL-02-ordering — 05_DeployQueueAndWire run() + host-wire path +/// @notice Exercises the ACTUAL `run(uint256)` broadcast path end-to-end (deploy +/// the queue behind its proxy + wire `setExitDelayQueue` on the supplied +/// product hosts), which previously had ZERO coverage. Also pins the +/// core SP2-CTRL-02-ordering fix: the deploy path DELIBERATELY does NOT +/// configure the controller's admin / globalDelaySeconds (activation steps +/// 4–5 are Owner actions) and does NOT assert the go-live gates (those +/// moved to 06_VerifyActivation) — so a correctly-ordered first deploy, +/// run BEFORE the controller is configured, no longer self-aborts. +contract DeployQueueAndWireRunTest is Test { + address constant CTRL_OWNER = address(0xC0FFEE); + address constant QUEUE_OWNER = address(0x0E7E7); + address constant QUEUE_ADMIN = address(0x6DA12D); // != owner (queue enforces Admin!=Owner) + address constant SOURCE = address(0x50117CE); + + // A stand-in queue-pointer address for the direct `wireHosts` tests (the wire + // step is agnostic to what the pointer points at — it just sets + reads it). + address constant QUEUE_PTR = address(0x0DE10A); + + DeployQueueAndWire script; + MockWRBTC wrbtc; + ExitFeeController controller; + + function setUp() public { + script = new DeployQueueAndWire(); + wrbtc = new MockWRBTC(); + + // ── Controller proxy (mirrors 03_DeployController), left UNCONFIGURED: + // admin==0, globalDelaySeconds==0 (activation steps 4–5 not run) — the deploy + // must not depend on it (SP2-CTRL-02-ordering). No deployment artifact is + // written here: this suite drives the deploy+wire logic directly + // (validateConfig / wireHosts / a direct proxy deploy), NOT env→run() — + // see the "run() env→broadcast coverage" note below. ── + ExitFeeController cImpl = new ExitFeeController(); + bytes memory cInit = abi.encodeWithSelector(ExitFeeController.initialize.selector, CTRL_OWNER); + controller = ExitFeeController(address(new ERC1967Proxy(address(cImpl), cInit))); + } + + // ── run() env→broadcast coverage lives in ONE place only ── + // The full env→run() deploy+wire path mutates PROCESS-global env via + // `vm.setEnv` (EXIT_DELAY_QUEUE_OWNER / *_HOST / DEFER_HOSTS / …), which forge + // does NOT isolate — and it runs test SUITES in parallel threads, so another + // suite's `vm.setEnv` of the SAME keys (06_VerifyActivation also drives an + // env→run() gate over SOVRYN_PROTOCOL_HOST/…) can land BETWEEN this suite's + // set and read, corrupting the config mid-test (a leaked non-zero host would + // make `wireHosts` call a non-contract → revert). To stay deterministic we + // therefore do NOT drive `run()` through env here. Instead the deploy+wire + // logic is pinned RACE-FREE by: + // • `validateConfig(...)` — every C1/C2 no-silent-blank abort branch, on an + // in-memory struct (pure, no env); + // • `wireHosts(...)` — the deploy's host-wire step directly, incl. the + // wiredCount return that drives the conditional success line (no env); + // • the queue-init assertions below, via a direct proxy deploy (no env). + // 06_VerifyActivation's `test_run_passes_cleanly_when_both_hosts_wired` + // is the single env→run() smoke test across these two script suites. + + // ── The deploy step's queue-init (owner/admin/floor from initialize) is pinned + // by deploying the exact impl+proxy the script deploys, with the same init + // encoding — NO env, so it cannot race. Proves the correctly-ordered first + // deploy yields a queue owned by the intended Owner (NOT the deployer) with + // the guardian + floor set, independent of the (still-unconfigured) + // controller. ── + function test_deploy_initializes_queue_owner_admin_floor() public { + // The controller is deliberately left unconfigured (admin==0, delay==0) — + // the deploy must not depend on it (SP2-CTRL-02-ordering). + assertEq(controller.admin(), address(0), "precondition: controller admin unset"); + assertEq(uint256(controller.globalDelaySeconds()), 0, "precondition: global delay unset"); + + address[] memory sources = new address[](1); + sources[0] = SOURCE; + + address queueImpl = address(new ExitDelayQueue()); + address queueProxy = address( + new ERC1967Proxy( + queueImpl, + abi.encodeCall( + ExitDelayQueue.initialize, + (QUEUE_OWNER, QUEUE_ADMIN, address(wrbtc), uint32(3600), sources) + ) + ) + ); + + assertTrue(queueProxy != queueImpl, "proxy != impl"); + + ExitDelayQueue queue = ExitDelayQueue(payable(queueProxy)); + assertEq(queue.owner(), QUEUE_OWNER, "queue owner from init (NOT deployer)"); + assertEq(queue.admin(), QUEUE_ADMIN, "queue admin from init"); + assertEq(uint256(queue.minimumDelaySeconds()), 3600, "queue floor from init"); + assertTrue(queue.isAllowedSource(SOURCE), "allowed source seeded from init"); + } + + // ── C1/C2 no-silent-blanks aborts are driven through the PURE `validateConfig` + // entrypoint (NOT env→run()), so each abort branch is pinned deterministically + // without racing on process-global env. `_validCfg()` builds a deployable + // baseline; each test zeroes exactly one field and asserts the distinct abort. ── + + function _validCfg() internal view returns (DeployQueueAndWire.DeployConfig memory cfg) { + cfg.queueOwner = QUEUE_OWNER; + cfg.queueAdmin = QUEUE_ADMIN; + cfg.wrbtc = address(wrbtc); + cfg.minDelay = 3600; + cfg.allowedSources = new address[](0); + cfg.sovrynHost = address(0xBEEF); + cfg.zeroHost = address(0xCAFE); + } + + // ── (C1) A blank queue owner ABORTS — a zero owner would let ExitDelayQueue. + // initialize resolve owner_ to msg.sender, silently leaving the deployer EOA + // holding queue authority. Fail loud, never default to 0. ── + function test_validateConfig_reverts_when_queue_owner_blank() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.queueOwner = address(0); + vm.expectRevert( + bytes( + "05: EXIT_DELAY_QUEUE_OWNER must be set (C1: zero owner => deployer EOA holds queue authority)" + ) + ); + script.validateConfig(cfg, false); + } + + // ── (C1) A blank queue admin ABORTS. ── + function test_validateConfig_reverts_when_queue_admin_blank() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.queueAdmin = address(0); + vm.expectRevert(bytes("05: EXIT_DELAY_QUEUE_ADMIN must be set")); + script.validateConfig(cfg, false); + } + + // ── Admin == Owner is the supported launch shape and must validate. ── + function test_validateConfig_accepts_admin_equal_to_owner() public view { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.queueAdmin = cfg.queueOwner; + // Does not revert: the governance Safe holds both roles at launch. + script.validateConfig(cfg, false); + } + + // ── (C1) A blank WRBTC address ABORTS. ── + function test_validateConfig_reverts_when_wrbtc_blank() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.wrbtc = address(0); + vm.expectRevert(bytes("05: WRBTC_ADDRESS must be set")); + script.validateConfig(cfg, false); + } + + // ── (C2) A blank sovryn host WITHOUT DEFER_HOSTS ABORTS — a missing host must + // never no-op-wire a fail-open zero-delay surface. ── + function test_validateConfig_reverts_when_sovryn_host_blank_and_not_deferred() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0); + vm.expectRevert(bytes("05: SOVRYN_PROTOCOL_HOST must be set (or set DEFER_HOSTS=true to defer)")); + script.validateConfig(cfg, false); + } + + // ── (C2) A blank Zero host WITHOUT DEFER_HOSTS ABORTS. ── + function test_validateConfig_reverts_when_zero_host_blank_and_not_deferred() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.zeroHost = address(0); + vm.expectRevert( + bytes("05: ZERO_BORROWER_OPERATIONS_HOST must be set (or set DEFER_HOSTS=true to defer)") + ); + script.validateConfig(cfg, false); + } + + // ── (C2) With DEFER_HOSTS=true, BOTH hosts may be blank — an EXPLICIT deferral + // is not an error (validateConfig returns silently). ── + function test_validateConfig_allows_blank_hosts_when_deferred() public view { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0); + cfg.zeroHost = address(0); + script.validateConfig(cfg, true); // does not revert + } + + // ── (SP2-G5R2-01 / GATE4-02) A NON-ZERO duplicate host pair (both spec-named + // vars pointing at the SAME address — copy-paste footgun) ABORTS: wiring one + // surface twice would leave the OTHER silently unwired at zero-delay. ── + function test_validateConfig_reverts_on_duplicate_nonzero_hosts() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0xDEAD); + cfg.zeroHost = address(0xDEAD); // same non-zero host as sovryn + vm.expectRevert(bytes("05: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)")); + script.validateConfig(cfg, false); + } + + // ── The duplicate check fires even under DEFER_HOSTS=true when the + // pair is a NON-ZERO duplicate — deferral excuses a ZERO host, never a genuine + // copy-paste of the same real address into both slots. ── + function test_validateConfig_reverts_on_duplicate_nonzero_hosts_even_when_deferred() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0xDEAD); + cfg.zeroHost = address(0xDEAD); + vm.expectRevert(bytes("05: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)")); + script.validateConfig(cfg, true); + } + + // ── The both-ZERO pair under DEFER_HOSTS=true is NOT a duplicate — + // it is an explicit both-host deferral and must still pass (regression guard + // that the `|| == address(0)` clause preserves the defer-both path). ── + function test_validateConfig_both_zero_hosts_not_treated_as_duplicate_when_deferred() public view { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0); + cfg.zeroHost = address(0); + script.validateConfig(cfg, true); // does not revert — both-zero defer, not a dup + } + + // ── A fully-valid config passes validateConfig with hosts required. ── + function test_validateConfig_passes_on_valid_config() public view { + script.validateConfig(_validCfg(), false); // does not revert + } + + // NOTE on env isolation: `vm.setEnv` mutates PROCESS-global env that forge does + // NOT isolate between the (parallel) test functions of one contract. So EXACTLY + // ONE test here drives the full env→run() broadcast path (above), and the host- + // WIRE coverage below drives the script's `wireHosts` entrypoint DIRECTLY (no + // env, no broadcast) — same on-chain wire logic, race-free. This also keeps the + // wire assertions decoupled from the queue-deploy so a wiring regression is + // pinned independently of the deploy path. + + // ── wireHosts wires the queue pointer into each supplied host + returns 2. ── + function test_wireHosts_wires_both_supplied_hosts() public { + // These direct calls are NOT under `startBroadcast`, so `msg.sender` seen by + // the host is `address(script)` — own the hosts by the script accordingly. + MockProductHost sovrynHost = new MockProductHost(address(script)); + MockProductHost zeroHost = new MockProductHost(address(script)); + + uint256 wired = script.wireHosts(QUEUE_PTR, address(sovrynHost), address(zeroHost)); + + assertEq(wired, 2, "both hosts counted as wired (C2 success-line driver)"); + assertEq(sovrynHost.exitDelayQueue(), QUEUE_PTR, "sovryn host wired"); + assertEq(zeroHost.exitDelayQueue(), QUEUE_PTR, "zero host wired"); + } + + // ── An unset (zero) host is SKIPPED (wiring deferred to a per-host SIP); the + // other supplied host is still wired, and only it is counted (wiredCount==1). ── + function test_wireHosts_skips_unset_host_and_wires_the_other() public { + MockProductHost sovrynHost = new MockProductHost(address(script)); + + uint256 wired = script.wireHosts(QUEUE_PTR, address(sovrynHost), address(0)); + + assertEq(wired, 1, "only the supplied host counted (C2)"); + assertEq(sovrynHost.exitDelayQueue(), QUEUE_PTR, "supplied host wired"); + } + + // ── Both hosts unset: wireHosts is a pure no-op and counts 0 (drives the + // "NO hosts wired" branch of the C2 conditional success line). ── + function test_wireHosts_both_unset_is_noop() public { + uint256 wired = script.wireHosts(QUEUE_PTR, address(0), address(0)); + assertEq(wired, 0, "no hosts wired (C2 deferred-all success line)"); + } + + // ── wireHosts reverts if the pointer write is unauthorized on a host (the + // caller is not the host Owner) — surfacing a mis-authorized deploy. ── + function test_wireHosts_reverts_when_host_write_unauthorized() public { + // Host owned by someone ELSE — the script cannot set the pointer. + MockProductHost sovrynHost = new MockProductHost(address(0xB0B)); + vm.expectRevert(bytes("MockProductHost: not owner")); + script.wireHosts(QUEUE_PTR, address(sovrynHost), address(0)); + } + + // ── wireHosts reverts if the write silently does not take (host accepts the + // call but the pointer read-back mismatches) — the read-back guard. ── + function test_wireHosts_reverts_when_pointer_does_not_take() public { + NoopHost badHost = new NoopHost(); // accepts setExitDelayQueue but stores nothing + vm.expectRevert(bytes("05: setExitDelayQueue did not take on host")); + script.wireHosts(QUEUE_PTR, address(badHost), address(0)); + } +} + +/// @dev A host that ACCEPTS `setExitDelayQueue` (no revert, no auth) but never +/// stores the pointer, so `exitDelayQueue()` stays 0 — exercises the wire +/// read-back guard in `_wireHost`. +contract NoopHost is IExitDelayQueueHost { + function setExitDelayQueue(address) external override {} + + function exitDelayQueue() external pure override returns (address) { + return address(0); + } +} diff --git a/test/unit/ExitDelayQueue.t.sol b/test/unit/ExitDelayQueue.t.sol new file mode 100644 index 0000000..75a0166 --- /dev/null +++ b/test/unit/ExitDelayQueue.t.sol @@ -0,0 +1,2328 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +// ─── Mocks ────────────────────────────────────────────────────────────── + +contract MockERC20 is ERC20 { + constructor() ERC20("Mock", "MOCK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Fee-on-transfer token: keeps 1 wei on every transfer. Used to prove the +/// receipt-proof ingress rejects a mismatched received amount. +contract FeeOnTransferERC20 is ERC20 { + constructor() ERC20("Fot", "FOT") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function _transfer(address from, address to, uint256 value) internal override { + // OZ 4.9: split into a 1-wei burn + (value-1) delivered, so the + // recipient measures value-1 (fee-on-transfer behavior). + super._transfer(from, address(0xdead), 1); + super._transfer(from, to, value - 1); + } +} + +/// @dev Minimal WRBTC: mints on deposit-equivalent, burns + sends native on +/// withdraw (unwrap path). +/// @dev A token that behaves normally until `setBurnOnTransfer` is flipped, then +/// burns an extra wei from the SENDER on every transfer. Models an +/// upgradeable token that becomes fee-on-transfer AFTER funds are escrowed +/// — the ingress receipt-proof cannot catch that, only the payout can. +contract SwitchableFeeERC20 is ERC20 { + bool public burnOnTransfer; + + constructor() ERC20("Switchable", "SW") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setBurnOnTransfer(bool on) external { + burnOnTransfer = on; + } + + function _transfer(address from, address to, uint256 value) internal override { + super._transfer(from, to, value); + if (burnOnTransfer) _burn(from, 1); + } +} + +/// @dev Charges the SENDER an extra wei, but only when paying one particular +/// recipient. Models the case that separates a solvency failure from a +/// bouncing receiver: the stored-receiver payout leaves the queue short +/// while the alternate-receiver payout would not. +contract RecipientBiasedFeeERC20 is ERC20 { + address public taxedRecipient; + + constructor() ERC20("Biased", "BIAS") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setTaxedRecipient(address r) external { + taxedRecipient = r; + } + + function _transfer(address from, address to, uint256 value) internal override { + super._transfer(from, to, value); + if (to == taxedRecipient && taxedRecipient != address(0)) _burn(from, 1); + } +} + +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + (bool ok,) = msg.sender.call{value: amount}(""); + require(ok, "wrbtc withdraw"); + } + + receive() external payable {} +} + +/// @dev A source contract that pulls funds from the test and calls the queue's +/// pull-ingress. Mirrors an iToken proxy (`_allowedSource`). +contract SourceHarness { + ExitDelayQueue public queue; + + constructor(ExitDelayQueue q) { + queue = q; + } + + function recordERC20( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrap + ) external returns (uint256) { + ERC20(token).approve(address(queue), amount); + return queue.recordERC20Exit( + token, amount, d, surfaceId, subProduct, effOrig, effOwner, receiver, unwrap + ); + } + + function recordReceivedERC20( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256) { + // push then record (measured-delta path) + ERC20(token).transfer(address(queue), amount); + return queue.recordReceivedERC20Exit( + token, amount, d, surfaceId, subProduct, effOrig, effOwner, receiver + ); + } + + function recordNative( + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external payable returns (uint256) { + // Forward the caller-supplied msg.value (which may deliberately differ + // from `amount` in the mismatch test) so the queue's msg.value==amount + // guard is actually exercised. + return queue.recordNativeExit{value: msg.value}( + amount, d, surfaceId, subProduct, effOrig, effOwner, receiver + ); + } + + function recordReceivedNative( + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256) { + return queue.recordReceivedNativeExit(amount, d, surfaceId, subProduct, effOrig, effOwner, receiver); + } +} + +/// @dev A native pusher (Zero ActivePool). Pushes value into the queue's +/// receive() before the record call fires in the same tx. +contract NativePusherHarness { + function push(address payable queue, uint256 amount) external { + (bool ok,) = queue.call{value: amount}(""); + require(ok, "push"); + } + + receive() external payable {} +} + +/// @dev A receiver that always reverts on receive — proves fail-closed payout. +contract RevertingReceiver { + receive() external payable { + revert("no"); + } +} + +/// @dev Receiver that burns gas in an unbounded loop until it runs out. Used to +/// show a payout attempt that consumes more than the recovery gas budget is +/// read as a bounce, while a caller who under-funds the whole call reverts +/// instead of redirecting. +contract GasSinkReceiver { + receive() external payable { + uint256 i; + while (true) { + i += 1; + // touch storage-ish work to consume gas fast + assembly { + mstore(0x0, i) + } + } + } +} + +/// @dev ERC20 whose transferFrom re-enters the queue's ingress. Proves +/// the `nonReentrant` guard on the four record* fns rejects a re-entrant +/// record during the token pull. The reentrant call MUST revert with the +/// OZ 4.9 require string "ReentrancyGuard: reentrant call"; the harness +/// surfaces both whether it reverted and the exact revert reason. +contract ReentrantERC20 is ERC20 { + ExitDelayQueue public queue; + bool public armed; + bool public reentered; + bool public reentryReverted; + string public reentryRevertReason; + + constructor() ERC20("Reenter", "RNT") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setQueue(ExitDelayQueue q) external { + queue = q; + } + + function arm() external { + armed = true; + } + + function _transfer(address from, address to, uint256 value) internal override { + super._transfer(from, to, value); + if (armed && from != address(0)) { + armed = false; // one-shot so we don't recurse forever + reentered = true; + // Re-enter the SAME pull ingress mid-transfer; nonReentrant must trip. + try queue.recordERC20Exit( + address(this), + uint128(1), + 2 hours, + keccak256("S"), + address(0xB00C), + address(0x111), + address(0x222), + address(0x333), + false + ) { + reentryReverted = false; + } catch Error(string memory reason) { + // String revert (require) — capture the reason so the test can + // assert it was specifically the ReentrancyGuard, not some other + // require (e.g. an unregistered-source authorization revert). + reentryReverted = true; + reentryRevertReason = reason; + } catch { + // Non-string revert (custom error / panic): still record that a + // revert happened, but leave the reason empty so a string + // assertion in the test fails and surfaces the wrong cause. + reentryReverted = true; + } + } + } +} + +/// @dev Minimal UUPS-upgrade target used to prove `_authorizeUpgrade` accepts a +/// valid, non-zero implementation from the Owner (the happy branch that the +/// `UpgradeImplZero` guard does NOT trip). Adds one new getter so we can +/// confirm the proxy is now running the v2 code post-upgrade. +contract ExitDelayQueueV2 is ExitDelayQueue { + function version() external pure returns (uint256) { + return 2; + } +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +contract ExitDelayQueueTest is Test { + // Local event redecls for vm.expectEmit (0.8.20 can't `emit Iface.Event`). + event ExitQueued( + uint256 indexed id, + address indexed originator, + address indexed owner, + address receiver, + address token, + uint128 amount, + uint64 unlockAt, + bytes32 surfaceId, + address subProduct + ); + event ExitExecuted(uint256 indexed id, address indexed receiver, address token, uint128 amount); + event AccountBlocked( + address indexed account, + IExitDelayQueue.BlockState state, + uint256 indexed triggerRequestId, + bytes32 reasonHash + ); + event SurplusSwept(address indexed token, address indexed to, uint256 amount); + + ExitDelayQueue queue; + MockERC20 token; + MockWRBTC wrbtc; + SourceHarness source; + NativePusherHarness pusher; + + address constant OWNER = address(0x0E1); + address constant ADMIN = address(0xAd11); + address constant OUTSIDER = address(0xC0); + address constant ORIG = address(0x0111); + address constant OWNR = address(0x0222); + address constant RCVR = address(0x0333); + + bytes32 constant SURFACE = keccak256("PERIMETER:LENDING_LENDER_WITHDRAW"); + bytes32 constant SURFACE_ZERO = keccak256("PERIMETER:ZERO_WITHDRAW_COLL"); + address constant SUBPRODUCT = address(0xB00C); + + uint32 constant MIN_DELAY = 1 hours; + uint32 constant DELAY = 2 hours; + + function setUp() public { + wrbtc = new MockWRBTC(); + + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + queue = ExitDelayQueue(payable(address(proxy))); + + token = new MockERC20(); + source = new SourceHarness(queue); + pusher = new NativePusherHarness(); + + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + + // fund the source & pusher + token.mint(address(source), 1_000_000 ether); + vm.deal(address(source), 1_000_000 ether); + vm.deal(address(pusher), 1_000_000 ether); + wrbtc.mint(address(source), 1_000_000 ether); + vm.deal(address(wrbtc), 1_000_000 ether); // back the unwrap + } + + // ── helpers ── + + function _queueErc20(uint128 amount) internal returns (uint256 id) { + vm.prank(address(this)); + id = source.recordERC20(address(token), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + // ── initialization ── + + function test_initialize_sets_state() public view { + assertEq(queue.owner(), OWNER); + assertEq(queue.admin(), ADMIN); + assertEq(queue.wrbtc(), address(wrbtc)); + assertEq(queue.minimumDelaySeconds(), MIN_DELAY); + assertEq(queue.lastRequestId(), 0); + } + + function test_initialize_admin_may_equal_owner() public { + // admin == owner is a + // supported shape (the governance Safe holds both roles at launch). + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, OWNER, address(wrbtc), MIN_DELAY, s + ); + ExitDelayQueue q = ExitDelayQueue(payable(address(new ERC1967Proxy(address(impl), init)))); + assertEq(q.owner(), OWNER); + assertEq(q.admin(), OWNER); + } + + function test_initialize_reverts_zero_wrbtc() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + bytes memory init = + abi.encodeWithSelector(ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(0), MIN_DELAY, s); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + new ERC1967Proxy(address(impl), init); + } + + function test_constructor_disables_initializers() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + vm.expectRevert(); + impl.initialize(OWNER, ADMIN, address(wrbtc), MIN_DELAY, s); + } + + function test_renounceOwnership_disabled() public { + vm.prank(OWNER); + vm.expectRevert(ExitDelayQueue.OwnershipCannotBeRenounced.selector); + queue.renounceOwnership(); + } + + // ── ingress: ERC20 pull ── + + function test_recordERC20_happy() public { + uint128 amount = 100 ether; + uint256 id = _queueErc20(amount); + assertEq(id, 1); + + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + assertEq(r.amount, amount); + assertEq(r.originator, ORIG); + assertEq(r.owner, OWNR); + assertEq(r.receiver, RCVR); + assertEq(r.token, address(token)); + assertEq(uint256(r.status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(r.unlockAt, uint64(block.timestamp + DELAY)); + assertEq(r.createdAt, uint64(block.timestamp)); + assertEq(queue.totalEscrowed(address(token)), amount); + assertEq(token.balanceOf(address(queue)), amount); + } + + function test_recordERC20_reverts_unregistered_source() public { + token.mint(OUTSIDER, 10 ether); + vm.startPrank(OUTSIDER); + token.approve(address(queue), 10 ether); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnregisteredSource.selector, OUTSIDER)); + queue.recordERC20Exit(address(token), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + vm.stopPrank(); + } + + function test_recordERC20_reverts_below_floor() public { + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.DelayBelowFloor.selector, uint32(MIN_DELAY - 1), MIN_DELAY) + ); + source.recordERC20( + address(token), 1 ether, MIN_DELAY - 1, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false + ); + } + + function test_recordERC20_reverts_zero_amount() public { + vm.expectRevert(IExitDelayQueue.ZeroAmount.selector); + source.recordERC20(address(token), 0, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + function test_recordERC20_reverts_zero_party() public { + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + source.recordERC20(address(token), 1 ether, DELAY, SURFACE, SUBPRODUCT, address(0), OWNR, RCVR, false); + } + + function test_recordERC20_fee_on_transfer_rejected() public { + FeeOnTransferERC20 fot = new FeeOnTransferERC20(); + fot.mint(address(source), 100 ether); + vm.expectRevert(); // ReceivedAmountMismatch + source.recordERC20(address(fot), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + function test_recordERC20_unwrap_flag_requires_wrbtc() public { + vm.expectRevert(IExitDelayQueue.UnwrapNonWrbtc.selector); + source.recordERC20( + address(token), 1 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, /*unwrap=*/ true + ); + } + + function test_recordERC20_wrbtc_unwrap_ok() public { + uint256 id = + source.recordERC20(address(wrbtc), 5 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + assertTrue(r.unwrapOnDelivery); + assertEq(r.token, address(wrbtc)); + } + + // ── ingress: measured-delta ERC20 ── + + function test_recordReceivedERC20_happy() public { + uint256 id = + source.recordReceivedERC20(address(token), 50 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR); + assertEq(id, 1); + assertEq(queue.totalEscrowed(address(token)), 50 ether); + } + + function test_recordReceivedERC20_reverts_on_short_push() public { + // push only 40 but claim 50 → delta mismatch + vm.prank(address(source)); + token.transfer(address(queue), 40 ether); + vm.prank(address(source)); + vm.expectRevert(); + queue.recordReceivedERC20Exit(address(token), 50 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR); + } + + // ── ingress: native ── + + function test_recordNative_happy() public { + uint256 id = + source.recordNative{value: 3 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + assertEq(queue.totalEscrowed(address(0)), 3 ether); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + assertEq(r.token, address(0)); + assertEq(address(queue).balance, 3 ether); + } + + function test_recordNative_reverts_value_mismatch() public { + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AmountMismatch.selector, 2 ether, 3 ether)); + source.recordNative{value: 2 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + } + + /// @dev `receive` is now UNCONDITIONAL — it accepts + /// native from ANYONE with no sender gate. Stray/donated RBTC only accrues + /// as sweepable surplus (never mis-credited, per the), and the gate + /// had to go because a storage-reading receive() OutOfGas-bricks the + /// 2300-stipend WRBTC unwrap (see ExitDelayQueueUnwrapStipend.t.sol). + function test_receive_accepts_from_anyone_unconditional() public { + vm.deal(OUTSIDER, 1 ether); + vm.prank(OUTSIDER); + (bool ok,) = payable(address(queue)).call{value: 1 ether}(""); + assertTrue(ok); + assertEq(address(queue).balance, 1 ether); + // The donated native is sweepable surplus, not backing (totalEscrowed==0). + assertEq(queue.totalEscrowed(address(0)), 0); + address to = address(0x5EEE); + vm.prank(OWNER); + queue.sweepSurplus(address(0), to); + assertEq(to.balance, 1 ether); + assertEq(address(queue).balance, 0); + } + + function test_recordReceivedNative_via_pusher() public { + vm.prank(OWNER); + queue.setNativePusher(address(pusher)); + // register the source that does the record (a source can be the pusher's + // caller in the real flow; here we register `source` and have IT record). + // Push 4 ether into the queue via the pusher, then record from source. + pusher.push(payable(address(queue)), 4 ether); + uint256 id = source.recordReceivedNative(4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + assertEq(queue.totalEscrowed(address(0)), 4 ether); + assertEq(id, 1); + } + + // ── execution ── + + function test_executeExit_by_owner_after_unlock() public { + uint128 amount = 100 ether; + _queueErc20(amount); + vm.warp(block.timestamp + DELAY); + uint256 rcvrBefore = token.balanceOf(RCVR); + vm.prank(OWNR); + queue.executeExit(1); + assertEq(token.balanceOf(RCVR), rcvrBefore + amount); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(token)), 0); + } + + function test_executeExit_by_originator() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ORIG); + queue.executeExit(1); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + function test_executeExit_reverts_receiver_not_executor() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(RCVR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotExecutor.selector, RCVR)); + queue.executeExit(1); + } + + function test_executeExit_reverts_before_unlock() public { + _queueErc20(10 ether); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.NotUnlocked.selector, 1, uint64(block.timestamp + DELAY)) + ); + queue.executeExit(1); + } + + function test_executeExit_at_exact_unlock_ok() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); // inclusive boundary + vm.prank(OWNR); + queue.executeExit(1); // must not revert + } + + function test_executeExit_reverts_unknown() public { + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.executeExit(99); + } + + function test_executeExit_reverts_double_execute() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.executeExit(1); + } + + function test_executeExit_reverts_when_paused() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + vm.prank(OWNR); + vm.expectRevert(IExitDelayQueue.QueuePaused.selector); + queue.executeExit(1); + } + + function test_executeExit_native_pays_receiver() public { + source.recordNative{value: 5 ether}(5 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.warp(block.timestamp + DELAY); + uint256 before = RCVR.balance; + vm.prank(OWNR); + queue.executeExit(1); + assertEq(RCVR.balance, before + 5 ether); + } + + function test_executeExit_wrbtc_unwraps_to_native() public { + uint256 id = + source.recordERC20(address(wrbtc), 5 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + vm.warp(block.timestamp + DELAY); + uint256 before = RCVR.balance; + vm.prank(OWNR); + queue.executeExit(id); + assertEq(RCVR.balance, before + 5 ether); // native received, not WRBTC + assertEq(wrbtc.balanceOf(RCVR), 0); + } + + function test_executeExit_reverting_receiver_holds_request() public { + RevertingReceiver rr = new RevertingReceiver(); + source.recordNative{value: 1 ether}(1 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(rr)); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(); // Address.sendValue bubbles + queue.executeExit(1); + // still Queued (whole call rolled back) → recoverable via Leg-3 + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + // ── batch execution ── + + function test_executeExits_batch_happy() public { + _queueErc20(1 ether); + _queueErc20(2 ether); + vm.warp(block.timestamp + DELAY); + uint256[] memory ids = new uint256[](2); + ids[0] = 1; + ids[1] = 2; + vm.prank(OWNR); + queue.executeExits(ids); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(uint256(queue.getRequest(2).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + function test_executeExits_reverts_whole_batch_on_invalid() public { + _queueErc20(1 ether); + _queueErc20(2 ether); + vm.warp(block.timestamp + DELAY); + uint256[] memory ids = new uint256[](2); + ids[0] = 1; + ids[1] = 99; // unknown → whole batch reverts + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.executeExits(ids); + // id 1 must NOT have executed (atomicity) + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + function test_executeExits_duplicate_id_reverts() public { + _queueErc20(1 ether); + vm.warp(block.timestamp + DELAY); + uint256[] memory ids = new uint256[](2); + ids[0] = 1; + ids[1] = 1; // duplicate → second hits AlreadyTerminal + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.executeExits(ids); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + function test_executeExits_empty_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(OWNR); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.executeExits(ids); + } + + // ── block model ── + + function test_freeze_blocks_execution() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ORIG, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.executeExit(1); + } + + function test_freeze_receiver_blocks_execution() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(RCVR); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, RCVR, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.executeExit(1); + } + + function test_unfreeze_restores_execution() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(ADMIN); + queue.unfreeze(ORIG); + vm.prank(OWNR); + queue.executeExit(1); // executes fine now + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + function test_freeze_only_admin_or_owner() public { + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.freeze(ORIG); + } + + function test_blacklist_escalates_from_frozen_atomically() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Frozen)); + vm.prank(ADMIN); + queue.blacklist(ORIG); // no unfreeze first + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_unblacklist_on_frozen_reverts() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotBlacklisted.selector, ORIG)); + queue.unblacklist(ORIG); + } + + function test_unfreeze_on_blacklisted_reverts() public { + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotFrozen.selector, ORIG)); + queue.unfreeze(ORIG); + } + + function test_unfreeze_absent_reverts() public { + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotFrozen.selector, ORIG)); + queue.unfreeze(ORIG); + } + + function test_freeze_on_blacklisted_no_downgrade() public { + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.prank(ADMIN); + queue.freeze(ORIG); // must NOT downgrade + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_freezeFromRequest_blocks_orig_and_owner() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.freezeFromRequest(1, false, bytes32(0)); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.None)); // freezeReceiver=false + assertEq(queue.blockTrigger(ORIG), 1); + } + + function test_freezeFromRequest_receiver_flag() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.freezeFromRequest(1, true, bytes32(0)); + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.Frozen)); + } + + function test_blockedAccounts_enumeration() public { + vm.startPrank(ADMIN); + queue.freeze(ORIG); + queue.blacklist(OWNR); + vm.stopPrank(); + (address[] memory got, uint256 total) = queue.blockedAccounts(0, 10); + assertEq(got.length, 2); + assertEq(total, 2); + } + + function test_batch_freeze() public { + address[] memory a = new address[](2); + a[0] = ORIG; + a[1] = OWNR; + vm.prank(ADMIN); + queue.freeze(a); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Frozen)); + } + + // ── recovery: Leg 2 ── + + function _setupRoute(bool topUp) internal returns (bytes32 routeId) { + if (topUp) { + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE, true); + } + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: topUp ? SUBPRODUCT : address(0xDE57), + topUpPool: topUp + }); + vm.prank(OWNER); + routeId = queue.setRecoveryRoute(route); + } + + function test_resolveToProtocol_requires_blacklisted_source() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + // no blacklist yet → SourceNotBlacklisted + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.SourceNotBlacklisted.selector, ORIG)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_happy_on_blacklisted_originator() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(ORIG); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + uint256 destBefore = token.balanceOf(address(0xDE57)); + vm.prank(ADMIN); + queue.resolveToProtocol(ids, routeId); + assertEq(token.balanceOf(address(0xDE57)), destBefore + 10 ether); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedToProtocol)); + assertEq(queue.totalEscrowed(address(token)), 0); + } + + function test_resolveToProtocol_authorized_by_owner_blacklist() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(OWNR); // owner blacklisted (OR predicate) + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + queue.resolveToProtocol(ids, routeId); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedToProtocol)); + } + + function test_resolveToProtocol_receiver_block_does_not_authorize() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(RCVR); // receiver-only → NEVER authorizes Leg-2 + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.SourceNotBlacklisted.selector, ORIG)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_provenance_mismatch() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.blacklist(ORIG); + // route with a different token + MockERC20 other = new MockERC20(); + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(other), + destination: address(0xDE57), + topUpPool: false + }); + vm.prank(OWNER); + bytes32 routeId = queue.setRecoveryRoute(route); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.RouteProvenanceMismatch.selector, 1, routeId)); + queue.resolveToProtocol(ids, routeId); + } + + function test_setRecoveryRoute_topup_requires_feasible() public { + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: SUBPRODUCT, + topUpPool: true + }); + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.TopUpInfeasibleSurface.selector, SURFACE)); + queue.setRecoveryRoute(route); + } + + function test_setRecoveryRoute_topup_rejects_native() public { + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE, true); + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(0), // native → can never be Leg-2a + destination: SUBPRODUCT, + topUpPool: true + }); + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.TopUpInfeasibleSurface.selector, SURFACE)); + queue.setRecoveryRoute(route); + } + + /// @dev A top-up route means "restore the pool this exit came from". The + /// struct documented that; registration did not check it, so a route + /// could be registered as a top-up while paying somewhere else, and a + /// Leg-2 recovery would carry a blocked user's escrow there under the + /// top-up label. + function test_setRecoveryRoute_topup_requires_destination_is_the_pool() public { + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE, true); + address elsewhere = address(0xDE57); + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: elsewhere, + topUpPool: true + }); + vm.prank(OWNER); + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.TopUpDestinationMismatch.selector, elsewhere, SUBPRODUCT) + ); + queue.setRecoveryRoute(route); + } + + /// @dev The same route with the destination bound to its own pool is fine — + /// proving the guard rejects the mismatch, not top-ups as such. + function test_setRecoveryRoute_topup_accepts_its_own_pool() public { + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE, true); + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: SUBPRODUCT, + topUpPool: true + }); + vm.prank(OWNER); + queue.setRecoveryRoute(route); + } + + /// @dev A successful `safeTransfer` proves only that the token did not + /// revert. A token that charges the SENDER on transfer moves more than + /// `amount` out while `_totalEscrowed` drops by exactly `amount`, + /// leaving every remaining request short. The escrow is created with a + /// well-behaved token and the fee is switched on afterwards, which is + /// what an upgradeable token can really do. + function test_executeExit_reverts_when_a_payout_would_leave_the_queue_short() public { + SwitchableFeeERC20 sneaky = new SwitchableFeeERC20(); + sneaky.mint(address(source), 100 ether); + + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + uint256 id = + source.recordERC20(address(sneaky), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + + // A second escrow so there is somebody left to shortchange. + source.recordERC20(address(sneaky), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + + sneaky.setBurnOnTransfer(true); + vm.warp(block.timestamp + DELAY + 1); + vm.prank(ORIG); + vm.expectRevert(IExitDelayQueue.SolvencyViolated.selector); + queue.executeExit(id); + } + + /// @dev `recoverStuckExit` attempts the stored receiver inside a try/catch + /// whose bare `catch` reads ANY revert as that receiver bouncing. A + /// solvency failure raised inside the payout would therefore be taken + /// for a bounce and redirect the funds to the CALLER-SELECTED alternate + /// receiver — bypassing the immutable receiver and hiding the alarm. + /// The check belongs outside that frame, and this pins it there. + function test_recoverStuckExit_does_not_redirect_on_a_solvency_failure() public { + RecipientBiasedFeeERC20 biased = new RecipientBiasedFeeERC20(); + biased.mint(address(source), 100 ether); + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + + uint256 id = + source.recordERC20(address(biased), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + // A second escrow, so there is somebody left to be shortchanged. + source.recordERC20(address(biased), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + + // Only the stored receiver is taxed; the alternate one is not. + address alt = address(0xA17E); + biased.setTaxedRecipient(RCVR); + + vm.warp(block.timestamp + DELAY + 1); + vm.prank(ORIG); + vm.expectRevert(IExitDelayQueue.SolvencyViolated.selector); + queue.recoverStuckExit(id, alt); + + assertEq(biased.balanceOf(alt), 0, "funds were redirected to the caller's address"); + } + + function test_resolveToProtocol_only_admin_or_owner() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.resolveToProtocol(ids, routeId); + } + + // ── recovery: Leg 3 ── + + function test_resolveBySIP_on_blocked_request() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.freeze(RCVR); // receiver-only block → held, resolvable by SIP + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + address dest = address(0x7EEA); + vm.prank(OWNER); + queue.resolveBySIP(ids, dest); + assertEq(token.balanceOf(dest), 10 ether); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedBySIP)); + } + + function test_resolveBySIP_on_paused() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + queue.resolveBySIP(ids, address(0x7EEA)); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedBySIP)); + } + + function test_resolveBySIP_on_not_yet_unlocked() public { + _queueErc20(10 ether); + // still locked → resolvable + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + queue.resolveBySIP(ids, address(0x7EEA)); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedBySIP)); + } + + function test_resolveBySIP_rejects_honest_unlocked_request() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); // unlocked, unblocked, not paused + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotResolvableBySIP.selector, 1)); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + function test_resolveBySIP_only_owner() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); // admin cannot Leg-3 + vm.expectRevert(); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + // ── recoverStuckExit(id, altReceiver) — verify-by-attempting redirect leg ── + + address constant ALT = address(0x0A17); // healthy alternate receiver (no code) + + /// @notice (a) bouncing original receiver + clean actors → recoverStuckExit + /// attempts the stored receiver (bounces), then pays altReceiver; + /// status Executed, escrow cleared. + function test_recover_bouncing_original_pays_altReceiver() public { + RevertingReceiver rr = new RevertingReceiver(); + uint256 id = source.recordNative{value: 4 ether}( + 4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(rr) + ); + vm.warp(block.timestamp + DELAY); + + // A straight execute bounces (fail-closed payout, request held). + vm.prank(OWNR); + vm.expectRevert(); + queue.executeExit(id); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + + uint256 altBefore = ALT.balance; + // The ExitExecuted event names the party that actually got paid: ALT. + vm.expectEmit(true, true, false, true, address(queue)); + emit ExitExecuted(id, ALT, address(0), 4 ether); + + vm.prank(OWNR); + queue.recoverStuckExit(id, ALT); + + assertEq(ALT.balance, altBefore + 4 ether, "alt paid on bounce"); + assertEq(address(rr).balance, 0, "bouncing original got nothing"); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(0)), 0); + } + + /// @notice (a') bouncing original for a WRBTC-escrowed (unwrapOnDelivery) + /// request: the stored-receiver unwrap+send bounces, then altReceiver is + /// paid NATIVE RBTC (the unwrap is re-done on the altReceiver leg). + function test_recover_bouncing_original_unwrap_pays_native_alt() public { + RevertingReceiver rr = new RevertingReceiver(); + uint128 amount = 5 ether; + uint256 id = source.recordERC20( + address(wrbtc), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, address(rr), true + ); + vm.warp(block.timestamp + DELAY); + + uint256 altBefore = ALT.balance; + vm.prank(OWNR); + queue.recoverStuckExit(id, ALT); + + assertEq(ALT.balance, altBefore + amount, "alt gets native (unwrapped)"); + assertEq(queue.totalEscrowed(address(wrbtc)), 0); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + /// @notice A caller cannot force a false bounce by starving the stored-receiver + /// payout: supplying less than the recovery gas budget reverts the whole + /// call (InsufficientGasForRecovery) rather than redirecting to altReceiver. + function test_recover_reverts_when_gas_below_budget() public { + GasSinkReceiver sink = new GasSinkReceiver(); + uint256 id = source.recordNative{value: 4 ether}( + 4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(sink) + ); + vm.warp(block.timestamp + DELAY); + + // Give the call less than RECOVER_PAYOUT_GAS + floor: it must revert, + // not swallow an out-of-gas as a bounce and pay ALT. + vm.prank(OWNR); + vm.expectRevert(ExitDelayQueue.InsufficientGasForRecovery.selector); + queue.recoverStuckExit{gas: 2_000_000}(id, ALT); + + // Escrow untouched, request still Queued — no redirect happened. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(0)), 4 ether); + } + + /// @notice A receiver that genuinely needs more than the gas budget IS a + /// bounce: with ample total gas, the stored-receiver attempt is capped, + /// caught, and altReceiver is paid — the intended stuck-exit recovery. + function test_recover_gas_sink_receiver_falls_through_to_alt() public { + GasSinkReceiver sink = new GasSinkReceiver(); + uint256 id = source.recordNative{value: 4 ether}( + 4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(sink) + ); + vm.warp(block.timestamp + DELAY); + + uint256 altBefore = ALT.balance; + vm.prank(OWNR); + queue.recoverStuckExit(id, ALT); // ample gas from the test harness + assertEq(ALT.balance, altBefore + 4 ether, "alt paid when receiver exceeds budget"); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + /// @notice resolveBySIP rejects a destination that would trap or swallow the + /// escrow: the queue itself, WRBTC, or the escrowed token. + function test_resolveBySIP_rejects_trapping_destinations() public { + uint256 id = _queueErc20(10 ether); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); // make the request resolvable + uint256[] memory ids = new uint256[](1); + ids[0] = id; + + vm.startPrank(OWNER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(queue))); + queue.resolveBySIP(ids, address(queue)); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(wrbtc))); + queue.resolveBySIP(ids, address(wrbtc)); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(token))); + queue.resolveBySIP(ids, address(token)); + vm.stopPrank(); + } + + /// @notice A plain freeze on an already-blacklisted address holds the stronger + /// state AND preserves the blacklist's recorded trigger — a no-evidence + /// freeze must not erase why the address was blacklisted. + function test_freeze_on_blacklisted_preserves_trigger() public { + uint256 id = _queueErc20(1 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.startPrank(ADMIN); + queue.blacklistFromRequest(ids, false, keccak256("theft")); + uint256 triggerBefore = queue.blockTrigger(ORIG); + assertEq(triggerBefore, id, "blacklist recorded the request id"); + + // Plain freeze carries no evidence (trigger 0): must not overwrite. + queue.freeze(ORIG); + vm.stopPrank(); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(queue.blockTrigger(ORIG), triggerBefore, "trigger preserved through freeze"); + } + + /// @notice (b) HEALTHY original receiver → recoverStuckExit pays the STORED + /// receiver; altReceiver is IGNORED (proves no arbitrary redirect — + /// a healthy exit can never be diverted, verify-by-attempting). + function test_recover_healthy_original_pays_stored_receiver_alt_ignored() public { + uint128 amount = 100 ether; + _queueErc20(amount); // stored receiver = RCVR (healthy, no code) + vm.warp(block.timestamp + DELAY); + + uint256 rcvrBefore = token.balanceOf(RCVR); + uint256 altBefore = token.balanceOf(ALT); + + // Event names the STORED receiver — altReceiver is not the payee. + vm.expectEmit(true, true, false, true, address(queue)); + emit ExitExecuted(1, RCVR, address(token), amount); + + vm.prank(OWNR); + queue.recoverStuckExit(1, ALT); + + assertEq(token.balanceOf(RCVR), rcvrBefore + amount, "stored receiver paid"); + assertEq(token.balanceOf(ALT), altBefore, "altReceiver ignored on a healthy exit"); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(token)), 0); + } + + /// @notice (b') healthy original for a NATIVE request → stored receiver paid, + /// altReceiver ignored. + function test_recover_healthy_native_pays_stored_receiver() public { + source.recordNative{value: 3 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.warp(block.timestamp + DELAY); + uint256 rcvrBefore = RCVR.balance; + uint256 altBefore = ALT.balance; + vm.prank(ORIG); + queue.recoverStuckExit(1, ALT); + assertEq(RCVR.balance, rcvrBefore + 3 ether, "stored receiver paid"); + assertEq(ALT.balance, altBefore, "alt ignored"); + } + + /// @notice (b'') altReceiver == stored receiver on a HEALTHY exit pays EXACTLY + /// ONCE (regression for the double-pay the invariant suite caught: a + /// successful stored-receiver attempt must never also fire the + /// altReceiver leg just because the two addresses are equal). + function test_recover_healthy_alt_equals_receiver_pays_once() public { + source.recordNative{value: 3 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.warp(block.timestamp + DELAY); + uint256 rcvrBefore = RCVR.balance; + uint256 queueBefore = address(queue).balance; + vm.prank(OWNR); + queue.recoverStuckExit(1, RCVR); // altReceiver == stored receiver + assertEq(RCVR.balance, rcvrBefore + 3 ether, "paid exactly once"); + assertEq(address(queue).balance, queueBefore - 3 ether, "queue drained exactly once"); + assertEq(queue.totalEscrowed(address(0)), 0); + } + + /// @notice (c) blocked STORED receiver → recoverStuckExit reverts + /// ActorBlocked(receiver) EVEN WITH a clean altReceiver. This is the + /// must-fix regression: a blocked/hacked original receiver refuses + /// recovery entirely (→ Leg-3), so it can never be bypassed by naming a + /// fresh altReceiver. The exact bypass the review caught. + function test_recover_reverts_if_stored_receiver_blocked_even_with_clean_alt() public { + _queueErc20(10 ether); // stored receiver = RCVR + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.blacklist(RCVR); // the STORED receiver is a confirmed-hack address + + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, RCVR, IExitDelayQueue.BlockState.Blacklisted + ) + ); + queue.recoverStuckExit(1, ALT); // clean alt, but stored receiver is blocked + + // Nothing moved — still Queued, escrow intact (falls to Leg-3). + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(token)), 10 ether); + } + + /// @notice (c') a merely-Frozen stored receiver also refuses recovery (both + /// block states gate). + function test_recover_reverts_if_stored_receiver_frozen() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(RCVR); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, RCVR, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (d-alt) blocked altReceiver → reverts ActorBlocked(altReceiver). + function test_recover_reverts_if_altReceiver_frozen() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ALT); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ALT, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + /// @notice (d-alt-bl) blacklisted altReceiver → reverts. + function test_recover_reverts_if_altReceiver_blacklisted() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.blacklist(ALT); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ALT, IExitDelayQueue.BlockState.Blacklisted + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (d-orig) blocked ORIGINATOR → reverts (a hacked source cannot escape + /// a freeze via the recovery leg). + function test_recover_reverts_if_originator_blocked() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ORIG, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (d-owner) blocked OWNER → reverts. + function test_recover_reverts_if_owner_blocked() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(OWNR); + vm.prank(ORIG); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, OWNR, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (e) altReceiver guard: reverts if altReceiver is + /// {0, this, token, wrbtc}. + function test_recover_reverts_if_altReceiver_zero() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(0))); + queue.recoverStuckExit(1, address(0)); + } + + function test_recover_reverts_if_altReceiver_is_queue() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(queue))); + queue.recoverStuckExit(1, address(queue)); + } + + function test_recover_reverts_if_altReceiver_is_token() public { + _queueErc20(10 ether); // request token == token + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(token))); + queue.recoverStuckExit(1, address(token)); + } + + function test_recover_reverts_if_altReceiver_is_wrbtc() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(wrbtc))); + queue.recoverStuckExit(1, address(wrbtc)); + } + + /// @notice (f) caller not in {originator, owner} → reverts NotExecutor. The + /// receiver may NOT recover (authorization matches executeExit). + function test_recover_reverts_if_caller_not_executor() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + // The stored receiver is not an executor. + vm.prank(RCVR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotExecutor.selector, RCVR)); + queue.recoverStuckExit(1, ALT); + // Neither is an arbitrary outsider. + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotExecutor.selector, OUTSIDER)); + queue.recoverStuckExit(1, ALT); + } + + /// @notice recoverStuckExit still enforces the unlock gate. + function test_recover_reverts_if_locked() public { + _queueErc20(10 ether); + uint64 unlockAt = queue.getRequest(1).unlockAt; + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotUnlocked.selector, 1, unlockAt)); + queue.recoverStuckExit(1, ALT); + } + + /// @notice recoverStuckExit still enforces the pause gate. + function test_recover_reverts_if_paused() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + vm.prank(OWNR); + vm.expectRevert(IExitDelayQueue.QueuePaused.selector); + queue.recoverStuckExit(1, ALT); + } + + /// @notice recoverStuckExit reverts UnknownRequest / AlreadyTerminal like execute. + function test_recover_reverts_unknown_and_terminal() public { + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 999)); + queue.recoverStuckExit(999, ALT); + + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); // terminal now (healthy pay to RCVR) + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.recoverStuckExit(1, ALT); + } + + /// @notice Both original AND altReceiver bounce → the whole call reverts and the + /// funds stay Queued (CEI rollback; no partial spend). + function test_recover_reverts_if_both_bounce() public { + RevertingReceiver rr1 = new RevertingReceiver(); + RevertingReceiver rr2 = new RevertingReceiver(); + uint256 id = source.recordNative{value: 2 ether}( + 2 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(rr1) + ); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(); // altReceiver (rr2) sendValue reverts → whole call rolls back + queue.recoverStuckExit(id, address(rr2)); + // Still Queued, escrow intact. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(0)), 2 ether); + } + + /// @notice payoutExternal (the internal try/catch trampoline) is self-call-only: + /// a direct external call reverts SelfOnly, so the leg's catchable payout + /// cannot be abused as an arbitrary transfer primitive. + function test_payoutExternal_is_self_only() public { + _queueErc20(10 ether); + vm.prank(OUTSIDER); + vm.expectRevert(IExitDelayQueue.SelfOnly.selector); + queue.payoutExternal(address(token), OUTSIDER, 1 ether, false); + } + + /// @notice Property: recoverStuckExit is solvency-safe and never double-spends + /// across {healthy | bouncing original} × {unlocked | locked} × + /// {altBlocked | clean} × {unpaused | paused}. Exactly one of two + /// outcomes holds: a successful terminal recover (escrow decremented by + /// amount, paid to stored-receiver-if-healthy else altReceiver) or a + /// whole-call revert (nothing changed). + function testFuzz_recover_is_solvency_safe( + bool bouncing, + bool unlocked, + bool altBlocked, + bool paused, + uint128 amount + ) public { + amount = uint128(bound(amount, 1, 1e24)); + + // Stored receiver: either a plain payable EOA (healthy) or a reverting + // contract (bouncing). ORIG/OWNR/ALT are plain payable no-code addrs. + address storedReceiver = bouncing ? address(new RevertingReceiver()) : RCVR; + uint256 id = source.recordNative{value: amount}( + amount, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, storedReceiver + ); + uint256 escrowBefore = queue.totalEscrowed(address(0)); + assertEq(escrowBefore, amount); + + // On a healthy exit the STORED receiver is paid (alt ignored); on a bounce + // the ALT is paid. Either way the payee is a distinct plain address here. + address effReceiver = bouncing ? ALT : storedReceiver; + uint256 effBefore = effReceiver.balance; + + if (unlocked) vm.warp(block.timestamp + DELAY); + if (altBlocked) { + vm.prank(ADMIN); + queue.freeze(ALT); + } + if (paused) { + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + } + + vm.prank(OWNR); + try queue.recoverStuckExit(id, ALT) { + // Success: terminal, escrow decremented exactly once, paid to effReceiver. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(0)), escrowBefore - amount); + assertEq(effReceiver.balance, effBefore + amount); + // Success requires all gates satisfied. Note: altBlocked always fails the + // gate (altReceiver is checked unconditionally, even on a healthy exit). + assertTrue(unlocked && !paused && !altBlocked, "success needs clean gates"); + } catch { + // Revert: nothing changed, still Queued, escrow intact. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(0)), escrowBefore); + assertTrue(!unlocked || paused || altBlocked, "revert requires a failing gate"); + } + } + + // ── sweepSurplus ── + + function test_sweepSurplus_moves_only_surplus() public { + _queueErc20(10 ether); // escrowed backing = 10 + // force-send 3 ether of dust + token.mint(address(queue), 3 ether); + uint256 destBefore = token.balanceOf(OWNER); + vm.prank(OWNER); + queue.sweepSurplus(address(token), OWNER); + assertEq(token.balanceOf(OWNER), destBefore + 3 ether); // only surplus + assertEq(token.balanceOf(address(queue)), 10 ether); // backing intact + assertEq(queue.totalEscrowed(address(token)), 10 ether); + } + + function test_sweepSurplus_native() public { + source.recordNative{value: 5 ether}(5 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.deal(address(queue), address(queue).balance + 2 ether); // dust + uint256 destBefore = address(0x5EE).balance; + vm.prank(OWNER); + queue.sweepSurplus(address(0), address(0x5EE)); + assertEq(address(0x5EE).balance, destBefore + 2 ether); + assertEq(address(queue).balance, 5 ether); + } + + function test_sweepSurplus_no_surplus_noop() public { + _queueErc20(10 ether); + vm.prank(OWNER); + queue.sweepSurplus(address(token), OWNER); // 0 surplus, must not revert + assertEq(token.balanceOf(address(queue)), 10 ether); + } + + function test_sweepSurplus_only_owner() public { + vm.prank(ADMIN); + vm.expectRevert(); + queue.sweepSurplus(address(token), ADMIN); + } + + // ── active index / views ── + + function test_getActive_pagination() public { + _queueErc20(1 ether); + _queueErc20(1 ether); + _queueErc20(1 ether); + (uint256[] memory ids, uint256 next) = queue.getActive(OWNR, 0, 2); + assertEq(ids.length, 2); + assertEq(next, 2); + (uint256[] memory ids2, uint256 next2) = queue.getActive(OWNR, 2, 2); + assertEq(ids2.length, 1); + assertEq(next2, 0); // end + } + + function test_getActive_removed_on_execute() public { + _queueErc20(1 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + (uint256[] memory ids,) = queue.getActive(OWNR, 0, 10); + assertEq(ids.length, 0); + (uint256[] memory ids2,) = queue.getActive(ORIG, 0, 10); + assertEq(ids2.length, 0); // removed from BOTH sets + } + + function test_getActive_dual_key_dedup_when_equal() public { + // originator == owner → single set entry + source.recordERC20(address(token), 1 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, ORIG, RCVR, false); + (uint256[] memory ids,) = queue.getActive(ORIG, 0, 10); + assertEq(ids.length, 1); + } + + function test_freeze_does_not_remove_from_active() public { + _queueErc20(1 ether); + vm.prank(ADMIN); + queue.freeze(ORIG); + (uint256[] memory ids,) = queue.getActive(ORIG, 0, 10); + assertEq(ids.length, 1); // freeze holds, does not remove + } + + // ── config ── + + function test_addAllowedSource_only_owner() public { + vm.prank(OUTSIDER); + vm.expectRevert(); + queue.addAllowedSource(OUTSIDER); + } + + function test_removeAllowedSource() public { + vm.prank(OWNER); + queue.removeAllowedSource(address(source)); + assertFalse(queue.isAllowedSource(address(source))); + } + + function test_setAdmin_may_equal_owner() public { + // Admin may equal Owner. + vm.prank(OWNER); + queue.setAdmin(OWNER); + assertEq(queue.admin(), OWNER); + } + + function test_setMinimumDelaySeconds() public { + vm.prank(OWNER); + queue.setMinimumDelaySeconds(3 hours); + assertEq(queue.minimumDelaySeconds(), 3 hours); + } + + /// @dev C3 / (creation-time): the floor is enforced ONCE, at record time. + /// Raising minimumDelaySeconds after a request is already Queued must NOT + /// retroactively extend that request's unlockAt — the raise governs only + /// NEW requests. The already-Queued exit remains executable at its + /// original unlockAt even though its (unlockAt − createdAt) is now BELOW + /// the raised live floor. + function test_floorRaise_does_not_extend_queued_request() public { + // Queue with DELAY (2h) under the initial 1h floor. + uint256 id = _queueErc20(10 ether); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + uint64 unlockAtBefore = r.unlockAt; + assertEq(uint256(r.unlockAt) - uint256(r.createdAt), DELAY); + + // Raise the floor to 10h — ABOVE this request's 2h span. + vm.prank(OWNER); + queue.setMinimumDelaySeconds(10 hours); + assertEq(queue.minimumDelaySeconds(), 10 hours); + + // The stored unlockAt is unchanged (immutable post-record). + IExitDelayQueue.ExitRequest memory r2 = queue.getRequest(id); + assertEq(r2.unlockAt, unlockAtBefore); + assertLt(uint256(r2.unlockAt) - uint256(r2.createdAt), queue.minimumDelaySeconds()); + + // And it is still executable at its ORIGINAL unlockAt (the raise did not + // push the gate out). Warp to the original unlock and execute. + vm.warp(unlockAtBefore); + uint256 balBefore = token.balanceOf(RCVR); + vm.prank(ORIG); + queue.executeExit(id); + assertEq(token.balanceOf(RCVR) - balBefore, 10 ether); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + /// @dev Complement to the above: a NEW request recorded AFTER the raise IS + /// subject to the new floor (DelayBelowFloor on a sub-floor delay). + function test_floorRaise_applies_to_new_requests() public { + vm.prank(OWNER); + queue.setMinimumDelaySeconds(10 hours); + // DELAY (2h) is now below the 10h floor → the new record is rejected. + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.DelayBelowFloor.selector, DELAY, uint32(10 hours)) + ); + vm.prank(address(this)); + source.recordERC20(address(token), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + function test_setSecurityPerimeterPaused_authority() public { + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.setSecurityPerimeterPaused(true); + } + + // ── batch by-request-id block variants ── + + /// @dev Queue a request with an explicit {orig, owner, receiver} so a batch can + /// span multiple distinct parties. Returns the new id. + function _queueErc20With(uint128 amount, address orig, address ownr, address rcvr) + internal + returns (uint256 id) + { + vm.prank(address(this)); + id = source.recordERC20(address(token), amount, DELAY, SURFACE, SUBPRODUCT, orig, ownr, rcvr, false); + } + + function test_freezeFromRequest_batch_blocks_all_parties() public { + // two requests, four distinct parties (orig/owner each) plus receivers. + address o1 = address(0xA100); + address w1 = address(0xA101); + address o2 = address(0xA200); + address w2 = address(0xA201); + uint256 id1 = _queueErc20With(10 ether, o1, w1, address(0xA1CE)); + uint256 id2 = _queueErc20With(11 ether, o2, w2, address(0xA2CE)); + + uint256[] memory ids = new uint256[](2); + ids[0] = id1; + ids[1] = id2; + vm.prank(ADMIN); + queue.freezeFromRequest(ids, false, keccak256("case-1")); + + // all four source parties are frozen in ONE tx. + assertEq(uint256(queue.blockStateOf(o1)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(w1)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(o2)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(w2)), uint256(IExitDelayQueue.BlockState.Frozen)); + // receivers untouched (freezeReceiver=false); trigger links to the id. + assertEq(uint256(queue.blockStateOf(address(0xA1CE))), uint256(IExitDelayQueue.BlockState.None)); + assertEq(queue.blockTrigger(o1), id1); + assertEq(queue.blockTrigger(o2), id2); + } + + function test_freezeFromRequest_batch_freezeReceiver_true() public { + uint256 id1 = _queueErc20With(10 ether, address(0xB100), address(0xB101), address(0xB1CE)); + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + vm.prank(ADMIN); + queue.freezeFromRequest(ids, true, bytes32(0)); + assertEq(uint256(queue.blockStateOf(address(0xB1CE))), uint256(IExitDelayQueue.BlockState.Frozen)); + } + + function test_blacklistFromRequest_batch_blocks_all_parties() public { + uint256 id1 = _queueErc20With(10 ether, address(0xC100), address(0xC101), address(0xC1CE)); + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + vm.prank(ADMIN); + queue.blacklistFromRequest(ids, false, bytes32(0)); + assertEq( + uint256(queue.blockStateOf(address(0xC100))), uint256(IExitDelayQueue.BlockState.Blacklisted) + ); + assertEq( + uint256(queue.blockStateOf(address(0xC101))), uint256(IExitDelayQueue.BlockState.Blacklisted) + ); + } + + /// @dev One unknown id reverts the WHOLE batch (atomic, like executeExits) — + /// no party from the valid id is left blocked. + function test_freezeFromRequest_batch_one_bad_id_reverts_whole_batch() public { + uint256 id1 = _queueErc20With(10 ether, address(0xD100), address(0xD101), address(0xD1CE)); + uint256[] memory ids = new uint256[](2); + ids[0] = id1; + ids[1] = 999; // never recorded + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 999)); + queue.freezeFromRequest(ids, false, bytes32(0)); + // valid id's parties are NOT blocked (whole batch rolled back). + assertEq(uint256(queue.blockStateOf(address(0xD100))), uint256(IExitDelayQueue.BlockState.None)); + assertEq(uint256(queue.blockStateOf(address(0xD101))), uint256(IExitDelayQueue.BlockState.None)); + } + + /// @dev Frozen→Blacklisted escalation WITHIN a batch: a party frozen by an + /// earlier op is escalated to Blacklisted by a later blacklist batch that + /// names its request (atomic escalation, last-write-wins trigger). + function test_blacklistFromRequest_batch_escalates_frozen_party() public { + address o1 = address(0xE100); + address w1 = address(0xE101); + uint256 id1 = _queueErc20With(10 ether, o1, w1, address(0xE1CE)); + + // first freeze via the single-id path. + vm.prank(ADMIN); + queue.freezeFromRequest(id1, false, keccak256("first")); + assertEq(uint256(queue.blockStateOf(o1)), uint256(IExitDelayQueue.BlockState.Frozen)); + + // now a batch blacklist over the same request escalates directly. + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + vm.prank(ADMIN); + queue.blacklistFromRequest(ids, false, keccak256("confirmed")); + assertEq(uint256(queue.blockStateOf(o1)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(uint256(queue.blockStateOf(w1)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_freezeFromRequest_batch_empty_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.freezeFromRequest(ids, false, bytes32(0)); + } + + function test_blacklistFromRequest_batch_empty_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.blacklistFromRequest(ids, false, bytes32(0)); + } + + function test_freezeFromRequest_batch_only_admin_or_owner() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.freezeFromRequest(ids, false, bytes32(0)); + } + + function test_blacklistFromRequest_batch_by_owner_allowed() public { + uint256 id1 = _queueErc20With(10 ether, address(0xF100), address(0xF101), address(0xF1CE)); + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + // Owner (not just Admin) may also block (onlyAdminOrOwner). + vm.prank(OWNER); + queue.blacklistFromRequest(ids, false, bytes32(0)); + assertEq( + uint256(queue.blockStateOf(address(0xF100))), uint256(IExitDelayQueue.BlockState.Blacklisted) + ); + } + + // ── Admin == Owner supported (chokepoint retired) ── + + /// @dev The 2-step handoff to the current Admin now succeeds and merges the + /// roles — a deliberate decision (launch shape: governance Safe holds + /// both). While merged, the Leg-2/Leg-3 split is intentionally + /// vacuous; it becomes real when ownership moves to Bitocracy. + function test_transferOwnership_to_admin_then_accept_merges_roles() public { + vm.prank(OWNER); + queue.transferOwnership(ADMIN); // pending only; no merge yet + assertEq(queue.owner(), OWNER); // still the old owner + + vm.prank(ADMIN); + queue.acceptOwnership(); + assertEq(queue.owner(), ADMIN, "roles merged: admin is now owner"); + assertEq(queue.admin(), ADMIN, "admin unchanged"); + } + + // ── record* are nonReentrant ── + + function test_recordERC20_is_nonReentrant() public { + ReentrantERC20 rnt = new ReentrantERC20(); + rnt.setQueue(queue); + // Register two sources so the ONLY remaining revert cause on the inner + // re-entrant call is the ReentrancyGuard: + // - address(this): the source that pulls rnt and calls recordERC20Exit + // (the outer call); + // - address(rnt): the source the re-entrant inner call passes as + // msg.sender (rnt._transfer re-enters, so msg.sender == rnt). Without + // this, the inner call would revert on the source-authorization check + // even with nonReentrant removed, masking the guard. + vm.startPrank(OWNER); + queue.addAllowedSource(address(this)); // this test contract is the source + queue.addAllowedSource(address(rnt)); // rnt is the inner re-entrant caller + vm.stopPrank(); + rnt.mint(address(this), 1000 ether); + rnt.approve(address(queue), type(uint256).max); + rnt.arm(); // next transferFrom re-enters the queue + + // outer record triggers the pull → rnt._transfer re-enters recordERC20Exit. + queue.recordERC20Exit( + address(rnt), uint128(5 ether), DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false + ); + // the outer call succeeded; the re-entrant inner call was attempted AND + // reverted (nonReentrant tripped). + assertTrue(rnt.reentered(), "reentrancy path not exercised"); + assertTrue(rnt.reentryReverted(), "nonReentrant did not block the re-entrant record"); + // Assert the revert was SPECIFICALLY the ReentrancyGuard, not some other + // require. This is the exact OZ 4.9.6 require string + // (lib/openzeppelin-contracts-upgradeable ReentrancyGuardUpgradeable.sol). + assertEq( + rnt.reentryRevertReason(), "ReentrancyGuard: reentrant call", "revert was not the ReentrancyGuard" + ); + } + + // ── blockedAccounts page cap / no overflow-revert ── + + function test_blockedAccounts_large_offset_and_limit_no_overflow() public { + vm.startPrank(ADMIN); + queue.freeze(ORIG); + queue.blacklist(OWNR); + vm.stopPrank(); + // A near-max offset+limit used to overflow-revert (unchecked add). Now the + // limit is capped and offset>=len returns empty — never reverts. `total` + // is still the true set size even when the page is empty. + (address[] memory got, uint256 total) = queue.blockedAccounts(type(uint256).max, type(uint256).max); + assertEq(got.length, 0); + assertEq(total, 2); + } + + function test_blockedAccounts_limit_capped_to_page_max() public { + // With only 2 blocked accounts and a huge limit, we get exactly 2 back and + // no overflow (limit clamped to MAX_GET_ACTIVE_PAGE internally). + vm.startPrank(ADMIN); + queue.freeze(ORIG); + queue.blacklist(OWNR); + vm.stopPrank(); + (address[] memory got, uint256 total) = queue.blockedAccounts(0, type(uint256).max); + assertEq(got.length, 2); + assertEq(total, 2); + } + + /// @dev `total` reports the FULL blocked-set size even when a + /// caller pages past the 500-entry cap — so a monitor never silently + /// undercounts. Block 600 accounts, then read page 0 with a huge limit: + /// page is clamped to 500 but `total` reads back the true 600. + function test_blockedAccounts_total_reports_full_size_past_page_cap() public { + uint256 n = 600; + address[] memory many = new address[](n); + for (uint256 i = 0; i < n; ++i) { + // start at 1 so no address is 0 (ZeroAddress guard in _setBlock) + many[i] = address(uint160(i + 1)); + } + vm.prank(ADMIN); + queue.freeze(many); + + (address[] memory page, uint256 total) = queue.blockedAccounts(0, type(uint256).max); + assertEq(page.length, 500); // clamped to MAX_GET_ACTIVE_PAGE + assertEq(total, n); // but total reads back the full 600 + + // The tail past the cap is reachable by advancing the offset; total is + // invariant across pages. + (address[] memory page2, uint256 total2) = queue.blockedAccounts(500, 500); + assertEq(page2.length, 100); + assertEq(total2, n); + } + + // ───────────────────────────────────────────────────────────────────── + // Coverage-gap fill (previous-cycle report): uncovered guard/revert arms + // and 0-hit fns. Fund-relevant Leg-2/Leg-3/solvency/ingress paths first. + // ───────────────────────────────────────────────────────────────────── + + // ── single-id blacklistFromRequest (was 0 hits; only batch tested) ── + + function test_blacklistFromRequest_single_blocks_orig_and_owner() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.blacklistFromRequest(uint256(1), false, keccak256("bad")); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + // receiver NOT blocked (freezeReceiver == false) + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.None)); + assertEq(queue.blockTrigger(ORIG), 1); + } + + function test_blacklistFromRequest_single_freezeReceiver_true() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.blacklistFromRequest(uint256(1), true, keccak256("bad")); + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_blacklistFromRequest_single_unknown_id_reverts() public { + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.blacklistFromRequest(uint256(99), false, bytes32(0)); + } + + // ── batch address-list variants (blacklist/unfreeze/unblacklist: 0 hits) ── + + function test_batch_blacklist_address_list() public { + address[] memory who = new address[](2); + who[0] = ORIG; + who[1] = OWNR; + vm.prank(ADMIN); + queue.blacklist(who); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_batch_unfreeze_address_list() public { + address[] memory who = new address[](2); + who[0] = ORIG; + who[1] = OWNR; + vm.startPrank(ADMIN); + queue.freeze(who); + queue.unfreeze(who); + vm.stopPrank(); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.None)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.None)); + } + + function test_batch_unblacklist_address_list() public { + address[] memory who = new address[](2); + who[0] = ORIG; + who[1] = OWNR; + vm.startPrank(ADMIN); + queue.blacklist(who); + queue.unblacklist(who); + vm.stopPrank(); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.None)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.None)); + } + + // ── EmptyIds on the four address-array block fns ── + + function test_freeze_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.freeze(who); + } + + function test_blacklist_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.blacklist(who); + } + + function test_unfreeze_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.unfreeze(who); + } + + function test_unblacklist_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.unblacklist(who); + } + + // ── MAX_GET_ACTIVE_PAGE is a public constant ── + + function test_MAX_GET_ACTIVE_PAGE_public_constant() public view { + assertEq(queue.MAX_GET_ACTIVE_PAGE(), 500); + // Reachable through the interface type too (self-describing on-chain). + assertEq(IExitDelayQueue(address(queue)).MAX_GET_ACTIVE_PAGE(), 500); + } + + // ── batch blacklistFromRequest authority arm (only freeze variant tested) ── + + function test_blacklistFromRequest_batch_only_admin_or_owner() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.blacklistFromRequest(ids, false, bytes32(0)); + } + + // ── _setBlock ZeroAddress guard (freeze/blacklist address(0)) ── + + function test_freeze_zero_address_reverts() public { + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.freeze(address(0)); + } + + // ── recordReceivedNativeExit short-push mismatch (native twin of ERC20) ── + + function test_recordReceivedNative_reverts_on_short_push() public { + vm.prank(OWNER); + queue.setNativePusher(address(pusher)); + // Push only 3 ether but claim 5 → measured delta (3) < amount (5). + pusher.push(payable(address(queue)), 3 ether); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ReceivedAmountMismatch.selector, address(0), 3 ether, 5 ether + ) + ); + source.recordReceivedNative(5 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + } + + // ── resolveToProtocol guard arms: EmptyIds / RouteInactive / UnknownRequest / AlreadyTerminal ── + + function test_resolveToProtocol_empty_ids_reverts() public { + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_inactive_route_reverts() public { + _queueErc20(10 ether); + // route id that was never registered → route.active == false + bytes32 routeId = keccak256("nonexistent-route"); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.RouteInactive.selector, routeId)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_reverts_after_route_removed() public { + // removeRecoveryRoute (0 hits) → the route deactivates → RouteInactive. + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.prank(OWNER); + queue.removeRecoveryRoute(routeId); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.RouteInactive.selector, routeId)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_unknown_request_reverts() public { + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](1); + ids[0] = 99; // never queued + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_already_terminal_reverts() public { + // Execute the request first (→ Executed, a terminal status), then try to + // Leg-2 it → AlreadyTerminal. + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + vm.prank(ADMIN); + queue.blacklist(ORIG); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.resolveToProtocol(ids, routeId); + } + + // ── resolveBySIP guard arms: EmptyIds / ZeroAddress / UnknownRequest / AlreadyTerminal ── + + function test_resolveBySIP_empty_ids_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + function test_resolveBySIP_zero_destination_reverts() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(0))); + queue.resolveBySIP(ids, address(0)); + } + + function test_resolveBySIP_unknown_request_reverts() public { + uint256[] memory ids = new uint256[](1); + ids[0] = 99; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + function test_resolveBySIP_already_terminal_reverts() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + // ── sweepSurplus backstop arms: SweepToZero + SolvencyViolated (native & ERC20) ── + + function test_sweepSurplus_to_zero_reverts() public { + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.SweepToZero.selector); + queue.sweepSurplus(address(token), address(0)); + } + + // The SolvencyViolated post-sweep checks (766 native / 772 ERC20) are the + // backstop: with correct accounting `surplus == bal - escrowed`, so the + // post-sweep balance lands at exactly `escrowed` and the require passes. + // Here we drive a legitimate full-surplus sweep so the ERC20 require at 772 + // is REACHED and passes (the pass-branch was the uncovered arm). The FAIL + // branch (a token whose transfer drains more than `surplus` from the queue) + // is exercised in ExitDelayQueueGrief.t.sol::test_sweepSurplus_solvency_violated + // with a bespoke over-draining token mock. + function test_sweepSurplus_erc20_reaches_solvency_check() public { + // Queue escrows 10; mint an extra 5 surplus directly to the queue. + _queueErc20(10 ether); + token.mint(address(queue), 5 ether); + uint256 before = token.balanceOf(address(0xBEEF)); + vm.prank(OWNER); + queue.sweepSurplus(address(token), address(0xBEEF)); + // exactly the 5 surplus moved; escrow backing (10) untouched → check passed + assertEq(token.balanceOf(address(0xBEEF)), before + 5 ether); + assertEq(token.balanceOf(address(queue)), 10 ether); + } + + // ── initialize with a non-empty initialAllowedSources (loop + guards) ── + + function test_initialize_with_initial_sources_and_dup() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address a = address(0xA1); + address b = address(0xB2); + address[] memory s = new address[](3); + s[0] = a; + s[1] = b; + s[2] = a; // duplicate → set.add returns false → skip branch (line 210) + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, s + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + ExitDelayQueue q = ExitDelayQueue(payable(address(proxy))); + assertTrue(q.isAllowedSource(a)); + assertTrue(q.isAllowedSource(b)); + assertEq(q.allowedSources().length, 2); // dup collapsed + } + + function test_initialize_reverts_zero_source() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](1); + s[0] = address(0); // ZeroAddress-source revert (line 209) + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, s + ); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + new ERC1967Proxy(address(impl), init); + } + + // ── _authorizeUpgrade (UUPS): happy upgrade + UpgradeImplZero + only-owner ── + + function test_upgrade_to_v2_by_owner() public { + ExitDelayQueueV2 v2 = new ExitDelayQueueV2(); + vm.prank(OWNER); + queue.upgradeTo(address(v2)); + assertEq(ExitDelayQueueV2(payable(address(queue))).version(), 2); + // state preserved across upgrade + assertEq(queue.owner(), OWNER); + assertEq(queue.admin(), ADMIN); + } + + function test_upgrade_zero_impl_reverts() public { + vm.prank(OWNER); + vm.expectRevert(ExitDelayQueue.UpgradeImplZero.selector); + queue.upgradeTo(address(0)); + } + + function test_upgrade_only_owner() public { + ExitDelayQueueV2 v2 = new ExitDelayQueueV2(); + vm.prank(OUTSIDER); + vm.expectRevert("Ownable: caller is not the owner"); + queue.upgradeTo(address(v2)); + } + + // ── getActive n > MAX_GET_ACTIVE_PAGE cap (the original mirrored) ── + + function test_getActive_n_capped_no_overflow() public { + _queueErc20(1 ether); + _queueErc20(1 ether); + // A near-max `n` used to risk `cursor + n` overflow; it is clamped to the + // page cap and returns only the 2 active ids without reverting. + (uint256[] memory ids, uint256 nextCursor) = queue.getActive(ORIG, 0, type(uint256).max); + assertEq(ids.length, 2); + assertEq(nextCursor, 0); + } + + // ── ZeroAddress guards on setRecoveryRoute / addAllowedSource / setAdmin ── + + function test_setRecoveryRoute_zero_destination_reverts() public { + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: address(0), + topUpPool: false + }); + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(0))); + queue.setRecoveryRoute(route); + } + + function test_addAllowedSource_zero_reverts() public { + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.addAllowedSource(address(0)); + } + + function test_setAdmin_zero_reverts() public { + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.setAdmin(address(0)); + } + + // ── view getters (read-only, low risk): exercise the 0-hit accessors ── + + function test_view_getters() public { + bytes32 routeId = _setupRoute(false); + IExitDelayQueue.RecoveryRoute memory got = queue.getRecoveryRoute(routeId); + assertEq(got.destination, address(0xDE57)); + assertTrue(got.active); + + address[] memory srcs = queue.allowedSources(); + assertEq(srcs.length, 1); // the SourceHarness registered in setUp + assertEq(srcs[0], address(source)); + + bytes32[] memory ids = queue.recoveryRouteIds(); + assertEq(ids.length, 1); + assertEq(ids[0], routeId); + + // topUpFeasible getter: false for an un-flagged surface, true after set + assertFalse(queue.topUpFeasible(SURFACE_ZERO)); + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE_ZERO, true); + assertTrue(queue.topUpFeasible(SURFACE_ZERO)); + } +} diff --git a/test/unit/ExitDelayQueueGrief.t.sol b/test/unit/ExitDelayQueueGrief.t.sol new file mode 100644 index 0000000..0f21ccc --- /dev/null +++ b/test/unit/ExitDelayQueueGrief.t.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +/// @dev regression suite . +/// The measured-delta ingress fns credit EXACTLY `amount` when the current +/// non-backing surplus `delta = balanceOf/balance − totalEscrowed` is +/// `>= amount`, and revert `ReceivedAmountMismatch` ONLY when `delta < amount`. +/// This file BOTH captures the original donation-grief PoC (now proving it is +/// fixed) AND adds positive/negative coverage for the new `>= amount` rule. + +contract Tok is ERC20 { + constructor() ERC20("T", "T") {} + + function mint(address a, uint256 v) external { + _mint(a, v); + } +} + +contract WR { + function withdraw(uint256) external {} + receive() external payable {} +} + +/// @dev A hostile token whose `transfer(to, v)` moves `v` to `to` but ALSO burns +/// an extra `drain` from the caller (the queue) in the same call. Under a +/// full-surplus sweep this pushes the queue's post-sweep balance BELOW the +/// escrowed backing, tripping the `SolvencyViolated` post-check (the +/// ERC20 arm at ExitDelayQueue.sol:772). Models a rebasing / hook token +/// that can silently reduce a holder's balance during a transfer. +contract OverDrainToken is ERC20 { + uint256 public drainOnNextTransfer; + + constructor() ERC20("Drain", "DRN") {} + + function mint(address a, uint256 v) external { + _mint(a, v); + } + + function armDrain(uint256 d) external { + drainOnNextTransfer = d; + } + + function transfer(address to, uint256 value) public override returns (bool) { + bool ok = super.transfer(to, value); + uint256 d = drainOnNextTransfer; + if (d != 0) { + drainOnNextTransfer = 0; + // Burn extra from msg.sender (the queue) → balance drops below escrow. + _burn(msg.sender, d); + } + return ok; + } +} + +/// @dev Stand-in for the 0.5.x source that pushes then records in the SAME tx. +contract Src { + ExitDelayQueue q; + + constructor(ExitDelayQueue q_) { + q = q_; + } + /// ERC20: transfer to the queue, then record (measured-delta path). + + function rec(address t, uint128 a, uint32 d, address o) external returns (uint256) { + ERC20(t).transfer(address(q), a); + return q.recordReceivedERC20Exit(t, a, d, keccak256("S"), address(0), o, o, o); + } + /// Record WITHOUT pushing enough — used for the under-delivery negative test. + + function recNoPush(address t, uint128 a, uint32 d, address o) external returns (uint256) { + return q.recordReceivedERC20Exit(t, a, d, keccak256("S"), address(0), o, o, o); + } + /// Full-arg ERC20 record (distinct originator/owner/receiver) — used by the + /// batch-block tests to create requests spanning many parties. + + function recFull(address t, uint128 a, uint32 d, address o, address w, address r) + external + returns (uint256) + { + ERC20(t).transfer(address(q), a); + return q.recordReceivedERC20Exit(t, a, d, keccak256("S"), address(0), o, w, r); + } + /// Native: forward the msg.value on to the queue, then record. + + function recNative(uint128 a, uint32 d, address o) external returns (uint256) { + (bool ok,) = payable(address(q)).call{value: a}(""); + require(ok, "push failed"); + return q.recordReceivedNativeExit(a, d, keccak256("S"), address(0), o, o, o); + } +} + +/// @dev Force-sends native RBTC to an arbitrary target via selfdestruct — the +/// `receive()` gate cannot stop this, which is the whole point of `>= amount`. +contract ForceSender { + constructor(address payable target) payable { + selfdestruct(target); + } +} + +contract Grief is Test { + ExitDelayQueue q; + Tok tok; + WR wr; + Src src; + + address constant PARTY = address(0x111); + + function setUp() public { + wr = new WR(); + tok = new Tok(); + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, address(this), address(0xAD), address(wr), uint32(1 hours), s + ); + q = ExitDelayQueue(payable(address(new ERC1967Proxy(address(impl), init)))); + src = new Src(q); + q.addAllowedSource(address(src)); + // The queue must be a registered native pusher target so the Src forward + // (which goes through the queue's receive()) is accepted for native tests. + // ActivePool == the Src here for the native push. + // (addAllowedSource already done; the receive() gate keys on nativePusher.) + tok.mint(address(src), 1000 ether); + } + + // ─── ERC20 measured-delta ─────────────────────────────────────────── + + /// @notice REGRESSION (the original grief PoC, now PROVING the fix): a 1-wei + /// token force-send before a legit same-tx push must NOT brick the + /// record. Under the old `delta == amount` rule this reverted; under + /// the `delta >= amount` rule it records and credits exactly + /// `amount`, leaving the 1-wei as sweepable surplus. + function test_dust_donation_does_not_brick_measured_delta() public { + // Griefer force-sends 1 wei of the token to the queue. + tok.mint(address(this), 1); + tok.transfer(address(q), 1); + + // Legit exit of 50 ether now SUCCEEDS (delta = 50e18 + 1 >= 50e18). + uint256 id = src.rec(address(tok), 50 ether, 2 hours, PARTY); + + // Credited EXACTLY amount — the 1-wei donation is NOT mis-credited. + assertEq(q.totalEscrowed(address(tok)), 50 ether, "credit exactly amount"); + IExitDelayQueue.ExitRequest memory r = q.getRequest(id); + assertEq(r.amount, 50 ether, "request amount"); + assertEq(uint8(r.status), uint8(IExitDelayQueue.ExitStatus.Queued), "queued"); + + // The 1-wei excess remains as non-backing surplus, sweepable by Owner. + assertEq(tok.balanceOf(address(q)), 50 ether + 1, "balance = escrow + donation"); + q.sweepSurplus(address(tok), address(0xBEEF)); + assertEq(tok.balanceOf(address(0xBEEF)), 1, "surplus swept"); + assertEq(tok.balanceOf(address(q)), 50 ether, "backing intact post-sweep"); + assertEq(q.totalEscrowed(address(tok)), 50 ether, "escrow unchanged by sweep"); + } + + /// @notice POSITIVE: a 1-wei donation before a legit push still records and + /// credits exactly amount (the fix-list's explicit positive case). + function test_positive_donation_then_push_credits_exactly_amount() public { + tok.mint(address(this), 1); + tok.transfer(address(q), 1); // donation + + uint256 id = src.rec(address(tok), 10 ether, 3 hours, PARTY); + assertEq(q.totalEscrowed(address(tok)), 10 ether); + assertEq(q.getRequest(id).amount, 10 ether); + } + + /// @notice Repeated records after a donation each consume exactly amount and + /// never re-brick (a donation only raises surplus, so >= still holds). + function test_multiple_records_after_donation() public { + tok.mint(address(this), 5); + tok.transfer(address(q), 5); // 5-wei donation sits as surplus + + src.rec(address(tok), 20 ether, 2 hours, PARTY); + src.rec(address(tok), 30 ether, 2 hours, address(0x222)); + assertEq(q.totalEscrowed(address(tok)), 50 ether, "each record consumed exactly amount"); + assertEq(tok.balanceOf(address(q)), 50 ether + 5, "donation still surplus"); + } + + /// @notice NEGATIVE: an under-delivered push (delta < amount) STILL reverts + /// ReceivedAmountMismatch — the fix relaxes only the upper bound. + function test_under_delivery_still_reverts() public { + // No push at all: delta = 0 < amount. + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ReceivedAmountMismatch.selector, address(tok), uint256(0), uint256(1 ether) + ) + ); + src.recNoPush(address(tok), 1 ether, 2 hours, PARTY); + } + + /// @notice NEGATIVE: partial delivery (a donation smaller than amount) reverts. + function test_partial_delivery_reverts() public { + tok.mint(address(this), 1 ether); + tok.transfer(address(q), 1 ether); // only 1 ether present + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ReceivedAmountMismatch.selector, + address(tok), + uint256(1 ether), + uint256(5 ether) + ) + ); + src.recNoPush(address(tok), 5 ether, 2 hours, PARTY); + } + + // ─── Native measured-receipt ──────────────────────────────────────── + + /// @notice REGRESSION (native): a `selfdestruct` force-send of native RBTC + /// (which the `receive()` gate CANNOT block) before a legit push must + /// not brick recordReceivedNativeExit. Credits exactly amount; the + /// force-sent wei stays as sweepable surplus. + function test_native_forcesend_does_not_brick_measured_delta() public { + // Register the Src as the native pusher so the queue's receive() accepts + // the Src's forward. (The Src plays ActivePool here.) + q.setNativePusher(address(src)); + + // Force-send 1 wei via selfdestruct — bypasses receive() entirely. + vm.deal(address(this), 1); + new ForceSender{value: 1}(payable(address(q))); + assertEq(address(q).balance, 1, "force-sent wei present"); + + // Legit native exit of 5 ether now records (delta = 5e18 + 1 >= 5e18). + vm.deal(address(src), 5 ether); + uint256 id = src.recNative(5 ether, 2 hours, PARTY); + + assertEq(q.totalEscrowed(address(0)), 5 ether, "credit exactly amount (native)"); + assertEq(q.getRequest(id).amount, 5 ether); + assertEq(address(q).balance, 5 ether + 1, "balance = escrow + force-send"); + + // Sweep the 1-wei surplus; backing stays intact. + q.sweepSurplus(address(0), address(0xBEEF)); + assertEq(address(q).balance, 5 ether, "backing intact post-sweep"); + } + + // ─── batch by-request-id block — emergency speed lever ───────── + + /// @notice The emergency lever: several known-malicious requests, each opened + /// by a distinct delegate for a distinct owner, are all blocked + /// (owner + delegate) in ONE `blacklistFromRequest(uint256[])` call — + /// the single-Admin-multisig-tx incident response describes. + function test_batch_blacklistFromRequest_blocks_many_parties_one_tx() public { + uint256 id1 = _queueDistinct(1); + uint256 id2 = _queueDistinct(2); + uint256 id3 = _queueDistinct(3); + + uint256[] memory ids = new uint256[](3); + ids[0] = id1; + ids[1] = id2; + ids[2] = id3; + // Admin (the fast guardian) blocks all six parties in one tx. + vm.prank(address(0xAD)); + q.blacklistFromRequest(ids, false, keccak256("incident-42")); + + for (uint160 k = 1; k <= 3; ++k) { + assertEq( + uint8(q.blockStateOf(_orig(k))), uint8(IExitDelayQueue.BlockState.Blacklisted), "orig blocked" + ); + assertEq( + uint8(q.blockStateOf(_ownr(k))), + uint8(IExitDelayQueue.BlockState.Blacklisted), + "owner blocked" + ); + } + } + + /// @notice ATOMICITY grief guard: an operator who accidentally (or a griefer who + /// maliciously) slips ONE unknown id into the batch gets the WHOLE batch + /// reverted — no half-applied block state that would need manual cleanup. + function test_batch_blockFromRequest_is_atomic_on_bad_id() public { + uint256 id1 = _queueDistinct(1); + uint256[] memory ids = new uint256[](2); + ids[0] = id1; + ids[1] = type(uint256).max; // never recorded + vm.prank(address(0xAD)); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, type(uint256).max)); + q.freezeFromRequest(ids, false, bytes32(0)); + // id1's parties are untouched — the whole batch rolled back. + assertEq(uint8(q.blockStateOf(_orig(1))), uint8(IExitDelayQueue.BlockState.None)); + assertEq(uint8(q.blockStateOf(_ownr(1))), uint8(IExitDelayQueue.BlockState.None)); + } + + // ─── solvency backstop (SolvencyViolated) ─────────────────────── + + /// @notice The post-sweep solvency require (ERC20 arm, src:772) MUST trip + /// if a hostile/rebasing token drains more than the computed surplus + /// from the queue during `safeTransfer`, dropping backing below the + /// escrowed total. Proves the backstop reverts the whole sweep rather + /// than silently leaking escrowed backing. + function test_sweepSurplus_solvency_violated_erc20() public { + OverDrainToken drn = new OverDrainToken(); + drn.mint(address(src), 1000 ether); + + // Escrow 50 of the drain token via the measured-delta path. + uint256 id = src.rec(address(drn), 50 ether, 2 hours, PARTY); + assertEq(q.totalEscrowed(address(drn)), 50 ether); + assertEq(uint8(q.getRequest(id).status), uint8(IExitDelayQueue.ExitStatus.Queued)); + + // Add 5 surplus so the sweep computes surplus = 5 and calls transfer. + drn.mint(address(q), 5 ether); + // Arm the token to burn an extra 10 from the queue during the transfer — + // post-sweep balance = 50 (escrow) - 10 = 40 < 50 escrowed → revert. + drn.armDrain(10 ether); + + vm.expectRevert(IExitDelayQueue.SolvencyViolated.selector); + q.sweepSurplus(address(drn), address(0xBEEF)); + } + + // ── helpers for the batch-block tests ── + + function _orig(uint160 k) internal pure returns (address) { + return address(0xB0000 + k * 2); + } + + function _ownr(uint160 k) internal pure returns (address) { + return address(0xB0000 + k * 2 + 1); + } + + /// Queue one ERC20 request with distinct originator/owner/receiver derived + /// from `k` via the measured-delta source's full-arg record. + function _queueDistinct(uint160 k) internal returns (uint256 id) { + tok.mint(address(src), 100 ether); + id = src.recFull(address(tok), 10 ether, 2 hours, _orig(k), _ownr(k), address(0xDCE0 + k)); + } +} diff --git a/test/unit/ExitDelayQueueUnwrapStipend.t.sol b/test/unit/ExitDelayQueueUnwrapStipend.t.sol new file mode 100644 index 0000000..70123af --- /dev/null +++ b/test/unit/ExitDelayQueueUnwrapStipend.t.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +/// @title ExitDelayQueueUnwrapStipend +/// @notice (HIGH) regression. Reproduces the empirically- +/// proven OutOfGas brick and proves the unconditional `receive()` fix. +/// +/// The real Rootstock WRBTC `withdraw()` returns native RBTC to the caller +/// via a **2300-gas `transfer` stipend** (WETH9 semantics). When the queue +/// unwraps an `unwrapOnDelivery` request at `executeExit`, WRBTC sends the +/// native back into the queue's `receive()`. A `receive()` that reads any +/// storage slot to gate the sender (SLOAD ≥ 2100 cold under EIP-2929 / +/// Paris) exceeds the 2300 stipend and reverts OutOfGas — permanently +/// bricking every native `burnToBTC` (unwrapOnDelivery) payout after unlock. +/// +/// Run under `forge test --isolate` so EIP-2929 cold/warm access gas is +/// charged realistically. The production-path test +/// (`test_unwrap_payout_succeeds_under_transfer_stipend`) passes in BOTH +/// modes and is the mode-independent gate; the apples-to-apples control +/// (`..._control_ISOLATE_ONLY`) reproduces the pre-fix brick only when +/// cold-access gas is charged (isolate) and self-skips otherwise. +contract ExitDelayQueueUnwrapStipendTest is Test { + ExitDelayQueue queue; + WETH9StyleWRBTC wrbtc; + Source source; + + address constant OWNER = address(0x0E1); + address constant ADMIN = address(0xAd11); + address constant ORIG = address(0x0111); + address constant OWNR = address(0x0222); + address payable constant RCVR = payable(address(0x0333)); + + bytes32 constant SURFACE = keccak256("PERIMETER:LENDING_LENDER_WITHDRAW"); + address constant SUBPRODUCT = address(0xB00C); + uint32 constant MIN_DELAY = 1 hours; + uint32 constant DELAY = 2 hours; + + function setUp() public { + wrbtc = new WETH9StyleWRBTC(); + + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + queue = ExitDelayQueue(payable(address(proxy))); + + source = new Source(queue); + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + + // Fund the source with WRBTC (backed 1:1 by native inside the WRBTC). + wrbtc.depositTo{value: 1_000 ether}(address(source)); + vm.deal(address(this), 1_000 ether); + } + + /// @notice The production fix: with the UNCONDITIONAL receive(), unwrapping a + /// WRBTC-escrowed request via the 2300-stipend WRBTC.withdraw succeeds + /// and the user receives native RBTC. Under `--isolate` the queue's + /// `receive()` slots are cold, so this would OutOfGas-revert if + /// `receive()` did ANY storage read (the pre-fix gate). + function test_unwrap_payout_succeeds_under_transfer_stipend() public { + uint128 amount = 5 ether; + uint256 id = source.record(address(wrbtc), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + + vm.warp(block.timestamp + DELAY); + uint256 before = RCVR.balance; + + vm.prank(OWNR); + queue.executeExit(id); + + assertEq(RCVR.balance, before + amount, "receiver got native RBTC"); + assertEq(wrbtc.balanceOf(RCVR), 0, "receiver holds no WRBTC"); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(wrbtc)), 0); + } + + /// @notice APPLES-TO-APPLES CONTROL (isolate-only): the identical unwrap-payout + /// flow, but the proxy runs an impl whose `receive()` reads ONE storage + /// slot to gate the sender (the exact pre-fix gate). Under `--isolate` + /// the queue's slots are COLD at the moment WRBTC.withdraw forwards + /// native, so the cold SLOAD (2100 gas, EIP-2929/Paris) on top of the + /// proxy delegatecall exceeds the 2300 `transfer` stipend → OutOfGas, + /// bricking the payout. The SOLE difference from the passing production + /// case above is whether `receive()` touches storage. + /// + /// The brick ONLY reproduces with cold access accounting, i.e. under + /// `forge test --isolate`. Without `--isolate` the queue's slots are + /// warmed earlier in the same tx (warm SLOAD = 100 gas, fits the + /// stipend), so the brick does not manifest and this control self-skips + /// — the production regression above is the real, mode-independent gate. + function test_gated_receive_bricks_unwrap_payout_control_ISOLATE_ONLY() public { + if (!_coldSloadExceedsStipend()) { + emit log("skip: cold-access gas not charged (run with --isolate to exercise this control)"); + return; + } + + // Stand up a second proxy running the sender-GATED impl. + GatedReceiveQueue gatedImpl = new GatedReceiveQueue(); + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ); + ERC1967Proxy gatedProxy = new ERC1967Proxy(address(gatedImpl), init); + ExitDelayQueue gq = ExitDelayQueue(payable(address(gatedProxy))); + + Source gsource = new Source(gq); + vm.prank(OWNER); + gq.addAllowedSource(address(gsource)); + wrbtc.depositTo{value: 100 ether}(address(gsource)); + + uint128 amount = 5 ether; + uint256 id = + gsource.record(address(wrbtc), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + vm.warp(block.timestamp + DELAY); + + // The gated receive() OutOfGas-reverts the WRBTC unwrap transfer; the whole + // executeExit rolls back (fail-closed), the request stays Queued — the + // permanent brick the fix removes. + vm.prank(OWNR); + vm.expectRevert(); + gq.executeExit(id); + assertEq(uint256(gq.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + /// @dev Detects whether the run charges EIP-2929 cold-access gas (i.e. we are + /// under `--isolate`). Probes a fresh contract's cold storage slot: a cold + /// SLOAD costs ~2100 gas, a warm one ~100. Returns true when the measured + /// cost is high enough that a cold SLOAD would blow the 2300 stipend. + function _coldSloadExceedsStipend() internal returns (bool) { + ColdSlotProbe p = new ColdSlotProbe(); + uint256 used = p.measureColdSload(); + // Cold SLOAD ≈ 2100; warm ≈ 100. Threshold well between the two. + return used > 1500; + } +} + +/// @dev Measures the gas cost of a single (cold) SLOAD in the current run's +/// access-accounting mode. Under `--isolate` a first-touch SLOAD is cold +/// (~2100 gas); otherwise the framework may have warmed it (~100 gas). +contract ColdSlotProbe { + uint256 private slot = 7; + + function measureColdSload() external view returns (uint256 used) { + uint256 g0 = gasleft(); + uint256 v = slot; // the SLOAD under measurement + uint256 g1 = gasleft(); + // touch v so the optimizer cannot elide the load + used = (g0 - g1) + (v & 0); + } +} + +// ─── Mocks ──────────────────────────────────────────────────────────────── + +/// @dev WETH9-style WRBTC: withdraw() forwards native via `transfer` (2300-gas +/// stipend), exactly like the real Rootstock WRBTC. This is the mock that +/// surfaces the brick — a `.call{value}` mock (unlimited gas) would not. +contract WETH9StyleWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } + + function depositTo(address to) external payable { + _mint(to, msg.value); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + // 2300-gas stipend forward — the load-bearing difference from a + // `.call{value: amount}("")` mock. + payable(msg.sender).transfer(amount); + } + + receive() external payable {} +} + +/// @dev Registered ingress source (mirrors an iToken proxy). Pulls WRBTC then +/// records with unwrapOnDelivery. +contract Source { + ExitDelayQueue public queue; + + constructor(ExitDelayQueue q) { + queue = q; + } + + function record( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrap + ) external returns (uint256) { + ERC20(token).approve(address(queue), amount); + return queue.recordERC20Exit( + token, amount, d, surfaceId, subProduct, effOrig, effOwner, receiver, unwrap + ); + } +} + +/// @dev Control impl: identical to production ExitDelayQueue EXCEPT `receive()` +/// reads a storage slot to gate the sender (the exact PRE-FIX gate). Deployed +/// behind the same proxy pattern so the ONLY behavioral difference from the +/// production impl is the storage read in `receive()`. Under the WRBTC +/// 2300-gas `transfer` stipend that read OutOfGas-bricks the unwrap payout. +contract GatedReceiveQueue is ExitDelayQueue { + receive() external payable override { + // Cold SLOAD of `wrbtc` (2100 gas under EIP-2929/Paris) + the comparison + // exceeds the 2300 stipend the WRBTC withdraw forwards → OutOfGas. This is + // the empirically-proven brick the unconditional receive() removes. + if (msg.sender != nativePusher && msg.sender != wrbtc) revert UnregisteredSource(msg.sender); + } +} diff --git a/test/unit/ExitFeeController.t.sol b/test/unit/ExitFeeController.t.sol index ffe718c..fe6d56e 100644 --- a/test/unit/ExitFeeController.t.sol +++ b/test/unit/ExitFeeController.t.sol @@ -20,15 +20,14 @@ contract ExitFeeControllerTest is Test { // Re-declared here so vm.expectEmit can match by topic signature. event SubProductPolicyRemoved(bytes32 indexed surfaceId, address indexed subProduct); event ActorPolicyRemoved(bytes32 indexed surfaceId, address indexed actor); - event AdminSet(address indexed admin); ExitFeeController controller; // NOTE: `ADMIN` predates the contract's admin role -- it is the proxy // OWNER throughout this file. The operational guardian stored in - // `ExitFeeController.admin` is `GUARDIAN` below. + // `ExitFeeController.admin` is `GUARDIAN`, declared in the + // delay-extension section below. address constant ADMIN = address(0xA1); - address constant GUARDIAN = address(0xAD); address constant VAULT = address(0xBA); address constant ACTOR = address(0xAC); address constant IXUSD = address(0x1750D); // dummy iToken proxy "iXUSD" @@ -36,8 +35,8 @@ contract ExitFeeControllerTest is Test { address constant OTHER = address(0xBEEF); address constant CAFE = address(0xCAFE); - bytes32 constant SURFACE = keccak256("COLFEE:SURFACE_LENDING_LENDER_WITHDRAW"); - bytes32 constant SURFACE_OTHER = keccak256("COLFEE:SURFACE_ZERO_WITHDRAW_COLL"); + bytes32 constant SURFACE = keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + bytes32 constant SURFACE_OTHER = keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); function setUp() public { ExitFeeController impl = new ExitFeeController(); @@ -127,18 +126,15 @@ contract ExitFeeControllerTest is Test { assertEq(q.netAmount, 1_000_000); assertEq(q.reason, uint8(IExitFeeController.SkipReason.DISABLED)); - // Positive control: flipping ONLY the surface gate on must revive the - // very overrides that were suppressed above. Without this the test - // would pass just as happily against a controller whose fee path was - // dead altogether -- it would prove nothing about the gate. + // Positive control: flip only the missing gate on and the suppressed + // override must revive. Without this, the test would pass just as + // happily against a controller whose fee path was dead altogether. vm.prank(ADMIN); controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 20})); - - IExitFeeController.ExitFeeQuote memory qOn = _quote(IWRBTC, 1_000_000); - assertTrue(qOn.active); - assertEq(qOn.rateBps, 5, "actor override applies once the gate is on"); - assertEq(qOn.feeAmount, 500); - assertEq(qOn.reason, uint8(IExitFeeController.SkipReason.NONE)); + q = _quote(IWRBTC, 1_000_000); + assertTrue(q.active); + assertEq(q.rateBps, 5); + assertEq(q.reason, uint8(IExitFeeController.SkipReason.NONE)); } // ─── Tier resolution: surface → subProduct → actor ────────────────── @@ -307,23 +303,31 @@ contract ExitFeeControllerTest is Test { uint256 huge = type(uint256).max / 9_999; // would overflow gross * MAX_BPS IExitFeeController.ExitFeeQuote memory q = _quote(IXUSD, huge); assertEq(q.reason, uint8(IExitFeeController.SkipReason.INVALID_QUOTE)); - assertFalse(q.active); - assertEq(q.feeAmount, 0); assertEq(q.netAmount, huge); // synthesized echo of gross + } + + /// @dev Pins the guard's exact boundary in both directions: the largest + /// gross that cannot overflow quotes honestly, one wei more trips + /// INVALID_QUOTE. A one-sided test would pass with the comparison + /// direction flipped. + function test_overflow_guard_exact_boundary() public { + vm.startPrank(ADMIN); + controller.setFeeReceiver(VAULT); + controller.setExitFeeEnabled(true); + controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 50})); + vm.stopPrank(); - // Pin the guard's comparison direction at the boundary. `max / MAX_BPS` - // is the largest gross whose `gross * MAX_BPS` still fits, so it MUST - // produce an honest quote; one wei more MUST trip the guard. Without - // both sides an off-by-one (`>` vs `>=`) is invisible. uint256 edge = type(uint256).max / 10_000; + IExitFeeController.ExitFeeQuote memory qEdge = _quote(IXUSD, edge); - assertTrue(qEdge.active, "gross == max/MAX_BPS must not trip the guard"); - assertEq(qEdge.reason, uint8(IExitFeeController.SkipReason.NONE)); + assertTrue(qEdge.active, "edge value must quote honestly"); assertEq(qEdge.feeAmount, (edge * 50) / 10_000); + assertEq(qEdge.netAmount, edge - qEdge.feeAmount); IExitFeeController.ExitFeeQuote memory qOver = _quote(IXUSD, edge + 1); - assertFalse(qOver.active, "one wei past the boundary must trip the guard"); + assertFalse(qOver.active); assertEq(qOver.reason, uint8(IExitFeeController.SkipReason.INVALID_QUOTE)); + assertEq(qOver.netAmount, edge + 1); } // ─── Admin / setter validation ─────────────────────────────────────── @@ -340,42 +344,17 @@ contract ExitFeeControllerTest is Test { controller.setExitFeeEnabled(true); } - // ─── Admin role (operational guardian) ─────────────────────────────── + // ─── Admin role: fee levers ──────────────────────────── + // + // setAdmin validation (zero / owner-equals / only-owner / emit) is + // covered in the delay-extension section below (test_setAdmin_*); this + // section pins the core-merge widening: the SAME guardian + // that flips the perimeter kill switch also drives the fee levers. function test_admin_unset_at_init() public view { assertEq(controller.admin(), address(0)); } - function test_setAdmin_sets_and_emits() public { - vm.expectEmit(true, false, false, false, address(controller)); - emit AdminSet(GUARDIAN); - vm.prank(ADMIN); - controller.setAdmin(GUARDIAN); - - assertEq(controller.admin(), GUARDIAN); - } - - function test_setAdmin_non_owner_reverts() public { - // setAdmin stays owner-only -- the guardian cannot appoint itself - // or a successor. - vm.prank(OTHER); - vm.expectRevert("Ownable: caller is not the owner"); - controller.setAdmin(GUARDIAN); - } - - function test_setAdmin_zero_reverts() public { - vm.prank(ADMIN); - vm.expectRevert(ExitFeeController.AdminZero.selector); - controller.setAdmin(address(0)); - } - - function test_setAdmin_may_equal_owner() public { - // admin == owner is a supported shape: one address may hold both roles. - vm.prank(ADMIN); - controller.setAdmin(ADMIN); - assertEq(controller.admin(), ADMIN); - } - function test_admin_can_setExitFeeEnabled_both_directions() public { vm.prank(ADMIN); controller.setAdmin(GUARDIAN); @@ -614,23 +593,21 @@ contract ExitFeeControllerTest is Test { ps[0] = IExitFeeController.RatePolicy({active: true, rateBps: 5}); ps[1] = IExitFeeController.RatePolicy({active: true, rateBps: 10}); controller.setActorPolicies(SURFACE, actors, ps); - // Pin the pre-state: without this the length-0 assertion below would - // also hold if the batch SET had never populated the index. - assertEq(controller.actorKeys(SURFACE).length, 2, "batch set populates the index"); + + // Pre-state pin: without this, the length-0 assertion below would + // also hold if the batch SET had never populated the index at all. + assertEq(controller.actorKeys(SURFACE).length, 2); // Removing the same list back drops both keys. controller.removeActorPolicies(SURFACE, actors); vm.stopPrank(); assertEq(controller.actorKeys(SURFACE).length, 0); - - // A hard remove clears the stored RatePolicy too, not just the index - // entry -- otherwise a re-added key would resurrect the old rate. - for (uint256 i = 0; i < actors.length; ++i) { - IExitFeeController.RatePolicy memory p = controller.actorPolicy(SURFACE, actors[i]); - assertFalse(p.active, "stale actor policy left behind"); - assertEq(p.rateBps, 0, "stale actor rate left behind"); - } + // Hard-remove clears the stored policy itself, not only the index -- + // a resurrected key must not revive an old rate. + IExitFeeController.RatePolicy memory cleared = controller.actorPolicy(SURFACE, ACTOR); + assertFalse(cleared.active); + assertEq(cleared.rateBps, 0); } function test_remove_address_zero_reverts() public { @@ -684,7 +661,11 @@ contract ExitFeeControllerTest is Test { controller.setFeeReceiver(VAULT); controller.setExitFeeEnabled(true); controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 25})); - controller.setAdmin(GUARDIAN); // own slot 257 -- the newest field, most at risk + // The post-256 slots are the ones an upgrade regression would hit + // first: admin alone in its slot, then the packed perimeter pair. + controller.setAdmin(GUARDIAN); + controller.setSecurityPerimeterEnabled(true); + controller.setGlobalDelaySeconds(7 days); vm.stopPrank(); ExitFeeControllerV2Mock v2impl = new ExitFeeControllerV2Mock(); @@ -699,10 +680,12 @@ contract ExitFeeControllerTest is Test { // 2) Pre-upgrade state preserved. assertTrue(controller.exitFeeEnabled()); assertEq(controller.feeReceiver(), VAULT); - assertEq(controller.admin(), GUARDIAN); IExitFeeController.RatePolicy memory sp = controller.surfacePolicy(SURFACE); assertTrue(sp.active); assertEq(sp.rateBps, 25); + assertEq(controller.admin(), GUARDIAN); + assertTrue(controller.securityPerimeterEnabled()); + assertEq(controller.globalDelaySeconds(), 7 days); // 3) Quote still works post-upgrade with the preserved policy. IExitFeeController.ExitFeeQuote memory q = _quote(IXUSD, 1_000_000); @@ -822,13 +805,6 @@ contract ExitFeeControllerTest is Test { // The call itself not reverting IS the load-bearing assertion. IExitFeeController.ExitFeeQuote memory q = controller.quoteExitFee(SURFACE, IXUSD, ACTOR, grossAmount); - // Everything is configured on, so the overflow guard is the ONLY thing - // that can turn the quote off. Asserting the biconditional (rather than - // just "overflow implies INVALID_QUOTE") is what pins the guard's - // boundary: a guard that rejected one value too many would still - // satisfy the one-way version below. - assertEq(q.active, grossAmount <= type(uint256).max / 10_000, "active iff gross cannot overflow"); - if (q.active) { assertEq(q.feeAmount + q.netAmount, grossAmount, "conservation in active branch"); } else { @@ -878,6 +854,929 @@ contract ExitFeeControllerTest is Test { assertEq(uint256(q.rateBps), actorRateBps, "actor tier wins over sub-product and surface"); } + // ════════════════════════════════════════════════════════════════════ + // DELAY EXTENSION + // + // NOTE: in this suite `ADMIN` is the controller's OWNER (passed to + // `initialize`). The delay `Admin` GUARDIAN is a distinct address, set via + // `setAdmin` and stored below as `GUARDIAN`. + // ════════════════════════════════════════════════════════════════════ + + address constant GUARDIAN = address(0x6DA12D); // delay Admin guardian (≠ owner) + address constant WRAPPER = address(0x323A99); // a registered passthrough + address constant USER_EOA = address(0xE0A); // the human behind a wrapper burn + + // Re-declared so vm.expectEmit can match delay-extension events by topic. + event SecurityPerimeterEnabledSet(bool enabled); + event GlobalDelaySet(uint32 seconds_); + event AdminSet(address indexed admin); + event SurfaceBypassSet(bytes32 indexed surfaceId, bool active, bool bypass); + event SurfaceBypassRemoved(bytes32 indexed surfaceId); + event ActorBypassSet(bytes32 indexed surfaceId, address indexed actor, bool active, bool bypass); + event PassthroughActorSet(bytes32 indexed surfaceId, address indexed actor, bool isPassthrough); + + uint32 constant DELAY = 6 hours; + + // Convenience: enable the perimeter with a global delay, guardian set. + function _enableDelay(uint32 d) internal { + vm.startPrank(ADMIN); + controller.setAdmin(GUARDIAN); + controller.setGlobalDelaySeconds(d); + controller.setSecurityPerimeterEnabled(true); + vm.stopPrank(); + } + + function _bp(bool active, bool bypass) + internal + pure + returns (IExitFeeController.DelayBypassPolicy memory) + { + return IExitFeeController.DelayBypassPolicy({active: active, bypass: bypass}); + } + + // ─── Kill switch (both directions; independent of exitFeeEnabled) ──── + + function test_delay_off_by_default_short_circuits_to_raw() public view { + // Perimeter disabled by default: d == 0, raw identities echoed, registry + // NOT consulted (Finding 3 liveness escape). + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0); + assertEq(effOrig, ACTOR); // RAW, not normalized + assertEq(effOwner, OTHER); // RAW + } + + function test_kill_switch_enable_imposes_global_delay() public { + _enableDelay(DELAY); + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY, "enabled perimeter imposes the global delay"); + } + + function test_kill_switch_disable_direction_by_guardian() public { + _enableDelay(DELAY); + // Guardian (Admin) can flip OFF (— both directions). + vm.prank(GUARDIAN); + controller.setSecurityPerimeterEnabled(false); + assertFalse(controller.securityPerimeterEnabled()); + (uint32 d, address effOrig,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0, "disabled -> direct"); + assertEq(effOrig, ACTOR, "disabled -> raw identities"); + } + + function test_kill_switch_enable_direction_by_guardian() public { + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + vm.prank(ADMIN); + controller.setGlobalDelaySeconds(DELAY); + // Guardian can also flip ON (both directions). + vm.prank(GUARDIAN); + controller.setSecurityPerimeterEnabled(true); + assertTrue(controller.securityPerimeterEnabled()); + } + + function test_kill_switch_owner_can_flip_both_directions() public { + vm.startPrank(ADMIN); // owner + controller.setGlobalDelaySeconds(DELAY); + controller.setSecurityPerimeterEnabled(true); + assertTrue(controller.securityPerimeterEnabled()); + controller.setSecurityPerimeterEnabled(false); + assertFalse(controller.securityPerimeterEnabled()); + vm.stopPrank(); + } + + function test_kill_switch_rejects_stranger() public { + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + vm.prank(OTHER); + vm.expectRevert(abi.encodeWithSelector(ExitFeeController.NotAdminOrOwner.selector, OTHER)); + controller.setSecurityPerimeterEnabled(true); + } + + function test_kill_switch_emits() public { + vm.prank(ADMIN); + vm.expectEmit(false, false, false, true); + emit SecurityPerimeterEnabledSet(true); + controller.setSecurityPerimeterEnabled(true); + } + + function test_perimeter_independent_of_exitFeeEnabled() public { + // The delay perimeter is INDEPENDENT of the fee kill switch and + // of any fee surfacePolicy. Fees OFF, perimeter ON ⇒ still delayed. + _enableDelay(DELAY); + assertFalse(controller.exitFeeEnabled(), "fees remain off"); + // No fee surfacePolicy configured at all. + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY, "delay fires with fees disabled and no fee policy"); + } + + function test_fee_surface_inactive_does_not_disable_delay() public { + // A fee-inactive surface can still be delay-active (sole gate is the + // perimeter switch, not surfacePolicy.active). + _enableDelay(DELAY); + vm.prank(ADMIN); + controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: false, rateBps: 0})); + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY, "fee surface inactive -> delay still fires"); + } + + // ─── globalDelaySeconds / floor (controller side) ─────────────────── + + function test_globalDelay_returned_faithfully() public { + // The controller returns EXACTLY globalDelaySeconds; the >= floor is a + // queue-side per-request check. Verify faithful passthrough across + // a couple of values incl. the max uint32. + _enableDelay(1); + (uint32 d1,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d1, 1); + + vm.prank(ADMIN); + controller.setGlobalDelaySeconds(type(uint32).max); + (uint32 d2,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d2, type(uint32).max, "~136y head-room passes through"); + } + + function test_globalDelay_zero_is_global_bypass() public { + // globalDelaySeconds == 0 with perimeter ON ⇒ d == 0 for every + // non-forced exit (equivalent to a global bypass; the queue skips it). + _enableDelay(0); + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0); + } + + function test_globalDelay_only_owner() public { + vm.prank(GUARDIAN); // guardian is NOT owner; global delay is owner-only + vm.expectRevert(); + controller.setGlobalDelaySeconds(DELAY); + } + + // ─── Bypass precedence (3-tier: actor > subProduct > surface) ─────── + + function test_bypass_default_no_tier_is_delayed() public { + _enableDelay(DELAY); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), DELAY); + } + + function test_surface_bypass_exempts() public { + _enableDelay(DELAY); + vm.prank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), 0, "surface bypass -> instant"); + // A different surface is untouched. + assertEq(controller.quoteExitDelay(SURFACE_OTHER, address(0), ACTOR), DELAY); + } + + function test_subProduct_bypass_overrides_surface() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, false)); // surface: force delay + controller.setSubProductBypass(SURFACE, IWRBTC, _bp(true, true)); // sub: bypass + vm.stopPrank(); + assertEq(controller.quoteExitDelay(SURFACE, IWRBTC, ACTOR), 0, "sub-product bypass wins"); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), DELAY, "other sub falls to surface"); + } + + function test_actor_bypass_overrides_subProduct_and_surface() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, false)); + controller.setSubProductBypass(SURFACE, IWRBTC, _bp(true, false)); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, true)); // MM exemption + vm.stopPrank(); + assertEq(controller.quoteExitDelay(SURFACE, IWRBTC, ACTOR), 0, "actor bypass wins"); + // A different actor still pays the (forced) delay. + assertEq(controller.quoteExitDelay(SURFACE, IWRBTC, OTHER), DELAY); + } + + function test_active_false_bypass_forces_delay_over_broader_bypass() public { + // The tricky override: a more-specific active {bypass:false} re-imposes + // delay even when a broader tier bypasses. + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); // broad bypass + controller.setActorBypass(SURFACE, ACTOR, _bp(true, false)); // re-impose on ACTOR + vm.stopPrank(); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), DELAY, "actor re-imposes delay"); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, OTHER), 0, "others keep surface bypass"); + } + + function test_inactive_bypass_tier_falls_through() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); // surface bypass + controller.setActorBypass(SURFACE, ACTOR, _bp(false, true)); // INACTIVE — ignored + vm.stopPrank(); + // Inactive actor tier must NOT consume; falls through to surface bypass. + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), 0, "inactive actor tier falls through"); + } + + function test_subProduct_zero_skips_subProduct_tier() public { + // Zero passes subProduct=address(0); the resolver must NOT consult the + // sub-product map for address(0) (a sibling entry cannot leak in). + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSubProductBypass(SURFACE_OTHER, IWRBTC, _bp(true, true)); + vm.stopPrank(); + // address(0) path falls straight through to surface (unconfigured) ⇒ global. + assertEq(controller.quoteExitDelay(SURFACE_OTHER, address(0), ACTOR), DELAY); + } + + // ─── Passthrough surface-scoping ────────────────────────────── + + function test_passthrough_resolves_to_receiver_on_scoped_surface() public { + _enableDelay(DELAY); + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + + // effectiveActor rewrites the wrapper to the receiver on THIS surface. + assertEq(controller.effectiveActor(SURFACE, WRAPPER, USER_EOA), USER_EOA); + // A non-passthrough is identity. + assertEq(controller.effectiveActor(SURFACE, ACTOR, USER_EOA), ACTOR); + + // quoteExitDelayFor returns the NORMALIZED originator/owner. + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY); + assertEq(effOrig, USER_EOA, "originator normalized wrapper->receiver"); + assertEq(effOwner, USER_EOA, "owner normalized wrapper->receiver"); + } + + function test_passthrough_is_surface_scoped_not_global() public { + _enableDelay(DELAY); + // Register WRAPPER as passthrough ONLY on the lending surface. + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + + // On Zero (no passthrough entry) the wrapper is NOT rewritten — margin/ + // Zero keep raw identities (never collapse originator/owner globally). + assertEq(controller.effectiveActor(SURFACE_OTHER, WRAPPER, USER_EOA), WRAPPER); + (, address effOrig, address effOwner) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE_OTHER, address(0)); + assertEq(effOrig, WRAPPER, "Zero keeps raw originator"); + assertEq(effOwner, WRAPPER, "Zero keeps raw owner"); + } + + function test_passthrough_actor_bypass_targets_effective_actor() public { + // Finding 2: the quote is on effOrig, so an actorBypass on the USER_EOA + // (not the wrapper) applies. Register wrapper passthrough + bypass on EOA. + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + controller.setActorBypass(SURFACE, USER_EOA, _bp(true, true)); // exempt the human + vm.stopPrank(); + + (uint32 d, address effOrig,) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE, IXUSD); + assertEq(effOrig, USER_EOA); + assertEq(d, 0, "actorBypass on the effective (EOA) actor applies, not the wrapper"); + } + + function test_disabled_perimeter_skips_passthrough_resolution() public { + // Kill switch OFF: even with a passthrough registered, quoteExitDelayFor + // returns RAW identities (short-circuits BEFORE the registry). + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + // perimeter still disabled + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0); + assertEq(effOrig, WRAPPER, "raw, registry not consulted"); + assertEq(effOwner, WRAPPER, "raw, registry not consulted"); + } + + function test_passthrough_deregister() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + assertTrue(controller.passthroughActor(SURFACE, WRAPPER)); + controller.setPassthroughActor(SURFACE, WRAPPER, false); + vm.stopPrank(); + assertFalse(controller.passthroughActor(SURFACE, WRAPPER)); + assertEq(controller.effectiveActor(SURFACE, WRAPPER, USER_EOA), WRAPPER); + } + + function test_setPassthrough_zero_reverts_and_only_owner() public { + vm.prank(ADMIN); + vm.expectRevert(ExitFeeController.ActorZero.selector); + controller.setPassthroughActor(SURFACE, address(0), true); + + vm.prank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + } + + // ─── Admin guardian setter (setAdmin) ─────────────────────────────── + + function test_setAdmin_sets_and_emits() public { + vm.prank(ADMIN); + vm.expectEmit(true, false, false, false); + emit AdminSet(GUARDIAN); + controller.setAdmin(GUARDIAN); + assertEq(controller.admin(), GUARDIAN); + } + + function test_setAdmin_rejects_zero() public { + vm.prank(ADMIN); + vm.expectRevert(ExitFeeController.AdminZero.selector); + controller.setAdmin(address(0)); + } + + function test_setAdmin_may_equal_owner() public { + // admin == owner is a + // supported shape (the governance Safe holds both roles at launch). + vm.prank(ADMIN); + controller.setAdmin(ADMIN); // ADMIN is the owner here + assertEq(controller.admin(), ADMIN); + } + + function test_setAdmin_only_owner() public { + vm.prank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setAdmin(GUARDIAN); + } + + function test_default_admin_zero_owner_still_flips_kill_switch() public { + // Before setAdmin, admin == address(0). The Owner can still flip the + // kill switch (safe default: only Owner until a guardian is appointed); + // a stranger (and address(0) callers can't exist) cannot. + assertEq(controller.admin(), address(0)); + vm.prank(ADMIN); + controller.setSecurityPerimeterEnabled(true); + assertTrue(controller.securityPerimeterEnabled()); + } + + // ─── Bypass setter validation / enumeration / removal ─────────────── + + function test_bypass_setters_only_owner() public { + vm.startPrank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, true)); + vm.stopPrank(); + } + + function test_bypass_zero_address_reverts() public { + vm.startPrank(ADMIN); + vm.expectRevert(ExitFeeController.SubProductZero.selector); + controller.setSubProductBypass(SURFACE, address(0), _bp(true, true)); + vm.expectRevert(ExitFeeController.ActorZero.selector); + controller.setActorBypass(SURFACE, address(0), _bp(true, true)); + vm.stopPrank(); + } + + function test_bypass_batch_and_length_mismatch() public { + vm.startPrank(ADMIN); + address[] memory actors = new address[](2); + actors[0] = ACTOR; + actors[1] = CAFE; + IExitFeeController.DelayBypassPolicy[] memory ps = new IExitFeeController.DelayBypassPolicy[](2); + ps[0] = _bp(true, true); + ps[1] = _bp(true, false); + controller.setActorBypasses(SURFACE, actors, ps); + + // Length mismatch reverts. + IExitFeeController.DelayBypassPolicy[] memory bad = new IExitFeeController.DelayBypassPolicy[](1); + bad[0] = _bp(true, true); + vm.expectRevert(ExitFeeController.LengthMismatch.selector); + controller.setActorBypasses(SURFACE, actors, bad); + vm.stopPrank(); + + address[] memory keys = controller.actorBypassKeys(SURFACE); + assertEq(keys.length, 2); + assertEq(keys[0], ACTOR); + assertEq(keys[1], CAFE); + } + + function test_bypass_enumeration_and_hard_removal() public { + vm.startPrank(ADMIN); + controller.setSubProductBypass(SURFACE, IWRBTC, _bp(true, true)); + controller.setSubProductBypass(SURFACE, IXUSD, _bp(true, false)); + assertEq(controller.subProductBypassKeys(SURFACE).length, 2); + + controller.removeSubProductBypass(SURFACE, IWRBTC); + vm.stopPrank(); + + assertEq(controller.subProductBypassKeys(SURFACE).length, 1); + IExitFeeController.DelayBypassPolicy memory p = controller.subProductBypass(SURFACE, IWRBTC); + assertFalse(p.active); + assertFalse(p.bypass); + } + + function test_bypass_remove_idempotent_when_absent() public { + vm.recordLogs(); + vm.prank(ADMIN); + controller.removeActorBypass(SURFACE, ACTOR); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0, "no event when nothing to remove"); + } + + function test_bypass_keys_retained_on_soft_retire() public { + vm.startPrank(ADMIN); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, true)); + controller.setActorBypass(SURFACE, ACTOR, _bp(false, false)); // soft retire + vm.stopPrank(); + address[] memory keys = controller.actorBypassKeys(SURFACE); + assertEq(keys.length, 1, "key retained on soft retire"); + assertEq(keys[0], ACTOR); + } + + // ─── quoteExitDelay inner view: disabled-perimeter parity ─────────── + + function test_inner_quote_returns_zero_when_disabled() public { + // Even with a forcing bypass configured, the inner view returns 0 while + // the perimeter is off (parity with quoteExitDelayFor). + vm.startPrank(ADMIN); + controller.setGlobalDelaySeconds(DELAY); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, false)); // would force delay + vm.stopPrank(); + // perimeter disabled + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), 0); + } + + // ─── Fuzz / property: precedence + short-circuit + faithful delay ─── + + /// @dev Property: when the perimeter is ON and no bypass tier is configured, + /// quoteExitDelay returns EXACTLY globalDelaySeconds for any actor and + /// any (non-zero-or-zero) subProduct — the "default delayed" rule. + function testFuzz_default_delay_equals_global(uint32 d, address actor, address sub) public { + _enableDelay(d); + assertEq(controller.quoteExitDelay(SURFACE, sub, actor), d); + } + + /// @dev Property: an active actor bypass ALWAYS decides on effOrig, + /// regardless of the sub-product and surface tiers. + function testFuzz_actor_bypass_always_wins( + uint32 d, + bool surfActive, + bool surfBypass, + bool subActive, + bool subBypass, + bool actorBypassVal + ) public { + vm.assume(d > 0); + _enableDelay(d); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(surfActive, surfBypass)); + controller.setSubProductBypass(SURFACE, IXUSD, _bp(subActive, subBypass)); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, actorBypassVal)); + vm.stopPrank(); + + uint32 got = controller.quoteExitDelay(SURFACE, IXUSD, ACTOR); + assertEq(got, actorBypassVal ? 0 : d, "active actor tier decides"); + } + + /// @dev Property: disabled perimeter ALWAYS returns (0, raw, owner) — the + /// registry is never consulted and identities are never normalized, + /// for any inputs (controller-side). + function testFuzz_disabled_always_raw_and_zero( + address raw, + address owner_, + address receiver, + address sub, + bool registerPassthrough + ) public { + // Optionally register a passthrough; it must NOT be consulted while off. + if (registerPassthrough && raw != address(0)) { + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, raw, true); + } + // perimeter disabled (default) + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(raw, owner_, receiver, SURFACE, sub); + assertEq(d, 0); + assertEq(effOrig, raw, "raw originator echoed"); + assertEq(effOwner, owner_, "raw owner echoed"); + } + + /// @dev Property: quoteExitDelayFor and the inner quoteExitDelay agree on the + /// resolved delay when the perimeter is ON and no passthrough rewrites + /// the actor (so effOrig == rawOriginator). + function testFuzz_outer_inner_agree(uint32 d, address actor, address sub) public { + vm.assume(actor != WRAPPER); // no passthrough registered anyway + _enableDelay(d); + (uint32 outer,,) = controller.quoteExitDelayFor(actor, actor, actor, SURFACE, sub); + uint32 inner = controller.quoteExitDelay(SURFACE, sub, actor); + assertEq(outer, inner, "outer and inner resolve the same delay"); + } + + // ─── Admin == Owner is a supported shape ── + // + // The `_transferOwnership` chokepoint and setAdmin's + // owner-equality check were removed by team decision: at launch the + // governance Safe holds BOTH roles. These pin the new behavior: role + // merges via the 2-step handoff succeed, and normal rotations are + // unaffected. + + function test_transferOwnership_to_admin_then_accept_merges_roles() public { + // Appoint a guardian, then hand ownership to that same guardian via + // the 2-step flow. Both steps succeed; owner == admin afterwards. + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + + vm.prank(ADMIN); + controller.transferOwnership(GUARDIAN); // stages pendingOwner = GUARDIAN + assertEq(controller.pendingOwner(), GUARDIAN, "pending staged"); + + vm.prank(GUARDIAN); + controller.acceptOwnership(); + + assertEq(controller.owner(), GUARDIAN, "roles merged: guardian is now owner"); + assertEq(controller.admin(), GUARDIAN, "admin unchanged"); + } + + function test_transferOwnership_to_nonadmin_still_works() public { + // A normal rotation to a fresh (non-admin) owner is unaffected. + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + + vm.prank(ADMIN); + controller.transferOwnership(OTHER); // OTHER != admin + vm.prank(OTHER); + controller.acceptOwnership(); + assertEq(controller.owner(), OTHER, "rotation to a non-admin owner succeeds"); + } + + function test_initialize_handoff_admin_unset_at_init() public { + // At initialize, admin == address(0); it is only appointed AFTER init + // via setAdmin. A fresh deploy handing off to a Safe starts adminless. + ExitFeeController impl = new ExitFeeController(); + bytes memory init = abi.encodeWithSelector(ExitFeeController.initialize.selector, CAFE); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + ExitFeeController c = ExitFeeController(address(proxy)); + assertEq(c.owner(), CAFE, "immediate handoff at init succeeds"); + assertEq(c.admin(), address(0), "admin unset until setAdmin"); + } + + // ─── surface-bypass + passthrough registries are ENUMERABLE ── + + function test_surfaceBypassKeys_enumerates_no_argument() public { + // The getter takes NO argument — surface bypasses are keyed by surfaceId + // alone. Configure two surfaces and confirm both are listed. + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + controller.setSurfaceBypass(SURFACE_OTHER, _bp(true, false)); + vm.stopPrank(); + + bytes32[] memory keys = controller.surfaceBypassKeys(); + assertEq(keys.length, 2, "both configured surfaces enumerated"); + // Order is set-insertion order. + assertEq(keys[0], SURFACE); + assertEq(keys[1], SURFACE_OTHER); + } + + function test_surfaceBypassKeys_idempotent_on_overwrite() public { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + controller.setSurfaceBypass(SURFACE, _bp(true, false)); // overwrite same id + vm.stopPrank(); + assertEq(controller.surfaceBypassKeys().length, 1, "no duplicate key on overwrite"); + } + + function test_surfaceBypassKeys_retained_on_soft_retire() public { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + controller.setSurfaceBypass(SURFACE, _bp(false, false)); // soft retire + vm.stopPrank(); + bytes32[] memory keys = controller.surfaceBypassKeys(); + assertEq(keys.length, 1, "soft-retired surface still enumerable"); + assertEq(keys[0], SURFACE); + } + + function test_removeSurfaceBypass_hard_removes_and_emits() public { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + assertEq(controller.surfaceBypassKeys().length, 1); + + vm.expectEmit(true, false, false, false); + emit SurfaceBypassRemoved(SURFACE); + controller.removeSurfaceBypass(SURFACE); + vm.stopPrank(); + + assertEq(controller.surfaceBypassKeys().length, 0, "key dropped"); + IExitFeeController.DelayBypassPolicy memory p = controller.surfaceBypass(SURFACE); + assertFalse(p.active, "policy cleared"); + assertFalse(p.bypass); + } + + function test_removeSurfaceBypass_idempotent_when_absent() public { + vm.recordLogs(); + vm.prank(ADMIN); + controller.removeSurfaceBypass(SURFACE); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0, "no event when nothing to remove"); + } + + function test_removeSurfaceBypass_only_owner() public { + vm.prank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + vm.prank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.removeSurfaceBypass(SURFACE); + } + + function test_bypass_enumeration_under_arbitrary_surfaceId() public { + // A bypass set under an arbitrary surfaceId (never registered as a named + // fee surface) must still be fully enumerated across all three tiers. + bytes32 arbitrary = keccak256("ARBITRARY:SURFACE:XYZ"); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(arbitrary, _bp(true, true)); + controller.setSubProductBypass(arbitrary, IXUSD, _bp(true, false)); + controller.setActorBypass(arbitrary, ACTOR, _bp(true, true)); + vm.stopPrank(); + + bytes32[] memory sKeys = controller.surfaceBypassKeys(); + assertEq(sKeys.length, 1); + assertEq(sKeys[0], arbitrary, "arbitrary surface enumerated"); + assertEq(controller.subProductBypassKeys(arbitrary).length, 1); + assertEq(controller.subProductBypassKeys(arbitrary)[0], IXUSD); + assertEq(controller.actorBypassKeys(arbitrary).length, 1); + assertEq(controller.actorBypassKeys(arbitrary)[0], ACTOR); + } + + function test_passthroughKeys_enumerates_and_drops_on_deregister() public { + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + controller.setPassthroughActor(SURFACE, CAFE, true); + vm.stopPrank(); + + address[] memory keys = controller.passthroughKeys(SURFACE); + assertEq(keys.length, 2, "both passthroughs enumerated"); + assertEq(keys[0], WRAPPER); + assertEq(keys[1], CAFE); + + // Deregister WRAPPER: exact-to-live (dropped from the index, unlike the + // soft-retained bypass key-sets). + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, false); + address[] memory keys2 = controller.passthroughKeys(SURFACE); + assertEq(keys2.length, 1, "deregistered passthrough dropped from index"); + assertEq(keys2[0], CAFE); + } + + function test_passthroughKeys_under_arbitrary_surfaceId() public { + // A passthrough registered under an arbitrary surfaceId is enumerated. + bytes32 arbitrary = keccak256("ARBITRARY:PASSTHROUGH:QQQ"); + vm.prank(ADMIN); + controller.setPassthroughActor(arbitrary, WRAPPER, true); + address[] memory keys = controller.passthroughKeys(arbitrary); + assertEq(keys.length, 1); + assertEq(keys[0], WRAPPER); + // Surface-scoped: NOT visible under a different surface. + assertEq(controller.passthroughKeys(SURFACE).length, 0); + } + + function test_passthroughKeys_idempotent_reregister() public { + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + controller.setPassthroughActor(SURFACE, WRAPPER, true); // re-register + vm.stopPrank(); + assertEq(controller.passthroughKeys(SURFACE).length, 1, "no duplicate on re-register"); + } + + function test_passthroughKeys_deregister_absent_is_noop() public { + // Deregistering an address that was never registered leaves the index + // empty and does not revert. + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, false); + assertEq(controller.passthroughKeys(SURFACE).length, 0); + assertFalse(controller.passthroughActor(SURFACE, WRAPPER)); + } + + /// @dev Property: every actively-registered passthrough is + /// enumerated, and enumeration membership tracks the boolean flag exactly + /// (register -> present, deregister -> absent) under a random walk. + function testFuzz_passthroughKeys_membership_tracks_flag(address a, bool register, bool thenDeregister) + public + { + vm.assume(a != address(0)); + bytes32 s = SURFACE; + vm.startPrank(ADMIN); + if (register) { + controller.setPassthroughActor(s, a, true); + if (thenDeregister) controller.setPassthroughActor(s, a, false); + } + vm.stopPrank(); + + bool expectPresent = register && !thenDeregister; + assertEq(controller.passthroughActor(s, a), expectPresent, "flag matches expectation"); + + address[] memory keys = controller.passthroughKeys(s); + bool found = false; + for (uint256 i = 0; i < keys.length; i++) { + if (keys[i] == a) { + found = true; + break; + } + } + assertEq(found, expectPresent, "index membership tracks the boolean flag exactly"); + } + + /// @dev Property: the surface-bypass key-set contains a + /// surfaceId iff a surface bypass was set-and-not-hard-removed for it. + function testFuzz_surfaceBypassKeys_membership(bytes32 s, bool active, bool bypass, bool thenRemove) + public + { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(s, _bp(active, bypass)); + if (thenRemove) controller.removeSurfaceBypass(s); + vm.stopPrank(); + + bytes32[] memory keys = controller.surfaceBypassKeys(); + bool found = false; + for (uint256 i = 0; i < keys.length; i++) { + if (keys[i] == s) { + found = true; + break; + } + } + // set-then-remove -> absent; set-only (even soft-retired) -> present. + assertEq(found, !thenRemove, "membership = set && !hardRemove"); + } + + // ════════════════════════════════════════════════════════════════════ + // — ANY-TIER-TOUCHED master id-sets + // + // The exact gap the previous cycle left: the master surface-id set was + // populated ONLY by setSurfaceBypass, so a sub-product- or actor-ONLY + // bypass (the most common exemption shape, actor tier) or a + // passthrough-only entry under an arbitrary surfaceId was undiscoverable + // by any single enumeration getter. These tests pin that NO zero-delay / + // identity-collapse config is invisible to the inspector's driver. + // ════════════════════════════════════════════════════════════════════ + + /// @dev REGRESSION (the exact gap): configure ONLY an actor-tier bypass with + /// NO prior setSurfaceBypass. The surfaceId MUST appear in + /// bypassSurfaceIds() even though surfaceBypassKeys() (surface-tier only) + /// does not contain it. + function test_bypassSurfaceIds_records_actor_only_bypass() public { + bytes32 arbitrary = keccak256("ARBITRARY:ACTOR:ONLY"); + address someMM = address(0x11A11); // a market-maker actor + vm.prank(ADMIN); + controller.setActorBypass( + arbitrary, someMM, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + + // The OLD surface-tier-only driver misses it: + assertEq(controller.surfaceBypassKeys().length, 0, "surface-tier set stays empty"); + + // The any-tier-touched master set discovers it: + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1, "actor-only bypass surfaces the id"); + assertEq(ids[0], arbitrary, "the arbitrary surfaceId is discoverable"); + + // And the per-surface actor tier is reachable from that id. + address[] memory actorKeys = controller.actorBypassKeys(arbitrary); + assertEq(actorKeys.length, 1); + assertEq(actorKeys[0], someMM); + } + + /// @dev REGRESSION: a sub-product-ONLY bypass with no prior setSurfaceBypass + /// is likewise discoverable via bypassSurfaceIds(). + function test_bypassSurfaceIds_records_subproduct_only_bypass() public { + bytes32 arbitrary = keccak256("ARBITRARY:SUBPRODUCT:ONLY"); + vm.prank(ADMIN); + controller.setSubProductBypass(arbitrary, IXUSD, _bp(true, false)); + + assertEq(controller.surfaceBypassKeys().length, 0, "surface-tier set stays empty"); + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1); + assertEq(ids[0], arbitrary); + assertEq(controller.subProductBypassKeys(arbitrary)[0], IXUSD); + } + + /// @dev The surface tier also records into the master set (so all three + /// writers feed it), and the master set dedups a surfaceId touched at + /// multiple tiers. + function test_bypassSurfaceIds_dedups_across_tiers() public { + bytes32 s = keccak256("ARBITRARY:ALL:TIERS"); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(s, _bp(true, true)); + controller.setSubProductBypass(s, IXUSD, _bp(true, false)); + controller.setActorBypass(s, ACTOR, _bp(true, true)); + vm.stopPrank(); + + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1, "single id even though 3 tiers touched it"); + assertEq(ids[0], s); + } + + /// @dev REGRESSION: removeSurfaceBypass while sub/actor entries remain live + /// does NOT drop the id from discovery — the id stays in the master set + /// so the inspector keeps probing the still-live sub/actor tiers. + function test_bypassSurfaceIds_retained_after_removeSurfaceBypass() public { + bytes32 s = keccak256("ARBITRARY:REMOVE:SURFACE"); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(s, _bp(true, true)); + controller.setActorBypass(s, ACTOR, _bp(true, true)); // still-live actor tier + controller.removeSurfaceBypass(s); // drop ONLY the surface tier + vm.stopPrank(); + + // Surface-tier key-set drops it (hard remove of the surface tier)... + assertEq(controller.surfaceBypassKeys().length, 0, "surface-tier key dropped"); + // ...but the any-tier master set retains it (actor tier still live). + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1, "master set retains id while sub/actor live"); + assertEq(ids[0], s); + assertEq(controller.actorBypassKeys(s)[0], ACTOR, "actor tier still live"); + } + + /// @dev REGRESSION: a passthrough-ONLY entry under an arbitrary surfaceId + /// (no bypass at any tier, not a named fee surface) is discoverable via + /// passthroughSurfaceIds(). + function test_passthroughSurfaceIds_records_passthrough_only_entry() public { + bytes32 arbitrary = keccak256("ARBITRARY:PASSTHROUGH:ONLY"); + vm.prank(ADMIN); + controller.setPassthroughActor(arbitrary, WRAPPER, true); + + // No bypass at any tier for this surfaceId: + assertEq(controller.bypassSurfaceIds().length, 0, "no bypass touched this id"); + assertEq(controller.surfaceBypassKeys().length, 0); + + // But the passthrough master set discovers it: + bytes32[] memory ids = controller.passthroughSurfaceIds(); + assertEq(ids.length, 1, "passthrough-only surface discoverable"); + assertEq(ids[0], arbitrary); + assertEq(controller.passthroughKeys(arbitrary)[0], WRAPPER); + } + + /// @dev passthroughSurfaceIds() is surface-level retention: the id stays + /// recorded even after every passthrough under it is deregistered (the + /// per-surface passthroughKeys going empty is the live signal). This + /// guarantees the inspector never loses the probe point. + function test_passthroughSurfaceIds_retained_after_deregister() public { + bytes32 s = keccak256("ARBITRARY:PASSTHROUGH:DEREG"); + vm.startPrank(ADMIN); + controller.setPassthroughActor(s, WRAPPER, true); + controller.setPassthroughActor(s, WRAPPER, false); // deregister the only one + vm.stopPrank(); + + // Per-surface live set is now empty... + assertEq(controller.passthroughKeys(s).length, 0, "no live passthrough under s"); + assertFalse(controller.passthroughActor(s, WRAPPER)); + // ...but the surface-level master set retains the probe point. + bytes32[] memory ids = controller.passthroughSurfaceIds(); + assertEq(ids.length, 1, "surface-level id retained for probing"); + assertEq(ids[0], s); + } + + /// @dev Property: bypassSurfaceIds() contains a surfaceId iff at least one + /// bypass tier (surface / sub-product / actor) was EVER written for it — + /// independent of which tier, and independent of soft-retire / surface + /// hard-remove (retention-only master set). + function testFuzz_bypassSurfaceIds_membership_any_tier(bytes32 s, uint8 tier, bool active, bool bypass) + public + { + vm.startPrank(ADMIN); + tier = uint8(bound(tier, 0, 2)); + if (tier == 0) { + controller.setSurfaceBypass(s, _bp(active, bypass)); + } else if (tier == 1) { + controller.setSubProductBypass(s, IXUSD, _bp(active, bypass)); + } else { + controller.setActorBypass(s, ACTOR, _bp(active, bypass)); + } + vm.stopPrank(); + + bytes32[] memory ids = controller.bypassSurfaceIds(); + bool found = false; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == s) { + found = true; + break; + } + } + assertTrue(found, "any tier write records the surfaceId in the master set"); + } + + /// @dev Property: passthroughSurfaceIds() contains a surfaceId iff a + /// passthrough was EVER registered under it (register-only recording, + /// surface-level retention). + function testFuzz_passthroughSurfaceIds_membership( + bytes32 s, + address a, + bool register, + bool thenDeregister + ) public { + vm.assume(a != address(0)); + vm.startPrank(ADMIN); + if (register) { + controller.setPassthroughActor(s, a, true); + if (thenDeregister) controller.setPassthroughActor(s, a, false); + } + vm.stopPrank(); + + bytes32[] memory ids = controller.passthroughSurfaceIds(); + bool found = false; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == s) { + found = true; + break; + } + } + // Ever-registered (even if later deregistered) -> present; never -> absent. + assertEq(found, register, "master set records on register, retains on deregister"); + } + // ─── Invariant testing design (NOT IMPLEMENTED -- sketch for follow-up) ── // // Stateful invariant testing in Foundry needs a Handler contract that diff --git a/test/unit/InspectControllerDiscovery.t.sol b/test/unit/InspectControllerDiscovery.t.sol new file mode 100644 index 0000000..6065f64 --- /dev/null +++ b/test/unit/InspectControllerDiscovery.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {IExitFeeController} from "../../src/interfaces/IExitFeeController.sol"; +import {InspectController} from "../../script/InspectController.s.sol"; + +/// @dev Test harness exposing InspectController's internal discovery driver so +/// the dump-path can be exercised without deployment artifacts / RPC. The +/// inspector prints from `_probeSurfaceIds` (the any-tier-touched union), +/// so proving that union CONTAINS an actor-only / passthrough-only id is +/// exactly the "dump path discovers it" regression. +contract InspectHarness is InspectController { + function probeSurfaceIds(ExitFeeController c) external view returns (bytes32[] memory) { + return _probeSurfaceIds(c); + } +} + +/// @title — InspectController discovery completeness +contract InspectControllerDiscoveryTest is Test { + address constant OWNER = address(0xC0FFEE); + address constant ACTOR = address(0xAC); + address constant SUB = address(0x1750D); + address constant WRAP = address(0x323A99); + + // A named fee surface, and two ARBITRARY surfaces never registered as fee surfaces. + bytes32 constant NAMED = keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + bytes32 constant ARB_ACTOR = keccak256("ARBITRARY:ACTOR:ONLY"); + bytes32 constant ARB_PASS = keccak256("ARBITRARY:PASSTHROUGH:ONLY"); + + ExitFeeController controller; + InspectHarness harness; + + function setUp() public { + ExitFeeController impl = new ExitFeeController(); + bytes memory init = abi.encodeWithSelector(ExitFeeController.initialize.selector, OWNER); + controller = ExitFeeController(address(new ERC1967Proxy(address(impl), init))); + harness = new InspectHarness(); + } + + function _contains(bytes32[] memory ids, bytes32 x) internal pure returns (bool) { + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == x) return true; + } + return false; + } + + /// @dev The exact gap: an actor-ONLY bypass under an arbitrary surfaceId with + /// NO prior setSurfaceBypass must appear in the inspector's probe set. + function test_probe_discovers_actor_only_bypass_under_arbitrary_surface() public { + vm.prank(OWNER); + controller.setActorBypass( + ARB_ACTOR, ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue(_contains(ids, ARB_ACTOR), "actor-only arbitrary surface discovered by dump path"); + // Named fee surfaces are always folded in (human rows). + assertTrue(_contains(ids, NAMED), "named fee surface always probed"); + } + + /// @dev A sub-product-ONLY bypass under an arbitrary surfaceId is discovered. + function test_probe_discovers_subproduct_only_bypass() public { + vm.prank(OWNER); + controller.setSubProductBypass( + ARB_ACTOR, SUB, IExitFeeController.DelayBypassPolicy({active: true, bypass: false}) + ); + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue(_contains(ids, ARB_ACTOR), "sub-product-only arbitrary surface discovered"); + } + + /// @dev A passthrough-ONLY entry under an arbitrary surfaceId (no bypass at any + /// tier, not named) is discovered via passthroughSurfaceIds(). + function test_probe_discovers_passthrough_only_entry() public { + vm.prank(OWNER); + controller.setPassthroughActor(ARB_PASS, WRAP, true); + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue(_contains(ids, ARB_PASS), "passthrough-only arbitrary surface discovered"); + } + + /// @dev removeSurfaceBypass while an actor entry stays live keeps the id in + /// the probe set (retention-only master set). + function test_probe_retains_id_after_removeSurfaceBypass_with_live_actor() public { + vm.startPrank(OWNER); + controller.setSurfaceBypass( + ARB_ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + controller.setActorBypass( + ARB_ACTOR, ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + controller.removeSurfaceBypass(ARB_ACTOR); + vm.stopPrank(); + + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue( + _contains(ids, ARB_ACTOR), + "id retained in probe set while actor tier live after surface hard-remove" + ); + } + + /// @dev The probe set is deduplicated: a surfaceId that is named AND carries + /// bypass + passthrough entries appears exactly once. + function test_probe_dedups_named_and_touched_surface() public { + vm.startPrank(OWNER); + controller.setActorBypass( + NAMED, ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + controller.setPassthroughActor(NAMED, WRAP, true); + vm.stopPrank(); + + bytes32[] memory ids = harness.probeSurfaceIds(controller); + uint256 count = 0; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == NAMED) count++; + } + assertEq(count, 1, "named+touched surface appears exactly once"); + } +} diff --git a/test/unit/PinnedIdentifiers.t.sol b/test/unit/PinnedIdentifiers.t.sol new file mode 100644 index 0000000..20a2cfb --- /dev/null +++ b/test/unit/PinnedIdentifiers.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; + +/// @title Pinned perimeter surface ids +/// @notice A surface id is `keccak256` of its name, so the name IS the value. +/// The same five names are declared independently here, in the lending +/// repo, in Zero and in the dapp; a one-character drift in any of them +/// resolves no policy and silently stops the fee rather than failing. +/// Each name is pinned to the literal 32 bytes it must hash to, so a +/// bulk rename cannot rewrite the name and its assertion together. +/// @dev A diff here means a redeploy and a re-bootstrap of the controller, +/// never a test edit. +contract PinnedIdentifiersTest is Test { + function testLenderWithdrawSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"), + 0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe + ); + } + + function testBorrowerWithdrawSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW"), + 0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018 + ); + } + + function testZeroWithdrawCollSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"), + 0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5 + ); + } + + function testZeroClaimSurplusSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS"), + 0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be + ); + } + + function testAmmRemoveLiquiditySurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY"), + 0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2 + ); + } + + /// @notice The Phase-1 names must be gone: an id derived from one of them + /// points at a policy slot the re-cut controller never writes. + function testStaleNamesDoNotCollide() public pure { + assertTrue( + keccak256("SURFACE_LENDING_LENDER_WITHDRAW") != + keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW") + ); + assertTrue( + keccak256("SURFACE_ZERO_WITHDRAW_COLL") != + keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL") + ); + } +} diff --git a/test/unit/VerifyActivation.t.sol b/test/unit/VerifyActivation.t.sol new file mode 100644 index 0000000..b9564d5 --- /dev/null +++ b/test/unit/VerifyActivation.t.sol @@ -0,0 +1,714 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../../src/interfaces/IExitDelayQueueHost.sol"; +import {VerifyActivation} from "../../script/06_VerifyActivation.s.sol"; + +/// @dev Test harness exposing the internal host-resolution helpers so the C1 +/// defer/warning/require logic is driveable on in-memory addresses WITHOUT +/// `vm.setEnv` (which mutates process-global env forge does not isolate +/// between parallel test functions — a genuine race). +contract VerifyActivationHarness is VerifyActivation { + function resolveHosts(bool deferHosts, address sovrynHost, address zeroHost) + external + view + returns (address[] memory) + { + return _resolveHosts(deferHosts, sovrynHost, zeroHost); + } + + /// Expose the banner emitter so BOTH legs — the unqualified "safe to run step 8" + /// banner (anyHostDeferred==false) and the qualified/warned downgrade + /// (anyHostDeferred==true) — are exercised deterministically WITHOUT the env→run + /// path (G5R2-02 coverage). View-only, no state, no revert. + function reportPass(bool anyHostDeferred) external view { + _reportPass(anyHostDeferred); + } +} + +/// @dev Minimal WRBTC stand-in for the queue's `wrbtc_` init param. +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + receive() external payable {} +} + +/// @dev Minimal product-host stand-in implementing the `setExitDelayQueue` pointer +/// so the C2 wiring assertions can be driven end-to-end. +contract MockProductHost is IExitDelayQueueHost { + address public exitDelayQueue; + + function setExitDelayQueue(address queue) external override { + exitDelayQueue = queue; + } +} + +/// @title activation step-7 COMPREHENSIVE go-live gate — 06_VerifyActivation (C1/C2) +/// @notice Proves the read-only verify gate reverts with a DISTINCT message on each +/// misconfig and PASSES on a fully-correct config: +/// (a) guardian — "not yet configured" vs mismatch +/// (b) floor — "not yet configured" vs sub-floor +/// (C1) ownership — owner still deployer / owner != governance +/// (C2) wiring — host not wired / host wired-but-not-allowed-source +/// plus the run(chainId) artifact-reading path. +contract VerifyActivationTest is Test { + // (SP2-G5R2-02 / GATE4-03) A DEDICATED test-only chain id — NOT 31337 — so the + // run()-path artifact writes land in deployments/31338/ and never clobber a real + // local anvil deploy's deployments/31337/*.json. fs_permissions grants the whole + // ./deployments tree, so no foundry.toml change is needed. + uint256 constant CHAIN_ID = 31338; + + address constant CTRL_OWNER = address(0xC0FFEE); // controller Owner after init + address constant GUARDIAN = address(0x6DA12D); // shared single guardian + address constant OTHER_ADMIN = address(0xBAD); // a DIFFERENT guardian -> mis-wired + address constant QUEUE_OWNER = address(0x0E7E7); // queue Owner after init; != guardian + address constant GOV_OWNER = address(0x60F); // intended governance Owner (C1) + address constant DEPLOYER = address(0xDEEDDE); // broadcast EOA (C1: must own neither) + address constant STRAY_OWNER = address(0x57A11); // not deployer, not governance (C1) + address constant SOURCE = address(0x50117CE); + + uint32 constant FLOOR = 3600; + + VerifyActivationHarness script; + MockWRBTC wrbtc; + + // Distinct concrete host addresses for the C1 host-resolution tests. + address constant SOVRYN_HOST = address(0x50147117); + address constant ZERO_HOST = address(0x2E70117); + ExitFeeController controller; + ExitDelayQueue queue; + + MockProductHost host; // registered allowed-source + wired by default + + function setUp() public { + script = new VerifyActivationHarness(); + wrbtc = new MockWRBTC(); + + // ── Controller owned by CTRL_OWNER (so we later transfer it to GOV_OWNER). ── + ExitFeeController cImpl = new ExitFeeController(); + bytes memory cInit = abi.encodeWithSelector(ExitFeeController.initialize.selector, CTRL_OWNER); + controller = ExitFeeController(address(new ERC1967Proxy(address(cImpl), cInit))); + + // ── Queue owned by QUEUE_OWNER; guardian GUARDIAN; the host pre-registered + // as an allowed-source so the default (correct) config is C2-clean. ── + host = new MockProductHost(); + address[] memory sources = new address[](2); + sources[0] = SOURCE; + sources[1] = address(host); + ExitDelayQueue qImpl = new ExitDelayQueue(); + bytes memory qInit = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, QUEUE_OWNER, GUARDIAN, address(wrbtc), FLOOR, sources + ); + queue = ExitDelayQueue(payable(address(new ERC1967Proxy(address(qImpl), qInit)))); + + // Wire the host's queue pointer at THIS queue (C2 default: wired). + host.setExitDelayQueue(address(queue)); + } + + // ─── Helpers ────────────────────────────────────────────────────────── + + /// Run activation steps 4–5 (Owner actions) on the controller. + function _configController(address admin_, uint32 globalDelay_) internal { + vm.startPrank(CTRL_OWNER); + controller.setGlobalDelaySeconds(globalDelay_); // step 4 + controller.setAdmin(admin_); // step 5 + vm.stopPrank(); + } + + /// Hand BOTH contracts to the intended governance Owner (Ownable2Step). + function _handToGovernance() internal { + vm.prank(CTRL_OWNER); + controller.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + controller.acceptOwnership(); + + vm.prank(QUEUE_OWNER); + queue.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + queue.acceptOwnership(); + } + + /// The default single-host intended list. + function _hosts() internal view returns (address[] memory hs) { + hs = new address[](1); + hs[0] = address(host); + } + + /// Drive the full comprehensive gate with the canonical args (non-deferred: the + /// default single-host list is non-empty so the C1 empty-list guard is satisfied). + function _verify() internal view { + script.verify(controller, queue, GOV_OWNER, DEPLOYER, _hosts(), false); + } + + /// A fully-correct deploy: config done, ownership handed off, host wired+allowed. + function _makeFullyCorrect() internal { + _configController(GUARDIAN, FLOOR); + _handToGovernance(); + } + + // ─── (a) guardian: distinct "not yet configured" vs mismatch ───── + + function test_verify_reverts_distinctly_when_admin_unconfigured() public { + _handToGovernance(); // ownership fine; admin still 0 + assertEq(controller.admin(), address(0), "precondition: admin unset"); + vm.expectRevert(bytes("guardian unconfigured: controller.admin()==0 -- run step 5 (setAdmin) first")); + _verify(); + } + + function test_verify_reverts_on_admin_mismatch() public { + _configController(OTHER_ADMIN, FLOOR); // configured, admin != queue.admin + _handToGovernance(); + vm.expectRevert( + bytes("single guardian violated: controller.admin() != queue.admin() -- single guardian violated") + ); + _verify(); + } + + // ─── (b) floor: distinct "not yet configured" vs sub-floor ───────── + + function test_verify_reverts_distinctly_when_global_delay_unconfigured() public { + vm.prank(CTRL_OWNER); + controller.setAdmin(GUARDIAN); // step 5 only; delay still 0 + _handToGovernance(); + vm.expectRevert( + bytes( + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ) + ); + _verify(); + } + + function test_verify_reverts_distinctly_on_subfloor_global_delay() public { + _configController(GUARDIAN, FLOOR - 1); + _handToGovernance(); + vm.expectRevert( + bytes( + "sub-floor delay: controller.globalDelaySeconds() < queue.minimumDelaySeconds() -- sub-floor delay self-bricks exits" + ) + ); + _verify(); + } + + // ─── (C1) ownership: owner still deployer / owner != governance ──────── + + /// Queue still owned by the deployer EOA (ownership never handed off): the + /// still-deployer check fires with the C1 "== deployer" message. + function test_verify_reverts_when_queue_owner_still_deployer() public { + _configController(GUARDIAN, FLOOR); + // Hand ONLY the controller to governance; move the queue to the DEPLOYER + // (simulating the silent-blank-owner footgun: deployer left in control). + vm.prank(CTRL_OWNER); + controller.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + controller.acceptOwnership(); + + vm.prank(QUEUE_OWNER); + queue.transferOwnership(DEPLOYER); + vm.prank(DEPLOYER); + queue.acceptOwnership(); + + vm.expectRevert( + bytes("C1: queue.owner() == deployer EOA -- ownership not handed to governance") + ); + _verify(); + } + + /// Controller still owned by the deployer EOA. + function test_verify_reverts_when_controller_owner_still_deployer() public { + _configController(GUARDIAN, FLOOR); + // Queue -> governance; controller -> deployer. + vm.prank(QUEUE_OWNER); + queue.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + queue.acceptOwnership(); + + vm.prank(CTRL_OWNER); + controller.transferOwnership(DEPLOYER); + vm.prank(DEPLOYER); + controller.acceptOwnership(); + + vm.expectRevert( + bytes( + "C1: controller.owner() == deployer EOA -- ownership not handed to governance" + ) + ); + _verify(); + } + + /// Queue owned by a NON-deployer address that is also not the intended + /// governance Owner: the "!= governance owner" message fires. + function test_verify_reverts_when_queue_owner_not_governance() public { + _configController(GUARDIAN, FLOOR); + address strayOwner = STRAY_OWNER; + // controller -> governance (correct) + vm.prank(CTRL_OWNER); + controller.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + controller.acceptOwnership(); + // queue -> stray (not deployer, not governance) + vm.prank(QUEUE_OWNER); + queue.transferOwnership(strayOwner); + vm.prank(strayOwner); + queue.acceptOwnership(); + + vm.expectRevert(bytes("C1: queue.owner() != governance owner")); + _verify(); + } + + function test_verify_reverts_when_controller_owner_not_governance() public { + _configController(GUARDIAN, FLOOR); + address strayOwner = STRAY_OWNER; + // queue -> governance (correct) + vm.prank(QUEUE_OWNER); + queue.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + queue.acceptOwnership(); + // controller -> stray + vm.prank(CTRL_OWNER); + controller.transferOwnership(strayOwner); + vm.prank(strayOwner); + controller.acceptOwnership(); + + vm.expectRevert(bytes("C1: controller.owner() != governance owner")); + _verify(); + } + + /// C1 arg hygiene: a zero governance-owner arg is a config error, not a pass. + function test_verify_reverts_when_governance_owner_arg_zero() public { + _makeFullyCorrect(); + vm.expectRevert( + bytes( + "C1: governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" + ) + ); + script.verify(controller, queue, address(0), DEPLOYER, _hosts(), false); + } + + function test_verify_reverts_when_deployer_arg_zero() public { + _makeFullyCorrect(); + vm.expectRevert(bytes("C1: deployer arg == 0 -- set EXIT_DELAY_DEPLOYER")); + script.verify(controller, queue, GOV_OWNER, address(0), _hosts(), false); + } + + function test_verify_reverts_when_governance_owner_equals_deployer() public { + _makeFullyCorrect(); + vm.expectRevert( + bytes("C1: governance owner == deployer -- they must differ") + ); + script.verify(controller, queue, GOV_OWNER, GOV_OWNER, _hosts(), false); + } + + // ─── (C2) wiring: host not wired / host wired-but-not-allowed-source ─── + + /// Host's queue pointer points elsewhere (or unset): fail-open zero-delay. + function test_verify_reverts_when_host_not_wired() public { + _makeFullyCorrect(); + // Re-point the host at a bogus queue address (unwired w.r.t. THIS queue). + host.setExitDelayQueue(address(0xDEAD)); + vm.expectRevert( + bytes( + string.concat( + "C2: host ", + vm.toString(address(host)), + " not wired -- host.exitDelayQueue() != queue (fail-open zero-delay)" + ) + ) + ); + _verify(); + } + + /// Host wired at THIS queue but NOT registered as an allowed-source: bricked + /// fail-closed (its record*() would revert UnregisteredSource). + function test_verify_reverts_when_host_wired_but_not_allowed_source() public { + // Deploy a SECOND host that is wired but never registered as a source. + MockProductHost unregHost = new MockProductHost(); + unregHost.setExitDelayQueue(address(queue)); + _makeFullyCorrect(); + + address[] memory hs = new address[](1); + hs[0] = address(unregHost); + + vm.expectRevert( + bytes( + string.concat( + "C2: host ", + vm.toString(address(unregHost)), + " not allowed-source -- queue.isAllowedSource(host)==false (bricked fail-closed)" + ) + ) + ); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); + } + + /// A zero host slipped into the intended list is rejected (defensive C2). + function test_verify_reverts_on_zero_host_in_list() public { + _makeFullyCorrect(); + address[] memory hs = new address[](1); + hs[0] = address(0); + vm.expectRevert(bytes("C2: intended host == 0")); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); + } + + // ─── (C1) host-input safety: required inputs + explicit VERIFY_DEFER_HOSTS ─ + // (replaces the deleted vacuous-pass test). Drives the + // _resolveHosts helper: the require/defer/warning logic that gates whether + // an intended-host list may ever be empty. The `run` path's REQUIRED-read + // (non-defer branch of _readHostEnv) revert-on-unset rests on forge's + // `vm.envAddress` cheatcode contract — it reverts when the var is unset or + // mistyped — so it is NOT re-asserted through an env-driven test here (see the + // NOTE below for why env-driven revert assertions are deliberately omitted: + // the process-global env race between parallel test functions/suites). + + /// deferHosts=false + both hosts non-zero: BOTH are C2-checked (list length 2). + function test_resolveHosts_both_present_no_defer() public view { + address[] memory hs = script.resolveHosts(false, SOVRYN_HOST, ZERO_HOST); + assertEq(hs.length, 2, "both hosts must be in the C2-checked list"); + assertEq(hs[0], SOVRYN_HOST, "sovryn host first"); + assertEq(hs[1], ZERO_HOST, "zero host second"); + } + + /// deferHosts=false + a zero sovryn host: HARD REVERT (no silent empty list / + /// vacuous PASS — a surface would ship unwired at zero-delay, fail-open). + function test_resolveHosts_reverts_on_zero_sovryn_without_defer() public { + vm.expectRevert( + bytes( + "C1: intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + script.resolveHosts(false, address(0), ZERO_HOST); + } + + /// deferHosts=false + a zero Zero host: HARD REVERT (same rule, other surface). + function test_resolveHosts_reverts_on_zero_zerohost_without_defer() public { + vm.expectRevert( + bytes( + "C1: intended host Zero BorrowerOperations (ZERO_BORROWER_OPERATIONS_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + script.resolveHosts(false, SOVRYN_HOST, address(0)); + } + + /// deferHosts=false + BOTH hosts zero (fresh-shell / all-typo'd): HARD REVERT + /// (the sovryn host is checked first). This is the exact vacuous-pass hole the + /// C1 fix closes — an empty list can NEVER be produced without an explicit opt-in. + function test_resolveHosts_reverts_on_both_zero_without_defer() public { + vm.expectRevert( + bytes( + "C1: intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + script.resolveHosts(false, address(0), address(0)); + } + + /// deferHosts=true + one host deferred (zero): the present host is checked, the + /// zero host is dropped WITH a loud warning (printed by _includeHost). No revert. + function test_resolveHosts_defers_zero_host_with_warning() public view { + address[] memory hs = script.resolveHosts(true, SOVRYN_HOST, address(0)); + assertEq(hs.length, 1, "only the wired host is C2-checked"); + assertEq(hs[0], SOVRYN_HOST, "the present host is retained"); + } + + /// deferHosts=true + BOTH hosts deferred (zero): allowed ONLY because deferral + /// is EXPLICIT — an empty list is produced but with loud per-host warnings. + function test_resolveHosts_defers_both_hosts_with_defer_optin() public view { + address[] memory hs = script.resolveHosts(true, address(0), address(0)); + assertEq(hs.length, 0, "both hosts explicitly deferred"); + } + + /// deferHosts=true but BOTH hosts present: defer opt-in does NOT drop a wired + /// host — both are still C2-checked (opt-in only excuses a ZERO host). + function test_resolveHosts_defer_true_but_both_present_still_checks_both() public view { + address[] memory hs = script.resolveHosts(true, SOVRYN_HOST, ZERO_HOST); + assertEq(hs.length, 2, "present hosts are always checked, defer or not"); + } + + /// (SP2-G5R2-01 / GATE4-02) A NON-ZERO duplicate host pair (both spec-named vars + /// pointing at the SAME address) HARD REVERTS — one surface would be C2-checked + /// twice while the OTHER ships silently unchecked/unwired. Applies regardless of + /// deferHosts (the check runs BEFORE the include/defer logic). + function test_resolveHosts_reverts_on_duplicate_nonzero_hosts() public { + vm.expectRevert( + bytes("C1: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") + ); + script.resolveHosts(false, SOVRYN_HOST, SOVRYN_HOST); + } + + /// The duplicate check fires even under deferHosts=true when the + /// pair is a genuine NON-ZERO duplicate — deferral excuses a ZERO host, never a + /// copy-paste of the same real address into both slots. + function test_resolveHosts_reverts_on_duplicate_nonzero_hosts_even_when_deferred() public { + vm.expectRevert( + bytes("C1: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") + ); + script.resolveHosts(true, ZERO_HOST, ZERO_HOST); + } + + /// (SP2-G5R2-01 regression) The both-ZERO pair under deferHosts=true is NOT a + /// duplicate — it is the explicit both-host deferral and must still yield an empty + /// list (proves the `|| == address(0)` clause preserves the defer-both path). + function test_resolveHosts_both_zero_not_treated_as_duplicate_when_deferred() public view { + address[] memory hs = script.resolveHosts(true, address(0), address(0)); + assertEq(hs.length, 0, "both-zero defer is not a duplicate; empty list allowed"); + } + + // ─── (C1) verify() refuses a vacuous PASS on an EMPTY host list ───────── + // The dry-run entrypoint must NOT silently certify go-live when the C2 wiring + // loop asserts nothing (empty list). An empty list is allowed ONLY when the + // caller EXPLICITLY asserts deferral (deferHosts==true) — the legitimate + // defer-both run() path. + + /// verify(..., empty list, deferHosts=false): HARD REVERT — an empty intended- + /// host list without an explicit defer opt-in is the exact fail-open vacuous PASS + /// the C1 decision mandates removing. + function test_verify_reverts_on_empty_host_list_without_defer() public { + _makeFullyCorrect(); + address[] memory empty = new address[](0); + vm.expectRevert( + bytes( + "empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" + ) + ); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, empty, false); + } + + /// verify(..., empty list, deferHosts=true): PASSES — the legitimate qualified / + /// deferred path (both hosts explicitly deferred to a later SIP). The other four + /// sub-checks (guardian/floor/ownership) still run and hold. + function test_verify_passes_on_empty_host_list_with_explicit_defer() public { + _makeFullyCorrect(); + address[] memory empty = new address[](0); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, empty, true); // does not revert + } + + /// (C1 regression) A NON-empty list with deferHosts=false still passes the empty- + /// list guard and proceeds to the sub-checks (guards against an over-broad guard). + function test_verify_nonempty_list_no_defer_passes_guard() public { + _makeFullyCorrect(); + _verify(); // non-empty _hosts(), deferHosts=false — passes + } + + // ─── (G5R2-02) _reportPass banner — BOTH legs, deterministically ──────── + // Driven through the harness (no env→run), so the qualified/downgraded banner + // branch (previously never executed) is exercised race-free. + + /// anyHostDeferred=false ⇒ the UNQUALIFIED "safe to run step 8" banner leg runs. + function test_reportPass_unqualified_when_no_host_deferred() public view { + script.reportPass(false); // view, no revert — exercises the unqualified leg + } + + /// anyHostDeferred=true ⇒ the QUALIFIED / warned downgrade banner leg runs (the + /// branch a real deferral takes; keyed off the resolved fact, not the raw flag). + function test_reportPass_qualified_when_a_host_deferred() public view { + script.reportPass(true); // view, no revert — exercises the downgrade leg + } + + // ─── (coverage 114-116) _readHostEnv deferHosts==true (envOr fallback) leg ── + // NOT driven here. `_readHostEnv` reads the SHARED host env keys + // (SOVRYN_PROTOCOL_HOST / ZERO_BORROWER_OPERATIONS_HOST); ANY test that + // touches them via `vm.setEnv` races the single clean-pass env→run() test + // (forge does not isolate process-global env between the concurrently- + // scheduled functions of one contract — empirically: an empty-string set here + // leaked into that test's `vm.envAddress` and broke its address parse). The + // defer leg is a one-line `vm.envOr(key, address(0))` whose "return the + // default on unset" behavior is a forge-cheatcode guarantee (same rationale as + // the REQUIRED-read revert leg, deliberately not env-tested). The DEFER + // SEMANTICS it feeds — a zero host dropped-with-warning vs a hard revert — are + // pinned deterministically on in-memory addresses by the test_resolveHosts_* + // suite. + + // ─── (d) PASSES on a fully-correct config ────────────────────────────── + + function test_verify_passes_when_fully_correct_at_floor() public { + _makeFullyCorrect(); + _verify(); // does not revert + } + + function test_verify_passes_when_delay_above_floor() public { + _configController(GUARDIAN, FLOOR * 2); + _handToGovernance(); + _verify(); + } + + // ─── (C2) floor MAY be 0; the `> 0` guard is on the ACTIVE delay ── + + /// @dev Redeploy the queue with a ZERO minimumDelaySeconds floor, re-wiring the + /// host + re-registering sources, so the C2 "floor MAY be 0" cases run + /// against a real zero-floor queue. + function _redeployQueueWithZeroFloor() internal { + host = new MockProductHost(); + address[] memory sources = new address[](2); + sources[0] = SOURCE; + sources[1] = address(host); + ExitDelayQueue qImpl = new ExitDelayQueue(); + bytes memory qInit = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, QUEUE_OWNER, GUARDIAN, address(wrbtc), uint32(0), sources + ); + queue = ExitDelayQueue(payable(address(new ERC1967Proxy(address(qImpl), qInit)))); + host.setExitDelayQueue(address(queue)); + } + + /// A zero `minimumDelaySeconds` FLOOR is NOT a go-live blocker: with a positive + /// globalDelaySeconds the gate PASSES (the per-request DelayBelowFloor backstop + /// is simply inactive — Owner-remediable, not a blocker). + function test_verify_passes_when_floor_is_zero_and_delay_positive() public { + _redeployQueueWithZeroFloor(); + assertEq(queue.minimumDelaySeconds(), 0, "precondition: zero floor"); + _configController(GUARDIAN, 1); // smallest positive active delay + _handToGovernance(); + _verify(); // does not revert — floor==0 is allowed, delay>0 satisfies C2 + } + + /// Even with a zero FLOOR, a zero ACTIVE globalDelaySeconds is REJECTED — a zero + /// global delay leaves the perimeter inert (C2 `> 0` applies to the active delay). + function test_verify_reverts_when_global_delay_zero_even_with_zero_floor() public { + _redeployQueueWithZeroFloor(); + vm.prank(CTRL_OWNER); + controller.setAdmin(GUARDIAN); // guardian ok; globalDelaySeconds stays 0 + _handToGovernance(); + vm.expectRevert( + bytes( + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ) + ); + _verify(); + } + + /// Multiple intended hosts, all wired + allowed: passes. + function test_verify_passes_with_multiple_hosts() public { + MockProductHost host2 = new MockProductHost(); + host2.setExitDelayQueue(address(queue)); + vm.prank(QUEUE_OWNER); + queue.addAllowedSource(address(host2)); + + _makeFullyCorrect(); + + address[] memory hs = new address[](2); + hs[0] = address(host); + hs[1] = address(host2); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); + } + + // ─── Ordering: guardian < floor < ownership < wiring (first unmet wins) ─ + + /// Nothing configured: (guardian) is the FIRST unmet invariant, so its + /// message fires ahead of the floor/ownership/wiring messages. + function test_verify_guardian_reported_first() public { + // admin==0, delay==0, ownership not handed, host fine. + vm.expectRevert(bytes("guardian unconfigured: controller.admin()==0 -- run step 5 (setAdmin) first")); + _verify(); + } + + // ─── run(chainId) artifact + env path ────────────────────────────────── + + /// @dev Write the deployment artifacts + the always-required (non-host) env vars + /// for the `run` path. Returns nothing; caller sets the host env vars per + /// the scenario under test. + function _writeRunArtifactsAndOwners() internal { + string memory dir = string.concat("deployments/", vm.toString(CHAIN_ID), "/"); + // (SP2-G5R2-02 / GATE4-03) Create the artifact dir first — on a FRESH clone + // deployments/31338/ does not exist and vm.writeFile would fail. `true` = + // recursive / no-error-if-exists. + vm.createDir(dir, true); + vm.writeFile( + string.concat(dir, "ExitFeeController.json"), + string.concat('{"proxyAddress":"', vm.toString(address(controller)), '"}') + ); + vm.writeFile( + string.concat(dir, "ExitDelayQueue.json"), + string.concat('{"proxyAddress":"', vm.toString(address(queue)), '"}') + ); + vm.setEnv("EXIT_DELAY_GOVERNANCE_OWNER", vm.toString(GOV_OWNER)); + vm.setEnv("EXIT_DELAY_DEPLOYER", vm.toString(DEPLOYER)); + } + + /// Deploy + register + wire a SECOND host so BOTH spec-named env hosts can point + /// at real wired allowed-sources for the clean-pass run path. + function _makeSecondWiredHost() internal returns (MockProductHost h2) { + h2 = new MockProductHost(); + h2.setExitDelayQueue(address(queue)); + vm.prank(QUEUE_OWNER); + queue.addAllowedSource(address(h2)); + } + + /// Clean go-live: BOTH hosts set, wired + allowed, no defer. run() passes and + /// emits the UNQUALIFIED banner. + function test_run_passes_cleanly_when_both_hosts_wired() public { + MockProductHost host2 = _makeSecondWiredHost(); + _makeFullyCorrect(); + _writeRunArtifactsAndOwners(); + + vm.setEnv("SOVRYN_PROTOCOL_HOST", vm.toString(address(host))); + vm.setEnv("ZERO_BORROWER_OPERATIONS_HOST", vm.toString(address(host2))); + vm.setEnv("VERIFY_DEFER_HOSTS", "false"); + + script.run(CHAIN_ID); // does not revert; unqualified PASS banner + } + + // NOTE — why only ONE env→run() test, and how the defer/banner branches are + // covered instead: + // + // `vm.setEnv` mutates PROCESS-global env that forge does NOT isolate between the + // (concurrently-scheduled) test functions of one contract. A SECOND env→run() + // test that set the host keys to DIFFERENT values than this one would race: its + // `vm.setEnv("ZERO_BORROWER_OPERATIONS_HOST", …)` can land between this test's set + // and `run()`'s read (empirically reproduced — a leaked non-defer host makes the + // C2 wiring check revert on a host this test never wired). So exactly ONE env→run + // test lives here (the clean unqualified-PASS path above), pinning the artifact + + // env + banner wiring end to end. + // + // The remaining C1/G5R2-02 branches are pinned DETERMINISTICALLY on in-memory + // addresses via the harness (NO env, race-free): + // • the REQUIRED-read revert-on-unset (non-defer leg of _readHostEnv) rests on + // forge's `vm.envAddress` cheatcode contract (reverts on unset/typo); + // • the defer leg of _readHostEnv (returns zero when VERIFY_DEFER_HOSTS=true) + // is covered via the test_resolveHosts_defers_* cases below; + // • the require / defer-warning / duplicate-host branches of _resolveHosts → + // the test_resolveHosts_* suite; + // • the empty-list vacuous-PASS refusal + defer-both pass → the + // test_verify_*_empty_host_list_* tests; + // • BOTH banner legs (unqualified + the G5R2-02 qualified/warned DOWNGRADE) → + // test_reportPass_unqualified_* / test_reportPass_qualified_* (which drive the + // exact `_reportPass(anyHostDeferred)` the run() path calls, keyed off the + // resolved fact `hosts.length < 2` that the run() body computes). + + // ─── Property: gate passes IFF ALL invariants hold; each specific unmet + // invariant maps to its specific revert reason, checked in gate order. ─── + + function testFuzz_verify_gate_and_reason(uint32 globalDelay, bool sameAdmin) public { + address ctrlAdmin = sameAdmin ? GUARDIAN : OTHER_ADMIN; + _configController(ctrlAdmin, globalDelay); + _handToGovernance(); // ownership + wiring always correct here + + // Guardian is checked first, then floor. Ownership/wiring are correct, so + // the outcome is fully determined by (sameAdmin, globalDelay). + if (!sameAdmin) { + vm.expectRevert( + bytes( + "single guardian violated: controller.admin() != queue.admin() -- single guardian violated" + ) + ); + _verify(); + } else if (globalDelay == 0) { + vm.expectRevert( + bytes( + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ) + ); + _verify(); + } else if (globalDelay < FLOOR) { + vm.expectRevert( + bytes( + "sub-floor delay: controller.globalDelaySeconds() < queue.minimumDelaySeconds() -- sub-floor delay self-bricks exits" + ) + ); + _verify(); + } else { + _verify(); // passes + } + } +} diff --git a/tools/check-abi-equivalence.sh b/tools/check-abi-equivalence.sh index 6e8a865..7ad5a5e 100755 --- a/tools/check-abi-equivalence.sh +++ b/tools/check-abi-equivalence.sh @@ -2,18 +2,38 @@ # # Cross-pragma ABI guard for the ExitFeeController interface. # -# Two files declare the SAME ABI surface: +# Two files declare the ExitFeeController ABI surface: # src/interfaces/IExitFeeController.sol (range pragma >=0.5.17 <0.9.0) # src/interfaces/v0_4/IExitFeeController.sol (0.4.26 outlier, AMM) # +# ── Fee surface vs delay extension ─────────────────────────── +# The unified file carries TWO surfaces: +# * the FEE surface (RatePolicy / ExitFeeQuote / quoteExitFee / policy +# setters + views + events) — consumed under 0.5.17 (Sovryn-smart), +# 0.6.11 (zero), 0.8.20 (this repo) AND 0.4.26 (AMM); and +# * the DELAY extension (securityPerimeterEnabled / globalDelaySeconds / +# admin / bypass tiers / passthrough registry / quoteExitDelay*), +# added by the Security-Perimeter delay feature. +# The AMM (0.4.26) is DEFERRED and "swaps are never delayed" (spec), so it +# never consumes the delay extension. The `v0_4/` outlier is therefore +# intentionally FEE-ONLY (26 members). The guard's job is to protect the +# fee surface the AMM actually calls — NOT to force ~20 dead delay members +# into a 0.4.26 interface. Check 1 below is accordingly a SUBSET relation: +# every v0_4 member MUST appear byte-for-byte in the unified ABI (fee surface +# matches exactly across pragmas); the unified file MAY carry additional +# (delay) members the outlier omits. A fee-side struct reorder or signature +# drift still fails (the reordered member no longer matches its v0_4 twin). +# # This script enforces equivalence on three axes that a casual # `forge inspect ... methodIdentifiers` diff would NOT catch: # -# 1. Full ABI shape: function inputs/outputs (including struct tuples), -# event `indexed` flags, `stateMutability`, `anonymous`, and error -# signatures. A struct field reorder inside `ExitFeeQuote` would keep -# function selectors identical (selectors only hash inputs) but break -# callers decoding the returned tuple. +# 1. Fee-surface ABI shape (SUBSET): every v0_4 member — function +# inputs/outputs (including struct tuples), event `indexed` flags, +# `stateMutability`, `anonymous`, and error signatures — must appear +# identically in the unified ABI. A struct field reorder inside +# `ExitFeeQuote` would keep function selectors identical (selectors +# only hash inputs) but break callers decoding the returned tuple, and +# it breaks the subset match here. # # 2. SkipReason enum ordinal order. The ABI encodes the enum as uint8, # so `forge inspect` cannot see a reorder. Synthesized skip-reason @@ -74,16 +94,34 @@ normalize_abi() { )' } -echo "── 1) Full ABI equivalence (inputs/outputs/indexed/stateMutability) ──" -if ! diff -u \ - <(forge inspect "$UNIFIED_FQ" abi --json | normalize_abi) \ - <(forge inspect "$V0_4_FQ" abi --json | normalize_abi); then - echo " error: ABIs differ — see diff above." >&2 - echo " Catches: function/event/error signatures, struct tuple" >&2 - echo " component order, indexed flags, stateMutability, anonymous." >&2 +echo "── 1) Fee-surface ABI subset (v0_4 ⊆ unified; exact match per member) ──" +# The v0_4 outlier is the fee-only reference surface. Every one of its +# normalized ABI members MUST appear byte-for-byte in the unified ABI. The +# unified ABI may carry extra (delay) members the outlier omits — those are +# NOT required in v0_4 (AMM never delays). jq computes `v0_4 − unified` +# (set difference on normalized members): a non-empty result is a fee-surface +# mismatch (drift or a struct reorder) and fails the gate. +UNIFIED_ABI=$(forge inspect "$UNIFIED_FQ" abi --json | normalize_abi) +V0_4_ABI=$(forge inspect "$V0_4_FQ" abi --json | normalize_abi) + +MISSING=$(jq -n \ + --argjson u "$UNIFIED_ABI" \ + --argjson v "$V0_4_ABI" \ + '$v | map(select( . as $x | ($u | any(. == $x)) | not ))') + +if [ "$(echo "$MISSING" | jq 'length')" != "0" ]; then + echo " error: v0_4 fee-surface members absent from (or divergent in) the unified ABI:" >&2 + echo "$MISSING" | jq -r '.[] | " \(.type) \(.name // "(anonymous)")"' >&2 + echo " Catches: fee function/event/error signature drift, struct tuple" >&2 + echo " component order, indexed flags, stateMutability, anonymous —" >&2 + echo " on the surface the 0.4.26 AMM actually consumes." >&2 + echo " (Delay-extension members legitimately live ONLY in the unified" >&2 + echo " file; they are NOT required in the v0_4 outlier )" >&2 exit 1 fi -echo " ok" +V0_4_COUNT=$(echo "$V0_4_ABI" | jq 'length') +UNIFIED_COUNT=$(echo "$UNIFIED_ABI" | jq 'length') +echo " ok ($V0_4_COUNT/$V0_4_COUNT v0_4 fee members matched; unified carries $UNIFIED_COUNT total)" # ─── 2) SkipReason enum ordinal equivalence ─────────────────────────────── # The ABI shows uint8 only; `forge inspect` cannot see the enum variant order. diff --git a/tools/diff-storage-layouts.py b/tools/diff-storage-layouts.py index 340fe03..7e71969 100755 --- a/tools/diff-storage-layouts.py +++ b/tools/diff-storage-layouts.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Storage-layout upgrade-compatibility check for the ColFee proxies. +Storage-layout upgrade-compatibility check for the Perimeter proxies. Usage: diff-storage-layouts.py diff --git a/tools/verify-proxy-fullmatch.sh b/tools/verify-proxy-fullmatch.sh new file mode 100755 index 0000000..74f6ccf --- /dev/null +++ b/tools/verify-proxy-fullmatch.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Attempt a FULL-match verification of an already-verified ERC1967Proxy. +# +# Blockscout matches a standard OpenZeppelin proxy from its own bytecode +# database, which yields a PARTIAL match: the runtime code matches but the +# metadata hash comes from someone else's compilation, not ours. This asks the +# explorer to re-verify against our exact build so the match becomes full. +# +# Read-only with respect to the chain: verification submits source, never a +# transaction. Safe to re-run; an "already verified" reply is not an error. +# +# tools/verify-proxy-fullmatch.sh +set -uo pipefail + +PROXY=${1:?proxy address required} +IMPL=${2:?implementation address required} +OWNER=${3:?initial owner (the address baked into initialize) required} +SRC="lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy" +VERIFIER_URL=${BLOCKSCOUT_URL:-https://rootstock.blockscout.com/api} + +INIT=$(cast calldata "initialize(address)" "$OWNER") +ARGS=$(cast abi-encode "constructor(address,bytes)" "$IMPL" "$INIT") + +echo "proxy: $PROXY" +echo "impl: $IMPL" +echo "owner: $OWNER" +echo "args: ${ARGS:0:42}..." +echo + +forge verify-contract "$PROXY" "$SRC" \ + --chain 30 \ + --verifier blockscout \ + --verifier-url "$VERIFIER_URL" \ + --constructor-args "$ARGS" \ + --watch