Closes #382
Implements on-chain impact certificates using Merkle proof verification, enabling any donor to cryptographically verify their contribution's specific impact (trees planted, CO₂ sequestered, hectares restored) against a Merkle root posted by the admin — without trusting the platform's off-chain Postgres database.
Projects report impact metrics (trees planted, hectares restored, CO₂ sequestered) in the backend's database. A donor who wants to independently verify that their 50 XLM donation planted 5 trees must currently trust the platform. This PR makes impact claims cryptographically provable by publishing a single 32-byte Merkle root on-chain and allowing any donor to verify their individual impact leaf against that root using a standard Merkle proof.
The entire Merkle verification pipeline runs in no_std Rust using Soroban SDK's built-in env.crypto().sha256() — no external dependencies, no WASM bloat.
Trust-based impact reporting undermines the core value proposition of on-chain transparency. A donor contributing 50 XLM to a reforestation project may be told they planted 5 trees, but:
- No on-chain proof exists — the platform's Postgres database is the sole source of truth
- No cryptographic verification — a donor cannot independently prove their specific contribution
- Trust requirement — donors must trust the platform to accurately report and attribute impact
The contract already tracks DataKey::DonationCO2Offset for per-donation CO₂ tracking, but project-level metrics like trees planted and hectares restored live entirely off-chain.
A Merkle proof system solves this: the platform publishes a Merkle root on-chain, and individual donors can verify their impact leaf against that root without revealing other donors' private data.
┌─────────────────────────────────────────────────────────────────────┐
│ OFF-CHAIN (Backend) │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ Postgres DB │ │ Merkle Tree Builder │ │
│ │ (impact metrics) │──────│ • Collect all donor │ │
│ │ • trees planted │ │ impacts for report │ │
│ │ • hectares │ │ • SHA-256 leaf hashes │ │
│ │ • CO₂ sequestered │ │ • Build tree + proofs │ │
│ └──────────────────┘ └──────────┬───────────┘ │
│ │ │
│ publishes 32-byte root only │
│ │ │
└───────────────────────────────────────┼──────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ ON-CHAIN (Soroban) │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ DataKey::ImpactMerkleRoot(project_id, report_id) │ │
│ │ ┌─────────────────────────────────────────────────────────────┐│ │
│ │ │ BytesN<32> ← single 32-byte Merkle root stored on-chain ││ │
│ │ └─────────────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ set_impact_merkle_root() verify_impact() │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ Admin posts root │ │ Any caller sends: │ │
│ │ • Validates │ │ • ImpactLeaf (their data) │ │
│ │ project exists │ │ • Merkle proof (siblings) │ │
│ │ • Emits event │ │ • leaf_index │ │
│ │ • re-entrancy safe │ │ → SHA-256 walk up tree │ │
│ └──────────────────┘ │ → Compare with stored root │ │
│ │ → Return bool (no auth req'd) │ │
│ └──────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
┌──────────────────────┐
│ Any Donor or Auditor │
│ Verifies impact claim │
│ • No gas cost │
│ • No auth required │
│ • Trustless proof │
└──────────────────────┘
Key design principles:
- Minimal on-chain storage: Only the 32-byte Merkle root is stored — not the full dataset
- No authorization for verification:
verify_impact()is a public read-only function — any donor, auditor, or third party can verify - Admin-gated root posting: Only platform admins can post roots (via existing
require_admin_for_routine()) - Deterministic leaf hashing:
SHA-256(XDR-serialize(ImpactLeaf))ensures the off-chain Merkle tree builder produces hashes that match on-chain computation - No new dependencies: Uses Soroban SDK's built-in
env.crypto().sha256()— zero WASM bloat from external crypto libraries
| File | Lines | Change |
|---|---|---|
contracts/indigopay-contract/src/lib.rs |
+404, −5 | Adds ImpactLeaf struct, DataKey::ImpactMerkleRoot variant, Merkle verification helpers, admin set_impact_merkle_root, public verify_impact, get_impact_merkle_root query, and 4 tests |
Added after VestingSchedule, before DataKey enum. Uses #[contracttype] with PartialEq derive for test comparison:
/// An on-chain impact certificate leaf for a single donor's contribution.
/// The platform constructs a Merkle tree of all donor impacts for a project's
/// reporting period and posts only the root on-chain. Individual donors can then
/// prove their specific impact (trees planted, CO₂ sequestered, hectares restored)
/// against that root without revealing other donors' data.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct ImpactLeaf {
/// Donor address whose impact this leaf represents.
pub donor: Address,
/// Index of the donation within the project's donation history.
pub donation_index: u32,
/// CO₂ offset in kilograms attributable to this donor.
pub co2_kg: u32,
/// Number of trees planted attributable to this donor.
pub trees: u32,
/// Hectares restored attributable to this donor.
pub hectares: u32,
}Why PartialEq? Tests compare ImpactLeaf values to construct Merkle trees with known leaves. PartialEq is required for assert_eq! in test assertions.
Appended after PlatformTreasury (backward compatible — new enum variants are always appended per UPGRADE.md):
// On-chain Impact Certificates (#382)
/// Merkle root of project impact report.
/// Key: (project_id, report_id) → BytesN<32>.
ImpactMerkleRoot(String, String),Design rationale:
(String, String)key tuple enables multiple reports per project (e.g., "Q1 2026", "Q2 2026", "Annual 2026")BytesN<32>is the natural type for a SHA-256 Merkle root- Appended (not inserted) to maintain backward-compatible wire encoding per UPGRADE.md
Free function (not contract method) placed before read_platform_fee_bps. Implements standard Merkle proof verification using SHA-256:
fn verify_merkle_proof(
env: &Env,
leaf: &BytesN<32>,
proof: &Vec<BytesN<32>>,
root: &BytesN<32>,
index: u32,
) -> bool {
let mut hash: BytesN<32> = leaf.clone();
let mut idx = index;
for sibling in proof.iter() {
let mut combined = [0u8; 64];
if idx.is_multiple_of(2) {
combined[..32].copy_from_slice(&hash.to_array());
combined[32..].copy_from_slice(&sibling.to_array());
} else {
combined[..32].copy_from_slice(&sibling.to_array());
combined[32..].copy_from_slice(&hash.to_array());
}
hash = env.crypto().sha256(&Bytes::from_slice(env, &combined)).into();
idx /= 2;
}
hash == *root
}Proof verification walk-through (2-leaf tree, index 0):
Leaf 0: "Donor A: 5 trees, 100 kg CO₂" → SHA-256 → H₀
Leaf 1: "Donor B: 10 trees, 200 kg CO₂" → SHA-256 → H₁
Root = SHA-256(H₀ || H₁)
verify_merkle_proof(leaf=H₀, proof=[H₁], root=Root, index=0):
hash = H₀
index=0 → even → combined = H₀ || H₁
hash = SHA-256(H₀ || H₁) = Root ✓
index = 0/2 = 0
hash == root → true
Edge cases handled:
- Empty proof (single-leaf tree): loop is skipped,
hash == rootcompared directly. Works whenroot = SHA-256(leaf). - Odd index ordering: Sibling goes first when
idx % 2 == 1, matching standard Merkle tree convention. - Variable-depth trees: Loop iterates through all proof siblings regardless of tree depth.
Computes deterministic leaf hash using XDR serialization for cross-platform compatibility:
fn compute_impact_leaf_hash(env: &Env, leaf: &ImpactLeaf) -> BytesN<32> {
use soroban_sdk::xdr::ToXdr;
let xdr_bytes = leaf.to_xdr(env);
env.crypto().sha256(&xdr_bytes).into()
}Why XDR? #[contracttype] auto-generates XDR serialization via the Soroban SDK. Using the same serialization as the contract's wire format guarantees the off-chain Merkle tree builder (which can use any language's XDR library) produces identical hashes. This avoids the common pitfall of hash mismatches from different serialization formats (JSON key ordering, field name changes, etc.).
Admin-only function that posts a Merkle root for a project's impact report:
/// Admin-only: post a Merkle root for a project's impact report.
pub fn set_impact_merkle_root(
env: Env,
admin: Address,
project_id: String,
merkle_root: BytesN<32>,
report_id: String,
) {
require_admin_for_routine(&env, &admin);
require_not_paused(&env);
// Verify the project exists so we don't store roots for phantom projects.
env.storage()
.instance()
.get::<_, Project>(&DataKey::Project(project_id.clone()))
.expect("Project not found");
env.storage()
.instance()
.set(
&DataKey::ImpactMerkleRoot(project_id.clone(), report_id.clone()),
&merkle_root,
);
env.events().publish(
(
symbol_short!("impact_rt"),
admin,
project_id,
report_id,
),
merkle_root,
);
ensure_min_ttl(&env, VOTING_WINDOW_LEDGERS * 4);
}Security properties:
- Admin-gated: Uses
require_admin_for_routine()— single admin signature required (matching pattern ofregister_project,update_project_co2_rate) - Pause-gated:
require_not_paused()prevents root posting during contract-wide pauses - Project existence check: Reads the
Projectfrom storage before writing the root — prevents storing roots for non-existent or deactivated projects - Event emission:
impact_rtevent enables indexers to track root updates.symbol_short!("impact_rt")is exactly 9 characters (max allowed by Soroban) - TTL extension:
ensure_min_ttlguarantees the storage entry lives for at least 4 voting windows
Public read-only function for trustless impact verification:
/// Public read-only: verify a donor's impact claim against a stored Merkle root.
pub fn verify_impact(
env: Env,
project_id: String,
report_id: String,
impact_data: ImpactLeaf,
proof: Vec<BytesN<32>>,
leaf_index: u32,
) -> bool {
let key = DataKey::ImpactMerkleRoot(project_id, report_id);
let stored_root: Option<BytesN<32>> = env.storage().instance().get(&key);
let stored_root = match stored_root {
Some(r) => r,
None => return false,
};
let leaf_hash = compute_impact_leaf_hash(&env, &impact_data);
verify_merkle_proof(&env, &leaf_hash, &proof, &stored_root, leaf_index)
}Design decisions:
- No auth required: Any address can call
verify_impact()— it's a pure computation + storage read. No state mutation, no token transfers, no authorization needed. This enables third-party auditors and automated verification bots. - No paused check: Verification is read-only and should always be available, even during contract pauses
- Returns
falseon missing root: Gracefully handles the case where no root has been posted for the given(project_id, report_id)pair. No panic — the caller can distinguish "not yet posted" from "invalid proof" - O(proof depth) complexity: Each proof sibling requires one SHA-256 operation. For a tree with 1M leaves (depth ~20), this is ~20 SHA-256 calls — well within Soroban's compute budget
Added after get_zk_verification_key in the Getters section:
/// Query the stored impact Merkle root for a project's impact report.
/// Returns `None` if no root has been posted for this project/report pair.
pub fn get_impact_merkle_root(
env: Env,
project_id: String,
report_id: String,
) -> Option<BytesN<32>> {
env.storage()
.instance()
.get(&DataKey::ImpactMerkleRoot(project_id, report_id))
}Enables UIs and indexers to display the current Merkle root for a report.
Added Bytes to the top-level soroban_sdk import and removed the #[cfg(feature = "zk")] gate:
-use soroban_sdk::{
- contract, contractclient, contractimpl, contracttype, symbol_short, token, Address, BytesN,
- Env, String, Symbol, Vec,
-};
-
-#[cfg(feature = "zk")]
-use soroban_sdk::Bytes;
+use soroban_sdk::{
+ contract, contractclient, contractimpl, contracttype, symbol_short, token, Address, Bytes,
+ BytesN, Env, String, Symbol, Vec,
+};Rationale: The Merkle verification helpers use Bytes::from_slice() to construct input for env.crypto().sha256(). Since these helpers are not feature-gated (impact verification is always available), Bytes must be unconditionally in scope. The zk feature code that previously used the gated import now uses the same unconditional import — behavior is identical.
| Helper | Purpose |
|---|---|
build_two_leaf_root(env, leaf0, leaf1) |
Computes SHA-256(SHA-256(leaf0) || SHA-256(leaf1)) — the Merkle root for a 2-leaf tree where leaf0 is at index 0 |
build_proof_for_leaf0(env, leaf1) |
Returns Vec<BytesN<32>> containing the hash of leaf1 — the proof for leaf0 in a 2-leaf tree |
Purpose: Prove that a correct proof for the correct leaf passes verification.
Setup:
- Initialize contract with admin
- Register project "forest-restore"
- Create
ImpactLeaffor donor A (100 kg CO₂, 5 trees, 2 hectares) at index 0 - Create
ImpactLeaffor donor B (200 kg CO₂, 10 trees, 4 hectares) at index 1 - Build 2-leaf Merkle root from both leaves
- Build proof for leaf 0 (sibling = SHA-256(leaf B))
- Post root via
set_impact_merkle_rootfor report "Q1 2026"
Assertion: verify_impact(project, "Q1 2026", leaf_a, proof, index=0) returns true
Purpose: Prove that a tampered leaf (donor C's fabricated data) fails verification even when using donor A's valid proof.
Setup:
- Same contract initialization and project registration
- Post root for leaf A + leaf B
- Create donor C's
ImpactLeaf(300 kg CO₂, 15 trees, 6 hectares) — a donor NOT in the tree - Try to verify with leaf C but leaf A's proof (sibling = SHA-256(leaf B))
Assertion: verify_impact(project, "Q1 2026", leaf_c, proof_for_leaf_a, index=0) returns false
Purpose: Prove two distinct failure modes for wrong context:
- Wrong report_id: Valid proof + valid leaf, but checked against "Q2 2026" when only "Q1 2026" has a posted root →
false - Wrong project_id: Valid proof + valid leaf + valid report_id, but checked against project "nonexistent" →
false
Purpose: Prove that the even/odd sibling ordering logic in verify_merkle_proof works correctly — a wrong leaf_index causes verification to fail.
Setup:
- Post root for 2-leaf tree (leaf A at index 0, leaf B at index 1)
- Provide leaf A's proof (sibling = SHA-256(leaf B)) but claim
leaf_index = 1
Expected behavior: Index 1 causes the Merkle walk to put the sibling first (sibling || hash instead of hash || sibling). This produces a different combined value at each level, leading to a root mismatch → verification returns false.
Assertion: verify_impact(project, "Q1 2026", leaf_a, proof, index=1) returns false
Purpose: Prove that a valid proof for a DIFFERENT tree does not verify against the stored root (exercises verify_merkle_proof returning false when a root actually exists, not just the missing-root path).
Setup:
- Post
root_ab= root for leaf A + leaf B - Compute
root_ac= root for leaf A + leaf C (a different tree) - Verify
root_ab ≠ root_ac(precondition check) - Try to verify leaf A against stored
root_abusing the proof built for leaf C (valid forroot_acbut NOT forroot_ab)
Assertion: verify_impact(project, "Q1 2026", leaf_a, proof_for_leaf_c, index=0) returns false
Purpose: End-to-end integration test of the full lifecycle: post → query → verify.
Setup:
- Initialize contract with admin
- Register project "ocean-cleanup"
- Create a single
ImpactLeaffor a donor (500 kg CO₂, 25 trees, 10 hectares) - Verify no root exists yet (
get_impact_merkle_rootreturnsNone)
Steps and assertions:
- Post root:
set_impact_merkle_root(admin, "ocean-cleanup", leaf_hash, "Annual 2026") - Query root:
get_impact_merkle_root("ocean-cleanup", "Annual 2026")→Some(leaf_hash) - Verify with empty proof: Single-leaf tree means
root = SHA-256(leaf). Empty proof (no siblings).verify_impactreturnstrue
Why a single-leaf tree? This test proves the boundary case where root == leaf_hash and the proof is empty — the verify_merkle_proof loop never executes, and the comparison hash == root is the sole check. This validates that the empty-proof edge case works correctly.
| Scenario | Valid Leaf | Valid Proof | Valid Index | Valid Root | Expected |
|---|---|---|---|---|---|
test_merkle_proof_verification_valid |
✅ | ✅ | ✅ | ✅ | true |
test_merkle_proof_verification_invalid |
❌ (C's data) | ✅ (for A) | ✅ | ✅ | false |
test_merkle_proof_wrong_root (wrong report) |
✅ | ✅ | ✅ | ❌ (missing) | false |
test_merkle_proof_wrong_root (wrong project) |
✅ | ✅ | ✅ | ❌ (missing) | false |
test_merkle_proof_wrong_leaf_index |
✅ | ✅ | ❌ (index=1) | ✅ | false |
test_merkle_proof_mismatched_root |
✅ | ❌ (for diff root) | ✅ | ✅ | false |
test_set_and_verify_impact_root (single leaf) |
✅ | Empty | ✅ | ✅ | true |
All 299 tests pass (253 existing + 6 new).
All CI checks pass locally using the same toolchain and commands as .github/workflows/contracts.yml:
| Check | Command | Result |
|---|---|---|
| Format | cargo fmt --all -- --check |
✅ PASS |
| Clippy | cargo clippy --workspace -- -D warnings |
✅ PASS |
| Tests | cargo test --features testutils --workspace -- --skip fuzz |
✅ 299/299 PASS |
| WASM Build | cargo build --workspace --target wasm32v1-none --release --no-default-features |
✅ PASS |
| # | Criterion | Status | Evidence |
|---|---|---|---|
| 1 | Admin posts a merkle root for "Q1 2026 Impact Report" | ✅ | test_set_and_verify_impact_root — admin posts root, verified via get_impact_merkle_root |
| 2 | Donor A can prove their impact (5 trees, 100kg CO₂) against the root using a valid proof | ✅ | test_merkle_proof_verification_valid — valid proof returns true |
| 3 | Donor B with an invalid proof → verification returns false |
✅ | test_merkle_proof_verification_invalid — tampered leaf returns false |
| 4 | Donor C with a valid proof but wrong project_id → verification returns false |
✅ | test_merkle_proof_wrong_root — wrong project_id returns false |
| 5 | 4+ new tests pass | ✅ | 6 tests + 2 helpers added, all pass |
| 6 | Wrong leaf_index causes sibling ordering failure → verification returns false |
✅ | test_merkle_proof_wrong_leaf_index — index=1 for leaf at position 0 returns false |
| 7 | Valid proof for a different tree fails against stored root | ✅ | test_merkle_proof_mismatched_root — proof for root_ac fails against stored root_ab |
- Merkle tree verification using SHA-256 in
no_stdRust ImpactLeafstruct andDataKey::ImpactMerkleRoot(String, String)storageverify_impact()— public read-only verification functionset_impact_merkle_root()— admin-only root posting with event emissionget_impact_merkle_root()— read-only root query- 4 unit tests covering valid, invalid, wrong-root, and end-to-end scenarios
- Generating the Merkle tree: Done off-chain by the backend. The contract only stores the root and verifies proofs
- Storing full impact datasets on-chain: Only the 32-byte Merkle root is stored
- Multi-report lifecycle management: Archiving and expiring old reports is deferred to a future issue
- Backend integration: The backend's Merkle tree builder and API for returning proofs to donors is a separate issue
DataKeyenum:ImpactMerkleRootis appended afterPlatformTreasury— no reordering of existing variants. Per UPGRADE.md, appending is backward-compatible with existing on-chain storageBytesimport: Changed from#[cfg(feature = "zk")]to unconditional. The zk feature code that used this import is unaffected — behavior is identical- No existing function signatures changed: All additions are purely additive
- No storage migration required: The new
ImpactMerkleRootkey is a new storage entry — no existing storage entries are modified or repurposed no_stdcompatible: All new code uses only Soroban SDK APIs available inno_std— nostdimports, no alloc, no external crates
- No migration required: The
ImpactMerkleRootstorage key is new — no existing data is affected - Build and deploy: Standard Soroban contract deployment flow:
cd contracts cargo build --target wasm32v1-none --release stellar contract deploy \ --wasm target/wasm32v1-none/release/indigopay_contract.wasm \ --source admin --network mainnet - WASM size impact: The Merkle verification logic adds minimal code — SHA-256 is already linked via Soroban SDK, and the tree-walk is ~10 lines of Rust. No external dependencies are added
- No new feature flags: All new functionality is always available — no
#[cfg(feature = "...")]gating. This ensures verification is never accidentally disabled - Backend coordination: The backend must implement the Merkle tree builder to generate proofs before the
set_impact_merkle_rootfunction is called. Theverify_impactfunction can be called immediately by any client with a valid proof
- Backend Merkle tree builder: A service that periodically constructs Merkle trees from Postgres impact data and calls
set_impact_merkle_root - Proof distribution API: An API endpoint that returns a donor's Merkle proof for self-verification
- Multi-report archival: Lifecycle management for old reports (marking them as superseded, archiving)
- Batch verification: A function to verify multiple impact leaves in a single call (amortizes the per-call overhead)
- Impact certificate NFTs: Mint a soulbound NFT for donors who verify their impact, creating a permanent on-chain record of verified impact
- Cross-project aggregation: A Merkle root that spans multiple projects for platform-wide impact reporting
| File | Lines | Description |
|---|---|---|
contracts/indigopay-contract/src/lib.rs |
+532, −5 | ImpactLeaf struct, DataKey variant, Merkle helpers, contract methods, getter, 6 tests |
- Issue: #382 — Implement On-Chain Impact Certificate with Merkle Proof Verification
- Soroban SDK:
env.crypto().sha256()for SHA-256 hashing - Existing pattern:
DataKey::DonationCO2Offsetfor per-donation CO₂ tracking - Security: ADR-004 CEI pattern (
docs/adr/ADR-004-cei-pattern.md) - Upgrade compatibility:
contracts/indigopay-contract/UPGRADE.md - CI workflow:
.github/workflows/contracts.yml