Skip to content

[Contract] Implement donor eligibility verification via Merkle commitment + nullifier scheme - #601

Open
SMOGZ6783 wants to merge 1 commit into
Fundable-Protocol:mainfrom
SMOGZ6783:feature/zk-donor-verification
Open

[Contract] Implement donor eligibility verification via Merkle commitment + nullifier scheme#601
SMOGZ6783 wants to merge 1 commit into
Fundable-Protocol:mainfrom
SMOGZ6783:feature/zk-donor-verification

Conversation

@SMOGZ6783

@SMOGZ6783 SMOGZ6783 commented Jul 30, 2026

Copy link
Copy Markdown

Summary

Implements anonymous donor eligibility verification in a new Soroban contract at contracts/donor-verification, so a donor's eligibility can be validated without disclosing their private account address on-chain.

Closes #515

Approach

A full zk-SNARK circuit (Groth16/PLONK) requires pairing-curve arithmetic and a trusted setup outside the scope of a single contract PR. This implementation instead uses a Merkle-commitment + nullifier scheme — the same privacy primitive used by systems like Tornado Cash:

  • Donors are enrolled off-chain as commitments (hash(secret, nullifier)), included as Merkle tree leaves; only the root is published on-chain.
  • verify_and_donate checks a submitted Merkle inclusion proof against the stored root and records the donor's nullifier, without ever storing or linking their account address to the commitment.
  • Reused nullifiers are rejected, preventing double-donation from the same enrolled identity.

This is flagged clearly in code comments as a scoped, testable first step. A true SNARK verifier could be swapped in behind the same entry point later if stronger unlinkability is required — happy to discuss scope with maintainers if that's the intended direction.

Changes

  • contracts/donor-verification/src/lib.rs: contract with initialize, update_root, verify_and_donate, is_nullifier_used, get_current_root
  • contracts/donor-verification/src/test.rs: unit tests covering success, invalid proof, reused nullifier, wrong proof length, unauthorized root update, and pre-initialization calls
  • contracts/donor-verification/Cargo.toml

Acceptance criteria

  • ✅ Compiles cleanly: cargo build --target wasm32-unknown-unknown --release
  • ✅ All unit tests pass: cargo test
  • ✅ Explicit require_auth() checks on initialize, update_root, verify_and_donate
  • Error enum with distinct error codes
  • ✅ Storage TTL management via extend_ttl on instance and persistent storage
  • ✅ Doc comments on all public functions

Summary by CodeRabbit

  • New Features

    • Added anonymous donor eligibility verification using Merkle proofs.
    • Added one-time donation protection through nullifier tracking.
    • Added administrator controls for initializing and updating eligibility data.
    • Added status queries for the current eligibility data and nullifier usage.
  • Documentation

    • Added documentation covering enrollment, verification, and privacy-preserving donation flows.
  • Tests

    • Added coverage for valid and invalid proofs, authorization, initialization, duplicate donations, and administrative updates.

@drips-wave

drips-wave Bot commented Jul 30, 2026

Copy link
Copy Markdown

@SMOGZ6783 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

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new Soroban donor-verification crate and contract using fixed-depth Merkle proofs, admin-managed roots, donor authorization, nullifier replay protection, persistent TTLs, verification events, unit tests, and contract documentation.

Changes

Anonymous donor verification

Layer / File(s) Summary
Contract foundation
contracts/donor-verification/Cargo.toml, contracts/donor-verification/src/lib.rs
Defines the crate configuration, public errors, storage keys, contract type, Merkle depth, and storage TTL constants.
Initialization and verification flow
contracts/donor-verification/src/lib.rs
Implements initialization, admin-only root updates, authorized Merkle proof verification, nullifier persistence, TTL extension, verification events, and read helpers.
Contract validation and documentation
contracts/donor-verification/src/test.rs, docs/contracts/donor-verification.md
Adds Merkle proof helpers and tests for successful verification, invalid proofs, replay protection, authorization, initialization, and proof length, alongside contract flow documentation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Donor
  participant DonorVerificationContract
  participant PersistentStorage
  participant EventSink
  Donor->>DonorVerificationContract: verify_and_donate(leaf, proof, leaf_index, nullifier)
  DonorVerificationContract->>DonorVerificationContract: Require authorization and compute Merkle root
  DonorVerificationContract->>PersistentStorage: Read root and nullifier marker
  DonorVerificationContract->>PersistentStorage: Store used nullifier with TTL
  DonorVerificationContract->>EventSink: Publish verified event
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new donor eligibility verification contract and its Merkle/nullifier approach.
Linked Issues check ✅ Passed The summary shows auth checks, error enums, tests, TTL handling, and public docs added for the anonymous donor verification contract.
Out of Scope Changes check ✅ Passed The changes stay within the donor-verification contract, its tests, and related documentation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
contracts/donor-verification/src/test.rs (1)

167-183: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Test missing authorization separately.

This only exercises the admin != stored_admin branch; it cannot catch removal of admin.require_auth(). Add cases using the stored admin without authorization, and a valid verify_and_donate call without donor authorization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/donor-verification/src/test.rs` around lines 167 - 183, Extend
test_update_root_requires_admin to disable mocked authorizations and call
update_root with the stored admin, asserting the authorization failure
separately from the Error::NotAdmin case. Add a test case for verify_and_donate
using a donor without authorization, asserting it also fails authorization while
preserving the existing valid-authority behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/donor-verification/src/lib.rs`:
- Around line 195-207: Validate leaf_index is less than 1u32 shifted by
TREE_DEPTH before entering proof verification, rejecting indices outside the
configured tree rather than truncating higher bits. Add a boundary test covering
both the maximum valid index and the first invalid index at 1u32 << TREE_DEPTH,
while preserving existing verification behavior for valid indices.
- Around line 120-154: The verify_and_donate flow in
contracts/donor-verification/src/lib.rs, specifically verify_and_donate, must
replace transparent Merkle inclusion with verification of a ZK statement binding
the secret, enrolled leaf, Merkle path, and nullifier hash; use relayed
submission without requiring a donor address when anonymity is required. Update
docs/contracts/donor-verification.md bash lines 3-19 to remove the unlinkability
claim and describe the current verifier as providing only public Merkle
membership until the ZK verifier is implemented.

---

Nitpick comments:
In `@contracts/donor-verification/src/test.rs`:
- Around line 167-183: Extend test_update_root_requires_admin to disable mocked
authorizations and call update_root with the stored admin, asserting the
authorization failure separately from the Error::NotAdmin case. Add a test case
for verify_and_donate using a donor without authorization, asserting it also
fails authorization while preserving the existing valid-authority behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e3198617-6fa7-44ab-93d7-c06f6c62e637

📥 Commits

Reviewing files that changed from the base of the PR and between 375c936 and c6ca2e7.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • contracts/donor-verification/Cargo.toml
  • contracts/donor-verification/src/lib.rs
  • contracts/donor-verification/src/test.rs
  • docs/contracts/donor-verification.md bash

Comment on lines +120 to +154
pub fn verify_and_donate(
env: Env,
donor: Address,
leaf: BytesN<32>,
proof: Vec<BytesN<32>>,
leaf_index: u32,
nullifier: BytesN<32>,
) -> Result<(), Error> {
donor.require_auth();

if proof.len() != TREE_DEPTH {
return Err(Error::InvalidProofLength);
}

let nullifier_key = DataKey::Nullifier(nullifier.clone());
if env.storage().persistent().has(&nullifier_key) {
return Err(Error::NullifierAlreadyUsed);
}

let root: BytesN<32> = Self::get_root(&env)?;
let computed_root = Self::compute_merkle_root(&env, &leaf, &proof, leaf_index);

if computed_root != root {
return Err(Error::InvalidMerkleProof);
}

// Record the nullifier as spent. Only the nullifier is stored —
// never the donor's address or the leaf/commitment itself.
env.storage().persistent().set(&nullifier_key, &true);
env.storage()
.persistent()
.extend_ttl(&nullifier_key, STORAGE_TTL_THRESHOLD, STORAGE_TTL_EXTEND_TO);

env.events()
.publish((symbol_short!("verified"),), nullifier);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Replace the transparent proof flow; it does not provide anonymous, one-time eligibility.

Merkle inclusion only proves that the supplied leaf is in root; it neither proves knowledge of a secret nor binds nullifier to that leaf. An enrolled donor can reuse one valid leaf/proof with unlimited fresh nullifiers. Further, donor, leaf, and proof are public arguments of the same invocation, so they are linkable on-chain.

  • contracts/donor-verification/src/lib.rs#L120-L154: verify a ZK statement binding the secret, enrolled leaf, Merkle path, and nullifier hash; use relayed submission rather than a donor address when anonymity is required.
  • docs/contracts/donor-verification.md bash#L3-L19: remove the unlinkability claim and describe this as public Merkle membership until that verifier exists.
📍 Affects 2 files
  • contracts/donor-verification/src/lib.rs#L120-L154 (this comment)
  • docs/contracts/donor-verification.md bash#L3-L19
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/donor-verification/src/lib.rs` around lines 120 - 154, The
verify_and_donate flow in contracts/donor-verification/src/lib.rs, specifically
verify_and_donate, must replace transparent Merkle inclusion with verification
of a ZK statement binding the secret, enrolled leaf, Merkle path, and nullifier
hash; use relayed submission without requiring a donor address when anonymity is
required. Update docs/contracts/donor-verification.md bash lines 3-19 to remove
the unlinkability claim and describe the current verifier as providing only
public Merkle membership until the ZK verifier is implemented.

Comment on lines +195 to +207
let mut index = leaf_index;

for sibling in proof.iter() {
let mut buf = Bytes::new(env);
if index % 2 == 0 {
buf.append(&Bytes::from_array(env, &current.to_array()));
buf.append(&Bytes::from_array(env, &sibling.to_array()));
} else {
buf.append(&Bytes::from_array(env, &sibling.to_array()));
buf.append(&Bytes::from_array(env, &current.to_array()));
}
current = env.crypto().sha256(&buf).into();
index /= 2;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject indices outside the configured tree.

Only the low 20 bits are consumed, so indices such as 0 and 1 << TREE_DEPTH compute the same path. Validate leaf_index < (1u32 << TREE_DEPTH) before verification and add a boundary test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/donor-verification/src/lib.rs` around lines 195 - 207, Validate
leaf_index is less than 1u32 shifted by TREE_DEPTH before entering proof
verification, rejecting indices outside the configured tree rather than
truncating higher bits. Add a boundary test covering both the maximum valid
index and the first invalid index at 1u32 << TREE_DEPTH, while preserving
existing verification behavior for valid indices.

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.

[Contract] Implement Zero-Knowledge Proof Verification for Anonymous Donors

1 participant