From f92ddd2e9c7d6858d724473aa41febfd31549d25 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 3 Aug 2026 00:17:37 +0000 Subject: [PATCH] feat: owned handler variants and mainnet/Gnosis deployments `OrderDescriptor` and `OrderModule` each inherited `BaseConditionalOrder`, so a handler reached that base twice the moment it wanted both, and Solidity then demanded an explicit override of every inherited function. The mixins now inherit only their own interface, and a handler composes them with `BaseConditionalOrder` directly. The two differ in what they commit to and in nothing else, so the commitment itself moves to a `Commitment` library: the `{uris, digest, kind}` struct, the three URI invariants, validation, and the advertised predicate. Each mixin holds its own `Commitment.Data`, so the two occupy distinct slots and are set independently while sharing one implementation. The three paired errors collapse to one set; which surface rejected a write is evident from the function called. Each mixin file then holds two contracts: the read-only one, which is what the existing handlers use, and an owned extension that adds a setter behind `onlyOwner`. Ownership is Solady's `Ownable`. A two-step handover earns its place: the commitment is the only mutable state here, and a mistyped transfer would strand it. Solady inverts the direction relative to OpenZeppelin's `Ownable2Step`, in that the recipient requests and the owner completes, which is the stronger of the two since control can only move to an address that has proven it holds the key. Holding the commitment in `internal` storage rather than immutables is what makes the extension free. The setter writes the slots the read-only accessors already read, so the owned variants override nothing: `OwnedTWAP` carries a constructor and one `supportsInterface`. The previous shape needed three delegating accessor overrides per variant. `OwnedTWAP`, `OwnedStopLoss` and `OwnedGoodAfterTime` extend the handler they are named for, so order generation is the same code, and one owner governs both commitments. Together this is 346 lines to 257, with the duplicated half of two files replaced by one shared implementation. Both surfaces now take `Commitment.Data` rather than three loose values. A handler previously took `(string[], bytes32, PackageKind)` and a rotatable one passed two identical triples to two different bases, with nothing at the call site saying which was the descriptor and which the module. The parameter is now named for its surface, and `Commitment.none()` states that a surface is uncommitted rather than leaving an arbitrary `PackageKind` beside a zero digest to be read as meaningful. `deploy_OwnedStack` holds both deployments over one shared base. `DeployMainnetStack` deploys the registry and TWAP only; `DeployGnosisStack` adds `StopLoss` and `GoodAfterTime`. Each pins its chain id, so a stale RPC URL cannot point it at the wrong network. Preflight also rejects a `SETTLEMENT` with no code, which is the wrong address or the wrong chain and would leave every order unsettleable, and a zero `ADMIN`, which would strand both commitments with no key able to call `setDescriptor`. The checks are split from the environment read so they are testable without `vm.setEnv`, which writes the host process environment and is not rolled back between tests. All three are mutation-checked. Deployment is CREATE2, so the registry and the handlers land on the same addresses on every chain. Nothing in the initcode is chain-specific: `GPv2Settlement` is at one address on every chain CoW deploys to, so the registry's constructor argument does not vary, which fixes the registry address, which in turn fixes each handler's constructor argument. The registry's chain-specific part, the settlement domain separator, is read in the constructor and held as an immutable, so it reaches the deployed code without reaching the address. That is available to these handlers precisely because they deploy uncommitted. On the immutable handlers the descriptor digest is a constructor argument, so the address would depend on a descriptor that cannot be built until the address is known; the `Owned*` variants set theirs afterwards, which breaks the cycle. `predict` returns every address without deploying, and each deployment returns what is already there rather than reverting, so re-running across a set of chains converges instead of failing on the second attempt. `ComposableCow` takes no commitment mixin; it has no descriptor. Descriptor documents regenerate byte-identically. Nothing is deployed by this commit. --- .env.example | 3 + .gas-snapshot | 88 ++++++---- README.md | 2 +- script/deploy_AnvilStack.s.sol | 7 +- script/deploy_OrderTypes.s.sol | 7 +- script/deploy_OwnedStack.s.sol | 259 +++++++++++++++++++++++++++++ script/deploy_ProdStack.s.sol | 9 +- src/OrderDescriptor.sol | 96 +++++------ src/OrderModule.sol | 88 +++++----- src/libraries/Commitment.sol | 66 ++++++++ src/types/GoodAfterTime.sol | 18 +- src/types/Owned.sol | 58 +++++++ src/types/PerpetualStableSwap.sol | 18 +- src/types/StopLoss.sol | 18 +- src/types/twap/TWAP.sol | 21 ++- test/ComposableCow.base.t.sol | 7 +- test/ComposableCow.discovery.t.sol | 48 ++++-- test/ComposableCow.gat.t.sol | 5 +- test/ComposableCow.manifest.t.sol | 5 +- test/ComposableCow.owned.t.sol | 176 ++++++++++++++++++++ test/ComposableCow.stoploss.t.sol | 5 +- test/ComposableCow.twap.t.sol | 6 +- test/DeployStack.t.sol | 246 +++++++++++++++++++++++++++ 23 files changed, 1070 insertions(+), 186 deletions(-) create mode 100644 script/deploy_OwnedStack.s.sol create mode 100644 src/libraries/Commitment.sol create mode 100644 src/types/Owned.sol create mode 100644 test/ComposableCow.owned.t.sol create mode 100644 test/DeployStack.t.sol diff --git a/.env.example b/.env.example index 7498114..5e6ebc3 100644 --- a/.env.example +++ b/.env.example @@ -15,5 +15,8 @@ SETTLEMENT=0x9008D19f58AAbD9eD0D60971565AA8510560ab41 # SAFE= # TWAP= +# Owner of the rotatable handlers, read by deploy_GnosisStack. +# ADMIN= + # Read by dev/verify-contracts.sh, and by forge script --verify. # ETHERSCAN_API_KEY= diff --git a/.gas-snapshot b/.gas-snapshot index 737a38e..3e3632a 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -8,36 +8,36 @@ ComposableCowDescriptorDocTest:test_descriptor_SelectorMatchesDeclaredName() (ga ComposableCowDescriptorDocTest:test_descriptor_SelectorsAreDistinct() (gas: 163733) ComposableCowDiscoveryTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11305) ComposableCowDiscoveryTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18325) -ComposableCowDiscoveryTest:test_descriptor_CommittedAdvertisesAndRoundTrips() (gas: 2033244) -ComposableCowDiscoveryTest:test_descriptor_ConstructorEmitsUpdate() (gas: 2027846) -ComposableCowDiscoveryTest:test_descriptor_UncommittedDoesNotAdvertise() (gas: 1931847) -ComposableCowDiscoveryTest:test_module_CommittedAdvertisesAndRoundTrips() (gas: 1498629) -ComposableCowDiscoveryTest:test_module_ContentAddressedNeedsNoURI() (gas: 1398108) -ComposableCowDiscoveryTest:test_module_NeedsInputSignal() (gas: 1405789) -ComposableCowDiscoveryTest:test_module_RevertsContentAddressedWithURI() (gas: 39723) -ComposableCowDiscoveryTest:test_module_RevertsSha256WithoutURI() (gas: 38899) -ComposableCowDiscoveryTest:test_module_RevertsUncommittedURI() (gas: 40273) -ComposableCowDiscoveryTest:test_module_UncommittedDoesNotAdvertise() (gas: 1395475) +ComposableCowDiscoveryTest:test_descriptor_CommittedAdvertisesAndRoundTrips() (gas: 2061293) +ComposableCowDiscoveryTest:test_descriptor_ConstructorEmitsUpdate() (gas: 2055583) +ComposableCowDiscoveryTest:test_descriptor_UncommittedDoesNotAdvertise() (gas: 1919984) +ComposableCowDiscoveryTest:test_module_CommittedAdvertisesAndRoundTrips() (gas: 1526676) +ComposableCowDiscoveryTest:test_module_ContentAddressedNeedsNoURI() (gas: 1406046) +ComposableCowDiscoveryTest:test_module_NeedsInputSignal() (gas: 1413627) +ComposableCowDiscoveryTest:test_module_RevertsContentAddressedWithURI() (gas: 40179) +ComposableCowDiscoveryTest:test_module_RevertsSha256WithoutURI() (gas: 39351) +ComposableCowDiscoveryTest:test_module_RevertsUncommittedURI() (gas: 40730) +ComposableCowDiscoveryTest:test_module_UncommittedDoesNotAdvertise() (gas: 1383610) ComposableCowForwarderTest:test_ERC1271Forwarder_isValidSignature_RevertsOnBadHash() (gas: 630770) ComposableCowForwarderTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11443) ComposableCowForwarderTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18481) ComposableCowGatTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11487) ComposableCowGatTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18657) -ComposableCowGatTest:test_generateOrder_FuzzContext(address,address,address,uint256,uint256,uint256,uint256,bool) (runs: 256, μ: 121422, ~: 121420) +ComposableCowGatTest:test_generateOrder_FuzzContext(address,address,address,uint256,uint256,uint256,uint256,bool) (runs: 256, μ: 121422, ~: 121422) ComposableCowGatTest:test_generateOrder_FuzzRevertBeforeStartTime(uint256,uint256) (runs: 256, μ: 21804, ~: 21804) -ComposableCowGatTest:test_generateOrder_FuzzRevertBelowMinBalance(uint256,uint256) (runs: 256, μ: 114446, ~: 114454) -ComposableCowGatTest:test_generateOrder_FuzzRevertTooLowOutput(uint256,uint256,uint256) (runs: 256, μ: 124256, ~: 124320) +ComposableCowGatTest:test_generateOrder_FuzzRevertBelowMinBalance(uint256,uint256) (runs: 256, μ: 114446, ~: 114453) +ComposableCowGatTest:test_generateOrder_FuzzRevertTooLowOutput(uint256,uint256,uint256) (runs: 256, μ: 124242, ~: 124309) ComposableCowGatTest:test_generateOrder_RevertZeroAmount() (gas: 113369) -ComposableCowGatTest:test_generateOrder_e2e_Fuzz(uint256,uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 299418, ~: 299344) -ComposableCowGatTest:test_generateOrder_e2e_FuzzWithPriceChecker(uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 308759, ~: 308596) +ComposableCowGatTest:test_generateOrder_e2e_Fuzz(uint256,uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 299402, ~: 299342) +ComposableCowGatTest:test_generateOrder_e2e_FuzzWithPriceChecker(uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 308748, ~: 308594) ComposableCowGatTest:test_pollHints_SingleShot() (gas: 16098) ComposableCowGatTest:test_settle_e2e() (gas: 491417) -ComposableCowGatTest:test_verify_e2e_fuzz(uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 137200, ~: 137037) +ComposableCowGatTest:test_verify_e2e_fuzz(uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 137188, ~: 137035) ComposableCowGuardsTest:test_BaseSwapGuard_supportsInterface() (gas: 138710) ComposableCowGuardsTest:test_ReceiverLock_verify_FuzzRevertsWhenReceiverNotSelf(address) (runs: 256, μ: 299499, ~: 299499) ComposableCowGuardsTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11487) ComposableCowGuardsTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18546) -ComposableCowGuardsTest:test_setSwapGuard_FuzzSetAndEmit(address,address) (runs: 256, μ: 31254, ~: 31236) +ComposableCowGuardsTest:test_setSwapGuard_FuzzSetAndEmit(address,address) (runs: 256, μ: 31255, ~: 31236) ComposableCowGuardsTest:test_setSwapGuard_e2e() (gas: 1030509) ComposableCowManifestTest:test_PSS_ManifestPage_NotFundedCarriesStatus() (gas: 37596) ComposableCowManifestTest:test_PSS_ManifestPage_WithBalance() (gas: 125366) @@ -60,6 +60,16 @@ ComposableCowManifestTest:test_manifestPage_EmptyPageCarriesWaitReason() (gas: 1 ComposableCowManifestTest:test_manifestPage_OutOfRangeTerminates() (gas: 1545516) ComposableCowManifestTest:test_manifest_DoesNotPerturbGeneratorInterfaceId() (gas: 855) ComposableCowManifestTest:test_manifest_SupportsInterface() (gas: 1538778) +ComposableCowOwnedTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11305) +ComposableCowOwnedTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18259) +ComposableCowOwnedTest:test_owned_EnforcesURIInvariants() (gas: 25494) +ComposableCowOwnedTest:test_owned_OrderGenerationIsUnchanged() (gas: 1937389) +ComposableCowOwnedTest:test_owned_RevertsForStranger() (gas: 18066) +ComposableCowOwnedTest:test_owned_RotationReplacesCommitment() (gas: 116398) +ComposableCowOwnedTest:test_owned_SetDescriptorAdvertisesAndRoundTrips() (gas: 49354) +ComposableCowOwnedTest:test_owned_SetModuleIsIndependentOfDescriptor() (gas: 156075) +ComposableCowOwnedTest:test_owned_TransferIsTwoStep() (gas: 40006) +ComposableCowOwnedTest:test_owned_UncommittedOnDeploy() (gas: 21451) ComposableCowPollTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11531) ComposableCowPollTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 19119) ComposableCowPollTest:test_checkOrder_ComposesFillOverlay() (gas: 1833891) @@ -80,9 +90,9 @@ ComposableCowPollTest:test_poll_DecodesPollNeedsOffchainInput() (gas: 1402236) ComposableCowPollTest:test_poll_DecodesPollTryAtBlock() (gas: 1330995) ComposableCowPollTest:test_poll_DecodesPollTryAtTimestamp() (gas: 1330907) ComposableCowPollTest:test_poll_DecodesPollTryNextBlock() (gas: 1290285) -ComposableCowPollTest:test_poll_FuzzOrderNotValid(bytes4) (runs: 256, μ: 1283632, ~: 1291095) -ComposableCowPollTest:test_poll_FuzzPollTryAtBlock(uint256,bytes4) (runs: 256, μ: 1323738, ~: 1330890) -ComposableCowPollTest:test_poll_FuzzPollTryAtTimestamp(uint256,bytes4) (runs: 256, μ: 1324266, ~: 1331418) +ComposableCowPollTest:test_poll_FuzzOrderNotValid(bytes4) (runs: 256, μ: 1283943, ~: 1291095) +ComposableCowPollTest:test_poll_FuzzPollTryAtBlock(uint256,bytes4) (runs: 256, μ: 1323272, ~: 1330890) +ComposableCowPollTest:test_poll_FuzzPollTryAtTimestamp(uint256,bytes4) (runs: 256, μ: 1323800, ~: 1331418) ComposableCowPollTest:test_poll_NeedsInputHandlerPostsWithInput() (gas: 1404879) ComposableCowPollTest:test_poll_PanicMapsToTryNextBlock() (gas: 1294536) ComposableCowPollTest:test_poll_ReturnsPostOnValidOrder() (gas: 1763768) @@ -107,7 +117,7 @@ ComposableCowProofTest:test_setRoot_RevertsZeroRootWithBlobs() (gas: 13434) ComposableCowProofTest:test_setRoot_RevertsZeroRootWithUris() (gas: 12784) ComposableCowProofTest:test_setRoot_ZeroRootClears() (gas: 32934) ComposableCowStopLossTest:test_OracleNormalisesPrice_concrete() (gas: 27435) -ComposableCowStopLossTest:test_OracleNormalisesPrice_fuzz(uint8,uint8,uint8,uint8) (runs: 256, μ: 29593, ~: 29613) +ComposableCowStopLossTest:test_OracleNormalisesPrice_fuzz(uint8,uint8,uint8,uint8) (runs: 256, μ: 29592, ~: 29613) ComposableCowStopLossTest:test_OracleRevertOnExpiredOrder_fuzz(uint32,uint32) (runs: 256, μ: 23185, ~: 23185) ComposableCowStopLossTest:test_OracleRevertOnInvalidPrice_fuzz(int256,int256) (runs: 256, μ: 36635, ~: 36635) ComposableCowStopLossTest:test_OracleRevertOnStalePrice_fuzz(uint256,uint256,uint256) (runs: 256, μ: 25348, ~: 25348) @@ -118,15 +128,15 @@ ComposableCowStopLossTest:test_generateOrder_RevertZeroAmount() (gas: 22286) ComposableCowStopLossTest:test_pollHints_SingleShot() (gas: 15780) ComposableCowStopLossTest:test_strikePriceMet_fuzz(int256,int256,int256,uint32) (runs: 256, μ: 29077, ~: 29077) ComposableCowStopLossTest:test_strikePriceNotMet_concrete() (gas: 26555) -ComposableCowTatTest:test_BalanceMet_fuzz(address,uint256,bytes32,uint256) (runs: 256, μ: 113402, ~: 113386) +ComposableCowTatTest:test_BalanceMet_fuzz(address,uint256,bytes32,uint256) (runs: 256, μ: 113402, ~: 113384) ComposableCowTatTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11283) ComposableCowTatTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18177) -ComposableCowTatTest:test_generateOrder_FuzzRevertBelowThreshold(uint256,uint256) (runs: 256, μ: 111617, ~: 111620) +ComposableCowTatTest:test_generateOrder_FuzzRevertBelowThreshold(uint256,uint256) (runs: 256, μ: 111619, ~: 111626) ComposableCowTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11575) ComposableCowTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18877) -ComposableCowTest:test_createAndRemove_FuzzSetAndEmit(address,address,bytes32,bytes) (runs: 256, μ: 39997, ~: 39883) +ComposableCowTest:test_createAndRemove_FuzzSetAndEmit(address,address,bytes32,bytes) (runs: 256, μ: 39999, ~: 39883) ComposableCowTest:test_createAndRemove_e2e() (gas: 468135) -ComposableCowTest:test_createWithContextAndRemove_FuzzSetAndEmit(address,address,bytes32,bytes,bytes32) (runs: 256, μ: 63661, ~: 63592) +ComposableCowTest:test_createWithContextAndRemove_FuzzSetAndEmit(address,address,bytes32,bytes,bytes32) (runs: 256, μ: 63662, ~: 63596) ComposableCowTest:test_create_RevertOnInvalidHandler() (gas: 10433) ComposableCowTest:test_getTradeableOrderWithSignature_FuzzRevertInvalidProof(address,bytes32[],bytes32,address,bytes32,bytes) (runs: 256, μ: 97007, ~: 96804) ComposableCowTest:test_getTradeableOrderWithSignature_FuzzRevertInvalidSingleOrder(address,address,bytes32,bytes) (runs: 256, μ: 18659, ~: 18642) @@ -135,25 +145,25 @@ ComposableCowTest:test_getTradeableOrderWithSignature_ReturnsValidPayloadForSafe ComposableCowTest:test_getTradeableOrderWithSignature_RevertInterfaceNotSupported() (gas: 52457) ComposableCowTest:test_isValidSafeSignature_BaseConditionalOrder_RevertOnInvalidHash() (gas: 60746) ComposableCowTest:test_isValidSafeSignature_FuzzPassesContextToHandler(address,bytes32) (runs: 256, μ: 57671, ~: 57671) -ComposableCowTest:test_isValidSafeSignature_FuzzRevertInvalidProof(address,bytes32[],bytes32,address,bytes32,bytes) (runs: 256, μ: 98629, ~: 98333) -ComposableCowTest:test_isValidSafeSignature_FuzzRevertInvalidSingleOrder(address,address,bytes32,bytes) (runs: 256, μ: 18637, ~: 18615) +ComposableCowTest:test_isValidSafeSignature_FuzzRevertInvalidProof(address,bytes32[],bytes32,address,bytes32,bytes) (runs: 256, μ: 98630, ~: 98333) +ComposableCowTest:test_isValidSafeSignature_FuzzRevertInvalidSingleOrder(address,address,bytes32,bytes) (runs: 256, μ: 18638, ~: 18615) ComposableCowTest:test_remove_EmitsConditionalOrderRemoved() (gas: 36281) ComposableCowTest:test_remove_FuzzEmitsEvent(address,bytes32) (runs: 256, μ: 31569, ~: 31544) ComposableCowTest:test_safeSignaturePayload_SelectorMatchesMuxerMagicValue() (gas: 1145) ComposableCowTest:test_setRootWithContext_FuzzSetAndEmit(address,bytes32,bytes32) (runs: 256, μ: 73696, ~: 73774) ComposableCowTest:test_setRootWithContext_e2e() (gas: 13599638) -ComposableCowTest:test_setRoot_FuzzSetAndEmit(address,bytes32) (runs: 256, μ: 41952, ~: 42105) +ComposableCowTest:test_setRoot_FuzzSetAndEmit(address,bytes32) (runs: 256, μ: 42028, ~: 42105) ComposableCowTest:test_setRoot_e2e() (gas: 13568277) ComposableCowTwapTest:test_SetUpState_ComposableCowDomainSeparator_is_set() (gas: 11488) ComposableCowTwapTest:test_SetUpState_ComposableCowDomainVerifier_is_set() (gas: 18987) -ComposableCowTwapTest:test_TWAPOrderMathLib_calculateValidTo(uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 12642, ~: 12389) +ComposableCowTwapTest:test_TWAPOrderMathLib_calculateValidTo(uint256,uint256,uint256,uint256,uint256) (runs: 256, μ: 12636, ~: 12389) ComposableCowTwapTest:test_describeOrder_RevertOnZeroFrequency() (gas: 21049) ComposableCowTwapTest:test_describeOrder_TwapParts() (gas: 29107) ComposableCowTwapTest:test_generateOrder_FuzzRevertIfBeforeStart(uint256,uint256) (runs: 256, μ: 29035, ~: 29035) ComposableCowTwapTest:test_generateOrder_FuzzRevertIfExpired(uint256,uint256) (runs: 256, μ: 30257, ~: 30108) ComposableCowTwapTest:test_generateOrder_FuzzRevertIfOrderAfterBlocktimestampValidity(uint256,uint256) (runs: 256, μ: 186801, ~: 187176) ComposableCowTwapTest:test_generateOrder_FuzzRevertIfOrderBeforeBlockTimestamp(uint256,uint256) (runs: 256, μ: 187290, ~: 187290) -ComposableCowTwapTest:test_generateOrder_FuzzRevertIfOutsideSpan(uint256,uint256) (runs: 256, μ: 32236, ~: 32137) +ComposableCowTwapTest:test_generateOrder_FuzzRevertIfOutsideSpan(uint256,uint256) (runs: 256, μ: 32285, ~: 32137) ComposableCowTwapTest:test_generateOrder_FuzzRevertOnInvalidFrequency(uint256) (runs: 256, μ: 19744, ~: 19747) ComposableCowTwapTest:test_generateOrder_FuzzRevertOnInvalidNumParts(uint256) (runs: 256, μ: 19601, ~: 19605) ComposableCowTwapTest:test_generateOrder_FuzzRevertOnInvalidSpan(uint256,uint256) (runs: 256, μ: 20164, ~: 20164) @@ -169,5 +179,19 @@ ComposableCowTwapTest:test_getNextPollTimestamp_PointsAtNextPart() (gas: 20153) ComposableCowTwapTest:test_getNextPollTimestamp_RevertOnZeroFrequency() (gas: 19285) ComposableCowTwapTest:test_getNextPollTimestamp_RevertOnZeroNumParts() (gas: 19451) ComposableCowTwapTest:test_settle_e2e() (gas: 13514423) -ComposableCowTwapTest:test_simulate_fuzz(uint32,uint32,uint32) (runs: 256, μ: 20619419, ~: 21359116) -ComposableCowTwapTest:test_verify_e2e_fuzz(uint256,uint256) (runs: 256, μ: 36221, ~: 35995) \ No newline at end of file +ComposableCowTwapTest:test_simulate_fuzz(uint32,uint32,uint32) (runs: 256, μ: 20318564, ~: 20794728) +ComposableCowTwapTest:test_verify_e2e_fuzz(uint256,uint256) (runs: 256, μ: 36221, ~: 35995) +DeployAddressTest:test_predict_AdminMovesHandlersOnly() (gas: 125019) +DeployAddressTest:test_predict_IsTheSameOnEveryChain() (gas: 185119) +DeployAddressTest:test_predict_MatchesCreate2() (gas: 73174) +DeployAddressTest:test_predict_TwapEncodesTheRegistryNotTheSettlement() (gas: 94443) +DeployAddressTest:test_predict_TwapFollowsTheRegistry() (gas: 124229) +DeployStackTest:test_validate_AcceptsAValidConfig() (gas: 12459) +DeployStackTest:test_validate_RejectsCodelessSettlement() (gas: 13780) +DeployStackTest:test_validate_RejectsWrongChain() (gas: 13497) +DeployStackTest:test_validate_RejectsZeroOwner() (gas: 13402) +DescriptorPublishTest:test_descriptor_DigestIsTheDocumentsSha256() (gas: 22715) +DescriptorPublishTest:test_descriptor_OptionalWhenUriUnset() (gas: 7536) +DescriptorPublishTest:test_descriptor_RejectsANonHttpsUri() (gas: 13730) +DescriptorPublishTest:test_descriptor_RequiresTheOwnerKey() (gas: 12366) +DescriptorPublishTest:test_isHttps() (gas: 13639) \ No newline at end of file diff --git a/README.md b/README.md index 4037cae..3dcd185 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ The following audits cover the **upstream** contracts before the changes in this ### Environment setup -Copy `.env.example` to `.env`. Every script reads `PRIVATE_KEY`; `SETTLEMENT` is needed to deploy `ComposableCow`, `COMPOSABLE_COW` to deploy order types against it, and `SAFE` plus `TWAP` to submit a single order. Contract verification reads `ETHERSCAN_API_KEY`. The RPC endpoint is passed per command with `--rpc-url`. +Copy `.env.example` to `.env`. Every script reads `PRIVATE_KEY`; `SETTLEMENT` is needed to deploy `ComposableCow`, `COMPOSABLE_COW` to deploy order types against it, and `SAFE` plus `TWAP` to submit a single order. The Gnosis integration deployment also reads `ADMIN`. Contract verification reads `ETHERSCAN_API_KEY`. The RPC endpoint is passed per command with `--rpc-url`. ### Testing diff --git a/script/deploy_AnvilStack.s.sol b/script/deploy_AnvilStack.s.sol index 9dac18e..8a55f9c 100644 --- a/script/deploy_AnvilStack.s.sol +++ b/script/deploy_AnvilStack.s.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; import {Script, console} from "forge-std/Script.sol"; // CoW Protocol @@ -62,9 +63,9 @@ contract DeployAnvilStack is Script { // deploy the Composable CoW ComposableCow composableCow = new ComposableCow(address(settlement)); - new TWAP(composableCow, new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new GoodAfterTime(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new PerpetualStableSwap(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + new TWAP(composableCow, Commitment.none()); + new GoodAfterTime(Commitment.none()); + new PerpetualStableSwap(Commitment.none()); new TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_OrderTypes.s.sol b/script/deploy_OrderTypes.s.sol index 9fbab50..6c52820 100644 --- a/script/deploy_OrderTypes.s.sol +++ b/script/deploy_OrderTypes.s.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; import {Script} from "forge-std/Script.sol"; import {ComposableCow} from "../src/ComposableCow.sol"; @@ -17,9 +18,9 @@ contract DeployOrderTypes is Script { address composableCow = vm.envAddress("COMPOSABLE_COW"); vm.startBroadcast(deployerPrivateKey); - new TWAP(ComposableCow(composableCow), new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new GoodAfterTime(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new PerpetualStableSwap(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + new TWAP(ComposableCow(composableCow), Commitment.none()); + new GoodAfterTime(Commitment.none()); + new PerpetualStableSwap(Commitment.none()); new TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_OwnedStack.s.sol b/script/deploy_OwnedStack.s.sol new file mode 100644 index 0000000..6b12c8a --- /dev/null +++ b/script/deploy_OwnedStack.s.sol @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import "forge-std/Script.sol"; + +import {ComposableCow} from "../src/ComposableCow.sol"; +import {Commitment} from "../src/libraries/Commitment.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; +import {OwnedGoodAfterTime, OwnedStopLoss, OwnedTWAP} from "../src/types/Owned.sol"; + +/** + * @title Rotatable-handler deployments + * @dev Deploys the registry and rotatable handlers at the same addresses on + * every chain, via CREATE2 through the canonical factory. + * + * That works here because nothing in the initcode is chain-specific. + * `GPv2Settlement` is at one address on every chain CoW deploys to, so the + * registry's constructor argument does not vary; the registry's address + * therefore does not vary either, which in turn fixes each handler's + * constructor argument. The chain-specific part of the registry, the + * settlement domain separator, is read in the constructor and held as an + * immutable, so it lands in the deployed code without touching the address. + * + * Two preconditions come with that, and neither is enforceable from here: + * `SETTLEMENT` must be the same address used on every other chain, and + * `ADMIN` must be too, or the handlers diverge while the registry does not. + * Run `predict` first and compare against a chain already deployed to. + * + * CREATE2 is available to these handlers precisely because they deploy + * uncommitted. On the immutable handlers the descriptor digest is a + * constructor argument, so the address would depend on a descriptor that + * cannot be built until the address is known. The `Owned*` variants set + * their descriptor afterwards with `setDescriptor`, which breaks the cycle. + * + * `ComposableCow` takes no commitment mixin: it has no descriptor. + * + * Requires `SETTLEMENT` and `ADMIN`, plus a signer: either + * `PRIVATE_KEY` in the environment or `--account`/`--keystore`. + */ +abstract contract DeployOwnedStack is Script { + bytes32 internal constant SALT = bytes32(0); + + /// @dev A stale `ETH_RPC_URL` pointing at the wrong network. + error WrongChain(uint256 expected, uint256 actual); + + /// @dev `SETTLEMENT` has no code here, so it is the wrong address or the + /// wrong chain. Every order would be unsettleable. + error SettlementHasNoCode(address settlement); + + /// @dev A zero owner would strand both commitments permanently: the handler + /// would deploy uncommitted with no key able to call `setDescriptor`. + error OwnerIsZero(); + + /// @dev A deployment did not land where it was predicted to. + error AddressMismatch(address predicted, address actual); + + /// @dev `setDescriptor` is `onlyOwner`, so the deploying key must be the + /// owner to publish in the same run. Otherwise deploy uncommitted and + /// call `setDescriptor` separately from the owner. + error DescriptorNeedsOwnerKey(address deployer, address admin); + + /// @dev The descriptor is committed as `SHA256` over the published bytes, + /// so the URI has to be the thing serving those bytes over https. + error DescriptorUriNotHttps(string uri); + + /// @dev Split from `_preflight` so the checks are reachable without the + /// environment: `vm.setEnv` writes the host process environment, which + /// foundry's per-test isolation does not roll back, so a test that went + /// through the env would race every other test in the run. + function _validate(uint256 expectedChain, address settlement, address admin) internal view { + require(block.chainid == expectedChain, WrongChain(expectedChain, block.chainid)); + require(settlement.code.length != 0, SettlementHasNoCode(settlement)); + require(admin != address(0), OwnerIsZero()); + } + + function _preflight(uint256 expectedChain) internal view returns (address settlement, address admin) { + settlement = vm.envAddress("SETTLEMENT"); + admin = vm.envAddress("ADMIN"); + _validate(expectedChain, settlement, admin); + } + + /// @dev The TWAP descriptor document in this repository. The digest is + /// computed from it rather than supplied, so what is committed on + /// chain cannot drift from the document that was generated. + string internal constant TWAP_DESCRIPTOR = "descriptors/TWAP.json"; + + /// @dev `TWAP_DESCRIPTOR_URI` is optional. Unset, the handler deploys + /// uncommitted exactly as before and the descriptor is published + /// later with `setDescriptor`. + function _twapDescriptor(address deployer, address admin) internal view returns (bool, Commitment.Data memory) { + return _descriptorFor(vm.envOr("TWAP_DESCRIPTOR_URI", string("")), deployer, admin); + } + + /// @dev Split from the environment read for the same reason `_validate` is: + /// `vm.setEnv` writes the host process environment, which foundry does + /// not roll back between tests. + function _descriptorFor(string memory uri, address deployer, address admin) + internal + view + returns (bool set, Commitment.Data memory descriptor) + { + if (bytes(uri).length == 0) return (false, descriptor); + + require(deployer == admin, DescriptorNeedsOwnerKey(deployer, admin)); + require(_isHttps(uri), DescriptorUriNotHttps(uri)); + + string[] memory uris = new string[](1); + uris[0] = uri; + + set = true; + descriptor = Commitment.Data({ + uris: uris, digest: sha256(bytes(vm.readFile(TWAP_DESCRIPTOR))), kind: PackageKind.SHA256 + }); + } + + function _isHttps(string memory uri) internal pure returns (bool) { + bytes memory b = bytes(uri); + bytes memory prefix = "https://"; + if (b.length <= prefix.length) return false; + for (uint256 i; i < prefix.length; ++i) { + if (b[i] != prefix[i]) return false; + } + return true; + } + + /// @dev Skips a redundant transaction when re-running against a chain that + /// already carries this exact document. + function _publish(OwnedTWAP twap, Commitment.Data memory descriptor) internal { + (bytes32 current,) = twap.descriptorCommitment(); + if (current != descriptor.digest) twap.setDescriptor(descriptor); + } + + /// @dev `PRIVATE_KEY` if set, otherwise whatever `--account`, `--keystore` + /// or `--ledger` supplies on the command line. A raw key in the + /// environment is the worse of the two, so it is not the only option. + function _broadcaster() internal view returns (address deployer, uint256 pk) { + pk = vm.envOr("PRIVATE_KEY", uint256(0)); + deployer = pk == 0 ? msg.sender : vm.addr(pk); + } + + function _begin(uint256 pk) internal { + if (pk == 0) { + vm.startBroadcast(); + } else { + vm.startBroadcast(pk); + } + } + + function _at(bytes memory initCode) internal pure returns (address) { + return vm.computeCreate2Address(SALT, keccak256(initCode)); + } + + /// @dev Every address this script can produce, without deploying anything. + /// The TWAP address depends on the registry's, which is why it is + /// derived here rather than computed independently. + function predict(address settlement, address admin) + public + pure + returns (address composableCow, address twap, address stopLoss, address goodAfterTime) + { + composableCow = _at(abi.encodePacked(type(ComposableCow).creationCode, abi.encode(settlement))); + twap = _at(abi.encodePacked(type(OwnedTWAP).creationCode, abi.encode(composableCow, admin))); + stopLoss = _at(abi.encodePacked(type(OwnedStopLoss).creationCode, abi.encode(admin))); + goodAfterTime = _at(abi.encodePacked(type(OwnedGoodAfterTime).creationCode, abi.encode(admin))); + } + + /// @dev The registry and the one handler every deployment wants. Returns + /// what is already there rather than reverting, so re-running across a + /// set of chains converges instead of failing on the second attempt. + function _core(address settlement, address admin) internal returns (ComposableCow composableCow, OwnedTWAP twap) { + (address at, address twapAt,,) = predict(settlement, admin); + + composableCow = at.code.length != 0 ? ComposableCow(at) : new ComposableCow{salt: SALT}(settlement); + require(address(composableCow) == at, AddressMismatch(at, address(composableCow))); + + twap = twapAt.code.length != 0 ? OwnedTWAP(twapAt) : new OwnedTWAP{salt: SALT}(composableCow, admin); + require(address(twap) == twapAt, AddressMismatch(twapAt, address(twap))); + } + + function _report(address admin, ComposableCow composableCow, OwnedTWAP twap) internal view { + console.log("chainId ", block.chainid); + console.log("owner ", admin); + console.log("ComposableCow ", address(composableCow)); + console.log("OwnedTWAP ", address(twap)); + } + + function _next() internal pure { + console.log(""); + console.log("Record these in deployments/networks.json, then publish each"); + console.log("descriptor and call setDescriptor from the owner."); + } +} + +/** + * @title Ethereum mainnet deployment + * @dev The registry and TWAP only. `StopLoss` and `GoodAfterTime` are not + * deployed here: `GoodAfterTime` still has the open `TRY_NEXT_BLOCK` + * defect in #58, and neither is wanted on mainnet yet. Deploying them + * later against this same registry moves no address, since neither is an + * input to anything else. + */ +contract DeployMainnetStack is DeployOwnedStack { + uint256 private constant MAINNET = 1; + + function run() external { + (address settlement, address admin) = _preflight(MAINNET); + + (address deployer, uint256 pk) = _broadcaster(); + (bool set, Commitment.Data memory descriptor) = _twapDescriptor(deployer, admin); + + _begin(pk); + (ComposableCow composableCow, OwnedTWAP twap) = _core(settlement, admin); + if (set) _publish(twap, descriptor); + vm.stopBroadcast(); + + _report(admin, composableCow, twap); + if (set) { + console.log("TWAP descriptor ", descriptor.uris[0]); + console.logBytes32(descriptor.digest); + } + _next(); + } +} + +/** + * @title Gnosis Chain integration deployment + * @dev The registry and all three rotatable handlers. + */ +contract DeployGnosisStack is DeployOwnedStack { + uint256 private constant GNOSIS = 100; + + function run() external { + (address settlement, address admin) = _preflight(GNOSIS); + (,, address stopLossAt, address gatAt) = predict(settlement, admin); + + (address deployer, uint256 pk) = _broadcaster(); + (bool set, Commitment.Data memory descriptor) = _twapDescriptor(deployer, admin); + + _begin(pk); + + (ComposableCow composableCow, OwnedTWAP twap) = _core(settlement, admin); + if (set) _publish(twap, descriptor); + + OwnedStopLoss stopLoss = + stopLossAt.code.length != 0 ? OwnedStopLoss(stopLossAt) : new OwnedStopLoss{salt: SALT}(admin); + OwnedGoodAfterTime goodAfterTime = + gatAt.code.length != 0 ? OwnedGoodAfterTime(gatAt) : new OwnedGoodAfterTime{salt: SALT}(admin); + + vm.stopBroadcast(); + + require(address(stopLoss) == stopLossAt, AddressMismatch(stopLossAt, address(stopLoss))); + require(address(goodAfterTime) == gatAt, AddressMismatch(gatAt, address(goodAfterTime))); + + _report(admin, composableCow, twap); + console.log("OwnedStopLoss ", address(stopLoss)); + console.log("OwnedGoodAfterTime", address(goodAfterTime)); + _next(); + } +} diff --git a/script/deploy_ProdStack.s.sol b/script/deploy_ProdStack.s.sol index 3f1eae7..a7b9093 100644 --- a/script/deploy_ProdStack.s.sol +++ b/script/deploy_ProdStack.s.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; import {Script} from "forge-std/Script.sol"; // ExtensibleFallbackHandler @@ -34,11 +35,11 @@ contract DeployProdStack is Script { ComposableCow composableCow = new ComposableCow{salt: bytes32(0)}(settlement); // Deploy order types - new TWAP{salt: bytes32(0)}(composableCow, new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new GoodAfterTime{salt: bytes32(0)}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new PerpetualStableSwap{salt: bytes32(0)}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + new TWAP{salt: bytes32(0)}(composableCow, Commitment.none()); + new GoodAfterTime{salt: bytes32(0)}(Commitment.none()); + new PerpetualStableSwap{salt: bytes32(0)}(Commitment.none()); new TradeAboveThreshold{salt: bytes32(0)}(); - new StopLoss{salt: bytes32(0)}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + new StopLoss{salt: bytes32(0)}(Commitment.none()); // Deploy value factories new CurrentBlockTimestampFactory{salt: bytes32(0)}(); diff --git a/src/OrderDescriptor.sol b/src/OrderDescriptor.sol index aca1fab..1acf639 100644 --- a/src/OrderDescriptor.sol +++ b/src/OrderDescriptor.sol @@ -1,86 +1,70 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Ownable} from "solady/auth/Ownable.sol"; + import {IOrderDescriptor} from "./interfaces/IOrderDescriptor.sol"; import {PackageKind} from "./interfaces/PackageKind.sol"; -import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; +import {Commitment} from "./libraries/Commitment.sol"; /** * @title Order Descriptor mixin - opt-in descriptor commitment for handlers * @author mfw78 - * @dev Immutable by omission: there is no setter, and `DescriptorUpdate` is - * emitted exactly once, from the constructor. Deployments that support - * rotation add their own access-controlled setter and re-emit. + * @dev Inherits nothing beyond its own interface, so a handler may compose it + * with `BaseConditionalOrder` and `OrderModule` without the three meeting + * at a common base. * - * A handler constructed with no URIs does NOT advertise - * `IOrderDescriptor` - feature detection stays honest for deployments - * that predate their descriptor document; committing requires a - * redeployment. The digest is a constructor argument and therefore - * part of the initcode that fixes a CREATE2 address, so the document it - * commits to carries no handler identity of its own. + * Immutable by omission: no setter, and `DescriptorUpdate` is emitted + * exactly once, from the constructor. The commitment is what advertises + * `IOrderDescriptor`, not the URI list, since a `BZZ_MANIFEST` commitment + * locates its own document and publishes no URI. Handlers consult + * `_descriptorAdvertised` from `supportsInterface`. */ -abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { - /** - * @dev URIs cannot be published without a commitment to verify them against - */ - error UncommittedDescriptorURI(); - - /** - * @dev `SHA256` does not locate the document, so it requires a URI - */ - error DescriptorURIRequired(); +abstract contract OrderDescriptor is IOrderDescriptor { + using Commitment for Commitment.Data; - /** - * @dev A `BZZ_MANIFEST` commitment locates its own document; a URI could not - * be verified against a structure root anyway - */ - error DescriptorURINotUsed(); + /// @dev `internal` so `OwnedOrderDescriptor` writes what these accessors read. + Commitment.Data internal _descriptor; - string[] private _descriptorUris; - bytes32 private immutable _DESCRIPTOR_DIGEST; - PackageKind private immutable _DESCRIPTOR_KIND; - - constructor(string[] memory uris, bytes32 digest, PackageKind kind) { - if (digest != bytes32(0)) { - if (kind == PackageKind.SHA256) { - require(uris.length > 0, DescriptorURIRequired()); - } else { - require(uris.length == 0, DescriptorURINotUsed()); - } - emit DescriptorUpdate(uris, digest, kind); - } else { - require(uris.length == 0, UncommittedDescriptorURI()); + constructor(Commitment.Data memory descriptor) { + _descriptor.set(descriptor); + if (descriptor.digest != bytes32(0)) { + emit DescriptorUpdate(descriptor.uris, descriptor.digest, descriptor.kind); } - _descriptorUris = uris; - _DESCRIPTOR_DIGEST = digest; - _DESCRIPTOR_KIND = kind; } /** * @inheritdoc IOrderDescriptor */ function descriptorURI() external view returns (string[] memory uris) { - return _descriptorUris; + return _descriptor.uris; } /** * @inheritdoc IOrderDescriptor */ function descriptorCommitment() external view returns (bytes32 digest, PackageKind kind) { - return (_DESCRIPTOR_DIGEST, _DESCRIPTOR_KIND); + return (_descriptor.digest, _descriptor.kind); } - /** - * @dev Advertise `IOrderDescriptor` only when a descriptor is committed: - * claiming the interface while returning empty values is - * non-conformant per the discovery specification. The commitment is - * the gate, not the URI list, since a `BZZ_MANIFEST` commitment - * locates its own document and publishes no URI. - */ - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - if (interfaceId == type(IOrderDescriptor).interfaceId) { - return _DESCRIPTOR_DIGEST != bytes32(0); - } - return super.supportsInterface(interfaceId); + function _descriptorAdvertised() internal view returns (bool) { + return _descriptor.advertised(); + } +} + +/** + * @dev Adds rotation. The setter writes the storage the read-only accessors + * already read, so nothing has to be overridden, and `DescriptorUpdate` is + * re-emitted so indexers observe the change without polling. + * + * Ownership is `Ownable2Step`: the commitment is the only mutable state + * here, so a mistyped transfer would strand it. The recipient must accept. + */ +abstract contract OwnedOrderDescriptor is OrderDescriptor, Ownable { + using Commitment for Commitment.Data; + + function setDescriptor(Commitment.Data memory descriptor) external onlyOwner { + _descriptor.set(descriptor); + emit DescriptorUpdate(descriptor.uris, descriptor.digest, descriptor.kind); } } diff --git a/src/OrderModule.sol b/src/OrderModule.sol index d2c8949..5345fe7 100644 --- a/src/OrderModule.sol +++ b/src/OrderModule.sol @@ -1,76 +1,68 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Ownable} from "solady/auth/Ownable.sol"; + import {IOrderModule} from "./interfaces/IOrderModule.sol"; import {PackageKind} from "./interfaces/PackageKind.sol"; -import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; +import {Commitment} from "./libraries/Commitment.sol"; /** * @title Order Module mixin - opt-in module commitment for handlers * @author mfw78 - * @dev Immutable by omission, as for `OrderDescriptor`. The commitment is what - * advertises `IOrderModule`, not the URI list: a `BZZ_MANIFEST` - * commitment locates its own package and so publishes no URI. Constructed - * with a zero digest, the handler does not advertise the interface. + * @dev Inherits nothing beyond its own interface, so a handler may compose it + * with `BaseConditionalOrder` and `OrderModule` without the three meeting + * at a common base. + * + * Immutable by omission: no setter, and `ModuleUpdate` is emitted + * exactly once, from the constructor. The commitment is what advertises + * `IOrderModule`, not the URI list, since a `BZZ_MANIFEST` commitment + * locates its own package and publishes no URI. Handlers consult + * `_moduleAdvertised` from `supportsInterface`. */ -abstract contract OrderModule is IOrderModule, BaseConditionalOrder { - /** - * @dev URIs cannot be published without a commitment to verify them against - */ - error UncommittedModuleURI(); - - /** - * @dev `SHA256` does not locate the package, so it requires a URI - */ - error ModuleURIRequired(); - - /** - * @dev A `BZZ_MANIFEST` commitment locates its own package; a URI could not - * be verified against a structure root anyway - */ - error ModuleURINotUsed(); +abstract contract OrderModule is IOrderModule { + using Commitment for Commitment.Data; - string[] private _moduleUris; - bytes32 private immutable _MODULE_DIGEST; - PackageKind private immutable _MODULE_KIND; + /// @dev `internal` so `OwnedOrderModule` writes what these accessors read. + Commitment.Data internal _module; - constructor(string[] memory uris, bytes32 digest, PackageKind kind) { - if (digest != bytes32(0)) { - if (kind == PackageKind.SHA256) { - require(uris.length > 0, ModuleURIRequired()); - } else { - require(uris.length == 0, ModuleURINotUsed()); - } - emit ModuleUpdate(uris, digest, kind); - } else { - require(uris.length == 0, UncommittedModuleURI()); - } - _moduleUris = uris; - _MODULE_DIGEST = digest; - _MODULE_KIND = kind; + constructor(Commitment.Data memory module) { + _module.set(module); + if (module.digest != bytes32(0)) emit ModuleUpdate(module.uris, module.digest, module.kind); } /** * @inheritdoc IOrderModule */ function moduleURI() external view returns (string[] memory uris) { - return _moduleUris; + return _module.uris; } /** * @inheritdoc IOrderModule */ function moduleCommitment() external view returns (bytes32 digest, PackageKind kind) { - return (_MODULE_DIGEST, _MODULE_KIND); + return (_module.digest, _module.kind); } - /** - * @dev Advertise `IOrderModule` only when a module is committed - */ - function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { - if (interfaceId == type(IOrderModule).interfaceId) { - return _MODULE_DIGEST != bytes32(0); - } - return super.supportsInterface(interfaceId); + function _moduleAdvertised() internal view returns (bool) { + return _module.advertised(); + } +} + +/** + * @dev Adds rotation. The setter writes the storage the read-only accessors + * already read, so nothing has to be overridden, and `ModuleUpdate` is + * re-emitted so indexers observe the change without polling. + * + * Ownership is `Ownable2Step`: the commitment is the only mutable state + * here, so a mistyped transfer would strand it. The recipient must accept. + */ +abstract contract OwnedOrderModule is OrderModule, Ownable { + using Commitment for Commitment.Data; + + function setModule(Commitment.Data memory module) external onlyOwner { + _module.set(module); + emit ModuleUpdate(module.uris, module.digest, module.kind); } } diff --git a/src/libraries/Commitment.sol b/src/libraries/Commitment.sol new file mode 100644 index 0000000..17c4e61 --- /dev/null +++ b/src/libraries/Commitment.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {PackageKind} from "../interfaces/PackageKind.sol"; + +/** + * @title A content commitment and where to fetch it from + * @author mfw78 + * @dev Shared by the descriptor and module surfaces, which differ in what they + * commit to and in nothing else. Each holds its own `Data`, so the two + * occupy distinct slots and are set independently. + */ +library Commitment { + /** + * @dev URIs cannot be published without a commitment to verify them against + */ + error UncommittedURI(); + + /** + * @dev `SHA256` does not locate the content, so it requires a URI + */ + error URIRequired(); + + /** + * @dev A `BZZ_MANIFEST` commitment locates its own content; a URI could not + * be verified against a structure root anyway + */ + error URINotUsed(); + + struct Data { + string[] uris; + bytes32 digest; + PackageKind kind; + } + + /** + * @dev An uncommitted surface. The kind is immaterial while the digest is + * zero, so this spells that out rather than leaving an arbitrary + * `PackageKind` at each construction site. + */ + function none() internal pure returns (Data memory) { + return Data({uris: new string[](0), digest: bytes32(0), kind: PackageKind.BZZ_MANIFEST}); + } + + /// @dev `bytes32(0)` means uncommitted; such a surface is not advertised. + function advertised(Data storage self) internal view returns (bool) { + return self.digest != bytes32(0); + } + + function set(Data storage self, Data memory value) internal { + validate(value.uris.length, value.digest, value.kind); + self.uris = value.uris; + self.digest = value.digest; + self.kind = value.kind; + } + + function validate(uint256 uriCount, bytes32 digest, PackageKind kind) internal pure { + if (digest == bytes32(0)) { + require(uriCount == 0, UncommittedURI()); + } else if (kind == PackageKind.SHA256) { + require(uriCount > 0, URIRequired()); + } else { + require(uriCount == 0, URINotUsed()); + } + } +} diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index 1b13aa2..9c44ed4 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../libraries/Commitment.sol"; +import {BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import {IOrderDescriptor} from "../interfaces/IOrderDescriptor.sol"; import {SafeCastLib} from "solady/utils/SafeCastLib.sol"; import {IExpectedOutCalculator} from "../vendored/Milkman.sol"; @@ -45,10 +48,8 @@ error PriceCheckerFailed(); * ensure that the order is not filled multiple times, a `minSellBalance` is * checked before the order is placed. */ -contract GoodAfterTime is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, PackageKind descriptorKind) - OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) - {} +contract GoodAfterTime is BaseConditionalOrder, OrderDescriptor { + constructor(Commitment.Data memory descriptor) OrderDescriptor(descriptor) {} using SafeCastLib for uint256; @@ -157,4 +158,13 @@ contract GoodAfterTime is OrderDescriptor { { return "good-after-time order ready"; } + + /** + * @inheritdoc BaseConditionalOrder + * @dev Adds the descriptor sidecar, which is advertised only once committed. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderDescriptor).interfaceId) return _descriptorAdvertised(); + return super.supportsInterface(interfaceId); + } } diff --git a/src/types/Owned.sol b/src/types/Owned.sol new file mode 100644 index 0000000..ae9ae30 --- /dev/null +++ b/src/types/Owned.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {ComposableCow} from "../ComposableCow.sol"; +import {OwnedOrderDescriptor} from "../OrderDescriptor.sol"; +import {OrderModule, OwnedOrderModule} from "../OrderModule.sol"; +import {IOrderDescriptor} from "../interfaces/IOrderDescriptor.sol"; +import {IOrderModule} from "../interfaces/IOrderModule.sol"; +import {Commitment} from "../libraries/Commitment.sol"; +import {GoodAfterTime} from "./GoodAfterTime.sol"; +import {StopLoss} from "./StopLoss.sol"; +import {TWAP} from "./twap/TWAP.sol"; + +/** + * @dev The handlers with rotatable commitments. Each extends the handler it is + * named for, so order generation is the same code and only the discovery + * surface differs. One owner governs both commitments. + * + * Constructed uncommitted, then committed by the owner. That ordering is + * forced rather than convenient: on the immutable handlers the digest is a + * constructor argument, so under CREATE2 the address would depend on a + * descriptor that cannot be built until the address is known. + */ +contract OwnedTWAP is TWAP, OwnedOrderDescriptor, OwnedOrderModule { + constructor(ComposableCow composableCow_, address owner_) + TWAP(composableCow_, Commitment.none()) + OrderModule(Commitment.none()) + { + _initializeOwner(owner_); + } + + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + if (interfaceId == type(IOrderModule).interfaceId) return _moduleAdvertised(); + return super.supportsInterface(interfaceId); + } +} + +contract OwnedStopLoss is StopLoss, OwnedOrderDescriptor, OwnedOrderModule { + constructor(address owner_) StopLoss(Commitment.none()) OrderModule(Commitment.none()) { + _initializeOwner(owner_); + } + + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + if (interfaceId == type(IOrderModule).interfaceId) return _moduleAdvertised(); + return super.supportsInterface(interfaceId); + } +} + +contract OwnedGoodAfterTime is GoodAfterTime, OwnedOrderDescriptor, OwnedOrderModule { + constructor(address owner_) GoodAfterTime(Commitment.none()) OrderModule(Commitment.none()) { + _initializeOwner(owner_); + } + + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + if (interfaceId == type(IOrderModule).interfaceId) return _moduleAdvertised(); + return super.supportsInterface(interfaceId); + } +} diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index e3e1e33..1ff773d 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -8,6 +8,9 @@ import { IConditionalOrderGenerator, BaseConditionalOrder } from "../BaseConditionalOrder.sol"; +import {Commitment} from "../libraries/Commitment.sol"; +import {BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import {IOrderDescriptor} from "../interfaces/IOrderDescriptor.sol"; import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; @@ -23,10 +26,8 @@ error NotFunded(); * @title A smart contract that is always willing to trade between tokenA and tokenB 1:1, * taking decimals into account (and adding specifiable spread) */ -contract PerpetualStableSwap is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, PackageKind descriptorKind) - OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) - {} +contract PerpetualStableSwap is BaseConditionalOrder, OrderDescriptor { + constructor(Commitment.Data memory descriptor) OrderDescriptor(descriptor) {} /** * Creates a new perpetual swap order. All resulting swaps will be made from the target contract. @@ -183,4 +184,13 @@ contract PerpetualStableSwap is OrderDescriptor { return (new ManifestEntry[](0), false, _manifestStatus(errorData)); } } + + /** + * @inheritdoc BaseConditionalOrder + * @dev Adds the descriptor sidecar, which is advertised only once committed. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderDescriptor).interfaceId) return _descriptorAdvertised(); + return super.supportsInterface(interfaceId); + } } diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index cf6234c..0cf3362 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -8,6 +8,9 @@ import { IConditionalOrderGenerator, BaseConditionalOrder } from "../BaseConditionalOrder.sol"; +import {Commitment} from "../libraries/Commitment.sol"; +import {BaseConditionalOrder} from "../BaseConditionalOrder.sol"; +import {IOrderDescriptor} from "../interfaces/IOrderDescriptor.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; @@ -42,10 +45,8 @@ error OrderExpired(); * @notice Both oracles need to be denominated in the same quote currency (e.g. GNO/ETH and USD/ETH for GNO/USD stop loss orders) * @dev This order type has replay protection due to the `validTo` parameter, ensuring it will just execute one time */ -contract StopLoss is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, PackageKind descriptorKind) - OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) - {} +contract StopLoss is BaseConditionalOrder, OrderDescriptor { + constructor(Commitment.Data memory descriptor) OrderDescriptor(descriptor) {} /** * @dev Scaling factor for the strike price @@ -169,4 +170,13 @@ contract StopLoss is OrderDescriptor { { return "stop-loss triggered"; } + + /** + * @inheritdoc BaseConditionalOrder + * @dev Adds the descriptor sidecar, which is advertised only once committed. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderDescriptor).interfaceId) return _descriptorAdvertised(); + return super.supportsInterface(interfaceId); + } } diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index c88da65..1170bc7 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -1,6 +1,9 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../../libraries/Commitment.sol"; +import {BaseConditionalOrder} from "../../BaseConditionalOrder.sol"; +import {IOrderDescriptor} from "../../interfaces/IOrderDescriptor.sol"; import {SafeCastLib} from "solady/utils/SafeCastLib.sol"; import {ComposableCow} from "../../ComposableCow.sol"; @@ -36,17 +39,12 @@ error OrderNotInitialized(); * specific price, even if the price of the token changes during the trade. * @dev Designed to be used with the CoW Protocol Conditional Order Framework. */ -contract TWAP is OrderDescriptor { +contract TWAP is BaseConditionalOrder, OrderDescriptor { using SafeCastLib for uint256; ComposableCow public immutable composableCow; - constructor( - ComposableCow _composableCow, - string[] memory descriptorUris, - bytes32 descriptorDigest_, - PackageKind descriptorKind - ) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) { + constructor(ComposableCow _composableCow, Commitment.Data memory descriptor) OrderDescriptor(descriptor) { composableCow = _composableCow; } @@ -301,4 +299,13 @@ contract TWAP is OrderDescriptor { isActive: block.timestamp >= validFrom && block.timestamp <= validTo }); } + + /** + * @inheritdoc BaseConditionalOrder + * @dev Adds the descriptor sidecar, which is advertised only once committed. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderDescriptor).interfaceId) return _descriptorAdvertised(); + return super.supportsInterface(interfaceId); + } } diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index b3b86cc..b7e91b9 100644 --- a/test/ComposableCow.base.t.sol +++ b/test/ComposableCow.base.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; + import {Safe} from "safe/Safe.sol"; import {Enum} from "safe/libraries/Enum.sol"; import {IERC165} from "safe/interfaces/IERC165.sol"; @@ -76,7 +78,10 @@ contract BaseComposableCowTest is Base { passThrough = new TestConditionalOrderGenerator(); mirror = new MirrorConditionalOrder(); - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + twap = new TWAP( + composableCow, + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); } /** diff --git a/test/ComposableCow.discovery.t.sol b/test/ComposableCow.discovery.t.sol index e6a148f..c4074f8 100644 --- a/test/ComposableCow.discovery.t.sol +++ b/test/ComposableCow.discovery.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol"; import {IERC165} from "safe/interfaces/IERC165.sol"; @@ -10,6 +11,7 @@ import {IOrderDescriptor} from "../src/interfaces/IOrderDescriptor.sol"; import {IOrderModule} from "../src/interfaces/IOrderModule.sol"; import {OrderDescriptor} from "../src/OrderDescriptor.sol"; import {OrderModule} from "../src/OrderModule.sol"; +import {BaseConditionalOrder} from "../src/BaseConditionalOrder.sol"; import {StopLoss} from "../src/types/StopLoss.sol"; import {PackageKind} from "../src/interfaces/PackageKind.sol"; @@ -18,8 +20,13 @@ error TestNoOrder(); /** * @dev Minimal handler committing a module: the OrderModule mixin under test */ -contract ModuleHandler is OrderModule { - constructor(string[] memory uris, bytes32 digest, PackageKind kind) OrderModule(uris, digest, kind) {} +contract ModuleHandler is BaseConditionalOrder, OrderModule { + constructor(Commitment.Data memory commitment) OrderModule(commitment) {} + + function supportsInterface(bytes4 interfaceId) public view override returns (bool) { + if (interfaceId == type(IOrderModule).interfaceId) return _moduleAdvertised(); + return super.supportsInterface(interfaceId); + } function generateOrder(address, bytes32, bytes calldata, bytes calldata) public @@ -49,7 +56,9 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * super chain */ function test_descriptor_CommittedAdvertisesAndRoundTrips() public { - StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + StopLoss handler = new StopLoss( + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); assertTrue(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); @@ -68,7 +77,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * sidecar - feature detection never lies about empty metadata */ function test_descriptor_UncommittedDoesNotAdvertise() public { - StopLoss handler = new StopLoss(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + StopLoss handler = new StopLoss(Commitment.none()); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); // the handler remains a fully functional generator @@ -82,7 +91,9 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { function test_descriptor_ConstructorEmitsUpdate() public { vm.expectEmit(true, true, true, true); emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); - new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + new StopLoss( + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); } // --- module --- @@ -95,7 +106,8 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { vm.expectEmit(true, true, true, true); emit IOrderModule.ModuleUpdate(moduleUris(), digest, PackageKind.SHA256); - ModuleHandler handler = new ModuleHandler(moduleUris(), digest, PackageKind.SHA256); + ModuleHandler handler = + new ModuleHandler(Commitment.Data({uris: moduleUris(), digest: digest, kind: PackageKind.SHA256})); assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); @@ -110,8 +122,8 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * non-conformant */ function test_module_RevertsUncommittedURI() public { - vm.expectRevert(OrderModule.UncommittedModuleURI.selector); - new ModuleHandler(moduleUris(), bytes32(0), PackageKind.BZZ_MANIFEST); + vm.expectRevert(Commitment.UncommittedURI.selector); + new ModuleHandler(Commitment.Data({uris: moduleUris(), digest: bytes32(0), kind: PackageKind.BZZ_MANIFEST})); } /** @@ -120,16 +132,18 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * root, and publishing one would write a gateway into the contract */ function test_module_RevertsContentAddressedWithURI() public { - vm.expectRevert(OrderModule.ModuleURINotUsed.selector); - new ModuleHandler(moduleUris(), keccak256("pkg"), PackageKind.BZZ_MANIFEST); + vm.expectRevert(Commitment.URINotUsed.selector); + new ModuleHandler( + Commitment.Data({uris: moduleUris(), digest: keccak256("pkg"), kind: PackageKind.BZZ_MANIFEST}) + ); } /** * @dev `SHA256` does not locate the package, so it requires a URI */ function test_module_RevertsSha256WithoutURI() public { - vm.expectRevert(OrderModule.ModuleURIRequired.selector); - new ModuleHandler(new string[](0), keccak256("pkg"), PackageKind.SHA256); + vm.expectRevert(Commitment.URIRequired.selector); + new ModuleHandler(Commitment.Data({uris: new string[](0), digest: keccak256("pkg"), kind: PackageKind.SHA256})); } /** @@ -137,7 +151,9 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * advertises with no URI published at all */ function test_module_ContentAddressedNeedsNoURI() public { - ModuleHandler handler = new ModuleHandler(new string[](0), keccak256("pkg"), PackageKind.BZZ_MANIFEST); + ModuleHandler handler = new ModuleHandler( + Commitment.Data({uris: new string[](0), digest: keccak256("pkg"), kind: PackageKind.BZZ_MANIFEST}) + ); assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); assertEq(handler.moduleURI().length, 0); } @@ -146,7 +162,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * @dev No module committed: no advertising, handler still functions */ function test_module_UncommittedDoesNotAdvertise() public { - ModuleHandler handler = new ModuleHandler(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + ModuleHandler handler = new ModuleHandler(Commitment.none()); assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); } @@ -156,7 +172,9 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * empty - the discovery trigger end to end */ function test_module_NeedsInputSignal() public { - ModuleHandler handler = new ModuleHandler(new string[](0), keccak256("module"), PackageKind.BZZ_MANIFEST); + ModuleHandler handler = new ModuleHandler( + Commitment.Data({uris: new string[](0), digest: keccak256("module"), kind: PackageKind.BZZ_MANIFEST}) + ); IConditionalOrderGenerator.GeneratorResult memory result = handler.poll(address(safe1), bytes32(0), bytes(""), bytes("")); diff --git a/test/ComposableCow.gat.t.sol b/test/ComposableCow.gat.t.sol index 1190500..6c5b8ef 100644 --- a/test/ComposableCow.gat.t.sol +++ b/test/ComposableCow.gat.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; import {ERC1271} from "safe/handler/extensible/SignatureVerifierMuxer.sol"; import { @@ -37,7 +38,9 @@ contract ComposableCowGatTest is BaseComposableCowTest { super.setUp(); // deploy the GAT handler - gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + gat = new GoodAfterTime( + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); // deploy the test expected out calculator testOutCalculator = new TestExpectedOutCalculator(); diff --git a/test/ComposableCow.manifest.t.sol b/test/ComposableCow.manifest.t.sol index 59486b3..a6b3236 100644 --- a/test/ComposableCow.manifest.t.sol +++ b/test/ComposableCow.manifest.t.sol @@ -11,6 +11,7 @@ import { PollTryAtTimestampHandler, SuccessHandler } from "./ComposableCow.base.t.sol"; +import {Commitment} from "../src/libraries/Commitment.sol"; import {IOrderManifest} from "../src/interfaces/IOrderManifest.sol"; import {IERC165} from "safe/interfaces/IERC165.sol"; import {TWAPOrder} from "../src/types/twap/libraries/TWAPOrder.sol"; @@ -42,7 +43,9 @@ contract ComposableCowManifestTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + perpetualSwap = new PerpetualStableSwap( + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); } diff --git a/test/ComposableCow.owned.t.sol b/test/ComposableCow.owned.t.sol new file mode 100644 index 0000000..ca0f744 --- /dev/null +++ b/test/ComposableCow.owned.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {Ownable} from "solady/auth/Ownable.sol"; + +import {Commitment} from "../src/libraries/Commitment.sol"; +import "./ComposableCow.base.t.sol"; + +import {OrderDescriptor} from "../src/OrderDescriptor.sol"; +import {IOrderDescriptor} from "../src/interfaces/IOrderDescriptor.sol"; +import {IOrderModule} from "../src/interfaces/IOrderModule.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; +import {OwnedGoodAfterTime, OwnedStopLoss, OwnedTWAP} from "../src/types/Owned.sol"; +import {StopLoss} from "../src/types/StopLoss.sol"; + +contract ComposableCowOwnedTest is BaseComposableCowTest { + OwnedTWAP private ownedTwap; + OwnedStopLoss private ownedStopLoss; + OwnedGoodAfterTime private ownedGat; + + address private constant OWNER = address(0xA11CE); + address private constant STRANGER = address(0xBAD); + + bytes32 private constant DIGEST = keccak256("descriptor"); + + function setUp() public virtual override(BaseComposableCowTest) { + super.setUp(); + ownedTwap = new OwnedTWAP(composableCow, OWNER); + ownedStopLoss = new OwnedStopLoss(OWNER); + ownedGat = new OwnedGoodAfterTime(OWNER); + } + + function _uris(string memory u) private pure returns (string[] memory out) { + out = new string[](1); + out[0] = u; + } + + /// @dev Uncommitted at construction: nothing to advertise until the owner sets it. + function test_owned_UncommittedOnDeploy() public { + (bytes32 digest,) = ownedTwap.descriptorCommitment(); + assertEq(digest, bytes32(0)); + assertEq(ownedTwap.descriptorURI().length, 0); + assertFalse(ownedTwap.supportsInterface(type(IOrderDescriptor).interfaceId)); + assertFalse(ownedTwap.supportsInterface(type(IOrderModule).interfaceId)); + assertEq(ownedTwap.owner(), OWNER, "deployer must not retain ownership"); + } + + function test_owned_SetDescriptorAdvertisesAndRoundTrips() public { + vm.expectEmit(true, true, true, true); + emit IOrderDescriptor.DescriptorUpdate(new string[](0), DIGEST, PackageKind.BZZ_MANIFEST); + + vm.prank(OWNER); + ownedTwap.setDescriptor( + Commitment.Data({uris: new string[](0), digest: DIGEST, kind: PackageKind.BZZ_MANIFEST}) + ); + + (bytes32 digest, PackageKind kind) = ownedTwap.descriptorCommitment(); + assertEq(digest, DIGEST); + assertEq(uint256(kind), uint256(PackageKind.BZZ_MANIFEST)); + assertTrue(ownedTwap.supportsInterface(type(IOrderDescriptor).interfaceId)); + assertFalse(ownedTwap.supportsInterface(type(IOrderModule).interfaceId)); + } + + /// @dev The two commitments are independent surfaces under one owner. + function test_owned_SetModuleIsIndependentOfDescriptor() public { + vm.prank(OWNER); + ownedStopLoss.setModule( + Commitment.Data({ + uris: _uris("https://example.invalid/module.tar.zst"), digest: DIGEST, kind: PackageKind.SHA256 + }) + ); + + (bytes32 digest, PackageKind kind) = ownedStopLoss.moduleCommitment(); + assertEq(digest, DIGEST); + assertEq(uint256(kind), uint256(PackageKind.SHA256)); + assertTrue(ownedStopLoss.supportsInterface(type(IOrderModule).interfaceId)); + assertFalse(ownedStopLoss.supportsInterface(type(IOrderDescriptor).interfaceId)); + } + + /// @dev Rotation is the whole point: a second set must replace the first. + function test_owned_RotationReplacesCommitment() public { + vm.startPrank(OWNER); + ownedGat.setDescriptor(Commitment.Data({uris: new string[](0), digest: DIGEST, kind: PackageKind.BZZ_MANIFEST})); + bytes32 next = keccak256("rotated"); + ownedGat.setDescriptor( + Commitment.Data({uris: _uris("https://example.invalid/d.json"), digest: next, kind: PackageKind.SHA256}) + ); + vm.stopPrank(); + + (bytes32 digest, PackageKind kind) = ownedGat.descriptorCommitment(); + assertEq(digest, next); + assertEq(uint256(kind), uint256(PackageKind.SHA256)); + assertEq(ownedGat.descriptorURI().length, 1); + } + + function test_owned_RevertsForStranger() public { + vm.startPrank(STRANGER); + + vm.expectRevert(Ownable.Unauthorized.selector); + ownedTwap.setDescriptor( + Commitment.Data({uris: new string[](0), digest: DIGEST, kind: PackageKind.BZZ_MANIFEST}) + ); + + vm.expectRevert(Ownable.Unauthorized.selector); + ownedTwap.setModule(Commitment.Data({uris: new string[](0), digest: DIGEST, kind: PackageKind.BZZ_MANIFEST})); + + vm.stopPrank(); + } + + /// @dev The constructor invariants of the immutable mixin, enforced on write. + function test_owned_EnforcesURIInvariants() public { + vm.startPrank(OWNER); + + vm.expectRevert(Commitment.URIRequired.selector); + ownedTwap.setDescriptor(Commitment.Data({uris: new string[](0), digest: DIGEST, kind: PackageKind.SHA256})); + + vm.expectRevert(Commitment.URINotUsed.selector); + ownedTwap.setDescriptor( + Commitment.Data({uris: _uris("bzz://x"), digest: DIGEST, kind: PackageKind.BZZ_MANIFEST}) + ); + + vm.expectRevert(Commitment.UncommittedURI.selector); + ownedTwap.setDescriptor( + Commitment.Data({uris: _uris("bzz://x"), digest: bytes32(0), kind: PackageKind.BZZ_MANIFEST}) + ); + + vm.stopPrank(); + } + + /** + * @dev Two-step handover, in the stronger direction. Solady moves control + * only to a recipient that has itself asked for it, so a mistyped + * address cannot strand the commitments: it will never have asked. + */ + function test_owned_TransferIsTwoStep() public { + // ownership cannot be pushed at an address that has not asked for it + vm.prank(OWNER); + vm.expectRevert(Ownable.NoHandoverRequest.selector); + ownedTwap.completeOwnershipHandover(STRANGER); + assertEq(ownedTwap.owner(), OWNER, "ownership moved without a request"); + + vm.prank(STRANGER); + ownedTwap.requestOwnershipHandover(); + assertEq(ownedTwap.owner(), OWNER, "ownership moved on the request alone"); + assertGt(ownedTwap.ownershipHandoverExpiresAt(STRANGER), block.timestamp); + + vm.prank(OWNER); + ownedTwap.completeOwnershipHandover(STRANGER); + assertEq(ownedTwap.owner(), STRANGER); + + vm.prank(OWNER); + vm.expectRevert(Ownable.Unauthorized.selector); + ownedTwap.setDescriptor( + Commitment.Data({uris: new string[](0), digest: DIGEST, kind: PackageKind.BZZ_MANIFEST}) + ); + } + + /** + * @dev The variants exist to change the discovery surface and nothing else. + * One owner governs both commitments. + */ + function test_owned_OrderGenerationIsUnchanged() public { + StopLoss.Data memory o; // zero amounts, rejected before any oracle is consulted + + StopLoss immutableHandler = new StopLoss(Commitment.none()); + + bytes memory expected = + abi.encodeWithSelector(IConditionalOrder.OrderNotValid.selector, bytes4(keccak256("ZeroAmount()"))); + + vm.expectRevert(expected); + immutableHandler.generateOrder(address(safe1), bytes32(0), abi.encode(o), bytes("")); + + vm.expectRevert(expected); + ownedStopLoss.generateOrder(address(safe1), bytes32(0), abi.encode(o), bytes("")); + } +} diff --git a/test/ComposableCow.stoploss.t.sol b/test/ComposableCow.stoploss.t.sol index b02e076..a37a547 100644 --- a/test/ComposableCow.stoploss.t.sol +++ b/test/ComposableCow.stoploss.t.sol @@ -8,6 +8,7 @@ import { BaseComposableCowTest, IConditionalOrderGenerator } from "./ComposableCow.base.t.sol"; +import {Commitment} from "../src/libraries/Commitment.sol"; import {IAggregatorV3Interface} from "../src/interfaces/IAggregatorV3Interface.sol"; import { StopLoss, @@ -34,7 +35,9 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + stopLoss = new StopLoss( + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); } function priceToAddress(int256 price) internal returns (address) { diff --git a/test/ComposableCow.twap.t.sol b/test/ComposableCow.twap.t.sol index 8413356..5f8a5da 100644 --- a/test/ComposableCow.twap.t.sol +++ b/test/ComposableCow.twap.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {Commitment} from "../src/libraries/Commitment.sol"; import {SafeCastLib} from "solady/utils/SafeCastLib.sol"; import {ERC1271} from "safe/handler/extensible/SignatureVerifierMuxer.sol"; @@ -56,7 +57,10 @@ contract ComposableCowTwapTest is BaseComposableCowTest { super.setUp(); // deploy the TWAP handler - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + twap = new TWAP( + composableCow, + Commitment.Data({uris: testDescriptorUris(), digest: TEST_DESCRIPTOR_DIGEST, kind: PackageKind.SHA256}) + ); // deploy the current block timestamp factory currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); diff --git a/test/DeployStack.t.sol b/test/DeployStack.t.sol new file mode 100644 index 0000000..c030ef0 --- /dev/null +++ b/test/DeployStack.t.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {Test} from "forge-std/Test.sol"; + +import {ComposableCow} from "../src/ComposableCow.sol"; +import {Commitment} from "../src/libraries/Commitment.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; +import {OwnedTWAP} from "../src/types/Owned.sol"; +import {DeployOwnedStack} from "../script/deploy_OwnedStack.s.sol"; + +contract ValidateHarness is DeployOwnedStack { + function validate(uint256 expectedChain, address settlement, address admin) external view { + _validate(expectedChain, settlement, admin); + } + + function descriptorFor(string calldata uri, address deployer, address admin) + external + view + returns (bool, Commitment.Data memory) + { + return _descriptorFor(uri, deployer, admin); + } + + function isHttps(string calldata uri) external pure returns (bool) { + return _isHttps(uri); + } +} + +/** + * @dev The descriptor is committed as a sha256 over published bytes, so the + * digest going on chain has to be the digest of the document that is + * actually served. The script computes it from the repository rather than + * taking it on trust, and these check that it does. + */ +contract DescriptorPublishTest is Test { + address internal constant ADMIN = address(0xA11CE); + string internal constant URI = "https://example.invalid/twap.json"; + + ValidateHarness internal harness; + + function setUp() public { + harness = new ValidateHarness(); + } + + /** + * @dev The digest is sha256 over the document as it sits in the repo. + * + * Pinned against the value `sha256sum descriptors/TWAP.json` prints, + * rather than against a second `sha256(vm.readFile(...))` in the test, + * which would only prove the test and the script agree with each other. + * What needs proving is that both agree with the tool anyone verifying + * the published document will reach for, and that `vm.readFile` hands + * back the file byte for byte: the document ends in a newline, and + * dropping it would silently change every digest ever published. + * + * Update this constant deliberately if the document changes. + */ + function test_descriptor_DigestIsTheDocumentsSha256() public view { + (bool set, Commitment.Data memory d) = harness.descriptorFor(URI, ADMIN, ADMIN); + + assertTrue(set); + assertEq( + d.digest, + 0xb499b04e292a7fe808b982113e7e931446261bbc5d702517ba89a8403536ce1a, + "digest does not match sha256sum of the document" + ); + assertEq(bytes(vm.readFile("descriptors/TWAP.json")).length, 1825, "readFile did not return the file exactly"); + assertEq(d.digest, sha256(bytes(vm.readFile("descriptors/TWAP.json"))), "digest is not the document's"); + assertEq(uint256(d.kind), uint256(PackageKind.SHA256)); + assertEq(d.uris.length, 1); + assertEq(d.uris[0], URI); + } + + /// @dev `setDescriptor` is `onlyOwner`, so publishing in the same run needs + /// the owner's key. Failing loudly beats a reverting broadcast. + function test_descriptor_RequiresTheOwnerKey() public { + vm.expectRevert( + abi.encodeWithSelector(DeployOwnedStack.DescriptorNeedsOwnerKey.selector, address(0xB0B), ADMIN) + ); + harness.descriptorFor(URI, address(0xB0B), ADMIN); + } + + function test_descriptor_RejectsANonHttpsUri() public { + vm.expectRevert( + abi.encodeWithSelector(DeployOwnedStack.DescriptorUriNotHttps.selector, "http://example.invalid/twap.json") + ); + harness.descriptorFor("http://example.invalid/twap.json", ADMIN, ADMIN); + } + + /// @dev Unset, the handler deploys uncommitted exactly as before. + function test_descriptor_OptionalWhenUriUnset() public view { + (bool set,) = harness.descriptorFor("", ADMIN, ADMIN); + assertFalse(set, "an unset URI still published"); + } + + function test_isHttps() public view { + assertTrue(harness.isHttps("https://a")); + assertFalse(harness.isHttps("http://a")); + assertFalse(harness.isHttps("https://"), "scheme alone is not a URI"); + assertFalse(harness.isHttps("bzz://a")); + assertFalse(harness.isHttps("")); + } +} + +/// @dev CREATE2 buys one address per contract across every chain. That only +/// holds while the initcode carries nothing chain-specific, so these pin +/// what does and does not reach an address. +contract DeployAddressTest is Test { + ValidateHarness internal harness; + + address internal constant SETTLEMENT = 0x9008D19f58AAbD9eD0D60971565AA8510560ab41; + address internal constant ADMIN = address(0xA11CE); + + function setUp() public { + harness = new ValidateHarness(); + } + + /// @dev The reason for CREATE2 at all: same inputs, different chain, same + /// addresses. `predict` is `pure`, so no chain state can reach it. + function test_predict_IsTheSameOnEveryChain() public { + vm.chainId(1); + (address a1, address b1, address c1, address d1) = harness.predict(SETTLEMENT, ADMIN); + + vm.chainId(100); + (address a2, address b2, address c2, address d2) = harness.predict(SETTLEMENT, ADMIN); + + vm.chainId(8453); + (address a3, address b3, address c3, address d3) = harness.predict(SETTLEMENT, ADMIN); + + assertEq(a1, a2, "registry moved"); + assertEq(a1, a3, "registry moved"); + assertEq(b1, b2, "twap moved"); + assertEq(b1, b3, "twap moved"); + assertEq(c1, c2, "stopLoss moved"); + assertEq(d1, d2, "goodAfterTime moved"); + } + + /// @dev The TWAP address is derived from the registry's, not computed + /// beside it: a different registry has to move the handler too, or + /// the second deployment would point at the wrong one. + function test_predict_TwapFollowsTheRegistry() public view { + (address regA, address twapA,,) = harness.predict(SETTLEMENT, ADMIN); + (address regB, address twapB,,) = harness.predict(address(0xBEEF), ADMIN); + + assertTrue(regA != regB, "settlement does not reach the registry address"); + assertTrue(twapA != twapB, "registry does not reach the twap address"); + } + + /// @dev The precondition worth stating loudly: a different owner is a + /// different handler address, while the registry is unaffected. + function test_predict_AdminMovesHandlersOnly() public view { + (address regA, address twapA, address slA, address gatA) = harness.predict(SETTLEMENT, ADMIN); + (address regB, address twapB, address slB, address gatB) = harness.predict(SETTLEMENT, address(0xB0B)); + + assertEq(regA, regB, "admin should not reach the registry address"); + assertTrue(twapA != twapB, "admin does not reach the twap address"); + assertTrue(slA != slB, "admin does not reach the stopLoss address"); + assertTrue(gatA != gatB, "admin does not reach the goodAfterTime address"); + } + + /** + * @dev The chaining itself: the handler's initcode must carry the registry + * address, not the settlement one. Moving the settlement moves both, so + * a test that only varies the settlement cannot tell the two apart, and + * a mutation swapping them survives it. This pins the encoding. + */ + function test_predict_TwapEncodesTheRegistryNotTheSettlement() public view { + (address registry, address twap,,) = harness.predict(SETTLEMENT, ADMIN); + + assertEq( + twap, + vm.computeCreate2Address( + bytes32(0), keccak256(abi.encodePacked(type(OwnedTWAP).creationCode, abi.encode(registry, ADMIN))) + ), + "twap initcode does not carry the registry" + ); + + assertTrue( + twap + != vm.computeCreate2Address( + bytes32(0), keccak256(abi.encodePacked(type(OwnedTWAP).creationCode, abi.encode(SETTLEMENT, ADMIN))) + ), + "twap initcode carries the settlement" + ); + } + + /// @dev Checked against the cheatcode rather than a second copy of the + /// same derivation. + function test_predict_MatchesCreate2() public view { + (address registry,,,) = harness.predict(SETTLEMENT, ADMIN); + assertEq( + registry, + vm.computeCreate2Address( + bytes32(0), keccak256(abi.encodePacked(type(ComposableCow).creationCode, abi.encode(SETTLEMENT))) + ) + ); + } +} + +/** + * @dev The preflight checks run once, on a live network, with real funds behind + * them. Each is asserted to fire rather than assumed to. + */ +contract DeployStackTest is Test { + uint256 internal constant MAINNET = 1; + + ValidateHarness internal harness; + address internal settlement; + address internal admin = address(0xA11CE); + + function setUp() public { + harness = new ValidateHarness(); + // the check is only that the address has code, so any contract serves + settlement = address(new Bytecode()); + vm.chainId(MAINNET); + } + + function test_validate_AcceptsAValidConfig() public view { + harness.validate(MAINNET, settlement, admin); + } + + /// @dev The check that catches a stale `ETH_RPC_URL`. + function test_validate_RejectsWrongChain() public { + vm.chainId(100); + vm.expectRevert(abi.encodeWithSelector(DeployOwnedStack.WrongChain.selector, MAINNET, uint256(100))); + harness.validate(MAINNET, settlement, admin); + } + + /// @dev A settlement address with no code means the wrong address or the + /// wrong chain, and every order would be unsettleable. + function test_validate_RejectsCodelessSettlement() public { + vm.expectRevert(abi.encodeWithSelector(DeployOwnedStack.SettlementHasNoCode.selector, address(0xBEEF))); + harness.validate(MAINNET, address(0xBEEF), admin); + } + + /// @dev A zero owner strands both commitments: the handler deploys + /// uncommitted and no key can ever call `setDescriptor`. + function test_validate_RejectsZeroOwner() public { + vm.expectRevert(DeployOwnedStack.OwnerIsZero.selector); + harness.validate(MAINNET, settlement, address(0)); + } +} + +/// @dev Something with a non-empty code size, and nothing else. +contract Bytecode {}