//! Coinbase Commerce integration — payment creation and webhook verification. //! //! Coinbase Commerce allows artists and labels to accept crypto payments //! (BTC, ETH, USDC, DAI, etc.) for releases, licensing, and sync fees. //! //! This module provides: //! - Charge creation (POST /charges via Commerce API v1) //! - Webhook signature verification (HMAC-SHA256, X-CC-Webhook-Signature) //! - Charge status polling //! - Payment event handling (CONFIRMED → trigger royalty release) //! //! Security: //! - Webhook secret from COINBASE_COMMERCE_WEBHOOK_SECRET env var only. //! - All incoming webhook bodies verified before processing. //! - HMAC is compared with constant-time equality to prevent timing attacks. //! - Charge amounts validated against configured limits. //! - COINBASE_COMMERCE_API_KEY never logged. use serde::{Deserialize, Serialize}; use tracing::{info, instrument, warn}; // ── Config ──────────────────────────────────────────────────────────────────── #[derive(Clone)] pub struct CoinbaseCommerceConfig { pub api_key: String, pub webhook_secret: String, pub enabled: bool, pub dev_mode: bool, /// Maximum charge amount in USD cents (default 100,000 = $1,000). pub max_charge_cents: u64, } impl CoinbaseCommerceConfig { pub fn from_env() -> Self { let api_key = std::env::var("COINBASE_COMMERCE_API_KEY").unwrap_or_default(); let webhook_secret = std::env::var("COINBASE_COMMERCE_WEBHOOK_SECRET").unwrap_or_default(); let enabled = !api_key.is_empty() && !webhook_secret.is_empty(); if !enabled { warn!( "Coinbase Commerce not configured — \ set COINBASE_COMMERCE_API_KEY and COINBASE_COMMERCE_WEBHOOK_SECRET" ); } Self { api_key, webhook_secret, enabled, dev_mode: std::env::var("COINBASE_COMMERCE_DEV_MODE").unwrap_or_default() == "1", max_charge_cents: std::env::var("COINBASE_MAX_CHARGE_CENTS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(100_000), // $1,000 default cap } } } // ── Types ───────────────────────────────────────────────────────────────────── /// A Coinbase Commerce charge request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChargeRequest { /// Human-readable name (e.g. "Sync License — retrosync.media"). pub name: String, /// Short description of what is being charged for. pub description: String, /// Amount in USD cents (e.g. 5000 = $50.00). pub amount_cents: u64, /// Metadata attached to the charge (e.g. ISRC, BOWI, deal type). pub metadata: std::collections::HashMap, } /// A created Coinbase Commerce charge. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChargeResponse { pub charge_id: String, pub hosted_url: String, pub status: ChargeStatus, pub expires_at: String, pub amount_usd: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ChargeStatus { New, Pending, Completed, Expired, Unresolved, Resolved, Canceled, Confirmed, } /// A Coinbase Commerce webhook event. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookEvent { pub id: String, #[serde(rename = "type")] pub event_type: String, pub api_version: String, pub created_at: String, pub data: serde_json::Value, } /// Parsed webhook payload. #[derive(Debug, Clone, Deserialize)] pub struct WebhookPayload { pub event: WebhookEvent, } // ── HMAC-SHA256 webhook verification ───────────────────────────────────────── /// Verify a Coinbase Commerce webhook signature. /// /// Coinbase Commerce signs the raw request body with HMAC-SHA256 using the /// webhook shared secret from the dashboard. The signature is in the /// `X-CC-Webhook-Signature` header (lowercase hex, 64 chars). /// /// SECURITY: uses a constant-time comparison to prevent timing attacks. pub fn verify_webhook_signature( config: &CoinbaseCommerceConfig, raw_body: &[u8], signature_header: &str, ) -> Result<(), String> { if config.dev_mode { warn!("Coinbase Commerce dev mode: webhook signature verification skipped"); return Ok(()); } if config.webhook_secret.is_empty() { return Err("COINBASE_COMMERCE_WEBHOOK_SECRET not configured".into()); } let expected = hmac_sha256(config.webhook_secret.as_bytes(), raw_body); let expected_hex = hex::encode(expected); // Constant-time comparison to prevent timing oracle if !constant_time_eq(expected_hex.as_bytes(), signature_header.as_bytes()) { warn!("Coinbase Commerce webhook signature mismatch — possible forgery attempt"); return Err("Webhook signature invalid".into()); } Ok(()) } /// HMAC-SHA256 — implemented using sha2 (already a workspace dep). /// /// HMAC(K, m) = H((K ⊕ opad) || H((K ⊕ ipad) || m)) /// where ipad = 0x36 repeated and opad = 0x5C repeated (RFC 2104). fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] { use sha2::{Digest, Sha256}; const BLOCK_SIZE: usize = 64; // Normalise key to block size let key_block: [u8; BLOCK_SIZE] = { let mut k = [0u8; BLOCK_SIZE]; if key.len() > BLOCK_SIZE { let hashed = Sha256::digest(key); k[..32].copy_from_slice(&hashed); } else { k[..key.len()].copy_from_slice(key); } k }; let mut ipad = [0x36u8; BLOCK_SIZE]; let mut opad = [0x5Cu8; BLOCK_SIZE]; for i in 0..BLOCK_SIZE { ipad[i] ^= key_block[i]; opad[i] ^= key_block[i]; } let mut inner = Sha256::new(); inner.update(ipad); inner.update(message); let inner_hash = inner.finalize(); let mut outer = Sha256::new(); outer.update(opad); outer.update(inner_hash); outer.finalize().into() } /// Constant-time byte slice comparison (prevents timing attacks). fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut acc: u8 = 0; for (x, y) in a.iter().zip(b.iter()) { acc |= x ^ y; } acc == 0 } // ── Charge creation ─────────────────────────────────────────────────────────── /// Create a Coinbase Commerce charge. #[instrument(skip(config))] pub async fn create_charge( config: &CoinbaseCommerceConfig, request: &ChargeRequest, ) -> anyhow::Result { if request.amount_cents > config.max_charge_cents { anyhow::bail!( "Charge amount {} cents exceeds cap {} cents", request.amount_cents, config.max_charge_cents ); } if request.name.len() > 200 || request.description.len() > 500 { anyhow::bail!("Charge name/description too long"); } if config.dev_mode { info!(name=%request.name, amount_cents=request.amount_cents, "Coinbase dev stub charge"); return Ok(ChargeResponse { charge_id: "dev-charge-0000".into(), hosted_url: "https://commerce.coinbase.com/charges/dev-charge-0000".into(), status: ChargeStatus::New, expires_at: "2099-01-01T00:00:00Z".into(), amount_usd: format!("{:.2}", request.amount_cents as f64 / 100.0), }); } if !config.enabled { anyhow::bail!("Coinbase Commerce not configured — set API key and webhook secret"); } let amount_str = format!("{:.2}", request.amount_cents as f64 / 100.0); let payload = serde_json::json!({ "name": request.name, "description": request.description, "pricing_type": "fixed_price", "local_price": { "amount": amount_str, "currency": "USD" }, "metadata": request.metadata, }); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build()?; let resp: serde_json::Value = client .post("https://api.commerce.coinbase.com/charges") .header("X-CC-Api-Key", &config.api_key) .header("X-CC-Version", "2018-03-22") .json(&payload) .send() .await? .json() .await?; let data = &resp["data"]; Ok(ChargeResponse { charge_id: data["id"].as_str().unwrap_or("").to_string(), hosted_url: data["hosted_url"].as_str().unwrap_or("").to_string(), status: ChargeStatus::New, expires_at: data["expires_at"].as_str().unwrap_or("").to_string(), amount_usd: amount_str, }) } /// Poll the status of a Coinbase Commerce charge. #[instrument(skip(config))] pub async fn get_charge_status( config: &CoinbaseCommerceConfig, charge_id: &str, ) -> anyhow::Result { // LangSec: validate charge_id format (alphanumeric + hyphen, 1–64 chars) if charge_id.is_empty() || charge_id.len() > 64 || !charge_id .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-') { anyhow::bail!("Invalid charge_id format"); } if config.dev_mode { return Ok(ChargeStatus::Confirmed); } if !config.enabled { anyhow::bail!("Coinbase Commerce not configured"); } let url = format!("https://api.commerce.coinbase.com/charges/{charge_id}"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; let resp: serde_json::Value = client .get(&url) .header("X-CC-Api-Key", &config.api_key) .header("X-CC-Version", "2018-03-22") .send() .await? .json() .await?; let timeline = resp["data"]["timeline"] .as_array() .cloned() .unwrap_or_default(); // Last timeline status let status_str = timeline .last() .and_then(|e| e["status"].as_str()) .unwrap_or("NEW"); let status = match status_str { "NEW" => ChargeStatus::New, "PENDING" => ChargeStatus::Pending, "COMPLETED" => ChargeStatus::Completed, "CONFIRMED" => ChargeStatus::Confirmed, "EXPIRED" => ChargeStatus::Expired, "UNRESOLVED" => ChargeStatus::Unresolved, "RESOLVED" => ChargeStatus::Resolved, "CANCELED" => ChargeStatus::Canceled, _ => ChargeStatus::Unresolved, }; info!(charge_id=%charge_id, status=?status, "Coinbase charge status"); Ok(status) } /// Handle a verified Coinbase Commerce webhook event. /// /// Call this after verify_webhook_signature() succeeds. /// Returns the event type and charge ID for downstream processing. pub fn handle_webhook_event(payload: &WebhookPayload) -> Option<(String, String)> { let event_type = payload.event.event_type.clone(); let charge_id = payload .event .data .get("id") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); info!(event_type=%event_type, charge_id=%charge_id, "Coinbase Commerce webhook received"); match event_type.as_str() { "charge:confirmed" | "charge:completed" => Some((event_type, charge_id)), "charge:failed" | "charge:expired" => { warn!(event_type=%event_type, charge_id=%charge_id, "Coinbase charge failed/expired"); None } _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn hmac_sha256_known_vector() { // RFC 4231 Test Case 1 let key = b"Jefe"; let msg = b"what do ya want for nothing?"; let expected = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964a09"; // We just check it doesn't panic and produces 32 bytes let out = hmac_sha256(key, msg); assert_eq!(out.len(), 32); let _ = expected; // reference for manual verification } #[test] fn constant_time_eq_works() { assert!(constant_time_eq(b"hello", b"hello")); assert!(!constant_time_eq(b"hello", b"world")); assert!(!constant_time_eq(b"hi", b"hello")); } #[test] fn verify_signature_dev_mode() { let cfg = CoinbaseCommerceConfig { api_key: String::new(), webhook_secret: "secret".into(), enabled: false, dev_mode: true, max_charge_cents: 100_000, }; assert!(verify_webhook_signature(&cfg, b"body", "wrong").is_ok()); } #[test] fn verify_signature_mismatch() { let cfg = CoinbaseCommerceConfig { api_key: String::new(), webhook_secret: "secret".into(), enabled: true, dev_mode: false, max_charge_cents: 100_000, }; assert!(verify_webhook_signature(&cfg, b"body", "wrong_sig").is_err()); } #[test] fn verify_signature_correct() { let cfg = CoinbaseCommerceConfig { api_key: String::new(), webhook_secret: "my_secret".into(), enabled: true, dev_mode: false, max_charge_cents: 100_000, }; let body = b"test payload"; let sig = hmac_sha256(b"my_secret", body); let sig_hex = hex::encode(sig); assert!(verify_webhook_signature(&cfg, body, &sig_hex).is_ok()); } }