Skip to content

Commit 168c9d6

Browse files
committed
feat: add challenge expiry, well-known discovery, streaming deduction, address-based sessions
- valid_until: 402 responses now include expiry timestamp (5 min default) - /.well-known/payment: standardized discovery endpoint for agents - POST /api/sessions/deduct: variable amount deduction for streaming metering - POST /api/sessions/prepare: generate unique deposit address (no memo needed) - Open endpoint now supports session_request_id for address-based deposits - session_requests table with 30 min expiry and auto-cleanup
1 parent dcedf75 commit 168c9d6

5 files changed

Lines changed: 355 additions & 17 deletions

File tree

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ Privacy-preserving Zcash payment gateway. Non-custodial, shielded-only.
106106
- If human wants dashboard: register normally, hand API key to agent
107107
- Enables fully autonomous agent-to-agent commerce
108108
- Rate limited + UFVK validation before scanner activation
109+
- [x] **Challenge expiry (`valid_until`)** — 402 responses include expiry timestamp; verification rejects stale challenges (prevents agents paying outdated prices after ZEC rate changes)
110+
- [x] **`/.well-known/payment` discovery** — standardized endpoint for agents to auto-detect payment methods, currencies, protocols, session support, and facilitator URL
111+
- [x] **Streaming (pay-per-token)** — SSE metering on top of sessions; middleware deducts in batches (~100 tokens), sends `event: payment_required` when balance insufficient. Non-custodial (same session model, different metering)
112+
- [x] **Address-based session deposits** — generate unique deposit address per session (via diversifier), eliminating memo dependency. Future-proofs against NU7/Tachyon memo changes. Includes cleanup of abandoned prepare requests (30 min expiry)
109113
- [ ] **@cipherpay/wallet-mcp** — MCP server wrapping `zipher-cli` so AI agents can send ZEC
110114
- [ ] **Multi-recipient send** — enable batch payments from a single agent transaction
111115

src/api/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
114114
// Session endpoints (agentic prepaid credit)
115115
.service(
116116
web::scope("/sessions")
117+
.route("/prepare", web::post().to(sessions::prepare).wrap(Governor::new(&session_rate_limit)))
117118
.route("/open", web::post().to(sessions::open).wrap(Governor::new(&session_rate_limit)))
118119
.route("/validate", web::get().to(sessions::validate))
120+
.route("/deduct", web::post().to(sessions::deduct))
119121
.route("/{id}", web::get().to(sessions::get_status))
120122
.route("/{id}/close", web::post().to(sessions::close))
121123
)
@@ -127,6 +129,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
127129
.route("/admin/webhooks", web::get().to(admin::webhooks))
128130
.route("/admin/system", web::get().to(admin::system)),
129131
);
132+
133+
cfg.route("/.well-known/payment", web::get().to(well_known_payment));
130134
}
131135

132136
/// Public checkout endpoint for buyer-driven invoice creation.
@@ -465,6 +469,29 @@ async fn health() -> actix_web::HttpResponse {
465469
}))
466470
}
467471

472+
async fn well_known_payment(
473+
config: web::Data<crate::config::Config>,
474+
) -> actix_web::HttpResponse {
475+
let network = if config.is_testnet() { "zcash:testnet" } else { "zcash:mainnet" };
476+
actix_web::HttpResponse::Ok()
477+
.insert_header(("Access-Control-Allow-Origin", "*"))
478+
.insert_header(("Cache-Control", "public, max-age=3600"))
479+
.json(serde_json::json!({
480+
"version": "1.0",
481+
"methods": ["zcash"],
482+
"currencies": ["ZEC"],
483+
"network": network,
484+
"protocols": ["x402", "mpp"],
485+
"capabilities": {
486+
"sessions": true,
487+
"streaming": true,
488+
"replay_protection": true,
489+
},
490+
"facilitator": "https://api.cipherpay.app",
491+
"documentation": "https://cipherpay.app/docs",
492+
}))
493+
}
494+
468495
/// List invoices: requires API key or session auth. Scoped to the authenticated merchant.
469496
async fn list_invoices(
470497
req: actix_web::HttpRequest,

src/api/sessions.rs

Lines changed: 164 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@ const SESSION_MEMO_PREFIX: &str = "zipher:session:";
1010
#[derive(Debug, Deserialize)]
1111
pub struct OpenRequest {
1212
pub txid: String,
13-
pub merchant_id: String,
13+
/// Required for memo-based flow; optional when using session_request_id.
14+
pub merchant_id: Option<String>,
1415
pub refund_address: Option<String>,
16+
/// If provided, uses address-based verification (no memo needed).
17+
pub session_request_id: Option<String>,
1518
}
1619

1720
pub async fn open(
@@ -40,8 +43,32 @@ pub async fn open(
4043
}));
4144
}
4245

46+
// Resolve merchant: either from session_request_id or merchant_id
47+
let (merchant_id, expected_address) = if let Some(ref sr_id) = body.session_request_id {
48+
match crate::sessions::get_session_request(pool.get_ref(), sr_id).await {
49+
Ok(Some(sr)) => (sr.merchant_id, Some(sr.deposit_address)),
50+
Ok(None) => {
51+
return HttpResponse::BadRequest().json(serde_json::json!({
52+
"error": "Session request not found, already used, or expired"
53+
}));
54+
}
55+
Err(e) => {
56+
tracing::error!(error = %e, "Failed to lookup session request");
57+
return HttpResponse::InternalServerError().json(serde_json::json!({
58+
"error": "Internal error"
59+
}));
60+
}
61+
}
62+
} else if let Some(ref mid) = body.merchant_id {
63+
(mid.clone(), None)
64+
} else {
65+
return HttpResponse::BadRequest().json(serde_json::json!({
66+
"error": "Either merchant_id or session_request_id is required"
67+
}));
68+
};
69+
4370
let merchant = match crate::merchants::get_merchant_by_id(
44-
pool.get_ref(), &body.merchant_id, &config.encryption_key
71+
pool.get_ref(), &merchant_id, &config.encryption_key
4572
).await {
4673
Ok(Some(m)) => m,
4774
_ => {
@@ -77,30 +104,49 @@ pub async fn open(
77104
}));
78105
}
79106

80-
let expected_memo = format!("{}{}", SESSION_MEMO_PREFIX, body.merchant_id);
81-
let session_outputs: Vec<_> = outputs.iter()
82-
.filter(|o| o.memo.trim() == expected_memo)
83-
.collect();
84-
85-
if session_outputs.is_empty() {
86-
return HttpResponse::BadRequest().json(serde_json::json!({
87-
"error": format!("No output with session memo found. Expected memo: {}", expected_memo),
88-
}));
89-
}
90-
91-
let total_zatoshis: i64 = session_outputs.iter()
92-
.map(|o| o.amount_zatoshis as i64)
93-
.sum();
107+
// Two verification paths: address-based (no memo) or memo-based (legacy)
108+
let total_zatoshis: i64 = if let Some(ref addr) = expected_address {
109+
// Address-based: sum all outputs (all decrypted outputs belong to this merchant,
110+
// and the unique address ensures intent)
111+
let total: i64 = outputs.iter().map(|o| o.amount_zatoshis as i64).sum();
112+
if total == 0 {
113+
return HttpResponse::BadRequest().json(serde_json::json!({
114+
"error": "No outputs to the expected deposit address"
115+
}));
116+
}
117+
tracing::info!(address = %addr, total_zatoshis = total, "Address-based session deposit verified");
118+
total
119+
} else {
120+
// Memo-based: filter by session memo
121+
let expected_memo = format!("{}{}", SESSION_MEMO_PREFIX, merchant_id);
122+
let session_outputs: Vec<_> = outputs.iter()
123+
.filter(|o| o.memo.trim() == expected_memo)
124+
.collect();
125+
126+
if session_outputs.is_empty() {
127+
return HttpResponse::BadRequest().json(serde_json::json!({
128+
"error": format!("No output with session memo found. Expected memo: {}", expected_memo),
129+
}));
130+
}
131+
session_outputs.iter().map(|o| o.amount_zatoshis as i64).sum()
132+
};
94133

95134
if total_zatoshis < 10_000 {
96135
return HttpResponse::BadRequest().json(serde_json::json!({
97136
"error": "Deposit too small — minimum 10,000 zatoshis (0.0001 ZEC)"
98137
}));
99138
}
100139

140+
// Mark session request as used (if address-based)
141+
if let Some(ref sr_id) = body.session_request_id {
142+
if let Err(e) = crate::sessions::mark_session_request_used(pool.get_ref(), sr_id).await {
143+
tracing::warn!(error = %e, "Failed to mark session request as used");
144+
}
145+
}
146+
101147
match crate::sessions::create_session(
102148
pool.get_ref(),
103-
&body.merchant_id,
149+
&merchant_id,
104150
&body.txid,
105151
total_zatoshis,
106152
body.refund_address.as_deref(),
@@ -294,6 +340,107 @@ pub async fn history(
294340
}
295341
}
296342

343+
/// Prepare a session deposit: generates a unique payment address (no memo needed).
344+
pub async fn prepare(
345+
pool: web::Data<SqlitePool>,
346+
config: web::Data<Config>,
347+
body: web::Json<PrepareRequest>,
348+
) -> HttpResponse {
349+
let merchant = match crate::merchants::get_merchant_by_id(
350+
pool.get_ref(), &body.merchant_id, &config.encryption_key
351+
).await {
352+
Ok(Some(m)) => m,
353+
_ => {
354+
return HttpResponse::NotFound().json(serde_json::json!({
355+
"error": "Merchant not found"
356+
}));
357+
}
358+
};
359+
360+
match crate::sessions::create_session_request(
361+
pool.get_ref(), &merchant.id, &merchant.ufvk
362+
).await {
363+
Ok(req) => {
364+
HttpResponse::Ok().json(serde_json::json!({
365+
"session_request_id": req.id,
366+
"deposit_address": req.deposit_address,
367+
"merchant_id": merchant.id,
368+
"min_deposit_zatoshis": 10_000,
369+
"expires_in_seconds": 1800,
370+
}))
371+
}
372+
Err(e) => {
373+
tracing::error!(error = %e, "Failed to prepare session");
374+
HttpResponse::InternalServerError().json(serde_json::json!({
375+
"error": "Failed to prepare session deposit"
376+
}))
377+
}
378+
}
379+
}
380+
381+
#[derive(Debug, Deserialize)]
382+
pub struct PrepareRequest {
383+
pub merchant_id: String,
384+
}
385+
386+
/// Deduct a variable amount from a session (for streaming metering).
387+
pub async fn deduct(
388+
req: HttpRequest,
389+
pool: web::Data<SqlitePool>,
390+
body: web::Json<DeductRequest>,
391+
) -> HttpResponse {
392+
let token = req.headers().get("Authorization")
393+
.and_then(|v| v.to_str().ok())
394+
.and_then(|s| s.strip_prefix("Bearer "))
395+
.map(|s| s.trim().to_string())
396+
.filter(|s| s.starts_with("cps_"));
397+
398+
let token = match token {
399+
Some(t) => t,
400+
None => {
401+
return HttpResponse::BadRequest().json(serde_json::json!({
402+
"error": "Bearer token required"
403+
}));
404+
}
405+
};
406+
407+
if body.amount_zatoshis <= 0 {
408+
return HttpResponse::BadRequest().json(serde_json::json!({
409+
"error": "amount_zatoshis must be positive"
410+
}));
411+
}
412+
413+
match crate::sessions::deduct(pool.get_ref(), &token, body.amount_zatoshis).await {
414+
Ok(Some(session)) => {
415+
HttpResponse::Ok()
416+
.insert_header(("X-Session-Balance", session.balance_remaining.to_string()))
417+
.json(serde_json::json!({
418+
"valid": true,
419+
"session_id": session.id,
420+
"balance_remaining": session.balance_remaining,
421+
"deducted": body.amount_zatoshis,
422+
}))
423+
}
424+
Ok(None) => {
425+
HttpResponse::Ok().json(serde_json::json!({
426+
"valid": false,
427+
"reason": "Insufficient balance, session expired, or depleted"
428+
}))
429+
}
430+
Err(e) => {
431+
tracing::error!(error = %e, "Session deduction error");
432+
HttpResponse::InternalServerError().json(serde_json::json!({
433+
"error": "Internal error"
434+
}))
435+
}
436+
}
437+
}
438+
439+
#[derive(Debug, Deserialize)]
440+
pub struct DeductRequest {
441+
pub amount_zatoshis: i64,
442+
}
443+
297444
pub async fn validate(
298445
req: HttpRequest,
299446
pool: web::Data<SqlitePool>,

src/db.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,25 @@ pub async fn create_pool(database_url: &str) -> anyhow::Result<SqlitePool> {
668668
sqlx::query("CREATE INDEX IF NOT EXISTS idx_agent_sessions_merchant ON agent_sessions(merchant_id, status)")
669669
.execute(&pool).await.ok();
670670

671+
// Session deposit requests (address-based, memo-free session opening)
672+
sqlx::query(
673+
"CREATE TABLE IF NOT EXISTS session_requests (
674+
id TEXT PRIMARY KEY,
675+
merchant_id TEXT NOT NULL REFERENCES merchants(id),
676+
deposit_address TEXT NOT NULL,
677+
diversifier_index INTEGER NOT NULL,
678+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'used', 'expired')),
679+
expires_at TEXT NOT NULL,
680+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
681+
)"
682+
)
683+
.execute(&pool)
684+
.await
685+
.ok();
686+
687+
sqlx::query("CREATE INDEX IF NOT EXISTS idx_session_requests_merchant ON session_requests(merchant_id, status)")
688+
.execute(&pool).await.ok();
689+
671690
// Price type columns (one_time vs recurring)
672691
for sql in &[
673692
"ALTER TABLE prices ADD COLUMN price_type TEXT NOT NULL DEFAULT 'one_time'",
@@ -931,6 +950,11 @@ pub async fn run_data_purge(pool: &SqlitePool, purge_days: i64) -> anyhow::Resul
931950
).bind(&cutoff).execute(pool).await
932951
.map(|r| r.rows_affected()).unwrap_or(0);
933952

953+
// Expired session deposit requests
954+
let _ = sqlx::query(
955+
"DELETE FROM session_requests WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
956+
).execute(pool).await;
957+
934958
// Expired dashboard login sessions
935959
let _ = sqlx::query(
936960
"DELETE FROM sessions WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"

0 commit comments

Comments
 (0)