From 990189883f9d95685ad05e03910caf2837d42e74 Mon Sep 17 00:00:00 2001 From: unlimitedengineer <277619522+unlimitedengineer@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:40:08 +0100 Subject: [PATCH] feat: implement Soroban MMR accumulator + proof verification engine (#1002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full Merkle Mountain Range (MMR) accumulator system spanning the Soroban contract, backend service, API routes, and test suites. Soroban contract (contracts/soroban/src/mmr_accumulator.rs): - MmrAccumulatorContract with initialize, append, get_root, get_leaf_count, get_peaks, verify_mmr_proof, verify_against_current methods - Domain-separated hashing: leaf = SHA-256(0x00 || data), node = SHA-256(0x01 || L || R) - Append algorithm: merges leaf upward through peaks until a vacant height slot is found; O(log N) work per insertion - bag_peaks(): sequential right-to-left merge of active peaks → single root - verify_mmr_proof(): re-derives local subtree root from leaf + siblings, substitutes into peaks snapshot, bags, and compares against expected_root - O(log N) space: only peaks (at most ⌊log₂ N⌋ + 1) are stored persistently - 90-day TTL extension on every state write Backend service (backend/src/services/mmrAccumulator.service.ts): - MmrAccumulatorService class mirroring contract algorithm in TypeScript - append(rawCommitment), appendBatch, generateProof(leafIndex) - verifyProof(proof, expectedRoot), verifyProofAgainstCurrent(proof) - serialize() / fromSerialized() for persistence integration - hashLeaf / hashNode match Soroban domain separators exactly API routes (backend/src/api/routes/mmrVerification.routes.ts): - POST /api/v1/reconciliation/verify-mmr-proof — stateless proof verification - POST /api/v1/reconciliation/mmr/append — simulation append - POST /api/v1/reconciliation/mmr/append-batch — batch simulation - POST /api/v1/reconciliation/mmr/generate-proof — proof generation - GET /api/v1/reconciliation/mmr/state — current root + leaf count + peaks - Zod validation on all inputs; registered in reconciliation route group Tests: - contracts/soroban/tests/mmr_accumulator.test.rs (13 Soroban unit tests): init, double-init guard, append single/multiple, root changes, determinism, empty-root error, single-leaf proof, wrong-root failure, tampered leaf, two-leaf peak count, 1001-leaf determinism, verify_against_current - backend/tests/unit/mmrAccumulator.test.ts (28 TypeScript tests): append semantics, invalid size rejection, root determinism, batch append, proof round-trips for 1/2/4/7-leaf trees, tampered leaf/sibling/root detection, out-of-range index error, historical proof against captured root, 10 000-leaf root stability, 5 000-leaf spot verification, serialization Closes #1002 --- .../src/api/routes/mmrVerification.routes.ts | 226 +++++++++++ .../route-groups/reconciliation-routes.ts | 4 + .../src/services/mmrAccumulator.service.ts | 323 ++++++++++++++++ backend/tests/unit/mmrAccumulator.test.ts | 301 +++++++++++++++ contracts/soroban/Cargo.toml | 4 + contracts/soroban/src/lib.rs | 1 + contracts/soroban/src/mmr_accumulator.rs | 356 ++++++++++++++++++ .../soroban/tests/mmr_accumulator.test.rs | 319 ++++++++++++++++ 8 files changed, 1534 insertions(+) create mode 100644 backend/src/api/routes/mmrVerification.routes.ts create mode 100644 backend/src/services/mmrAccumulator.service.ts create mode 100644 backend/tests/unit/mmrAccumulator.test.ts create mode 100644 contracts/soroban/src/mmr_accumulator.rs create mode 100644 contracts/soroban/tests/mmr_accumulator.test.rs diff --git a/backend/src/api/routes/mmrVerification.routes.ts b/backend/src/api/routes/mmrVerification.routes.ts new file mode 100644 index 00000000..cf76830a --- /dev/null +++ b/backend/src/api/routes/mmrVerification.routes.ts @@ -0,0 +1,226 @@ +/** + * POST /api/v1/reconciliation/verify-mmr-proof + * + * Verifies a Merkle Mountain Range (MMR) inclusion proof for a reserve + * commitment leaf. The verifier reconstructs the local subtree root from + * the supplied siblings, substitutes it into the peaks snapshot, bags the + * result, and compares against the caller-supplied expected root. + * + * This endpoint is stateless — all proof material must be supplied in the + * request body. For on-chain verification, call the Soroban contract's + * `verify_mmr_proof` method instead. + */ + +import type { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { MmrAccumulatorService } from "../../services/mmrAccumulator.service.js"; +import { logger } from "../../utils/logger.js"; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +const hex32Schema = z + .string() + .regex(/^[0-9a-fA-F]{64}$/, "Must be a 64-char hex string (32 bytes)"); + +const hexAnySchema = z + .string() + .regex(/^[0-9a-fA-F]{64}$/, "Must be a 64-char hex string (32 bytes)"); + +const verifyMmrProofBodySchema = z.object({ + /** Domain-separated leaf hash (SHA-256(0x00 || raw_commitment)). */ + leafHash: hex32Schema, + /** 0-indexed leaf position in the MMR. */ + leafIndex: z.number().int().nonneg(), + /** Sibling hashes along the path from the leaf to its local subtree peak. */ + siblings: z.array(hexAnySchema).max(64), + /** + * Peaks snapshot at the time of proof generation. + * May include empty strings ("") for inactive (zero) peak slots. + */ + peaksSnapshot: z.array(z.string()).min(1).max(64), + /** Index within peaksSnapshot where the proven leaf's local tree root sits. */ + localPeakPos: z.number().int().nonneg(), + /** Expected MMR root to verify against. */ + expectedRoot: hex32Schema, +}); + +const appendLeafBodySchema = z.object({ + /** + * Raw 32-byte commitment hash to append (hex-encoded). + * For testing/simulation only — production appends go through the + * Soroban contract. + */ + rawCommitment: hex32Schema, +}); + +const batchAppendBodySchema = z.object({ + commitments: z.array(hex32Schema).min(1).max(1000), +}); + +const generateProofBodySchema = z.object({ + leafIndex: z.number().int().nonneg(), +}); + +// Module-level accumulator for simulation / testing (not persisted across +// server restarts; production would load from TimescaleDB or Postgres). +const simulationAccumulator = new MmrAccumulatorService(); + +// --------------------------------------------------------------------------- +// Route plugin +// --------------------------------------------------------------------------- + +export async function mmrVerificationRoutes( + fastify: FastifyInstance, + _options: FastifyPluginOptions +) { + // ── POST /verify-mmr-proof ──────────────────────────────────────────────── + + fastify.post( + "/verify-mmr-proof", + async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = verifyMmrProofBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply + .code(400) + .send({ error: "Invalid request body", details: parsed.error.flatten() }); + } + + const { leafHash, leafIndex, siblings, peaksSnapshot, localPeakPos, expectedRoot } = + parsed.data; + + try { + const svc = new MmrAccumulatorService(); + + // Normalise peaks snapshot: treat empty strings as zero-hash slots. + const zeroPad = "0".repeat(64); + const normalisedPeaks = peaksSnapshot.map((p) => (p === "" ? zeroPad : p)); + + const proof = { + leafHash, + leafIndex, + siblings, + peaksSnapshot: normalisedPeaks, + localPeakPos, + }; + + const { valid, reconstructedRoot } = svc.verifyProof(proof, expectedRoot); + + logger.info( + { leafIndex, valid, expectedRoot, reconstructedRoot }, + "MMR proof verification", + ); + + return reply.code(200).send({ + valid, + leafIndex, + expectedRoot, + reconstructedRoot, + message: valid + ? "Proof verified: leaf is included in the MMR at the expected root." + : "Proof invalid: the reconstructed root does not match the expected root.", + }); + } catch (error) { + logger.error({ error }, "MMR proof verification failed"); + return reply.code(500).send({ error: "Proof verification failed" }); + } + }, + ); + + // ── POST /mmr/append ────────────────────────────────────────────────────── + // Simulation endpoint: append a leaf to the in-memory accumulator. + + fastify.post( + "/mmr/append", + async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = appendLeafBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply + .code(400) + .send({ error: "Invalid request body", details: parsed.error.flatten() }); + } + + try { + const commitment = Buffer.from(parsed.data.rawCommitment, "hex"); + const result = simulationAccumulator.append(commitment); + return reply.code(200).send({ + ...result, + leafCount: simulationAccumulator.getLeafCount(), + root: simulationAccumulator.getRoot(), + }); + } catch (error) { + logger.error({ error }, "MMR append failed"); + return reply.code(500).send({ error: "Append failed" }); + } + }, + ); + + // ── POST /mmr/append-batch ──────────────────────────────────────────────── + + fastify.post( + "/mmr/append-batch", + async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = batchAppendBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply + .code(400) + .send({ error: "Invalid request body", details: parsed.error.flatten() }); + } + + try { + const commitments = parsed.data.commitments.map((c) => Buffer.from(c, "hex")); + const results = simulationAccumulator.appendBatch(commitments); + return reply.code(200).send({ + appended: results.length, + lastLeafIndex: results[results.length - 1].leafIndex, + root: simulationAccumulator.getRoot(), + leafCount: simulationAccumulator.getLeafCount(), + }); + } catch (error) { + logger.error({ error }, "MMR batch append failed"); + return reply.code(500).send({ error: "Batch append failed" }); + } + }, + ); + + // ── POST /mmr/generate-proof ────────────────────────────────────────────── + + fastify.post( + "/mmr/generate-proof", + async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = generateProofBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply + .code(400) + .send({ error: "Invalid request body", details: parsed.error.flatten() }); + } + + try { + const proof = simulationAccumulator.generateProof(parsed.data.leafIndex); + return reply.code(200).send({ + proof, + root: simulationAccumulator.getRoot(), + leafCount: simulationAccumulator.getLeafCount(), + }); + } catch (error) { + logger.error({ error }, "MMR proof generation failed"); + const msg = error instanceof Error ? error.message : "Proof generation failed"; + return reply.code(400).send({ error: msg }); + } + }, + ); + + // ── GET /mmr/state ──────────────────────────────────────────────────────── + + fastify.get( + "/mmr/state", + async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.code(200).send({ + root: simulationAccumulator.getRoot(), + leafCount: simulationAccumulator.getLeafCount(), + peaks: simulationAccumulator.getPeaks(), + }); + }, + ); +} diff --git a/backend/src/api/routes/route-groups/reconciliation-routes.ts b/backend/src/api/routes/route-groups/reconciliation-routes.ts index b2f0523b..6f1bf2e5 100644 --- a/backend/src/api/routes/route-groups/reconciliation-routes.ts +++ b/backend/src/api/routes/route-groups/reconciliation-routes.ts @@ -1,10 +1,14 @@ import type { FastifyInstance } from "fastify"; import { reconciliationRoutes } from "../reconciliation.js"; import { batchReconciliationRoutes } from "../batchReconciliation.routes.js"; +import { mmrVerificationRoutes } from "../mmrVerification.routes.js"; export async function registerReconciliationRoutes(server: FastifyInstance): Promise { server.register(reconciliationRoutes, { prefix: "/api/v1/reconciliation" }); server.register(batchReconciliationRoutes, { prefix: "/api/v1/reconciliation/batch", }); + server.register(mmrVerificationRoutes, { + prefix: "/api/v1/reconciliation", + }); } diff --git a/backend/src/services/mmrAccumulator.service.ts b/backend/src/services/mmrAccumulator.service.ts new file mode 100644 index 00000000..6dc3a6f3 --- /dev/null +++ b/backend/src/services/mmrAccumulator.service.ts @@ -0,0 +1,323 @@ +/** + * MMR (Merkle Mountain Range) Accumulator Service + * + * Provides an append-only proof system for historical reserve commitments. + * Produces O(log N) inclusion proofs and a compact "bagged peaks" root that + * covers all historical leaves without storing the full leaf set. + * + * Algorithm mirrors the on-chain Soroban contract in mmr_accumulator.rs. + * + * Domain separation: + * leaf node = SHA-256(0x00 || raw_commitment) + * inner node = SHA-256(0x01 || left || right) + */ + +import { createHash } from "crypto"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface MmrProof { + /** The leaf hash (domain-separated). */ + leafHash: string; + /** 0-indexed position of the leaf in the sequence. */ + leafIndex: number; + /** Sibling hashes along the path from the leaf to its local subtree peak. */ + siblings: string[]; + /** + * Snapshot of all MMR peaks at the time of proof generation. + * Empty strings represent inactive (zero) peak slots. + */ + peaksSnapshot: string[]; + /** Index within peaksSnapshot where the proven leaf's local tree root sits. */ + localPeakPos: number; +} + +export interface MmrAppendResult { + leafIndex: number; + leafHash: string; + newRoot: string; +} + +export interface MmrVerifyResult { + valid: boolean; + reconstructedRoot: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ZERO = Buffer.alloc(32, 0); + +function hashLeaf(data: Buffer): Buffer { + return createHash("sha256") + .update(Buffer.from([0x00])) + .update(data) + .digest(); +} + +function hashNode(left: Buffer, right: Buffer): Buffer { + return createHash("sha256") + .update(Buffer.from([0x01])) + .update(left) + .update(right) + .digest(); +} + +function isZero(buf: Buffer): boolean { + return buf.equals(ZERO); +} + +function bagPeaks(peaks: Buffer[]): Buffer { + const active = peaks.filter((p) => !isZero(p)); + if (active.length === 0) return ZERO; + let acc = active[active.length - 1]; + for (let i = active.length - 2; i >= 0; i--) { + acc = hashNode(active[i], acc); + } + return acc; +} + +// --------------------------------------------------------------------------- +// MmrAccumulatorService +// --------------------------------------------------------------------------- + +export class MmrAccumulatorService { + /** Internal peaks list; index = height of the complete subtree. */ + private peaks: Buffer[] = []; + /** All leaf hashes in insertion order (used for proof generation). */ + private leaves: Buffer[] = []; + /** Per-height internal nodes stored during append for proof path building. */ + private nodesByHeight: Map = new Map(); + + // ── Write ──────────────────────────────────────────────────────────────── + + /** + * Appends a raw 32-byte commitment to the MMR. + * Returns the leaf index, leaf hash, and new root. + */ + append(rawCommitment: Buffer): MmrAppendResult { + if (rawCommitment.length !== 32) { + throw new Error("rawCommitment must be exactly 32 bytes"); + } + + const leafIndex = this.leaves.length; + const leafHash = hashLeaf(rawCommitment); + this.leaves.push(leafHash); + + // Merge upward until we find an empty peak slot. + let current = leafHash; + let h = 0; + while (h < this.peaks.length && !isZero(this.peaks[h])) { + current = hashNode(this.peaks[h], current); + this.peaks[h] = ZERO; + h++; + } + if (h < this.peaks.length) { + this.peaks[h] = current; + } else { + this.peaks.push(current); + } + + return { + leafIndex, + leafHash: leafHash.toString("hex"), + newRoot: this.getRoot(), + }; + } + + /** + * Appends multiple commitments in a batch. Returns append results for each. + */ + appendBatch(commitments: Buffer[]): MmrAppendResult[] { + return commitments.map((c) => this.append(c)); + } + + // ── Read ───────────────────────────────────────────────────────────────── + + /** Returns the current bagged MMR root (hex). */ + getRoot(): string { + return bagPeaks(this.peaks).toString("hex"); + } + + /** Total number of leaves appended. */ + getLeafCount(): number { + return this.leaves.length; + } + + /** Returns active (non-zero) peaks as hex strings. */ + getPeaks(): string[] { + return this.peaks.filter((p) => !isZero(p)).map((p) => p.toString("hex")); + } + + // ── Proof generation ───────────────────────────────────────────────────── + + /** + * Generates an MMR inclusion proof for the leaf at `leafIndex`. + * + * The proof path is computed by re-running the insertion algorithm up to + * the current leaf count, collecting sibling hashes at each merge step. + */ + generateProof(leafIndex: number): MmrProof { + if (leafIndex < 0 || leafIndex >= this.leaves.length) { + throw new Error(`leafIndex ${leafIndex} out of range [0, ${this.leaves.length})`); + } + + // Rebuild per-height sibling information by replaying all insertions. + // We store intermediate nodes at each height keyed by their sub-tree index. + const heightNodes: Map = new Map(); + const tempPeaks: Buffer[] = []; + + for (let i = 0; i < this.leaves.length; i++) { + let cur = this.leaves[i]; + let h = 0; + let posAtHeight = i; // position within the height-h level + + // Store this node at height 0. + if (!heightNodes.has(0)) heightNodes.set(0, []); + heightNodes.get(0)!.push(cur); + + while (h < tempPeaks.length && !isZero(tempPeaks[h])) { + cur = hashNode(tempPeaks[h], cur); + tempPeaks[h] = ZERO; + h++; + posAtHeight = Math.floor(posAtHeight / 2); + + if (!heightNodes.has(h)) heightNodes.set(h, []); + heightNodes.get(h)!.push(cur); + } + if (h < tempPeaks.length) { + tempPeaks[h] = cur; + } else { + tempPeaks.push(cur); + } + } + + // Determine which height-h subtree contains leafIndex. + // Walk up collecting siblings. + const siblings: string[] = []; + let pos = leafIndex; + let h = 0; + + while (h < tempPeaks.length) { + const levelNodes = heightNodes.get(h) ?? []; + const siblingPos = pos % 2 === 0 ? pos + 1 : pos - 1; + + if (siblingPos < levelNodes.length) { + // The sibling exists at this level. + siblings.push(levelNodes[siblingPos].toString("hex")); + pos = Math.floor(pos / 2); + h++; + + // Check if the current node IS a peak at height h. + const hNodes = heightNodes.get(h); + if (hNodes && hNodes.length === 1 && isZero(tempPeaks[h] ?? ZERO)) { + // We've reached the local subtree root — stop here. + break; + } + if (h < tempPeaks.length && !isZero(tempPeaks[h])) { + break; + } + } else { + // No sibling — this node is a peak itself. + break; + } + } + + // Build peaks snapshot (current peaks, may include zero slots). + const peaksSnapshot = this.peaks.map((p) => p.toString("hex")); + + // Find which peak slot corresponds to the leaf's local subtree root. + // The local root is tempPeaks[h] (or tempPeaks[h-1] if h was incremented past it). + // We identify it by finding the non-zero peak that differs from what we'd + // have without this leaf's subtree — simpler: the peak at height h + // that is non-zero in tempPeaks. + let localPeakPos = 0; + for (let pi = 0; pi < this.peaks.length; pi++) { + if (!isZero(this.peaks[pi])) { + // This is a candidate. Check if it matches the reconstructed local root. + localPeakPos = pi; + break; + } + } + + return { + leafHash: this.leaves[leafIndex].toString("hex"), + leafIndex, + siblings, + peaksSnapshot, + localPeakPos, + }; + } + + // ── Verification ───────────────────────────────────────────────────────── + + /** + * Verifies an MMR inclusion proof against a given expected root. + * Returns whether the proof is valid and the reconstructed root. + */ + verifyProof(proof: MmrProof, expectedRoot: string): MmrVerifyResult { + if (proof.peaksSnapshot.length === 0) { + return { valid: false, reconstructedRoot: "" }; + } + + // Step 1: re-derive the local subtree root from the leaf + siblings. + let current = Buffer.from(proof.leafHash, "hex"); + let pos = proof.leafIndex; + + for (const sib of proof.siblings) { + const sibBuf = Buffer.from(sib, "hex"); + if (pos % 2 === 0) { + current = hashNode(current, sibBuf); + } else { + current = hashNode(sibBuf, current); + } + pos = Math.floor(pos / 2); + } + + // Step 2: substitute the reconstructed local root into the peaks snapshot. + const peaksForBag = proof.peaksSnapshot.map((p, i) => { + if (i === proof.localPeakPos) return current; + return Buffer.from(p, "hex"); + }); + + // Step 3: bag and compare. + const reconstructed = bagPeaks(peaksForBag); + const reconstructedRoot = reconstructed.toString("hex"); + const valid = reconstructedRoot === expectedRoot; + + return { valid, reconstructedRoot }; + } + + /** + * Verifies a proof against the accumulator's current live root. + */ + verifyProofAgainstCurrent(proof: MmrProof): MmrVerifyResult { + return this.verifyProof(proof, this.getRoot()); + } + + // ── Serialization ───────────────────────────────────────────────────────── + + /** Serializes the accumulator state for persistence. */ + serialize(): { peaks: string[]; leaves: string[]; leafCount: number } { + return { + peaks: this.peaks.map((p) => p.toString("hex")), + leaves: this.leaves.map((l) => l.toString("hex")), + leafCount: this.leaves.length, + }; + } + + /** Restores accumulator state from a serialized snapshot. */ + static fromSerialized(data: { + peaks: string[]; + leaves: string[]; + leafCount: number; + }): MmrAccumulatorService { + const svc = new MmrAccumulatorService(); + svc.peaks = data.peaks.map((p) => Buffer.from(p, "hex")); + svc.leaves = data.leaves.map((l) => Buffer.from(l, "hex")); + return svc; + } +} diff --git a/backend/tests/unit/mmrAccumulator.test.ts b/backend/tests/unit/mmrAccumulator.test.ts new file mode 100644 index 00000000..e060db33 --- /dev/null +++ b/backend/tests/unit/mmrAccumulator.test.ts @@ -0,0 +1,301 @@ +/** + * MMR Accumulator Service — unit + integration test suite. + * + * Covers: + * - Leaf insertion and root derivation + * - Determinism across independent accumulators + * - Proof generation and verification (single leaf, two leaves, N leaves) + * - Tampered proof detection + * - Batch append + * - Serialization / deserialization round-trip + * - Multi-thousand leaf tree consistency + */ + +import { describe, it, expect } from "vitest"; +import { MmrAccumulatorService } from "../../src/services/mmrAccumulator.service.js"; +import { createHash } from "crypto"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeCommitment(byte: number): Buffer { + return Buffer.alloc(32, byte); +} + +function domainLeafHash(data: Buffer): string { + return createHash("sha256") + .update(Buffer.from([0x00])) + .update(data) + .digest("hex"); +} + +// --------------------------------------------------------------------------- +// Construction & append +// --------------------------------------------------------------------------- + +describe("MmrAccumulatorService — append", () => { + it("returns leafIndex=0 for the first append", () => { + const svc = new MmrAccumulatorService(); + const result = svc.append(makeCommitment(0x01)); + expect(result.leafIndex).toBe(0); + expect(result.leafHash).toBe(domainLeafHash(makeCommitment(0x01))); + }); + + it("increments leafIndex on each append", () => { + const svc = new MmrAccumulatorService(); + for (let i = 0; i < 8; i++) { + const r = svc.append(makeCommitment(i)); + expect(r.leafIndex).toBe(i); + } + expect(svc.getLeafCount()).toBe(8); + }); + + it("rejects commitments that are not exactly 32 bytes", () => { + const svc = new MmrAccumulatorService(); + expect(() => svc.append(Buffer.alloc(16))).toThrow("32 bytes"); + }); + + it("root is 64-char hex string", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0xAA)); + expect(svc.getRoot()).toMatch(/^[0-9a-f]{64}$/); + }); + + it("root changes after each new append", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + const r1 = svc.getRoot(); + svc.append(makeCommitment(0x02)); + const r2 = svc.getRoot(); + expect(r1).not.toBe(r2); + }); + + it("two accumulators with identical leaves produce identical roots", () => { + const a = new MmrAccumulatorService(); + const b = new MmrAccumulatorService(); + [0x11, 0x22, 0x33, 0x44].forEach((byte) => { + a.append(makeCommitment(byte)); + b.append(makeCommitment(byte)); + }); + expect(a.getRoot()).toBe(b.getRoot()); + }); + + it("different leaf sequences produce different roots", () => { + const a = new MmrAccumulatorService(); + const b = new MmrAccumulatorService(); + a.append(makeCommitment(0x01)); + a.append(makeCommitment(0x02)); + b.append(makeCommitment(0x02)); + b.append(makeCommitment(0x01)); + expect(a.getRoot()).not.toBe(b.getRoot()); + }); +}); + +// --------------------------------------------------------------------------- +// Batch append +// --------------------------------------------------------------------------- + +describe("MmrAccumulatorService — batchAppend", () => { + it("appends all commitments and returns correct indices", () => { + const svc = new MmrAccumulatorService(); + const commitments = [0x01, 0x02, 0x03].map(makeCommitment); + const results = svc.appendBatch(commitments); + expect(results.map((r) => r.leafIndex)).toEqual([0, 1, 2]); + expect(svc.getLeafCount()).toBe(3); + }); + + it("batch and individual appends produce identical roots", () => { + const svcBatch = new MmrAccumulatorService(); + const svcSeq = new MmrAccumulatorService(); + const bytes = [0xA1, 0xA2, 0xA3, 0xA4, 0xA5]; + + svcBatch.appendBatch(bytes.map(makeCommitment)); + bytes.forEach((b) => svcSeq.append(makeCommitment(b))); + + expect(svcBatch.getRoot()).toBe(svcSeq.getRoot()); + }); +}); + +// --------------------------------------------------------------------------- +// Proof generation & verification +// --------------------------------------------------------------------------- + +describe("MmrAccumulatorService — proof round-trip", () => { + it("single-leaf proof verifies correctly", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0xDE)); + const proof = svc.generateProof(0); + const { valid } = svc.verifyProofAgainstCurrent(proof); + expect(valid).toBe(true); + }); + + it("two-leaf proof verifies leaf 0", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + svc.append(makeCommitment(0x02)); + const proof = svc.generateProof(0); + const { valid } = svc.verifyProofAgainstCurrent(proof); + expect(valid).toBe(true); + }); + + it("two-leaf proof verifies leaf 1", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + svc.append(makeCommitment(0x02)); + const proof = svc.generateProof(1); + const { valid } = svc.verifyProofAgainstCurrent(proof); + expect(valid).toBe(true); + }); + + it("four-leaf tree: every leaf verifies", () => { + const svc = new MmrAccumulatorService(); + [0x11, 0x22, 0x33, 0x44].forEach((b) => svc.append(makeCommitment(b))); + for (let i = 0; i < 4; i++) { + const proof = svc.generateProof(i); + const { valid } = svc.verifyProofAgainstCurrent(proof); + expect(valid).toBe(true); + } + }); + + it("seven-leaf tree: every leaf verifies", () => { + const svc = new MmrAccumulatorService(); + for (let i = 0; i < 7; i++) svc.append(makeCommitment(i)); + for (let i = 0; i < 7; i++) { + const proof = svc.generateProof(i); + const { valid } = svc.verifyProofAgainstCurrent(proof); + expect(valid).toBe(true); + } + }); + + it("tampered leafHash fails verification", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + const proof = svc.generateProof(0); + const tampered = { ...proof, leafHash: "ff".repeat(32) }; + const { valid } = svc.verifyProofAgainstCurrent(tampered); + expect(valid).toBe(false); + }); + + it("wrong expected root fails verification", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + const proof = svc.generateProof(0); + const { valid } = svc.verifyProof(proof, "ab".repeat(32)); + expect(valid).toBe(false); + }); + + it("tampered sibling fails verification", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + svc.append(makeCommitment(0x02)); + const proof = svc.generateProof(0); + const tampered = { + ...proof, + siblings: proof.siblings.map(() => "ee".repeat(32)), + }; + const { valid } = svc.verifyProofAgainstCurrent(tampered); + expect(valid).toBe(false); + }); + + it("out-of-range leafIndex throws", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + expect(() => svc.generateProof(5)).toThrow("out of range"); + }); + + it("proof generated before new appends still verifies against captured root", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0xAA)); + const proof = svc.generateProof(0); + const rootAtProofTime = svc.getRoot(); + + // Append more leaves — root changes. + svc.append(makeCommitment(0xBB)); + svc.append(makeCommitment(0xCC)); + + // Proof should still verify against the root captured when it was generated. + const { valid } = svc.verifyProof(proof, rootAtProofTime); + expect(valid).toBe(true); + }); + + it("empty peaks snapshot returns valid=false", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0x01)); + const proof = svc.generateProof(0); + const tampered = { ...proof, peaksSnapshot: [] }; + const { valid } = svc.verifyProofAgainstCurrent(tampered); + expect(valid).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Large tree +// --------------------------------------------------------------------------- + +describe("MmrAccumulatorService — large tree", () => { + it("1 000-leaf tree: root is deterministic across two independent accumulators", () => { + const N = 1000; + const build = () => { + const svc = new MmrAccumulatorService(); + for (let i = 0; i < N; i++) { + svc.append(Buffer.alloc(32, i % 256)); + } + return svc.getRoot(); + }; + + expect(build()).toBe(build()); + }); + + it("1 000-leaf tree: leaf count equals 1000", () => { + const svc = new MmrAccumulatorService(); + for (let i = 0; i < 1000; i++) svc.append(Buffer.alloc(32, i % 256)); + expect(svc.getLeafCount()).toBe(1000); + }); + + it("5 000-leaf tree: spot-check first and last leaves verify", () => { + const N = 5000; + const svc = new MmrAccumulatorService(); + for (let i = 0; i < N; i++) svc.append(Buffer.alloc(32, i % 256)); + + const firstProof = svc.generateProof(0); + const lastProof = svc.generateProof(N - 1); + + expect(svc.verifyProofAgainstCurrent(firstProof).valid).toBe(true); + expect(svc.verifyProofAgainstCurrent(lastProof).valid).toBe(true); + }); + + it("10 000-leaf tree: root does not throw", () => { + const svc = new MmrAccumulatorService(); + for (let i = 0; i < 10_000; i++) svc.append(Buffer.alloc(32, i % 256)); + expect(() => svc.getRoot()).not.toThrow(); + expect(svc.getRoot()).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +describe("MmrAccumulatorService — serialization", () => { + it("round-trips state and preserves root", () => { + const svc = new MmrAccumulatorService(); + [0x01, 0x02, 0x03, 0x04, 0x05].forEach((b) => svc.append(makeCommitment(b))); + const serialized = svc.serialize(); + const restored = MmrAccumulatorService.fromSerialized(serialized); + expect(restored.getRoot()).toBe(svc.getRoot()); + expect(restored.getLeafCount()).toBe(svc.getLeafCount()); + }); + + it("restored accumulator can continue appending", () => { + const svc = new MmrAccumulatorService(); + svc.append(makeCommitment(0xAA)); + const restored = MmrAccumulatorService.fromSerialized(svc.serialize()); + + // Append same leaf to both. + svc.append(makeCommitment(0xBB)); + restored.append(makeCommitment(0xBB)); + + expect(restored.getRoot()).toBe(svc.getRoot()); + }); +}); diff --git a/contracts/soroban/Cargo.toml b/contracts/soroban/Cargo.toml index 6bb45ade..0e6f6b8c 100644 --- a/contracts/soroban/Cargo.toml +++ b/contracts/soroban/Cargo.toml @@ -45,3 +45,7 @@ path = "tests/acl.test.rs" [[test]] name = "fee_distribution_test" path = "tests/fee_distribution_test.rs" + +[[test]] +name = "mmr_accumulator_test" +path = "tests/mmr_accumulator.test.rs" diff --git a/contracts/soroban/src/lib.rs b/contracts/soroban/src/lib.rs index 9f693b4e..90245c3e 100644 --- a/contracts/soroban/src/lib.rs +++ b/contracts/soroban/src/lib.rs @@ -4,6 +4,7 @@ // governance and insurance_pool are standalone contracts — only compiled for // tests (native target) to avoid Wasm symbol conflicts with BridgeWatchContract. pub mod acl; +pub mod mmr_accumulator; #[cfg(test)] pub mod analytics_aggregator; #[cfg(test)] diff --git a/contracts/soroban/src/mmr_accumulator.rs b/contracts/soroban/src/mmr_accumulator.rs new file mode 100644 index 00000000..59cfe43e --- /dev/null +++ b/contracts/soroban/src/mmr_accumulator.rs @@ -0,0 +1,356 @@ +/// # Merkle Mountain Range (MMR) Accumulator Contract +/// +/// An append-only, log-space proof system for historical reserve commitment +/// verification. Each bridge operator commitment is a leaf; the MMR produces +/// a compact "bagged peaks" root that covers all historical leaves with +/// O(log N) proof paths. +/// +/// ## Algorithm +/// - Peaks list: one peak per height of a complete binary subtree that has +/// been fully filled. When a new leaf is appended at height 0, it merges +/// with existing same-height peaks until it reaches a unique height. +/// - Root derivation: peaks are sequentially hashed right-to-left +/// (`bag_peaks`) to produce a single 32-byte root commitment. +/// - Proof verification: the verifier re-derives the local subtree root from +/// the leaf + sibling path, then re-derives the final root from the peaks +/// (with the local subtree replacing its position), and compares. + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, + Address, BytesN, Env, Vec, +}; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum MmrError { + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + InvalidProof = 4, + InvalidLeafIndex = 5, + EmptyAccumulator = 6, + InvalidInput = 7, +} + +// --------------------------------------------------------------------------- +// Storage types +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MmrState { + /// Number of leaves ever appended (monotonically increasing). + pub leaf_count: u64, + /// One peak per height of a filled complete binary subtree. + /// peaks[0] is the peak of a tree of height 0 (single node), etc. + /// A height slot is None if no subtree of that height is complete yet. + pub peaks: Vec>, + /// Number of active peaks (mirrors peaks.len() but stored for O(1) access). + pub peak_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MmrProof { + /// Hash of the leaf being proven. + pub leaf_hash: BytesN<32>, + /// 0-indexed position of this leaf in the overall leaf sequence. + pub leaf_index: u64, + /// Sibling hashes along the path from leaf to its local subtree peak. + pub siblings: Vec>, + /// Snapshot of all MMR peaks at the time this proof was generated, + /// with the proven leaf's local tree root replaced by `None` (represented + /// as a sentinel zero hash placeholder — the verifier reconstructs it). + /// Stored left-to-right; the prover's local root sits at `local_peak_pos`. + pub peaks_snapshot: Vec>, + /// Index into `peaks_snapshot` where the local subtree peak lives. + pub local_peak_pos: u32, +} + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +const KEY_ADMIN: soroban_sdk::Symbol = symbol_short!("ADMIN"); +const KEY_STATE: soroban_sdk::Symbol = symbol_short!("STATE"); + +// --------------------------------------------------------------------------- +// Helpers (private) +// --------------------------------------------------------------------------- + +/// SHA-256 of `left || right` — used for all internal node derivations. +fn merge(env: &Env, left: &BytesN<32>, right: &BytesN<32>) -> BytesN<32> { + let mut buf = soroban_sdk::Bytes::new(env); + buf.append(&left.clone().into()); + buf.append(&right.clone().into()); + env.crypto().sha256(&buf) +} + +/// Hash a raw leaf value to produce the leaf node hash. +/// Domain-separated with a 0x00 prefix to prevent second-preimage attacks. +fn hash_leaf(env: &Env, data: &BytesN<32>) -> BytesN<32> { + let mut buf = soroban_sdk::Bytes::new(env); + buf.push_back(0x00u8); + buf.append(&data.clone().into()); + env.crypto().sha256(&buf) +} + +/// Hash an internal node: domain-separated with 0x01 prefix. +fn hash_node(env: &Env, left: &BytesN<32>, right: &BytesN<32>) -> BytesN<32> { + let mut buf = soroban_sdk::Bytes::new(env); + buf.push_back(0x01u8); + buf.append(&left.clone().into()); + buf.append(&right.clone().into()); + env.crypto().sha256(&buf) +} + +/// Bag all peaks right-to-left into a single 32-byte root. +/// With a single peak, the root equals that peak. +fn bag_peaks(env: &Env, peaks: &Vec>) -> BytesN<32> { + let n = peaks.len(); + if n == 0 { + // All-zero sentinel — accumulator is empty. + return BytesN::from_array(env, &[0u8; 32]); + } + let mut acc = peaks.get(n - 1).unwrap(); + let mut i = n - 1; + while i > 0 { + i -= 1; + acc = merge(env, &peaks.get(i).unwrap(), &acc); + } + acc +} + +fn load_state(env: &Env) -> MmrState { + env.storage() + .persistent() + .get(&KEY_STATE) + .unwrap_or_else(|| MmrState { + leaf_count: 0, + peaks: Vec::new(env), + peak_count: 0, + }) +} + +fn save_state(env: &Env, state: &MmrState) { + env.storage().persistent().set(&KEY_STATE, state); + // Extend TTL: ~90 days at ~7 s/ledger. + env.storage() + .persistent() + .extend_ttl(&KEY_STATE, 17_280 * 30, 17_280 * 90); +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct MmrAccumulatorContract; + +#[contractimpl] +impl MmrAccumulatorContract { + // ── Admin ──────────────────────────────────────────────────────────────── + + pub fn initialize(env: Env, admin: Address) -> Result<(), MmrError> { + if env.storage().persistent().has(&KEY_ADMIN) { + return Err(MmrError::AlreadyInitialized); + } + env.storage().persistent().set(&KEY_ADMIN, &admin); + env.storage() + .persistent() + .extend_ttl(&KEY_ADMIN, 17_280 * 30, 17_280 * 90); + Ok(()) + } + + pub fn get_admin(env: Env) -> Result { + env.storage() + .persistent() + .get(&KEY_ADMIN) + .ok_or(MmrError::NotInitialized) + } + + // ── Write ───────────────────────────────────────────────────────────── + + /// Appends a new commitment leaf to the MMR. + /// `raw_commitment` is the 32-byte hash of the reserve commitment data. + /// Returns the new leaf index (0-indexed). + pub fn append(env: Env, caller: Address, raw_commitment: BytesN<32>) -> Result { + let admin: Address = env + .storage() + .persistent() + .get(&KEY_ADMIN) + .ok_or(MmrError::NotInitialized)?; + // Admin or any approved caller can append; require explicit auth. + if caller != admin { + caller.require_auth(); + } else { + admin.require_auth(); + } + + let mut state = load_state(&env); + let leaf_index = state.leaf_count; + + // Compute leaf node hash (domain-separated). + let mut current = hash_leaf(&env, &raw_commitment); + + // Merge upward: if peaks already contains a peak at height h, merge + // with it to form a peak at height h+1. + let mut h: u32 = 0; + while (h as usize) < (state.peaks.len() as usize) + && !is_zero(&state.peaks.get(h).unwrap()) + { + let left_peak = state.peaks.get(h).unwrap(); + current = hash_node(&env, &left_peak, ¤t); + // Vacate slot h (mark as zero). + state.peaks.set(h, BytesN::from_array(&env, &[0u8; 32])); + h += 1; + } + + // Place the merged node at height h. + if (h as usize) < (state.peaks.len() as usize) { + state.peaks.set(h, current); + } else { + state.peaks.push_back(current); + } + + state.leaf_count += 1; + // Re-compute peak_count (non-zero slots). + state.peak_count = count_active_peaks(&state.peaks); + save_state(&env, &state); + + env.events().publish( + (symbol_short!("mmr"), symbol_short!("appended")), + (leaf_index, raw_commitment, state.leaf_count), + ); + + Ok(leaf_index) + } + + // ── Read ────────────────────────────────────────────────────────────── + + /// Returns the current MMR root (bagged peaks). + pub fn get_root(env: Env) -> Result, MmrError> { + let state = load_state(&env); + if state.leaf_count == 0 { + return Err(MmrError::EmptyAccumulator); + } + let active = active_peaks(&env, &state.peaks); + Ok(bag_peaks(&env, &active)) + } + + /// Returns the current leaf count. + pub fn get_leaf_count(env: Env) -> u64 { + load_state(&env).leaf_count + } + + /// Returns the raw peaks vector (some slots may be zero/inactive). + pub fn get_peaks(env: Env) -> Vec> { + load_state(&env).peaks + } + + // ── Verification ────────────────────────────────────────────────────── + + /// Verifies an MMR inclusion proof. + /// + /// The verifier: + /// 1. Re-derives the local subtree root from `leaf_hash` + `siblings`. + /// 2. Reconstructs the full bagged root by substituting the local root + /// into `peaks_snapshot` at `local_peak_pos` and bagging. + /// 3. Compares the reconstructed root against `expected_root`. + pub fn verify_mmr_proof( + env: Env, + proof: MmrProof, + expected_root: BytesN<32>, + ) -> Result { + if proof.peaks_snapshot.len() == 0 { + return Err(MmrError::InvalidInput); + } + + // Step 1: walk the sibling path to reconstruct the local subtree root. + let mut current = proof.leaf_hash.clone(); + let mut pos = proof.leaf_index; + + for sib in proof.siblings.iter() { + if pos % 2 == 0 { + // current is a left child → merge(current, sibling) + current = hash_node(&env, ¤t, &sib); + } else { + // current is a right child → merge(sibling, current) + current = hash_node(&env, &sib, ¤t); + } + pos /= 2; + } + + // Step 2: substitute local root into peaks snapshot at local_peak_pos. + let mut peaks_for_bag: Vec> = Vec::new(&env); + for (i, p) in proof.peaks_snapshot.iter().enumerate() { + if i as u32 == proof.local_peak_pos { + peaks_for_bag.push_back(current.clone()); + } else { + peaks_for_bag.push_back(p); + } + } + + // Filter out zero-sentinels (inactive peaks). + let active = active_peaks(&env, &peaks_for_bag); + let reconstructed = bag_peaks(&env, &active); + + // Step 3: compare. + let valid = reconstructed == expected_root; + + env.events().publish( + (symbol_short!("mmr"), symbol_short!("verified")), + (proof.leaf_index, expected_root, valid), + ); + + Ok(valid) + } + + /// Convenience method: verify using the accumulator's current stored root. + /// Useful when the verifier trusts the on-chain state as the source of truth. + pub fn verify_against_current( + env: Env, + proof: MmrProof, + ) -> Result { + let state = load_state(&env); + if state.leaf_count == 0 { + return Err(MmrError::EmptyAccumulator); + } + let active = active_peaks(&env, &state.peaks); + let current_root = bag_peaks(&env, &active); + Self::verify_mmr_proof(env, proof, current_root) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn is_zero(h: &BytesN<32>) -> bool { + h.to_array() == [0u8; 32] +} + +fn count_active_peaks(peaks: &Vec>) -> u32 { + let mut count = 0u32; + for p in peaks.iter() { + if !is_zero(&p) { + count += 1; + } + } + count +} + +fn active_peaks(env: &Env, peaks: &Vec>) -> Vec> { + let mut out: Vec> = Vec::new(env); + for p in peaks.iter() { + if !is_zero(&p) { + out.push_back(p); + } + } + out +} diff --git a/contracts/soroban/tests/mmr_accumulator.test.rs b/contracts/soroban/tests/mmr_accumulator.test.rs new file mode 100644 index 00000000..9fb02fb9 --- /dev/null +++ b/contracts/soroban/tests/mmr_accumulator.test.rs @@ -0,0 +1,319 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::Address as _, + Address, BytesN, Env, Vec, +}; +use bridge_watch_contracts::mmr_accumulator::{ + MmrAccumulatorContract, MmrAccumulatorContractClient, MmrError, MmrProof, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + (env, admin) +} + +fn deploy(env: &Env, admin: &Address) -> MmrAccumulatorContractClient<'_> { + let id = env.register_contract(None, MmrAccumulatorContract); + let client = MmrAccumulatorContractClient::new(env, &id); + client.initialize(admin).unwrap(); + client +} + +fn leaf(env: &Env, byte: u8) -> BytesN<32> { + BytesN::from_array(env, &[byte; 32]) +} + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_and_double_init_fails() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + let err = client.try_initialize(&admin).unwrap_err().unwrap(); + assert_eq!(err, MmrError::AlreadyInitialized); +} + +#[test] +fn test_get_admin() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + assert_eq!(client.get_admin().unwrap(), admin); +} + +// --------------------------------------------------------------------------- +// Append +// --------------------------------------------------------------------------- + +#[test] +fn test_append_single_leaf_returns_index_zero() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + let idx = client.append(&admin, &leaf(&env, 0x01)).unwrap(); + assert_eq!(idx, 0u64); + assert_eq!(client.get_leaf_count(), 1u64); +} + +#[test] +fn test_append_multiple_leaves_increments_count() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + for i in 0u8..8 { + let idx = client.append(&admin, &leaf(&env, i)).unwrap(); + assert_eq!(idx, i as u64); + } + assert_eq!(client.get_leaf_count(), 8u64); +} + +#[test] +fn test_root_changes_after_each_append() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + client.append(&admin, &leaf(&env, 0xAA)).unwrap(); + let root1 = client.get_root().unwrap(); + + client.append(&admin, &leaf(&env, 0xBB)).unwrap(); + let root2 = client.get_root().unwrap(); + + assert_ne!(root1, root2); +} + +#[test] +fn test_same_commitment_produces_deterministic_root() { + let (env1, admin1) = setup(); + let client1 = deploy(&env1, &admin1); + client1.append(&admin1, &leaf(&env1, 0x11)).unwrap(); + client1.append(&admin1, &leaf(&env1, 0x22)).unwrap(); + let root1 = client1.get_root().unwrap(); + + let (env2, admin2) = setup(); + let client2 = deploy(&env2, &admin2); + client2.append(&admin2, &leaf(&env2, 0x11)).unwrap(); + client2.append(&admin2, &leaf(&env2, 0x22)).unwrap(); + let root2 = client2.get_root().unwrap(); + + assert_eq!(root1, root2); +} + +#[test] +fn test_get_root_empty_fails() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + let err = client.try_get_root().unwrap_err().unwrap(); + assert_eq!(err, MmrError::EmptyAccumulator); +} + +// --------------------------------------------------------------------------- +// Proof verification (simple case: single-leaf tree) +// --------------------------------------------------------------------------- + +#[test] +fn test_verify_single_leaf_proof() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + let commitment = leaf(&env, 0xDE); + client.append(&admin, &commitment).unwrap(); + let root = client.get_root().unwrap(); + + // For a single leaf, the proof has no siblings and the leaf IS the peak. + // peaks_snapshot contains the single peak, local_peak_pos = 0. + let peaks = client.get_peaks(); + + // Derive the stored leaf_hash the same way the contract does internally. + // We'll build the proof manually: leaf hash = SHA-256(0x00 || commitment). + let mut buf = soroban_sdk::Bytes::new(&env); + buf.push_back(0x00u8); + buf.append(&commitment.clone().into()); + let leaf_hash = env.crypto().sha256(&buf); + + let proof = MmrProof { + leaf_hash, + leaf_index: 0, + siblings: Vec::new(&env), + peaks_snapshot: peaks, + local_peak_pos: 0, + }; + + let valid = client.verify_mmr_proof(&proof, &root).unwrap(); + assert!(valid); +} + +#[test] +fn test_verify_proof_wrong_root_fails() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + let commitment = leaf(&env, 0xAB); + client.append(&admin, &commitment).unwrap(); + + let bad_root = leaf(&env, 0xFF); + let peaks = client.get_peaks(); + + let mut buf = soroban_sdk::Bytes::new(&env); + buf.push_back(0x00u8); + buf.append(&commitment.clone().into()); + let leaf_hash = env.crypto().sha256(&buf); + + let proof = MmrProof { + leaf_hash, + leaf_index: 0, + siblings: Vec::new(&env), + peaks_snapshot: peaks, + local_peak_pos: 0, + }; + + let valid = client.verify_mmr_proof(&proof, &bad_root).unwrap(); + assert!(!valid); +} + +#[test] +fn test_verify_tampered_leaf_fails() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + client.append(&admin, &leaf(&env, 0x01)).unwrap(); + let root = client.get_root().unwrap(); + let peaks = client.get_peaks(); + + // Use a different leaf hash (tampered). + let tampered_leaf = leaf(&env, 0xFF); + + let proof = MmrProof { + leaf_hash: tampered_leaf, + leaf_index: 0, + siblings: Vec::new(&env), + peaks_snapshot: peaks, + local_peak_pos: 0, + }; + + let valid = client.verify_mmr_proof(&proof, &root).unwrap(); + assert!(!valid); +} + +// --------------------------------------------------------------------------- +// Two-leaf tree proof (one sibling) +// --------------------------------------------------------------------------- + +#[test] +fn test_two_leaf_tree_peak_count_is_one() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + client.append(&admin, &leaf(&env, 0x01)).unwrap(); + client.append(&admin, &leaf(&env, 0x02)).unwrap(); + + // After 2 leaves the MMR has one merged peak (a complete binary tree of height 1). + let peaks = client.get_peaks(); + let active: Vec> = peaks + .iter() + .filter(|p| p.to_array() != [0u8; 32]) + .collect::>() + .into_iter() + .fold(Vec::new(&env), |mut v, p| { v.push_back(p); v }); + assert_eq!(active.len(), 1); +} + +// --------------------------------------------------------------------------- +// Large tree: 1000 leaves — root must be deterministic +// --------------------------------------------------------------------------- + +#[test] +fn test_thousand_leaf_root_is_deterministic() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + for i in 0u8..=255 { + client.append(&admin, &leaf(&env, i)).unwrap(); + } + // Continue with wrapping values to reach 1000+ appends + for i in 0u8..=255 { + client.append(&admin, &leaf(&env, i.wrapping_add(1))).unwrap(); + } + for i in 0u8..=255 { + client.append(&admin, &leaf(&env, i.wrapping_add(2))).unwrap(); + } + for i in 0u8..=232 { + client.append(&admin, &leaf(&env, i.wrapping_add(3))).unwrap(); + } + + let root_a = client.get_root().unwrap(); + assert_eq!(client.get_leaf_count(), 1001u64); + + // Second independent accumulator with same leaves. + let (env2, admin2) = setup(); + let client2 = deploy(&env2, &admin2); + for i in 0u8..=255 { + client2.append(&admin2, &leaf(&env2, i)).unwrap(); + } + for i in 0u8..=255 { + client2.append(&admin2, &leaf(&env2, i.wrapping_add(1))).unwrap(); + } + for i in 0u8..=255 { + client2.append(&admin2, &leaf(&env2, i.wrapping_add(2))).unwrap(); + } + for i in 0u8..=232 { + client2.append(&admin2, &leaf(&env2, i.wrapping_add(3))).unwrap(); + } + + let root_b = client2.get_root().unwrap(); + assert_eq!(root_a, root_b); +} + +// --------------------------------------------------------------------------- +// verify_against_current convenience method +// --------------------------------------------------------------------------- + +#[test] +fn test_verify_against_current() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + let commitment = leaf(&env, 0xCC); + client.append(&admin, &commitment).unwrap(); + let peaks = client.get_peaks(); + + let mut buf = soroban_sdk::Bytes::new(&env); + buf.push_back(0x00u8); + buf.append(&commitment.clone().into()); + let leaf_hash = env.crypto().sha256(&buf); + + let proof = MmrProof { + leaf_hash, + leaf_index: 0, + siblings: Vec::new(&env), + peaks_snapshot: peaks, + local_peak_pos: 0, + }; + + let valid = client.verify_against_current(&proof).unwrap(); + assert!(valid); +} + +#[test] +fn test_verify_against_current_empty_fails() { + let (env, admin) = setup(); + let client = deploy(&env, &admin); + + let proof = MmrProof { + leaf_hash: leaf(&env, 0x00), + leaf_index: 0, + siblings: Vec::new(&env), + peaks_snapshot: Vec::new(&env), + local_peak_pos: 0, + }; + let err = client.try_verify_against_current(&proof).unwrap_err().unwrap(); + assert_eq!(err, MmrError::EmptyAccumulator); +}