diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 0d66cada..649e5c2f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -72,6 +72,12 @@ services: STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + # Coinbase Business / CDP (new Payment Link API) + CDP_API_KEY_ID: ${CDP_API_KEY_ID:-} + CDP_API_KEY_SECRET: ${CDP_API_KEY_SECRET:-} + CDP_SANDBOX: ${CDP_SANDBOX:-true} + COINBASE_PAYMENT_LINK_WEBHOOK_SECRET: ${COINBASE_PAYMENT_LINK_WEBHOOK_SECRET:-} + LOCAL_TESTING_MODE: ${LOCAL_TESTING_MODE:-false} # Cognito authentication (ensure these are passed from .env.local) diff --git a/docs/coinbase-business-migration-backend-review.md b/docs/coinbase-business-migration-backend-review.md new file mode 100644 index 00000000..29a1fbf2 --- /dev/null +++ b/docs/coinbase-business-migration-backend-review.md @@ -0,0 +1,310 @@ +# Coinbase Commerce to Business Migration - Backend Review Checklist + +**Migration Deadline: March 31, 2026** +**Reference**: [Coinbase Transition Guide](https://help.coinbase.com/en/transitioning-from-coinbase-commerce-to-coinbase-business) + +--- + +## 1. Coinbase Dashboard / Account Setup + +- [ ] **Create Coinbase Business account** (or convert existing Commerce account) + - [Getting Started](https://docs.cdp.coinbase.com/coinbase-business/introduction/get-started) +- [ ] **Complete KYB (Know Your Business) verification** if not already done +- [ ] **Generate CDP Secret API Key** in the [CDP Portal](https://portal.cdp.coinbase.com/projects/api-keys) + - Go to the **Secret API Keys** tab and click **Create API key** + - Signature algorithm: Ed25519 (recommended) or ECDSA + - Save the **Key ID** (UUID) → `CDP_API_KEY_ID` + - Save the **Key Secret** (base64 string) → `CDP_API_KEY_SECRET` + - These are shown only once + +--- + +## 2. Environment Variables to Configure + +| Variable | Description | Where | +|---|---|---| +| `CDP_API_KEY_ID` | Secret API Key ID (UUID) from [CDP Portal](https://portal.cdp.coinbase.com/projects/api-keys) | All environments | +| `CDP_API_KEY_SECRET` | Secret API Key secret (base64) from CDP Portal | All environments (secrets manager) | +| `CDP_SANDBOX` | `true` for sandbox (no real transactions), `false` for production | Per environment | +| `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` | From webhook subscription metadata | All environments | + +### Variables to Keep (Legacy - Transition Period) + +| Variable | Reason | +|---|---| +| `COINBASE_COMMERCE_WEBHOOK_SECRET` | Legacy Commerce API — **kept during transition period** for backward compatibility | + +> **Note:** Legacy Commerce variables will be removed after migration is confirmed complete and all in-flight Commerce charges have settled. + +--- + +## 3. Webhook Configuration + +- [ ] **Register webhook endpoint** in Coinbase Business dashboard + - URL: `https:///api/v1/webhooks/coinbase` + - Content-Type: `application/json` +- [ ] **Subscribe to events**: + - `payment_link.payment.success` + - `payment_link.payment.failed` + - `payment_link.payment.expired` +- [ ] **Save the webhook secret** from the subscription metadata response + - Set as `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` env var +- [ ] **Test webhook delivery** using Coinbase's test tools or [Postman collection](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/postman-files) + +### Webhook Signature Format (new) +- Header: `X-Hook0-Signature` +- Format: `t=,h=,v1=` +- Replay protection: Rejects events older than 5 minutes + +### Old Format (still supported during transition) +- Header: `X-CC-Webhook-Signature` — legacy Commerce format +- The webhook endpoint auto-detects the format based on which signature header is present +- Both formats are supported simultaneously via `_detect_webhook_format()` + +--- + +## 4. API Authentication Changes + +### Old (Commerce) +``` +X-CC-Api-Key: +X-CC-Version: 2018-03-22 +``` + +### New (CDP / Business) +``` +Authorization: Bearer +Content-Type: application/json +``` + +JWT generation is handled by the `cdp-sdk` Python package using: +- `CDP_API_KEY_ID` (UUID) and `CDP_API_KEY_SECRET` (base64) +- Supports both Ed25519 and ECDSA key types (SDK auto-detects) +- See: https://docs.cdp.coinbase.com/api-reference/v2/authentication + +Implementation: `src/services/coinbase_auth.py` + +--- + +## 5. Payment Link API Endpoints + +### Coinbase Business API (upstream) + +Base URL: `https://business.coinbase.com` + +| Operation | Method | Production Path | Sandbox Path | +|---|---|---|---| +| Create | `POST` | `/api/v1/payment-links` | `/sandbox/api/v1/payment-links` | +| List | `GET` | `/api/v1/payment-links` | `/sandbox/api/v1/payment-links` | +| Get | `GET` | `/api/v1/payment-links/{id}` | `/sandbox/api/v1/payment-links/{id}` | +| Deactivate | `POST` | `/api/v1/payment-links/{id}/deactivate` | `/sandbox/api/v1/payment-links/{id}/deactivate` | + +Controlled by `CDP_SANDBOX` env var. See [Sandbox docs](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox). + +### Our API Endpoints + +**User-facing** (under `/api/v1/billing/coinbase/`): + +| Operation | Method | Path | Auth | +|---|---|---|---| +| Create Payment Link | `POST` | `/api/v1/billing/coinbase/payment-links` | User (Cognito JWT) | +| Get Payment Link | `GET` | `/api/v1/billing/coinbase/payment-links/{id}` | User (Cognito JWT) | + +Implementation: `src/api/v1/billing/coinbase.py` + +**Admin-only** (under `/api/v1/billing/`, requires `X-Admin-Secret`): + +| Operation | Method | Path | Auth | +|---|---|---|---| +| List Payment Links | `GET` | `/api/v1/billing/payment-links` | Admin (X-Admin-Secret) | +| Deactivate Payment Link | `POST` | `/api/v1/billing/payment-links/{id}/deactivate` | Admin (X-Admin-Secret) | + +Implementation: `src/api/v1/billing/admin.py` + +### Key Differences from Commerce Charges + +| Aspect | Commerce (old) | Payment Link (new) | +|---|---|---| +| ID format | UUID | 24-char hex | +| URL field | `hosted_url` | `url` | +| Amount | `pricing.local.amount` | `amount` (flat) | +| Currency | `pricing.local.currency` | `currency` (flat) | +| Status | `timeline` array | Single `status` field | +| Statuses | NEW, SIGNED, PENDING, COMPLETED | ACTIVE, COMPLETED, EXPIRED, DEACTIVATED | +| Currencies | BTC, ETH, USDC, DAI, USD | **USDC only** | +| Network | Multiple | **Base only** | +| Idempotency | Not required | `X-Idempotency-Key` header | + +--- + +## 6. Currency Limitation — Important + +The Payment Link API currently **only supports USDC on Base network**. If users were previously paying with BTC, ETH, or other currencies via Commerce, they will need to use USDC going forward. + +Verify: +- [ ] Frontend payment UI reflects USDC-only +- [ ] Any documentation/help text referencing multi-currency is updated +- [ ] Pricing is displayed in USDC (1:1 with USD) + +--- + +## 7. Database / Data Migration + +No schema changes required — the `credits_ledger` table already supports both formats via the `payment_metadata` JSONB column. + +### Verify +- [ ] Existing `payment_metadata` entries with `"type": "charge"` remain queryable +- [ ] New entries will have `"type": "payment_link"` +- [ ] `external_transaction_id` now stores 24-char hex IDs (was UUID charge codes) +- [ ] No migration script needed for existing data + +--- + +## 8. User Identification in Payment Flow + +The `metadata` field on the payment link is used to pass the user identifier through the Coinbase payment flow. Coinbase treats metadata as an opaque key-value store and returns it verbatim in webhook payloads. + +### Flow + +1. **Create** (`POST /api/v1/billing/coinbase/payment-links`): + The authenticated user's `cognito_user_id` is **automatically injected** into `metadata.user_id` server-side. The caller cannot override this — it is set from the JWT-authenticated session. + + ```json + // Sent to Coinbase API: + { + "amount": "10.00", + "currency": "USDC", + "metadata": { + "user_id": "" + } + } + ``` + +2. **Webhook** (`POST /api/v1/webhooks/coinbase`): + Coinbase sends the `metadata` back in the event payload. The webhook handler reads `metadata.user_id` to look up the user and credit their account. + + ```json + // Received from Coinbase: + { + "id": "69163c762331ed43dc64a6ef", + "eventType": "payment_link.payment.success", + "amount": "10.00", + "currency": "USDC", + "metadata": { + "user_id": "" + }, + ... + } + ``` + +3. **Credit**: The webhook service looks up the user by `cognito_user_id`, validates the amount, and creates a purchase ledger entry. + +### Implementation +- Injection: `src/api/v1/billing/coinbase.py` → `metadata["user_id"] = current_user.cognito_user_id` +- Extraction: `src/services/coinbase_webhook_service.py` → `_get_user_from_metadata()` + +--- + +## 9. Transition Architecture (Dual Webhook Support) + +During the transition period, the system supports **both** Commerce and Payment Link webhooks simultaneously: + +``` +POST /api/v1/webhooks/coinbase + ├── X-Hook0-Signature header present → Payment Link handler (new) + └── X-CC-Webhook-Signature header present → Legacy Commerce handler (deprecated) +``` + +### Files involved: +- `src/api/v1/webhooks/coinbase.py` — Dual-format webhook endpoint with auto-detection +- `src/services/coinbase_webhook_service.py` — Event handlers for both formats +- `src/api/v1/billing/coinbase.py` — New Payment Link CRUD endpoints (user-facing) +- `src/services/coinbase_payment_link_service.py` — Payment Link API client +- `src/services/coinbase_auth.py` — CDP JWT auth for API calls +- `src/core/config.py` — Both `COINBASE_COMMERCE_WEBHOOK_SECRET` and `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` + +--- + +## 10. Testing Checklist + +### Sandbox Testing +- [ ] Set `CDP_SANDBOX=true` and verify API calls go to `/sandbox/api/v1/payment-links` +- [ ] Create a sandbox payment link and complete payment with [testnet USDC](https://portal.cdp.coinbase.com/products/faucet) +- [ ] Register a sandbox webhook subscription (with `"sandbox": "true"` label) +- [ ] Verify sandbox webhook events are received and processed correctly + +### Pre-deployment +- [ ] Verify JWT signing works with test CDP key +- [ ] Create a test payment link via `POST /api/v1/billing/coinbase/payment-links` +- [ ] Verify webhook signature verification with test payload (both formats) +- [ ] Test idempotency (same webhook delivered twice) +- [ ] Test expired/failed webhook handling +- [ ] Verify legacy Commerce webhooks still work during transition + +### Post-deployment +- [ ] Create a real payment link and complete payment +- [ ] Verify credits appear in user's balance +- [ ] Verify payment metadata is stored correctly +- [ ] Monitor logs for `coinbase_pl_webhook_*` events +- [ ] Verify deactivation works + +### Postman Collection +Coinbase provides a [Postman collection](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/postman-files) for testing all Payment Link API endpoints. + +--- + +## 11. Rollback Plan + +If issues are discovered after deployment: + +1. The webhook endpoint supports both `X-Hook0-Signature` and `X-CC-Webhook-Signature` — legacy Commerce continues to work without code changes +2. CDP API key credentials coexist with Commerce API keys during transition +3. Both `COINBASE_COMMERCE_WEBHOOK_SECRET` and `COINBASE_PAYMENT_LINK_WEBHOOK_SECRET` can be set simultaneously + +--- + +## 12. Post-Migration Cleanup (after transition) + +Once all Commerce charges have settled and new system is confirmed working: + +- [ ] Remove `COINBASE_COMMERCE_WEBHOOK_SECRET` from config +- [ ] Remove `_detect_webhook_format()` and `verify_legacy_commerce_signature()` from webhook handler +- [ ] Remove `_handle_legacy_commerce_webhook()` from webhook handler +- [ ] Remove legacy event types and `handle_charge_confirmed()` from webhook service +- [ ] Update webhook endpoint to only accept `X-Hook0-Signature` + +--- + +## 13. IP Allowlisting + +- [ ] If using CDP API key IP allowlisting, ensure all API server IPs are added +- [ ] If behind a load balancer, verify the outbound IP (NAT gateway) is allowlisted + +--- + +## 14. Monitoring & Alerting + +Ensure alerts are configured for these log event types: + +**Payment Link (new):** +- `coinbase_pl_webhook_not_configured` — Secret missing (critical) +- `coinbase_pl_webhook_invalid_signature` — Signature mismatch (security) +- `coinbase_pl_webhook_replay` — Replay attack attempt (security) +- `coinbase_payment_link_error` — API call failures (operational) + +**Legacy Commerce (transition period):** +- `coinbase_legacy_webhook_not_configured` — Legacy secret missing +- `coinbase_legacy_webhook_invalid_signature` — Legacy signature mismatch + +--- + +## 15. Documentation References + +- [Migration Overview](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/overview) +- [API & Schema Mapping](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/api-schema-mapping) +- [Payment Link API Reference](https://docs.cdp.coinbase.com/api-reference/business-api/rest-api/payment-links/introduction) +- [CDP API Key Auth](https://docs.cdp.coinbase.com/api-reference/v2/authentication) +- [Webhook Docs](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks) +- [Sandbox Environment](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox) +- [Testnet Faucet (Base Sepolia USDC)](https://portal.cdp.coinbase.com/products/faucet) +- [Migration FAQ](https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/migrate/faq) diff --git a/env.example b/env.example index 3c7a83a1..e4e60d62 100644 --- a/env.example +++ b/env.example @@ -157,6 +157,25 @@ STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key # The CLI will display the signing secret (whsec_...) STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret +# ============================================================================= +# COINBASE BUSINESS CONFIGURATION +# ============================================================================= +# CDP Secret API Key for Payment Link CRUD operations +# Generate at: https://portal.cdp.coinbase.com/projects/api-keys (Secret API Keys tab) +# Docs: https://docs.cdp.coinbase.com/api-reference/v2/authentication +# Key ID: UUID from the CDP portal +CDP_API_KEY_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# Key Secret: base64-encoded secret from the CDP portal +CDP_API_KEY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX== +# Set to true for sandbox (no real transactions, uses Base Sepolia testnet) +# See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox +CDP_SANDBOX=false + +# Payment Link webhook signature verification secret +# From metadata.secret when creating a webhook subscription +# See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks +COINBASE_PAYMENT_LINK_WEBHOOK_SECRET=your_webhook_secret + # ============================================================================= # BUILDERS API CONFIGURATION (MOR Staking Data) # ============================================================================= diff --git a/pyproject.toml b/pyproject.toml index b774ea0a..afccaa23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,10 +26,11 @@ boto3 = "^1.34.0" structlog = "^23.2.0" python-multipart = "0.0.20" stripe = "^11.0.0" -web3 = "^6.0.0" -eth-account = "^0.10.0" +cdp-sdk = "^1.4.0" +web3 = "^7.0.0" +eth-account = "^0.13.0" redis = "^5.0.0" -siwe = "^4.0.0" +siwe = "^4.4.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" diff --git a/src/api/v1/__init__.py b/src/api/v1/__init__.py index 92549a55..9e9e84d3 100644 --- a/src/api/v1/__init__.py +++ b/src/api/v1/__init__.py @@ -9,6 +9,7 @@ from .audio.index import router as audio_router from .billing.index import router as billing_router from .billing.admin import admin_router as billing_admin_router +from .billing.coinbase import coinbase_billing_router from .webhooks.stripe import stripe_webhook_router from .webhooks.coinbase import coinbase_webhook_router from .wallet.index import router as wallet_router @@ -37,6 +38,7 @@ # Billing router billing = APIRouter() billing.include_router(billing_router) +billing.include_router(coinbase_billing_router) # Billing admin router (separate Swagger page at /admin/docs) billing_admin = APIRouter() diff --git a/src/api/v1/billing/admin.py b/src/api/v1/billing/admin.py index afef28d3..df329067 100644 --- a/src/api/v1/billing/admin.py +++ b/src/api/v1/billing/admin.py @@ -24,9 +24,14 @@ RateLimitMultiplierRequest, RateLimitMultiplierResponse, ) +from ....schemas.payment_link import ( + PaymentLinkResponse, + PaymentLinkListResponse, +) from ....core.logging_config import get_api_logger from ....core.config import settings from ....services.cache_service import cache_service +from ....services.coinbase_payment_link_service import coinbase_payment_link_service logger = get_api_logger() @@ -407,3 +412,90 @@ async def get_rate_limit_multiplier( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error getting rate limit multiplier: {str(e)}", ) + + +# === Coinbase Payment Link Admin Endpoints === + + +@admin_router.get("/payment-links", response_model=PaymentLinkListResponse, tags=["Payment Links"]) +async def list_payment_links( + page_size: int = Query(default=20, ge=1, le=100, alias="pageSize", description="Max results per page"), + page_token: Optional[str] = Query(default=None, alias="pageToken", description="Pagination token from previous response"), + link_status: Optional[str] = Query(default=None, alias="status", description="Filter by status"), + current_user: User = Depends(get_current_user), + _admin_verified: bool = Depends(verify_billing_admin_secret), +): + """ + List Coinbase Business Payment Links. + + **Admin endpoint** - Requires X-Admin-Secret header. + + Returns a paginated list of payment links with optional status filtering. + Status values: ACTIVE, PROCESSING, COMPLETED, EXPIRED, DEACTIVATED, FAILED. + """ + try: + result = await coinbase_payment_link_service.list_payment_links( + page_size=page_size, + page_token=page_token, + status=link_status, + ) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error listing payment links", + error=str(e), + error_type=type(e).__name__, + event_type="admin_payment_link_list_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error listing payment links: {str(e)}", + ) + + +@admin_router.post("/payment-links/{payment_link_id}/deactivate", response_model=PaymentLinkResponse, tags=["Payment Links"]) +async def deactivate_payment_link( + payment_link_id: str, + current_user: User = Depends(get_current_user), + _admin_verified: bool = Depends(verify_billing_admin_secret), +): + """ + Deactivate a Coinbase Business Payment Link. + + **Admin endpoint** - Requires X-Admin-Secret header. + + Prevents further payments on this link. Cannot be undone. + """ + try: + result = await coinbase_payment_link_service.deactivate_payment_link(payment_link_id) + + logger.info( + "Payment link deactivated via admin API", + user_id=current_user.id, + payment_link_id=payment_link_id, + event_type="admin_payment_link_deactivated", + ) + + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error deactivating payment link", + payment_link_id=payment_link_id, + error=str(e), + error_type=type(e).__name__, + event_type="admin_payment_link_deactivate_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error deactivating payment link: {str(e)}", + ) diff --git a/src/api/v1/billing/coinbase.py b/src/api/v1/billing/coinbase.py new file mode 100644 index 00000000..6c16fdf7 --- /dev/null +++ b/src/api/v1/billing/coinbase.py @@ -0,0 +1,105 @@ +""" +Coinbase Business Payment Link billing endpoints. +Authenticated user endpoints for creating payment links and checking status. +""" +from fastapi import APIRouter, Depends, HTTPException, status + +from ....db.models import User +from ....dependencies import get_current_user +from ....schemas.payment_link import ( + CreatePaymentLinkRequest, + PaymentLinkResponse, +) +from ....core.logging_config import get_api_logger +from ....services.coinbase_payment_link_service import coinbase_payment_link_service + +logger = get_api_logger() + +router = APIRouter(prefix="/coinbase", tags=["Coinbase Billing"]) + + +@router.post("/payment-links", response_model=PaymentLinkResponse) +async def create_payment_link( + request: CreatePaymentLinkRequest, + current_user: User = Depends(get_current_user), +): + """ + Create a Coinbase Business Payment Link for the authenticated user. + + Creates a USDC payment link via the Coinbase Business API. + The user_id is automatically added to metadata so the webhook + can credit the correct account. + """ + try: + metadata = request.metadata or {} + metadata["user_id"] = current_user.cognito_user_id + + result = await coinbase_payment_link_service.create_payment_link( + amount=request.amount, + currency=request.currency, + metadata=metadata, + description=request.description, + success_redirect_url=request.success_redirect_url, + failure_redirect_url=request.failure_redirect_url, + expires_at=request.expires_at, + ) + + logger.info( + "Payment link created", + user_id=current_user.id, + payment_link_id=result.get("id"), + amount=request.amount, + currency=request.currency, + event_type="coinbase_payment_link_created", + ) + + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error creating payment link", + error=str(e), + error_type=type(e).__name__, + event_type="coinbase_payment_link_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating payment link: {str(e)}", + ) + + +@router.get("/payment-links/{payment_link_id}", response_model=PaymentLinkResponse) +async def get_payment_link( + payment_link_id: str, + current_user: User = Depends(get_current_user), +): + """ + Get a specific Coinbase Business Payment Link by ID. + """ + try: + result = await coinbase_payment_link_service.get_payment_link(payment_link_id) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(e), + ) + except Exception as e: + logger.error( + "Error getting payment link", + payment_link_id=payment_link_id, + error=str(e), + error_type=type(e).__name__, + event_type="coinbase_payment_link_get_error", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting payment link: {str(e)}", + ) + + +coinbase_billing_router = router diff --git a/src/api/v1/webhooks/coinbase.py b/src/api/v1/webhooks/coinbase.py index c73b5af4..5ad37d2e 100644 --- a/src/api/v1/webhooks/coinbase.py +++ b/src/api/v1/webhooks/coinbase.py @@ -296,7 +296,6 @@ async def _handle_payment_link_webhook( coinbase_payment_link_id=payment_link_id, error=message, ) - # Don't raise 500 for failed payments - just log elif event_type == coinbase_webhook_service.EVENT_TYPE_PL_PAYMENT_EXPIRED: success, message = await coinbase_webhook_service.handle_payment_expired( @@ -309,7 +308,6 @@ async def _handle_payment_link_webhook( coinbase_payment_link_id=payment_link_id, error=message, ) - # Don't raise 500 for expired payments - just log else: logger.info( diff --git a/src/core/config.py b/src/core/config.py index 00b56e36..48674c9d 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -226,10 +226,21 @@ def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str # Required for processing legacy Coinbase Commerce charge webhooks COINBASE_COMMERCE_WEBHOOK_SECRET: str | None = Field(default=os.getenv("COINBASE_COMMERCE_WEBHOOK_SECRET")) - # Coinbase Payment Link Settings (New) + # Coinbase Business / CDP Settings + # Payment Link webhook signature verification secret # Secret from metadata.secret returned when creating a webhook subscription # See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/webhooks COINBASE_PAYMENT_LINK_WEBHOOK_SECRET: str | None = Field(default=os.getenv("COINBASE_PAYMENT_LINK_WEBHOOK_SECRET")) + + # CDP API Key credentials for Payment Link CRUD operations + # Key ID: UUID from the CDP portal (Secret API Key tab) + # Key Secret: base64-encoded secret from the CDP portal + # See: https://docs.cdp.coinbase.com/api-reference/v2/authentication + CDP_API_KEY_ID: str | None = Field(default=os.getenv("CDP_API_KEY_ID")) + CDP_API_KEY_SECRET: str | None = Field(default=os.getenv("CDP_API_KEY_SECRET")) + # Set to true to use the Coinbase sandbox environment (no real transactions) + # See: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox + CDP_SANDBOX: bool = Field(default=os.getenv("CDP_SANDBOX", "false").lower() == "true") # Web3 Provider Settings (optional - enables EIP-1271 smart contract wallet verification) # If not set, only EOA wallets will be supported diff --git a/src/schemas/payment_link.py b/src/schemas/payment_link.py new file mode 100644 index 00000000..7ce9f538 --- /dev/null +++ b/src/schemas/payment_link.py @@ -0,0 +1,40 @@ +""" +Pydantic schemas for Coinbase Business Payment Link API. +""" +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field + + +class CreatePaymentLinkRequest(BaseModel): + """Request to create a new Coinbase Payment Link.""" + amount: str = Field(..., description="Payment amount (e.g., '100.00')") + currency: str = Field(default="USDC", description="Currency code (currently only USDC)") + metadata: Optional[Dict[str, Any]] = Field(None, description="Key-value pairs passed through the payment flow") + description: Optional[str] = Field(None, description="Description shown on the payment page") + success_redirect_url: Optional[str] = Field(None, description="HTTPS URL to redirect on success") + failure_redirect_url: Optional[str] = Field(None, description="HTTPS URL to redirect on failure") + expires_at: Optional[str] = Field(None, description="ISO 8601 timestamp when the link expires") + + +class PaymentLinkResponse(BaseModel): + """Response from the Coinbase Payment Link API.""" + id: str = Field(..., description="Payment link ID (24-char hex)") + url: Optional[str] = Field(None, description="Payment link URL") + status: Optional[str] = Field(None, description="ACTIVE, COMPLETED, EXPIRED, or DEACTIVATED") + amount: Optional[str] = None + currency: Optional[str] = None + description: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + created_at: Optional[str] = Field(None, alias="createdAt") + updated_at: Optional[str] = Field(None, alias="updatedAt") + expires_at: Optional[str] = Field(None, alias="expiresAt") + + model_config = {"populate_by_name": True} + + +class PaymentLinkListResponse(BaseModel): + """Paginated list of payment links.""" + payment_links: List[Dict[str, Any]] = Field(default_factory=list, alias="paymentLinks") + next_page_token: Optional[str] = Field(None, alias="nextPageToken") + + model_config = {"populate_by_name": True} diff --git a/src/services/coinbase_auth.py b/src/services/coinbase_auth.py new file mode 100644 index 00000000..72a5b453 --- /dev/null +++ b/src/services/coinbase_auth.py @@ -0,0 +1,72 @@ +""" +CDP (Coinbase Developer Platform) API Key authentication. + +Uses the CDP SDK to generate signed JWTs for authenticating with the +Coinbase Business API. Each request requires a unique JWT with the +target URI embedded in the payload. + +Docs: https://docs.cdp.coinbase.com/api-reference/v2/authentication +Sandbox: https://docs.cdp.coinbase.com/coinbase-business/payment-link-apis/sandbox +""" +from cdp.auth.utils.jwt import generate_jwt, JwtOptions + +from src.core.config import settings +from src.core.logging_config import get_core_logger + +logger = get_core_logger() + +CDP_API_HOST = "business.coinbase.com" +CDP_API_BASE_URL = f"https://{CDP_API_HOST}" + +# Sandbox adds /sandbox before the API path; production uses no prefix. +CDP_PATH_PREFIX = "/sandbox" if settings.CDP_SANDBOX else "" + + +def _build_jwt(method: str, path: str) -> str: + """ + Build a signed JWT for a CDP API request using the CDP SDK. + + The path should already include the sandbox prefix when applicable. + + Args: + method: HTTP method (GET, POST, etc.) + path: Full API path (e.g., /sandbox/api/v1/payment-links or /api/v1/payment-links) + + Returns: + Signed JWT string + + Raises: + ValueError: If CDP API key credentials are not configured + """ + if not settings.CDP_API_KEY_ID or not settings.CDP_API_KEY_SECRET: + raise ValueError( + "CDP API credentials not configured. " + "Set CDP_API_KEY_ID and CDP_API_KEY_SECRET environment variables." + ) + + return generate_jwt(JwtOptions( + api_key_id=settings.CDP_API_KEY_ID, + api_key_secret=settings.CDP_API_KEY_SECRET, + request_method=method.upper(), + request_host=CDP_API_HOST, + request_path=path, + expires_in=120, + )) + + +def get_auth_headers(method: str, path: str) -> dict: + """ + Get authentication headers for a CDP API request. + + Args: + method: HTTP method + path: Full API path (including sandbox prefix if applicable) + + Returns: + Dict with Authorization header + """ + token = _build_jwt(method, path) + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } diff --git a/src/services/coinbase_payment_link_service.py b/src/services/coinbase_payment_link_service.py new file mode 100644 index 00000000..f47377dd --- /dev/null +++ b/src/services/coinbase_payment_link_service.py @@ -0,0 +1,193 @@ +""" +Coinbase Business Payment Link API service. + +Provides CRUD operations for creating, listing, retrieving, and deactivating +payment links via the Coinbase Business REST API. + +API Reference: https://docs.cdp.coinbase.com/api-reference/business-api/rest-api/payment-links/introduction +""" +from typing import Optional, Dict, Any + +import httpx + +from src.services.coinbase_auth import get_auth_headers, CDP_API_BASE_URL, CDP_PATH_PREFIX +from src.core.logging_config import get_core_logger + +logger = get_core_logger() + +# Payment Link API base path (includes /sandbox prefix when CDP_SANDBOX=true) +PAYMENT_LINKS_PATH = f"{CDP_PATH_PREFIX}/api/v1/payment-links" + +# Default timeout for API calls (seconds) +DEFAULT_TIMEOUT = 30.0 + + +class CoinbasePaymentLinkService: + """ + Service for managing Coinbase Business Payment Links. + + Payment links are single-use USDC payment URLs that can be shared + with customers. Once paid, a webhook is fired and the link status + transitions to COMPLETED. + """ + + async def create_payment_link( + self, + amount: str, + currency: str = "USDC", + metadata: Optional[Dict[str, Any]] = None, + description: Optional[str] = None, + success_redirect_url: Optional[str] = None, + failure_redirect_url: Optional[str] = None, + expires_at: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Create a new payment link. + + Args: + amount: Payment amount as a string (e.g., "100.00") + currency: Currency code (currently only USDC supported) + metadata: Optional key-value pairs passed through the payment flow + description: Optional description shown on the payment page + success_redirect_url: HTTPS URL to redirect on success + failure_redirect_url: HTTPS URL to redirect on failure + expires_at: ISO 8601 timestamp when the link expires (default: 1 year) + idempotency_key: Optional idempotency key for the request + + Returns: + Payment link response dict with id, url, status, etc. + + Raises: + httpx.HTTPStatusError: On API error responses + ValueError: If CDP credentials are not configured + """ + path = PAYMENT_LINKS_PATH + headers = get_auth_headers("POST", path) + + if idempotency_key: + headers["X-Idempotency-Key"] = idempotency_key + + body: Dict[str, Any] = { + "amount": amount, + "currency": currency, + } + + if metadata: + body["metadata"] = metadata + if description: + body["description"] = description + if success_redirect_url: + body["successRedirectUrl"] = success_redirect_url + if failure_redirect_url: + body["failRedirectUrl"] = failure_redirect_url + if expires_at: + body["expiresAt"] = expires_at + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.post( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + json=body, + ) + response.raise_for_status() + + result = response.json() + logger.info( + "Created Coinbase Payment Link", + payment_link_id=result.get("id"), + payment_link_url=result.get("url"), + amount=amount, + currency=currency, + ) + return result + + async def list_payment_links( + self, + page_size: int = 20, + page_token: Optional[str] = None, + status: Optional[str] = None, + ) -> Dict[str, Any]: + """ + List payment links with optional filtering and pagination. + + Args: + page_size: Max results per page (default 20, max 100) + page_token: Pagination token from a previous response's nextPageToken + status: Filter by status (ACTIVE, COMPLETED, EXPIRED, DEACTIVATED) + + Returns: + Paginated response with payment links and pagination info + """ + path = PAYMENT_LINKS_PATH + headers = get_auth_headers("GET", path) + + params: Dict[str, Any] = {"pageSize": page_size} + if page_token: + params["pageToken"] = page_token + if status: + params["status"] = status + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.get( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + params=params, + ) + response.raise_for_status() + + return response.json() + + async def get_payment_link(self, payment_link_id: str) -> Dict[str, Any]: + """ + Retrieve a single payment link by ID. + + Args: + payment_link_id: The 24-char hex payment link ID + + Returns: + Payment link details + """ + path = f"{PAYMENT_LINKS_PATH}/{payment_link_id}" + headers = get_auth_headers("GET", path) + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.get( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + ) + response.raise_for_status() + + return response.json() + + async def deactivate_payment_link(self, payment_link_id: str) -> Dict[str, Any]: + """ + Deactivate a payment link (prevents further payments). + + Args: + payment_link_id: The 24-char hex payment link ID + + Returns: + Updated payment link with DEACTIVATED status + """ + path = f"{PAYMENT_LINKS_PATH}/{payment_link_id}/deactivate" + headers = get_auth_headers("POST", path) + + async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client: + response = await client.post( + f"{CDP_API_BASE_URL}{path}", + headers=headers, + ) + response.raise_for_status() + + result = response.json() + logger.info( + "Deactivated Coinbase Payment Link", + payment_link_id=payment_link_id, + new_status=result.get("status"), + ) + return result + + +# Singleton instance +coinbase_payment_link_service = CoinbasePaymentLinkService() diff --git a/src/services/coinbase_webhook_service.py b/src/services/coinbase_webhook_service.py index f5c1f1c7..07b3e08a 100644 --- a/src/services/coinbase_webhook_service.py +++ b/src/services/coinbase_webhook_service.py @@ -493,14 +493,12 @@ async def handle_charge_confirmed( "coinbase_charge_id": charge_id, } - # Check for duplicate existing = await self._check_idempotency( db, event_id, event_type, transaction_id, log_context ) if existing: return True, "Already processed" - # Get user from metadata metadata = event_data.get("metadata") or {} user, error = await self._get_user_from_metadata( db, metadata, log_context=log_context @@ -508,13 +506,11 @@ async def handle_charge_confirmed( if error: return False, error - # Validate amount pricing = event_data.get("pricing") or {} amount_usd, error = self._validate_legacy_charge_amount(pricing, log_context) if error: return False, error - # Build payment metadata payment_metadata = { "charge_id": charge_id, "charge_code": charge_code, @@ -524,7 +520,6 @@ async def handle_charge_confirmed( "type": "charge", } - # Create entry and update balance entry = await self._create_purchase_entry( db=db, user_id=user.id,