Skip to content

Commit dcedf75

Browse files
committed
fix: enforce UIVK uniqueness at merchant registration
Reject duplicate viewing keys before INSERT — the DB UNIQUE constraint on the encrypted ufvk column was ineffective (AES-GCM random nonce produces different ciphertext each time). Check via deterministic payment_address instead. Returns 409 Conflict with clear error message.
1 parent 5a5a844 commit dcedf75

3 files changed

Lines changed: 27 additions & 5 deletions

File tree

ROADMAP.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,14 @@ Privacy-preserving Zcash payment gateway. Non-custodial, shielded-only.
9696
- [x] **@cipherpay/mcp** — MCP server for Claude/Cursor (invoices, rates, x402 verify, sessions)
9797
- [x] **Agent sessions** — prepaid credit system: deposit ZEC, get bearer token, pay per-request
9898
- [x] **Agent wallet CLI** (`@cipherpay/zipher-cli`) — headless Zcash wallet for agents (pay, sessions, x402, MPP)
99+
- [x] **UIVK uniqueness enforcement** — reject registration if viewing key already belongs to a merchant (prevents duplicate scanning, double billing, cross-merchant payment confusion). Applies to both dashboard and programmatic registration.
99100
- [ ] **Programmatic merchant registration** — agents create their own merchant accounts via API
100101
- `POST /api/merchants/register` with `{ ufvk, payment_address }`
101102
- Requires ~$10 USD deposit in shielded ZEC (anti-spam)
102103
- Deposit split: portion kept as CipherPay activation fee, remainder credited to merchant fee balance
103-
- Returns `{ merchant_id, api_key }` — no dashboard, no password
104+
- Returns `{ merchant_id, api_key }` — no dashboard, no password, API-only
105+
- Agent merchants have no dashboard access by design (no credentials to leak via prompt injection)
106+
- If human wants dashboard: register normally, hand API key to agent
104107
- Enables fully autonomous agent-to-agent commerce
105108
- Rate limited + UFVK validation before scanner activation
106109
- [ ] **@cipherpay/wallet-mcp** — MCP server wrapping `zipher-cli` so AI agents can send ZEC

src/api/merchants.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ pub async fn create(
1717
match create_merchant(pool.get_ref(), &body, &config.encryption_key).await {
1818
Ok(resp) => HttpResponse::Created().json(resp),
1919
Err(e) => {
20-
tracing::error!(error = %e, "Failed to create merchant");
21-
HttpResponse::InternalServerError().json(serde_json::json!({
22-
"error": "Failed to create merchant"
23-
}))
20+
let msg = e.to_string();
21+
if msg.contains("already registered") {
22+
HttpResponse::Conflict().json(serde_json::json!({
23+
"error": msg
24+
}))
25+
} else {
26+
tracing::error!(error = %e, "Failed to create merchant");
27+
HttpResponse::InternalServerError().json(serde_json::json!({
28+
"error": "Failed to create merchant"
29+
}))
30+
}
2431
}
2532
}
2633
}

src/merchants/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ pub async fn create_merchant(
8181
.map_err(|e| anyhow::anyhow!("Invalid viewing key — could not derive address: {}", e))?;
8282
let payment_address = derived.ua_string;
8383

84+
// Reject if this viewing key is already registered (same UIVK = same derived address)
85+
let existing: Option<(String,)> = sqlx::query_as(
86+
"SELECT id FROM merchants WHERE payment_address = ?"
87+
)
88+
.bind(&payment_address)
89+
.fetch_optional(pool)
90+
.await?;
91+
92+
if existing.is_some() {
93+
anyhow::bail!("A merchant with this viewing key is already registered");
94+
}
95+
8496
let id = Uuid::new_v4().to_string();
8597
let api_key = generate_api_key();
8698
let key_hash = hash_key(&api_key);

0 commit comments

Comments
 (0)