Skip to content

[Certora] OfferTree Soundness#816

Open
bhargavbh wants to merge 41 commits into
mainfrom
certora/offer-tree
Open

[Certora] OfferTree Soundness#816
bhargavbh wants to merge 41 commits into
mainfrom
certora/offer-tree

Conversation

@bhargavbh

@bhargavbh bhargavbh commented May 7, 2026

Copy link
Copy Markdown
Contributor

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, 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 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.
  • OfferTreeMembership.spec checks the main soundness result: for any well-formed tree, if a Merkle proof verifies an offer's hash against the root via isLeaf, 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 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.

The verification setup and technique is inspired from the Merkle Tree Membership soundness spec in Universal Rewards Distributor

@MathisGD MathisGD changed the title added rules inspired form URD [Certora] added rules inspired form URD May 11, 2026
@bhargavbh bhargavbh self-assigned this May 19, 2026
@bhargavbh bhargavbh changed the title [Certora] added rules inspired form URD [Certora] OfferTree soundness and completeness May 20, 2026
bhargavbh and others added 12 commits June 1, 2026 14:44
Conflicts resolved:
- certora/specs/Ratification.spec: kept the offer-tree additions (isLeaf
  capture, isRatified dispatcher, isRatifiedRequiresIsLeaf/takeRequiresIsLeaf)
  and adopted main's take() parameter reorder (ratifierData 2nd) in the new
  takeRequiresIsLeaf rule; kept main's tradingFee->settlementFee rename.
- certora/confs/Ratification.conf: kept the ratifier files + links needed for
  the cross-ratifier dispatch claim.
- certora/helpers/Utils.sol: auto-merged (our hashOffer reword + removed
  hashNode wrapper, plus main's maxTradingFee->maxSettlementFee rename).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@bhargavbh bhargavbh changed the title [Certora] OfferTree soundness and completeness [Certora] OfferTree Soundness Jun 2, 2026
@bhargavbh bhargavbh marked this pull request as ready for review June 2, 2026 19:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be2a5635c1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread certora/specs/Ratification.spec

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6aaa42049a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"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 👍 / 👎.

Comment thread certora/helpers/Utils.sol
Comment on lines 17 to +18
function hashMarket(Market memory market) external pure returns (bytes32) {
return keccak256(abi.encode(market));
return HashLib.hashMarket(market);

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 Keep the CVL market-id summary loop-free

Several existing specs summarize IdLib.toId as Utils.hashMarket(market), and their configs use small loop bounds such as loop_iter: 2; replacing the previous single keccak256(abi.encode(market)) helper with HashLib.hashMarket adds a loop over market.collateralParams, even though markets may contain up to 128 collaterals. For markets with more entries than the spec loop bound, those unrelated accounting/created-market proofs now reason under optimistic loop truncation for their market IDs, which can make them prove properties about the wrong or colliding IDs. Keep this helper loop-free for ID summaries and add a separate EIP-712 helper for offer-tree hashing.

Useful? React with 👍 / 👎.

n.hashNode = id;
}

function newInternalNode(bytes32 id, bytes32 left, bytes32 right) public {

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.

For a leafnode, the id equals the hash (tree[i].hashNode == id). But for internal nodes you allow the id to be different from the hashNode. Is there a reason for it?

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.

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 isWellFormed (the check id == expected). Now you could have a function that would change marketHash and adapt hashNode accordingly (as the hashLeaf of the new params with the changed marketHash). This would not break the invariant isWellFormed, but it would allow to forge another root.

Another more straightforward way to avoid the above scenario, is to check that the tree is immutable.

Comment thread 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

Comment thread certora/README.md

- [`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

Comment thread certora/README.md
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)

Comment on lines +112 to +123
function hashOffer(Offer memory offer) public pure returns (bytes32) {
return HashLib.hashOffer(offer);
}

function hashNode(bytes32 left, bytes32 right) public pure returns (bytes32) {
return HashLib.hashNode(left, right);
}

function _hashLeaf(bytes32 id) public view returns (bytes32) {
return _hashLeaf(tree[id].leaf);
}

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
function hashOffer(Offer memory offer) public pure returns (bytes32) {
return HashLib.hashOffer(offer);
}
function hashNode(bytes32 left, bytes32 right) public pure returns (bytes32) {
return HashLib.hashNode(left, right);
}
function _hashLeaf(bytes32 id) public view returns (bytes32) {
return _hashLeaf(tree[id].leaf);
}

Comment on lines +96 to +102
function _isEmpty(Node storage n) internal view returns (bool) {
return n.left == 0 && n.right == 0 && n.hashNode == 0;
}

function isEmpty(bytes32 id) public view returns (bool) {
return _isEmpty(tree[id]);
}

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
function _isEmpty(Node storage n) internal view returns (bool) {
return n.left == 0 && n.right == 0 && n.hashNode == 0;
}
function isEmpty(bytes32 id) public view returns (bool) {
return _isEmpty(tree[id]);
}
function isEmpty(Node storage n) internal view returns (bool) {
return n.left == 0 && n.right == 0 && n.hashNode == 0;
}

(and change _isEmpty occurrences to isEmpty)

// keccak (no dynamic-array reads, no loops) — the property that lets the wellFormed invariant scale, exactly
// as it does for URD's fixed-size leaves. _hashLeaf mirrors hashOffer's outer keccak field-for-field, so a
// leaf's id is the real hashOffer value; the wellFormed invariant's newLeaf case checks this mirror holds
// (id == hashOffer(offer) and id == _hashLeaf(leaf)), so the correspondence is verified, not assumed.

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.

It really checks: hashNode == _hashLeaf(leaf) and id == _hashLeaf(leaf). But it doesn't verify that hashNode == hashOffer(offer) (which it can't do because it doesn't have the offer), so this part is really assumed

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.

actually it does verify it with the rule hashLeafReproducesHashOffer. Maybe it deserves a longer explanation though

if (n.left != 0 && n.right != 0) {
bytes32 leftHash = tree[n.left].hashNode;
bytes32 rightHash = tree[n.right].hashNode;
return leftHash != 0 && rightHash != 0 && n.hashNode == HashLib.hashNode(leftHash, rightHash);

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.

Here we are verifying that the hashes are not zero, which is not exactly the definition of isEmpty. Should we simplify isEmpty to only hashNode == 0 ?

Comment on lines +151 to +152
// - leaves have the correct identifier and hashing
// - internal nodes have the correct hashing

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 avoid using the word "correct", it hides the meaning

Suggested change
// - leaves have the correct identifier and hashing
// - internal nodes have the correct hashing
// - leaves have the hash of the leaf data as identifier, which is also their stored hash
// - internal nodes have the hash of their children has their stored hash

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.

this is too many changes to Ratification.spec IMO (the new assumptions we make are impacting other rules).
We could either:

  1. try to reduce the number of assumptions (do we need all of them ?)
  2. move it to a separate spec
  3. remove those changes, the added rules are almost trivial, even though it is an important property

I would favor option 3, if we cannot do option 1 and remove a lot of those assumptions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants