Skip to content

feat: add privacy/commitments access-registry to marketplace - #10

Open
Mrwicks00 wants to merge 1 commit into
Stellar-AgentVerse:mainfrom
Mrwicks00:feat/privacy-commitments-access-registry
Open

feat: add privacy/commitments access-registry to marketplace#10
Mrwicks00 wants to merge 1 commit into
Stellar-AgentVerse:mainfrom
Mrwicks00:feat/privacy-commitments-access-registry

Conversation

@Mrwicks00

Copy link
Copy Markdown

feat: Anonymous Commitment-Based Access Registry

Summary

Implements the privacy architecture described in issue #2.

Replaces the DataKey::Purchase(Address, String) → bool pattern — which leaks the buyer-to-prompt relationship in plain storage — with an anonymous commitment scheme consisting of three components:

  • Policy commitments — admin stores only SHA256(prompt_policy ∥ ciphertext_ref ∥ expiration ∥ salt); content_uri and prompt identity never touch the ledger in the private flow
  • Merkle accumulator root — a SHA256-chained root updated on every issue_access call; each purchase produces an opaque leaf (SHA256(policy_id_bytes ∥ session_commitment)) with no buyer address in storage
  • Nullifier setconsume(nullifier) marks a credential as spent; used(nullifier) checks without exposing which prompt was consumed

Files Changed

File Change
contracts/marketplace/src/privacy/mod.rs New — privacy module entry point
contracts/marketplace/src/privacy/types.rs New — Bytes32 = BytesN<32> type alias
contracts/marketplace/src/storage/types.rs Added PolicyData struct; added PolicyCounter, Policy(u64), AccessRoot, Nullifier(BytesN<32>) to DataKey
contracts/marketplace/src/contract.rs Added 3 new events + 5 new public functions + 2 internal helpers
contracts/marketplace/src/lib.rs Exposed pub mod privacy
contracts/marketplace/src/tests.rs 10 new privacy storage tests

New API

AccessRegistry trait (contract functions)

// Admin-only: store only the commitment hash + price. Returns monotonic policy_id.
pub fn register_policy(e: &Env, policy_commitment: Bytes32, price: i128) -> u64

// Admin-only: updates the Merkle accumulator and returns the opaque leaf.
// No buyer address or prompt_id is written to storage.
pub fn issue_access(e: &Env, policy_id: u64, session_commitment: Bytes32) -> Bytes32

// Public getter for the current Merkle accumulator root.
pub fn root(e: &Env) -> Bytes32

AccessNullifiers trait (contract functions)

// Permissionless: marks nullifier as consumed. Panics on reuse.
pub fn consume(e: &Env, nullifier: Bytes32)

// Public getter: true if nullifier has been consumed.
pub fn used(e: &Env, nullifier: Bytes32) -> bool

Acceptance Criteria

Criterion Status
content_uri not stored in clear for private flow PolicyData stores only commitment: BytesN<32> + price
Contract emits only opaque commitments PolicyRegistered, AccessIssued, NullifierConsumed — no plaintext content
issue_access does not expose wallet + prompt in public storage ✅ Only AccessRoot and leaf derivation; no (Address, String) key
consume(nullifier) blocks reuse ✅ Second call panics with "nullifier already used"
used(nullifier) works correctly ✅ Returns false before consume, true after
Storage tests pass ✅ 27/27 tests pass (17 existing + 10 new)

Design Notes

Two flows coexist: The existing public marketplace (register_prompt, buy_prompt, has_access) is unchanged for backward compatibility. The new private flow is opt-in via register_policy / issue_access.

Merkle accumulator: new_root = SHA256(old_root ∥ leaf) is a simple hash-chain accumulator. It does not support Merkle proofs in the classical sense, but it establishes a commitment to the set of issued leaves. A full sparse Merkle tree would require off-chain construction and on-chain root verification, which is out of scope for this issue.

Auth model: issue_access is admin-gated. The admin verifies off-chain that the buyer paid (e.g., via a token-burn tx), then calls issue_access(policy_id, session_commitment) where session_commitment is supplied by the buyer. This prevents the contract from ever writing (buyer_address, prompt_id) to storage.

Unlinkability: Two purchases of the same policy with different session_commitment values produce distinct, unrelated leaves — confirmed by test_issue_access_storage_is_opaque.

Closes #2

Replace the buyer->prompt_id storage pattern with an anonymous
commitment scheme. Introduces a PolicyData struct keyed by opaque u64
id, a SHA256-based Merkle accumulator root updated on each access
issuance, and a nullifier set that prevents reuse without revealing
which prompt was consumed.

New DataKey variants: PolicyCounter, Policy(u64), AccessRoot,
Nullifier(BytesN<32>). New contract functions: register_policy,
issue_access, root, consume, used. New events: PolicyRegistered,
AccessIssued, NullifierConsumed. New privacy module at
contracts/marketplace/src/privacy/ with Bytes32 type alias.

27 tests pass (17 existing + 10 new privacy storage tests).

Closes Stellar-AgentVerse#2
@Mrwicks00

Copy link
Copy Markdown
Author

@Joaco2603

@Joaco2603

Copy link
Copy Markdown
Collaborator

Thanks for the PR. The overall direction is good, and I think the feature introduces useful building blocks for a privacy/access registry. However, I don't think this is merge-ready yet because several of the security and protocol guarantees implied by the API are not actually enforced.

The main concern is that the contract currently stores privacy-related primitives, but does not fully enforce the properties those primitives are expected to guarantee.

1. consume() is publicly callable without authentication or proof validation

consume() can currently be called by anyone and does not require authentication or proof verification. This means a third party can mark a valid nullifier as consumed before the legitimate user, creating a frontrunning/DoS vector.

I would expect consume() to validate ownership or proof of knowledge before persisting the nullifier as used. If the proof system is not implemented yet, I would avoid exposing this as a production endpoint.

2. issue_access() does not enforce payment or proof requirements

issue_access() verifies that a policy exists, but it does not verify payment, burn tokens, validate a proof, or otherwise enforce the conditions implied by the policy.

If payment settlement is intentionally handled off-chain, I think the API should explicitly communicate that assumption (for example through naming or documentation). Otherwise, the current behavior suggests stronger guarantees than the contract actually provides.

3. PolicyData.commitment and PolicyData.price are stored but not enforced

Both fields are persisted, but neither currently participates in access enforcement.

As implemented today, PolicyData functions mostly as metadata. Either these fields should become part of the authorization/payment flow, or they should be removed until they are actively enforced by the protocol.

4. The accumulator is not a Merkle structure

The current root update mechanism:

sha256(root || leaf)

creates a hash chain rather than a Merkle tree or incremental Merkle accumulator.

This distinction matters because standard membership proofs cannot be constructed from the current structure. I would either replace this with a proof-capable accumulator or avoid referring to it as a Merkle accumulator until those guarantees exist.

5. Duplicate leaf issuance is not prevented

The leaf is deterministically derived from:

hash(policy_id || session_commitment)

If the same inputs are submitted multiple times, the same leaf can be issued repeatedly while still modifying the root.

This creates ambiguous accumulator semantics and may lead to synchronization issues between clients and contract state. I would recommend either preventing duplicate issuance or introducing a nonce/index with clearly defined proof semantics.

6. Nullifier scope should be explicitly defined

The current implementation treats nullifiers as globally unique, but the contract does not clearly define whether replay protection is intended to be global or policy-scoped.

This is not only a storage design decision—it directly affects the privacy model. A global nullifier space can enable correlation across policies, while scoped nullifiers provide different privacy guarantees.

The intended behavior should be explicitly documented and covered by tests.

7. Missing adversarial tests

The current tests demonstrate that storage updates correctly, but they do not validate the protocol's security properties.

I would strongly recommend adding tests covering:

  • Nullifier frontrunning/DoS scenarios.
  • Duplicate leaf issuance.
  • Access issuance without payment enforcement.
  • Unused policy commitments.
  • Root evolution after duplicate issuance.
  • Nullifier scope behavior (global vs policy-scoped).

Overall, I think the implementation is a good foundation, but the current version does not yet demonstrate the privacy and access guarantees that the API suggests.

The tests passing is a positive sign, but at the moment they mostly prove storage behavior rather than protocol correctness. For a feature in this area, I believe we need tests and enforcement mechanisms that clearly demonstrate the security properties the system is intended to provide before merging.

@Joaco2603

Copy link
Copy Markdown
Collaborator

@Mrwicks00

@Joaco2603 Joaco2603 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — PR #10

Thanks @Mrwicks00 for the implementation. The overall direction of adding privacy primitives is valuable, but I agree with the concerns already raised by Joaco2603 in the PR comments — this is not ready to merge in its current form.

Critical: protocol guarantees not enforced

The contract stores privacy primitives but does not enforce the security properties the API implies:

1. consume() is publicly callable without authentication or proof validation

Anyone can mark any nullifier as consumed before the legitimate user. This is a real frontrunning/DoS vector. Either:

  • Gate consume() behind proof-of-ownership (e.g., the caller must prove knowledge of the leaf/secret), or
  • Acknowledge this is a public "mark as spent" utility with no security guarantees, rename accordingly, and document the limitation.

2. issue_access() does not enforce payment or proofs

The function checks the policy exists but does not verify payment, validate the session commitment, or enforce any policy condition. The claim "payment verification happens off-chain" is fine, but the API surface implies stronger guarantees than the contract provides.

3. PolicyData.commitment and PolicyData.price are stored but not enforced

Neither field participates in access enforcement. price is validated to be positive on registration but never checked on issuance. commitment is stored but never verified against the leaf.

4. The "Merkle accumulator" is a hash chain

sha256(root || leaf)

This is not a Merkle tree or an incremental Merkle accumulator — it is a simple hash chain. Membership proofs cannot be constructed from this structure. Either implement a provable accumulator or rename/re-document accordingly.

5. Duplicate leaf issuance is not prevented

The same (policy_id, session_commitment) pair produces the same leaf, yet issue_access can be called multiple times with identical inputs, altering the root each time. This creates ambiguous accumulator semantics.

6. Nullifier scope is undefined

Are nullifiers global or policy-scoped? The current storage treats them as global (DataKey::Nullifier(BytesN<32>)), but this is not documented and has privacy implications (global nullifiers enable correlation across policies).

Missing tests

The test suite demonstrates storage behavior but does not validate protocol security:

  • No frontrunning/DoS tests for consume()
  • No duplicate leaf issuance tests
  • No tests verifying issue_access rejects when payment conditions are not met
  • No adversarial tests for unused/null policies
  • No nullifier scope tests

What I would keep

  • The Bytes32 type alias and privacy module structure
  • The register_policy function with admin gating
  • The event definitions

Veredicto: CHANGES_REQUESTED. The feature needs security enforcement or explicit documented limitations before merge. Happy to re-review once addressed.

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.

[Architecture/Crypto] Replace buyer -> prompt_id mappings with an anonymous Commitments structure

2 participants