Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
21739ee
added rules inspired form URD
bhargavbh May 7, 2026
030c175
tuned
bhargavbh May 7, 2026
8e2e5ee
merged main
bhargavbh May 19, 2026
4898d72
take correctness passes; added some diagnostic tests
bhargavbh May 19, 2026
215698b
removed the diagnostic rules
bhargavbh May 19, 2026
b62eae7
cleaned up comments
bhargavbh May 19, 2026
b9d943e
add completeness rule
bhargavbh May 19, 2026
9b92e50
abstract properties instantiated to midnight
bhargavbh May 19, 2026
0cd4f3e
cvl fmt
bhargavbh May 19, 2026
07d95e3
fmt
bhargavbh May 19, 2026
bcc5198
renamed spec; minor changes in OfferTree definition
bhargavbh May 20, 2026
38c1875
tuned
bhargavbh May 20, 2026
8208a49
fmt
bhargavbh May 20, 2026
80d2ad2
forge fmt
bhargavbh May 20, 2026
7a3e019
Merge remote-tracking branch 'origin/main' into certora/offer-tree
bhargavbh May 20, 2026
03ba8cd
resolved errors from merge
bhargavbh May 20, 2026
dd415d0
updated callback summary signatures
bhargavbh May 20, 2026
24140f4
new rule in Ratification.spec: isRatified always calls isLeaf
bhargavbh May 20, 2026
cfb723f
rhashNode summarised similar to hashOffer
bhargavbh May 20, 2026
8187c24
revamped soundness: Offer included in the node
bhargavbh May 21, 2026
2d1eb87
tuned comment
bhargavbh May 21, 2026
d77d0b3
add checker
bhargavbh May 25, 2026
939a570
add checker
bhargavbh May 25, 2026
d9f174e
summarise isRatify
bhargavbh May 25, 2026
fc758a3
trimmed down to core properties
bhargavbh May 26, 2026
253e37e
removed completeness
bhargavbh May 29, 2026
89c39d6
comment style adapted to URD
bhargavbh Jun 1, 2026
a43f97d
retained only testVerifyCertificate in Checker; split offer into stat…
bhargavbh Jun 1, 2026
65e69d1
removed ratifier
bhargavbh Jun 1, 2026
7091ed9
added justification rules for the hashLeaf trick
bhargavbh Jun 1, 2026
9d0635b
removed assumptions from soundness rule
bhargavbh Jun 2, 2026
4a3f121
tuned
bhargavbh Jun 2, 2026
5dec124
tuned; retained market-level hashLeaf
bhargavbh Jun 2, 2026
956a11f
cleanup ratification spec
bhargavbh Jun 2, 2026
ea94696
varibale renaming in create_certificate.py; tuned checker
bhargavbh Jun 2, 2026
7d251ea
Merge origin/main into certora/offer-tree
bhargavbh Jun 2, 2026
fa88df5
added high-level verification approach in README
bhargavbh Jun 2, 2026
a1b4b8e
fmt
bhargavbh Jun 2, 2026
be2a563
acknowledge URD spec in README
bhargavbh Jun 2, 2026
6aaa420
asserts replaced with raising exceptions
bhargavbh Jun 2, 2026
bec4e8f
tuned
bhargavbh Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions certora/README.md

Copy link
Copy Markdown
Collaborator

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

Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- [`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.
- [`Ratification.spec`](specs/Ratification.spec) connects this to the on-chain path: every successful `isRatified` (across all ratifier implementations) and every successful `take` actually verifies the Merkle tree membership of an offer, taking as input the offer's hash and checking it against the root.

Because isLeaf is an implementation detail that is not mentioned before


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the URD the proofs.json was obtained as an artifact of the creation of the rewards distribution program (see the proofs.json files under this folder). Since here it doesn't work the same it makes less sense to take a proofs.json file. I'm thinking that we may prefer the full offer tree, which is what is produced when doing clear signing (see test/frontend/README.md)

```
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.
Expand Down
62 changes: 62 additions & 0 deletions certora/checker/Checker.sol
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");
}
}
229 changes: 229 additions & 0 deletions certora/checker/create_certificate.py
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()
22 changes: 22 additions & 0 deletions certora/confs/OfferTreeMembership.conf
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Raise the proof loop bound to cover supported trees

With optimistic_loop enabled, this loop_iter only unrolls the proof.length loops in OfferTree.wellFormedPath and HashLib.isLeaf for three levels, while the ratifiers accept signed offer trees up to height 20 via offerTreeTypeHash. 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 require proof.length <= 3 in the property and docs).

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"
}
20 changes: 20 additions & 0 deletions certora/confs/OfferTreeWellFormed.conf
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"
}
9 changes: 8 additions & 1 deletion certora/confs/Ratification.conf
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
{
"files": [
"src/Midnight.sol"
"src/Midnight.sol",
"src/ratifiers/EcrecoverRatifier.sol",
"src/ratifiers/SetterRatifier.sol",
"certora/helpers/Utils.sol"
],
"link": [
"EcrecoverRatifier:MIDNIGHT=Midnight",
"SetterRatifier:MIDNIGHT=Midnight"
],
"verify": "Midnight:certora/specs/Ratification.spec",
"solc": "solc-0.8.34",
Expand Down
Loading