-
Notifications
You must be signed in to change notification settings - Fork 51
[Certora] OfferTree Soundness #816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
21739ee
030c175
8e2e5ee
4898d72
215698b
b62eae7
b9d943e
9b92e50
0cd4f3e
07d95e3
bcc5198
38c1875
8208a49
80d2ad2
7a3e019
03ba8cd
dd415d0
24140f4
cfb723f
8187c24
2d1eb87
d77d0b3
939a570
d9f174e
fc758a3
253e37e
89c39d6
a43f97d
65e69d1
7091ed9
9d0635b
4a3f121
5dec124
956a11f
ea94696
7d251ea
fa88df5
a1b4b8e
be2a563
6aaa420
bec4e8f
c5fb0c9
bf8c505
6a5f897
758bfba
b8c5a24
86fd2d1
a613bc8
8da8e32
80eec16
2fe1897
58cf26e
f112dec
99aefcc
7942e0d
9ba4fdb
fc42bf1
059e09f
dff0e87
9b4a0cb
fed6519
01a91b3
eb46443
bf81ea9
098e1e3
1a1f195
b5aa320
ffb0dca
a729282
367e97b
9c8831f
cea79ab
73755a4
79a30b8
f89c785
1e52be2
4b9d2e7
bac71f7
c02787f
df175fe
9c1031a
642713a
7670ea8
88344c5
82ff177
38fa6f8
77188c9
b8508cd
aa0d660
d4e32d3
8297909
6a129c4
35143e5
f65b91e
7d727f8
631c286
c614e85
db3f600
b5d2d62
9a50fa9
e003af3
9446625
d0051d2
bfe5d11
eed485c
7f77776
024a893
bcdccd1
aad45e1
c3f8d3a
a1f624f
8e0a58f
94b10cc
28406dd
9c246c1
1e21778
aca534c
b105ad9
58f86a2
085246a
bb98ee9
5c05e7f
c2d82fd
b3c76af
5754a84
1940aeb
7360f87
dc3a82a
6eba1a5
88ec1d4
4a86303
c60091c
f10a7bd
8300f7c
aeac0d0
18f8179
8ea0155
7997fc5
314b913
86551f6
aef0c2a
42ad512
29b71a3
e113fc0
5277f60
56f27bf
7f9a1e1
34b1ce0
cbdbe1d
008b787
a52e7ac
a258ed6
f6cbe2f
c0e2d92
a539e66
b5870a9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "files": [ | ||
| "certora/helpers/OfferTree.sol" | ||
| ], | ||
| "verify": "OfferTree:certora/specs/OfferTreeMembership.spec", | ||
| "solc": "solc-0.8.34", | ||
| "solc_via_ir": true, | ||
| "solc_optimize": "200", | ||
| "solc_evm_version": "osaka", | ||
| "optimistic_loop": true, | ||
| "loop_iter": 3, | ||
| "optimistic_hashing": true, | ||
| "hashing_length_bound": 1024, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For an otherwise valid offer whose Useful? React with 👍 / 👎. |
||
| "rule_sanity": "basic", | ||
| "smt_timeout": 7200, | ||
| "prover_args": [ | ||
| "-splitParallel true", | ||
| "-s [z3:def{randomSeed=1},z3:def{randomSeed=2},z3:def{randomSeed=3},z3:def{randomSeed=4},z3:def{randomSeed=5}]" | ||
| ], | ||
| "msg": "Offer Tree Membership" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "files": [ | ||
| "certora/helpers/OfferTree.sol" | ||
| ], | ||
| "verify": "OfferTree:certora/specs/OfferTreeWellFormed.spec", | ||
| "solc": "solc-0.8.34", | ||
| "solc_via_ir": true, | ||
| "solc_optimize": "200", | ||
| "solc_evm_version": "osaka", | ||
| "optimistic_loop": true, | ||
| "loop_iter": 3, | ||
|
Comment on lines
+10
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With Useful? React with 👍 / 👎. |
||
| "optimistic_hashing": true, | ||
| "hashing_length_bound": 1024, | ||
| "rule_sanity": "basic", | ||
| "smt_timeout": 7200, | ||
| "prover_args": [ | ||
| "-splitParallel true", | ||
| "-s [z3:def{randomSeed=1},z3:def{randomSeed=2},z3:def{randomSeed=3},z3:def{randomSeed=4},z3:def{randomSeed=5}]" | ||
| ], | ||
| "msg": "OfferTreeWellFormed" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2025 Morpho Association | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import {OfferTree} from "./OfferTree.sol"; | ||
| import {Offer} from "../../src/interfaces/IMidnight.sol"; | ||
| import {HashLib} from "../../src/ratifiers/libraries/HashLib.sol"; | ||
|
|
||
| contract GenerateRoot is OfferTree { | ||
| // Build a perfect tree from a non-empty power-of-two list. Duplicate hashes share nodes. | ||
| function generateRoot(Offer[] memory leaves) public returns (bytes32) { | ||
| require(leaves.length > 0 && (leaves.length & (leaves.length - 1)) == 0, "invalid leaves length"); | ||
|
|
||
| bytes32[] memory level = new bytes32[](leaves.length); | ||
| for (uint256 i = 0; i < leaves.length; i++) { | ||
| bytes32 leafHash = HashLib.hashOffer(leaves[i]); | ||
| if (isEmpty(tree[leafHash])) { | ||
| newLeaf(leaves[i]); | ||
| } else { | ||
| require(isLeafNode(leafHash), "leaf id collision"); | ||
| } | ||
| level[i] = leafHash; | ||
| } | ||
|
|
||
| uint256 levelLength = level.length; | ||
| while (levelLength > 1) { | ||
| levelLength /= 2; | ||
| for (uint256 i = 0; i < levelLength; i++) { | ||
| bytes32 left = level[2 * i]; | ||
| bytes32 right = level[2 * i + 1]; | ||
| bytes32 nodeHash = HashLib.hashNode(left, right); | ||
| Node storage n = tree[nodeHash]; | ||
| if (isEmpty(n)) { | ||
| newInternalNode(nodeHash, left, right); | ||
| } else { | ||
| require(n.left == left && n.right == right && n.hash == nodeHash, "internal node id collision"); | ||
| } | ||
| level[i] = nodeHash; | ||
| } | ||
| } | ||
|
|
||
| return level[0]; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2025 Morpho Association | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import {Offer} from "../../src/interfaces/IMidnight.sol"; | ||
| import {HashLib, OFFER_TYPEHASH} from "../../src/ratifiers/libraries/HashLib.sol"; | ||
|
|
||
| // Fixed-size pre-image of HashLib.hashOffer. Dynamic fields are stored as hashes so CVL can re-hash a leaf | ||
| // without iterating over dynamic data. | ||
| struct Leaf { | ||
| bytes32 marketHash; // = HashLib.hashMarket(offer.market) | ||
| bool buy; | ||
| address maker; | ||
| uint256 start; | ||
| uint256 expiry; | ||
| uint256 tick; | ||
| bytes32 group; | ||
| address callback; | ||
| bytes32 callbackDataHash; // = keccak256(offer.callbackData) | ||
| address receiverIfMakerIsSeller; | ||
| address ratifier; | ||
| bool reduceOnly; | ||
| uint256 maxUnits; | ||
| uint256 maxAssets; | ||
| uint256 continuousFeeCap; | ||
| } | ||
|
|
||
| contract OfferTree { | ||
| struct Node { | ||
| bytes32 left; | ||
| bytes32 right; | ||
| Leaf leaf; | ||
| // Offer hash for leaves and hash of the children for internal nodes. | ||
| bytes32 hash; | ||
| } | ||
|
|
||
| // Leaf ids are offer hashes. Internal node ids may be arbitrary. | ||
| mapping(bytes32 => Node) internal tree; | ||
|
|
||
| function newLeaf(Offer memory offer) public { | ||
| bytes32 id = HashLib.hashOffer(offer); | ||
| require(id != 0, "id is the zero bytes"); | ||
| Node storage n = tree[id]; | ||
| require(isEmpty(n), "leaf is not empty"); | ||
| Leaf storage l = n.leaf; | ||
| l.marketHash = HashLib.hashMarket(offer.market); | ||
| l.buy = offer.buy; | ||
| l.maker = offer.maker; | ||
| l.start = offer.start; | ||
| l.expiry = offer.expiry; | ||
| l.tick = offer.tick; | ||
| l.group = offer.group; | ||
| l.callback = offer.callback; | ||
| l.callbackDataHash = keccak256(offer.callbackData); | ||
| l.receiverIfMakerIsSeller = offer.receiverIfMakerIsSeller; | ||
| l.ratifier = offer.ratifier; | ||
| l.reduceOnly = offer.reduceOnly; | ||
| l.maxUnits = offer.maxUnits; | ||
| l.maxAssets = offer.maxAssets; | ||
| l.continuousFeeCap = offer.continuousFeeCap; | ||
| n.hash = id; | ||
| } | ||
|
|
||
| function newInternalNode(bytes32 id, bytes32 left, bytes32 right) public { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For a leafnode, the id equals the hash (
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Initially this constraint was there to prevent having the same address multiple times in the tree. We don't have this requirement, but it's weirdly useful here. Assume that the id of the leaves was not necessarily the same as the hash, and you remove the corresponding check in Another more straightforward way to avoid the above scenario, is to check that the tree is immutable. |
||
| require(id != 0, "zero id"); | ||
| Node storage n = tree[id]; | ||
| require(isEmpty(n), "node already populated"); | ||
| bytes32 leftHash = tree[left].hash; | ||
| bytes32 rightHash = tree[right].hash; | ||
| require(leftHash != 0, "left empty"); | ||
| require(rightHash != 0, "right empty"); | ||
| bytes32 hash = HashLib.hashNode(leftHash, rightHash); | ||
| require(hash != 0, "zero hash"); | ||
| n.left = left; | ||
| n.right = right; | ||
| n.hash = hash; | ||
| } | ||
|
|
||
| function isEmpty(Node storage n) internal view returns (bool) { | ||
| return n.hash == 0; | ||
| } | ||
|
|
||
| function isEmpty(bytes32 id) public view returns (bool) { | ||
| return isEmpty(tree[id]); | ||
| } | ||
|
|
||
| function getHash(bytes32 id) public view returns (bytes32) { | ||
| return tree[id].hash; | ||
| } | ||
|
|
||
| function isLeafNode(bytes32 id) public view returns (bool) { | ||
| return tree[id].left == 0 && tree[id].right == 0 && tree[id].hash != 0; | ||
| } | ||
|
|
||
| function hashOffer(Offer memory offer) public pure returns (bytes32) { | ||
| return HashLib.hashOffer(offer); | ||
| } | ||
|
|
||
| function isLeaf(bytes32 root, bytes32 leafHash, uint256 leafIndex, bytes32[] memory proof) | ||
| public | ||
| pure | ||
| returns (bool) | ||
| { | ||
| return HashLib.isLeaf(root, leafHash, leafIndex, proof); | ||
| } | ||
|
|
||
| // Reconstruct HashLib.hashOffer from the stored pre-image. | ||
| function hashLeaf(Leaf storage l) internal view returns (bytes32) { | ||
| return keccak256( | ||
| abi.encode( | ||
| OFFER_TYPEHASH, | ||
| l.marketHash, | ||
| l.buy, | ||
| l.maker, | ||
| l.start, | ||
| l.expiry, | ||
| l.tick, | ||
| l.group, | ||
| l.callback, | ||
| l.callbackDataHash, | ||
| l.receiverIfMakerIsSeller, | ||
| l.ratifier, | ||
| l.reduceOnly, | ||
| l.maxUnits, | ||
| l.maxAssets, | ||
| l.continuousFeeCap | ||
| ) | ||
|
Comment on lines
+123
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| ); | ||
| } | ||
|
|
||
| // A node is empty, a correctly hashed leaf, or a correctly hashed internal node with two children. | ||
| function isWellFormed(bytes32 id) public view returns (bool) { | ||
| Node storage n = tree[id]; | ||
| if (isEmpty(n)) return n.left == 0 && n.right == 0; | ||
| if (n.left == 0 && n.right == 0) { | ||
| bytes32 expected = hashLeaf(n.leaf); | ||
| return n.hash == expected && id == expected; | ||
| } | ||
| if (n.left != 0 && n.right != 0) { | ||
| bytes32 leftHash = tree[n.left].hash; | ||
| bytes32 rightHash = tree[n.right].hash; | ||
| return leftHash != 0 && rightHash != 0 && n.hash == HashLib.hashNode(leftHash, rightHash); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| // Check the path selected by leafIndex. | ||
| function wellFormedPath(bytes32 id, uint256 leafIndex, uint256 depth) public view returns (bool) { | ||
| for (uint256 i = depth;;) { | ||
| require(isWellFormed(id)); | ||
|
|
||
| if (i == 0) break; | ||
|
|
||
| --i; | ||
| id = ((leafIndex >> i) & 1 == 0) ? tree[id].left : tree[id].right; | ||
| } | ||
| return true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
|
|
||
| methods { | ||
| function getHash(bytes32) external returns (bytes32) envfree; | ||
| function hashOffer(OfferTree.Offer) external returns (bytes32) envfree; | ||
| function isLeaf(bytes32, bytes32, uint256, bytes32[]) external returns (bool) envfree; | ||
| function isLeafNode(bytes32) external returns (bool) envfree; | ||
| function wellFormedPath(bytes32, uint256, uint256) external returns (bool) envfree; | ||
| } | ||
|
|
||
| // Soundness: if a Merkle proof verifies along a well-formed path, hashOffer(offer) must be a leaf node. | ||
| rule membershipSoundness(OfferTree.Offer offer, bytes32 root, uint256 leafIndex, bytes32[] proof) { | ||
| bytes32 node; | ||
| require getHash(node) == root, "root is the hash of node"; | ||
| require wellFormedPath(node, leafIndex, proof.length), "the path from the root to the leaf is well-formed"; | ||
| bytes32 leafId = hashOffer(offer); | ||
| require isLeaf(root, leafId, leafIndex, proof), "Merkle proof verifies the offer"; | ||
|
|
||
| assert isLeafNode(leafId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
|
|
||
| // Leaves store a fixed-size pre-image so isWellFormed can re-hash them without dynamic loops. | ||
|
|
||
| methods { | ||
| function isEmpty(bytes32) external returns (bool) envfree; | ||
| function isWellFormed(bytes32) external returns (bool) envfree; | ||
| } | ||
|
|
||
| // The zero node remains empty. | ||
| strong invariant zeroIsEmpty() | ||
| isEmpty(to_bytes32(0)); | ||
|
|
||
| // Every node remains well-formed. | ||
| strong invariant wellFormed(bytes32 id) | ||
| isWellFormed(id) | ||
| { | ||
| preserved { | ||
| requireInvariant zeroIsEmpty(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With
optimistic_loopenabled, thisloop_iteronly unrolls theproof.lengthloops inOfferTree.wellFormedPathandHashLib.isLeaffor three levels, while the ratifiers accept signed offer trees up to height 20 viaofferTreeTypeHash. For any concrete root/proof of height 4–20, the headline membership soundness proof is therefore only checking a truncated Merkle path, so it does not justify the README claim for the supported tree sizes. Set the loop bound to 20 (or otherwise requireproof.length <= 3in the property and docs).Useful? React with 👍 / 👎.