Repository navigation
Merkle Proof System
- Introduction
- Project Structure
- Core Components
- Architecture Overview
- Detailed Component Analysis
- Dependency Analysis
- Performance Considerations
- Troubleshooting Guide
- Conclusion
This document explains the Merkle tree proof system used by Sigvara to commit evidence batches to the blockchain and allow consumers to verify individual pieces of evidence without downloading entire batches. The system is split between:
- An off-chain oracle implementation that builds Merkle trees from verified payment events, generates per-leaf proofs, and serves evidence payloads.
- An on-chain reputation contract that stores a Merkle root for each agent and exposes a gas-efficient verification function.
The design ensures that anyone can independently rebuild the committed set from on-chain payment data, confirm that a specific payment was included in the score’s evidence, and detect if an oracle changes the set afterwards. The outcome in each leaf is not on the chain, so it is the oracle's report, and the root commits the oracle to it.
The Merkle proof system touches three main areas:
| Area | Responsibility | Key files |
|---|---|---|
| Off-chain Merkle logic | Leaf encoding, tree construction, sibling-path generation, local verification | oracle/merkle.js |
| Oracle evidence API | Builds JSON evidence bodies with roots, leaves, and proofs; caches results | oracle/index.js |
| On-chain commitment and verification | Stores evidence roots per agent; verifies Merkle proofs against finalized scores | src/SigvaraReputation.sol |
| Cross-implementation fixture guard | Ensures Solidity and JavaScript agree on leaf format, pair ordering, and odd-node handling |
scripts/check-evidence-fixture.mjs, test/SigvaraReputation.t.sol
|
graph TB
A["Oracle process<br/>oracle/index.js"] --> B["Merkle builder<br/>oracle/merkle.js"]
A --> C["HTTP evidence response<br/>JSON body with root, leaves, proofs"]
D["On-chain reputation<br/>src/SigvaraReputation.sol"] --> E["OpenZeppelin MerkleProof.verify"]
F["Tests and fixtures<br/>test/SigvaraReputation.t.sol"] --> G["Fixture generator<br/>scripts/check-evidence-fixture.mjs"]
G --> B
F --> D
Diagram sources
- index.js:1912-1966
- merkle.js
- SigvaraReputation.sol:7
- SigvaraReputation.sol:761-769
- check-evidence-fixture.mjs:1-81
- SigvaraReputation.t.sol:1221-1304
Section sources
The system has four core responsibilities:
- Evidence leaf encoding: Each payment event is encoded into a deterministic 32-byte leaf hash.
- Merkle tree construction: Leaves are combined into a binary tree with sorted-pair hashing and odd-node promotion.
- Proof generation: For each leaf, a sibling path is produced so consumers can recompute the root locally.
- On-chain verification: Consumers call a view function that checks whether a leaf belongs to the committed root.
A leaf represents one verified payment. It commits to:
- Transaction hash
- Payer address
- Amount
- Settlement time in seconds
- Outcome code
The outcome code distinguishes three states:
| State | Meaning | Encoded value |
|---|---|---|
| Failed | Payment recorded but work failed | 0 |
| Succeeded | Payment recorded and work succeeded | 1 |
| Not reported | No outcome on record: the payment was found on chain, or attested with neither an oracle token nor the payer's signature | 2 |
The leaf is double-hashed using keccak256. This prevents an internal node from being mistaken for a leaf and guards against second preimage attacks.
Section sources
The tree is built bottom-up:
- Leaves are hashed payment records.
- Internal nodes are created by hashing pairs.
- Pair order is sorted lexicographically before hashing, matching OpenZeppelin’s Merkle proof expectations.
- If a level has an odd number of nodes, the last node is promoted rather than duplicated.
Promotion is important: duplicating the odd leaf would create a malleability vulnerability where extending the tree could still produce the same root.
Section sources
For a leaf at index i, the proof contains the sibling hashes needed to walk from the leaf up to the root. The sibling list is ordered exactly as expected by MerkleProof.verify:
- Start at the leaf.
- At each level, include the sibling of the current node.
- Move to the parent index.
- Stop when reaching the root.
If there is no sibling because the node was promoted at an odd position, that level contributes no sibling.
Section sources
The reputation contract stores one Merkle root per agent under evidenceRoots. Consumers call verifyEvidence(didHash, leaf, proof) to check whether a leaf belongs to the live evidence set.
Important behavior:
- If no root exists for the agent, verification returns
false. - The proof is scoped to the agent’s
didHash; it cannot be reused across agents. - The root becomes live only after finalization; it is not active while pending.
- The live root is the one from the last finalized proposal, so it covers the payments as they stood then. A payment credited, given an outcome or pruned since changes what
/evidenceserves, not what was committed. - Slashing clears the evidence root along with the score.
Section sources
The end-to-end flow connects off-chain evidence building with on-chain commitment and consumer verification.
sequenceDiagram
participant Oracle as "Oracle"
participant Store as "Payment store"
participant Merkle as "merkle.js"
participant API as "Evidence API"
participant Chain as "SigvaraReputation.sol"
participant Consumer as "Verifier"
Oracle->>Store : Read verified payments
Oracle->>Merkle : buildTree(events)
Merkle-->>Oracle : {root, leaves, layers}
Oracle->>API : Build evidence body
API-->>Consumer : JSON with root, leaves, proofs
Oracle->>Chain : proposeReputation(didHash, score, evidenceRoot)
Chain-->>Chain : Challenge window
Chain-->>Chain : finalizeReputation()
Chain-->>Chain : Store evidenceRoot[didHash]
Consumer->>Chain : verifyEvidence(didHash, leaf, proof)
Chain-->>Consumer : bool
Diagram sources
- index.js:1912-1966
- merkle.js:178-217
- SigvaraReputation.sol:378-448
- SigvaraReputation.sol:454-504
- SigvaraReputation.sol:761-769
The JavaScript module implements the canonical Merkle logic used by the oracle.
| Function | Purpose | Notes |
|---|---|---|
settledSeconds(event) |
Converts millisecond timestamps to block-aligned seconds | Prevents mismatch between off-chain milliseconds and on-chain seconds |
outcomeCode(success) |
Maps boolean or null success to uint8
|
Three-state encoding avoids collapsing “not reported” into “failed” |
leafFor(event) |
Encodes and double-hashes a payment event | Requires a settlement transaction hash. Builds the five 32-byte words by hand and caches the leaf against a frozen event |
leafByAbi(event) |
Produces the same leaf through the ABI coder | The definition of the encoding, and the fallback for anything leafFor does not recognise |
hashPair(a, b) |
Sorted-pair keccak256 hash | Matches OpenZeppelin’s Merkle proof semantics |
buildTree(events) |
Builds layered Merkle tree | Promotes odd nodes instead of duplicating them |
rootFor(events) |
Returns the root of that tree | Cached against a frozen array of frozen events |
proofFor(tree, index) |
Produces sibling path for a leaf | Ordered for MerkleProof.verify
|
verifyProof(leaf, proof, root) |
Local proof check | Used by the oracle to refuse invalid proofs |
- Leaves: One per payment event, derived deterministically from the payment fields on the chain plus the reported outcome.
- Layers: Array of arrays representing tree levels from leaves to root.
-
Tree object: Contains
root,leaves, andlayers.
- Building the tree:
O(E log E)overEevents. - Generating one proof:
O(log E)siblings. - Verifying one proof on-chain:
O(log E)hash operations.
flowchart TD
Start(["Build evidence batch"]) --> Encode["Encode each event into a leaf"]
Encode --> CheckEmpty{"Any events?"}
CheckEmpty --> |No| ZeroRoot["Return zero hash root"]
CheckEmpty --> |Yes| Layers["Create leaf layer"]
Layers --> Loop{"More than one node?"}
Loop --> |Yes| Pair["Sort pair and hash"]
Pair --> NextLayer["Push next layer"]
NextLayer --> Loop
Loop --> |No| Root["Root is top node"]
Root --> Done(["Return tree"])
Diagram sources
Section sources
The oracle builds a JSON evidence payload per agent and payment set. The payload includes:
- Agent identifier
- Committed evidence root
- Event count
- List of committed fields
- Per-event evidence with leaf and proof
The evidence body is cached because it is a pure function of the underlying events. It is recomputed only when payments are credited, given an outcome or pruned. The route needs no token, because a commitment only its maker can open is not a commitment to anyone else, but it is limited to 60 requests per caller per minute, since a cache miss rebuilds the tree. The didHash is read in its one lowercase spelling, so an agent has a single cache entry.
flowchart TD
Input["Agent DID + events"] --> Tree["buildTree(events)"]
Tree --> Root["tree.root"]
Tree --> Leaves["tree.leaves"]
Input --> Proofs["proofFor(tree, i) for each i"]
Root --> Body["Evidence JSON"]
Leaves --> Body
Proofs --> Body
Body --> Cache["Cache per (agent, payment set)"]
Diagram sources
Section sources
The reputation contract integrates Merkle proofs into the scoring lifecycle.
- Oracle proposes a score with an
evidenceRoot. - The root is stored in the pending proposal.
- During the challenge window, the committee may reject the proposal.
- After the window, anyone can finalize. On main, finalization refuses a proposal whose proposer has since lost its authority, and the end of the window is fixed when the score is proposed.
- Finalization writes the root to
evidenceRoots[didHash]. - Slash clears both the score and its evidence root.
Consumers call verifyEvidence(didHash, leaf, proof). Internally, this reads the stored root and delegates to OpenZeppelin’s MerkleProof.verify.
PendingScore also records the proposer and its finalizableAt. The Arc testnet proxy has recorded both since 26 October 2026; a proposal made before then reads zero in both.
classDiagram
class SigvaraReputation {
+bytes32[] evidenceRoots
+proposeReputation(didHash, data, evidenceRoot)
+finalizeReputation(didHash)
+rejectReputation(didHash)
+zeroReputation(didHash)
+verifyEvidence(didHash, leaf, proof) bool
+getTotalScore(didHash) uint8
+getEarnedScore(didHash) uint8
}
class PendingScore {
+data ReputationData
+proposedAt uint256
+exists bool
+evidenceRoot bytes32
+proposer address
+finalizableAt uint256
}
class MerkleProof {
+verify(proof, root, leaf) bool
}
SigvaraReputation --> MerkleProof : "uses"
SigvaraReputation --> PendingScore : "stores"
Diagram sources
- SigvaraReputation.sol:151-168
- SigvaraReputation.sol:378-448
- SigvaraReputation.sol:454-504
- SigvaraReputation.sol:541-557
- SigvaraReputation.sol:761-769
- SigvaraReputation.sol:7
Section sources
- SigvaraReputation.sol:199-220
- SigvaraReputation.sol:378-504
- SigvaraReputation.sol:541-557
- SigvaraReputation.sol:761-769
The repository includes a dedicated script that regenerates test fixtures from the same Merkle implementation used by the oracle. This prevents silent divergence between JavaScript and Solidity.
The fixture covers:
- Root hash
- Individual leaf hashes
- Sibling node hash
- Proof values used in tests
It runs as a gate and fails if the Solidity constants do not match the current Merkle output.
Section sources
The Merkle proof system has clear boundaries and minimal coupling.
graph LR
JS["oracle/merkle.js"] --> API["oracle/index.js"]
API --> CHAIN["src/SigvaraReputation.sol"]
CHAIN --> OZ["@openzeppelin MerkleProof"]
TEST["test/SigvaraReputation.t.sol"] --> CHAIN
FIXTURE["scripts/check-evidence-fixture.mjs"] --> JS
Diagram sources
- merkle.js
- index.js:1912-1966
- SigvaraReputation.sol:7
- SigvaraReputation.t.sol:1221-1304
- check-evidence-fixture.mjs:1-81
-
merkle.jsis self-contained and exports pure functions. -
index.jsdepends onmerkle.jsto build evidence payloads. -
SigvaraReputation.soldepends only on OpenZeppelin’s Merkle verification utility. - Tests and fixtures enforce cross-language consistency.
- Ethereum
keccak256 - ABI encoding for deterministic leaf construction
- OpenZeppelin’s
MerkleProof.verify
Section sources
On-chain verification uses OpenZeppelin’s optimized Merkle proof verifier. The cost is dominated by log E keccak256 operations, where E is the number of evidence leaves.
Recommendations:
- Keep evidence batches reasonably sized to limit per-proof gas costs.
- Avoid unnecessary branching in proof computation.
- Serve complete proofs from the oracle so consumers do not need to reconstruct them.
The code does not impose a hard-coded maximum batch size. Practical limits come from:
- Oracle CPU and memory usage when building trees.
- HTTP payload size for evidence responses.
- On-chain gas cost for verifying longer proofs.
- Storage and caching overhead in the oracle service.
The current implementation sends full sibling paths. Possible compression strategies include:
- Bitmask-based sibling selection to reduce repeated sibling data.
- Index-only proofs where the client reconstructs siblings from a compact representation.
- Batch proofs for multiple leaves sharing common ancestors.
These are optimization opportunities rather than existing features.
- Cache evidence bodies per
(didHash, events)since they are pure functions of inputs. - Rebuild only when the underlying payment set changes.
merkle.jsalso caches each leaf against its frozen event and each root against its frozen event array, so the epoch does not hash an agent's evidence again when none of it changed. Hashing 5,000 events took 1,450 ms before the leaf was built by hand and 480 ms after. - Avoid storing redundant fields already derivable from chain data.
- Use deterministic serialization to keep cache keys stable.
| Symptom | Likely cause | Resolution |
|---|---|---|
verifyEvidence returns false even though the payment exists |
No root stored for the agent | Ensure the score was proposed and finalized |
| Proof works locally but fails on-chain | Mismatch in leaf encoding or pair ordering | Run the fixture generator and compare outputs |
| Different operators publish different roots | Outcome encoding changed, inconsistent timestamp handling, or one operator holds a payment or an outcome the other lacks | Confirm outcomeCode and settledSeconds are consistent, then diff the two /evidence bodies |
The root from /evidence differs from the one on chain |
Payments were credited, given an outcome or pruned after the last finalized proposal | Compare with the root of the proposal that was finalized, not the live set |
| Proof for one agent verifies against another agent’s root | Using wrong didHash
|
Bind proofs to the correct agent identity |
| Old evidence remains usable after slash | Evidence root should have been cleared | Verify zeroReputation clears evidenceRoots
|
- Rebuild the leaf from the payment fields using the documented encoding.
- Recompute the Merkle root from the same event set.
- Generate the sibling path for the target leaf.
- Call
verifyEvidencewith the correctdidHash, leaf, and proof. - If the result differs, run
scripts/check-evidence-fixture.mjsto detect drift.
The site's Agents page (sigvara.xyz/agents) runs steps 1 to 4 in the browser for any agent on Arc testnet: it rebuilds each leaf from the fields the primary's /evidence lists, rebuilds the root, compares it with the roots on chain (the finalized one, and a pending proposal's if there is one), and calls verifyEvidence for every payment the finalized root covers. oracle/explorer-merkle.test.js holds the page's rebuild and oracle/merkle.js to the same leaves, roots and proofs. The SDK's evidence.ts (from 1.0.0-alpha.15) is held to the contract test's fixture, which CI holds to oracle/merkle.js, and sigvara-mcp's sigvara_verify_evidence runs the same root check, confirming up to the last 100 covered payments with verifyEvidence.
Section sources
Sigvara’s Merkle proof system provides auditable evidence commitments for reputation scoring. The oracle builds deterministic leaves from payment data a verifier can read on chain plus the reported outcome, constructs a Merkle tree with safe odd-node handling, and serves per-leaf proofs. The on-chain contract stores a single root per agent and exposes a simple verification interface.
The most important guarantees are:
- Roots commit to a specific set of evidence fields.
- Consumers can prove inclusion of a payment without trusting the oracle’s full dataset.
- Roots become live only after finalization.
- Slashing removes evidence so terminated agents cannot reuse old proofs.
- Cross-implementation fixtures prevent silent divergence between off-chain and on-chain logic.
Future improvements can focus on proof compression, batch verification, and explicit batch-size policies, but the current design already balances correctness, auditability, and gas efficiency.
- Agent Registration and Lifecycle
- Contract Overview and Architecture
- Economic Security Model and Incentives
- Evidence Verification and Merkle Proofs
- Key Management and Rotation
- Maturity Mechanism and Score Release
- Proposal Workflow and Challenge System
- Score Storage and Data Model
- SigvaraIdentity Contract
- SigvaraOracleBond Contract
- SigvaraReputation Contract
- SigvaraStaking Contract
- Slashing Lifecycle and Dispute Resolution
- Stake Management Functions
- Status Management and Control
- Token and Fee Contracts
- Unbonding Period and Withdrawal Security
- Anti-Manipulation and Security Measures
- Community Flag Processing
- Evidence Sources and Verification
- External Trust Integration
- Merkle Proof System
- Oracle Score Computation
- Payment-Backed Attestations
- Scoring Model and Formulas
- Agent Creation and Key Management
- Agent Operations
- Agent Registration Workflow
- Challenge-Response Authentication
- Gate Access Control
- Integration Patterns and Examples
- SDK Overview and Setup
- Verifier Functions