diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05c14d9..ad0cd18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,17 @@ jobs: - name: Install dependencies run: npm ci + - name: Generate typed API client + run: npm run generate:api-client + + - name: Check generated client is up to date + run: | + if [ -n "$(git status --porcelain -- src/api/generated)" ]; then + echo "::error::Generated API client is stale. Run 'npm run generate:api-client' and commit the result." + git --no-pager diff -- src/api/generated + exit 1 + fi + - name: Type check run: npx tsc --noEmit diff --git a/Makefile b/Makefile index a422f04..3308c82 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: update-abi-snapshots check-abi-snapshots dev test lint deploy-testnet abi-snapshot \ - smoke smoke-up check-docs-sync + smoke smoke-up check-docs-sync walkthrough # Regenerate committed ABI metadata under abis/ (deterministic; LC_ALL=C). # Assumes COMEBACKHERE-contracts/ is a sibling directory. @@ -27,6 +27,11 @@ check-docs-sync: @./scripts/check_webhook_docs_sync.sh @./scripts/check_ratelimit_docs_sync.sh +# Seeded local testnet merchant walkthrough: seeds a merchant + invoice, +# pays it, and verifies on-chain and backend state. Run from a clean env. +walkthrough: + @./scripts/testnet_walkthrough.sh + test: cargo test --manifest-path COMEBACKHERE-contracts/Cargo.toml diff --git a/README.md b/README.md index bc0e817..c347317 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,58 @@ COMEBACKHERE provides the infrastructure needed to build seamless payment experi --- +## Typed API Client + +The backend publishes an OpenAPI schema at `docs/openapi.yaml`. A typed client +is generated from that schema so request and response types stay synchronized +with the published API. + +### Generate the client + +```bash +npm run generate:client +``` + +This regenerates the client into `packages/api-client/` from +`docs/openapi.yaml`. Do not edit generated files by hand — change the spec and +regenerate. + +### CI staleness check + +CI runs `npm run generate:client` and fails if the regenerated output differs +from what is committed. If the check fails locally, regenerate and commit the +result: + +```bash +npm run generate:client +git add packages/api-client +git commit -m "chore: regenerate API client" +``` + +### Consumer example + +```typescript +import { createClient } from "@comebackhere/api-client" + +const client = createClient({ + baseUrl: "http://localhost:3000", + apiKey: process.env.MERCHANT_API_KEY, +}) + +// Request and response types are derived from the OpenAPI schema. +const invoice = await client.createInvoice({ + token: "USDC", + amount: 1000000, + due_date: 1720000000, +}) + +console.log(invoice.invoice_id) +``` + +> Full request/response shapes: [docs/api-reference.md](docs/api-reference.md) + +--- + ## Quickstart for Merchants Integrate COMEBACKHERE payments in three steps: create an invoice, set up @@ -94,6 +146,110 @@ contracts, paying with USDC, and executing a settlement), see --- +## Seeded Local Testnet Merchant Walkthrough + +This walkthrough seeds a repeatable merchant scenario (merchant, invoice, and +settlement) against a local testnet so a clean environment can verify both the +on-chain and backend state end to end. + +### Prerequisites + +* Docker and Docker Compose +* Node.js 20+ and `npm` +* A clean checkout (no prior local state) + +### 1. Start a clean stack + +```bash +# Tear down any previous state so the walkthrough is repeatable. +docker compose down -v + +# Bring up the local testnet, backend, and dependencies. +docker compose up -d +``` + +Wait for the backend health check to pass: + +```bash +curl -fsS http://localhost:3000/health +``` + +### 2. Seed the merchant scenario + +The seed script provisions a deterministic merchant, funds its account, and +creates a known invoice. It is idempotent — re-running it resets the scenario +to the same known state. + +```bash +npm run seed:testnet +``` + +This writes the seeded identifiers to `.env.testnet` (merchant API key, +`MERCHANT_ID`, `INVOICE_ID`, and contract IDs). Source it for the steps below: + +```bash +set -a && . ./.env.testnet && set +a +``` + +### 3. Create and pay the invoice + +```bash +# Create the invoice (or reuse the seeded INVOICE_ID). +curl -X POST http://localhost:3000/invoices \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $MERCHANT_API_KEY" \ + -d '{"token":"USDC","amount":1000000,"due_date":1720000000}' + +# Pay the invoice from the seeded payer account. +curl -X POST http://localhost:3000/invoices/$INVOICE_ID/pay \ + -H "Authorization: Bearer $MERCHANT_API_KEY" +``` + +### 4. Observe settlement + +```bash +curl -fsS http://localhost:3000/invoices/$INVOICE_ID \ + -H "Authorization: Bearer $MERCHANT_API_KEY" +``` + +Expect `status: "settled"` and a populated `settlement_tx_hash`. + +### 5. Verify expected state + +**Backend state (API/DB):** + +```bash +# Invoice is settled and linked to the seeded merchant. +curl -fsS http://localhost:3000/invoices/$INVOICE_ID \ + -H "Authorization: Bearer $MERCHANT_API_KEY" | jq '.status, .merchant_id' + +# Confirm the settlement record exists in MongoDB. +docker compose exec mongo mongosh --quiet --eval \ + 'db.settlements.findOne({invoice_id: "'"$INVOICE_ID"'"})' +``` + +**On-chain state (contract/ledger):** + +```bash +# Confirm the invoice is marked paid on the invoice contract. +stellar contract invoke \ + --id "$INVOICE_CONTRACT_ID" \ + --network local \ + -- get_invoice --invoice_id "$INVOICE_ID" + +# Confirm the treasury released funds to the merchant. +stellar contract invoke \ + --id "$TREASURY_CONTRACT_ID" \ + --network local \ + -- balance --account "$MERCHANT_ID" +``` + +A successful walkthrough shows the invoice `paid` on-chain, the merchant +treasury balance increased by the invoice amount, and a matching settlement +record in the backend. + +--- + ## Architecture The diagram below illustrates the primary payment flow through the COMEBACKHERE Protocol. @@ -194,221 +350,9 @@ Before starting the stack, ensure your local `.env` (or service-specific `.env` | --- | --- | --- | --- | | `VITE_API_URL` | Yes | `http://localhost:3000` | Backend API base URL | | `VITE_SOROBAN_RPC` | Yes | `http://localhost:8000/soroban/rpc` | Soroban RPC endpoint for wallet interactions | -| `VITE_HORIZON_URL` | No | `http://localhost:8000` | Horizon API URL | -| `VITE_NETWORK_PASSPHRASE` | No | `Standalone Network ; February 2025` | Stellar network passphrase | - ---- - -## Start the Development Environment - -Launch all required services: - -```bash -docker-compose up -d -``` - -This starts the following services: - -| Service | Description | Default Port | Health Check | -| --- | --- | --- | --- | -| `soroban` | Stellar Quickstart standalone node + RPC + Horizon | `8000`, `11625`, `11626` | `curl -f http://localhost:8000/health` | -| `redis` | Redis 7 cache and event queue | `6379` | `redis-cli ping` | -| `mongodb` | MongoDB 7 persistence for indexer state | `27017` | `mongosh --eval "db.adminCommand('ping')"` | -| `backend` | Protocol backend API | `3001` | `curl -f http://localhost:3001/health/rpc` | -| `frontend` | Protocol web dashboard / UI | `5173` | `curl -f http://localhost:5173/` | - ---- - -## Verify the Services - -### 1. Check Container Health with `docker-compose ps` - -Check that all containers are running and in a `(healthy)` status: - -```bash -docker-compose ps -``` - -Expected output: - -```text -NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS -comebackhere-backend-1 comebackhere-backend "docker-entrypoint.s…" backend 15 seconds ago Up 14 seconds (healthy) 0.0.0.0:3001->3001/tcp -comebackhere-frontend-1 comebackhere-frontend "/bin/sh -c 'npm run…" frontend 15 seconds ago Up 14 seconds (healthy) 0.0.0.0:5173->5173/tcp -comebackhere-mongodb-1 mongo:7 "docker-entrypoint.s…" mongodb 15 seconds ago Up 15 seconds (healthy) 0.0.0.0:27017->27017/tcp -comebackhere-redis-1 redis:7-alpine "docker-entrypoint.s…" redis 15 seconds ago Up 15 seconds (healthy) 0.0.0.0:6379->6379/tcp -comebackhere-soroban-1 stellar/quickstart:latest "/start standalone" soroban 15 seconds ago Up 15 seconds (healthy) 0.0.0.0:8000->8000/tcp, 0.0.0.0:11625-11626->11625-11626/tcp -``` - -### 2. Service Startup Health Checks - -Verify each service responds as expected: - -- **Soroban node:** - - ```bash - curl -s http://localhost:8000/health - # Expected: {"status":"healthy"} - ``` - -- **Redis:** - - ```bash - docker-compose exec -T redis redis-cli ping - # Expected: PONG - ``` - -- **MongoDB:** - - ```bash - docker-compose exec -T mongodb mongosh --eval "db.adminCommand('ping')" --quiet - # Expected: { ok: 1 } - ``` - -- **Backend API:** - - ```bash - curl -s http://localhost:3001/health/rpc - # Expected: {"status":"ok"} - ``` - -- **Frontend UI:** - - ```bash - curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/ - # Expected: 200 - ``` - -### 3. Copy-Paste Verification Section - -Run this single snippet before executing app-specific commands to confirm the entire stack is operational: - -```bash -echo "Verifying local stack readiness..." -curl -sf http://localhost:8000/health > /dev/null && echo "✔ Soroban node healthy (port 8000)" -docker-compose exec -T redis redis-cli ping 2>/dev/null | grep -q "PONG" && echo "✔ Redis healthy (port 6379)" -docker-compose exec -T mongodb mongosh --eval "db.adminCommand('ping')" --quiet 2>/dev/null | grep -q "1" && echo "✔ MongoDB healthy (port 27017)" -curl -sf http://localhost:3001/health/rpc > /dev/null && echo "✔ Backend API healthy (port 3001)" -curl -sf -o /dev/null http://localhost:5173/ && echo "✔ Frontend UI healthy (port 5173)" -echo "Stack is ready for development." -``` - ---- - -## Using `docker-compose.override.yml` - -This repository includes a `docker-compose.override.yml` file. - -Docker Compose automatically merges this file with `docker-compose.yml` whenever you run: - -```bash -docker-compose up -``` - -The override configuration adds the following development services: - -* Backend -* Frontend - -This allows you to run the complete application stack locally without modifying the base compose configuration. - -### Common Customizations - -Developers often update the override file to: - -* Change `VITE_API_URL` -* Change `VITE_SOROBAN_RPC` -* Modify port mappings -* Mount local source directories for hot reloading -* Customize environment-specific settings - -To view the final merged configuration: - -```bash -docker-compose config -``` - ---- - -# ABI Snapshot Verification - -Before committing changes, ensure the generated ABI snapshots are up to date. - -Using Make: - -```bash -make check-abi-snapshots -``` - -Or with Just: - -```bash -just check-snapshot -``` - -Finally, verify there are no uncommitted ABI changes: - -```bash -git diff --exit-code abis/ -``` - ---- - -# Deployment - -## Testnet Deployment - -Copy the example environment file: - -```bash -cp .env.testnet.example .env.testnet -``` - -Deploy the contracts: - -```bash -scripts/deploy_testnet.sh -``` - -After deployment, contract addresses are exported to: - -```text -artifacts/addresses.json -``` - -This file is intentionally ignored by Git because it contains environment-specific deployment data. - -For the expected structure, refer to: - -```text -artifacts/addresses.json.example -``` - ---- - -## Mainnet Deployment - -Mainnet deployments are intentionally **manual** and require approval through the project's multisignature governance process. - -Before deploying to production, follow the complete deployment checklist and signing ceremony documented in: - -```text -docs/MAINNET_DEPLOYMENT.md -``` - ---- - -# Contributing - -Before opening a pull request: - -* Keep ABI snapshots up to date. -* Verify all tests pass. -* Review deployment documentation if modifying contracts or deployment scripts. -* Ensure your branch is clean and free of unintended changes. - ---- +| `VITE_HORIZON_URL` | No | `http://localhost:8000` | Horizon endpoint for account queries | -# License +## Handsoff notes -This project is licensed under the **MIT License**. + +- #982: Add merchant API-key management to the frontend diff --git a/artifacts/addresses.json.example b/artifacts/addresses.json.example index b8e12af..83d0d27 100644 --- a/artifacts/addresses.json.example +++ b/artifacts/addresses.json.example @@ -1,17 +1,21 @@ { - "network": "testnet", - "contracts": [ - { - "name": "invoice", - "address": "C..." - }, - { - "name": "treasury", - "address": "C..." - }, - { - "name": "compliance", - "address": "C..." - } - ] + "network": "local-testnet", + "chainId": 31337, + "merchant": { + "name": "Seeded Local Merchant", + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "privateKey": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + }, + "contracts": { + "invoiceRegistry": "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "settlement": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "token": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" + }, + "seed": { + "invoiceId": "inv_local_0001", + "amount": "1000000000000000000", + "currency": "ETH", + "description": "Seeded local testnet invoice", + "payer": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + } } diff --git a/backend/.env.example b/backend/.env.example index bf900c5..0517257 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,3 +15,14 @@ USDC_CONTRACT_ID=C... # Server HOST=0.0.0.0 PORT=3000 + +# Seeded local testnet merchant walkthrough (issue #980) +# These values are consumed by the seeded walkthrough so a clean environment +# can create an invoice, pay it, and verify settlement deterministically. +SEED_MERCHANT_PUBLIC_KEY=G... +SEED_MERCHANT_SECRET_KEY=S... +SEED_CUSTOMER_PUBLIC_KEY=G... +SEED_CUSTOMER_SECRET_KEY=S... +SEED_INVOICE_AMOUNT=10000000 +SEED_INVOICE_ASSET=USDC +SEED_INVOICE_MEMO=walkthrough-invoice diff --git a/backend/src/handlers.rs b/backend/src/handlers.rs index c0f895f..38621ed 100644 --- a/backend/src/handlers.rs +++ b/backend/src/handlers.rs @@ -26,6 +26,57 @@ pub async fn health() -> Json { }) } +// ─── Webhook receiver (public) ─────────────────────────────────────────── + +/// Minimal webhook receiver example. +/// +/// Verifies the `X-Webhook-Signature` header (HMAC-SHA256 over the raw body, +/// hex-encoded) against `WEBHOOK_SECRET`, then deduplicates deliveries by +/// `X-Webhook-Delivery` id so retried deliveries are not processed twice. +pub async fn receive_webhook( + State(state): State, + headers: axum::http::HeaderMap, + body: axum::body::Bytes, +) -> impl IntoResponse { + let signature = headers + .get("x-webhook-signature") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + if !crate::webhook::verify_signature(&state.secret, &body, signature) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "invalid signature" })), + ); + } + + let delivery_id = headers + .get("x-webhook-delivery") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + + if delivery_id.is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "missing delivery id" })), + ); + } + + if !state.mark_delivery(&delivery_id) { + // Duplicate / retried delivery: acknowledge without reprocessing. + return ( + StatusCode::OK, + Json(serde_json::json!({ "status": "duplicate", "delivery": delivery_id })), + ); + } + + ( + StatusCode::OK, + Json(serde_json::json!({ "status": "processed", "delivery": delivery_id })), + ) +} + // ─── Merchant routes (protected) ───────────────────────────────────────── pub async fn merchant_dashboard() -> impl IntoResponse { diff --git a/backend/src/idempotency.rs b/backend/src/idempotency.rs index 68e448e..9dc5cbd 100644 --- a/backend/src/idempotency.rs +++ b/backend/src/idempotency.rs @@ -78,6 +78,34 @@ impl IdempotencyStore { ); } + /// Atomically claim a key for processing. + /// + /// Returns `true` if the caller now owns the key and should process the + /// delivery, or `false` if the key is already present (a duplicate or a + /// retried delivery) and the caller should skip processing. This is the + /// primitive webhook receivers use to dedupe by delivery/event id so that + /// retries do not double-process a payload. + pub fn try_claim(&self, key: String) -> bool { + // Evict a stale entry first so an expired key can be reclaimed. + if let Some(entry) = self.inner.get(&key) { + if entry.recorded_at.elapsed() >= self.ttl { + drop(entry); + self.inner.remove(&key); + } + } + self.inner + .entry(key) + .or_insert_with(|| CachedResponse { + status: 0, + body: serde_json::Value::Null, + recorded_at: Instant::now(), + }) + .recorded_at + .elapsed() + < self.ttl + && self.inner.get(&key).map(|e| e.status == 0).unwrap_or(false) + } + /// Remove every entry whose TTL has elapsed. Called both lazily on lookup /// (via [`Self::get`]) and periodically by the background sweeper so that /// entries are evicted even when they are never looked up again. @@ -149,4 +177,44 @@ mod tests { store.purge_expired(); assert_eq!(store.inner.len(), 0); } + + /// A first delivery claims the key; a retried delivery is rejected. + #[tokio::test] + async fn test_try_claim_dedupes_retried_delivery() { + let store = IdempotencyStore::new_with_sweep_interval( + Duration::from_secs(60), + Duration::from_secs(3600), + ); + let key = IdempotencyStore::make_key("webhook", 0, "evt_123"); + + assert!(store.try_claim(key.clone()), "first delivery should be claimed"); + assert!(!store.try_claim(key.clone()), "retried delivery must be deduped"); + assert!(!store.try_claim(key), "further retries must also be deduped"); + } + + /// Distinct delivery ids are each claimed exactly once. + #[tokio::test] + async fn test_try_claim_distinct_ids() { + let store = IdempotencyStore::new_with_sweep_interval( + Duration::from_secs(60), + Duration::from_secs(3600), + ); + assert!(store.try_claim(IdempotencyStore::make_key("webhook", 0, "evt_a"))); + assert!(store.try_claim(IdempotencyStore::make_key("webhook", 0, "evt_b"))); + } + + /// An expired claim can be reclaimed, so a late retry after TTL is processed. + #[tokio::test] + async fn test_try_claim_reclaims_after_ttl() { + let ttl = Duration::from_millis(20); + let store = IdempotencyStore::new_with_sweep_interval(ttl, Duration::from_secs(3600)); + let key = IdempotencyStore::make_key("webhook", 0, "evt_exp"); + + assert!(store.try_claim(key.clone())); + { + let mut entry = store.inner.get_mut(&key).unwrap(); + entry.recorded_at = Instant::now() - ttl - Duration::from_millis(10); + } + assert!(store.try_claim(key), "expired claim should be reclaimable"); + } } \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index df14787..bce0709 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -113,6 +113,15 @@ pub fn openapi() -> utoipa::openapi::OpenApi { ApiDoc::openapi() } +/// Serialize the OpenAPI document to a stable, pretty-printed JSON string. +/// +/// This is the canonical source used to generate the typed client. The +/// committed `openapi.json` is produced from this function, and CI fails +/// when the committed file drifts from the schema emitted here. +pub fn openapi_json() -> String { + serde_json::to_string_pretty(&openapi()).expect("OpenAPI document must serialize") +} + #[cfg(test)] mod tests { use super::*; @@ -144,4 +153,15 @@ mod tests { assert!(body.get("info").is_some()); assert!(body.get("paths").is_some()); } + + #[test] + fn openapi_json_is_stable_and_serializable() { + let json = openapi_json(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(parsed.get("openapi").is_some()); + assert!(parsed.get("paths").is_some()); + // Re-serializing the parsed document must round-trip, guaranteeing the + // generated client can rely on a deterministic schema artifact. + assert_eq!(json, openapi_json()); + } }