From d825bb1553735a30cae8afcc32e5cd744b4bc33d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 30 Jul 2026 09:48:25 +0000 Subject: [PATCH 1/5] feat: handler discovery interfaces IOrderDescriptor and IOrderModule --- script/deploy_AnvilStack.s.sol | 6 +- script/deploy_OrderTypes.s.sol | 6 +- script/deploy_ProdStack.s.sol | 8 +- src/BaseConditionalOrder.sol | 2 +- src/OrderDescriptor.sol | 56 ++++++++++++ src/OrderModule.sol | 56 ++++++++++++ src/interfaces/IOrderDescriptor.sol | 35 ++++++++ src/interfaces/IOrderModule.sol | 35 ++++++++ src/types/GoodAfterTime.sol | 7 +- src/types/PerpetualStableSwap.sol | 7 +- src/types/StopLoss.sol | 7 +- src/types/twap/TWAP.sol | 7 +- test/ComposableCow.base.t.sol | 12 ++- test/ComposableCow.discovery.t.sol | 134 ++++++++++++++++++++++++++++ test/ComposableCow.gat.t.sol | 2 +- test/ComposableCow.manifest.t.sol | 2 +- test/ComposableCow.stoploss.t.sol | 2 +- test/ComposableCow.twap.t.sol | 2 +- 18 files changed, 365 insertions(+), 21 deletions(-) create mode 100644 src/OrderDescriptor.sol create mode 100644 src/OrderModule.sol create mode 100644 src/interfaces/IOrderDescriptor.sol create mode 100644 src/interfaces/IOrderModule.sol create mode 100644 test/ComposableCow.discovery.t.sol diff --git a/script/deploy_AnvilStack.s.sol b/script/deploy_AnvilStack.s.sol index 45124aa8..01a11afe 100644 --- a/script/deploy_AnvilStack.s.sol +++ b/script/deploy_AnvilStack.s.sol @@ -61,9 +61,9 @@ contract DeployAnvilStack is Script { // deploy the Composable CoW ComposableCow composableCow = new ComposableCow(address(settlement)); - new TWAP(composableCow); - new GoodAfterTime(); - new PerpetualStableSwap(); + new TWAP(composableCow, new string[](0), bytes32(0)); + new GoodAfterTime(new string[](0), bytes32(0)); + new PerpetualStableSwap(new string[](0), bytes32(0)); new TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_OrderTypes.s.sol b/script/deploy_OrderTypes.s.sol index 3a666ba2..f03d6913 100644 --- a/script/deploy_OrderTypes.s.sol +++ b/script/deploy_OrderTypes.s.sol @@ -16,9 +16,9 @@ contract DeployOrderTypes is Script { address composableCow = vm.envAddress("COMPOSABLE_COW"); vm.startBroadcast(deployerPrivateKey); - new TWAP(ComposableCow(composableCow)); - new GoodAfterTime(); - new PerpetualStableSwap(); + new TWAP(ComposableCow(composableCow), new string[](0), bytes32(0)); + new GoodAfterTime(new string[](0), bytes32(0)); + new PerpetualStableSwap(new string[](0), bytes32(0)); new TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_ProdStack.s.sol b/script/deploy_ProdStack.s.sol index 67d3fa0c..77c90dde 100644 --- a/script/deploy_ProdStack.s.sol +++ b/script/deploy_ProdStack.s.sol @@ -33,11 +33,11 @@ contract DeployProdStack is Script { ComposableCow composableCow = new ComposableCow{salt: "v1.0.0"}(settlement); // Deploy order types - new TWAP{salt: "v1.0.0"}(composableCow); - new GoodAfterTime{salt: "v1.0.0"}(); - new PerpetualStableSwap{salt: "v1.0.0"}(); + new TWAP{salt: "v1.0.0"}(composableCow, new string[](0), bytes32(0)); + new GoodAfterTime{salt: "v1.0.0"}(new string[](0), bytes32(0)); + new PerpetualStableSwap{salt: "v1.0.0"}(new string[](0), bytes32(0)); new TradeAboveThreshold{salt: "v1.0.0"}(); - new StopLoss{salt: "v1.0.0"}(); + new StopLoss{salt: "v1.0.0"}(new string[](0), bytes32(0)); // Deploy value factories new CurrentBlockTimestampFactory{salt: "v1.0.0"}(); diff --git a/src/BaseConditionalOrder.sol b/src/BaseConditionalOrder.sol index c7b8cef7..3322e670 100644 --- a/src/BaseConditionalOrder.sol +++ b/src/BaseConditionalOrder.sol @@ -193,7 +193,7 @@ abstract contract BaseConditionalOrder is IConditionalOrderGenerator, IOrderMani /** * @inheritdoc IERC165 */ - function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { return interfaceId == type(IConditionalOrderGenerator).interfaceId || interfaceId == type(IOrderManifest).interfaceId || interfaceId == type(IERC165).interfaceId; } diff --git a/src/OrderDescriptor.sol b/src/OrderDescriptor.sol new file mode 100644 index 00000000..e658e940 --- /dev/null +++ b/src/OrderDescriptor.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {IOrderDescriptor} from "./interfaces/IOrderDescriptor.sol"; +import {BaseConditionalOrder} from "./BaseConditionalOrder.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. + * + * 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 descriptor digest is per-deployment anyway). + */ +abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { + string[] private _descriptorUris; + bytes32 private immutable _DESCRIPTOR_DIGEST; + + constructor(string[] memory uris, bytes32 digest) { + _descriptorUris = uris; + _DESCRIPTOR_DIGEST = digest; + if (uris.length > 0) { + emit DescriptorUpdate(uris, digest); + } + } + + /** + * @inheritdoc IOrderDescriptor + */ + function descriptorURI() external view returns (string[] memory uris) { + return _descriptorUris; + } + + /** + * @inheritdoc IOrderDescriptor + */ + function descriptorDigest() external view returns (bytes32) { + return _DESCRIPTOR_DIGEST; + } + + /** + * @dev Advertise `IOrderDescriptor` only when a descriptor is committed: + * claiming the interface while returning empty values is + * non-conformant per the discovery specification. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderDescriptor).interfaceId) { + return _descriptorUris.length > 0; + } + return super.supportsInterface(interfaceId); + } +} diff --git a/src/OrderModule.sol b/src/OrderModule.sol new file mode 100644 index 00000000..5909d546 --- /dev/null +++ b/src/OrderModule.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {IOrderModule} from "./interfaces/IOrderModule.sol"; +import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; + +/** + * @title Order Module mixin - opt-in module commitment for handlers + * @author mfw78 + * @dev Immutable by omission, as for `OrderDescriptor`. A zero digest is + * non-conformant (the digest is the module's canonical identity), so + * construction with any URIs requires a non-zero digest; constructed + * with no URIs the handler does not advertise `IOrderModule`. + */ +abstract contract OrderModule is IOrderModule, BaseConditionalOrder { + /** + * @dev A module commitment requires a non-zero digest + */ + error InvalidModuleDigest(); + + string[] private _moduleUris; + bytes32 private immutable _MODULE_DIGEST; + + constructor(string[] memory uris, bytes32 digest) { + if (uris.length > 0) { + require(digest != bytes32(0), InvalidModuleDigest()); + emit ModuleUpdate(uris, digest); + } + _moduleUris = uris; + _MODULE_DIGEST = digest; + } + + /** + * @inheritdoc IOrderModule + */ + function moduleURI() external view returns (string[] memory uris) { + return _moduleUris; + } + + /** + * @inheritdoc IOrderModule + */ + function moduleDigest() external view returns (bytes32) { + return _MODULE_DIGEST; + } + + /** + * @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 _moduleUris.length > 0; + } + return super.supportsInterface(interfaceId); + } +} diff --git a/src/interfaces/IOrderDescriptor.sol b/src/interfaces/IOrderDescriptor.sol new file mode 100644 index 00000000..170e04ee --- /dev/null +++ b/src/interfaces/IOrderDescriptor.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +/** + * @title Order Descriptor - declarative handler metadata for discovery + * @author mfw78 + * @dev Sidecar interface with its own ERC-165 id, feature-detected + * independently and never on the settlement path. The descriptor + * document is presentation metadata - hints, never authority: every + * economically material fact is derived from the chain + * (see `docs/discovery.md` §1). + */ +interface IOrderDescriptor { + /** + * @notice Emitted when the descriptor location or commitment changes. + * @dev MUST be emitted from the constructor of implementing contracts so + * indexers discover the descriptor without polling. + */ + event DescriptorUpdate(string[] uris, bytes32 digest); + + /** + * @notice Locations of the handler descriptor document. + * @dev All URIs MUST reference the same document bytes (redundant + * mirrors), never alternative content. + */ + function descriptorURI() external view returns (string[] memory uris); + + /** + * @notice keccak256 of the exact descriptor document bytes as published. + * @dev Consumers MUST verify fetched bytes against this digest before + * parsing when the URI is not content-addressed. bytes32(0) means + * uncommitted; consumers MUST treat such descriptors as untrusted. + */ + function descriptorDigest() external view returns (bytes32); +} diff --git a/src/interfaces/IOrderModule.sol b/src/interfaces/IOrderModule.sol new file mode 100644 index 00000000..cda49850 --- /dev/null +++ b/src/interfaces/IOrderModule.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +/** + * @title Order Module - executable client module for custom handlers + * @author mfw78 + * @dev Sidecar interface with its own ERC-165 id. An order module constructs + * `offchainInput` for handlers that signal `NEEDS_INPUT` - the one + * aspect of servicing an order that cannot be derived on-chain. Module + * output is untrusted input to on-chain verification; the runtime + * contract is defined by the reference monitoring service + * (see `docs/discovery.md` §2). + */ +interface IOrderModule { + /** + * @notice Emitted when the module location or commitment changes. + * @dev MUST be emitted from the constructor of implementing contracts. + */ + event ModuleUpdate(string[] uris, bytes32 digest); + + /** + * @notice Locations of the module. All URIs MUST reference the same bytes. + */ + function moduleURI() external view returns (string[] memory uris); + + /** + * @notice keccak256 of the exact module bytes. MUST be non-zero. + * @dev The module's canonical identity and the final pre-execution gate. + * Fetch integrity is per-transport (a Swarm reference, CID, or + * RFC 6920 hash verifies the fetch); the digest is what consent + * lists, caches, and budgets key by. Consumers MUST verify + * keccak256(bytes) == moduleDigest() before execution. + */ + function moduleDigest() external view returns (bytes32); +} diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index d0b2fdbe..a726aad7 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -12,6 +12,7 @@ import { BaseConditionalOrder } from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {OrderDescriptor} from "../OrderDescriptor.sol"; // --- error strings /** @@ -43,7 +44,11 @@ error PriceCheckerFailed(); * ensure that the order is not filled multiple times, a `minSellBalance` is * checked before the order is placed. */ -contract GoodAfterTime is BaseConditionalOrder { +contract GoodAfterTime is OrderDescriptor { + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_) + OrderDescriptor(descriptorUris, descriptorDigest_) + {} + using SafeCast for uint256; // --- types diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index e157c6b2..d32f6d62 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -10,6 +10,7 @@ import { } from "../BaseConditionalOrder.sol"; import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {OrderDescriptor} from "../OrderDescriptor.sol"; // --- error strings /** @@ -21,7 +22,11 @@ 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 BaseConditionalOrder { +contract PerpetualStableSwap is OrderDescriptor { + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_) + OrderDescriptor(descriptorUris, descriptorDigest_) + {} + /** * Creates a new perpetual swap order. All resulting swaps will be made from the target contract. * @param tokenA One of the two tokens that can be perpetually swapped against one another diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index ef0c9289..80b7a785 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -10,6 +10,7 @@ import { } from "../BaseConditionalOrder.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {OrderDescriptor} from "../OrderDescriptor.sol"; // --- error strings @@ -40,7 +41,11 @@ 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 BaseConditionalOrder { +contract StopLoss is OrderDescriptor { + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_) + OrderDescriptor(descriptorUris, descriptorDigest_) + {} + /** * @dev Scaling factor for the strike price */ diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index 94ac5c83..34c97eb6 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -14,6 +14,7 @@ import { import {IOrderManifest} from "../../interfaces/IOrderManifest.sol"; import {TWAPOrder} from "./libraries/TWAPOrder.sol"; import {TWAPOrderMathLib, AfterTwapFinish} from "./libraries/TWAPOrderMathLib.sol"; +import {OrderDescriptor} from "../../OrderDescriptor.sol"; // --- error strings @@ -34,12 +35,14 @@ 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 BaseConditionalOrder { +contract TWAP is OrderDescriptor { using SafeCast for uint256; ComposableCow public immutable composableCow; - constructor(ComposableCow _composableCow) { + constructor(ComposableCow _composableCow, string[] memory descriptorUris, bytes32 descriptorDigest_) + OrderDescriptor(descriptorUris, descriptorDigest_) + { composableCow = _composableCow; } diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index eafc5e90..49467559 100644 --- a/test/ComposableCow.base.t.sol +++ b/test/ComposableCow.base.t.sol @@ -75,7 +75,7 @@ contract BaseComposableCowTest is Base, Merkle { passThrough = new TestConditionalOrderGenerator(); mirror = new MirrorConditionalOrder(); - twap = new TWAP(composableCow); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); } /** @@ -184,6 +184,16 @@ contract BaseComposableCowTest is Base, Merkle { } } + /** + * @dev A committed test descriptor: a data: URI and the digest of its bytes + */ + bytes32 internal constant TEST_DESCRIPTOR_DIGEST = keccak256('{"version":"1","name":"test"}'); + + function testDescriptorUris() internal pure returns (string[] memory uris) { + uris = new string[](1); + uris[0] = 'data:application/json,{"version":"1","name":"test"}'; + } + function getBlankOrder() internal pure returns (GPv2Order.Data memory order) { return getOrderWithAppData(keccak256("blank order")); } diff --git a/test/ComposableCow.discovery.t.sol b/test/ComposableCow.discovery.t.sol new file mode 100644 index 00000000..e91da016 --- /dev/null +++ b/test/ComposableCow.discovery.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {GPv2Order} from "cowprotocol/contracts/libraries/GPv2Order.sol"; +import {IERC165} from "safe/interfaces/IERC165.sol"; + +import {IConditionalOrder, IConditionalOrderGenerator, BaseComposableCowTest} from "./ComposableCow.base.t.sol"; +import {IOrderManifest} from "../src/interfaces/IOrderManifest.sol"; +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 {StopLoss} from "../src/types/StopLoss.sol"; + +error TestNoOrder(); + +/** + * @dev Minimal handler committing a module: the OrderModule mixin under test + */ +contract ModuleHandler is OrderModule { + constructor(string[] memory uris, bytes32 digest) OrderModule(uris, digest) {} + + function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) + public + pure + override + returns (GPv2Order.Data memory) + { + revert IConditionalOrderGenerator.PollNeedsOffchainInput(TestNoOrder.selector); + } +} + +/** + * @title Tests for the discovery sidecars: feature detection, commitment + * gating, and constructor events + */ +contract ComposableCowDiscoveryTest is BaseComposableCowTest { + function moduleUris() internal pure returns (string[] memory uris) { + uris = new string[](1); + uris[0] = "bzz://c0ffee"; + } + + // --- descriptor --- + + /** + * @dev A committed descriptor advertises the sidecar and round-trips + * its commitment; the base interfaces keep advertising through the + * super chain + */ + function test_descriptor_CommittedAdvertisesAndRoundTrips() public { + StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + + assertTrue(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); + assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); + assertTrue(handler.supportsInterface(type(IOrderManifest).interfaceId)); + assertTrue(handler.supportsInterface(type(IERC165).interfaceId)); + assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); + + assertEq(handler.descriptorURI()[0], testDescriptorUris()[0]); + assertEq(handler.descriptorDigest(), TEST_DESCRIPTOR_DIGEST); + } + + /** + * @dev Uncommitted (no URIs): the handler does NOT advertise the + * sidecar - feature detection never lies about empty metadata + */ + function test_descriptor_UncommittedDoesNotAdvertise() public { + StopLoss handler = new StopLoss(new string[](0), bytes32(0)); + + assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); + // the handler remains a fully functional generator + assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); + } + + /** + * @dev DescriptorUpdate fires exactly once, from the constructor, so + * indexers discover descriptors without polling + */ + function test_descriptor_ConstructorEmitsUpdate() public { + vm.expectEmit(true, true, true, true); + emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + } + + // --- module --- + + /** + * @dev A committed module advertises the sidecar with its identity digest + */ + function test_module_CommittedAdvertisesAndRoundTrips() public { + bytes32 digest = keccak256("module component bytes"); + + vm.expectEmit(true, true, true, true); + emit IOrderModule.ModuleUpdate(moduleUris(), digest); + ModuleHandler handler = new ModuleHandler(moduleUris(), digest); + + assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); + assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); + assertEq(handler.moduleURI()[0], moduleUris()[0]); + assertEq(handler.moduleDigest(), digest); + } + + /** + * @dev A zero digest is non-conformant: the digest is the module's + * canonical identity and the final pre-execution gate + */ + function test_module_RevertsZeroDigest() public { + vm.expectRevert(OrderModule.InvalidModuleDigest.selector); + new ModuleHandler(moduleUris(), bytes32(0)); + } + + /** + * @dev No module committed: no advertising, handler still functions + */ + function test_module_UncommittedDoesNotAdvertise() public { + ModuleHandler handler = new ModuleHandler(new string[](0), bytes32(0)); + assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); + assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); + } + + /** + * @dev The module-requiring handler signals NEEDS_INPUT when polled + * empty - the discovery trigger end to end + */ + function test_module_NeedsInputSignal() public { + ModuleHandler handler = new ModuleHandler(moduleUris(), keccak256("module")); + + IConditionalOrderGenerator.GeneratorResult memory result = + handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); + + assertEq(uint256(result.code), uint256(IConditionalOrderGenerator.GeneratorResultCode.NEEDS_INPUT)); + assertEq(result.reasonCode, TestNoOrder.selector); + } +} diff --git a/test/ComposableCow.gat.t.sol b/test/ComposableCow.gat.t.sol index 6fa6d03b..560ccdec 100644 --- a/test/ComposableCow.gat.t.sol +++ b/test/ComposableCow.gat.t.sol @@ -36,7 +36,7 @@ contract ComposableCowGatTest is BaseComposableCowTest { super.setUp(); // deploy the GAT handler - gat = new GoodAfterTime(); + gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); // deploy the test expected out calculator testOutCalculator = new TestExpectedOutCalculator(); diff --git a/test/ComposableCow.manifest.t.sol b/test/ComposableCow.manifest.t.sol index 533e3923..33ad5808 100644 --- a/test/ComposableCow.manifest.t.sol +++ b/test/ComposableCow.manifest.t.sol @@ -41,7 +41,7 @@ contract ComposableCowManifestTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - perpetualSwap = new PerpetualStableSwap(); + perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); } diff --git a/test/ComposableCow.stoploss.t.sol b/test/ComposableCow.stoploss.t.sol index 67957387..7cba687b 100644 --- a/test/ComposableCow.stoploss.t.sol +++ b/test/ComposableCow.stoploss.t.sol @@ -33,7 +33,7 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - stopLoss = new StopLoss(); + stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); } function priceToAddress(int256 price) internal returns (address) { diff --git a/test/ComposableCow.twap.t.sol b/test/ComposableCow.twap.t.sol index 3a5e5212..6d43d452 100644 --- a/test/ComposableCow.twap.t.sol +++ b/test/ComposableCow.twap.t.sol @@ -55,7 +55,7 @@ contract ComposableCowTwapTest is BaseComposableCowTest { super.setUp(); // deploy the TWAP handler - twap = new TWAP(composableCow); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); // deploy the current block timestamp factory currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); From aef6bb300cdf9b955d151b38af206cd7c7135235 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 31 Jul 2026 03:56:44 +0000 Subject: [PATCH 2/5] feat!: commit descriptors and modules by (digest, kind) Replaces the keccak256-over-bytes digest on both discovery sidecars with a `(bytes32 digest, PackageKind kind)` pair, where `kind` says how the package is put together and therefore how its digest is computed and verified. `BZZ_MANIFEST` hashes a structure, so only Swarm can recompute it: it locates itself, publishes no URI, and verifies per entry. `TAR_ZST` hashes bytes, so any transport can be checked by hashing what it delivered, which is what makes mirroring across bzz, ipfs and https possible at all. Neither needs a canonical byte serialisation specified. Two consequences in the mixins: - ERC-165 advertisement now gates on the digest rather than on the URI list. Gating on URIs would have silently stopped a `BZZ` or `IPFS` handler from advertising at all, since those publish none. - `TAR_ZST` locates nothing, so it requires at least one URI, and URIs cannot be published without a commitment to verify them against. `InvalidModuleDigest` is replaced by `UncommittedModuleURI` and `ModuleURIRequired`, which say which of the two rules was broken. BREAKING CHANGE: `descriptorDigest()` and `moduleDigest()` are replaced by `descriptorCommitment()` and `moduleCommitment()`; `DescriptorUpdate` and `ModuleUpdate` carry the kind; handler constructors take it. --- docs/discovery.md | 151 ++++++++++------------------ script/deploy_AnvilStack.s.sol | 7 +- script/deploy_OrderTypes.s.sol | 7 +- script/deploy_ProdStack.s.sol | 9 +- src/OrderDescriptor.sol | 34 +++++-- src/OrderModule.sol | 36 ++++--- src/interfaces/DigestKind.sol | 17 ++++ src/interfaces/IOrderDescriptor.sol | 19 ++-- src/interfaces/IOrderModule.sol | 29 +++--- src/types/GoodAfterTime.sol | 5 +- src/types/PerpetualStableSwap.sol | 5 +- src/types/StopLoss.sol | 5 +- src/types/twap/TWAP.sol | 10 +- test/ComposableCow.base.t.sol | 3 +- test/ComposableCow.discovery.t.sol | 55 +++++++--- test/ComposableCow.gat.t.sol | 3 +- test/ComposableCow.manifest.t.sol | 3 +- test/ComposableCow.stoploss.t.sol | 3 +- test/ComposableCow.twap.t.sol | 3 +- 19 files changed, 227 insertions(+), 177 deletions(-) create mode 100644 src/interfaces/DigestKind.sol diff --git a/docs/discovery.md b/docs/discovery.md index 55cb9bdc..63f97f8d 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -35,8 +35,8 @@ described in RFC 2119. from the chain. - **Executable code is pulled only against an on-chain commitment.** Module bytes MUST verify against `moduleCommitment()` before execution. The - commitment is a root in a named addressing packaging (§0), so verification uses - that packaging's own primitives rather than a separate hash over the fetched + commitment is a root in a named addressing scheme (§0), so verification uses + that scheme's own primitives rather than a separate hash over the fetched bytes. Verified modules MAY be fetched and executed automatically, but only inside the sandbox and budget regime of §2. - **Feature detection, never gating.** Every interface here is a sidecar with @@ -48,10 +48,10 @@ described in RFC 2119. commitment resolves to: a descriptor resolves to one JSON document, a module to a package. Proof payloads are the exception and verify by recomputing the merkle root, since they are authored per root rather than published once. -- **Content addressing locates as well as verifies.** A `BZZ_MANIFEST` +- **Content addressing locates as well as verifies.** For `BZZ` and `IPFS` the commitment already names where the bytes live, so a handler publishes no URI and no gateway is written into a deployed contract. Retrieval is the host's - concern. URIs exist for `TAR_ZST`, where the digest locates nothing. + concern. URIs exist for `SHA256`, where the digest locates nothing. - **Fail closed.** Unknown URI schemes, unsupported or reverting views, and unverifiable documents are treated as "no discovery", never as errors to retry aggressively and never as data to trust. @@ -59,87 +59,53 @@ described in RFC 2119. ## 0. Commitments Descriptors (§1) and modules (§2) are both committed to on-chain as a -`(bytes32 digest, PackageKind kind)` pair. `kind` names how the bytes are -packaged and therefore how the digest is computed and verified. +`(bytes32 digest, DigestKind kind)` pair. `kind` names the addressing scheme +the digest is expressed in; verification uses that scheme's own primitives. ```solidity -enum PackageKind { - BZZ_MANIFEST, // mantaray manifest root - TAR_ZST // sha256 of a .tar.zst archive +enum DigestKind { + BZZ, // digest is a Swarm BMT root + IPFS, // digest is the sha2-256 multihash digest of the root CID + SHA256 // digest is sha256 over the published bytes } ``` -The two kinds trade the same two properties against each other, and a -publisher picks which one it wants: - -| | `BZZ_MANIFEST` | `TAR_ZST` | -|---|---|---| -| locates itself | yes, the root is the Swarm reference | no, a URI is required | -| verification | per entry, from BMT roots in the manifest | whole archive, in one pass | -| partial fetch | yes, read `nexum.toml` before the component | no | -| mirrors | Swarm only | bzz, ipfs, https, anywhere | - -The asymmetry is structural rather than a preference. A mantaray root is a -hash over a *structure*, so only Swarm can recompute it; that is exactly what -buys per-entry verification and costs cross-scheme mirroring. A `TAR_ZST` -digest is a hash over *bytes*, so any transport can be checked by hashing what -it delivered, at the cost of verifying nothing until the whole archive is in -hand. - -Neither kind requires a canonical byte serialisation to be specified. Mantaray -already defines a deterministic structure, and an archive is committed to as -the exact bytes published. +`kind` is what makes the digest interpretable, and it is the reason no +canonical byte serialisation is specified anywhere in this document: a +mantaray manifest and a UnixFS DAG each already define a deterministic +structure, so there is nothing left to canonicalise. `SHA256` covers the +non-content-addressed case, where the published bytes are hashed directly. ### 0.1 Location -`BZZ_MANIFEST` is content-addressed: the commitment names the bytes, so it also -locates them. The URI list SHOULD be empty and a host resolves the digest -through whatever Swarm access it has. A handler that lists URIs anyway supplies -non-normative hints; a host MAY use them and MUST still verify against the -commitment. +`BZZ` and `IPFS` are content-addressed: the commitment names the bytes, so it +also locates them. For these kinds the URI list SHOULD be empty and a host +resolves the digest through whatever Swarm or IPFS access it has. A handler +that lists URIs anyway supplies non-normative hints; a host MAY use them and +MUST still verify against the commitment. -`TAR_ZST` is not self-locating. Its URI list MUST be non-empty. Any scheme may -appear there, including `ipfs://`, which verifies its own content in transit -but whose CID is not the commitment: a UnixFS CID depends on chunker and DAG -layout, which are publisher settings rather than properties of the content, so -it cannot be recomputed from bytes fetched elsewhere. +`SHA256` is not self-locating. Its URI list MUST be non-empty, and every URI +MUST resolve to bytes whose sha256 equals the digest. A `bytes32(0)` digest is uncommitted. Consumers MUST treat the surface as absent rather than fetching anything. ### 0.2 Verification -`BZZ_MANIFEST`: resolve the root, then verify each entry against the BMT root -the manifest carries for it. A single document (a descriptor) is committed to -as a leaf rather than a manifest. - -`TAR_ZST`: fetch the archive from any URI, verify `sha256(archive)` equals the -digest, then extract. Verifying the archive authenticates every byte in it, so -no per-entry digest is declared anywhere. Restating entry digests inside the -package would create a second source of truth and a disagreement to specify -around, without adding a check. - -Extraction is the attack surface an archive brings and a manifest does not. -Consumers MUST, before writing anything: +Verification is a chain from the on-chain commitment to the bytes actually +used, with no step taken on trust: -- reject duplicate entry paths, which is where archive parsers disagree; -- reject absolute paths, `..` traversal, and symlinks; -- cap entry count and total decompressed size, refusing the package rather - than expanding it. - -### 0.3 Compression - -Each container compresses at the level it can, so the component is stored -differently in each and `nexum.toml` is byte-identical across both: - -- `TAR_ZST`: entries are stored uncompressed; the archive compresses them. -- `BZZ_MANIFEST`: mantaray has no archive-level compression, so the component - is stored zstd-compressed at `.zst`, which is worth doing on a - network that charges per chunk. `nexum.toml` is stored uncompressed: it is - small, and a consumer reads and verifies it before deciding whether to fetch - the component at all. +| kind | commitment resolves to | entries verify by | +|---|---|---| +| `BZZ` | mantaray manifest, or a leaf for a single document | BMT roots carried in the manifest | +| `IPFS` | UnixFS directory, or a file for a single document | CIDs carried in the DAG | +| `SHA256` | a zip archive, or the document bytes | the archive digest covers every byte | -The decompressed-size cap of §0.2 applies to both. +No per-entry digest is declared anywhere off-chain. For `BZZ` and `IPFS` the +manifest already carries the root of each entry, and for `SHA256` verifying +the archive authenticates all of its contents at once. Restating those +digests in a manifest file would create a second source of truth and a +disagreement to specify around, without adding a check. ## 1. Handler descriptors @@ -152,13 +118,13 @@ interface IOrderDescriptor { * @dev MUST be emitted from the constructor of implementing contracts so * indexers discover the descriptor without polling. */ - event DescriptorUpdate(string[] uris, bytes32 digest, PackageKind kind); + event DescriptorUpdate(string[] uris, bytes32 digest, DigestKind kind); /** * @notice Locations of the handler descriptor document. - * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Required - * for `TAR_ZST`. Any URI listed is a hint: all MUST resolve to the - * same document bytes, never alternative content. + * @dev Empty for content-addressed kinds, which the commitment locates. + * Required for `SHA256`. Any URI listed is a hint: all MUST resolve + * to the same document bytes, never alternative content. */ function descriptorURI() external view returns (string[] memory uris); @@ -171,7 +137,7 @@ interface IOrderDescriptor { function descriptorCommitment() external view - returns (bytes32 digest, PackageKind kind); + returns (bytes32 digest, DigestKind kind); } ``` @@ -195,10 +161,10 @@ governs retrieval only. Where a URI is used it MUST be one of: | Scheme | Used with | |---|---| -| `bzz://` | `BZZ_MANIFEST`, as a hint; the commitment already locates the document | -| `ipfs://` | `TAR_ZST`, as a location; the CID is not the commitment | -| `data:` | either kind, for a document published in-band | -| `https:` | `TAR_ZST`, the common case | +| `bzz://` | `BZZ`, as a hint; the digest already locates the document | +| `ipfs://` | `IPFS`, as a hint | +| `data:` | any kind, for a document published in-band | +| `https:` | `SHA256`, where it is the only way to locate the document | `http:`, `file:`, and any URI resolving to loopback, link-local, or private address ranges are prohibited. Fetchers SHOULD disable redirects (or re-validate @@ -206,7 +172,7 @@ every hop against this policy), enforce a size cap (256 KiB RECOMMENDED), and enforce a total timeout. An `https:` URI is never trusted on its own: bytes fetched from one are used -only after they verify against the commitment, which for `TAR_ZST` is the whole +only after they verify against the commitment, which for `SHA256` is the whole of the integrity argument. ### 1.3 Document @@ -293,12 +259,12 @@ interface IOrderModule { * @notice Emitted when the module location or commitment changes. * @dev MUST be emitted from the constructor of implementing contracts. */ - event ModuleUpdate(string[] uris, bytes32 digest, PackageKind kind); + event ModuleUpdate(string[] uris, bytes32 digest, DigestKind kind); /** * @notice Locations of the module package. - * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Required - * for `TAR_ZST`. Any URI listed is a hint. + * @dev Empty for content-addressed kinds, which the commitment locates. + * Required for `SHA256`. Any URI listed is a hint. */ function moduleURI() external view returns (string[] memory uris); @@ -314,7 +280,7 @@ interface IOrderModule { function moduleCommitment() external view - returns (bytes32 digest, PackageKind kind); + returns (bytes32 digest, DigestKind kind); } ``` @@ -454,12 +420,11 @@ structural instead of a rule to enforce. #### Package -The commitment resolves to a package with the same logical layout under either -kind, differing only in how the component is stored (§0.3): +The commitment resolves to a package whose layout is the same across kinds: ``` -nexum.toml # uncompressed under both kinds -component.wasm # stored as component.wasm.zst under BZZ_MANIFEST +nexum.toml +component.wasm ``` ```toml @@ -486,21 +451,15 @@ pair = "WETH/USDC" linking, rather than failing on a missing import. - `[config]` is passed verbatim to `init`. A module that cannot run with the configuration it is given MUST fail there, not per poll. -- `component` names the logical artifact, not the stored entry, so this file - is byte-identical whichever kind a publisher chooses. - No file digests are declared. §0.2 covers why: the commitment already authenticates every entry. #### Execution -1. Resolve the commitment (§0.1) and verify it (§0.2), applying the extraction - rules for `TAR_ZST`. -2. Read `nexum.toml`. Under `BZZ_MANIFEST` this is one entry, verified and - parsed before the component is fetched at all; under `TAR_ZST` the whole - archive is already in hand. -3. Fetch the component, decompressing it under `BZZ_MANIFEST` (§0.3), and - instantiate it in the `videre:ccow/order-module` world with HTTP restricted - to `[capabilities.http].allow`. +1. Resolve the commitment (§0.1) and verify the package root (§0.2). +2. Read `nexum.toml` from the package, verifying its entry. +3. Instantiate `component.wasm` in the `videre:ccow/order-module` world, with + HTTP restricted to `[capabilities.http].allow`. 4. `init` with `[config]`. A failure here parks the handler; it is a packaging fault, not a transient one. 5. `build-offchain-input` per poll, dispatching on `outcome`: `ready` supplies diff --git a/script/deploy_AnvilStack.s.sol b/script/deploy_AnvilStack.s.sol index 01a11afe..0beb408b 100644 --- a/script/deploy_AnvilStack.s.sol +++ b/script/deploy_AnvilStack.s.sol @@ -25,6 +25,7 @@ import {TWAP} from "../src/types/twap/TWAP.sol"; import {GoodAfterTime} from "../src/types/GoodAfterTime.sol"; import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol"; import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; contract DeployAnvilStack is Script { // --- constants @@ -61,9 +62,9 @@ contract DeployAnvilStack is Script { // deploy the Composable CoW ComposableCow composableCow = new ComposableCow(address(settlement)); - new TWAP(composableCow, new string[](0), bytes32(0)); - new GoodAfterTime(new string[](0), bytes32(0)); - new PerpetualStableSwap(new string[](0), bytes32(0)); + new TWAP(composableCow, new string[](0), bytes32(0), DigestKind.BZZ); + new GoodAfterTime(new string[](0), bytes32(0), DigestKind.BZZ); + new PerpetualStableSwap(new string[](0), bytes32(0), DigestKind.BZZ); new TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_OrderTypes.s.sol b/script/deploy_OrderTypes.s.sol index f03d6913..3de2c62f 100644 --- a/script/deploy_OrderTypes.s.sol +++ b/script/deploy_OrderTypes.s.sol @@ -9,6 +9,7 @@ import {TWAP} from "../src/types/twap/TWAP.sol"; import {GoodAfterTime} from "../src/types/GoodAfterTime.sol"; import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol"; import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; contract DeployOrderTypes is Script { function run() external { @@ -16,9 +17,9 @@ contract DeployOrderTypes is Script { address composableCow = vm.envAddress("COMPOSABLE_COW"); vm.startBroadcast(deployerPrivateKey); - new TWAP(ComposableCow(composableCow), new string[](0), bytes32(0)); - new GoodAfterTime(new string[](0), bytes32(0)); - new PerpetualStableSwap(new string[](0), bytes32(0)); + new TWAP(ComposableCow(composableCow), new string[](0), bytes32(0), DigestKind.BZZ); + new GoodAfterTime(new string[](0), bytes32(0), DigestKind.BZZ); + new PerpetualStableSwap(new string[](0), bytes32(0), DigestKind.BZZ); new TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_ProdStack.s.sol b/script/deploy_ProdStack.s.sol index 77c90dde..6c97dd44 100644 --- a/script/deploy_ProdStack.s.sol +++ b/script/deploy_ProdStack.s.sol @@ -18,6 +18,7 @@ import {StopLoss} from "../src/types/StopLoss.sol"; // Value factories import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; contract DeployProdStack is Script { function run() external { @@ -33,11 +34,11 @@ contract DeployProdStack is Script { ComposableCow composableCow = new ComposableCow{salt: "v1.0.0"}(settlement); // Deploy order types - new TWAP{salt: "v1.0.0"}(composableCow, new string[](0), bytes32(0)); - new GoodAfterTime{salt: "v1.0.0"}(new string[](0), bytes32(0)); - new PerpetualStableSwap{salt: "v1.0.0"}(new string[](0), bytes32(0)); + new TWAP{salt: "v1.0.0"}(composableCow, new string[](0), bytes32(0), DigestKind.BZZ); + new GoodAfterTime{salt: "v1.0.0"}(new string[](0), bytes32(0), DigestKind.BZZ); + new PerpetualStableSwap{salt: "v1.0.0"}(new string[](0), bytes32(0), DigestKind.BZZ); new TradeAboveThreshold{salt: "v1.0.0"}(); - new StopLoss{salt: "v1.0.0"}(new string[](0), bytes32(0)); + new StopLoss{salt: "v1.0.0"}(new string[](0), bytes32(0), DigestKind.BZZ); // Deploy value factories new CurrentBlockTimestampFactory{salt: "v1.0.0"}(); diff --git a/src/OrderDescriptor.sol b/src/OrderDescriptor.sol index e658e940..5d66f288 100644 --- a/src/OrderDescriptor.sol +++ b/src/OrderDescriptor.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.0 <0.9.0; import {IOrderDescriptor} from "./interfaces/IOrderDescriptor.sol"; +import {DigestKind} from "./interfaces/DigestKind.sol"; import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; /** @@ -17,15 +18,30 @@ import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; * redeployment (the descriptor digest is per-deployment anyway). */ 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(); + string[] private _descriptorUris; bytes32 private immutable _DESCRIPTOR_DIGEST; + DigestKind private immutable _DESCRIPTOR_KIND; - constructor(string[] memory uris, bytes32 digest) { + constructor(string[] memory uris, bytes32 digest, DigestKind kind) { + if (digest != bytes32(0)) { + require(kind != DigestKind.SHA256 || uris.length > 0, DescriptorURIRequired()); + emit DescriptorUpdate(uris, digest, kind); + } else { + require(uris.length == 0, UncommittedDescriptorURI()); + } _descriptorUris = uris; _DESCRIPTOR_DIGEST = digest; - if (uris.length > 0) { - emit DescriptorUpdate(uris, digest); - } + _DESCRIPTOR_KIND = kind; } /** @@ -38,18 +54,20 @@ abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { /** * @inheritdoc IOrderDescriptor */ - function descriptorDigest() external view returns (bytes32) { - return _DESCRIPTOR_DIGEST; + function descriptorCommitment() external view returns (bytes32 digest, DigestKind 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. + * non-conformant per the discovery specification. The commitment is + * the gate, not the URI list, since a content-addressed 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 _descriptorUris.length > 0; + return _DESCRIPTOR_DIGEST != bytes32(0); } return super.supportsInterface(interfaceId); } diff --git a/src/OrderModule.sol b/src/OrderModule.sol index 5909d546..0c62beea 100644 --- a/src/OrderModule.sol +++ b/src/OrderModule.sol @@ -2,32 +2,42 @@ pragma solidity >=0.8.0 <0.9.0; import {IOrderModule} from "./interfaces/IOrderModule.sol"; +import {DigestKind} from "./interfaces/DigestKind.sol"; import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; /** * @title Order Module mixin - opt-in module commitment for handlers * @author mfw78 - * @dev Immutable by omission, as for `OrderDescriptor`. A zero digest is - * non-conformant (the digest is the module's canonical identity), so - * construction with any URIs requires a non-zero digest; constructed - * with no URIs the handler does not advertise `IOrderModule`. + * @dev Immutable by omission, as for `OrderDescriptor`. The commitment is what + * advertises `IOrderModule`, not the URI list: a content-addressed + * commitment locates its own package and so publishes no URI. Constructed + * with a zero digest, the handler does not advertise the interface. */ abstract contract OrderModule is IOrderModule, BaseConditionalOrder { /** - * @dev A module commitment requires a non-zero digest + * @dev URIs cannot be published without a commitment to verify them against */ - error InvalidModuleDigest(); + error UncommittedModuleURI(); + + /** + * @dev `SHA256` does not locate the package, so it requires a URI + */ + error ModuleURIRequired(); string[] private _moduleUris; bytes32 private immutable _MODULE_DIGEST; + DigestKind private immutable _MODULE_KIND; - constructor(string[] memory uris, bytes32 digest) { - if (uris.length > 0) { - require(digest != bytes32(0), InvalidModuleDigest()); - emit ModuleUpdate(uris, digest); + constructor(string[] memory uris, bytes32 digest, DigestKind kind) { + if (digest != bytes32(0)) { + require(kind != DigestKind.SHA256 || uris.length > 0, ModuleURIRequired()); + emit ModuleUpdate(uris, digest, kind); + } else { + require(uris.length == 0, UncommittedModuleURI()); } _moduleUris = uris; _MODULE_DIGEST = digest; + _MODULE_KIND = kind; } /** @@ -40,8 +50,8 @@ abstract contract OrderModule is IOrderModule, BaseConditionalOrder { /** * @inheritdoc IOrderModule */ - function moduleDigest() external view returns (bytes32) { - return _MODULE_DIGEST; + function moduleCommitment() external view returns (bytes32 digest, DigestKind kind) { + return (_MODULE_DIGEST, _MODULE_KIND); } /** @@ -49,7 +59,7 @@ abstract contract OrderModule is IOrderModule, BaseConditionalOrder { */ function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { if (interfaceId == type(IOrderModule).interfaceId) { - return _moduleUris.length > 0; + return _MODULE_DIGEST != bytes32(0); } return super.supportsInterface(interfaceId); } diff --git a/src/interfaces/DigestKind.sol b/src/interfaces/DigestKind.sol new file mode 100644 index 00000000..b0a0cbaa --- /dev/null +++ b/src/interfaces/DigestKind.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +/** + * @dev The addressing scheme an on-chain commitment digest is expressed in. + * Verification uses that scheme's own primitives, so no canonical byte + * serialisation is defined (see `docs/discovery.md` §0). + * + * `BZZ` and `IPFS` are content-addressed: the digest locates the bytes as + * well as verifying them, so no URI is required. `SHA256` locates + * nothing and requires at least one URI. + */ +enum DigestKind { + BZZ, // Swarm BMT root + IPFS, // sha2-256 multihash digest of the root CID + SHA256 // sha256 over the published bytes +} diff --git a/src/interfaces/IOrderDescriptor.sol b/src/interfaces/IOrderDescriptor.sol index 170e04ee..5b37231d 100644 --- a/src/interfaces/IOrderDescriptor.sol +++ b/src/interfaces/IOrderDescriptor.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {DigestKind} from "./DigestKind.sol"; + /** * @title Order Descriptor - declarative handler metadata for discovery * @author mfw78 @@ -16,20 +18,21 @@ interface IOrderDescriptor { * @dev MUST be emitted from the constructor of implementing contracts so * indexers discover the descriptor without polling. */ - event DescriptorUpdate(string[] uris, bytes32 digest); + event DescriptorUpdate(string[] uris, bytes32 digest, DigestKind kind); /** * @notice Locations of the handler descriptor document. - * @dev All URIs MUST reference the same document bytes (redundant - * mirrors), never alternative content. + * @dev Empty for content-addressed kinds, which the commitment locates. + * Non-empty for `SHA256`. Any URI listed is a retrieval hint and MUST + * resolve to the same document bytes, never alternative content. */ function descriptorURI() external view returns (string[] memory uris); /** - * @notice keccak256 of the exact descriptor document bytes as published. - * @dev Consumers MUST verify fetched bytes against this digest before - * parsing when the URI is not content-addressed. bytes32(0) means - * uncommitted; consumers MUST treat such descriptors as untrusted. + * @notice Commitment to the descriptor document. + * @dev `digest` is the document root in `kind`'s addressing. Consumers + * MUST verify fetched bytes against it before parsing. `bytes32(0)` + * means uncommitted; treat such descriptors as absent. */ - function descriptorDigest() external view returns (bytes32); + function descriptorCommitment() external view returns (bytes32 digest, DigestKind kind); } diff --git a/src/interfaces/IOrderModule.sol b/src/interfaces/IOrderModule.sol index cda49850..8e229285 100644 --- a/src/interfaces/IOrderModule.sol +++ b/src/interfaces/IOrderModule.sol @@ -1,35 +1,40 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; +import {DigestKind} from "./DigestKind.sol"; + /** * @title Order Module - executable client module for custom handlers * @author mfw78 * @dev Sidecar interface with its own ERC-165 id. An order module constructs * `offchainInput` for handlers that signal `NEEDS_INPUT` - the one * aspect of servicing an order that cannot be derived on-chain. Module - * output is untrusted input to on-chain verification; the runtime - * contract is defined by the reference monitoring service - * (see `docs/discovery.md` §2). + * output is untrusted input to on-chain verification. The module is a + * WebAssembly component; its export contract is `ccow:module` and the + * host surface is `videre:ccow` (see `docs/discovery.md` §2). */ interface IOrderModule { /** * @notice Emitted when the module location or commitment changes. * @dev MUST be emitted from the constructor of implementing contracts. */ - event ModuleUpdate(string[] uris, bytes32 digest); + event ModuleUpdate(string[] uris, bytes32 digest, DigestKind kind); /** - * @notice Locations of the module. All URIs MUST reference the same bytes. + * @notice Locations of the module package. + * @dev Empty for content-addressed kinds, which the commitment locates. + * Non-empty for `SHA256`. Any URI listed is a retrieval hint and MUST + * resolve to the same package. */ function moduleURI() external view returns (string[] memory uris); /** - * @notice keccak256 of the exact module bytes. MUST be non-zero. - * @dev The module's canonical identity and the final pre-execution gate. - * Fetch integrity is per-transport (a Swarm reference, CID, or - * RFC 6920 hash verifies the fetch); the digest is what consent - * lists, caches, and budgets key by. Consumers MUST verify - * keccak256(bytes) == moduleDigest() before execution. + * @notice Commitment to the module package. `digest` MUST be non-zero. + * @dev The package root in `kind`'s addressing, and the module's canonical + * identity: consent lists, caches, and budgets key by it, so changing + * where a package is mirrored never invalidates operator trust in the + * same code. Consumers MUST verify the package against it before + * execution and MUST NOT serve cached bytes against any other key. */ - function moduleDigest() external view returns (bytes32); + function moduleCommitment() external view returns (bytes32 digest, DigestKind kind); } diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index a726aad7..ea5856c6 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -13,6 +13,7 @@ import { } from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; +import {DigestKind} from "../interfaces/DigestKind.sol"; // --- error strings /** @@ -45,8 +46,8 @@ error PriceCheckerFailed(); * checked before the order is placed. */ contract GoodAfterTime is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_) - OrderDescriptor(descriptorUris, descriptorDigest_) + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, DigestKind descriptorKind) + OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) {} using SafeCast for uint256; diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index d32f6d62..f410bee2 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -11,6 +11,7 @@ import { import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; +import {DigestKind} from "../interfaces/DigestKind.sol"; // --- error strings /** @@ -23,8 +24,8 @@ error NotFunded(); * taking decimals into account (and adding specifiable spread) */ contract PerpetualStableSwap is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_) - OrderDescriptor(descriptorUris, descriptorDigest_) + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, DigestKind descriptorKind) + OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) {} /** diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index 80b7a785..d6ad0c6b 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -11,6 +11,7 @@ import { import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; +import {DigestKind} from "../interfaces/DigestKind.sol"; // --- error strings @@ -42,8 +43,8 @@ error OrderExpired(); * @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_) - OrderDescriptor(descriptorUris, descriptorDigest_) + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, DigestKind descriptorKind) + OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) {} /** diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index 34c97eb6..437007e2 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -15,6 +15,7 @@ import {IOrderManifest} from "../../interfaces/IOrderManifest.sol"; import {TWAPOrder} from "./libraries/TWAPOrder.sol"; import {TWAPOrderMathLib, AfterTwapFinish} from "./libraries/TWAPOrderMathLib.sol"; import {OrderDescriptor} from "../../OrderDescriptor.sol"; +import {DigestKind} from "../../interfaces/DigestKind.sol"; // --- error strings @@ -40,9 +41,12 @@ contract TWAP is OrderDescriptor { ComposableCow public immutable composableCow; - constructor(ComposableCow _composableCow, string[] memory descriptorUris, bytes32 descriptorDigest_) - OrderDescriptor(descriptorUris, descriptorDigest_) - { + constructor( + ComposableCow _composableCow, + string[] memory descriptorUris, + bytes32 descriptorDigest_, + DigestKind descriptorKind + ) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) { composableCow = _composableCow; } diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index 49467559..37de0726 100644 --- a/test/ComposableCow.base.t.sol +++ b/test/ComposableCow.base.t.sol @@ -28,6 +28,7 @@ import {ReceiverLock} from "../src/guards/ReceiverLock.sol"; import {IValueFactory} from "../src/interfaces/IValueFactory.sol"; import {ISwapGuard, ComposableCow, GPv2Order} from "../src/ComposableCow.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; contract BaseComposableCowTest is Base, Merkle { using ComposableCowLib for IConditionalOrder.ConditionalOrderParams; @@ -75,7 +76,7 @@ contract BaseComposableCowTest is Base, Merkle { passThrough = new TestConditionalOrderGenerator(); mirror = new MirrorConditionalOrder(); - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); } /** diff --git a/test/ComposableCow.discovery.t.sol b/test/ComposableCow.discovery.t.sol index e91da016..98706f28 100644 --- a/test/ComposableCow.discovery.t.sol +++ b/test/ComposableCow.discovery.t.sol @@ -11,6 +11,7 @@ import {IOrderModule} from "../src/interfaces/IOrderModule.sol"; import {OrderDescriptor} from "../src/OrderDescriptor.sol"; import {OrderModule} from "../src/OrderModule.sol"; import {StopLoss} from "../src/types/StopLoss.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; error TestNoOrder(); @@ -18,7 +19,7 @@ error TestNoOrder(); * @dev Minimal handler committing a module: the OrderModule mixin under test */ contract ModuleHandler is OrderModule { - constructor(string[] memory uris, bytes32 digest) OrderModule(uris, digest) {} + constructor(string[] memory uris, bytes32 digest, DigestKind kind) OrderModule(uris, digest, kind) {} function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) public @@ -48,7 +49,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * super chain */ function test_descriptor_CommittedAdvertisesAndRoundTrips() public { - StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); assertTrue(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); @@ -57,7 +58,9 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); assertEq(handler.descriptorURI()[0], testDescriptorUris()[0]); - assertEq(handler.descriptorDigest(), TEST_DESCRIPTOR_DIGEST); + (bytes32 digest, DigestKind kind) = handler.descriptorCommitment(); + assertEq(digest, TEST_DESCRIPTOR_DIGEST); + assertEq(uint256(kind), uint256(DigestKind.BZZ)); } /** @@ -65,7 +68,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)); + StopLoss handler = new StopLoss(new string[](0), bytes32(0), DigestKind.BZZ); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); // the handler remains a fully functional generator @@ -78,8 +81,8 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { */ function test_descriptor_ConstructorEmitsUpdate() public { vm.expectEmit(true, true, true, true); - emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); - new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); } // --- module --- @@ -91,29 +94,49 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { bytes32 digest = keccak256("module component bytes"); vm.expectEmit(true, true, true, true); - emit IOrderModule.ModuleUpdate(moduleUris(), digest); - ModuleHandler handler = new ModuleHandler(moduleUris(), digest); + emit IOrderModule.ModuleUpdate(moduleUris(), digest, DigestKind.BZZ); + ModuleHandler handler = new ModuleHandler(moduleUris(), digest, DigestKind.BZZ); assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertEq(handler.moduleURI()[0], moduleUris()[0]); - assertEq(handler.moduleDigest(), digest); + (bytes32 got, DigestKind kind) = handler.moduleCommitment(); + assertEq(got, digest); + assertEq(uint256(kind), uint256(DigestKind.BZZ)); } /** - * @dev A zero digest is non-conformant: the digest is the module's - * canonical identity and the final pre-execution gate + * @dev URIs without a commitment are unverifiable, so publishing them is + * non-conformant */ - function test_module_RevertsZeroDigest() public { - vm.expectRevert(OrderModule.InvalidModuleDigest.selector); - new ModuleHandler(moduleUris(), bytes32(0)); + function test_module_RevertsUncommittedURI() public { + vm.expectRevert(OrderModule.UncommittedModuleURI.selector); + new ModuleHandler(moduleUris(), bytes32(0), DigestKind.BZZ); + } + + /** + * @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"), DigestKind.SHA256); + } + + /** + * @dev A content-addressed commitment locates its own package, so it + * advertises with no URI published at all + */ + function test_module_ContentAddressedNeedsNoURI() public { + ModuleHandler handler = new ModuleHandler(new string[](0), keccak256("pkg"), DigestKind.BZZ); + assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); + assertEq(handler.moduleURI().length, 0); } /** * @dev No module committed: no advertising, handler still functions */ function test_module_UncommittedDoesNotAdvertise() public { - ModuleHandler handler = new ModuleHandler(new string[](0), bytes32(0)); + ModuleHandler handler = new ModuleHandler(new string[](0), bytes32(0), DigestKind.BZZ); assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); } @@ -123,7 +146,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * empty - the discovery trigger end to end */ function test_module_NeedsInputSignal() public { - ModuleHandler handler = new ModuleHandler(moduleUris(), keccak256("module")); + ModuleHandler handler = new ModuleHandler(moduleUris(), keccak256("module"), DigestKind.BZZ); IConditionalOrderGenerator.GeneratorResult memory result = handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); diff --git a/test/ComposableCow.gat.t.sol b/test/ComposableCow.gat.t.sol index 560ccdec..3f20a755 100644 --- a/test/ComposableCow.gat.t.sol +++ b/test/ComposableCow.gat.t.sol @@ -23,6 +23,7 @@ import { BalanceInsufficient, PriceCheckerFailed } from "../src/types/GoodAfterTime.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; contract ComposableCowGatTest is BaseComposableCowTest { using ComposableCowLib for IConditionalOrder.ConditionalOrderParams[]; @@ -36,7 +37,7 @@ contract ComposableCowGatTest is BaseComposableCowTest { super.setUp(); // deploy the GAT handler - gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); // deploy the test expected out calculator testOutCalculator = new TestExpectedOutCalculator(); diff --git a/test/ComposableCow.manifest.t.sol b/test/ComposableCow.manifest.t.sol index 33ad5808..995f598c 100644 --- a/test/ComposableCow.manifest.t.sol +++ b/test/ComposableCow.manifest.t.sol @@ -18,6 +18,7 @@ import {OrderNotInitialized} from "../src/types/twap/TWAP.sol"; import {PerpetualStableSwap, NotFunded} from "../src/types/PerpetualStableSwap.sol"; import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; import {IValueFactory} from "../src/interfaces/IValueFactory.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; /** * @dev Test reason errors, as a handler would declare them @@ -41,7 +42,7 @@ contract ComposableCowManifestTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); } diff --git a/test/ComposableCow.stoploss.t.sol b/test/ComposableCow.stoploss.t.sol index 7cba687b..95972274 100644 --- a/test/ComposableCow.stoploss.t.sol +++ b/test/ComposableCow.stoploss.t.sol @@ -17,6 +17,7 @@ import { OracleInvalidPrice, OrderExpired } from "../src/types/StopLoss.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; contract ComposableCowStopLossTest is BaseComposableCowTest { IERC20 immutable SELL_TOKEN = IERC20(address(0x1)); @@ -33,7 +34,7 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); } function priceToAddress(int256 price) internal returns (address) { diff --git a/test/ComposableCow.twap.t.sol b/test/ComposableCow.twap.t.sol index 6d43d452..356c84b5 100644 --- a/test/ComposableCow.twap.t.sol +++ b/test/ComposableCow.twap.t.sol @@ -33,6 +33,7 @@ import { import {TWAPOrderMathLib, BeforeTwapStart, AfterTwapFinish} from "../src/types/twap/libraries/TWAPOrderMathLib.sol"; import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; +import {DigestKind} from "../src/interfaces/DigestKind.sol"; uint256 constant SELL_AMOUNT = 24000e18; uint256 constant LIMIT_PRICE = 100e18; @@ -55,7 +56,7 @@ contract ComposableCowTwapTest is BaseComposableCowTest { super.setUp(); // deploy the TWAP handler - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); // deploy the current block timestamp factory currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); From 3f4596a551513be9de0f48259b65f48b1502f243 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 31 Jul 2026 06:07:56 +0000 Subject: [PATCH 3/5] refactor!: DigestKind becomes PackageKind with two variants Follows the spec narrowing: `{BZZ, IPFS, SHA256}` becomes `{BZZ_MANIFEST, TAR_ZST}`. The name changes with it because the value now says how the package is put together, not merely which hash function was used, and that is what determines whether the commitment locates itself and whether it can be mirrored on another scheme. BREAKING CHANGE: `DigestKind` is renamed and its variants replaced; the constructor argument and event field change type accordingly. --- docs/discovery.md | 151 ++++++++++++++++++---------- script/deploy_AnvilStack.s.sol | 8 +- script/deploy_OrderTypes.s.sol | 8 +- script/deploy_ProdStack.s.sol | 10 +- src/OrderDescriptor.sol | 14 +-- src/OrderModule.sol | 14 +-- src/interfaces/DigestKind.sol | 17 ---- src/interfaces/IOrderDescriptor.sol | 12 +-- src/interfaces/IOrderModule.sol | 12 +-- src/interfaces/PackageKind.sol | 17 ++++ src/types/GoodAfterTime.sol | 4 +- src/types/PerpetualStableSwap.sol | 4 +- src/types/StopLoss.sol | 4 +- src/types/twap/TWAP.sol | 4 +- test/ComposableCow.base.t.sol | 4 +- test/ComposableCow.discovery.t.sol | 34 +++---- test/ComposableCow.gat.t.sol | 4 +- test/ComposableCow.manifest.t.sol | 4 +- test/ComposableCow.stoploss.t.sol | 4 +- test/ComposableCow.twap.t.sol | 4 +- 20 files changed, 187 insertions(+), 146 deletions(-) delete mode 100644 src/interfaces/DigestKind.sol create mode 100644 src/interfaces/PackageKind.sol diff --git a/docs/discovery.md b/docs/discovery.md index 63f97f8d..55cb9bdc 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -35,8 +35,8 @@ described in RFC 2119. from the chain. - **Executable code is pulled only against an on-chain commitment.** Module bytes MUST verify against `moduleCommitment()` before execution. The - commitment is a root in a named addressing scheme (§0), so verification uses - that scheme's own primitives rather than a separate hash over the fetched + commitment is a root in a named addressing packaging (§0), so verification uses + that packaging's own primitives rather than a separate hash over the fetched bytes. Verified modules MAY be fetched and executed automatically, but only inside the sandbox and budget regime of §2. - **Feature detection, never gating.** Every interface here is a sidecar with @@ -48,10 +48,10 @@ described in RFC 2119. commitment resolves to: a descriptor resolves to one JSON document, a module to a package. Proof payloads are the exception and verify by recomputing the merkle root, since they are authored per root rather than published once. -- **Content addressing locates as well as verifies.** For `BZZ` and `IPFS` the +- **Content addressing locates as well as verifies.** A `BZZ_MANIFEST` commitment already names where the bytes live, so a handler publishes no URI and no gateway is written into a deployed contract. Retrieval is the host's - concern. URIs exist for `SHA256`, where the digest locates nothing. + concern. URIs exist for `TAR_ZST`, where the digest locates nothing. - **Fail closed.** Unknown URI schemes, unsupported or reverting views, and unverifiable documents are treated as "no discovery", never as errors to retry aggressively and never as data to trust. @@ -59,53 +59,87 @@ described in RFC 2119. ## 0. Commitments Descriptors (§1) and modules (§2) are both committed to on-chain as a -`(bytes32 digest, DigestKind kind)` pair. `kind` names the addressing scheme -the digest is expressed in; verification uses that scheme's own primitives. +`(bytes32 digest, PackageKind kind)` pair. `kind` names how the bytes are +packaged and therefore how the digest is computed and verified. ```solidity -enum DigestKind { - BZZ, // digest is a Swarm BMT root - IPFS, // digest is the sha2-256 multihash digest of the root CID - SHA256 // digest is sha256 over the published bytes +enum PackageKind { + BZZ_MANIFEST, // mantaray manifest root + TAR_ZST // sha256 of a .tar.zst archive } ``` -`kind` is what makes the digest interpretable, and it is the reason no -canonical byte serialisation is specified anywhere in this document: a -mantaray manifest and a UnixFS DAG each already define a deterministic -structure, so there is nothing left to canonicalise. `SHA256` covers the -non-content-addressed case, where the published bytes are hashed directly. +The two kinds trade the same two properties against each other, and a +publisher picks which one it wants: + +| | `BZZ_MANIFEST` | `TAR_ZST` | +|---|---|---| +| locates itself | yes, the root is the Swarm reference | no, a URI is required | +| verification | per entry, from BMT roots in the manifest | whole archive, in one pass | +| partial fetch | yes, read `nexum.toml` before the component | no | +| mirrors | Swarm only | bzz, ipfs, https, anywhere | + +The asymmetry is structural rather than a preference. A mantaray root is a +hash over a *structure*, so only Swarm can recompute it; that is exactly what +buys per-entry verification and costs cross-scheme mirroring. A `TAR_ZST` +digest is a hash over *bytes*, so any transport can be checked by hashing what +it delivered, at the cost of verifying nothing until the whole archive is in +hand. + +Neither kind requires a canonical byte serialisation to be specified. Mantaray +already defines a deterministic structure, and an archive is committed to as +the exact bytes published. ### 0.1 Location -`BZZ` and `IPFS` are content-addressed: the commitment names the bytes, so it -also locates them. For these kinds the URI list SHOULD be empty and a host -resolves the digest through whatever Swarm or IPFS access it has. A handler -that lists URIs anyway supplies non-normative hints; a host MAY use them and -MUST still verify against the commitment. +`BZZ_MANIFEST` is content-addressed: the commitment names the bytes, so it also +locates them. The URI list SHOULD be empty and a host resolves the digest +through whatever Swarm access it has. A handler that lists URIs anyway supplies +non-normative hints; a host MAY use them and MUST still verify against the +commitment. -`SHA256` is not self-locating. Its URI list MUST be non-empty, and every URI -MUST resolve to bytes whose sha256 equals the digest. +`TAR_ZST` is not self-locating. Its URI list MUST be non-empty. Any scheme may +appear there, including `ipfs://`, which verifies its own content in transit +but whose CID is not the commitment: a UnixFS CID depends on chunker and DAG +layout, which are publisher settings rather than properties of the content, so +it cannot be recomputed from bytes fetched elsewhere. A `bytes32(0)` digest is uncommitted. Consumers MUST treat the surface as absent rather than fetching anything. ### 0.2 Verification -Verification is a chain from the on-chain commitment to the bytes actually -used, with no step taken on trust: +`BZZ_MANIFEST`: resolve the root, then verify each entry against the BMT root +the manifest carries for it. A single document (a descriptor) is committed to +as a leaf rather than a manifest. -| kind | commitment resolves to | entries verify by | -|---|---|---| -| `BZZ` | mantaray manifest, or a leaf for a single document | BMT roots carried in the manifest | -| `IPFS` | UnixFS directory, or a file for a single document | CIDs carried in the DAG | -| `SHA256` | a zip archive, or the document bytes | the archive digest covers every byte | +`TAR_ZST`: fetch the archive from any URI, verify `sha256(archive)` equals the +digest, then extract. Verifying the archive authenticates every byte in it, so +no per-entry digest is declared anywhere. Restating entry digests inside the +package would create a second source of truth and a disagreement to specify +around, without adding a check. + +Extraction is the attack surface an archive brings and a manifest does not. +Consumers MUST, before writing anything: + +- reject duplicate entry paths, which is where archive parsers disagree; +- reject absolute paths, `..` traversal, and symlinks; +- cap entry count and total decompressed size, refusing the package rather + than expanding it. + +### 0.3 Compression + +Each container compresses at the level it can, so the component is stored +differently in each and `nexum.toml` is byte-identical across both: + +- `TAR_ZST`: entries are stored uncompressed; the archive compresses them. +- `BZZ_MANIFEST`: mantaray has no archive-level compression, so the component + is stored zstd-compressed at `.zst`, which is worth doing on a + network that charges per chunk. `nexum.toml` is stored uncompressed: it is + small, and a consumer reads and verifies it before deciding whether to fetch + the component at all. -No per-entry digest is declared anywhere off-chain. For `BZZ` and `IPFS` the -manifest already carries the root of each entry, and for `SHA256` verifying -the archive authenticates all of its contents at once. Restating those -digests in a manifest file would create a second source of truth and a -disagreement to specify around, without adding a check. +The decompressed-size cap of §0.2 applies to both. ## 1. Handler descriptors @@ -118,13 +152,13 @@ interface IOrderDescriptor { * @dev MUST be emitted from the constructor of implementing contracts so * indexers discover the descriptor without polling. */ - event DescriptorUpdate(string[] uris, bytes32 digest, DigestKind kind); + event DescriptorUpdate(string[] uris, bytes32 digest, PackageKind kind); /** * @notice Locations of the handler descriptor document. - * @dev Empty for content-addressed kinds, which the commitment locates. - * Required for `SHA256`. Any URI listed is a hint: all MUST resolve - * to the same document bytes, never alternative content. + * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Required + * for `TAR_ZST`. Any URI listed is a hint: all MUST resolve to the + * same document bytes, never alternative content. */ function descriptorURI() external view returns (string[] memory uris); @@ -137,7 +171,7 @@ interface IOrderDescriptor { function descriptorCommitment() external view - returns (bytes32 digest, DigestKind kind); + returns (bytes32 digest, PackageKind kind); } ``` @@ -161,10 +195,10 @@ governs retrieval only. Where a URI is used it MUST be one of: | Scheme | Used with | |---|---| -| `bzz://` | `BZZ`, as a hint; the digest already locates the document | -| `ipfs://` | `IPFS`, as a hint | -| `data:` | any kind, for a document published in-band | -| `https:` | `SHA256`, where it is the only way to locate the document | +| `bzz://` | `BZZ_MANIFEST`, as a hint; the commitment already locates the document | +| `ipfs://` | `TAR_ZST`, as a location; the CID is not the commitment | +| `data:` | either kind, for a document published in-band | +| `https:` | `TAR_ZST`, the common case | `http:`, `file:`, and any URI resolving to loopback, link-local, or private address ranges are prohibited. Fetchers SHOULD disable redirects (or re-validate @@ -172,7 +206,7 @@ every hop against this policy), enforce a size cap (256 KiB RECOMMENDED), and enforce a total timeout. An `https:` URI is never trusted on its own: bytes fetched from one are used -only after they verify against the commitment, which for `SHA256` is the whole +only after they verify against the commitment, which for `TAR_ZST` is the whole of the integrity argument. ### 1.3 Document @@ -259,12 +293,12 @@ interface IOrderModule { * @notice Emitted when the module location or commitment changes. * @dev MUST be emitted from the constructor of implementing contracts. */ - event ModuleUpdate(string[] uris, bytes32 digest, DigestKind kind); + event ModuleUpdate(string[] uris, bytes32 digest, PackageKind kind); /** * @notice Locations of the module package. - * @dev Empty for content-addressed kinds, which the commitment locates. - * Required for `SHA256`. Any URI listed is a hint. + * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Required + * for `TAR_ZST`. Any URI listed is a hint. */ function moduleURI() external view returns (string[] memory uris); @@ -280,7 +314,7 @@ interface IOrderModule { function moduleCommitment() external view - returns (bytes32 digest, DigestKind kind); + returns (bytes32 digest, PackageKind kind); } ``` @@ -420,11 +454,12 @@ structural instead of a rule to enforce. #### Package -The commitment resolves to a package whose layout is the same across kinds: +The commitment resolves to a package with the same logical layout under either +kind, differing only in how the component is stored (§0.3): ``` -nexum.toml -component.wasm +nexum.toml # uncompressed under both kinds +component.wasm # stored as component.wasm.zst under BZZ_MANIFEST ``` ```toml @@ -451,15 +486,21 @@ pair = "WETH/USDC" linking, rather than failing on a missing import. - `[config]` is passed verbatim to `init`. A module that cannot run with the configuration it is given MUST fail there, not per poll. +- `component` names the logical artifact, not the stored entry, so this file + is byte-identical whichever kind a publisher chooses. - No file digests are declared. §0.2 covers why: the commitment already authenticates every entry. #### Execution -1. Resolve the commitment (§0.1) and verify the package root (§0.2). -2. Read `nexum.toml` from the package, verifying its entry. -3. Instantiate `component.wasm` in the `videre:ccow/order-module` world, with - HTTP restricted to `[capabilities.http].allow`. +1. Resolve the commitment (§0.1) and verify it (§0.2), applying the extraction + rules for `TAR_ZST`. +2. Read `nexum.toml`. Under `BZZ_MANIFEST` this is one entry, verified and + parsed before the component is fetched at all; under `TAR_ZST` the whole + archive is already in hand. +3. Fetch the component, decompressing it under `BZZ_MANIFEST` (§0.3), and + instantiate it in the `videre:ccow/order-module` world with HTTP restricted + to `[capabilities.http].allow`. 4. `init` with `[config]`. A failure here parks the handler; it is a packaging fault, not a transient one. 5. `build-offchain-input` per poll, dispatching on `outcome`: `ready` supplies diff --git a/script/deploy_AnvilStack.s.sol b/script/deploy_AnvilStack.s.sol index 0beb408b..478535fb 100644 --- a/script/deploy_AnvilStack.s.sol +++ b/script/deploy_AnvilStack.s.sol @@ -25,7 +25,7 @@ import {TWAP} from "../src/types/twap/TWAP.sol"; import {GoodAfterTime} from "../src/types/GoodAfterTime.sol"; import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol"; import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; contract DeployAnvilStack is Script { // --- constants @@ -62,9 +62,9 @@ contract DeployAnvilStack is Script { // deploy the Composable CoW ComposableCow composableCow = new ComposableCow(address(settlement)); - new TWAP(composableCow, new string[](0), bytes32(0), DigestKind.BZZ); - new GoodAfterTime(new string[](0), bytes32(0), DigestKind.BZZ); - new PerpetualStableSwap(new string[](0), bytes32(0), DigestKind.BZZ); + 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 TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_OrderTypes.s.sol b/script/deploy_OrderTypes.s.sol index 3de2c62f..9fbab509 100644 --- a/script/deploy_OrderTypes.s.sol +++ b/script/deploy_OrderTypes.s.sol @@ -9,7 +9,7 @@ import {TWAP} from "../src/types/twap/TWAP.sol"; import {GoodAfterTime} from "../src/types/GoodAfterTime.sol"; import {PerpetualStableSwap} from "../src/types/PerpetualStableSwap.sol"; import {TradeAboveThreshold} from "../src/types/TradeAboveThreshold.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; contract DeployOrderTypes is Script { function run() external { @@ -17,9 +17,9 @@ contract DeployOrderTypes is Script { address composableCow = vm.envAddress("COMPOSABLE_COW"); vm.startBroadcast(deployerPrivateKey); - new TWAP(ComposableCow(composableCow), new string[](0), bytes32(0), DigestKind.BZZ); - new GoodAfterTime(new string[](0), bytes32(0), DigestKind.BZZ); - new PerpetualStableSwap(new string[](0), bytes32(0), DigestKind.BZZ); + 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 TradeAboveThreshold(); vm.stopBroadcast(); diff --git a/script/deploy_ProdStack.s.sol b/script/deploy_ProdStack.s.sol index 6c97dd44..81f22a16 100644 --- a/script/deploy_ProdStack.s.sol +++ b/script/deploy_ProdStack.s.sol @@ -18,7 +18,7 @@ import {StopLoss} from "../src/types/StopLoss.sol"; // Value factories import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; contract DeployProdStack is Script { function run() external { @@ -34,11 +34,11 @@ contract DeployProdStack is Script { ComposableCow composableCow = new ComposableCow{salt: "v1.0.0"}(settlement); // Deploy order types - new TWAP{salt: "v1.0.0"}(composableCow, new string[](0), bytes32(0), DigestKind.BZZ); - new GoodAfterTime{salt: "v1.0.0"}(new string[](0), bytes32(0), DigestKind.BZZ); - new PerpetualStableSwap{salt: "v1.0.0"}(new string[](0), bytes32(0), DigestKind.BZZ); + new TWAP{salt: "v1.0.0"}(composableCow, new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + new GoodAfterTime{salt: "v1.0.0"}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + new PerpetualStableSwap{salt: "v1.0.0"}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); new TradeAboveThreshold{salt: "v1.0.0"}(); - new StopLoss{salt: "v1.0.0"}(new string[](0), bytes32(0), DigestKind.BZZ); + new StopLoss{salt: "v1.0.0"}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); // Deploy value factories new CurrentBlockTimestampFactory{salt: "v1.0.0"}(); diff --git a/src/OrderDescriptor.sol b/src/OrderDescriptor.sol index 5d66f288..b5f64205 100644 --- a/src/OrderDescriptor.sol +++ b/src/OrderDescriptor.sol @@ -2,7 +2,7 @@ pragma solidity >=0.8.0 <0.9.0; import {IOrderDescriptor} from "./interfaces/IOrderDescriptor.sol"; -import {DigestKind} from "./interfaces/DigestKind.sol"; +import {PackageKind} from "./interfaces/PackageKind.sol"; import {BaseConditionalOrder} from "./BaseConditionalOrder.sol"; /** @@ -24,17 +24,17 @@ abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { error UncommittedDescriptorURI(); /** - * @dev `SHA256` does not locate the document, so it requires a URI + * @dev `TAR_ZST` does not locate the document, so it requires a URI */ error DescriptorURIRequired(); string[] private _descriptorUris; bytes32 private immutable _DESCRIPTOR_DIGEST; - DigestKind private immutable _DESCRIPTOR_KIND; + PackageKind private immutable _DESCRIPTOR_KIND; - constructor(string[] memory uris, bytes32 digest, DigestKind kind) { + constructor(string[] memory uris, bytes32 digest, PackageKind kind) { if (digest != bytes32(0)) { - require(kind != DigestKind.SHA256 || uris.length > 0, DescriptorURIRequired()); + require(kind != PackageKind.TAR_ZST || uris.length > 0, DescriptorURIRequired()); emit DescriptorUpdate(uris, digest, kind); } else { require(uris.length == 0, UncommittedDescriptorURI()); @@ -54,7 +54,7 @@ abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { /** * @inheritdoc IOrderDescriptor */ - function descriptorCommitment() external view returns (bytes32 digest, DigestKind kind) { + function descriptorCommitment() external view returns (bytes32 digest, PackageKind kind) { return (_DESCRIPTOR_DIGEST, _DESCRIPTOR_KIND); } @@ -62,7 +62,7 @@ abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { * @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 content-addressed commitment + * 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) { diff --git a/src/OrderModule.sol b/src/OrderModule.sol index 0c62beea..b38f160f 100644 --- a/src/OrderModule.sol +++ b/src/OrderModule.sol @@ -2,14 +2,14 @@ pragma solidity >=0.8.0 <0.9.0; import {IOrderModule} from "./interfaces/IOrderModule.sol"; -import {DigestKind} from "./interfaces/DigestKind.sol"; +import {PackageKind} from "./interfaces/PackageKind.sol"; import {BaseConditionalOrder} from "./BaseConditionalOrder.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 content-addressed + * 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. */ @@ -20,17 +20,17 @@ abstract contract OrderModule is IOrderModule, BaseConditionalOrder { error UncommittedModuleURI(); /** - * @dev `SHA256` does not locate the package, so it requires a URI + * @dev `TAR_ZST` does not locate the package, so it requires a URI */ error ModuleURIRequired(); string[] private _moduleUris; bytes32 private immutable _MODULE_DIGEST; - DigestKind private immutable _MODULE_KIND; + PackageKind private immutable _MODULE_KIND; - constructor(string[] memory uris, bytes32 digest, DigestKind kind) { + constructor(string[] memory uris, bytes32 digest, PackageKind kind) { if (digest != bytes32(0)) { - require(kind != DigestKind.SHA256 || uris.length > 0, ModuleURIRequired()); + require(kind != PackageKind.TAR_ZST || uris.length > 0, ModuleURIRequired()); emit ModuleUpdate(uris, digest, kind); } else { require(uris.length == 0, UncommittedModuleURI()); @@ -50,7 +50,7 @@ abstract contract OrderModule is IOrderModule, BaseConditionalOrder { /** * @inheritdoc IOrderModule */ - function moduleCommitment() external view returns (bytes32 digest, DigestKind kind) { + function moduleCommitment() external view returns (bytes32 digest, PackageKind kind) { return (_MODULE_DIGEST, _MODULE_KIND); } diff --git a/src/interfaces/DigestKind.sol b/src/interfaces/DigestKind.sol deleted file mode 100644 index b0a0cbaa..00000000 --- a/src/interfaces/DigestKind.sol +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.0 <0.9.0; - -/** - * @dev The addressing scheme an on-chain commitment digest is expressed in. - * Verification uses that scheme's own primitives, so no canonical byte - * serialisation is defined (see `docs/discovery.md` §0). - * - * `BZZ` and `IPFS` are content-addressed: the digest locates the bytes as - * well as verifying them, so no URI is required. `SHA256` locates - * nothing and requires at least one URI. - */ -enum DigestKind { - BZZ, // Swarm BMT root - IPFS, // sha2-256 multihash digest of the root CID - SHA256 // sha256 over the published bytes -} diff --git a/src/interfaces/IOrderDescriptor.sol b/src/interfaces/IOrderDescriptor.sol index 5b37231d..4996edf3 100644 --- a/src/interfaces/IOrderDescriptor.sol +++ b/src/interfaces/IOrderDescriptor.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -import {DigestKind} from "./DigestKind.sol"; +import {PackageKind} from "./PackageKind.sol"; /** * @title Order Descriptor - declarative handler metadata for discovery @@ -18,13 +18,13 @@ interface IOrderDescriptor { * @dev MUST be emitted from the constructor of implementing contracts so * indexers discover the descriptor without polling. */ - event DescriptorUpdate(string[] uris, bytes32 digest, DigestKind kind); + event DescriptorUpdate(string[] uris, bytes32 digest, PackageKind kind); /** * @notice Locations of the handler descriptor document. - * @dev Empty for content-addressed kinds, which the commitment locates. - * Non-empty for `SHA256`. Any URI listed is a retrieval hint and MUST - * resolve to the same document bytes, never alternative content. + * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Non-empty + * for `TAR_ZST`. Any URI listed is a retrieval hint and MUST resolve + * to the same document bytes, never alternative content. */ function descriptorURI() external view returns (string[] memory uris); @@ -34,5 +34,5 @@ interface IOrderDescriptor { * MUST verify fetched bytes against it before parsing. `bytes32(0)` * means uncommitted; treat such descriptors as absent. */ - function descriptorCommitment() external view returns (bytes32 digest, DigestKind kind); + function descriptorCommitment() external view returns (bytes32 digest, PackageKind kind); } diff --git a/src/interfaces/IOrderModule.sol b/src/interfaces/IOrderModule.sol index 8e229285..83b3603d 100644 --- a/src/interfaces/IOrderModule.sol +++ b/src/interfaces/IOrderModule.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.8.0 <0.9.0; -import {DigestKind} from "./DigestKind.sol"; +import {PackageKind} from "./PackageKind.sol"; /** * @title Order Module - executable client module for custom handlers @@ -18,13 +18,13 @@ interface IOrderModule { * @notice Emitted when the module location or commitment changes. * @dev MUST be emitted from the constructor of implementing contracts. */ - event ModuleUpdate(string[] uris, bytes32 digest, DigestKind kind); + event ModuleUpdate(string[] uris, bytes32 digest, PackageKind kind); /** * @notice Locations of the module package. - * @dev Empty for content-addressed kinds, which the commitment locates. - * Non-empty for `SHA256`. Any URI listed is a retrieval hint and MUST - * resolve to the same package. + * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Non-empty + * for `TAR_ZST`. Any URI listed is a retrieval hint and MUST resolve + * to the same package. */ function moduleURI() external view returns (string[] memory uris); @@ -36,5 +36,5 @@ interface IOrderModule { * same code. Consumers MUST verify the package against it before * execution and MUST NOT serve cached bytes against any other key. */ - function moduleCommitment() external view returns (bytes32 digest, DigestKind kind); + function moduleCommitment() external view returns (bytes32 digest, PackageKind kind); } diff --git a/src/interfaces/PackageKind.sol b/src/interfaces/PackageKind.sol new file mode 100644 index 00000000..00499868 --- /dev/null +++ b/src/interfaces/PackageKind.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +/** + * @dev How a committed package is put together, and therefore how its digest + * is computed and verified (see `docs/discovery.md` §0). + * + * `BZZ_MANIFEST` hashes a structure, so only Swarm can recompute it: it + * locates itself and verifies per entry, but cannot be mirrored on + * another scheme. `TAR_ZST` hashes bytes, so any transport can be checked + * by hashing what it delivered, at the cost of a URI and whole-archive + * verification. + */ +enum PackageKind { + BZZ_MANIFEST, // mantaray manifest root + TAR_ZST // sha256 of a .tar.zst archive +} diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index ea5856c6..ee54abc7 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -13,7 +13,7 @@ import { } from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; -import {DigestKind} from "../interfaces/DigestKind.sol"; +import {PackageKind} from "../interfaces/PackageKind.sol"; // --- error strings /** @@ -46,7 +46,7 @@ error PriceCheckerFailed(); * checked before the order is placed. */ contract GoodAfterTime is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, DigestKind descriptorKind) + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, PackageKind descriptorKind) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) {} diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index f410bee2..5e6999f7 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -11,7 +11,7 @@ import { import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; -import {DigestKind} from "../interfaces/DigestKind.sol"; +import {PackageKind} from "../interfaces/PackageKind.sol"; // --- error strings /** @@ -24,7 +24,7 @@ error NotFunded(); * taking decimals into account (and adding specifiable spread) */ contract PerpetualStableSwap is OrderDescriptor { - constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, DigestKind descriptorKind) + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, PackageKind descriptorKind) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) {} diff --git a/src/types/StopLoss.sol b/src/types/StopLoss.sol index d6ad0c6b..cbb23a1e 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -11,7 +11,7 @@ import { import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; import {OrderDescriptor} from "../OrderDescriptor.sol"; -import {DigestKind} from "../interfaces/DigestKind.sol"; +import {PackageKind} from "../interfaces/PackageKind.sol"; // --- error strings @@ -43,7 +43,7 @@ error OrderExpired(); * @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_, DigestKind descriptorKind) + constructor(string[] memory descriptorUris, bytes32 descriptorDigest_, PackageKind descriptorKind) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) {} diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index 437007e2..a071785a 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -15,7 +15,7 @@ import {IOrderManifest} from "../../interfaces/IOrderManifest.sol"; import {TWAPOrder} from "./libraries/TWAPOrder.sol"; import {TWAPOrderMathLib, AfterTwapFinish} from "./libraries/TWAPOrderMathLib.sol"; import {OrderDescriptor} from "../../OrderDescriptor.sol"; -import {DigestKind} from "../../interfaces/DigestKind.sol"; +import {PackageKind} from "../../interfaces/PackageKind.sol"; // --- error strings @@ -45,7 +45,7 @@ contract TWAP is OrderDescriptor { ComposableCow _composableCow, string[] memory descriptorUris, bytes32 descriptorDigest_, - DigestKind descriptorKind + PackageKind descriptorKind ) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) { composableCow = _composableCow; } diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index 37de0726..a545ccc7 100644 --- a/test/ComposableCow.base.t.sol +++ b/test/ComposableCow.base.t.sol @@ -28,7 +28,7 @@ import {ReceiverLock} from "../src/guards/ReceiverLock.sol"; import {IValueFactory} from "../src/interfaces/IValueFactory.sol"; import {ISwapGuard, ComposableCow, GPv2Order} from "../src/ComposableCow.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; contract BaseComposableCowTest is Base, Merkle { using ComposableCowLib for IConditionalOrder.ConditionalOrderParams; @@ -76,7 +76,7 @@ contract BaseComposableCowTest is Base, Merkle { passThrough = new TestConditionalOrderGenerator(); mirror = new MirrorConditionalOrder(); - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); } /** diff --git a/test/ComposableCow.discovery.t.sol b/test/ComposableCow.discovery.t.sol index 98706f28..5a73b76e 100644 --- a/test/ComposableCow.discovery.t.sol +++ b/test/ComposableCow.discovery.t.sol @@ -11,7 +11,7 @@ import {IOrderModule} from "../src/interfaces/IOrderModule.sol"; import {OrderDescriptor} from "../src/OrderDescriptor.sol"; import {OrderModule} from "../src/OrderModule.sol"; import {StopLoss} from "../src/types/StopLoss.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; error TestNoOrder(); @@ -19,7 +19,7 @@ error TestNoOrder(); * @dev Minimal handler committing a module: the OrderModule mixin under test */ contract ModuleHandler is OrderModule { - constructor(string[] memory uris, bytes32 digest, DigestKind kind) OrderModule(uris, digest, kind) {} + constructor(string[] memory uris, bytes32 digest, PackageKind kind) OrderModule(uris, digest, kind) {} function generateOrder(address, address, bytes32, bytes calldata, bytes calldata) public @@ -49,7 +49,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * super chain */ function test_descriptor_CommittedAdvertisesAndRoundTrips() public { - StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); assertTrue(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); @@ -58,9 +58,9 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); assertEq(handler.descriptorURI()[0], testDescriptorUris()[0]); - (bytes32 digest, DigestKind kind) = handler.descriptorCommitment(); + (bytes32 digest, PackageKind kind) = handler.descriptorCommitment(); assertEq(digest, TEST_DESCRIPTOR_DIGEST); - assertEq(uint256(kind), uint256(DigestKind.BZZ)); + assertEq(uint256(kind), uint256(PackageKind.BZZ_MANIFEST)); } /** @@ -68,7 +68,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), DigestKind.BZZ); + StopLoss handler = new StopLoss(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); // the handler remains a fully functional generator @@ -81,8 +81,8 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { */ function test_descriptor_ConstructorEmitsUpdate() public { vm.expectEmit(true, true, true, true); - emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); - new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); } // --- module --- @@ -94,15 +94,15 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { bytes32 digest = keccak256("module component bytes"); vm.expectEmit(true, true, true, true); - emit IOrderModule.ModuleUpdate(moduleUris(), digest, DigestKind.BZZ); - ModuleHandler handler = new ModuleHandler(moduleUris(), digest, DigestKind.BZZ); + emit IOrderModule.ModuleUpdate(moduleUris(), digest, PackageKind.BZZ_MANIFEST); + ModuleHandler handler = new ModuleHandler(moduleUris(), digest, PackageKind.BZZ_MANIFEST); assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertEq(handler.moduleURI()[0], moduleUris()[0]); - (bytes32 got, DigestKind kind) = handler.moduleCommitment(); + (bytes32 got, PackageKind kind) = handler.moduleCommitment(); assertEq(got, digest); - assertEq(uint256(kind), uint256(DigestKind.BZZ)); + assertEq(uint256(kind), uint256(PackageKind.BZZ_MANIFEST)); } /** @@ -111,7 +111,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { */ function test_module_RevertsUncommittedURI() public { vm.expectRevert(OrderModule.UncommittedModuleURI.selector); - new ModuleHandler(moduleUris(), bytes32(0), DigestKind.BZZ); + new ModuleHandler(moduleUris(), bytes32(0), PackageKind.BZZ_MANIFEST); } /** @@ -119,7 +119,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { */ function test_module_RevertsSha256WithoutURI() public { vm.expectRevert(OrderModule.ModuleURIRequired.selector); - new ModuleHandler(new string[](0), keccak256("pkg"), DigestKind.SHA256); + new ModuleHandler(new string[](0), keccak256("pkg"), PackageKind.TAR_ZST); } /** @@ -127,7 +127,7 @@ 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"), DigestKind.BZZ); + ModuleHandler handler = new ModuleHandler(new string[](0), keccak256("pkg"), PackageKind.BZZ_MANIFEST); assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); assertEq(handler.moduleURI().length, 0); } @@ -136,7 +136,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), DigestKind.BZZ); + ModuleHandler handler = new ModuleHandler(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); assertFalse(handler.supportsInterface(type(IOrderModule).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); } @@ -146,7 +146,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * empty - the discovery trigger end to end */ function test_module_NeedsInputSignal() public { - ModuleHandler handler = new ModuleHandler(moduleUris(), keccak256("module"), DigestKind.BZZ); + ModuleHandler handler = new ModuleHandler(moduleUris(), keccak256("module"), PackageKind.BZZ_MANIFEST); IConditionalOrderGenerator.GeneratorResult memory result = handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); diff --git a/test/ComposableCow.gat.t.sol b/test/ComposableCow.gat.t.sol index 3f20a755..e00bfd9f 100644 --- a/test/ComposableCow.gat.t.sol +++ b/test/ComposableCow.gat.t.sol @@ -23,7 +23,7 @@ import { BalanceInsufficient, PriceCheckerFailed } from "../src/types/GoodAfterTime.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; contract ComposableCowGatTest is BaseComposableCowTest { using ComposableCowLib for IConditionalOrder.ConditionalOrderParams[]; @@ -37,7 +37,7 @@ contract ComposableCowGatTest is BaseComposableCowTest { super.setUp(); // deploy the GAT handler - gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); // deploy the test expected out calculator testOutCalculator = new TestExpectedOutCalculator(); diff --git a/test/ComposableCow.manifest.t.sol b/test/ComposableCow.manifest.t.sol index 995f598c..a2135470 100644 --- a/test/ComposableCow.manifest.t.sol +++ b/test/ComposableCow.manifest.t.sol @@ -18,7 +18,7 @@ import {OrderNotInitialized} from "../src/types/twap/TWAP.sol"; import {PerpetualStableSwap, NotFunded} from "../src/types/PerpetualStableSwap.sol"; import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; import {IValueFactory} from "../src/interfaces/IValueFactory.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; /** * @dev Test reason errors, as a handler would declare them @@ -42,7 +42,7 @@ contract ComposableCowManifestTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); } diff --git a/test/ComposableCow.stoploss.t.sol b/test/ComposableCow.stoploss.t.sol index 95972274..a07e7324 100644 --- a/test/ComposableCow.stoploss.t.sol +++ b/test/ComposableCow.stoploss.t.sol @@ -17,7 +17,7 @@ import { OracleInvalidPrice, OrderExpired } from "../src/types/StopLoss.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; contract ComposableCowStopLossTest is BaseComposableCowTest { IERC20 immutable SELL_TOKEN = IERC20(address(0x1)); @@ -34,7 +34,7 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); } function priceToAddress(int256 price) internal returns (address) { diff --git a/test/ComposableCow.twap.t.sol b/test/ComposableCow.twap.t.sol index 356c84b5..21ff145a 100644 --- a/test/ComposableCow.twap.t.sol +++ b/test/ComposableCow.twap.t.sol @@ -33,7 +33,7 @@ import { import {TWAPOrderMathLib, BeforeTwapStart, AfterTwapFinish} from "../src/types/twap/libraries/TWAPOrderMathLib.sol"; import {CurrentBlockTimestampFactory} from "../src/value_factories/CurrentBlockTimestampFactory.sol"; -import {DigestKind} from "../src/interfaces/DigestKind.sol"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; uint256 constant SELL_AMOUNT = 24000e18; uint256 constant LIMIT_PRICE = 100e18; @@ -56,7 +56,7 @@ contract ComposableCowTwapTest is BaseComposableCowTest { super.setUp(); // deploy the TWAP handler - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, DigestKind.BZZ); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); // deploy the current block timestamp factory currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); From 3523767b9ebbb54da848ef0dbc5608aadf9a06c9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 31 Jul 2026 08:09:31 +0000 Subject: [PATCH 4/5] chore: blank the CREATE2 salts in the production deploy script The salt was `"v1.0.0"` on all eight deployments, which reads as a version but is not one. A CREATE2 address is derived from the deployer, the salt and the hash of the init code, and the init code includes the constructor arguments, so any change to a contract or its arguments already moves its address. The version string never affected that. The `{salt: ...}` block stays. Dropping it would turn these into plain `new`, which is nonce-dependent and would lose deterministic addresses across chains, which is the reason CREATE2 is used here at all. No deployment is affected: this fork has none, per the manifest removal. --- script/deploy_ProdStack.s.sol | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/script/deploy_ProdStack.s.sol b/script/deploy_ProdStack.s.sol index 81f22a16..b64d05a2 100644 --- a/script/deploy_ProdStack.s.sol +++ b/script/deploy_ProdStack.s.sol @@ -28,19 +28,19 @@ contract DeployProdStack is Script { vm.startBroadcast(deployerPrivateKey); // Deploy ExtensibleFallbackHandler - new ExtensibleFallbackHandler{salt: "v1.0.0"}(); + new ExtensibleFallbackHandler{salt: bytes32(0)}(); // Deploy ComposableCow - ComposableCow composableCow = new ComposableCow{salt: "v1.0.0"}(settlement); + ComposableCow composableCow = new ComposableCow{salt: bytes32(0)}(settlement); // Deploy order types - new TWAP{salt: "v1.0.0"}(composableCow, new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new GoodAfterTime{salt: "v1.0.0"}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new PerpetualStableSwap{salt: "v1.0.0"}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); - new TradeAboveThreshold{salt: "v1.0.0"}(); - new StopLoss{salt: "v1.0.0"}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); + 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 TradeAboveThreshold{salt: bytes32(0)}(); + new StopLoss{salt: bytes32(0)}(new string[](0), bytes32(0), PackageKind.BZZ_MANIFEST); // Deploy value factories - new CurrentBlockTimestampFactory{salt: "v1.0.0"}(); + new CurrentBlockTimestampFactory{salt: bytes32(0)}(); } } From f27c8b6312c82f5a712e56c4d5624ec3dfda3c9b Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 31 Jul 2026 08:32:28 +0000 Subject: [PATCH 5/5] refactor!: forbid URIs on content-addressed commitments Two changes that fell out of asking what a URI is for when the commitment already locates the bytes. `BZZ_MANIFEST` now rejects a URI rather than merely discouraging one. A URI there is not redundant, it is unusable: verifying against a mantaray root needs chunk-level data, and a gateway serves resolved file content, so bytes fetched from one cannot be checked against the root at all. Allowing it bought nothing and invited a gateway operator to be written into a deployed contract. The rule is now on-chain and exact: when committed, a URI list is non-empty if and only if the kind is byte-hashed. `ModuleURINotUsed` and `DescriptorURINotUsed` say which way it was broken. `TAR_ZST` becomes `SHA256`, because the name did not fit a descriptor. A descriptor is one JSON document, not a package, and there is no sense in which it is a tar archive. The kind now names how the digest is computed and the spec says what the bytes are per surface: a module commits to its `.tar.zst` package, a descriptor to its document. Nothing is lost, since exactly one archive format is mandated, so the format was never a choice the enum needed to carry. Test fixtures move with the rule: handlers carrying a `data:` descriptor are in-band publications and so byte-hashed, and the module fixture URI becomes an https archive, which is the case where a URI is required at all. --- docs/discovery.md | 55 ++++++++++++++++------------- src/OrderDescriptor.sol | 14 ++++++-- src/OrderModule.sol | 14 ++++++-- src/interfaces/IOrderDescriptor.sol | 2 +- src/interfaces/IOrderModule.sol | 2 +- src/interfaces/PackageKind.sol | 17 ++++----- test/ComposableCow.base.t.sol | 2 +- test/ComposableCow.discovery.t.sol | 30 ++++++++++------ test/ComposableCow.gat.t.sol | 2 +- test/ComposableCow.manifest.t.sol | 2 +- test/ComposableCow.stoploss.t.sol | 2 +- test/ComposableCow.twap.t.sol | 2 +- 12 files changed, 91 insertions(+), 53 deletions(-) diff --git a/docs/discovery.md b/docs/discovery.md index 55cb9bdc..cb67498b 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -51,7 +51,7 @@ described in RFC 2119. - **Content addressing locates as well as verifies.** A `BZZ_MANIFEST` commitment already names where the bytes live, so a handler publishes no URI and no gateway is written into a deployed contract. Retrieval is the host's - concern. URIs exist for `TAR_ZST`, where the digest locates nothing. + concern. URIs exist for `SHA256`, where the digest locates nothing. - **Fail closed.** Unknown URI schemes, unsupported or reverting views, and unverifiable documents are treated as "no discovery", never as errors to retry aggressively and never as data to trust. @@ -59,20 +59,22 @@ described in RFC 2119. ## 0. Commitments Descriptors (§1) and modules (§2) are both committed to on-chain as a -`(bytes32 digest, PackageKind kind)` pair. `kind` names how the bytes are -packaged and therefore how the digest is computed and verified. +`(bytes32 digest, PackageKind kind)` pair. `kind` names how the digest is +computed, and therefore how it is verified. What the committed bytes *are* is +fixed per surface rather than by the kind: a module commits to a `.tar.zst` +package, a descriptor to its JSON document. ```solidity enum PackageKind { - BZZ_MANIFEST, // mantaray manifest root - TAR_ZST // sha256 of a .tar.zst archive + BZZ_MANIFEST, // Swarm BMT root + SHA256 // sha256 over the published bytes } ``` The two kinds trade the same two properties against each other, and a publisher picks which one it wants: -| | `BZZ_MANIFEST` | `TAR_ZST` | +| | `BZZ_MANIFEST` | `SHA256` | |---|---|---| | locates itself | yes, the root is the Swarm reference | no, a URI is required | | verification | per entry, from BMT roots in the manifest | whole archive, in one pass | @@ -81,7 +83,7 @@ publisher picks which one it wants: The asymmetry is structural rather than a preference. A mantaray root is a hash over a *structure*, so only Swarm can recompute it; that is exactly what -buys per-entry verification and costs cross-scheme mirroring. A `TAR_ZST` +buys per-entry verification and costs cross-scheme mirroring. A `SHA256` digest is a hash over *bytes*, so any transport can be checked by hashing what it delivered, at the cost of verifying nothing until the whole archive is in hand. @@ -93,12 +95,13 @@ the exact bytes published. ### 0.1 Location `BZZ_MANIFEST` is content-addressed: the commitment names the bytes, so it also -locates them. The URI list SHOULD be empty and a host resolves the digest -through whatever Swarm access it has. A handler that lists URIs anyway supplies -non-normative hints; a host MAY use them and MUST still verify against the -commitment. +locates them. Its URI list MUST be empty, and implementations reject a +commitment that carries one. A URI here would not merely be redundant: a +gateway serves resolved file content rather than chunks, so bytes fetched from +one cannot be checked against a structure root at all. Publishing one would +write a gateway into a deployed contract in exchange for nothing. -`TAR_ZST` is not self-locating. Its URI list MUST be non-empty. Any scheme may +`SHA256` is not self-locating. Its URI list MUST be non-empty. Any scheme may appear there, including `ipfs://`, which verifies its own content in transit but whose CID is not the commitment: a UnixFS CID depends on chunker and DAG layout, which are publisher settings rather than properties of the content, so @@ -113,8 +116,10 @@ absent rather than fetching anything. the manifest carries for it. A single document (a descriptor) is committed to as a leaf rather than a manifest. -`TAR_ZST`: fetch the archive from any URI, verify `sha256(archive)` equals the -digest, then extract. Verifying the archive authenticates every byte in it, so +`SHA256`: fetch the bytes from any URI and verify their `sha256` equals the +digest. For a descriptor those bytes are the document and verification ends +there. For a module they are the `.tar.zst` package, which is then extracted; +verifying the archive authenticates every byte in it, so no per-entry digest is declared anywhere. Restating entry digests inside the package would create a second source of truth and a disagreement to specify around, without adding a check. @@ -129,10 +134,12 @@ Consumers MUST, before writing anything: ### 0.3 Compression -Each container compresses at the level it can, so the component is stored -differently in each and `nexum.toml` is byte-identical across both: +This concerns module packages; a descriptor is a single document and is +published as-is. Each container compresses at the level it can, so the +component is stored differently in each while `nexum.toml` stays +byte-identical across both: -- `TAR_ZST`: entries are stored uncompressed; the archive compresses them. +- `SHA256`: entries are stored uncompressed; the archive compresses them. - `BZZ_MANIFEST`: mantaray has no archive-level compression, so the component is stored zstd-compressed at `.zst`, which is worth doing on a network that charges per chunk. `nexum.toml` is stored uncompressed: it is @@ -157,7 +164,7 @@ interface IOrderDescriptor { /** * @notice Locations of the handler descriptor document. * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Required - * for `TAR_ZST`. Any URI listed is a hint: all MUST resolve to the + * for `SHA256`. Any URI listed is a hint: all MUST resolve to the * same document bytes, never alternative content. */ function descriptorURI() external view returns (string[] memory uris); @@ -196,9 +203,9 @@ governs retrieval only. Where a URI is used it MUST be one of: | Scheme | Used with | |---|---| | `bzz://` | `BZZ_MANIFEST`, as a hint; the commitment already locates the document | -| `ipfs://` | `TAR_ZST`, as a location; the CID is not the commitment | +| `ipfs://` | `SHA256`, as a location; the CID is not the commitment | | `data:` | either kind, for a document published in-band | -| `https:` | `TAR_ZST`, the common case | +| `https:` | `SHA256`, the common case | `http:`, `file:`, and any URI resolving to loopback, link-local, or private address ranges are prohibited. Fetchers SHOULD disable redirects (or re-validate @@ -206,7 +213,7 @@ every hop against this policy), enforce a size cap (256 KiB RECOMMENDED), and enforce a total timeout. An `https:` URI is never trusted on its own: bytes fetched from one are used -only after they verify against the commitment, which for `TAR_ZST` is the whole +only after they verify against the commitment, which for `SHA256` is the whole of the integrity argument. ### 1.3 Document @@ -298,7 +305,7 @@ interface IOrderModule { /** * @notice Locations of the module package. * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Required - * for `TAR_ZST`. Any URI listed is a hint. + * for `SHA256`. Any URI listed is a hint. */ function moduleURI() external view returns (string[] memory uris); @@ -494,9 +501,9 @@ pair = "WETH/USDC" #### Execution 1. Resolve the commitment (§0.1) and verify it (§0.2), applying the extraction - rules for `TAR_ZST`. + rules for `SHA256`. 2. Read `nexum.toml`. Under `BZZ_MANIFEST` this is one entry, verified and - parsed before the component is fetched at all; under `TAR_ZST` the whole + parsed before the component is fetched at all; under `SHA256` the whole archive is already in hand. 3. Fetch the component, decompressing it under `BZZ_MANIFEST` (§0.3), and instantiate it in the `videre:ccow/order-module` world with HTTP restricted diff --git a/src/OrderDescriptor.sol b/src/OrderDescriptor.sol index b5f64205..40330170 100644 --- a/src/OrderDescriptor.sol +++ b/src/OrderDescriptor.sol @@ -24,17 +24,27 @@ abstract contract OrderDescriptor is IOrderDescriptor, BaseConditionalOrder { error UncommittedDescriptorURI(); /** - * @dev `TAR_ZST` does not locate the document, so it requires a URI + * @dev `SHA256` does not locate the document, so it requires a URI */ error DescriptorURIRequired(); + /** + * @dev A `BZZ_MANIFEST` commitment locates its own document; a URI could not + * be verified against a structure root anyway + */ + error DescriptorURINotUsed(); + 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)) { - require(kind != PackageKind.TAR_ZST || uris.length > 0, DescriptorURIRequired()); + 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()); diff --git a/src/OrderModule.sol b/src/OrderModule.sol index b38f160f..d2c89499 100644 --- a/src/OrderModule.sol +++ b/src/OrderModule.sol @@ -20,17 +20,27 @@ abstract contract OrderModule is IOrderModule, BaseConditionalOrder { error UncommittedModuleURI(); /** - * @dev `TAR_ZST` does not locate the package, so it requires a URI + * @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(); + string[] private _moduleUris; bytes32 private immutable _MODULE_DIGEST; PackageKind private immutable _MODULE_KIND; constructor(string[] memory uris, bytes32 digest, PackageKind kind) { if (digest != bytes32(0)) { - require(kind != PackageKind.TAR_ZST || uris.length > 0, ModuleURIRequired()); + 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()); diff --git a/src/interfaces/IOrderDescriptor.sol b/src/interfaces/IOrderDescriptor.sol index 4996edf3..f0f76366 100644 --- a/src/interfaces/IOrderDescriptor.sol +++ b/src/interfaces/IOrderDescriptor.sol @@ -23,7 +23,7 @@ interface IOrderDescriptor { /** * @notice Locations of the handler descriptor document. * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Non-empty - * for `TAR_ZST`. Any URI listed is a retrieval hint and MUST resolve + * for `SHA256`. Any URI listed is a retrieval hint and MUST resolve * to the same document bytes, never alternative content. */ function descriptorURI() external view returns (string[] memory uris); diff --git a/src/interfaces/IOrderModule.sol b/src/interfaces/IOrderModule.sol index 83b3603d..d0b484ba 100644 --- a/src/interfaces/IOrderModule.sol +++ b/src/interfaces/IOrderModule.sol @@ -23,7 +23,7 @@ interface IOrderModule { /** * @notice Locations of the module package. * @dev Empty for `BZZ_MANIFEST`, which the commitment locates. Non-empty - * for `TAR_ZST`. Any URI listed is a retrieval hint and MUST resolve + * for `SHA256`. Any URI listed is a retrieval hint and MUST resolve * to the same package. */ function moduleURI() external view returns (string[] memory uris); diff --git a/src/interfaces/PackageKind.sol b/src/interfaces/PackageKind.sol index 00499868..fa49b6d9 100644 --- a/src/interfaces/PackageKind.sol +++ b/src/interfaces/PackageKind.sol @@ -2,16 +2,17 @@ pragma solidity >=0.8.0 <0.9.0; /** - * @dev How a committed package is put together, and therefore how its digest - * is computed and verified (see `docs/discovery.md` §0). + * @dev How a commitment digest is computed, and therefore how it is verified + * (see `docs/discovery.md` §0). What the committed bytes are is fixed per + * surface: a module commits to a `.tar.zst` package, a descriptor to its + * JSON document. * * `BZZ_MANIFEST` hashes a structure, so only Swarm can recompute it: it - * locates itself and verifies per entry, but cannot be mirrored on - * another scheme. `TAR_ZST` hashes bytes, so any transport can be checked - * by hashing what it delivered, at the cost of a URI and whole-archive - * verification. + * locates itself and verifies per entry, and publishes no URI. `SHA256` + * hashes bytes, so any transport can be checked by hashing what it + * delivered, at the cost of requiring a URI to locate them. */ enum PackageKind { - BZZ_MANIFEST, // mantaray manifest root - TAR_ZST // sha256 of a .tar.zst archive + BZZ_MANIFEST, // Swarm BMT root of a mantaray manifest, or of the document + SHA256 // sha256 over the published bytes } diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index a545ccc7..632bc3c5 100644 --- a/test/ComposableCow.base.t.sol +++ b/test/ComposableCow.base.t.sol @@ -76,7 +76,7 @@ contract BaseComposableCowTest is Base, Merkle { passThrough = new TestConditionalOrderGenerator(); mirror = new MirrorConditionalOrder(); - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); } /** diff --git a/test/ComposableCow.discovery.t.sol b/test/ComposableCow.discovery.t.sol index 5a73b76e..44bd0cff 100644 --- a/test/ComposableCow.discovery.t.sol +++ b/test/ComposableCow.discovery.t.sol @@ -38,7 +38,7 @@ contract ModuleHandler is OrderModule { contract ComposableCowDiscoveryTest is BaseComposableCowTest { function moduleUris() internal pure returns (string[] memory uris) { uris = new string[](1); - uris[0] = "bzz://c0ffee"; + uris[0] = "https://example.com/twap-oracle.tar.zst"; } // --- descriptor --- @@ -49,7 +49,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * super chain */ function test_descriptor_CommittedAdvertisesAndRoundTrips() public { - StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + StopLoss handler = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); assertTrue(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertTrue(handler.supportsInterface(type(IConditionalOrderGenerator).interfaceId)); @@ -60,7 +60,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { assertEq(handler.descriptorURI()[0], testDescriptorUris()[0]); (bytes32 digest, PackageKind kind) = handler.descriptorCommitment(); assertEq(digest, TEST_DESCRIPTOR_DIGEST); - assertEq(uint256(kind), uint256(PackageKind.BZZ_MANIFEST)); + assertEq(uint256(kind), uint256(PackageKind.SHA256)); } /** @@ -81,8 +81,8 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { */ function test_descriptor_ConstructorEmitsUpdate() public { vm.expectEmit(true, true, true, true); - emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); - new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + emit IOrderDescriptor.DescriptorUpdate(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); } // --- module --- @@ -94,15 +94,15 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { bytes32 digest = keccak256("module component bytes"); vm.expectEmit(true, true, true, true); - emit IOrderModule.ModuleUpdate(moduleUris(), digest, PackageKind.BZZ_MANIFEST); - ModuleHandler handler = new ModuleHandler(moduleUris(), digest, PackageKind.BZZ_MANIFEST); + emit IOrderModule.ModuleUpdate(moduleUris(), digest, PackageKind.SHA256); + ModuleHandler handler = new ModuleHandler(moduleUris(), digest, PackageKind.SHA256); assertTrue(handler.supportsInterface(type(IOrderModule).interfaceId)); assertFalse(handler.supportsInterface(type(IOrderDescriptor).interfaceId)); assertEq(handler.moduleURI()[0], moduleUris()[0]); (bytes32 got, PackageKind kind) = handler.moduleCommitment(); assertEq(got, digest); - assertEq(uint256(kind), uint256(PackageKind.BZZ_MANIFEST)); + assertEq(uint256(kind), uint256(PackageKind.SHA256)); } /** @@ -114,12 +114,22 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { new ModuleHandler(moduleUris(), bytes32(0), PackageKind.BZZ_MANIFEST); } + /** + * @dev A `BZZ_MANIFEST` commitment locates its own package, so a URI is + * not merely redundant: it could not be verified against a structure + * 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); + } + /** * @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.TAR_ZST); + new ModuleHandler(new string[](0), keccak256("pkg"), PackageKind.SHA256); } /** @@ -146,7 +156,7 @@ contract ComposableCowDiscoveryTest is BaseComposableCowTest { * empty - the discovery trigger end to end */ function test_module_NeedsInputSignal() public { - ModuleHandler handler = new ModuleHandler(moduleUris(), keccak256("module"), PackageKind.BZZ_MANIFEST); + ModuleHandler handler = new ModuleHandler(new string[](0), keccak256("module"), PackageKind.BZZ_MANIFEST); IConditionalOrderGenerator.GeneratorResult memory result = handler.poll(address(safe1), address(this), bytes32(0), bytes(""), bytes("")); diff --git a/test/ComposableCow.gat.t.sol b/test/ComposableCow.gat.t.sol index e00bfd9f..34423885 100644 --- a/test/ComposableCow.gat.t.sol +++ b/test/ComposableCow.gat.t.sol @@ -37,7 +37,7 @@ contract ComposableCowGatTest is BaseComposableCowTest { super.setUp(); // deploy the GAT handler - gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + gat = new GoodAfterTime(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, 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 a2135470..6fd4eeb8 100644 --- a/test/ComposableCow.manifest.t.sol +++ b/test/ComposableCow.manifest.t.sol @@ -42,7 +42,7 @@ contract ComposableCowManifestTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + perpetualSwap = new PerpetualStableSwap(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); currentBlockTimestampFactory = new CurrentBlockTimestampFactory(); } diff --git a/test/ComposableCow.stoploss.t.sol b/test/ComposableCow.stoploss.t.sol index a07e7324..1e107e24 100644 --- a/test/ComposableCow.stoploss.t.sol +++ b/test/ComposableCow.stoploss.t.sol @@ -34,7 +34,7 @@ contract ComposableCowStopLossTest is BaseComposableCowTest { function setUp() public virtual override(BaseComposableCowTest) { super.setUp(); - stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + stopLoss = new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); } function priceToAddress(int256 price) internal returns (address) { diff --git a/test/ComposableCow.twap.t.sol b/test/ComposableCow.twap.t.sol index 21ff145a..e236180a 100644 --- a/test/ComposableCow.twap.t.sol +++ b/test/ComposableCow.twap.t.sol @@ -56,7 +56,7 @@ contract ComposableCowTwapTest is BaseComposableCowTest { super.setUp(); // deploy the TWAP handler - twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.BZZ_MANIFEST); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); // deploy the current block timestamp factory currentBlockTimestampFactory = new CurrentBlockTimestampFactory();