Skip to content

feat: add snapshot Merkle inclusion proof verifier (#463)#491

Open
morelucks wants to merge 3 commits into
RevoraOrg:masterfrom
morelucks:feat/snapshot-merkle-proof
Open

feat: add snapshot Merkle inclusion proof verifier (#463)#491
morelucks wants to merge 3 commits into
RevoraOrg:masterfrom
morelucks:feat/snapshot-merkle-proof

Conversation

@morelucks

Copy link
Copy Markdown

Summary

Closes #463

Adds verify_snapshot_inclusion — a pure, stateless Merkle inclusion proof helper that lets off-chain holders prove their leaf was included in a finalized snapshot without storing the full holder set on-chain.


What changed

src/lib.rs — single new public function verify_snapshot_inclusion


Design

Leaf encoding

Identical to finalize_snapshot (same hash function, same serialisation):

leaf_hash = SHA-256( index_xdr || holder_xdr || shares_bps_xdr )

Each field is XDR-serialised with .to_xdr(&env). This guarantees that any leaf produced by the existing finalization pass is directly provable — no separate encoding step.

Sorted-pair Merkle tree

Internal nodes:

parent = SHA-256( min(a, b) || max(a, b) )

Sorting the pair lexicographically before hashing makes the tree position-independent: the proof array carries only sibling hashes, no left/right flags. Off-chain tooling must build proofs with the same sorted-pair rule.

Verification algorithm

  1. Hash the supplied raw leaf bytes with SHA-256 → current
  2. For each sibling in proof: sort pair, concatenate, SHA-256 → new current
  3. current == entry.content_hashtrue, otherwise false

Security properties

Property Detail
Finalization guard Returns false for unfinalized snapshots (untrusted content_hash)
Missing snapshot Returns false — no error leaked (prevents enumeration)
Fixed-width proof elements Vec<BytesN<32>> — no length-extension attack surface
Collision resistance Forging a valid proof requires breaking SHA-256
Same hash function as finalize_snapshot env.crypto().sha256() throughout

Leaf encoding reference (for off-chain implementors)

// index: slot position (u32), holder: Address, share_bps: u32
let mut raw = Bytes::new(&env);
raw.append(&index.to_xdr(&env));
raw.append(&holder.to_xdr(&env));
raw.append(&share_bps.to_xdr(&env));
let leaf_hash = env.crypto().sha256(&raw).to_bytes(); // BytesN<32>

Pass raw (the pre-hash bytes) as the leaf argument to verify_snapshot_inclusion.

Closes RevoraOrg#463

Add verify_snapshot_inclusion(issuer, namespace, token, snapshot_ref,
leaf, proof) -> bool, a pure off-chain Merkle inclusion proof helper
that lets holders prove their leaf was included in a finalized snapshot
without storing the full holder set on-chain.

## Design

### Leaf encoding (identical to finalize_snapshot)

  leaf_hash = SHA-256( index_xdr || holder_xdr || shares_bps_xdr )

where each field is XDR-serialised via .to_xdr(&env), matching the
exact byte layout used in finalize_snapshot. This guarantees that any
leaf produced by the existing finalization pass is directly provable.

### Merkle tree construction (sorted-pair)

Internal nodes are computed as:

  parent = SHA-256( min(left, right) || max(left, right) )

Sorting the pair lexicographically before hashing makes the tree
position-independent — no left/right flag is needed in the proof array.
Off-chain tooling builds proofs with the same sorted-pair rule.

### Proof verification

1. Hash the supplied raw leaf bytes with SHA-256.
2. For each sibling in the proof array, sort the pair and hash.
3. The final value must equal the committed content_hash.

### Security guards

- Returns false (not an error) when the snapshot does not exist or is
  not yet finalized. An unfinalized snapshot has an untrusted
  content_hash and must not be accepted as a proof root.
- Every proof element is BytesN<32> — fixed-width, no length extension.
- SHA-256 collision resistance makes forging a valid proof infeasible.

Relevant files: src/lib.rs (verify_snapshot_inclusion)
@drips-wave

drips-wave Bot commented Jun 28, 2026

Copy link
Copy Markdown

@morelucks Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

morelucks added 2 commits July 6, 2026 15:47
- Move SnapshotFinalized from DataKey (51 variants > Soroban 50 limit)
  into DataKey2; redirect all DataKey::SnapshotFinalized references
- Add missing DataKey2 variants: SupplyCap, DepositedRevenue,
  InvestmentConstraints, MinRevenueThreshold
- Add PauseState enum (NotPaused/SoftPaused/HardPaused)
- Add StaleConcentrationData = 54; fix duplicate discriminant 48
- Fix get_pending_periods -> get_pending_periods_page in test
- Fix 5-arg register_offering and deposit_revenue calls in tests
- Fix unused Address/token imports in test files
- Fix format! -> std::format! in test_compute_share_invariants
- Fix for &bps in array -> .iter()
- Fix empty line after doc comment in lib.rs
- Fix clamp-like pattern; add too_many_arguments allow
- Fix deprecated register_stellar_asset_contract
- Remove u32 as u32 redundant casts
- Fix needless borrow in vesting.rs; collapse nested if
- Fix unexpected_cfgs in kani harness
- Run cargo fmt
- Fix arithmetic overflow in test_compute_share_invariants (i128::MAX +
  9_999 overflows; use aligned boundary values instead)
- Fix sibling offering test: deploy a real FailingTransferToken for
  offering B so deposit_revenue can actually transfer tokens
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add snapshot Merkle inclusion proof verification helper for off-chain claim attestation

1 participant