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/script/deploy_AnvilStack.s.sol b/script/deploy_AnvilStack.s.sol index 45124aa8..478535fb 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 {PackageKind} from "../src/interfaces/PackageKind.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 GoodAfterTime(); - new PerpetualStableSwap(); + 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 3a666ba2..9fbab509 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 {PackageKind} from "../src/interfaces/PackageKind.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 GoodAfterTime(); - new PerpetualStableSwap(); + 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 67d3fa0c..b64d05a2 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 {PackageKind} from "../src/interfaces/PackageKind.sol"; contract DeployProdStack is Script { function run() external { @@ -27,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 GoodAfterTime{salt: "v1.0.0"}(); - new PerpetualStableSwap{salt: "v1.0.0"}(); - new TradeAboveThreshold{salt: "v1.0.0"}(); - new StopLoss{salt: "v1.0.0"}(); + 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)}(); } } 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..40330170 --- /dev/null +++ b/src/OrderDescriptor.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {IOrderDescriptor} from "./interfaces/IOrderDescriptor.sol"; +import {PackageKind} from "./interfaces/PackageKind.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 { + /** + * @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(); + + /** + * @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)) { + 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()); + } + _descriptorUris = uris; + _DESCRIPTOR_DIGEST = digest; + _DESCRIPTOR_KIND = kind; + } + + /** + * @inheritdoc IOrderDescriptor + */ + function descriptorURI() external view returns (string[] memory uris) { + return _descriptorUris; + } + + /** + * @inheritdoc IOrderDescriptor + */ + function descriptorCommitment() external view returns (bytes32 digest, PackageKind kind) { + return (_DESCRIPTOR_DIGEST, _DESCRIPTOR_KIND); + } + + /** + * @dev Advertise `IOrderDescriptor` only when a descriptor is committed: + * claiming the interface while returning empty values is + * non-conformant per the discovery specification. The commitment is + * the gate, not the URI list, since a `BZZ_MANIFEST` commitment + * locates its own document and publishes no URI. + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderDescriptor).interfaceId) { + return _DESCRIPTOR_DIGEST != bytes32(0); + } + return super.supportsInterface(interfaceId); + } +} diff --git a/src/OrderModule.sol b/src/OrderModule.sol new file mode 100644 index 00000000..d2c89499 --- /dev/null +++ b/src/OrderModule.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {IOrderModule} from "./interfaces/IOrderModule.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 `BZZ_MANIFEST` + * 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 URIs cannot be published without a commitment to verify them against + */ + error UncommittedModuleURI(); + + /** + * @dev `SHA256` does not locate the package, so it requires a URI + */ + error ModuleURIRequired(); + + /** + * @dev A `BZZ_MANIFEST` commitment locates its own package; a URI could not + * be verified against a structure root anyway + */ + error ModuleURINotUsed(); + + 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)) { + if (kind == PackageKind.SHA256) { + require(uris.length > 0, ModuleURIRequired()); + } else { + require(uris.length == 0, ModuleURINotUsed()); + } + emit ModuleUpdate(uris, digest, kind); + } else { + require(uris.length == 0, UncommittedModuleURI()); + } + _moduleUris = uris; + _MODULE_DIGEST = digest; + _MODULE_KIND = kind; + } + + /** + * @inheritdoc IOrderModule + */ + function moduleURI() external view returns (string[] memory uris) { + return _moduleUris; + } + + /** + * @inheritdoc IOrderModule + */ + function moduleCommitment() external view returns (bytes32 digest, PackageKind kind) { + return (_MODULE_DIGEST, _MODULE_KIND); + } + + /** + * @dev Advertise `IOrderModule` only when a module is committed + */ + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + if (interfaceId == type(IOrderModule).interfaceId) { + return _MODULE_DIGEST != bytes32(0); + } + return super.supportsInterface(interfaceId); + } +} diff --git a/src/interfaces/IOrderDescriptor.sol b/src/interfaces/IOrderDescriptor.sol new file mode 100644 index 00000000..f0f76366 --- /dev/null +++ b/src/interfaces/IOrderDescriptor.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {PackageKind} from "./PackageKind.sol"; + +/** + * @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, PackageKind kind); + + /** + * @notice Locations of the handler descriptor document. + * @dev Empty for `BZZ_MANIFEST`, 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 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 descriptorCommitment() external view returns (bytes32 digest, PackageKind kind); +} diff --git a/src/interfaces/IOrderModule.sol b/src/interfaces/IOrderModule.sol new file mode 100644 index 00000000..d0b484ba --- /dev/null +++ b/src/interfaces/IOrderModule.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {PackageKind} from "./PackageKind.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 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, PackageKind kind); + + /** + * @notice Locations of the module package. + * @dev Empty for `BZZ_MANIFEST`, 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 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 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..fa49b6d9 --- /dev/null +++ b/src/interfaces/PackageKind.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.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, 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, // Swarm BMT root of a mantaray manifest, or of the document + SHA256 // sha256 over the published bytes +} diff --git a/src/types/GoodAfterTime.sol b/src/types/GoodAfterTime.sol index d0b2fdbe..ee54abc7 100644 --- a/src/types/GoodAfterTime.sol +++ b/src/types/GoodAfterTime.sol @@ -12,6 +12,8 @@ import { BaseConditionalOrder } from "../BaseConditionalOrder.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {OrderDescriptor} from "../OrderDescriptor.sol"; +import {PackageKind} from "../interfaces/PackageKind.sol"; // --- error strings /** @@ -43,7 +45,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_, PackageKind descriptorKind) + OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) + {} + using SafeCast for uint256; // --- types diff --git a/src/types/PerpetualStableSwap.sol b/src/types/PerpetualStableSwap.sol index e157c6b2..5e6999f7 100644 --- a/src/types/PerpetualStableSwap.sol +++ b/src/types/PerpetualStableSwap.sol @@ -10,6 +10,8 @@ import { } from "../BaseConditionalOrder.sol"; import {IOrderManifest} from "../interfaces/IOrderManifest.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {OrderDescriptor} from "../OrderDescriptor.sol"; +import {PackageKind} from "../interfaces/PackageKind.sol"; // --- error strings /** @@ -21,7 +23,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_, PackageKind descriptorKind) + OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) + {} + /** * 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..cbb23a1e 100644 --- a/src/types/StopLoss.sol +++ b/src/types/StopLoss.sol @@ -10,6 +10,8 @@ import { } from "../BaseConditionalOrder.sol"; import {IAggregatorV3Interface} from "../interfaces/IAggregatorV3Interface.sol"; import {ConditionalOrdersUtilsLib as Utils} from "./ConditionalOrdersUtilsLib.sol"; +import {OrderDescriptor} from "../OrderDescriptor.sol"; +import {PackageKind} from "../interfaces/PackageKind.sol"; // --- error strings @@ -40,7 +42,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_, PackageKind descriptorKind) + OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) + {} + /** * @dev Scaling factor for the strike price */ diff --git a/src/types/twap/TWAP.sol b/src/types/twap/TWAP.sol index 94ac5c83..a071785a 100644 --- a/src/types/twap/TWAP.sol +++ b/src/types/twap/TWAP.sol @@ -14,6 +14,8 @@ 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"; +import {PackageKind} from "../../interfaces/PackageKind.sol"; // --- error strings @@ -34,12 +36,17 @@ 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_, + PackageKind descriptorKind + ) OrderDescriptor(descriptorUris, descriptorDigest_, descriptorKind) { composableCow = _composableCow; } diff --git a/test/ComposableCow.base.t.sol b/test/ComposableCow.base.t.sol index eafc5e90..632bc3c5 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 {PackageKind} from "../src/interfaces/PackageKind.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); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); } /** @@ -184,6 +185,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..44bd0cff --- /dev/null +++ b/test/ComposableCow.discovery.t.sol @@ -0,0 +1,167 @@ +// 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"; +import {PackageKind} from "../src/interfaces/PackageKind.sol"; + +error TestNoOrder(); + +/** + * @dev Minimal handler committing a module: the OrderModule mixin under test + */ +contract ModuleHandler is OrderModule { + constructor(string[] memory uris, bytes32 digest, PackageKind kind) OrderModule(uris, digest, kind) {} + + 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] = "https://example.com/twap-oracle.tar.zst"; + } + + // --- 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, PackageKind.SHA256); + + 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]); + (bytes32 digest, PackageKind kind) = handler.descriptorCommitment(); + assertEq(digest, TEST_DESCRIPTOR_DIGEST); + assertEq(uint256(kind), uint256(PackageKind.SHA256)); + } + + /** + * @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), PackageKind.BZZ_MANIFEST); + + 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, PackageKind.SHA256); + new StopLoss(testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); + } + + // --- 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, 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.SHA256)); + } + + /** + * @dev URIs without a commitment are unverifiable, so publishing them is + * non-conformant + */ + function test_module_RevertsUncommittedURI() public { + vm.expectRevert(OrderModule.UncommittedModuleURI.selector); + 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.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"), PackageKind.BZZ_MANIFEST); + 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), PackageKind.BZZ_MANIFEST); + 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(new string[](0), keccak256("module"), PackageKind.BZZ_MANIFEST); + + 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..34423885 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 {PackageKind} from "../src/interfaces/PackageKind.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(); + 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 533e3923..6fd4eeb8 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 {PackageKind} from "../src/interfaces/PackageKind.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(); + 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 67957387..1e107e24 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 {PackageKind} from "../src/interfaces/PackageKind.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(); + 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 3a5e5212..e236180a 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 {PackageKind} from "../src/interfaces/PackageKind.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); + twap = new TWAP(composableCow, testDescriptorUris(), TEST_DESCRIPTOR_DIGEST, PackageKind.SHA256); // deploy the current block timestamp factory currentBlockTimestampFactory = new CurrentBlockTimestampFactory();