diff --git a/src/ComposableCow.sol b/src/ComposableCow.sol index 8b61eaf..e53710e 100644 --- a/src/ComposableCow.sol +++ b/src/ComposableCow.sol @@ -35,6 +35,14 @@ interface ISafeSignaturePayload { contract ComposableCow is ISafeSignatureVerifier { // --- errors error ProofNotAuthed(); + /** + * @dev A zero root (explicit clear) must carry an empty proof + */ + error ProofDataMalformed(); + /** + * @dev A listed blob versioned hash is not attached to this transaction + */ + error BlobNotAttached(); error SingleOrderNotAuthed(); error SwapGuardRestricted(); error InvalidHandler(); @@ -50,10 +58,20 @@ contract ComposableCow is ISafeSignatureVerifier { bytes offchainInput; } - // A struct representing where to find the proofs + /** + * @dev Where to find the merkle payload document (the complete leaf set, + * see `docs/discovery.md` ยง3). Two orthogonal publication channels, + * each optional: + * - `uris`: mirrors for the payload document; all URIs MUST reference + * the same bytes. Never interpreted on-chain. + * - `blobVersionedHashes`: EIP-4844 blobs carrying the payload. Every + * listed hash is verified attached to THIS transaction, binding + * publication to authorization. + * Both empty = private: no discovery is expected. + */ struct Proof { - uint256 location; - bytes data; + string[] uris; + bytes32[] blobVersionedHashes; } /** @@ -382,10 +400,33 @@ contract ComposableCow is ISafeSignatureVerifier { * @dev Write the root and emit, carrying the resolved cabinet context */ function _setRoot(bytes32 root, Proof calldata proof, bytes memory context) internal { + if (root == bytes32(0)) { + // Explicit clear: a zero root authorizes no leaf; publishing a + // payload for it is malformed + require(proof.uris.length == 0 && proof.blobVersionedHashes.length == 0, ProofDataMalformed()); + } + // Publication is atomic with authorization: every listed blob must be + // attached to the transaction setting the root + for (uint256 i = 0; i < proof.blobVersionedHashes.length; i++) { + require(_blobAttached(proof.blobVersionedHashes[i]), BlobNotAttached()); + } roots[msg.sender] = root; emit MerkleRootSet(msg.sender, root, proof, context); } + /** + * @dev True iff `versionedHash` is among this transaction's blob hashes. + * `blobhash(i)` returns zero past the last blob, and a real versioned + * hash can never be zero (it always begins with the 0x01 version byte). + */ + function _blobAttached(bytes32 versionedHash) internal view returns (bool) { + for (uint256 i;; i++) { + bytes32 h = blobhash(i); + if (h == bytes32(0)) return false; + if (h == versionedHash) return true; + } + } + /** * @dev Reject a null handler, then hash. Obtaining the `orderHash` that * `_create` requires therefore cannot skip the check, and the context diff --git a/test/ComposableCoW.t.sol b/test/ComposableCoW.t.sol index 81d11ba..f3674ad 100644 --- a/test/ComposableCoW.t.sol +++ b/test/ComposableCoW.t.sol @@ -27,12 +27,16 @@ contract ComposableCowTest is BaseComposableCowTest { * @dev Can set the Merkle root for `owner` */ function test_setRoot_FuzzSetAndEmit(address owner, bytes32 root) public { - _setRoot(owner, root, ComposableCow.Proof({location: 0, data: ""})); + _setRoot(owner, root, ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)})); } function test_setRootWithContext_FuzzSetAndEmit(address owner, bytes32 root, bytes32 data) public { _setRootWithContext( - owner, root, ComposableCow.Proof({location: 0, data: ""}), testContextValue, abi.encode(data) + owner, + root, + ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)}), + testContextValue, + abi.encode(data) ); } @@ -54,7 +58,8 @@ contract ComposableCowTest is BaseComposableCowTest { composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); // should set the root correctly - ComposableCow.Proof memory proofStruct = ComposableCow.Proof({location: 0, data: ""}); + ComposableCow.Proof memory proofStruct = + ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)}); _setRoot(address(safe1), root, proofStruct); // should pass with the root correctly set @@ -97,7 +102,8 @@ contract ComposableCowTest is BaseComposableCowTest { composableCow.getTradeableOrderWithSignature(address(safe1), params, bytes(""), proof); // should set the root correctly - ComposableCow.Proof memory proofStruct = ComposableCow.Proof({location: 0, data: ""}); + ComposableCow.Proof memory proofStruct = + ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)}); _setRootWithContext(address(safe1), root, proofStruct, testContextValue, abi.encode(bytes32("testValue"))); // should pass with the root correctly set @@ -289,7 +295,7 @@ contract ComposableCowTest is BaseComposableCowTest { }); // should set the root - _setRoot(owner, root, ComposableCow.Proof({location: 0, data: ""})); + _setRoot(owner, root, ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)})); // should revert as the proof is invalid vm.expectRevert(ComposableCow.ProofNotAuthed.selector); @@ -423,7 +429,7 @@ contract ComposableCowTest is BaseComposableCowTest { }); // should set the root - _setRoot(owner, root, ComposableCow.Proof({location: 0, data: ""})); + _setRoot(owner, root, ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)})); // should revert as the proof is invalid vm.expectRevert(ComposableCow.ProofNotAuthed.selector); diff --git a/test/ComposableCow.proof.t.sol b/test/ComposableCow.proof.t.sol new file mode 100644 index 0000000..b0b1540 --- /dev/null +++ b/test/ComposableCow.proof.t.sol @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +import {IConditionalOrder, ComposableCow, BaseComposableCowTest} from "./ComposableCow.base.t.sol"; +import {ComposableCowLib} from "./libraries/ComposableCowLib.t.sol"; + +/** + * @title Tests for the URI-based Proof struct: blob publication verification, + * zero-root hygiene, and the normative payload tree construction + */ +contract ComposableCowProofTest is BaseComposableCowTest { + using ComposableCowLib for IConditionalOrder.ConditionalOrderParams[]; + + /** + * @dev A plausible EIP-4844 versioned hash: 0x01 version byte prefix + */ + function _versionedHash(bytes32 seed) internal pure returns (bytes32) { + return bytes32((uint256(keccak256(abi.encode(seed))) >> 8) | (uint256(0x01) << 248)); + } + + /** + * @dev Set the transaction's blob hashes via the cheatcode address + * directly - the pinned forge-std Vm interface predates it + */ + function _setTxBlobs(bytes32[] memory hashes) internal { + (bool ok,) = address(vm).call(abi.encodeWithSignature("blobhashes(bytes32[])", hashes)); + require(ok, "vm.blobhashes unavailable"); + } + + function _emptyProof() internal pure returns (ComposableCow.Proof memory) { + return ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)}); + } + + // --- URI mirrors --- + + /** + * @dev URIs are opaque to the contract: any mirror set round-trips + * through the event unmodified + */ + function test_setRoot_EmitsUriMirrors() public { + string[] memory uris = new string[](2); + uris[0] = "bzz://c0ffee"; + uris[1] = "ipfs://bafybeigdyrzt5example"; + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: uris, blobVersionedHashes: new bytes32[](0)}); + + vm.expectEmit(true, true, true, true); + emit ComposableCow.MerkleRootSet(address(safe1), keccak256("root"), proof, bytes("")); + + vm.prank(address(safe1)); + composableCow.setRoot(keccak256("root"), proof); + } + + // --- blob publication verification --- + + /** + * @dev Every listed blob hash attached to the transaction: accepted + */ + function test_setRoot_BlobsAttached() public { + bytes32[] memory txBlobs = new bytes32[](2); + txBlobs[0] = _versionedHash("blob0"); + txBlobs[1] = _versionedHash("blob1"); + _setTxBlobs(txBlobs); + + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: txBlobs}); + + vm.prank(address(safe1)); + composableCow.setRoot(keccak256("root"), proof); + assertEq(composableCow.roots(address(safe1)), keccak256("root")); + } + + /** + * @dev A subset of the transaction's blobs is fine - the payload need + * not occupy every blob in the transaction + */ + function test_setRoot_BlobSubsetAttached() public { + bytes32[] memory txBlobs = new bytes32[](3); + txBlobs[0] = _versionedHash("blob0"); + txBlobs[1] = _versionedHash("blob1"); + txBlobs[2] = _versionedHash("blob2"); + _setTxBlobs(txBlobs); + + bytes32[] memory listed = new bytes32[](1); + listed[0] = txBlobs[1]; + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: listed}); + + vm.prank(address(safe1)); + composableCow.setRoot(keccak256("root"), proof); + } + + /** + * @dev A listed hash missing from the transaction: publication is not + * atomic with authorization, so the root cannot be set + */ + function test_setRoot_RevertsBlobNotAttached() public { + bytes32[] memory txBlobs = new bytes32[](1); + txBlobs[0] = _versionedHash("blob0"); + _setTxBlobs(txBlobs); + + bytes32[] memory listed = new bytes32[](2); + listed[0] = txBlobs[0]; + listed[1] = _versionedHash("not attached"); + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: listed}); + + vm.prank(address(safe1)); + vm.expectRevert(ComposableCow.BlobNotAttached.selector); + composableCow.setRoot(keccak256("root"), proof); + } + + /** + * @dev No blobs on the transaction at all: any listed hash reverts + */ + function test_setRoot_RevertsBlobNotAttachedNoBlobs() public { + bytes32[] memory listed = new bytes32[](1); + listed[0] = _versionedHash("blob0"); + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: listed}); + + vm.prank(address(safe1)); + vm.expectRevert(ComposableCow.BlobNotAttached.selector); + composableCow.setRoot(keccak256("root"), proof); + } + + // --- zero-root clear hygiene --- + + /** + * @dev Zero root with an empty proof is an explicit clear + */ + function test_setRoot_ZeroRootClears() public { + vm.startPrank(address(safe1)); + composableCow.setRoot(keccak256("root"), _emptyProof()); + composableCow.setRoot(bytes32(0), _emptyProof()); + vm.stopPrank(); + assertEq(composableCow.roots(address(safe1)), bytes32(0)); + } + + /** + * @dev Publishing a payload for a root that authorizes nothing is + * malformed: clears must carry an empty proof + */ + function test_setRoot_RevertsZeroRootWithUris() public { + string[] memory uris = new string[](1); + uris[0] = "bzz://c0ffee"; + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: uris, blobVersionedHashes: new bytes32[](0)}); + + vm.prank(address(safe1)); + vm.expectRevert(ComposableCow.ProofDataMalformed.selector); + composableCow.setRoot(bytes32(0), proof); + } + + function test_setRoot_RevertsZeroRootWithBlobs() public { + bytes32[] memory txBlobs = new bytes32[](1); + txBlobs[0] = _versionedHash("blob0"); + _setTxBlobs(txBlobs); + ComposableCow.Proof memory proof = ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: txBlobs}); + + vm.prank(address(safe1)); + vm.expectRevert(ComposableCow.ProofDataMalformed.selector); + composableCow.setRoot(bytes32(0), proof); + } + + // --- normative tree construction (leafEncoding "v1" vectors) --- + + /** + * @dev The payload standard's tree construction, implemented + * independently of the Murky test helper: ascending-sorted leaf + * hashes, bottom-up sorted-pair keccak, odd trailing node promoted + * unchanged. Mutates `hashes` in place. + */ + function _normativeRoot(bytes32[] memory hashes) internal pure returns (bytes32) { + _sortAscending(hashes); + uint256 n = hashes.length; + while (n > 1) { + uint256 half = n / 2; + for (uint256 i = 0; i < half; i++) { + hashes[i] = _hashSortedPair(hashes[2 * i], hashes[2 * i + 1]); + } + if (n % 2 == 1) { + // odd trailing node promoted unchanged + hashes[half] = hashes[n - 1]; + n = half + 1; + } else { + n = half; + } + } + return hashes[0]; + } + + function _hashSortedPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { + return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); + } + + function _sortAscending(bytes32[] memory a) internal pure { + for (uint256 i = 1; i < a.length; i++) { + bytes32 key = a[i]; + uint256 j = i; + while (j > 0 && a[j - 1] > key) { + a[j] = a[j - 1]; + j--; + } + a[j] = key; + } + } + + /** + * @dev Derive the inclusion proof for `target` under the normative + * construction. A node promoted from an odd level contributes no + * sibling for that level. + */ + function _normativeProof(bytes32[] memory hashes, bytes32 target) internal pure returns (bytes32[] memory proof) { + _sortAscending(hashes); + bytes32[] memory scratch = new bytes32[](hashes.length); + bytes32[] memory acc = new bytes32[](64); + uint256 depth; + bytes32 tracked = target; + uint256 n = hashes.length; + for (uint256 i = 0; i < n; i++) { + scratch[i] = hashes[i]; + } + while (n > 1) { + uint256 half = n / 2; + for (uint256 i = 0; i < half; i++) { + bytes32 a = scratch[2 * i]; + bytes32 b = scratch[2 * i + 1]; + bytes32 parent = _hashSortedPair(a, b); + if (a == tracked || b == tracked) { + acc[depth++] = a == tracked ? b : a; + tracked = parent; + } + scratch[i] = parent; + } + if (n % 2 == 1) { + // promoted unchanged: no sibling at this level + scratch[half] = scratch[n - 1]; + n = half + 1; + } else { + n = half; + } + } + proof = new bytes32[](depth); + for (uint256 i = 0; i < depth; i++) { + proof[i] = acc[i]; + } + } + + /** + * @dev The normative construction (`leafEncoding: "v1"`) is verifiable by + * exactly the check `_auth` performs (OZ `MerkleProof.verify`), for + * every leaf across minimal, even, and odd tree sizes - including + * the odd-promotion levels + */ + function test_payloadTree_NormativeConstructionVerifiesLikeAuth() public { + uint256[5] memory sizes = [uint256(2), 3, 4, 5, 7]; + for (uint256 s = 0; s < sizes.length; s++) { + IConditionalOrder.ConditionalOrderParams[] memory bundle = getBundle(safe1, sizes[s]); + bytes32[] memory hashes = new bytes32[](bundle.length); + for (uint256 i = 0; i < bundle.length; i++) { + hashes[i] = keccak256(abi.encode(bundle[i])); + } + + bytes32[] memory forRoot = new bytes32[](hashes.length); + for (uint256 i = 0; i < hashes.length; i++) { + forRoot[i] = hashes[i]; + } + bytes32 root = _normativeRoot(forRoot); + + for (uint256 i = 0; i < bundle.length; i++) { + bytes32 leaf = keccak256(abi.encode(bundle[i])); + bytes32[] memory forProof = new bytes32[](hashes.length); + for (uint256 j = 0; j < hashes.length; j++) { + forProof[j] = hashes[j]; + } + bytes32[] memory proof = _normativeProof(forProof, leaf); + // the exact check _auth performs + assertTrue(MerkleProof.verify(proof, root, leaf), "normative proof rejected by OZ verify"); + } + } + } +} diff --git a/test/ComposableCow.twap.t.sol b/test/ComposableCow.twap.t.sol index d737e98..3a5e521 100644 --- a/test/ComposableCow.twap.t.sol +++ b/test/ComposableCow.twap.t.sol @@ -489,7 +489,9 @@ contract ComposableCowTwapTest is BaseComposableCowTest { _leaves.getRootAndProof(0, leaves, getRoot, getProof); // 3. Set the root - _setRoot(address(safe1), root, ComposableCow.Proof({location: 0, data: ""})); + _setRoot( + address(safe1), root, ComposableCow.Proof({uris: new string[](0), blobVersionedHashes: new bytes32[](0)}) + ); // 4. Get the order and signature (ComposableCow.PollResult memory orderRes, bytes memory signature) =