Skip to content

feat(payments): bind zkEmail receipt commitments to payments (#119) - #132

Merged
DioChuks merged 3 commits into
BuidlZone-Labs:mainfrom
josephchimebuka:feat/zkemail-receipt-commitment
Jun 27, 2026
Merged

feat(payments): bind zkEmail receipt commitments to payments (#119)#132
DioChuks merged 3 commits into
BuidlZone-Labs:mainfrom
josephchimebuka:feat/zkemail-receipt-commitment

Conversation

@josephchimebuka

@josephchimebuka josephchimebuka commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Linked issue

Closes #119


What this PR does

The payments contract can now bind an optional zkEmail receipt commitment to each payment record. A buyer (or relayer on their behalf) supplies a salted hash of their email — e.g. H(email || ticket_id) — computed entirely off-chain. The hash is stored on the payment record so an off-chain relayer can verify delivery eligibility without ever putting the raw email on-chain. The field is optional: payments without a commitment proceed normally for fully anonymous attendees.

Two integration paths are supported:

  1. At payment time via the new pay_for_ticket_with_commitment entrypoint (when the commitment is already known).
  2. After payment via bind_email_commitment (when the commitment must be salted with ticket_id, which is only assigned once the payment is created).

Existing pay_for_ticket and pay_for_ticket_with_options entrypoints are unchanged and delegate with None, so no cross-contract callers break.


Change type

  • New contract entrypoint
  • Struct / storage change
  • Event / emission change
  • Cross-contract interface change
  • Bug fix
  • Refactor / deprecation
  • Test-only change
  • Documentation change

Storage impact

Field Before After Notes
PaymentRecord.zk_email_commitment N/A Option<BytesN<32>> Optional salted email hash; None = anonymous / opted out

Is this a breaking storage change?

  • No — new optional field with a default
  • No — additive only, existing records unaffected
  • Yes — existing stored data must be migrated (attach migration plan below)

On-chain vs. off-chain behaviour

Claim Storage level Event level Notes / gaps
Commitment stored per payment N/A PaymentRecord.zk_email_commitment
Raw email never stored Only a 32-byte hash is accepted
Commitment never emitted ReceiptCommitmentBound emits only ids + timestamp; positive-control test proves commitment hex never appears in any event
Optional — anonymous payments work None commitment; existing entrypoints unchanged
Relayer can verify eligibility N/A get_payment_commitment + verify_email_commitment

Cross-contract impact

  • No cross-contract interface was changed
  • Yes — changed: <!-- fn name --> in <!-- contract -->

Existing cross-contract callers (event contract → pay_for_ticket) continue to work unchanged because the new field defaults to None inside create_payment.


Privacy checklist

  • Raw wallet addresses are not stored where a commitment or nullifier is appropriate
  • Emitted events contain no fields that can be cross-referenced to derive identity
  • The refund / reversal path preserves the privacy level of the original action
  • zkPassport nullifiers (if used) are stored to prevent proof reuse across events
  • zkEmail commitments (if used) store only the hash, never the raw address
  • N/A — this PR does not touch any privacy-sensitive flow

Note: the legacy email_hash field on PaymentReceiptRequested still emits its hash (pre-existing behaviour). The new zk_email_commitment is deliberately not emitted.


Security checklist

  • No new entrypoint is callable without appropriate auth (require_auth / admin check)
  • Integer arithmetic uses checked ops or Soroban's safe primitives — no silent overflow
  • Any new capacity or supply check cannot be bypassed by batching calls in one tx
  • Escrow / withdrawal logic enforces correct state transitions
  • No free-event attendance path can be drained by a single caller without a commitment scheme
  • N/A — this PR does not touch auth, arithmetic, capacity, or escrow

bind_email_commitment requires payer auth, is write-once, and is rejected after refund.


Test coverage

New tests added:

Test name What it actually proves
test_pay_with_commitment_stores_and_reads_back Commitment supplied at payment time is persisted and retrievable
test_commitment_is_optional Payment proceeds with None commitment
test_bind_commitment_after_payment Post-payment binding works (ticket_id-salted flow)
test_bind_commitment_is_write_once Second bind rejected with CommitmentAlreadySet
test_bind_commitment_requires_payer_ownership Non-payer cannot bind
test_bind_commitment_rejected_after_refund Refunded payment cannot accept commitment
test_verify_email_commitment_matches_and_mismatches Relayer verification path works
test_verify_returns_false_when_no_commitment Verify returns false when unset
test_commitment_is_stored_but_never_emitted Positive control: legacy receipt hash IS emitted; zkEmail commitment is NOT
test_bind_event_does_not_leak_commitment ReceiptCommitmentBound event carries no hash

Edge cases covered:

  • The "happy path" for each new entrypoint
  • Rejection of invalid state transitions
  • Boundary values (zero price, max capacity, min/max ledger windows)
  • The specific attack or misuse scenario described in the linked issue

Test count: 10 new, 96 total in payments-contract; 74 event-contract tests still pass.


Acceptance criteria sign-off

  • AC: Payments contract optionally stores a zk_email_commitment: Option<BytesN<32>> per payment record
    • Satisfied by: PaymentRecord.zk_email_commitment in types.rs; test_pay_with_commitment_stores_and_reads_back
  • AC: User provides commitment at payment time (hash of email address salted with ticket_id)
    • Satisfied by: pay_for_ticket_with_commitment + bind_email_commitment; test_bind_commitment_after_payment
  • AC: Commitment is stored — raw email is never stored or emitted in events
    • Satisfied by: only BytesN<32> accepted; test_commitment_is_stored_but_never_emitted, test_bind_event_does_not_leak_commitment
  • AC: Off-chain relayer can verify commitment to prove delivery eligibility without exposing the email
    • Satisfied by: get_payment_commitment, verify_email_commitment; test_verify_email_commitment_matches_and_mismatches
  • AC: Commitment field is optional — payment proceeds without it for fully anonymous attendees
    • Satisfied by: existing entrypoints pass None; test_commitment_is_optional

What this PR deliberately does NOT cover

  • Relayer-side email hashing implementation (off-chain; contract accepts the precomputed hash)
  • Wiring the event contract to call pay_for_ticket_with_commitment (callers can adopt incrementally)
  • Migrating or deprecating the legacy email_hash / PaymentReceiptRequested emission path

Reviewer focus areas

  1. Confirm zk_email_commitment is never emitted — only stored (see privacy tests with positive control against legacy email_hash emission).
  2. Review write-once semantics on bind_email_commitment (payer auth, refund guard).
  3. Confirm existing pay_for_ticket callers remain unaffected (additive optional field only).

Checklist

  • My code follows the project's style guidelines
  • I have run cargo fmt and cargo clippy
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published

Test plan

  • cargo fmt -p payments-contract
  • cargo clippy -p payments-contract --all-targets -- -D warnings
  • cargo test -p payments-contract -p event-contract --offline
  • cargo build -p payments-contract --release --offline

Made with Cursor

Summary by CodeRabbit

  • New Features
    • Payments can now optionally include and store a receipt commitment when a ticket is purchased.
    • Added an entrypoint to bind a commitment after payment, plus APIs to retrieve and verify the stored commitment.
    • Emits a receipt-commitment-bounded event (without exposing commitment contents).
  • Bug Fixes
    • Commitment binding is restricted to the original payer and is blocked after refunds.
    • Enforces write-once behavior with clearer errors when a commitment is already set or not allowed.

…ne-Labs#119)

Payments can now carry an optional zkEmail receipt commitment — a salted
hash of the buyer's email (e.g. H(email || ticket_id)) computed off-chain.
The commitment binds off-chain delivery eligibility to the on-chain payment
record while never exposing the raw email.

- PaymentRecord gains zk_email_commitment: Option<BytesN<32>> (stored, never
  emitted). None = fully anonymous attendee.
- New pay_for_ticket_with_commitment entrypoint accepts a commitment at
  payment time; existing pay_for_ticket / pay_for_ticket_with_options are
  unchanged (delegate with None) so no cross-contract caller breaks.
- New bind_email_commitment(payer, payment_id, commitment): payer-owned,
  write-once binding for the ticket_id-salted case (ticket_id is only known
  after payment). Rejected once set and after refund.
- New get_payment_commitment getter and verify_email_commitment view let an
  off-chain relayer prove delivery eligibility without revealing the email.
- ReceiptCommitmentBound event carries only payment/event ids + timestamp;
  the commitment hash is never published.

10 new tests (96 total in payments) incl. a positive-control check proving
the commitment never appears in any emitted event.

Co-authored-by: Cursor <cursoragent@cursor.com>
@DioChuks
DioChuks self-requested a review June 25, 2026 20:17
@DioChuks

Copy link
Copy Markdown
Contributor

@josephchimebuka hi pls resolve the merge conflict

Resolve PaymentError enum conflict by keeping postponement errors (33–34)
and assigning zkEmail commitment errors to 35–36.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a647a199-a344-4875-83ec-b6edfb97fa2d

📥 Commits

Reviewing files that changed from the base of the PR and between 5c777d4 and 1c402ef.

📒 Files selected for processing (2)
  • contracts/payments/src/lib.rs
  • contracts/payments/src/receipt_commitment_test.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • contracts/payments/src/receipt_commitment_test.rs
  • contracts/payments/src/lib.rs

📝 Walkthrough

Walkthrough

Adds optional zkEmail receipt commitment support to the payments contract. Payment records can now store a zk_email_commitment, new entrypoints can create, bind, read, and verify it, and a new event records binding without exposing the commitment value.

zkEmail Receipt Commitment

Layer / File(s) Summary
PaymentRecord and errors
contracts/payments/src/types.rs, contracts/payments/src/errors.rs
PaymentRecord gains zk_email_commitment: Option<BytesN<32>>; PaymentError gains CommitmentAlreadySet = 35 and CommitmentNotAllowed = 36.
Commitment event and storage wiring
contracts/payments/src/events.rs, contracts/payments/src/lib.rs
ReceiptCommitmentBound is added and emitted; PaymentParams carries the commitment field; create_payment stores it; existing ticket-payment paths pass None.
Commitment entrypoints
contracts/payments/src/lib.rs
pay_for_ticket_with_commitment, bind_email_commitment, get_payment_commitment, and verify_email_commitment are added, and the test module is wired in.
Receipt commitment tests
contracts/payments/src/receipt_commitment_test.rs
Tests cover payment-time storage, optional omission, binding, write-once and payer checks, refund rejection, verification, missing-payment lookup, and event privacy.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • DioChuks

🐇 A tiny hash in a contract nest,
Bound to a payment, then put to the test.
No email in events, just silence and care,
The bunny hops onward with zk-zen flair. 🎟️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: binding zkEmail receipt commitments to payments.
Description check ✅ Passed The description covers the required template sections and the key behavior, storage, privacy, and test details.
Linked Issues check ✅ Passed The changes satisfy #119 by storing an optional commitment, supporting verification, hiding raw email, and preserving anonymous payments.
Out of Scope Changes check ✅ Passed The new errors, event, getters, verifier, and tests all support the commitment hook and do not appear unrelated.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 1

🤖 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/payments/src/lib.rs`:
- Around line 1617-1620: The get_payment_commitment API is collapsing storage
errors and missing payments into None via .ok().and_then(...), which hides
PaymentNotFound and future decode failures. Update get_payment_commitment in
contracts/payments/src/lib.rs to return Result<Option<BytesN<32>>, PaymentError>
and propagate storage::get_payment from storage::get_payment instead of
discarding the error, while preserving None only for the real “payer opted out”
case.
🪄 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: 2673fb73-1e36-4900-92d2-ee0824776c1d

📥 Commits

Reviewing files that changed from the base of the PR and between a1b3246 and 5c777d4.

📒 Files selected for processing (5)
  • contracts/payments/src/errors.rs
  • contracts/payments/src/events.rs
  • contracts/payments/src/lib.rs
  • contracts/payments/src/receipt_commitment_test.rs
  • contracts/payments/src/types.rs

Comment thread contracts/payments/src/lib.rs Outdated
Return Result<Option<BytesN<32>>, PaymentError> so relayers can
distinguish a missing payment from a payer who opted out of a commitment.

Co-authored-by: Cursor <cursoragent@cursor.com>
@DioChuks
DioChuks merged commit 710bfcd into BuidlZone-Labs:main Jun 27, 2026
5 checks passed
josephchimebuka added a commit to josephchimebuka/zicket-contract that referenced this pull request Jun 27, 2026
Resolve conflicts with postponement, anon-claims, and merged PR BuidlZone-Labs#132
(zkEmail commitments). Keep both revenue-split and commitment APIs with
non-overlapping PaymentError codes (35–42).

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

Implement zkEmail receipt commitment hook in payments contract

2 participants