-
Notifications
You must be signed in to change notification settings - Fork 45
[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
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -43,6 +43,39 @@ How offers are consumed when taken. | |||||
| - [`EmptyOffer.spec`](specs/EmptyOffer.spec) checks that taking an empty offer always reverts (so the offer tree can be padded with empty offers). | ||||||
| - [`Ratification.spec`](specs/Ratification.spec) checks that every successful take requires the maker to have authorized the ratifier. | ||||||
|
|
||||||
| ## Offer trees | ||||||
|
|
||||||
| Offers are authorized in batches: a ratifier signs a single Merkle root over an offer tree, and a `take` only succeeds if the offer's hash is proven to be a leaf under that root. | ||||||
| Objective is to show that a successful `take` can only settle an offer that was genuinely committed in the signed tree. | ||||||
| We reason about [`OfferTree`](helpers/OfferTree.sol), a model of the tree built only through the `newLeaf` and `newInternalNode` primitives. Leaves are keyed by `HashLib.hashOffer(offer)` and store a fixed-size pre-image of the offer, so `isWellFormed` re-hashes a leaf with a single bounded keccak instead of looping over the offer's dynamic members, which keeps the proofs bounded regardless of offer size. | ||||||
|
|
||||||
| - [`OfferTreeWellFormed.spec`](specs/OfferTreeWellFormed.spec) checks that the primitives only ever build well-formed trees: every node is empty, a leaf carrying a genuine `hashOffer`, or an internal node correctly hashing its two children. In particular, there is no restriction for the left and right children of a parent node to be sorted. | ||||||
| - [`OfferTreeMembership.spec`](specs/OfferTreeMembership.spec) checks the main soundness result: for any well-formed tree, if a Merkle proof verifies an offer's hash against the root, then the offer is registered as a leaf. Equivalently, no valid proof can be forged for an offer that is not in the tree. | ||||||
| - [`Ratification.spec`](specs/Ratification.spec) connects this to the on-chain path: every successful `isRatified` (across all ratifier implementations) and every successful `take` actually invokes `HashLib.isLeaf` and it returns true. | ||||||
|
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.
Suggested change
Because |
||||||
|
|
||||||
| Combining the three: a successful `take` runs a Merkle membership check against the ratified root (`Ratification`), which for a well-formed root implies the offer is genuinely a leaf of that tree (`OfferTreeMembership`), and the root is well-formed because it is built only from the well-formedness-preserving primitives (`OfferTreeWellFormed`). | ||||||
|
|
||||||
| ### Checking a concrete root | ||||||
|
|
||||||
| The membership result is stated for any well-formed root; the checker in [`checker`](checker) lets anyone confirm what a specific root commits to, by rebuilding the tree through the same verified primitives. | ||||||
|
|
||||||
| 1. Write a `proofs.json` listing the claimed `root` and the offers (`leaves`), padded to a power of two with empty offers. | ||||||
| 2. Generate the certificate, from the repository root: | ||||||
| ``` | ||||||
| python certora/checker/create_certificate.py proofs.json | ||||||
|
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. In the URD the |
||||||
| ``` | ||||||
| This recomputes the tree bottom-up, asserts the computed root equals the claimed `root`, and writes `certificate.json`. | ||||||
| 3. Replay it through the verified primitives: | ||||||
| ``` | ||||||
| FOUNDRY_PROFILE=checker forge test --match-test testVerifyCertificate | ||||||
| ``` | ||||||
| [`Checker.sol`](checker/Checker.sol) reads `certificate.json`, rebuilds the tree via `OfferTree.newLeaf`/`newInternalNode`, and asserts the constructed root equals `root`. | ||||||
|
|
||||||
| A passing run certifies that the root is the root of a well-formed tree built from exactly those offers, so the membership guarantee applies to it. | ||||||
| This confirms what a root commits to, not that it was signed; verifying the ratifier's signature is a separate step. | ||||||
|
|
||||||
| The verification setup and technique is inspired from the Merkle Tree Membership soundness spec in [Universal Rewards Distributor](https://github.com/morpho-org/universal-rewards-distributor/). | ||||||
|
|
||||||
| ## Fees | ||||||
|
|
||||||
| Continuous-fee accrual and settlement-fee rounding stay within their expected bounds. | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2025 Morpho Association | ||
| pragma solidity ^0.8.0; | ||
|
|
||
| import {Test} from "../../lib/forge-std/src/Test.sol"; | ||
| import {stdJson} from "../../lib/forge-std/src/StdJson.sol"; | ||
|
|
||
| import {OfferTree} from "../helpers/OfferTree.sol"; | ||
| import {Offer} from "../../src/interfaces/IMidnight.sol"; | ||
| import {HashLib} from "../../src/ratifiers/libraries/HashLib.sol"; | ||
|
|
||
| contract Checker is Test { | ||
| using stdJson for string; | ||
|
|
||
| struct InternalNode { | ||
| bytes32 id; | ||
| bytes32 left; | ||
| bytes32 right; | ||
| } | ||
|
|
||
| // Replay the certificate through the verified `newLeaf` and `newInternalNode` | ||
| // primitives, then assert that the final certificate item matches `root`. | ||
| function testVerifyCertificate() public { | ||
| string memory path = string.concat(vm.projectRoot(), "/certificate.json"); | ||
| if (!vm.exists(path)) vm.skip(true, "no certificate.json at project root"); | ||
|
|
||
| string memory json = vm.readFile(path); | ||
| bytes32 root = json.readBytes32(".root"); | ||
|
|
||
| uint256 leafLength = json.readUint(".leafLength"); | ||
| Offer[] memory leaves = new Offer[](leafLength); | ||
| for (uint256 i = 0; i < leafLength; i++) { | ||
| bytes memory enc = json.readBytes(string.concat(".leaf[", vm.toString(i), "]")); | ||
| leaves[i] = abi.decode(enc, (Offer)); | ||
| } | ||
|
|
||
| uint256 nodeLength = json.readUint(".nodeLength"); | ||
| InternalNode[] memory nodes = new InternalNode[](nodeLength); | ||
| for (uint256 i = 0; i < nodeLength; i++) { | ||
| bytes memory enc = json.readBytes(string.concat(".node[", vm.toString(i), "]")); | ||
| nodes[i] = abi.decode(enc, (InternalNode)); | ||
| } | ||
|
|
||
| require(leaves.length > 0, "no leaves"); | ||
| require(nodes.length > 0 || leaves.length == 1, "missing internal nodes"); | ||
|
|
||
| OfferTree tree = new OfferTree(); | ||
|
|
||
| for (uint256 i = 0; i < leaves.length; i++) { | ||
| tree.newLeaf(leaves[i]); | ||
| } | ||
|
|
||
| bytes32 rootId = HashLib.hashOffer(leaves[0]); | ||
| for (uint256 i = 0; i < nodes.length; i++) { | ||
| InternalNode memory node = nodes[i]; | ||
| tree.newInternalNode(node.id, node.left, node.right); | ||
| rootId = node.id; | ||
| } | ||
|
|
||
| assertEq(tree.getHash(rootId), root, "mismatched roots"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| import json | ||
| import sys | ||
|
|
||
| from eth_abi import encode | ||
| from web3 import Web3 | ||
|
|
||
| w3 = Web3() | ||
|
|
||
| COLLATERAL_PARAMS_TYPEHASH = bytes.fromhex( | ||
| "af44a88eb50ebdbbebd980e5a23045c44f61ece5f80ab708a1bbe8718102e6af" | ||
| ) | ||
| MARKET_TYPEHASH = bytes.fromhex( | ||
| "358117e98511cc3df97175dca58053b06675b43ad090b0553f8a1eff008b6e2e" | ||
| ) | ||
| OFFER_TYPEHASH = bytes.fromhex( | ||
| "980a4cfc9766df84667f316d76e10cefc8caf04fb4cd4a9fca00a8e7b34f619c" | ||
| ) | ||
|
|
||
| # ABI signature for `Offer` matching src/interfaces/IMidnight.sol field order. | ||
| OFFER_ABI_TYPE = ( | ||
| "(" | ||
| "(address,(address,uint256,uint256,address)[],uint256,uint256,address,address)," | ||
| "bool,address,uint256,uint256,uint256,bytes32,address,bytes,address,address,bool,uint256,uint256" | ||
| ")" | ||
| ) | ||
| INTERNAL_NODE_ABI_TYPE = "(bytes32,bytes32,bytes32)" | ||
|
|
||
|
|
||
| # Safety checks use this instead of `assert`, which is stripped under `python -O`. | ||
| def _require(cond, msg): | ||
| if not cond: | ||
| raise ValueError(msg) | ||
|
|
||
|
|
||
| def _bytes32(v): | ||
| raw = bytes.fromhex(v.removeprefix("0x")) | ||
| _require(len(raw) == 32, f"expected 32 bytes, got {len(raw)}") | ||
| return raw | ||
|
|
||
|
|
||
| def _hexbytes(v): | ||
| return bytes.fromhex(v.removeprefix("0x")) | ||
|
|
||
|
|
||
| def _keccak_abi(types, values): | ||
| return w3.keccak(encode(types, values)) | ||
|
|
||
|
|
||
| # Returns the abi-encoded form of an Offer, ready for `abi.decode(bytes, (Offer))`. | ||
| def abi_encode_offer(o): | ||
| m = o["market"] | ||
| market = ( | ||
| w3.to_checksum_address(m["loanToken"]), | ||
| [ | ||
| ( | ||
| w3.to_checksum_address(cp["token"]), | ||
| int(cp["lltv"]), | ||
| int(cp["maxLif"]), | ||
| w3.to_checksum_address(cp["oracle"]), | ||
| ) | ||
| for cp in m["collateralParams"] | ||
| ], | ||
| int(m["maturity"]), | ||
| int(m["rcfThreshold"]), | ||
| w3.to_checksum_address(m["enterGate"]), | ||
| w3.to_checksum_address(m["liquidatorGate"]), | ||
| ) | ||
| offer = ( | ||
| market, | ||
| bool(o["buy"]), | ||
| w3.to_checksum_address(o["maker"]), | ||
| int(o["start"]), | ||
| int(o["expiry"]), | ||
| int(o["tick"]), | ||
| _bytes32(o["group"]), | ||
| w3.to_checksum_address(o["callback"]), | ||
| _hexbytes(o["callbackData"]), | ||
| w3.to_checksum_address(o["receiverIfMakerIsSeller"]), | ||
| w3.to_checksum_address(o["ratifier"]), | ||
| bool(o["reduceOnly"]), | ||
| int(o["maxUnits"]), | ||
| int(o["maxAssets"]), | ||
| ) | ||
| return "0x" + encode([OFFER_ABI_TYPE], [offer]).hex() | ||
|
|
||
|
|
||
| def abi_encode_node(node_id, left, right): | ||
| node = (_bytes32(node_id), _bytes32(left), _bytes32(right)) | ||
| return "0x" + encode([INTERNAL_NODE_ABI_TYPE], [node]).hex() | ||
|
|
||
|
|
||
| def hash_collateral_params(cp): | ||
| return _keccak_abi( | ||
| ["bytes32", "address", "uint256", "uint256", "address"], | ||
| [ | ||
| COLLATERAL_PARAMS_TYPEHASH, | ||
| w3.to_checksum_address(cp["token"]), | ||
| int(cp["lltv"]), | ||
| int(cp["maxLif"]), | ||
| w3.to_checksum_address(cp["oracle"]), | ||
| ], | ||
| ) | ||
|
|
||
|
|
||
| def hash_market(m): | ||
| collateral_params_hashes = b"".join( | ||
| hash_collateral_params(cp) for cp in m["collateralParams"] | ||
| ) | ||
| collateral_params_hash = w3.keccak(collateral_params_hashes) | ||
| return _keccak_abi( | ||
| ["bytes32", "address", "bytes32", "uint256", "uint256", "address", "address"], | ||
| [ | ||
| MARKET_TYPEHASH, | ||
| w3.to_checksum_address(m["loanToken"]), | ||
| collateral_params_hash, | ||
| int(m["maturity"]), | ||
| int(m["rcfThreshold"]), | ||
| w3.to_checksum_address(m["enterGate"]), | ||
| w3.to_checksum_address(m["liquidatorGate"]), | ||
| ], | ||
| ) | ||
|
|
||
|
|
||
| # Returns the hash of an offer (mirrors HashLib.hashOffer). | ||
| def hash_offer(o): | ||
| return w3.to_hex( | ||
| _keccak_abi( | ||
| [ | ||
| "bytes32", | ||
| "bytes32", | ||
| "bool", | ||
| "address", | ||
| "uint256", | ||
| "uint256", | ||
| "uint256", | ||
| "bytes32", | ||
| "address", | ||
| "bytes32", | ||
| "address", | ||
| "address", | ||
| "bool", | ||
| "uint256", | ||
| "uint256", | ||
| ], | ||
| [ | ||
| OFFER_TYPEHASH, | ||
| hash_market(o["market"]), | ||
| bool(o["buy"]), | ||
| w3.to_checksum_address(o["maker"]), | ||
| int(o["start"]), | ||
| int(o["expiry"]), | ||
| int(o["tick"]), | ||
| _bytes32(o["group"]), | ||
| w3.to_checksum_address(o["callback"]), | ||
| w3.keccak(_hexbytes(o["callbackData"])), | ||
| w3.to_checksum_address(o["receiverIfMakerIsSeller"]), | ||
| w3.to_checksum_address(o["ratifier"]), | ||
| bool(o["reduceOnly"]), | ||
| int(o["maxUnits"]), | ||
| int(o["maxAssets"]), | ||
| ], | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| # Returns the hash of a node given the hashes of its children. | ||
| def hash_node(left, right): | ||
| return w3.to_hex(w3.keccak(_bytes32(left) + _bytes32(right))) | ||
|
|
||
|
|
||
| # Builds a certificate from a power-of-two list of offers, in leftIndex order, by pairing | ||
| # leaves level-by-level into a perfect binary tree. | ||
| def build_certificate(leaves, claimed_root): | ||
| n = len(leaves) | ||
| _require(n > 0 and (n & (n - 1)) == 0, "leaves count must be a power of two") | ||
|
|
||
| leaf_instructions = {} | ||
| node_instructions = {} | ||
|
|
||
| level = [hash_offer(o) for o in leaves] | ||
| for leaf, leaf_hash in zip(leaves, level): | ||
| encoded_leaf = abi_encode_offer(leaf) | ||
| previous = leaf_instructions.setdefault(leaf_hash, encoded_leaf) | ||
| _require(previous == encoded_leaf, "leaf hash collides with a different offer") | ||
|
|
||
| while len(level) > 1: | ||
| next_level = [] | ||
| for i in range(len(level) // 2): | ||
| left = level[2 * i] | ||
| right = level[2 * i + 1] | ||
| node_hash = hash_node(left, right) | ||
| _require(node_hash not in leaf_instructions, "internal node id collides with a leaf id") | ||
| encoded_node = abi_encode_node(node_hash, left, right) | ||
| previous = node_instructions.setdefault(node_hash, encoded_node) | ||
| _require(previous == encoded_node, "internal node id collides with different children") | ||
| next_level.append(node_hash) | ||
| level = next_level | ||
|
|
||
| _require( | ||
| level[0].lower() == claimed_root.lower(), | ||
| f"computed root {level[0]} != claimed root {claimed_root}", | ||
| ) | ||
|
|
||
| return { | ||
| "root": claimed_root, | ||
| "leafLength": len(leaf_instructions), | ||
| "leaf": list(leaf_instructions.values()), | ||
| "nodeLength": len(node_instructions), | ||
| "node": list(node_instructions.values()), | ||
| } | ||
|
|
||
|
|
||
| def main(): | ||
| if len(sys.argv) != 2: | ||
| print("usage: python create_certificate.py <proofs.json>", file=sys.stderr) | ||
| sys.exit(2) | ||
|
|
||
| with open(sys.argv[1]) as f: | ||
| proofs = json.load(f) | ||
|
|
||
| root = proofs["root"] | ||
| certificate = build_certificate([entry["offer"] for entry in proofs["leaves"]], root) | ||
|
|
||
| with open("certificate.json", "w") as f: | ||
| json.dump(certificate, f, indent=2) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| { | ||
| "files": [ | ||
| "src/Midnight.sol", | ||
| "certora/helpers/OfferTree.sol", | ||
| "certora/helpers/Utils.sol" | ||
| ], | ||
| "verify": "Midnight:certora/specs/OfferTreeMembership.spec", | ||
| "solc": "solc-0.8.34", | ||
| "solc_via_ir": true, | ||
| "solc_evm_version": "osaka", | ||
| "optimistic_loop": true, | ||
| "loop_iter": 3, | ||
|
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": "Offer Tree Membership" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| { | ||
| "files": [ | ||
| "certora/helpers/OfferTree.sol" | ||
| ], | ||
| "verify": "OfferTree:certora/specs/OfferTreeWellFormed.spec", | ||
| "solc": "solc-0.8.34", | ||
| "solc_via_ir": true, | ||
| "solc_evm_version": "osaka", | ||
| "optimistic_loop": true, | ||
| "loop_iter": 3, | ||
| "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" | ||
| } |
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.
Let's keep the following convention: one sentence per line