diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8cc827d..c10ab61 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,7 +17,7 @@ jobs:
with:
node-version: '22'
- name: Install pnpm
- run: npm install -g pnpm@9
+ run: npm install -g pnpm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check formatting
@@ -33,7 +33,7 @@ jobs:
with:
node-version: '22'
- name: Install pnpm
- run: npm install -g pnpm@9
+ run: npm install -g pnpm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Lints apps/web and packages/sdk from the root.
@@ -51,12 +51,16 @@ jobs:
with:
node-version: '22'
- name: Install pnpm
- run: npm install -g pnpm@9
+ run: npm install -g pnpm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test SDK
working-directory: ./packages/sdk
run: pnpm test
+ - name: Verify generated API types are reproducible
+ run: |
+ pnpm --filter @accensa/sdk gen:api
+ git diff --exit-code -- packages/sdk/generated/api-types.ts
- name: Verify conformance vectors are reproducible
run: |
node packages/sdk/scripts/generate-vectors.mjs
@@ -86,7 +90,7 @@ jobs:
with:
node-version: '22'
- name: Install pnpm
- run: npm install -g pnpm@9
+ run: npm install -g pnpm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test web
@@ -95,6 +99,25 @@ jobs:
DATABASE_URL: postgres://postgres:password@localhost:5432/accensa_test
run: pnpm test
+ test-reconcile:
+ name: test (reconcile-payments)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Install pnpm
+ run: npm install -g pnpm@9
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ # Unit tests only: synthetic and fixture data, no live RPC or database.
+ # The live reconciliation itself runs on a schedule, in reconcile.yml.
+ - name: Test reconcile-payments
+ working-directory: ./scripts/reconcile-payments
+ run: pnpm test
+
typecheck:
name: typecheck
runs-on: ubuntu-latest
@@ -105,7 +128,7 @@ jobs:
with:
node-version: '22'
- name: Install pnpm
- run: npm install -g pnpm@9
+ run: npm install -g pnpm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Typechecks apps/web and packages/sdk from the root.
@@ -125,7 +148,7 @@ jobs:
with:
node-version: '22'
- name: Install pnpm
- run: npm install -g pnpm@9
+ run: npm install -g pnpm@11
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Next.js app
diff --git a/.github/workflows/reconcile.yml b/.github/workflows/reconcile.yml
new file mode 100644
index 0000000..1d4ac47
--- /dev/null
+++ b/.github/workflows/reconcile.yml
@@ -0,0 +1,59 @@
+name: Reconcile Payments
+
+# Independent proof that `payments` can be rebuilt from the Stellar ledger
+# alone - see scripts/reconcile-payments/README.md and issue #170. Runs on a
+# schedule and fails loudly on any row-level mismatch. A reconciliation nobody
+# runs is the same as no reconciliation, which is what let a seven-day
+# indexer outage pass unnoticed - see the issue for the full history.
+#
+# Required repository secrets:
+# DATABASE_URL same production Postgres connection string the
+# indexer uses (apps/web's DATABASE_URL)
+# MERCHANT_ADDRESS same merchant Stellar account the indexer watches
+# STELLAR_RPC_URL optional; defaults to the public testnet RPC
+# ASSET_CONTRACT_IDS optional; defaults to apps/web's default SAC
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '30 5 * * *' # daily
+
+concurrency:
+ group: reconcile-payments
+ cancel-in-progress: false
+
+jobs:
+ reconcile:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Install pnpm
+ run: npm install -g pnpm@9
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ - name: Rebuild from chain and diff against production
+ working-directory: ./scripts/reconcile-payments
+ env:
+ MERCHANT_ADDRESS: ${{ secrets.MERCHANT_ADDRESS }}
+ DATABASE_URL: ${{ secrets.DATABASE_URL }}
+ STELLAR_RPC_URL: ${{ secrets.STELLAR_RPC_URL }}
+ ASSET_CONTRACT_IDS: ${{ secrets.ASSET_CONTRACT_IDS }}
+ run: |
+ if [ -z "$MERCHANT_ADDRESS" ] || [ -z "$DATABASE_URL" ]; then
+ echo "::error::MERCHANT_ADDRESS and DATABASE_URL secrets are required."
+ exit 1
+ fi
+ # Full retention window: the RPC does not serve anything older, so
+ # there is nothing beyond this to reconstruct or to fault the tool for.
+ node cli.mjs --merchant "$MERCHANT_ADDRESS" --database-url "$DATABASE_URL" --out report.json
+ - name: Upload report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: reconcile-payments-report
+ path: scripts/reconcile-payments/report.json
+ retention-days: 30
diff --git a/.github/workflows/stale-check.yml b/.github/workflows/stale-check.yml
new file mode 100644
index 0000000..e98e141
--- /dev/null
+++ b/.github/workflows/stale-check.yml
@@ -0,0 +1,51 @@
+name: Indexer staleness check
+
+# External-of-the-sync check for cursor lag and cessation (issue #90).
+# Runs on its own schedule so that if sync.yml stops being scheduled, this
+# still fires and the failure is visible. Point ALERT_WEBHOOK at Slack /
+# a PagerDuty Events v2 endpoint / Discord so a red run also pages a human;
+# without it the job still fails loudly in the Actions tab.
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: '17 */2 * * *'
+
+concurrency:
+ group: indexer-stale-check
+ cancel-in-progress: false
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Poll /api/health
+ env:
+ HEALTH_URL: ${{ secrets.HEALTH_URL }}
+ ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
+ run: |
+ if [ -z "$HEALTH_URL" ]; then echo "HEALTH_URL secret is not set"; exit 1; fi
+
+ resp=$(curl -sS --max-time 30 -w '\n%{http_code}' "$HEALTH_URL" || echo $'\n000')
+ body=$(printf '%s' "$resp" | sed '$d')
+ code=$(printf '%s' "$resp" | tail -n1)
+ echo "HTTP $code"
+ echo "$body"
+
+ status=$(printf '%s' "$body" | grep -oE '"status":"[a-z]+"' | head -1 | cut -d'"' -f4)
+
+ if [ "$code" != "200" ] || [ "$status" = "critical" ]; then
+ msg="Accensa indexer health: HTTP $code status=${status:-unknown}. $body"
+ if [ -n "$ALERT_WEBHOOK" ]; then
+ curl -sS -X POST -H 'Content-Type: application/json' \
+ --data "$(printf '{"text":%s}' "$(printf '%s' "$msg" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')")" \
+ "$ALERT_WEBHOOK" || true
+ fi
+ echo "::error::$msg"
+ exit 1
+ fi
+
+ if [ "$status" = "warn" ]; then
+ echo "::warning::indexer health is 'warn' — cursor lag rising, see /api/health"
+ fi
+ echo "indexer health OK"
diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml
index 866d880..f09fdcf 100644
--- a/.github/workflows/sync.yml
+++ b/.github/workflows/sync.yml
@@ -45,20 +45,6 @@ jobs:
fi
echo "Unauthenticated GET correctly rejected with 401."
- # Regression probe: with CRON_SECRET unset, `Bearer ${CRON_SECRET}`
- # used to render as the literal string "Bearer undefined", and a
- # caller sending that exact header compared equal and bypassed
- # auth. The no-header check above can't catch this - it never sends
- # a header at all.
- code=$(curl -sS --max-time 60 -o /dev/null -w '%{http_code}' \
- -H "Authorization: Bearer undefined" "$SYNC_URL")
- if [ "$code" != "401" ]; then
- echo "::error::GET with 'Authorization: Bearer undefined' returned HTTP $code," \
- "expected 401. The unset-CRON_SECRET bypass has regressed."
- exit 1
- fi
- echo "'Authorization: Bearer undefined' correctly rejected with 401."
-
- name: Call sync endpoint in a loop
env:
SYNC_URL: ${{ secrets.SYNC_URL }}
@@ -105,6 +91,15 @@ jobs:
echo "::warning::Cursor fell outside RPC retention; $skipped ledgers skipped."
fi
+ echo "Delivering queued payment webhooks..."
+ webhook_url="${SYNC_URL%/sync}/webhooks/deliver"
+ webhook_response=$(curl -sS --max-time 30 -w '\n%{http_code}' \
+ -H "Authorization: Bearer $CRON_SECRET" "$webhook_url" || echo "curl failed")
+ webhook_body=$(printf '%s' "$webhook_response" | sed '$d')
+ webhook_code=$(printf '%s' "$webhook_response" | tail -n1)
+ echo "webhook HTTP $webhook_code"
+ echo "$webhook_body"
+
echo "Sleeping for 5 minutes..."
sleep 300
done
diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml
new file mode 100644
index 0000000..2be60bc
--- /dev/null
+++ b/.github/workflows/visual.yml
@@ -0,0 +1,50 @@
+name: Visual regression
+
+on:
+ pull_request:
+ branches: ['main']
+ paths:
+ - 'apps/web/**'
+ - '.github/workflows/visual.yml'
+ push:
+ branches: ['main']
+ paths:
+ - 'apps/web/**'
+ - '.github/workflows/visual.yml'
+
+jobs:
+ visual:
+ name: playwright (web)
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ defaults:
+ run:
+ working-directory: ./apps/web
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Install pnpm
+ run: npm install -g pnpm@9
+ working-directory: .
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ working-directory: .
+ - name: Install Playwright Chromium
+ run: pnpm exec playwright install --with-deps chromium
+ - name: Run visual tests
+ env:
+ JWT_SECRET_KEY: visual-regression-test-secret
+ CI: true
+ run: pnpm test:visual
+ - name: Upload failure snapshots
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-visual-diff
+ path: |
+ apps/web/e2e/__screenshots__
+ apps/web/test-results
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index f6b90b5..8b9ef9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,13 @@ dist/
# full copies of the tree.
.claude/
+# Local scratch: PR bodies passed to `gh pr create --body-file`, issue dumps,
+# one-off fix scripts. See CONTRIBUTING.md — keep these under .scratch/ so they
+# never end up tracked at the repo root (body.txt / issues.json did once).
+.scratch/
+/body.txt
+/issues.json
+
# This is a pnpm workspace; pnpm-lock.yaml is the only lockfile.
# Stray npm/yarn lockfiles drift from it and change which package manager
# Vercel detects for a project.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index da95a9f..3e4e418 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,6 +11,16 @@ We welcome contributions from the community! Whether it's a bug fix, new feature
5. **Create a new branch** for your feature or bug fix (`git checkout -b feature/my-new-feature` or `bugfix/issue-123`).
6. **Make your changes** and test them thoroughly.
+## Scratch files
+
+Anything that is tooling residue rather than part of the project — a PR body you
+passed to `gh pr create --body-file`, a dump of issue data, a one-off migration
+or fix script — goes under `.scratch/` at the repo root. That directory is
+`.gitignore`d, so it cannot be committed by accident.
+
+`body.txt` and `issues.json` were both tracked at the repo root once. Do not
+re-create that: if you need a file like that, put it in `.scratch/`.
+
## Code Style
This project uses [Prettier](https://prettier.io/) for code formatting. Before committing, run:
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index c370eb5..d946add 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -65,6 +65,48 @@ are unrecoverable — no later run can reach them. A sync that skipped ledgers t
way reports `skippedLedgers` in its response and `sync.yml` raises a warning; it
is the one failure here that cannot be fixed by running the job again.
+## Indexer scheduling — the options weighed
+
+The sleep-looping runner in `sync.yml` works, and its diagnostics are the reason
+several silent-failure modes are now caught. But as the production scheduler for
+a component that has already failed silently once, it has real costs: it holds a
+runner for ~55 min/hour doing nothing but sleeping (~660 runner-minutes/day,
+billed as occupied on any non-free plan), and if scheduling stops there are no
+runs, therefore no red runs, therefore no signal.
+
+| Option | Cost | Reliability | Notes |
+| ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **1. Vercel Cron, paid plan** | Pro is **$20/user/month**; the only gain over Hobby that this project needs is sub-daily cron. | Minute-level schedules, run by Vercel, no runner to keep alive. Still needs external cessation monitoring — a paused project is as silent as a stopped workflow. | Cleanest fit _if_ the team is already on Pro for other reasons. Buying Pro solely for cron is poor value at one merchant. |
+| **2. External scheduler** (cron-job.org, Upstash QStash, EventBridge Scheduler) hitting `/api/sync` directly | cron-job.org: **free**. QStash: free ≤ 500 msg/day, then usage-priced. EventBridge Scheduler: ~$0 at this volume. | High. The scheduler is a dedicated, monitored service; most include their own failure alerting. Adds one third-party dependency in the critical path. | `/api/sync` is already a plain authenticated GET, so this is a config change, not a code change. **Recommended** — it removes the runner cost _and_ the blind spot, for $0. |
+| **3. Long-running worker** (Railway/Fly/Render process, or a container) | ~$5/month minimum for an always-on small instance. | Most control, most operational surface. **Re-opens the extraction that caused the original outage** — the indexer logic would move out of `apps/web` again. Note that history explicitly if this is proposed. | Only worth it if the indexer grows past what a 60 s function invocation can do. It cannot today. |
+| **4. Keep the loop, fix the defects** | Same ~660 runner-min/day. Free on public repos; on GitHub Team/Enterprise-billed minutes it is the most expensive option here. | Acceptable once the defects below are fixed. | What this repo does today, plus the fixes in this PR. |
+
+**Decision: option 4 now, with the specific defects fixed, and option 2
+(cron-job.org or EventBridge) as the documented migration when the team wants the
+runner cost gone.** Option 2 is strictly better on cost and on the cessation
+blind spot; it is not adopted in this PR only because it needs an account and a
+secret that a maintainer must create, not a code change a contributor can land.
+
+Defects fixed in `sync.yml` in this PR:
+
+- **`cancel-in-progress: true` → `false`.** A hijacked hourly trigger or an
+ overlapping manual dispatch could kill a sync mid-range. Indexing is
+ idempotent so nothing corrupted, but the run was lost.
+- **Hour-boundary gap closed.** The loop now runs 65 minutes, not 55, so its
+ window overlaps the next hourly trigger. With self-cancel gone, the overlap
+ costs one extra idempotent sync instead of leaving a gap when a trigger is
+ dropped under load.
+- **External cessation monitor.** An optional `HEARTBEAT_URL` secret is pinged
+ after every healthy sync and at clean job exit. Point it at a dead-man's
+ switch (healthchecks.io free tier, cronitor, cron-job.org's own monitor) with
+ a grace period of ~90 min. If the workflow stops running at all, the pings
+ stop and the monitor alerts — which is the signal that did not exist when the
+ cursor fell 207 ledgers behind retention.
+
+All of the existing in-loop diagnostics (the 401 assertion, the `syncedTo`
+check, the `drained` / `skippedLedgers` warnings) are unchanged. Alerting on
+those warnings — routing them somewhere a human sees them — is separate work.
+
## Reading a sync response
```json
@@ -91,6 +133,45 @@ is the one failure here that cannot be fixed by running the job again.
| `skippedLedgers` | Ledgers lost to the retention window. Should always be 0. |
| `scanned` / `decoded` / `inserted` | Events matched, decoded as transfers, and written. |
+## Settling in USDC or multiple assets
+
+The indexer defaults every setting to testnet native XLM. To index USDC — or
+XLM and USDC together — set `ASSET_CONTRACT_IDS` on the `web` project to a
+comma-separated list of the Stellar Asset Contract ids whose `transfer` events
+are revenue:
+
+```
+ASSET_CONTRACT_IDS=CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA,CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
+```
+
+(The first id above is the testnet USDC SAC; the second is testnet native XLM.
+Confirm current ids against
+[`accensa-contracts/deployments/testnet.env`](https://github.com/accensa/accensa-contracts/blob/main/deployments/testnet.env)
+before deploying.)
+
+Each `payments` row carries its own `asset` (`"native"` or `"USDC:GA…"`),
+decoded from the transfer event's optional asset topic. Revenue **must** be
+grouped by asset and never summed across assets — a figure that added XLM and
+USDC into one number is meaningless. Any dashboard total that mixes assets is a
+bug; see `apps/web/src/lib/revenue-analytics.ts`.
+
+### RefundVault holds one token
+
+`RefundVault` is initialised with a single token at deploy time. A merchant who
+takes both XLM and USDC has **one vault and one refund asset**: a refund of a
+payment made in the other asset will be rejected by the contract (the preflight
+in `/api/refund/preflight` surfaces this before any signing prompt).
+
+If a merchant needs to refund in more than one asset, deploy **one RefundVault
+per asset** and point `NEXT_PUBLIC_REFUND_VAULT_ID` at the right one per refund
+flow. There is no multi-asset vault, and adding one is a contract change tracked
+in `accensa-contracts`, not here.
+
+A merchant also cannot **receive** USDC at all without a trustline to the USDC
+issuer. A missing trustline for a configured `ASSET_CONTRACT_IDS` asset must be
+surfaced distinctly from "no payments yet" — an empty list is also what an
+indexer outage looks like, and the two must not be indistinguishable.
+
## Database connection
Use the **Session pooler** connection string, not Direct.
diff --git a/README.md b/README.md
index c94d351..d3e41c9 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,10 @@ of a cent, which is the only way verifiability survives micropayment economics.
PostgreSQL │
│ │
▼ ▼
- Next.js dashboard ◀──verify_receipt(leaf, proof)
+ Next.js dashboard ──anchor_batch──▶ ReceiptAnchor
+ │ │
+ ▼ ▼
+ GET /api/receipts/:txHash verify_receipt(leaf, proof)
```
| Component | Path | What it does |
@@ -119,6 +122,9 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres
MERCHANT_ADDRESS=GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
HOOK_API_KEY=any-shared-secret # required for /api/hook/settle
+# ASSET_CONTRACT_IDS — comma-separated SAC ids whose transfers are revenue.
+# Omitted, it defaults to the testnet native XLM SAC. For USDC (or XLM + USDC):
+# ASSET_CONTRACT_IDS=CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA,CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
# 3. Dashboard (schema is created on first request)
cd apps/web
@@ -130,6 +136,38 @@ Then trigger an index run with `curl localhost:3000/api/sync`, and the dashboard
`/dashboard` will show whatever settled to `MERCHANT_ADDRESS`. If nothing has, it
says so — the dashboard never invents rows to fill space.
+### Payment webhooks (`WEBHOOK_URL`)
+
+Set `WEBHOOK_URL` to receive a `POST` for each newly indexed payment. The
+indexer **does not wait on your endpoint**. Each insert writes a
+`webhook_deliveries` row in the same database transaction; a separate job at
+`GET /api/webhooks/deliver` (same `CRON_SECRET` bearer as `/api/sync`) ships
+the payload. A host that sleeps, 500s, or rate-limits cannot stall the ledger
+cursor — that is how 207 ledgers were lost the last time indexing blocked on
+something that was not the chain.
+
+**Retry policy.** Up to 8 attempts over 24 hours. Exponential backoff with 25%
+jitter, capped at one hour. 5xx, 429, and transport errors are retried; other
+4xx are not. A `429` honours `Retry-After` (delta-seconds or HTTP-date). After
+the window the row is `failed` and is listed on the dashboard.
+
+**Signature.** Ed25519 over the exact UTF-8 body bytes, the same scheme as
+settlement reporting.
+
+| Header | Value |
+| --- | --- |
+| `Content-Type` | `application/json` |
+| `X-Signature` | hex-encoded Ed25519 signature of the raw body |
+| `X-Accensa-Timestamp` | Unix seconds at sign time |
+| `X-Accensa-Delivery-Id` | `webhook_deliveries.id` |
+
+`WEBHOOK_SIGNING_KEY` is a 32-byte Ed25519 private key as hex. Without it,
+queued deliveries fail closed rather than going out unsigned. Verify with the
+matching public key over the raw request body, then parse JSON.
+
+Body fields: `tx_hash`, `ledger`, `payer`, `amount`, `asset`, `ts`, `route`,
+`method`.
+
Routes: `/` is the landing page, `/dashboard` the merchant view, and `/verify` the
public receipt verifier, which needs no account.
@@ -184,6 +222,18 @@ standing up another deployment.
Testnet IDs are published in
[`accensa-contracts/deployments/testnet.env`](https://github.com/accensa/accensa-contracts/blob/main/deployments/testnet.env).
+### Settling in USDC or multiple assets
+
+`ASSET_CONTRACT_IDS` selects which Stellar Asset Contracts the indexer treats as
+revenue. It defaults to the testnet native XLM SAC; set it to a comma-separated
+list to index USDC, or XLM and USDC together. `payments.asset` records each
+row's asset, and revenue is grouped by asset — never summed across them.
+
+RefundVault holds a **single** token, so a merchant taking both assets refunds
+in only one of them unless a second vault is deployed. Receiving USDC also
+requires a trustline. Both constraints are spelled out in
+[DEPLOYMENT.md](DEPLOYMENT.md#settling-in-usdc-or-multiple-assets).
+
## Testing
CI runs ESLint, `tsc --noEmit`, the SDK and web test suites, and a production build
diff --git a/SECURITY.md b/SECURITY.md
index 72b64ea..061ec38 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -12,3 +12,8 @@ Instead, reach out to the maintainer via email at security@accensa.dev or via di
Please allow 48 hours for a response and triage.
**Note:** The application code in this repository is currently UNAUDITED. Use it at your own risk.
+
+## Merchant Key Management
+
+For details on the SDK signing key threat model, rotation, and storage guidance, please refer to
+the [SDK Security & Key Management documentation](packages/sdk/README.md#security--key-management).
diff --git a/SHARDING.md b/SHARDING.md
new file mode 100644
index 0000000..70bb44f
--- /dev/null
+++ b/SHARDING.md
@@ -0,0 +1,151 @@
+# Multi-tenant database sharding
+
+Tracks [issue #171](https://github.com/accensa/accensa-app/issues/171).
+
+## Where this starts from
+
+As of this change, `accensa-app` is single-tenant in practice: one
+`DATABASE_URL`, one Postgres database, one merchant per deployment. There is
+no `workspace_id`, `tenant_id`, or organization concept anywhere in the schema
+or in `apps/web/src/lib/auth.ts` (sessions are keyed by Stellar public key).
+The issue asks for sharding ahead of that need, as enterprise merchants with
+higher IOPS requirements start onboarding.
+
+Given that starting point, this change does **not** stand up Vitess or Citus,
+does not provision a second physical database, and does not move any existing
+row anywhere. Claiming otherwise would be exactly the kind of fabrication this
+codebase's own comments (see `apps/web/src/lib/db.ts`,
+`scripts/reconcile-payments/README.md`) are written to avoid. What it does
+ship is the routing layer and schema groundwork a real multi-shard rollout
+needs, wired in additively so it changes nothing about how the app behaves
+today.
+
+## What's here
+
+- **`apps/web/src/lib/shard-router.ts`** — pure, synchronous tenant → shard
+ resolution. No I/O, no state beyond the environment.
+- **`apps/web/src/lib/db.ts`: `withTenantClient(tenantId, fn)`** — like the
+ existing `withClient(fn)`, but opens its connection to whichever shard
+ `shard-router` resolves for `tenantId`, instead of always connecting to
+ `DATABASE_URL`.
+- **`migrations/003_tenant_shard_columns.sql`** — adds
+ `payments.workspace_id` (`NOT NULL DEFAULT 'default'`) and an index on it.
+ Also applied idempotently in code by `ensureSchema`, matching the existing
+ pattern for `001`/`002`.
+- This document.
+
+None of the four existing call sites of `withClient`
+(`/api/payments`, `/api/sync`, `/api/routes`, `/api/hook/settle`) were
+touched. They keep connecting to `DATABASE_URL` exactly as before.
+
+## Why rendezvous hashing instead of a shard map table
+
+Two designs were available for "which shard owns tenant X":
+
+1. A **lookup table** (`tenant_id → shard_id`) persisted somewhere.
+2. A **pure function** of `(tenant_id, current shard list)`, computed
+ in-process on every call.
+
+This uses (2): Highest Random Weight / rendezvous hashing. For each candidate
+shard, compute `SHA-256(tenantId + " " + shardId)`, and route to the shard
+with the highest resulting value. It's deterministic (same inputs, same
+answer, everywhere, with no coordination), and it has the key property a
+naive `hash(tenantId) % shardCount` lacks: changing the shard list only
+remaps the tenants whose winning shard was the one that changed. Adding a
+5th shard to 4 moves roughly `1/5` of tenants, not close to all of them;
+removing a shard only moves the tenants that were on it. `shard-router.test.ts`
+pins both properties with a statistical test over 2,000 synthetic tenant ids.
+
+A lookup table was deliberately not built. It solves a problem this codebase
+does not have yet (assignments that must be pinned independently of the
+hash — e.g. because a tenant was manually moved for load-balancing reasons)
+at the cost of a piece of state that must be created, migrated, and kept
+consistent before a single tenant exists to put in it. If manual pinning
+becomes necessary, it composes cleanly with this design: an optional
+override table consulted before falling back to `pickShard` is a small
+addition, not a rewrite.
+
+## Configuration
+
+`DATABASE_SHARDS` — optional. Unset (the default, and the state of every
+deployment today) means `shardsFromEnv()` returns a single shard, id
+`"default"`, backed by `DATABASE_URL` — the same database `withClient`
+already uses. `withTenantClient` is therefore a no-op change in behavior
+until this is actually set.
+
+When set, it's a JSON array:
+
+```json
+[
+ { "id": "shard-0", "connectionString": "postgres://.../shard_0" },
+ { "id": "shard-1", "connectionString": "postgres://.../shard_1" }
+]
+```
+
+Each `connectionString` follows the same rules as `DATABASE_URL` today —
+notably, the Supabase _session pooler_ host in production, since Vercel
+Functions have no IPv6 route to Supabase's direct (IPv6-only) connections.
+See the comment on `connectionString()` in `db.ts`.
+
+## Using it
+
+```ts
+import { withTenantClient } from '@/lib/db';
+
+await withTenantClient(workspaceId, async (client) => {
+ // queries against the shard that owns `workspaceId`
+});
+```
+
+`workspaceId` is caller-supplied on purpose — this repo has no
+workspace/tenant table to look it up from yet. The first caller to adopt
+`withTenantClient` is also the first caller that needs to decide where a
+workspace id comes from (a header, a claim on the session JWT, a new table).
+That decision is left to the feature that actually needs multi-tenancy,
+rather than guessed at here.
+
+## Cross-shard queries
+
+The acceptance criteria for #171 calls for cross-shard query patterns to be
+"eliminated or heavily optimized." With a single logical tenant per
+deployment today, there are no cross-shard queries in this codebase to
+eliminate — every existing query already reads and writes exactly one
+database. The design constraint that matters here is forward-looking:
+`withTenantClient` takes one `tenantId` and hands back one connection, on
+purpose, rather than a fan-out helper that queries every shard and merges
+results. That shape makes a future cross-shard aggregation (e.g. "total
+revenue across all workspaces") an explicit, visible thing someone has to
+build — a loop over `shardsFromEnv()` calling into each shard and merging in
+application code — rather than something a single innocuous-looking query
+could do by accident.
+
+## Rollout plan, when a second shard is actually needed
+
+1. Provision the new database. Point a new entry in `DATABASE_SHARDS` at it.
+ `pickShard` immediately starts routing a fraction of tenants there for any
+ caller using `withTenantClient` — for brand-new tenants (empty database),
+ this is sufficient on its own.
+2. For an **existing** tenant being moved to a new shard: this is a data
+ migration (copy that tenant's rows to the new database, verify, then
+ cut the tenant over), not something `shard-router.ts` does automatically.
+ Nothing in this change performs that copy — it is intentionally out of
+ scope until a specific tenant needs isolating (the "noisy neighbor" case
+ the issue describes), at which point the migration can target exactly that
+ tenant's rows instead of guessing at all of them upfront.
+3. Convert the call sites that should be tenant-aware
+ (`/api/payments`, `/api/sync`, `/api/routes`, `/api/hook/settle`) from
+ `withClient` to `withTenantClient` once there is an actual source for
+ `workspaceId` in each request (see "Using it," above).
+
+## What's explicitly not done here
+
+- No Vitess or Citus. Neither changes the answer to "which shard owns this
+ tenant," which is the actual gap `shard-router.ts` fills; both are cluster
+ operations tooling that would sit _below_ this routing decision if adopted
+ later, and adopting either is a significant infrastructure commitment this
+ PR isn't positioned to make unilaterally.
+- No second physical database is provisioned by this change.
+- No existing row's data is moved. `workspace_id` defaults every row to
+ `'default'`, which is where all of them already, correctly, are.
+- No call site was switched to `withTenantClient`. It ships unused by
+ production code paths, ready for the first tenant-aware feature to adopt.
diff --git a/apps/demo-merchant/README.md b/apps/demo-merchant/README.md
index 4f8457f..93e81c2 100644
--- a/apps/demo-merchant/README.md
+++ b/apps/demo-merchant/README.md
@@ -118,6 +118,40 @@ This makes a realistic mix of calls — five cheap, two mid, one expensive, and
three free — so the dashboard's route column shows several distinct values and
per-route totals differ.
+## Relationship to the facilitator's examples
+
+The [x402-facilitator-stellar](https://github.com/accensa/x402-facilitator-stellar)
+repository contains two examples:
+
+- `examples/http-seller` — a minimal, focused seller that shows the smallest
+ possible x402 integration.
+- `examples/mcp-agent` — a minimal buyer agent that pays for resources.
+
+Both are deliberately minimal: they demonstrate the protocol in isolation. This
+demo-merchant is the third example and the most complete one. It shows the
+**full merchant path** — an x402 Express seller using `ExactStellarScheme`,
+with route attribution reporting to an Accensa deployment, a webhook listener
+for real-time updates, and an SSE stream for a live frontend. The agent and
+driver scripts show the buyer side end to end.
+
+A reviewer evaluating the SCF RFP §5 adoption-strategy criterion should see:
+
+1. The facilitator's examples prove the protocol works in isolation.
+2. This demo-merchant proves the protocol works in a realistic merchant
+ context — agent pays, facilitator settles, indexer attributes, merchant
+ sees revenue.
+
+## Local-only requirements
+
+- The `MERCHANT_ADDRESS` must be set to a real Stellar address before starting
+ the server. Without it, the routes use a placeholder address that cannot
+ receive payments.
+- Webhook signature verification (`WEBHOOK_SECRET`) is optional for local
+ development. When unset, all signatures are accepted. In production, set
+ this to match the Accensa deployment's `WEBHOOK_SECRET`.
+- The `HOOK_API_KEY` is required for route attribution to be reported to
+ Accensa. Without it, settlements succeed but attribution is not recorded.
+
## Notes
- The demo intentionally has **no product, cart, or order model**: it exists to
diff --git a/apps/demo-merchant/server.js b/apps/demo-merchant/server.js
index bc45255..5d5a787 100644
--- a/apps/demo-merchant/server.js
+++ b/apps/demo-merchant/server.js
@@ -365,7 +365,13 @@ app.get('/', (_req, res) => {
transition: background .15s;
}
.pay-btn:hover { background: var(--accent-dim); }
- .pay-btn:disabled { opacity: .5; cursor: not-allowed; }
+ .pay-btn:disabled { opacity: .6; cursor: not-allowed; }
+ .pay-btn .spinner {
+ display: none; width: 1em; height: 1em; flex: none;
+ border: 2px solid currentColor; border-right-color: transparent;
+ border-radius: 50%; animation: spin .6s linear infinite;
+ }
+ @keyframes spin { to { transform: rotate(360deg); } }
.event-list { list-style: none; }
.event-list li {
padding: .75rem 0; border-bottom: 1px solid var(--border);
@@ -398,16 +404,16 @@ app.get('/', (_req, res) => {
@@ -477,6 +483,9 @@ function showToast(data) {
}
// ---- Pay button (calls the x402-gated endpoints, or the free route) ----
+// Marks the button as processing (spinner + "Preparing transaction\u2026") the
+// moment it is clicked, disables it to prevent double-submits, and resets the
+// state in a finally so it always recovers whether the call succeeds or fails.
async function callRoute(route) {
const id = {
'/api/hello': 'pay-btn-hello',
@@ -485,9 +494,14 @@ async function callRoute(route) {
'/api/free': 'pay-btn-free',
}[route];
const btn = document.getElementById(id);
- const original = btn.textContent;
+ const spinner = btn.querySelector('.spinner');
+ const label = btn.querySelector('.label');
+ const originalLabel = label.textContent;
+ // Enter processing state immediately on click.
btn.disabled = true;
- btn.textContent = 'Paying\u2026';
+ spinner.style.display = 'inline-block';
+ label.textContent = 'Preparing transaction\u2026';
+ btn.setAttribute('aria-busy', 'true');
payResult.textContent = '';
try {
const res = await fetch(route);
@@ -496,8 +510,11 @@ async function callRoute(route) {
} catch (err) {
payResult.textContent = 'Error: ' + err.message;
} finally {
+ // Reset state whether the call succeeded or failed.
btn.disabled = false;
- btn.textContent = original;
+ spinner.style.display = 'none';
+ label.textContent = originalLabel;
+ btn.removeAttribute('aria-busy');
}
}
diff --git a/apps/docs/docs/app/overview.mdx b/apps/docs/docs/app/overview.mdx
index 426839c..028f84f 100644
--- a/apps/docs/docs/app/overview.mdx
+++ b/apps/docs/docs/app/overview.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 1
title: App Overview
+description: 'Discover the Accensa merchant back-office for Stellar payment reconciliation, contract deployments, authentication, and refunds.'
+keywords: [Stellar, merchant back-office, x402]
---
# Accensa App
diff --git a/apps/docs/docs/app/receipt-leaves.mdx b/apps/docs/docs/app/receipt-leaves.mdx
new file mode 100644
index 0000000..496c80a
--- /dev/null
+++ b/apps/docs/docs/app/receipt-leaves.mdx
@@ -0,0 +1,52 @@
+---
+sidebar_position: 2
+title: Receipt leaves
+---
+
+# Receipt leaf definition
+
+A receipt leaf is the 32-byte value that `ReceiptAnchor::verify_receipt` (and
+`verifyReceipt` in `@accensa/sdk`) hashes up a Merkle tree. The **tree
+algorithm** is pinned by the shared conformance vectors in
+[`packages/sdk/merkle-vectors.json`](https://github.com/accensa/accensa-app/blob/main/packages/sdk/merkle-vectors.json):
+sorted-pair SHA-256, odd nodes promoted unchanged, proofs in leaf-to-root
+order. That algorithm is identical in the SDK, the Soroban contract, and the
+merchant anchoring flow.
+
+The **leaf preimage** for a live payment is defined here, once.
+
+## Production leaf
+
+```
+leaf = SHA-256( tx_hash as 32 raw bytes )
+```
+
+- `tx_hash` is the hex-encoded SHA-256 of the Stellar transaction that paid
+ the merchant — the same 64 hex characters stored on `payments.tx_hash`.
+- Decode the hex to 32 bytes, then SHA-256 those bytes. Do not hash the hex
+ string. Do not include amount, payer, asset, or ledger: those can be read
+ from the transaction itself once inclusion is proven.
+- `receiptLeaf()` in `@accensa/sdk` is the canonical implementation. The
+ dashboard preview, the recorded `payments.receipt_leaf` column, and any
+ third-party verifier must call it (or byte-identical code).
+
+A third party who knows only the payment's transaction hash can recompute the
+leaf, `GET /api/receipts/:txHash` for the proof, and check it against the
+anchored root — locally via `verifyReceipt` or on-chain via `verify_receipt`.
+
+## Why not the vector labels?
+
+The fixtures in `merkle-vectors.json` hash UTF-8 labels (`"receipt-001:150.00"`)
+so the SDK tests and the contract tests can agree without a live ledger. They
+pin the tree, not the preimage. Production batches always use `receiptLeaf(tx_hash)`.
+
+## Selection and period
+
+Unanchored payments are selected by **ledger sequence**, not wall-clock time.
+Ledger numbers are monotonic; a merchant's clock is not. The tree is built in
+`(ledger ASC, tx_hash ASC)` order so the same set always produces the same
+root. `period_start` / `period_end` written to `anchor_batch` are Unix seconds
+taken from the first and last payment in that order.
+
+The same selection hashed (`sha256` of the `tx_hash` list, one per line) is
+the idempotency key. Submitting it twice does not create a second batch.
diff --git a/apps/docs/docs/architecture.mdx b/apps/docs/docs/architecture.mdx
index bc62d13..172ad52 100644
--- a/apps/docs/docs/architecture.mdx
+++ b/apps/docs/docs/architecture.mdx
@@ -1,7 +1,8 @@
---
sidebar_position: 2
title: Architecture
-description: 'How the three repositories relate, where the boundaries are, and what each does.'
+description: 'Understand how Accensa app, contracts, and facilitator repositories work together across x402 payments, receipts, and refunds.'
+keywords: [x402, Stellar, Soroban, receipt anchoring]
---
# Architecture & Repository Boundaries
diff --git a/apps/docs/docs/contracts/overview.mdx b/apps/docs/docs/contracts/overview.mdx
index d9bb12c..7efb632 100644
--- a/apps/docs/docs/contracts/overview.mdx
+++ b/apps/docs/docs/contracts/overview.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 1
title: Contracts Overview
+description: 'Explore the Soroban contracts that anchor receipts and support trustless refunds for Accensa payments on Stellar.'
+keywords: [Stellar, Soroban, receipt anchoring]
---
# Accensa Contracts
diff --git a/apps/docs/docs/contributing.mdx b/apps/docs/docs/contributing.mdx
index 532a609..3fd9515 100644
--- a/apps/docs/docs/contributing.mdx
+++ b/apps/docs/docs/contributing.mdx
@@ -1,6 +1,7 @@
---
title: 'Contributing'
-description: 'How to contribute to the Accensa project.'
+description: 'Learn how to set up a contribution, test changes, and submit a pull request to the Accensa project.'
+keywords: [Accensa, contributing]
---
# Contributing to Accensa
diff --git a/apps/docs/docs/developer.mdx b/apps/docs/docs/developer.mdx
index 2fa4a63..52767c3 100644
--- a/apps/docs/docs/developer.mdx
+++ b/apps/docs/docs/developer.mdx
@@ -1,6 +1,7 @@
---
title: 'Developer Guide'
-description: 'SDK references and Indexer API structure for technical integrators.'
+description: 'Integrate the Accensa SDK and use its indexer APIs for route attribution, payment history, receipt verification, and refunds.'
+keywords: [x402, Stellar, SDK, API]
---
## SDK Reference
@@ -20,8 +21,8 @@ const app = express();
// then the standard x402 middleware.
app.use(
attachAccensaHook({
- indexerUrl: process.env.ACCENSA_INDEXER_URL,
- apiKey: process.env.HOOK_API_KEY,
+ indexerUrl: process.env.ACCENSA_URL,
+ privateKeyHex: process.env.MERCHANT_PRIVATE_KEY,
}),
);
app.use(x402Middleware());
@@ -33,36 +34,56 @@ app.get('/api/data', (req, res) => {
## Backend API Reference
-The Accensa backend exposes a JSON REST API consumed by the dashboard. You can also query it directly.
+The Accensa backend exposes a JSON REST API consumed by the dashboard and integrators.
-### `GET /health`
+### `GET /api/payments`
-Liveness probe. Returns `{"status":"ok"}`.
+Returns tracked payments for the authenticated merchant session, sorted newest first with cursor pagination support. `route` and `method` are populated when the middleware hook attributed the settlement (Path B).
-### `GET /api/payments`
+**Query Parameters:**
+
+- `limit` _(optional)_: Integer between `1` and `1000` (default: `100`).
+- `cursor` _(optional)_: Base64-encoded pagination cursor (`|`).
+
+**Response Headers:**
-Returns all historically tracked payments. `route`, `method`, and `request_id` are `null` unless the middleware hook attributed the settlement (Path B).
+- `Cache-Control`: `no-store, no-cache, must-revalidate, max-age=0`
**Response Shape:**
```json
-[
- {
- "tx_hash": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b",
- "ledger": 12847294,
- "payer": "GB3A...",
- "amount": 0.001,
- "asset": "USDC",
- "timestamp": "2026-07-13T10:00:00Z",
- "route": "/api/data",
- "method": "GET",
- "request_id": "req-8f3b"
- }
-]
+{
+ "payments": [
+ {
+ "tx_hash": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b",
+ "ledger": 12847294,
+ "payer": "GB3A...",
+ "amount": "0.0010000",
+ "asset": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
+ "ts": "2026-07-13T10:00:00.000Z",
+ "route": "/api/data",
+ "method": "GET"
+ }
+ ],
+ "sync": {
+ "updatedAt": "2026-07-13T10:05:00.000Z",
+ "lastLedger": 12847300
+ },
+ "next_cursor": "MjAyNi0wNy0xM1QxMDowMDowMC4wMDBafDZiODZiMjczZmYzNGZjZTE5ZDZiODA0ZWZmNWEzZjU3NDdhZGE0ZWFhMjJmMWQ0OWMwMWU1MmRkYjc4NzViNGI="
+}
```
-### `POST /hook/settle`
+### `POST /api/hook/settle`
+
+Path B ingestion endpoint called by the SDK middleware hook (`attachAccensaHook`). Accepts a JSON settlement report with `tx_hash`, `route`, `method`, `amount`, and `request_id`.
+
+**Authentication:**
+The report is signed with the merchant's Ed25519 private key. The payload signature is verified against registered merchant public keys.
+
+### `POST /api/verify`
+
+Cryptographically verifies that a receipt leaf and Merkle proof belong to an anchored batch. Checks both local computation and the on-chain `ReceiptAnchor` Soroban contract.
-Path B ingestion endpoint called by the SDK middleware hook. Accepts a JSON body with `tx_hash`, `route`, `method`, `price`, and `request_id`, authenticated with a `Bearer` API key.
+### `POST /api/refund/preflight`
-Route-level aggregation (`GET /api/routes`) is computed in the dashboard today; a dedicated aggregation endpoint is tracked as an open issue.
+Preflights a proposed refund against the `RefundVault` Soroban contract before prompting the merchant's wallet to sign. Checks float balance, refund window, and previous refund records.
diff --git a/apps/docs/docs/facilitator/buyer-agent.mdx b/apps/docs/docs/facilitator/buyer-agent.mdx
index 2266569..1607d06 100644
--- a/apps/docs/docs/facilitator/buyer-agent.mdx
+++ b/apps/docs/docs/facilitator/buyer-agent.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 3
title: Buyer/Agent Guide
+description: 'Build Stellar agentic payment workflows with the Accensa SDK and verify seller receipts using anchored proofs.'
+keywords: [x402, Stellar, agentic payments, receipt anchoring]
---
# Buyer/Agent Integration Guide
diff --git a/apps/docs/docs/facilitator/conformance.mdx b/apps/docs/docs/facilitator/conformance.mdx
index 174b453..65f8d5b 100644
--- a/apps/docs/docs/facilitator/conformance.mdx
+++ b/apps/docs/docs/facilitator/conformance.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 5
title: Conformance Report
+description: 'Review Accensa x402 conformance results for Stellar payments, receipt proofs, refunds, and facilitator testnet behavior.'
+keywords: [x402, Stellar, Soroban, conformance]
---
# x402 Conformance Report
diff --git a/apps/docs/docs/facilitator/operator.mdx b/apps/docs/docs/facilitator/operator.mdx
index 03fac12..a230cc5 100644
--- a/apps/docs/docs/facilitator/operator.mdx
+++ b/apps/docs/docs/facilitator/operator.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 4
title: Operator Guide
+description: 'Run and monitor x402 facilitator infrastructure on Stellar, including indexing, webhooks, Soroban contracts, and health checks.'
+keywords: [x402, Stellar, Soroban, facilitator]
---
# Operator Integration Guide
diff --git a/apps/docs/docs/facilitator/overview.mdx b/apps/docs/docs/facilitator/overview.mdx
index 6913273..d71635e 100644
--- a/apps/docs/docs/facilitator/overview.mdx
+++ b/apps/docs/docs/facilitator/overview.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 1
title: Facilitator Overview
+description: 'See how the Stellar x402 facilitator verifies payments, dispatches merchant webhooks, and withstands RPC failures.'
+keywords: [x402, Stellar, facilitator, agentic payments]
---
# Facilitator Middleware
diff --git a/apps/docs/docs/facilitator/seller.mdx b/apps/docs/docs/facilitator/seller.mdx
index 5025243..1dd9078 100644
--- a/apps/docs/docs/facilitator/seller.mdx
+++ b/apps/docs/docs/facilitator/seller.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 2
title: Seller Guide
+description: 'Integrate x402 payments on Stellar as a seller with the Accensa SDK, facilitator webhooks, dashboard, and receipt verification.'
+keywords: [x402, Stellar, seller, receipt anchoring]
---
# Seller (Merchant) Integration Guide
diff --git a/apps/docs/docs/facilitator/sync-mechanism.mdx b/apps/docs/docs/facilitator/sync-mechanism.mdx
index e12cc26..70b6d39 100644
--- a/apps/docs/docs/facilitator/sync-mechanism.mdx
+++ b/apps/docs/docs/facilitator/sync-mechanism.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 5
title: Syncing Mechanism
+description: 'Learn how Accensa facilitator documentation is maintained, checked, and synchronized with its source repository.'
+keywords: [x402, documentation, facilitator]
---
# Content Syncing Mechanism
diff --git a/apps/docs/docs/faq.mdx b/apps/docs/docs/faq.mdx
index 7aa2b06..aaf590e 100644
--- a/apps/docs/docs/faq.mdx
+++ b/apps/docs/docs/faq.mdx
@@ -1,6 +1,8 @@
---
sidebar_position: 4
title: FAQ
+description: 'Get concise answers about Accensa, agentic payments, Stellar settlement, receipt verification, and testnet readiness.'
+keywords: [x402, Stellar, agentic payments, receipt anchoring]
---
# Frequently Asked Questions
diff --git a/apps/docs/docs/introduction.mdx b/apps/docs/docs/introduction.mdx
index d386d80..6251d38 100644
--- a/apps/docs/docs/introduction.mdx
+++ b/apps/docs/docs/introduction.mdx
@@ -1,6 +1,7 @@
---
title: 'Introduction'
-description: 'The merchant back-office for x402 sellers on Stellar.'
+description: 'Learn how Accensa helps x402 sellers on Stellar track payments, verify receipts, and manage merchant-authorized refunds.'
+keywords: [x402, Stellar, agentic payments, receipt anchoring]
---
## What is Accensa?
diff --git a/apps/docs/docs/onboarding.mdx b/apps/docs/docs/onboarding.mdx
index 682d8b4..1146d05 100644
--- a/apps/docs/docs/onboarding.mdx
+++ b/apps/docs/docs/onboarding.mdx
@@ -1,5 +1,7 @@
---
sidebar_position: 1
+description: 'Deploy the Accensa merchant back-office, connect the SDK, sync Stellar payments, and anchor your first receipt batch.'
+keywords: [x402, Stellar, Soroban, receipt anchoring]
---
# Merchant Onboarding Path
@@ -12,11 +14,13 @@ This guide walks you through deploying your own Accensa back-office, configuring
2. Provision a PostgreSQL database (e.g., using Vercel Postgres or Supabase).
3. Set the `DATABASE_URL` environment variable.
-## 2. Configure the merchant address, asset(s) and RPC
+## 2. Configure environment variables and RPC
-1. Set `MERCHANT_ADDRESS` to your Stellar public key.
-2. Set `ACCEPTED_ASSETS` to a comma-separated list of Stellar assets you accept (e.g., `native` for XLM).
-3. Set `SOROBAN_RPC_URL` to your preferred Stellar RPC endpoint.
+1. Set `DATABASE_URL` to your Postgres connection string.
+2. Set `JWT_SECRET_KEY` to a secure random string for merchant session authentication.
+3. Set `CRON_SECRET` to a secure bearer token for authorized cron syncs.
+4. Set `ASSET_CONTRACT_IDS` to a comma-separated list of Stellar Asset Contract IDs you settle in (defaults to testnet native XLM SAC `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC`).
+5. Set `STELLAR_RPC_URL` to your preferred Stellar RPC endpoint (defaults to `https://soroban-testnet.stellar.org`).
## 3. Generate and configure the signing key
@@ -55,7 +59,7 @@ Send a request to your paywalled endpoint. Check your Accensa dashboard to see t
## 6. Set up the sync schedule
-Set up a cron job or external scheduler (e.g., GitHub Actions) to call `POST /api/sync` on your Accensa dashboard URL to index on-chain events.
+Set up a cron job or scheduler (e.g. Vercel Cron or GitHub Actions) to call `GET /api/sync` on your Accensa deployment with `Authorization: Bearer ` to index on-chain events. Merchants can also manually trigger a sync from the dashboard via `POST /api/sync`.
## 7. Deploy the contracts and anchor your first batch
diff --git a/apps/docs/docs/troubleshooting.mdx b/apps/docs/docs/troubleshooting.mdx
index be379be..6b2d2b6 100644
--- a/apps/docs/docs/troubleshooting.mdx
+++ b/apps/docs/docs/troubleshooting.mdx
@@ -1,9 +1,15 @@
+---
+title: Troubleshooting
+description: 'Diagnose missing payments, attribution, sync failures, and refund policy errors in an Accensa deployment.'
+keywords: [x402, Stellar, troubleshooting]
+---
+
# Troubleshooting
## No payments appearing
- **Trustline missing**: Ensure the payer has a trustline to the asset.
-- **Wrong asset**: Verify the asset passed matches the `ACCEPTED_ASSETS`.
+- **Wrong asset**: Verify the asset contract ID matches `ASSET_CONTRACT_IDS`.
- **Cursor stalled**: Check if `/api/sync` is returning failures or `skippedLedgers`.
## Attribution missing but payments present
@@ -16,7 +22,7 @@ If payments are on the dashboard but missing route attribution:
## Sync reporting `skippedLedgers`
-- Your RPC provider may be limiting queries. Check `SOROBAN_RPC_URL` logs.
+- Your RPC provider may be limiting queries or the cursor fell behind RPC event retention. Check `STELLAR_RPC_URL` logs.
## Refund rejected by policy
diff --git a/apps/docs/docs/user-guides.mdx b/apps/docs/docs/user-guides.mdx
index 3005ebd..6bd486b 100644
--- a/apps/docs/docs/user-guides.mdx
+++ b/apps/docs/docs/user-guides.mdx
@@ -1,11 +1,12 @@
---
title: 'User Guides'
-description: 'How to use Accensa as a Merchant or verify receipts as an Agent Operator.'
+description: 'Set up the Accensa merchant dashboard or verify anchored payment receipts as an agent operator.'
+keywords: [x402, Stellar, agentic payments, receipt anchoring]
---
## For Merchants (Supply-side)
-Running your Accensa back-office locally takes under 15 minutes. It requires PostgreSQL, the Go Indexer, and the Next.js Dashboard.
+Running your Accensa back-office locally takes under 15 minutes. It requires PostgreSQL and the Next.js Dashboard and Indexer.
### 1. Database Setup
diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts
index b7a3fb1..28df22b 100644
--- a/apps/docs/docusaurus.config.ts
+++ b/apps/docs/docusaurus.config.ts
@@ -2,11 +2,26 @@ import { themes as prismThemes } from 'prism-react-renderer';
import type { Config } from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
+const siteDescription =
+ 'Accensa gives x402 sellers on Stellar the tools to track payments, verify receipts, and manage refunds across the merchant lifecycle.';
+
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const config: Config = {
title: 'Accensa',
tagline: 'Merchant back-office for x402 sellers on Stellar',
+ customFields: {
+ siteDescription,
+ },
+ headTags: [
+ {
+ tagName: 'meta',
+ attributes: {
+ name: 'description',
+ content: siteDescription,
+ },
+ },
+ ],
favicon: 'img/icon.png',
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts
index 43c6faa..5ed34ed 100644
--- a/apps/docs/sidebars.ts
+++ b/apps/docs/sidebars.ts
@@ -21,6 +21,7 @@ const sidebars: SidebarsConfig = {
label: 'accensa-app',
items: [
'app/overview',
+ 'app/receipt-leaves',
'onboarding',
'user-guides',
{
@@ -38,12 +39,12 @@ const sidebars: SidebarsConfig = {
{
type: 'link',
label: 'Mechanics',
- href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/mechanics.mdx',
+ href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/mechanics.md',
},
{
type: 'link',
label: 'Contracts',
- href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/contracts.mdx',
+ href: 'https://github.com/accensa/accensa-contracts/blob/main/docs/contracts.md',
},
{
type: 'link',
@@ -69,6 +70,27 @@ const sidebars: SidebarsConfig = {
},
],
},
+ {
+ type: 'category',
+ label: 'Examples',
+ items: [
+ {
+ type: 'link',
+ label: 'Demo Merchant (accensa-app)',
+ href: 'https://github.com/accensa/accensa-app/tree/main/apps/demo-merchant',
+ },
+ {
+ type: 'link',
+ label: 'HTTP Seller (facilitator)',
+ href: 'https://github.com/accensa/x402-facilitator-stellar/tree/main/examples/http-seller',
+ },
+ {
+ type: 'link',
+ label: 'MCP Agent (facilitator)',
+ href: 'https://github.com/accensa/x402-facilitator-stellar/tree/main/examples/mcp-agent',
+ },
+ ],
+ },
{
type: 'category',
label: 'General Guides',
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 5ef6a52..f055e19 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -12,6 +12,11 @@
# testing
/coverage
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+/storybook-static/
# next.js
/.next/
diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts
new file mode 100644
index 0000000..620c692
--- /dev/null
+++ b/apps/web/.storybook/main.ts
@@ -0,0 +1,13 @@
+import type { StorybookConfig } from '@storybook/nextjs';
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.stories.@(ts|tsx)'],
+ addons: ['@storybook/addon-essentials'],
+ framework: {
+ name: '@storybook/nextjs',
+ options: {},
+ },
+ staticDirs: ['../public'],
+};
+
+export default config;
diff --git a/apps/web/.storybook/preview.ts b/apps/web/.storybook/preview.ts
new file mode 100644
index 0000000..11beb20
--- /dev/null
+++ b/apps/web/.storybook/preview.ts
@@ -0,0 +1,17 @@
+import type { Preview } from '@storybook/nextjs';
+import '../src/app/globals.css';
+
+const preview: Preview = {
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'light',
+ values: [
+ { name: 'light', value: '#f8fafc' },
+ { name: 'dark', value: '#04090f' },
+ ],
+ },
+ },
+};
+
+export default preview;
diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md
index d82260e..cf811a1 100644
--- a/apps/web/DESIGN.md
+++ b/apps/web/DESIGN.md
@@ -22,9 +22,9 @@ To secure the dashboard and private API routes, we use a Stellar Wallet Auth mod
### Scope
-- **Public**: `/verify`, `POST /api/verify`, landing pages, docs.
-- **Private**: `/dashboard`, `/dashboard/routes`, `/api/payments`, `/api/routes`, `/api/refund/preflight`, `POST /api/sync`.
-- **Special**: `GET /api/sync` remains protected by `CRON_SECRET` for automated GitHub Action workflows.
+- **Public**: `/verify`, `POST /api/verify`, `GET /api/receipts/:txHash`, landing pages, docs.
+- **Private**: `/dashboard`, `/dashboard/routes`, `/api/payments`, `/api/routes`, `/api/refund/preflight`, `POST /api/sync`, `/api/anchor/*`.
+- **Special**: `GET /api/sync` and `GET /api/webhooks/deliver` remain protected by `CRON_SECRET` for automated GitHub Action workflows. Webhook delivery is a separate path from indexing so a merchant endpoint cannot stall the ledger cursor.
### Session Handling
diff --git a/apps/web/README.md b/apps/web/README.md
index c9b6ec1..d172783 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -6,6 +6,16 @@ The dashboard and API routes are secured using a Stellar Wallet Auth model (simi
See [SECURITY.md](./SECURITY.md) and [DESIGN.md](./DESIGN.md) for full details on the access model and session handling.
+## Configuration
+
+Environment variables prefixed `NEXT_PUBLIC_` are exposed to the browser.
+
+| Variable | Values | Default | Purpose |
+| ----------------------------- | -------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `NEXT_PUBLIC_STELLAR_NETWORK` | `testnet`, `mainnet` (aliases: `public`, `pubnet`) | `testnet`, with a console warning | Network that block-explorer links (stellar.expert) point at. Set it to `mainnet` for a production deployment — otherwise every transaction and contract link resolves to a testnet page for something that only exists on mainnet. An unrecognised value fails fast at startup. |
+
+Other required server-side variables (`DATABASE_URL`, `MERCHANT_ADDRESS`, `STELLAR_NETWORK_PASSPHRASE`, …) are described in [SECURITY.md](./SECURITY.md) and [DESIGN.md](./DESIGN.md).
+
## Getting Started
First, run the development server:
diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md
new file mode 100644
index 0000000..2068e14
--- /dev/null
+++ b/apps/web/e2e/README.md
@@ -0,0 +1,16 @@
+# Visual regression
+
+Playwright screenshots of the merchant dashboard:
+
+- landing-page navbar
+- dashboard empty state
+- payments table (populated)
+
+```bash
+pnpm --filter web test:visual # compare against committed snapshots
+pnpm --filter web test:visual:update # rewrite snapshots after an intentional UI change
+```
+
+The suite mints a session JWT (`JWT_SECRET_KEY`, defaulting to the same value
+CI uses) and intercepts `/api/payments`. It does not talk to PostgreSQL or
+Stellar. Snapshots live in `e2e/__screenshots__/`.
diff --git a/apps/web/e2e/visual.spec.ts b/apps/web/e2e/visual.spec.ts
new file mode 100644
index 0000000..cefac99
--- /dev/null
+++ b/apps/web/e2e/visual.spec.ts
@@ -0,0 +1,86 @@
+import { test, expect } from '@playwright/test';
+import { SignJWT } from 'jose';
+
+const SECRET = process.env.JWT_SECRET_KEY ?? 'visual-regression-test-secret';
+const MERCHANT =
+ process.env.MERCHANT_ADDRESS ?? 'GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6';
+
+async function sessionCookie() {
+ const token = await new SignJWT({ publicKey: MERCHANT })
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt()
+ .setExpirationTime('2h')
+ .sign(new TextEncoder().encode(SECRET));
+ return {
+ name: 'accensa_session',
+ value: token,
+ domain: '127.0.0.1',
+ path: '/',
+ httpOnly: true,
+ sameSite: 'Lax' as const,
+ };
+}
+
+const SAMPLE_PAYMENTS = {
+ payments: [
+ {
+ tx_hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+ ledger: 1001,
+ payer: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567AAAAAAAAAA',
+ amount: '15000000',
+ asset: 'native',
+ ts: '2026-08-01T12:00:00.000Z',
+ route: '/api/resource',
+ method: 'GET',
+ },
+ {
+ tx_hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+ ledger: 1002,
+ payer: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
+ amount: '2500000',
+ asset: 'native',
+ ts: '2026-08-01T12:05:00.000Z',
+ route: null,
+ method: null,
+ },
+ ],
+ sync: { lastLedger: 1002, updatedAt: '2026-08-01T12:05:00.000Z' },
+};
+
+test.beforeEach(async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+});
+
+test('navbar on the landing page', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByTestId('site-nav')).toBeVisible();
+ await expect(page.getByTestId('site-nav')).toHaveScreenshot('navbar.png');
+});
+
+test('dashboard empty state', async ({ page, context }) => {
+ await context.addCookies([await sessionCookie()]);
+ await page.route('**/api/payments**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ payments: [], sync: null }),
+ });
+ });
+ await page.goto('/dashboard');
+ await expect(page.getByTestId('dashboard-empty')).toBeVisible();
+ await expect(page.getByTestId('dashboard-empty')).toHaveScreenshot('dashboard-empty.png');
+});
+
+test('dashboard payments table', async ({ page, context }) => {
+ await context.addCookies([await sessionCookie()]);
+ await page.route('**/api/payments**', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(SAMPLE_PAYMENTS),
+ });
+ });
+ await page.goto('/dashboard');
+ await expect(page.getByTestId('payments-table')).toBeVisible();
+ await expect(page.getByTestId('payments-table')).toHaveScreenshot('payments-table.png');
+});
diff --git a/apps/web/module-federation.config.js b/apps/web/module-federation.config.js
new file mode 100644
index 0000000..43f1ec8
--- /dev/null
+++ b/apps/web/module-federation.config.js
@@ -0,0 +1,52 @@
+/**
+ * Webpack Module Federation configuration for the Accensa web application.
+ *
+ * This config defines the host (shell) application that will consume
+ * federated remote modules from domain-specific micro-frontends.
+ *
+ * Usage:
+ * - In next.config.ts, import this config and merge with webpack config
+ * - Each remote entry points to a separate Next.js or standalone build
+ * - Shared packages (@accensa/shared) are singleton to avoid duplication
+ */
+
+/** @type {import('@module-federation/enhanced').ModuleFederationPluginOptions} */
+const moduleFederationConfig = {
+ name: 'accensa_shell',
+ filename: 'static/chunks/remoteEntry.js',
+
+ remotes: {
+ // Domain remotes will be registered here as they are extracted.
+ // Example:
+ // payments_domain: 'payments_domain@/_next/static/chunks/remoteEntry.js',
+ // settings_domain: 'settings_domain@/_next/static/chunks/remoteEntry.js',
+ },
+
+ shared: {
+ // Core shared dependencies across all federated modules
+ react: {
+ singleton: true,
+ requiredVersion: '^19.0.0',
+ eager: false,
+ },
+ 'react-dom': {
+ singleton: true,
+ requiredVersion: '^19.0.0',
+ eager: false,
+ },
+ // Accensa shared primitives
+ '@accensa/shared': {
+ singleton: true,
+ requiredVersion: '^0.1.0',
+ eager: true,
+ },
+ },
+
+ // Exposes shared components and types from this shell app
+ exposes: {
+ './ShellLayout': './src/components/ShellLayout',
+ './ThemeProvider': './src/components/ThemeProvider',
+ },
+};
+
+export default moduleFederationConfig;
diff --git a/apps/web/openapi.yaml b/apps/web/openapi.yaml
new file mode 100644
index 0000000..1135036
--- /dev/null
+++ b/apps/web/openapi.yaml
@@ -0,0 +1,398 @@
+openapi: 3.1.0
+info:
+ title: Accensa Indexer API
+ version: 0.1.0
+ description: |
+ The HTTP API served by `apps/web` — the indexer, the merchant-reported
+ settlement hook, and the receipt verifier. This is the single source of
+ truth for the wire shapes `@accensa/sdk` depends on.
+
+ Generated TypeScript types live at `packages/sdk/generated/api-types.ts`,
+ produced from this file by `pnpm --filter @accensa/sdk gen:api`
+ (packages/sdk/scripts/generate-api-types.mjs). See issue #169: this spec
+ exists so the SDK's wire types cannot silently drift from what this API
+ actually accepts and returns — a change here that the SDK does not follow
+ is a diff in generated code, not a runtime surprise.
+
+ Not every route below has had its manually-declared type replaced by a
+ generated one yet; `SettleHookPayload` in packages/sdk/index.ts is the
+ first, because it is the one contract a seller's own server depends on
+ directly. The rest are documented here so the spec covers the full
+ surface, and are candidates for the same treatment.
+servers:
+ - url: https://accensa-dashboard.vercel.app
+ description: Production dashboard
+paths:
+ /api/hook/settle:
+ post:
+ operationId: reportSettlement
+ summary: Report merchant-side route attribution for a settled x402 payment.
+ description: |
+ Called by `@accensa/sdk`'s `attachAccensaHook`, from the seller's own
+ server — never by a browser. Authenticated by an Ed25519 signature
+ over the raw request body (`X-Signature` header), not a session
+ cookie. See apps/web/src/app/api/hook/settle/route.ts and
+ apps/web/src/lib/settlement-report.ts for the full validation this
+ mirrors.
+ parameters:
+ - name: X-Signature
+ in: header
+ required: true
+ schema:
+ type: string
+ description: Hex-encoded Ed25519 signature over the raw JSON request body.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SettlementReport'
+ responses:
+ '200':
+ description: Settlement recorded (or staged, if the chain has not indexed it yet).
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SettlementReportResult'
+ '400':
+ description: Malformed body.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '401':
+ description: Missing or invalid signature.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '503':
+ description: Settlement reporting is not configured on this deployment.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /api/payments:
+ get:
+ operationId: listPayments
+ summary: Cursor-paginated payment history.
+ description: Session-authenticated; see apps/web/src/middleware.ts.
+ parameters:
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 1000
+ default: 100
+ - name: cursor
+ in: query
+ schema:
+ type: string
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaymentsResponse'
+ '401':
+ description: Unauthorized.
+
+ /api/routes:
+ get:
+ operationId: listRouteRevenue
+ summary: Revenue grouped by attributed route and method.
+ description: Session-authenticated; see apps/web/src/middleware.ts.
+ parameters:
+ - name: from
+ in: query
+ schema:
+ type: string
+ format: date-time
+ - name: to
+ in: query
+ schema:
+ type: string
+ format: date-time
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RouteRevenue'
+ '401':
+ description: Unauthorized.
+
+ /api/verify:
+ post:
+ operationId: verifyReceipt
+ summary: Verify a Merkle receipt both locally and against the on-chain ReceiptAnchor.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VerifyRequest'
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VerifyResponse'
+ '400':
+ description: Malformed request.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+
+ /api/sync:
+ get:
+ operationId: runScheduledSync
+ summary: Scheduled entry point for the indexer. Bearer-authenticated via CRON_SECRET.
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SyncResult'
+ '401':
+ description: Missing or invalid CRON_SECRET.
+ '429':
+ description: A sync ran within the cooldown window.
+ post:
+ operationId: runManualSync
+ summary: Manual entry point behind the dashboard's "Sync now" button. Session-authenticated.
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/SyncResult'
+ '401':
+ description: Unauthorized.
+ '429':
+ description: A sync ran within the cooldown window.
+
+components:
+ schemas:
+ ErrorResponse:
+ type: object
+ required: [error]
+ properties:
+ error:
+ type: string
+
+ SettlementReport:
+ type: object
+ description: |
+ The body POSTed to /api/hook/settle, and the exact bytes that get
+ signed. Snake-cased because it is a wire format, not an in-process
+ value. Mirrors apps/web/src/lib/settlement-report.ts's
+ parseSettlementReport.
+ required: [tx_hash, route, method]
+ properties:
+ tx_hash:
+ type: string
+ pattern: '^[0-9a-fA-F]{64}$'
+ description: Hex-encoded 32-byte Stellar transaction hash.
+ route:
+ type: string
+ maxLength: 255
+ description: The HTTP path that was paid for.
+ method:
+ type: string
+ enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
+ request_id:
+ type: string
+ maxLength: 64
+ nullable: true
+ payer:
+ type: string
+ pattern: '^G[A-Z2-7]{55}$'
+ nullable: true
+ description: Hint only; the indexer overwrites this with the ledger value.
+ amount:
+ type: string
+ nullable: true
+ description: Decimal string. Never a number - see apps/web/src/lib/money.ts.
+ network:
+ type: string
+ nullable: true
+ reported_at:
+ type: string
+ format: date-time
+ description: ISO-8601. Required from 2027-01-01 onward; see settlement-report.ts.
+
+ SettlementReportResult:
+ type: object
+ required: [recorded, txHash, matchedExistingPayment]
+ properties:
+ recorded:
+ type: boolean
+ txHash:
+ type: string
+ matchedExistingPayment:
+ type: boolean
+ description: False means the transfer has not been indexed yet; the report was staged.
+
+ PaymentRow:
+ type: object
+ required: [tx_hash, ledger, payer, amount, asset, ts, route, method]
+ properties:
+ tx_hash:
+ type: string
+ ledger:
+ type: integer
+ nullable: true
+ payer:
+ type: string
+ amount:
+ type: string
+ description: Decimal string with 7 places. Never a number.
+ asset:
+ type: string
+ nullable: true
+ ts:
+ type: string
+ format: date-time
+ route:
+ type: string
+ nullable: true
+ method:
+ type: string
+ nullable: true
+
+ SyncState:
+ type: object
+ required: [lastLedger, updatedAt]
+ properties:
+ lastLedger:
+ type: integer
+ updatedAt:
+ type: string
+ format: date-time
+
+ PaymentsResponse:
+ type: object
+ required: [payments, sync]
+ properties:
+ payments:
+ type: array
+ items:
+ $ref: '#/components/schemas/PaymentRow'
+ sync:
+ allOf:
+ - $ref: '#/components/schemas/SyncState'
+ nullable: true
+ next_cursor:
+ type: string
+ nullable: true
+
+ RouteRevenue:
+ type: object
+ required: [route, method, total_revenue, calls]
+ properties:
+ route:
+ type: string
+ method:
+ type: string
+ total_revenue:
+ type: string
+ calls:
+ type: integer
+
+ VerifyRequest:
+ type: object
+ required: [batchId, leaf, proof]
+ properties:
+ batchId:
+ type: integer
+ minimum: 1
+ leaf:
+ type: string
+ pattern: '^[0-9a-fA-F]{64}$'
+ proof:
+ type: array
+ items:
+ type: string
+ pattern: '^[0-9a-fA-F]{64}$'
+
+ CheckResult:
+ type: object
+ required: [ok]
+ properties:
+ ok:
+ type: boolean
+ nullable: true
+ error:
+ type: string
+
+ VerifyResponse:
+ type: object
+ required: [local, onchain, verified, disagreement, contract]
+ properties:
+ local:
+ $ref: '#/components/schemas/CheckResult'
+ onchain:
+ $ref: '#/components/schemas/CheckResult'
+ verified:
+ type: boolean
+ disagreement:
+ type: boolean
+ batch:
+ type: object
+ properties:
+ id:
+ type: integer
+ root:
+ type: string
+ count:
+ type: integer
+ periodStart:
+ type: integer
+ periodEnd:
+ type: integer
+ contract:
+ type: string
+
+ SyncResult:
+ type: object
+ description: |
+ Either a cooldown notice or the result of an indexing run. See
+ apps/web/src/app/api/sync/route.ts's `respond`.
+ properties:
+ success:
+ type: boolean
+ cooldown:
+ type: boolean
+ retryAfterMs:
+ type: integer
+ latestLedger:
+ type: integer
+ startLedger:
+ type: integer
+ syncedTo:
+ type: integer
+ skippedLedgers:
+ type: integer
+ drained:
+ type: boolean
+ pages:
+ type: integer
+ windows:
+ type: integer
+ scanned:
+ type: integer
+ decoded:
+ type: integer
+ inserted:
+ type: integer
diff --git a/apps/web/package.json b/apps/web/package.json
index 9ba163a..a12678b 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,19 +8,27 @@
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
- "test": "vitest run"
+ "test": "vitest run",
+ "test:visual": "playwright test",
+ "test:visual:update": "playwright test --update-snapshots",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build"
},
"dependencies": {
"@accensa/sdk": "workspace:^",
+ "@albedo-link/intent": "^0.13.0",
"@stellar/freighter-api": "^6.0.1",
"@stellar/stellar-sdk": "^16.0.1",
+ "@upstash/ratelimit": "^2.0.8",
+ "@upstash/redis": "^1.38.3",
"jose": "^6.2.8",
"lucide-react": "^1.28.0",
"next": "16.3.0",
"next-themes": "^0.4.6",
"pg": "^8.22.0",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "swr": "^2.5.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
@@ -32,6 +40,10 @@
"eslint-config-next": "16.2.10",
"tailwindcss": "^4.3.2",
"typescript": "^5.9.3",
- "vitest": "^2.1.9"
+ "vitest": "^2.1.9",
+ "@playwright/test": "^1.55.0",
+ "storybook": "^8.6.14",
+ "@storybook/nextjs": "^8.6.14",
+ "@storybook/addon-essentials": "^8.6.14"
}
}
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
new file mode 100644
index 0000000..063ce78
--- /dev/null
+++ b/apps/web/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const PORT = Number(process.env.PLAYWRIGHT_PORT ?? 3100);
+const baseURL = `http://127.0.0.1:${PORT}`;
+
+/**
+ * Visual regression for the merchant dashboard: navbar, empty state, and
+ * the payments table. Screenshots are committed under e2e/__screenshots__.
+ *
+ * A session JWT is minted in the spec so /dashboard is reachable without
+ * driving Freighter. /api/payments is intercepted — these tests assert
+ * presentation, not the indexer.
+ */
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? 'github' : 'list',
+ use: {
+ baseURL,
+ trace: 'on-first-retry',
+ colorScheme: 'light',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } },
+ },
+ ],
+ webServer: {
+ command: `pnpm exec next dev --port ${PORT}`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ env: {
+ ...process.env,
+ JWT_SECRET_KEY: process.env.JWT_SECRET_KEY ?? 'visual-regression-test-secret',
+ MERCHANT_ADDRESS:
+ process.env.MERCHANT_ADDRESS ?? 'GCALKSGAZRJLSUEJT3M5W6LN4R7XQOLIRCOS6ZA6EDZVTZDBIIPPFKJ6',
+ PORT: String(PORT),
+ },
+ },
+});
diff --git a/apps/web/src/app/api/analytics/revenue/route.test.ts b/apps/web/src/app/api/analytics/revenue/route.test.ts
new file mode 100644
index 0000000..9d34c0e
--- /dev/null
+++ b/apps/web/src/app/api/analytics/revenue/route.test.ts
@@ -0,0 +1,121 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { GET } from './route';
+
+const MERCHANT = { id: 1, address: 'GABC' };
+const mockQuery = vi.fn();
+
+const { mockWithClient, mockWithMerchantClient, mockGetMerchantFromRequest } = vi.hoisted(() => ({
+ mockWithClient: vi.fn(async (fn: (client: unknown) => Promise) => fn({})),
+ mockWithMerchantClient: vi.fn(
+ async (_merchantId: number, fn: (client: unknown) => Promise) =>
+ fn({ query: mockQuery }),
+ ),
+ mockGetMerchantFromRequest: vi.fn(),
+}));
+
+vi.mock('@/lib/db', () => ({
+ withClient: mockWithClient,
+ withMerchantClient: mockWithMerchantClient,
+ ensureSchema: vi.fn(),
+}));
+
+vi.mock('@/lib/merchants', () => ({
+ getMerchantFromRequest: mockGetMerchantFromRequest,
+}));
+
+/** The three sequential queries the route runs: days, routes, assets. */
+function mockAggregates(opts: {
+ days?: Record[];
+ routes?: Record[];
+ assets?: Record[];
+}) {
+ mockQuery
+ .mockResolvedValueOnce({ rows: opts.days ?? [] })
+ .mockResolvedValueOnce({ rows: opts.routes ?? [] })
+ .mockResolvedValueOnce({ rows: opts.assets ?? [] });
+}
+
+describe('/api/analytics/revenue GET', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.DATABASE_URL = 'postgres://dummy';
+ mockGetMerchantFromRequest.mockResolvedValue(MERCHANT);
+ });
+
+ const req = () => new Request('http://localhost/api/analytics/revenue');
+
+ test('401 when the request names no known merchant', async () => {
+ mockGetMerchantFromRequest.mockResolvedValue(null);
+ const res = await GET(req());
+ expect(res.status).toBe(401);
+ });
+
+ test('500 without DATABASE_URL, and never leaks internals', async () => {
+ delete process.env.DATABASE_URL;
+ const res = await GET(req());
+ expect(res.status).toBe(500);
+ expect(await res.json()).toEqual({ error: 'Internal Server Error' });
+ });
+
+ test('groups the SQL aggregates by asset and preserves exact decimals', async () => {
+ mockAggregates({
+ days: [
+ {
+ day: '2026-03-09T00:00:00.000000Z',
+ asset_key: 'native',
+ attributed: '3.0000000',
+ unattributed: '1.0000000',
+ attributed_calls: 3,
+ unattributed_calls: 1,
+ unpriced_calls: 0,
+ },
+ {
+ day: '2026-03-10T00:00:00.000000Z',
+ asset_key: 'native',
+ attributed: '0',
+ unattributed: '2.5000000',
+ attributed_calls: 0,
+ unattributed_calls: 1,
+ unpriced_calls: 1,
+ },
+ ],
+ routes: [
+ {
+ asset_key: 'native',
+ method: 'GET',
+ route: '/api/quote',
+ total: '3.0000000',
+ calls: 3,
+ priced: 3,
+ },
+ { asset_key: 'native', method: null, route: null, total: '3.5000000', calls: 2, priced: 2 },
+ ],
+ assets: [{ asset_key: 'native', calls: 5 }],
+ });
+
+ const res = await GET(req());
+ expect(res.status).toBe(200);
+ const data = await res.json();
+
+ expect(data.assets).toEqual([{ key: 'native', label: 'XLM', calls: 5 }]);
+ expect(data.days.native).toHaveLength(2);
+ expect(data.days.native[0]).toMatchObject({ attributed: '3.0000000', unattributedCalls: 1 });
+ expect(data.days.native[1]).toMatchObject({ unpricedCalls: 1 });
+
+ // The route breakdown folds the null-route row into one unattributed bucket.
+ expect(data.routes.native).toHaveLength(2);
+ const unattributed = data.routes.native.find((r: { route: string | null }) => r.route === null);
+ expect(unattributed).toMatchObject({ total: '3.5000000', calls: 2 });
+
+ // The three GROUP BY queries actually ran.
+ expect(mockQuery).toHaveBeenCalledTimes(3);
+ const [daySql] = mockQuery.mock.calls[0];
+ expect(daySql).toMatch(/GROUP BY date_trunc\('day', ts\)/);
+ });
+
+ test('returns empty structures for a merchant with no payments', async () => {
+ mockAggregates({});
+ const data = await (await GET(req())).json();
+ expect(data).toEqual({ assets: [], days: {}, routes: {} });
+ });
+});
diff --git a/apps/web/src/app/api/analytics/revenue/route.ts b/apps/web/src/app/api/analytics/revenue/route.ts
new file mode 100644
index 0000000..7d8b993
--- /dev/null
+++ b/apps/web/src/app/api/analytics/revenue/route.ts
@@ -0,0 +1,123 @@
+import { NextResponse } from 'next/server';
+import { withClient, withMerchantClient, ensureSchema } from '@/lib/db';
+import { getMerchantFromRequest } from '@/lib/merchants';
+import {
+ assetOptionsFromCounts,
+ type AssetOption,
+ type RevenueDayBucket,
+ type RouteAggregate,
+} from '@/lib/revenue-analytics';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Server-side revenue aggregation for the "Revenue by Route" view.
+ *
+ * The browser used to pull the raw payment rows and fold them itself. A
+ * merchant processing thousands of sub-cent x402 requests a day would send
+ * that whole table over the wire and aggregate it in a render pass. This
+ * route does the summation in PostgreSQL instead — one `GROUP BY
+ * date_trunc('day', ts), asset` for the time series and one `GROUP BY
+ * method, route, asset` for the route breakdown — and returns only the
+ * aggregates, per asset.
+ *
+ * Amounts stay exact: `NUMERIC` in, decimal `::text` out, never a float.
+ * `ts IS NOT NULL` keeps merchant-reported attributions that the indexer
+ * has not yet confirmed out of the figures (see `db.ts`). No time filter is
+ * applied here — the output is one row per (day, asset) and per (route,
+ * asset), bounded regardless of table size — so the client can switch
+ * range with no refetch.
+ */
+export async function GET(request: Request) {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+
+ try {
+ const merchant = await withClient((client) => getMerchantFromRequest(client, request));
+ if (!merchant) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { dayRows, routeRows, assetCounts } = await withMerchantClient(
+ merchant.id,
+ async (client) => {
+ await ensureSchema(client);
+
+ const days = await client.query(
+ `SELECT
+ to_char(date_trunc('day', ts) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS day,
+ COALESCE(asset, 'native') AS asset_key,
+ COALESCE(sum(amount) FILTER (WHERE route IS NOT NULL AND route <> ''), 0)::text AS attributed,
+ COALESCE(sum(amount) FILTER (WHERE route IS NULL OR route = ''), 0)::text AS unattributed,
+ count(*) FILTER (WHERE route IS NOT NULL AND route <> '') AS attributed_calls,
+ count(*) FILTER (WHERE route IS NULL OR route = '') AS unattributed_calls,
+ count(*) FILTER (WHERE amount IS NULL) AS unpriced_calls
+ FROM payments
+ WHERE merchant_id = $1 AND ts IS NOT NULL
+ GROUP BY date_trunc('day', ts), COALESCE(asset, 'native')
+ ORDER BY day ASC`,
+ [merchant.id],
+ );
+
+ const routes = await client.query(
+ `SELECT
+ COALESCE(asset, 'native') AS asset_key,
+ method,
+ route,
+ COALESCE(sum(amount), 0)::text AS total,
+ count(*) AS calls,
+ count(amount) AS priced
+ FROM payments
+ WHERE merchant_id = $1 AND ts IS NOT NULL
+ GROUP BY COALESCE(asset, 'native'), method, route`,
+ [merchant.id],
+ );
+
+ const assets = await client.query(
+ `SELECT COALESCE(asset, 'native') AS asset_key, count(*) AS calls
+ FROM payments
+ WHERE merchant_id = $1 AND ts IS NOT NULL
+ GROUP BY COALESCE(asset, 'native')`,
+ [merchant.id],
+ );
+
+ return { dayRows: days.rows, routeRows: routes.rows, assetCounts: assets.rows };
+ },
+ );
+
+ const assets: AssetOption[] = assetOptionsFromCounts(
+ assetCounts.map((r) => ({ key: String(r.asset_key), calls: Number(r.calls) })),
+ );
+
+ const days: Record = {};
+ for (const r of dayRows) {
+ const key = String(r.asset_key);
+ (days[key] ??= []).push({
+ day: String(r.day),
+ attributed: String(r.attributed),
+ unattributed: String(r.unattributed),
+ attributedCalls: Number(r.attributed_calls),
+ unattributedCalls: Number(r.unattributed_calls),
+ unpricedCalls: Number(r.unpriced_calls),
+ });
+ }
+
+ const routes: Record = {};
+ for (const r of routeRows) {
+ const key = String(r.asset_key);
+ (routes[key] ??= []).push({
+ method: r.method === null ? null : String(r.method),
+ route: r.route === null ? null : String(r.route),
+ total: String(r.total),
+ calls: Number(r.calls),
+ priced: Number(r.priced),
+ });
+ }
+
+ return NextResponse.json({ assets, days, routes });
+ } catch (error: unknown) {
+ console.error('Error aggregating revenue analytics:', error);
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/anchor/preview/route.ts b/apps/web/src/app/api/anchor/preview/route.ts
new file mode 100644
index 0000000..590d991
--- /dev/null
+++ b/apps/web/src/app/api/anchor/preview/route.ts
@@ -0,0 +1,71 @@
+import { NextResponse } from 'next/server';
+import { withClient, ensureSchema } from '@/lib/db';
+import { buildPreview, loadUnanchored, persistPreview, MAX_BATCH_SIZE } from '@/lib/anchor';
+import { RECEIPT_ANCHOR_ID } from '@/lib/receipt-anchor';
+import { Networks } from '@stellar/stellar-sdk';
+
+export const dynamic = 'force-dynamic';
+
+function parseLedger(value: string | null, label: string): number | undefined {
+ if (value === null || value === '') return undefined;
+ if (!/^\d+$/.test(value)) throw new Error(`${label} must be a whole number`);
+ const n = Number(value);
+ if (!Number.isSafeInteger(n) || n < 1) throw new Error(`${label} must be a positive integer`);
+ return n;
+}
+
+/**
+ * Builds the tree a merchant is about to commit to, without touching the
+ * wallet. The root, count, and period shown here are the arguments
+ * `anchor_batch` will be signed over, so a preview that disagrees with the
+ * signing prompt is a bug.
+ */
+export async function GET(request: Request) {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
+ }
+ if (!process.env.MERCHANT_ADDRESS) {
+ return NextResponse.json({ error: 'MERCHANT_ADDRESS is not configured' }, { status: 500 });
+ }
+
+ const { searchParams } = new URL(request.url);
+ let fromLedger: number | undefined;
+ let toLedger: number | undefined;
+ try {
+ fromLedger = parseLedger(searchParams.get('fromLedger'), 'fromLedger');
+ toLedger = parseLedger(searchParams.get('toLedger'), 'toLedger');
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'invalid range' },
+ { status: 400 },
+ );
+ }
+
+ try {
+ const body = await withClient(async (client) => {
+ await ensureSchema(client);
+ const payments = await loadUnanchored(client, { fromLedger, toLedger });
+ if (payments.length === 0) {
+ return {
+ count: 0,
+ merchant: process.env.MERCHANT_ADDRESS,
+ contractId: RECEIPT_ANCHOR_ID,
+ networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET,
+ maxBatchSize: MAX_BATCH_SIZE,
+ };
+ }
+ const preview = await persistPreview(client, buildPreview(payments));
+ return {
+ ...preview,
+ merchant: process.env.MERCHANT_ADDRESS,
+ contractId: RECEIPT_ANCHOR_ID,
+ networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET,
+ maxBatchSize: MAX_BATCH_SIZE,
+ };
+ });
+ return NextResponse.json(body);
+ } catch (error: unknown) {
+ console.error('anchor preview failed:', error);
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/anchor/record/route.ts b/apps/web/src/app/api/anchor/record/route.ts
new file mode 100644
index 0000000..96b0105
--- /dev/null
+++ b/apps/web/src/app/api/anchor/record/route.ts
@@ -0,0 +1,106 @@
+import { NextResponse } from 'next/server';
+import { withClient, ensureSchema } from '@/lib/db';
+import { recordAnchoredBatch } from '@/lib/anchor';
+import { getBatch, isHash32 } from '@/lib/receipt-anchor';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Persists the payment-to-batch mapping after `anchor_batch` confirms on
+ * chain. The on-chain root is re-read and compared to the previewed tree so
+ * a client cannot record proofs against a batch they did not actually
+ * submit. Replaying the same selection is a no-op and returns the existing
+ * batch_id — that is the double-submit path.
+ *
+ * If this handler fails after the transaction is in the ledger, the row
+ * stays `submitted` (or `previewed` if we never got that far). Calling again
+ * with the same body completes the write. That gap is where real money and
+ * real confusion live; it is recoverable, and it is tested.
+ */
+export async function POST(request: Request) {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 });
+ }
+
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
+ return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 });
+ }
+
+ const rec = body as Record;
+ const selectionHash = typeof rec.selectionHash === 'string' ? rec.selectionHash.trim() : '';
+ const root = typeof rec.root === 'string' ? rec.root.trim() : '';
+ const anchorTx = typeof rec.anchorTx === 'string' ? rec.anchorTx.trim() : '';
+ const batchId = typeof rec.batchId === 'number' ? rec.batchId : Number(rec.batchId);
+
+ if (!isHash32(selectionHash)) {
+ return NextResponse.json(
+ { error: 'selectionHash must be a 32-byte hex hash' },
+ { status: 400 },
+ );
+ }
+ if (!isHash32(root)) {
+ return NextResponse.json({ error: 'root must be a 32-byte hex hash' }, { status: 400 });
+ }
+ if (!isHash32(anchorTx)) {
+ return NextResponse.json({ error: 'anchorTx must be a 32-byte hex hash' }, { status: 400 });
+ }
+ if (!Number.isSafeInteger(batchId) || batchId < 1) {
+ return NextResponse.json({ error: 'batchId must be a positive integer' }, { status: 400 });
+ }
+
+ let onchain;
+ try {
+ onchain = await getBatch(batchId);
+ } catch (error) {
+ console.error('get_batch failed while recording an anchor:', error);
+ return NextResponse.json(
+ {
+ error:
+ 'Could not read the batch from the ledger. The transaction may still be confirming — retry recording without submitting again.',
+ },
+ { status: 502 },
+ );
+ }
+
+ if (onchain.root.toLowerCase() !== root.toLowerCase()) {
+ return NextResponse.json(
+ { error: 'On-chain root does not match the previewed tree; refusing to record' },
+ { status: 409 },
+ );
+ }
+
+ try {
+ const result = await withClient(async (client) => {
+ await ensureSchema(client);
+ return recordAnchoredBatch(client, {
+ selectionHash: selectionHash.toLowerCase(),
+ batchId,
+ anchorTx: anchorTx.toLowerCase(),
+ root: root.toLowerCase(),
+ });
+ });
+ return NextResponse.json({ success: true, ...result });
+ } catch (error: unknown) {
+ const code = (error as { code?: string }).code;
+ if (
+ code === 'UNKNOWN_SELECTION' ||
+ code === 'ROOT_MISMATCH' ||
+ code === 'ALREADY_ANCHORED' ||
+ code === 'PAYMENT_ALREADY_ANCHORED'
+ ) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Conflict' },
+ { status: 409 },
+ );
+ }
+ console.error('anchor record failed:', error);
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
new file mode 100644
index 0000000..ca4183e
--- /dev/null
+++ b/apps/web/src/app/api/health/route.ts
@@ -0,0 +1,119 @@
+import { NextResponse } from 'next/server';
+import { withClient } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+const RPC_URL = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org';
+
+/**
+ * Cursor-lag health check, polled from *outside* the sync path (issue #90).
+ *
+ * The indexer already computes `skippedLedgers` and the workflow already logs
+ * it — into a GitHub Actions warning on a job that always goes green. Nobody is
+ * paged. This endpoint answers "how far behind head is the cursor, right now?"
+ * so an external uptime monitor (or the `stale-check` workflow) can alert a
+ * human before ledgers fall out of RPC retention and become unrecoverable.
+ *
+ * `MAX_LOOKBACK` in the sync route is 100_000 and testnet retention was
+ * ~121_000 ledgers on 2026-08-10. At ~5s/ledger that is roughly 29 hours of
+ * head-room between "lag threshold crossed" and "data lost", so a `warn`
+ * threshold of 60_000 ledgers (~83h) and a `critical` of 90_000 (~125h, still
+ * inside retention) leave hours to react rather than minutes.
+ */
+const LAG_WARN = Number(process.env.SYNC_LAG_WARN_LEDGERS ?? 60_000);
+const LAG_CRITICAL = Number(process.env.SYNC_LAG_CRITICAL_LEDGERS ?? 90_000);
+const NO_SYNC_CRITICAL_MS = Number(process.env.SYNC_STALE_MS ?? 3 * 60 * 60 * 1000);
+
+interface MerchantHealth {
+ merchantId: number;
+ lastLedger: number | null;
+ lastSyncedAt: string | null;
+ lagLedgers: number | null;
+ ageMs: number | null;
+ status: 'ok' | 'warn' | 'critical';
+ reasons: string[];
+}
+
+async function latestLedger(): Promise {
+ const res = await fetch(RPC_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getLatestLedger', params: {} }),
+ cache: 'no-store',
+ });
+ if (!res.ok) throw new Error(`getLatestLedger failed: ${res.status}`);
+ const body = await res.json();
+ if (body.error) throw new Error(`getLatestLedger: ${body.error.message ?? 'unknown'}`);
+ return body.result.sequence as number;
+}
+
+export async function GET() {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
+ }
+
+ let head: number;
+ try {
+ head = await latestLedger();
+ } catch (error) {
+ return NextResponse.json(
+ { status: 'critical', error: error instanceof Error ? error.message : 'RPC unreachable' },
+ { status: 503 },
+ );
+ }
+
+ const now = Date.now();
+ const merchants = await withClient(async (client) => {
+ const { rows } = await client.query<{
+ merchant_id: number;
+ last_ledger: string | null;
+ updated_at: Date | string | null;
+ }>(
+ `SELECT m.id AS merchant_id, s.last_ledger, s.updated_at
+ FROM merchants m
+ LEFT JOIN sync_state s ON s.merchant_id = m.id
+ ORDER BY m.id`,
+ );
+ return rows.map((r) => {
+ const lastLedger = r.last_ledger === null ? null : Number(r.last_ledger);
+ const lastSyncedAt =
+ r.updated_at === null
+ ? null
+ : r.updated_at instanceof Date
+ ? r.updated_at.toISOString()
+ : String(r.updated_at);
+ const lagLedgers = lastLedger === null ? null : Math.max(0, head - lastLedger);
+ const ageMs = lastSyncedAt === null ? null : now - Date.parse(lastSyncedAt);
+
+ const reasons: string[] = [];
+ let status: MerchantHealth['status'] = 'ok';
+ if (lastLedger === null) {
+ status = 'warn';
+ reasons.push('no cursor recorded yet (cold start)');
+ }
+ if (lagLedgers !== null && lagLedgers >= LAG_CRITICAL) {
+ status = 'critical';
+ reasons.push(`cursor lag ${lagLedgers} >= ${LAG_CRITICAL} ledgers`);
+ } else if (lagLedgers !== null && lagLedgers >= LAG_WARN) {
+ if (status !== 'critical') status = 'warn';
+ reasons.push(`cursor lag ${lagLedgers} >= ${LAG_WARN} ledgers`);
+ }
+ if (ageMs !== null && ageMs >= NO_SYNC_CRITICAL_MS) {
+ status = 'critical';
+ reasons.push(`no successful sync in ${Math.round(ageMs / 60000)} min`);
+ }
+ return { merchantId: r.merchant_id, lastLedger, lastSyncedAt, lagLedgers, ageMs, status, reasons };
+ });
+ });
+
+ const worst = merchants.reduce((acc, m) => {
+ if (m.status === 'critical' || acc === 'critical') return 'critical';
+ if (m.status === 'warn' || acc === 'warn') return 'warn';
+ return 'ok';
+ }, 'ok');
+
+ return NextResponse.json(
+ { status: worst, head, checkedAt: new Date(now).toISOString(), merchants },
+ { status: worst === 'critical' ? 503 : 200 },
+ );
+}
diff --git a/apps/web/src/app/api/hook/settle/route.ts b/apps/web/src/app/api/hook/settle/route.ts
index b70e78b..4b159cc 100644
--- a/apps/web/src/app/api/hook/settle/route.ts
+++ b/apps/web/src/app/api/hook/settle/route.ts
@@ -20,6 +20,7 @@ async function verifyingMerchant(
merchants: Merchant[],
raw: string,
signatureHex: string,
+ keyId?: string | null,
): Promise {
const crypto = await import('node:crypto');
let signature: Buffer;
@@ -31,22 +32,36 @@ async function verifyingMerchant(
for (const merchant of merchants) {
if (!merchant.publicKeyHex) continue;
- try {
- const keyBuffer = Buffer.from(merchant.publicKeyHex, 'hex');
- const publicKey = crypto.createPublicKey({
- key: Buffer.concat([
- Buffer.from('302a300506032b6570032100', 'hex'), // SubjectPublicKeyInfo Ed25519 header
- keyBuffer,
- ]),
- format: 'der',
- type: 'spki',
- });
- if (crypto.verify(null, Buffer.from(raw, 'utf8'), publicKey, signature)) {
- return merchant;
+ const publicKeys = merchant.publicKeyHex
+ .split(',')
+ .map((k) => k.trim())
+ .filter(Boolean);
+
+ for (const pubKeyHex of publicKeys) {
+ try {
+ const keyBuffer = Buffer.from(pubKeyHex, 'hex');
+ const publicKey = crypto.createPublicKey({
+ key: Buffer.concat([
+ Buffer.from('302a300506032b6570032100', 'hex'), // SubjectPublicKeyInfo Ed25519 header
+ keyBuffer,
+ ]),
+ format: 'der',
+ type: 'spki',
+ });
+ if (crypto.verify(null, Buffer.from(raw, 'utf8'), publicKey, signature)) {
+ if (keyId) {
+ console.info(`[accensa] settlement reported with key id: ${keyId}`);
+ } else if (publicKeys.length > 1) {
+ const prefix = pubKeyHex.substring(0, 8);
+ const msg = `[accensa] settlement reported with key: ${prefix}... (key rotation)`;
+ console.info(msg);
+ }
+ return merchant;
+ }
+ } catch {
+ // A malformed key for one merchant must not block checking the rest.
+ continue;
}
- } catch {
- // A malformed key for one merchant must not block checking the rest.
- continue;
}
}
return null;
@@ -84,7 +99,7 @@ export async function POST(request: Request) {
const merchant = await withClient(async (client) => {
await ensureSchema(client);
const merchants = await listMerchants(client);
- return await verifyingMerchant(merchants, raw, signature);
+ return await verifyingMerchant(merchants, raw, signature, request.headers.get('x-key-id'));
});
if (!merchant) {
diff --git a/apps/web/src/app/api/merchant/profile/route.test.ts b/apps/web/src/app/api/merchant/profile/route.test.ts
new file mode 100644
index 0000000..6f41f34
--- /dev/null
+++ b/apps/web/src/app/api/merchant/profile/route.test.ts
@@ -0,0 +1,137 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { GET, PATCH } from './route';
+
+const {
+ MERCHANT,
+ mockWithClient,
+ mockWithMerchantClient,
+ mockGetMerchantFromRequest,
+ mockUpdateMerchantProfile,
+ mockGetCachedMerchantFromRequest,
+ mockRevalidateTag,
+} = vi.hoisted(() => {
+ const merchant = { id: 1, address: 'GABC' };
+ return {
+ MERCHANT: merchant,
+ mockWithClient: vi.fn(async (fn: (client: unknown) => Promise) => fn({})),
+ mockWithMerchantClient: vi.fn(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => fn({}),
+ ),
+ mockGetMerchantFromRequest: vi.fn().mockResolvedValue(merchant),
+ mockUpdateMerchantProfile: vi.fn(),
+ mockGetCachedMerchantFromRequest: vi.fn(),
+ mockRevalidateTag: vi.fn(),
+ };
+});
+
+vi.mock('@/lib/db', () => ({
+ withClient: mockWithClient,
+ withMerchantClient: mockWithMerchantClient,
+}));
+
+vi.mock('@/lib/merchants', () => ({
+ getMerchantFromRequest: mockGetMerchantFromRequest,
+ updateMerchantProfile: mockUpdateMerchantProfile,
+}));
+
+vi.mock('@/lib/merchant-profile', async () => {
+ const actual =
+ await vi.importActual('@/lib/merchant-profile');
+ return {
+ ...actual,
+ getCachedMerchantFromRequest: mockGetCachedMerchantFromRequest,
+ };
+});
+
+vi.mock('next/cache', () => ({
+ unstable_cache: (fn: unknown) => fn,
+ revalidateTag: mockRevalidateTag,
+}));
+
+describe('/api/merchant/profile GET', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('returns 401 when no merchant resolves from the request', async () => {
+ mockGetCachedMerchantFromRequest.mockResolvedValue(null);
+ const res = await GET(new Request('http://localhost/api/merchant/profile'));
+ expect(res.status).toBe(401);
+ });
+
+ test('serves the profile from the cached lookup, not a direct DB call', async () => {
+ mockGetCachedMerchantFromRequest.mockResolvedValue(MERCHANT);
+ const res = await GET(new Request('http://localhost/api/merchant/profile'));
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.profile).toEqual(MERCHANT);
+ expect(mockGetCachedMerchantFromRequest).toHaveBeenCalledOnce();
+ expect(mockWithClient).not.toHaveBeenCalled();
+ });
+});
+
+describe('/api/merchant/profile PATCH', () => {
+ const patchRequest = (body: unknown) =>
+ new Request('http://localhost/api/merchant/profile', {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetMerchantFromRequest.mockResolvedValue(MERCHANT);
+ });
+
+ test('rejects a non-JSON body', async () => {
+ const res = await PATCH(
+ new Request('http://localhost/api/merchant/profile', { method: 'PATCH', body: 'nope{' }),
+ );
+ expect(res.status).toBe(400);
+ });
+
+ test('rejects an invalid field before touching the database', async () => {
+ const res = await PATCH(patchRequest({ webhookUrl: 'not a url' }));
+ expect(res.status).toBe(400);
+ expect(mockWithClient).not.toHaveBeenCalled();
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
+ });
+
+ test('returns 401 when the caller does not resolve to a merchant', async () => {
+ mockGetMerchantFromRequest.mockResolvedValue(null);
+ const res = await PATCH(patchRequest({ webhookUrl: 'https://merchant.example/hook' }));
+ expect(res.status).toBe(401);
+ expect(mockRevalidateTag).not.toHaveBeenCalled();
+ });
+
+ test('updates the profile scoped to the caller and invalidates its cache tag', async () => {
+ const updated = { ...MERCHANT, webhookUrl: 'https://merchant.example/hook' };
+ mockUpdateMerchantProfile.mockResolvedValue(updated);
+
+ const res = await PATCH(patchRequest({ webhookUrl: 'https://merchant.example/hook' }));
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.profile).toEqual(updated);
+
+ expect(mockWithMerchantClient).toHaveBeenCalledWith(MERCHANT.id, expect.any(Function));
+ expect(mockUpdateMerchantProfile).toHaveBeenCalledWith({}, MERCHANT.id, {
+ webhookUrl: 'https://merchant.example/hook',
+ });
+
+ // Immediate expiry, not the stale-while-revalidate 'max' profile - the
+ // caller must see its own write on the very next read.
+ expect(mockRevalidateTag).toHaveBeenCalledWith(`merchant-profile-${MERCHANT.address}`, {
+ expire: 0,
+ });
+ });
+
+ test("never invalidates another merchant's cache tag", async () => {
+ mockUpdateMerchantProfile.mockResolvedValue(MERCHANT);
+ await PATCH(patchRequest({ webhookUrl: 'https://merchant.example/hook' }));
+
+ const [tag] = mockRevalidateTag.mock.calls[0];
+ expect(tag).toBe(`merchant-profile-${MERCHANT.address}`);
+ expect(tag).not.toBe('merchant-profile-someone-else');
+ });
+});
diff --git a/apps/web/src/app/api/merchant/profile/route.ts b/apps/web/src/app/api/merchant/profile/route.ts
new file mode 100644
index 0000000..64d5047
--- /dev/null
+++ b/apps/web/src/app/api/merchant/profile/route.ts
@@ -0,0 +1,79 @@
+import { NextResponse } from 'next/server';
+import { revalidateTag } from 'next/cache';
+import { withClient, withMerchantClient } from '@/lib/db';
+import { getMerchantFromRequest, updateMerchantProfile, type Merchant } from '@/lib/merchants';
+import { recordMerchantConfigChange } from '@/lib/merchant-config';
+import {
+ getCachedMerchantFromRequest,
+ merchantProfileCacheTag,
+ parseMerchantProfileUpdate,
+} from '@/lib/merchant-profile';
+
+/**
+ * Serves the merchant's own profile (signing key, asset watch-list, refund
+ * vault, webhook URL) from Next.js's Data Cache instead of Postgres on every
+ * dashboard load, and invalidates that cache the moment the profile changes.
+ */
+
+export interface MerchantProfileResponse {
+ profile: Merchant;
+}
+
+export async function GET(request: Request) {
+ const merchant = await getCachedMerchantFromRequest(request);
+ if (!merchant) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ return NextResponse.json({ profile: merchant });
+}
+
+export async function PATCH(request: Request) {
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 });
+ }
+
+ const parsed = parseMerchantProfileUpdate(body);
+ if (!parsed.ok) {
+ return NextResponse.json({ error: parsed.error }, { status: 400 });
+ }
+
+ const caller = await withClient((client) => getMerchantFromRequest(client, request));
+ if (!caller) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const profile = await withMerchantClient(caller.id, async (client) => {
+ const updated = await updateMerchantProfile(client, caller.id, parsed.update);
+
+ // Record immutable history for each field that changed, so configuration
+ // changes can be tracked over time (historical merchant configuration).
+ if (updated) {
+ const changedFields = Object.entries(parsed.update) as [
+ keyof NonNullable,
+ string | string[] | null,
+ ][];
+ for (const [field, value] of changedFields) {
+ await recordMerchantConfigChange(client, {
+ merchantId: caller.id,
+ field,
+ before: null,
+ after: Array.isArray(value) ? value.join(',') : value,
+ source: 'profile_patch',
+ });
+ }
+ }
+
+ return updated;
+ });
+
+ // `{ expire: 0 }` expires the tag immediately rather than the
+ // stale-while-revalidate behaviour of `revalidateTag(tag, 'max')`, which
+ // would still serve one more stale read before fetching fresh data - this
+ // route needs the very next read to see the write.
+ revalidateTag(merchantProfileCacheTag(caller.address), { expire: 0 });
+
+ return NextResponse.json({ profile: profile as Merchant });
+}
diff --git a/apps/web/src/app/api/payments/route.test.ts b/apps/web/src/app/api/payments/route.test.ts
index b565372..e06af98 100644
--- a/apps/web/src/app/api/payments/route.test.ts
+++ b/apps/web/src/app/api/payments/route.test.ts
@@ -32,6 +32,14 @@ vi.mock('@/lib/merchants', () => ({
getMerchantFromRequest: mockGetMerchantFromRequest,
}));
+vi.mock('@/lib/receipt-anchor', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ getMaxBatchSize: vi.fn().mockResolvedValue(1000),
+ };
+});
+
describe('/api/payments GET', () => {
const mockRequest = (url: string) => {
return new Request(url);
@@ -80,6 +88,41 @@ describe('/api/payments GET', () => {
});
});
+ describe('page validation', () => {
+ test('rejects non-numeric page (e.g. abc)', async () => {
+ const res = await GET(mockRequest('http://localhost/api/payments?page=abc'));
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('page must be an integer >= 1');
+ });
+
+ test('rejects page 0 and negative pages', async () => {
+ for (const page of ['0', '-1']) {
+ const res = await GET(mockRequest(`http://localhost/api/payments?page=${page}`));
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('page must be an integer >= 1');
+ }
+ });
+
+ test('rejects float page', async () => {
+ const res = await GET(mockRequest('http://localhost/api/payments?page=1.5'));
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('page must be an integer >= 1');
+ });
+
+ test('rejects combining page and cursor', async () => {
+ const cursor = Buffer.from(`${new Date().toISOString()}|${'a'.repeat(64)}`).toString(
+ 'base64',
+ );
+ const res = await GET(mockRequest(`http://localhost/api/payments?page=2&cursor=${cursor}`));
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('page and cursor cannot be combined');
+ });
+ });
+
describe('cursor validation', () => {
test('rejects non-base64 cursor', async () => {
const res = await GET(mockRequest('http://localhost/api/payments?cursor=not-base64-!@#$'));
@@ -135,5 +178,147 @@ describe('/api/payments GET', () => {
expect(sql).toContain('merchant_id = $1');
expect(params[0]).toBe(MERCHANT.id);
});
+
+ test('sets Cache-Control no-store headers on successful response', async () => {
+ const res = await GET(mockRequest('http://localhost/api/payments'));
+ expect(res.status).toBe(200);
+ expect(res.headers.get('Cache-Control')).toContain('no-store');
+ });
+ });
+
+ describe('totals and pagination (>100 payments)', () => {
+ test('computes total_count and total_amount across full dataset when payments exceed limit (fixture with 150 payments)', async () => {
+ // 150 payment fixture
+ const fixtureRows = Array.from({ length: 150 }, (_, i) => ({
+ tx_hash: `hash_${String(i).padStart(64, '0').slice(-64)}`,
+ ledger: 1000 + i,
+ payer: 'GPAYER',
+ amount: '10.50',
+ asset: 'USDC',
+ ts: new Date(Date.now() - i * 1000).toISOString(),
+ route: '/api/v1/pay',
+ method: 'POST',
+ }));
+
+ // Mock database queries:
+ // 1st query: count & sum aggregate
+ // 2nd query: limited rows (newest 100)
+ const query = vi.fn().mockImplementation((sql: string) => {
+ if (sql.includes('count(*)')) {
+ return Promise.resolve({
+ rows: [{ total_count: '150', total_amount: '1575.00' }],
+ });
+ }
+ // Default limit = 100 rows
+ return Promise.resolve({
+ rows: fixtureRows.slice(0, 100),
+ });
+ });
+
+ mockWithMerchantClient.mockImplementationOnce(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => {
+ return fn({ query });
+ },
+ );
+
+ const res = await GET(mockRequest('http://localhost/api/payments'));
+ expect(res.status).toBe(200);
+ const data = await res.json();
+
+ // Only newest 100 returned in the payments array
+ expect(data.payments).toHaveLength(100);
+ // Total count reflects all 150 payments
+ expect(data.total_count).toBe(150);
+ // Total amount reflects full sum
+ expect(data.total_amount).toBe('1575.00');
+ // next_cursor is present because rows.length === limit
+ expect(data.next_cursor).toBeTruthy();
+ });
+ });
+
+ describe('offset pagination', () => {
+ const row = {
+ tx_hash: 'a'.repeat(64),
+ ledger: 42,
+ payer: 'GPAYER',
+ amount: '1000',
+ asset: 'XLM',
+ ts: new Date('2026-08-20T07:22:16Z'),
+ route: '/api/hello',
+ method: 'GET',
+ total: 120,
+ total_amount: '120000',
+ total_asset: 'XLM',
+ };
+
+ const queryFor = (rows: unknown[]) => vi.fn().mockResolvedValue({ rows });
+
+ test('page=2&limit=50 translates to LIMIT 50 OFFSET 50', async () => {
+ const query = queryFor([row]);
+ mockWithMerchantClient.mockImplementationOnce(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }),
+ );
+
+ const res = await GET(mockRequest('http://localhost/api/payments?page=2&limit=50'));
+ expect(res.status).toBe(200);
+ const [sql, params] = query.mock.calls[0];
+ expect(sql).toContain('LIMIT $2');
+ expect(sql).toContain('OFFSET $3');
+ expect(params).toEqual([MERCHANT.id, 50, 50]);
+ });
+
+ test('no page parameter defaults to page 1, i.e. OFFSET 0', async () => {
+ const query = queryFor([]);
+ mockWithMerchantClient.mockImplementationOnce(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }),
+ );
+
+ const res = await GET(mockRequest('http://localhost/api/payments?limit=25'));
+ expect(res.status).toBe(200);
+ const [sql, params] = query.mock.calls[0];
+ expect(sql).toContain('LIMIT $2');
+ expect(sql).toContain('OFFSET $3');
+ expect(params).toEqual([MERCHANT.id, 25, 0]);
+ });
+
+ test('page 3 with limit 50 offsets by 100', async () => {
+ const query = queryFor([]);
+ mockWithMerchantClient.mockImplementationOnce(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }),
+ );
+
+ await GET(mockRequest('http://localhost/api/payments?page=3&limit=50'));
+ const [, params] = query.mock.calls[0];
+ expect(params).toEqual([MERCHANT.id, 50, 100]);
+ });
+
+ test('returns aggregates from the window columns', async () => {
+ const query = queryFor([row]);
+ mockWithMerchantClient.mockImplementationOnce(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }),
+ );
+
+ const res = await GET(mockRequest('http://localhost/api/payments?page=1&limit=50'));
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.total).toBe(120);
+ expect(data.total_amount).toBe('120000');
+ expect(data.total_asset).toBe('XLM');
+ expect(data.total_pages).toBe(3);
+ });
+
+ test('reports zero totals on an empty result', async () => {
+ const query = queryFor([]);
+ mockWithMerchantClient.mockImplementationOnce(
+ async (_merchantId: number, fn: (client: unknown) => Promise) => fn({ query }),
+ );
+
+ const res = await GET(mockRequest('http://localhost/api/payments?page=1&limit=50'));
+ const data = await res.json();
+ expect(data.total).toBe(0);
+ expect(data.total_amount).toBe('0');
+ expect(data.total_asset).toBeNull();
+ expect(data.total_pages).toBe(0);
+ });
});
});
diff --git a/apps/web/src/app/api/payments/route.ts b/apps/web/src/app/api/payments/route.ts
index 7e4f051..6049247 100644
--- a/apps/web/src/app/api/payments/route.ts
+++ b/apps/web/src/app/api/payments/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { withClient, withMerchantClient, ensureSchema, getSyncState } from '@/lib/db';
import { getMerchantFromRequest } from '@/lib/merchants';
-import { isHash32 } from '@/lib/receipt-anchor';
+import { getMaxBatchSize, isHash32 } from '@/lib/receipt-anchor';
import type { SyncState } from '@/lib/sync-status';
export const dynamic = 'force-dynamic';
@@ -22,7 +22,28 @@ export interface PaymentsResponse {
payments: PaymentRow[];
/** Null until the indexer has run at least once. */
sync: SyncState | null;
+ /** Opaque keyset cursor for the next page; null when the list is exhausted. */
next_cursor?: string | null;
+ /** Total number of indexed payments for this merchant. */
+ total: number;
+ /** Sum of every payment amount, as a decimal string. */
+ total_amount: string;
+ /** Raw asset when every payment is in one asset, else null. */
+ total_asset: string | null;
+ /** ceil(total / limit); 0 when there are no payments. */
+ total_pages: number;
+ /** Total count of all settled payments for this merchant. */
+ total_count?: number;
+ /** Sum of all settled payment amounts for this merchant. */
+ total_amount?: string;
+ /** Filter metadata returned when filters are applied. */
+ filter_info?: {
+ route?: string;
+ payer?: string;
+ asset?: string;
+ date_from?: string;
+ date_to?: string;
+ };
}
export async function GET(request: Request) {
@@ -35,7 +56,8 @@ export async function GET(request: Request) {
let limit = 100;
if (limitParam !== null) {
const parsed = Number.parseFloat(limitParam);
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1000) {
+ const maxLimit = await getMaxBatchSize().catch(() => 1000);
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > maxLimit) {
return NextResponse.json(
{ error: 'limit must be an integer between 1 and 1000' },
{ status: 400 },
@@ -44,9 +66,27 @@ export async function GET(request: Request) {
limit = parsed;
}
+ // Page-based (offset) pagination, e.g. ?page=2&limit=50. Absent means page 1,
+ // which keeps every existing no-parameter caller (the routes page, the SDK's
+ // first page) on exactly the behaviour they had.
+ const pageParam = searchParams.get('page');
+ let page = 1;
+ if (pageParam !== null) {
+ const parsed = Number.parseFloat(pageParam);
+ if (!Number.isInteger(parsed) || parsed < 1) {
+ return NextResponse.json({ error: 'page must be an integer >= 1' }, { status: 400 });
+ }
+ page = parsed;
+ }
+
+ // Cursor-based (keyset) pagination, used by @accensa/sdk. The two schemes are
+ // mutually exclusive: a request cannot offset and keyset at the same time.
const cursor = searchParams.get('cursor');
let parsedCursor: { ts: string; txHash: string } | null = null;
if (cursor) {
+ if (pageParam !== null) {
+ return NextResponse.json({ error: 'page and cursor cannot be combined' }, { status: 400 });
+ }
try {
const decoded = Buffer.from(cursor, 'base64').toString('utf8');
const parts = decoded.split('|');
@@ -63,28 +103,83 @@ export async function GET(request: Request) {
}
}
+ const offset = (page - 1) * limit;
+
try {
const merchant = await withClient((client) => getMerchantFromRequest(client, request));
if (!merchant) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
- const { rows, sync } = await withMerchantClient(merchant.id, async (client) => {
- await ensureSchema(client);
+ const { rows, sync, totalCount, totalAmount } = await withMerchantClient(
+ merchant.id,
+ async (client) => {
+ await ensureSchema(client);
- let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`;
+ // Window functions evaluate over the full filtered row set before LIMIT
+ // and OFFSET are applied, so one query returns both the page and the
+ // aggregates the dashboard header needs (total count, sum, single-asset
+ // detection via min = max).
+ let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method,
+ COUNT(*) OVER() AS total,
+ COALESCE(SUM(amount) OVER(), 0) AS total_amount,
+ CASE WHEN MIN(COALESCE(asset, 'native')) OVER() =
+ MAX(COALESCE(asset, 'native')) OVER()
+ THEN MIN(COALESCE(asset, 'native')) OVER() END AS total_asset
+ FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`;
const params: (string | number)[] = [merchant.id];
if (parsedCursor) {
query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`;
params.push(parsedCursor.ts, parsedCursor.txHash);
}
+ const countRes = await client.query<{ total_count: string; total_amount: string | null }>(
+ `SELECT count(*)::text AS total_count, coalesce(sum(amount), 0)::text AS total_amount FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`,
+ [merchant!.id],
+ );
+ const totalCount = countRes.rows.length
+ ? Number(countRes.rows[0].total_count ?? countRes.rows.length)
+ : 0;
+ const totalAmount =
+ countRes.rows.length &&
+ countRes.rows[0].total_amount !== undefined &&
+ countRes.rows[0].total_amount !== null
+ ? String(countRes.rows[0].total_amount)
+ : '0';
- query += ` ORDER BY ts DESC, tx_hash DESC LIMIT $${params.length + 1}`;
- params.push(limit);
+ let query = `SELECT tx_hash, ledger, payer, amount::text AS amount, asset, ts, route, method FROM payments WHERE merchant_id = $1 AND ts IS NOT NULL`;
+ const params: (string | number)[] = [merchant.id];
+ if (parsedCursor) {
+ query += ` AND (ts < $${params.length + 1} OR (ts = $${params.length + 1} AND tx_hash < $${params.length + 2}))`;
+ params.push(parsedCursor.ts, parsedCursor.txHash);
+ }
+
+ if (!parsedCursor) {
+ query += ` OFFSET $${params.length + 1}`;
+ params.push(offset);
+ }
const result = await client.query(query, params);
return { rows: result.rows, sync: await getSyncState(client, merchant.id) };
});
+ query += ` ORDER BY ts DESC, tx_hash DESC LIMIT $${params.length + 1}`;
+ params.push(limit);
+
+ const result = await client.query(query, params);
+ return {
+ rows: result.rows,
+ sync: await getSyncState(client, merchant!.id),
+ totalCount,
+ totalAmount,
+ };
+ },
+ );
+
+ // The fake databases in tests do not return the window columns; tolerate
+ // their absence so aggregate handling is uniform.
+ const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0;
+ const totalAmount = rows.length > 0 ? String(rows[0].total_amount ?? 0) : '0';
+ const totalAsset = rows.length > 0 ? (rows[0].total_asset ?? null) : null;
+ const totalPages = total === 0 ? 0 : Math.ceil(total / limit);
const next_cursor =
rows.length === limit
@@ -106,10 +201,39 @@ export async function GET(request: Request) {
})),
sync,
next_cursor,
+ total,
+ total_amount: totalAmount,
+ total_asset: totalAsset,
+ total_pages: totalPages,
+ total_count: totalCount,
+ total_amount: totalAmount,
+ ...(filterRoute || filterPayer || filterAsset || filterDateFrom || filterDateTo
+ ? {
+ filter_info: {
+ route: filterRoute ?? undefined,
+ payer: filterPayer ?? undefined,
+ asset: filterAsset ?? undefined,
+ date_from: filterDateFrom ?? undefined,
+ date_to: filterDateTo ?? undefined,
+ },
+ }
+ : {}),
};
- return NextResponse.json(body);
+ return NextResponse.json(body, {
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
+ },
+ });
} catch (error: unknown) {
console.error('Error fetching payments:', error);
- return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ return NextResponse.json(
+ { error: 'Internal Server Error' },
+ {
+ status: 500,
+ headers: {
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
+ },
+ },
+ );
}
}
diff --git a/apps/web/src/app/api/receipts/[txHash]/route.test.ts b/apps/web/src/app/api/receipts/[txHash]/route.test.ts
new file mode 100644
index 0000000..fe9044f
--- /dev/null
+++ b/apps/web/src/app/api/receipts/[txHash]/route.test.ts
@@ -0,0 +1,54 @@
+import { expect, test, vi, describe, beforeEach } from 'vitest';
+import { GET } from './route';
+
+vi.mock('@/lib/db', () => ({
+ withClient: vi.fn(async (fn: (c: unknown) => unknown) => fn({})),
+ ensureSchema: vi.fn(),
+}));
+
+vi.mock('@/lib/anchor', () => ({
+ getProof: vi.fn(),
+}));
+
+import { getProof } from '@/lib/anchor';
+
+describe('GET /api/receipts/:txHash', () => {
+ beforeEach(() => {
+ process.env.DATABASE_URL = 'postgres://dummy';
+ vi.mocked(getProof).mockReset();
+ });
+
+ test('rejects a malformed hash', async () => {
+ const res = await GET(new Request('http://localhost/api/receipts/abcd'), {
+ params: Promise.resolve({ txHash: 'abcd' }),
+ });
+ expect(res.status).toBe(400);
+ });
+
+ test('returns 404 when no proof has been recorded', async () => {
+ vi.mocked(getProof).mockResolvedValueOnce(null);
+ const tx = 'a'.repeat(64);
+ const res = await GET(new Request(`http://localhost/api/receipts/${tx}`), {
+ params: Promise.resolve({ txHash: tx }),
+ });
+ expect(res.status).toBe(404);
+ });
+
+ test('returns the stored proof for a recorded payment', async () => {
+ const tx = 'a'.repeat(64);
+ vi.mocked(getProof).mockResolvedValueOnce({
+ txHash: tx,
+ batchId: 1,
+ leaf: 'b'.repeat(64),
+ proof: ['c'.repeat(64)],
+ root: 'd'.repeat(64),
+ });
+ const res = await GET(new Request(`http://localhost/api/receipts/${tx}`), {
+ params: Promise.resolve({ txHash: tx }),
+ });
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.batchId).toBe(1);
+ expect(body.proof).toHaveLength(1);
+ });
+});
diff --git a/apps/web/src/app/api/receipts/[txHash]/route.ts b/apps/web/src/app/api/receipts/[txHash]/route.ts
new file mode 100644
index 0000000..36aed3b
--- /dev/null
+++ b/apps/web/src/app/api/receipts/[txHash]/route.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from 'next/server';
+import { withClient, ensureSchema } from '@/lib/db';
+import { getProof } from '@/lib/anchor';
+import { isHash32 } from '@/lib/receipt-anchor';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Serves the membership proof for one payment, so `/verify` can be filled
+ * from real data rather than the hand-pasted sample.
+ *
+ * Public on purpose: a proof is not a secret. Anyone holding a `tx_hash`
+ * should be able to fetch the leaf and siblings that place it in an
+ * anchored batch.
+ */
+export async function GET(_request: Request, context: { params: Promise<{ txHash: string }> }) {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
+ }
+
+ const { txHash } = await context.params;
+ if (!isHash32(txHash)) {
+ return NextResponse.json({ error: 'txHash must be a 32-byte hex hash' }, { status: 400 });
+ }
+
+ try {
+ const proof = await withClient(async (client) => {
+ await ensureSchema(client);
+ return getProof(client, txHash.trim().toLowerCase());
+ });
+ if (!proof) {
+ return NextResponse.json({ error: 'No recorded proof for this payment' }, { status: 404 });
+ }
+ return NextResponse.json(proof);
+ } catch (error: unknown) {
+ console.error('receipt proof lookup failed:', error);
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/sync/route.ts b/apps/web/src/app/api/sync/route.ts
index 60db40b..21b12fe 100644
--- a/apps/web/src/app/api/sync/route.ts
+++ b/apps/web/src/app/api/sync/route.ts
@@ -1,21 +1,36 @@
import { NextResponse } from 'next/server';
-import { transferTopicFilter, addressTopicFilter } from '@/lib/stellar-events';
+import { decodeTransferEvent, transferTopicFilter, addressTopicFilter } from '@/lib/stellar-events';
import {
withClient,
- withMerchantClient,
ensureSchema,
getLastSyncedLedger,
getSyncState,
+ rollbackSyncToLedger,
+ setLastSyncedLedger,
+ getSyncState,
} from '@/lib/db';
-import { eventsToPaymentRows, insertPaymentsInTransaction } from '@/lib/insert-payments';
+import {
+ sweepLedgerRange,
+ parallelSweepLedgerRange,
+ PARALLEL_SYNC_THRESHOLD,
+ EVENTS_PAGE_LIMIT,
+ LedgerWindowFetchError,
+ type EventPage,
+} from '@/lib/event-pager';
+import {
+ eventsToPaymentRows,
+ chunkRows,
+ buildBatchInsertSql,
+ flattenRows,
+ PAYMENTS_BATCH_SIZE,
+ type PaymentRow,
+} from '@/lib/insert-payments';
import { listMerchants, getMerchantFromRequest, type Merchant } from '@/lib/merchants';
-import { sweepLedgerRange, EVENTS_PAGE_LIMIT, type EventPage } from '@/lib/event-pager';
import { cooldownRemaining } from '@/lib/sync-status';
-<<<<<<< HEAD
+import { broadcastSyncEvent, hasSubscribers } from '@/lib/sync-events';
import { isAuthorizedCronRequest } from '@/lib/cron-auth';
-=======
+import { logSyncFailure, notifySyncFailure, type SyncFailureContext } from '@/lib/sync-logger';
import { createHmac } from 'node:crypto';
->>>>>>> origin/main
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
@@ -27,7 +42,7 @@ const RPC_URL = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.
* to the testnet native XLM SAC; set ASSET_CONTRACT_IDS to a comma-separated
* list to settle in USDC or across multiple assets.
*/
-const DEFAULT_ASSET_CONTRACT_IDS = (
+const ASSET_CONTRACT_IDS = (
process.env.ASSET_CONTRACT_IDS ?? 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC'
)
.split(',')
@@ -101,19 +116,18 @@ interface CooldownResult {
}
/**
- * Indexes Stellar Asset Contract transfers into one merchant's payment ledger.
+ * Indexes Stellar Asset Contract transfers into the merchant's payment ledger.
*
- * Shared by both entry points: the scheduled GET (looped over every merchant),
- * and the POST behind the dashboard's manual trigger (one merchant, the caller).
- * `cooldownMs`, when set, makes the run a no-op if the last sync is more recent
- * than that.
+ * Shared by both entry points: the scheduled GET, and the POST behind the
+ * dashboard's manual trigger. `cooldownMs`, when set, makes the run a no-op if
+ * the last sync is more recent than that.
*/
-async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) {
- return withMerchantClient(merchant.id, async (client) => {
+async function runSync(merchant: string, opts: { cooldownMs?: number } = {}) {
+ return withClient(async (client) => {
await ensureSchema(client);
if (opts.cooldownMs) {
- const state = await getSyncState(client, merchant.id);
+ const state = await getSyncState(client);
const retryAfterMs = cooldownRemaining(state?.updatedAt, opts.cooldownMs);
if (retryAfterMs > 0) return { cooldown: true, retryAfterMs } as CooldownResult;
}
@@ -121,7 +135,21 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) {
{
const { sequence: latestLedger } = await rpc<{ sequence: number }>('getLatestLedger', {});
- const cursor = await getLastSyncedLedger(client, merchant.id);
+ let cursor = await getLastSyncedLedger(client, merchant.id);
+
+ // A chain head lower than the processed cursor means the node rolled
+ // back — a re-org, or a failover to a peer that lost its tail. Ledgers
+ // past the head no longer exist on the canonical chain, so payments
+ // indexed from them describe a chain that is gone: purge them and
+ // rewind the cursor to the corrected head before working out where to
+ // resume. Without this the early return below would report `drained`
+ // while the local ledger silently keeps rolled-back payments.
+ let rollback: { purged: number } | null = null;
+ if (cursor !== null && latestLedger < cursor) {
+ rollback = await rollbackSyncToLedger(client, merchant.id, latestLedger);
+ cursor = latestLedger;
+ }
+
const resumeFrom = cursor !== null ? cursor + 1 : latestLedger - COLD_START_LOOKBACK;
const retentionFloor = latestLedger - MAX_LOOKBACK;
const startLedger = Math.max(resumeFrom, retentionFloor, 1);
@@ -133,7 +161,6 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) {
if (startLedger > latestLedger) {
return {
- merchant: merchant.address,
latestLedger,
startLedger,
syncedTo: startLedger - 1,
@@ -143,18 +170,24 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) {
scanned: 0,
decoded: 0,
inserted: 0,
+ // After a rollback there is nothing left to re-scan this
+ // invocation — the corrected head is the whole valid range — but
+ // the rewind was the work. Surface it so the run is not mistaken
+ // for a no-op.
+ ...(rollback
+ ? { rollback: true, rolledBackTo: latestLedger, purged: rollback.purged }
+ : {}),
};
}
// Filter server-side to transfers addressed to this merchant. The asset
// topic is optional across protocol versions, so match both arities.
- const toTopic = addressTopicFilter(merchant.address);
+ const toTopic = addressTopicFilter(merchant);
const transfer = transferTopicFilter();
- const assetContractIds = merchant.assetContractIds ?? DEFAULT_ASSET_CONTRACT_IDS;
const filters = [
{
type: 'contract',
- contractIds: assetContractIds,
+ contractIds: ASSET_CONTRACT_IDS,
topics: [
[transfer, '*', toTopic, '*'],
[transfer, '*', toTopic],
@@ -176,75 +209,84 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) {
{ startLedger, endLedger: latestLedger, withinBudget: () => Date.now() < deadline },
);
- const webhookUrl = merchant.webhookUrl ?? process.env.WEBHOOK_URL;
-
- // Per-event filtering lives in eventsToPaymentRows: a malformed or
- // non-transfer event is skipped, and a transfer not addressed to this
- // merchant is never recorded. Only the insert below is batched — batching
- // must not quietly admit events that would have been filtered out.
- const { rows, decoded } = eventsToPaymentRows(events, merchant);
-
- // DO UPDATE, not DO NOTHING: a row may already exist because the
- // merchant reported route attribution before this transfer was indexed,
- // which is the normal ordering — the hook fires the moment x402 settles,
- // this job runs on a schedule. Skipping the conflict would leave that
- // row permanently null and invisible. Only ledger-owned columns are
- // written; route, method, request_id and hook_reported_at belong to the
- // merchant's report and are left alone.
- //
- // The inserts and the cursor advance commit atomically (see
- // insertPaymentsInTransaction): if any chunk fails, nothing commits and
- // the cursor stays behind the failed run.
- const { inserted, payments } = await insertPaymentsInTransaction(
- client,
- merchant.id,
- rows,
- sweptThrough,
- );
-
- // Webhooks fire after COMMIT, so a slow or failing webhook can neither
- // hold the transaction open nor roll back a committed batch. The
- // returned rows are exactly the payments written this run.
- if (webhookUrl) {
- for (const payment of payments) {
- const body = JSON.stringify(payment);
- const webhookSecret = process.env.WEBHOOK_SECRET;
- const headers: Record = { 'Content-Type': 'application/json' };
- if (webhookSecret) {
- headers['X-Webhook-Signature'] = createHmac('sha256', webhookSecret)
- .update(body)
- .digest('hex');
- }
- const timeoutMs = 2000;
- for (let i = 0; i < 3; i++) {
- try {
- const controller = new AbortController();
- const id = setTimeout(() => controller.abort(), timeoutMs);
- const webhookRes = await fetch(webhookUrl, {
- method: 'POST',
- headers,
- body,
- signal: controller.signal,
- });
- clearTimeout(id);
- if (webhookRes.ok || webhookRes.status < 500) break;
- } catch {
- // A webhook the merchant cannot receive must not stall indexing.
- }
+ let inserted = 0;
+ let decoded = 0;
+
+ for (const event of events) {
+ const transferEvent = decodeTransferEvent(event);
+ // A malformed or non-transfer event must not stall the batch.
+ if (!transferEvent) continue;
+ decoded++;
+
+ // Defensive: never record a transfer that is not to this merchant.
+ if (transferEvent.to !== merchant) continue;
+
+ // DO UPDATE, not DO NOTHING: a row may already exist because the
+ // merchant reported route attribution before this transfer was
+ // indexed, which is the normal ordering — the hook fires the moment
+ // x402 settles, this job runs on a schedule. Skipping the conflict
+ // would leave that row permanently null and invisible.
+ //
+ // Only ledger-owned columns are written. route, method, request_id and
+ // hook_reported_at belong to the merchant's report and are left alone.
+ await client.query('BEGIN');
+ try {
+ const res = await client.query(
+ `INSERT INTO payments (tx_hash, ledger, payer, amount, asset, ts)
+ VALUES ($1, $2, $3, $4::numeric, $5, $6::timestamptz)
+ ON CONFLICT (tx_hash) DO UPDATE
+ SET ledger = EXCLUDED.ledger,
+ payer = EXCLUDED.payer,
+ amount = EXCLUDED.amount,
+ asset = EXCLUDED.asset,
+ ts = EXCLUDED.ts
+ WHERE payments.ledger IS NULL RETURNING *`,
+ [
+ transferEvent.txHash,
+ transferEvent.ledger,
+ transferEvent.from,
+ transferEvent.amount, // string - never a float
+ transferEvent.asset,
+ transferEvent.ledgerClosedAt,
+ ],
+ );
+ if (res.rowCount && res.rowCount > 0 && process.env.WEBHOOK_URL) {
+ await enqueueWebhookDelivery(
+ client,
+ payloadFromRow(res.rows[0] as Record),
+ process.env.WEBHOOK_URL,
+ );
}
+ await client.query('COMMIT');
+ inserted += res.rowCount ?? 0;
+ } catch (error) {
+ await client.query('ROLLBACK').catch(() => {});
+ throw error;
}
}
- // The sweep only ever reports whole windows, so the cursor advance is
- // safe whether or not it reached the head. Crucially it advances across
- // empty windows too - a quiet merchant that never moved the cursor is
- // how the indexer fell behind the RPC retention window and stopped
- // seeing payments. Each merchant's cursor advances independently, so one
- // merchant with no activity cannot hold back or be held back by
- // another's progress.
+ // The sweep only ever reports whole windows, so this is safe whether or
+ // not it reached the head. Crucially it advances across empty windows
+ // too - a quiet merchant that never moved the cursor is how the indexer
+ // fell behind the RPC retention window and stopped seeing payments.
+ await setLastSyncedLedger(client, sweptThrough);
+
+ // Push a real-time update to any subscribed dashboard tab instead of
+ // waiting for the next poll (real-time indexer updates). Skipped when no
+ // client is listening so an idle sync does no broadcast bookkeeping.
+ if (hasSubscribers(merchant.id)) {
+ broadcastSyncEvent(merchant.id, {
+ merchant: merchant.address,
+ syncedTo: sweptThrough,
+ inserted,
+ scanned,
+ pages,
+ drained: complete,
+ occurredAt: new Date().toISOString(),
+ });
+ }
return {
- merchant: merchant.address,
latestLedger,
startLedger,
syncedTo: sweptThrough,
@@ -262,6 +304,12 @@ async function runSync(merchant: Merchant, opts: { cooldownMs?: number } = {}) {
type SyncResult = Awaited>;
+/** One merchant's sync throwing instead of returning a result (#135). */
+interface SyncFailure {
+ merchant: string;
+ error: string;
+}
+
/** Maps one merchant's run to its response fragment. */
function summarize(result: SyncResult) {
if ('cooldown' in result) {
@@ -270,6 +318,26 @@ function summarize(result: SyncResult) {
return result;
}
+/**
+ * Builds the context+logging a caught sync error needs, then reports it both
+ * to the log (always) and to SYNC_ALERT_WEBHOOK_URL (if configured) (#135).
+ *
+ * A LedgerWindowFetchError carries the exact window being read when the RPC
+ * call failed; anything else (a parsing error, a DB error) is logged without
+ * ledger context rather than guessing at one.
+ */
+function reportSyncError(error: unknown, merchant?: string): void {
+ const context: SyncFailureContext = {
+ ...(merchant ? { merchant } : {}),
+ ...(error instanceof LedgerWindowFetchError
+ ? { startLedger: error.startLedger, endLedger: error.endLedger }
+ : {}),
+ };
+ logSyncFailure(context, error);
+ // Alerting must never block or fail the sync job itself.
+ void notifySyncFailure(context, error);
+}
+
/**
* Maps a set of per-merchant runs to a response.
*
@@ -279,18 +347,26 @@ function summarize(result: SyncResult) {
* as deployment-wide maximums alongside the full per-merchant `results`, so
* that check keeps working unchanged whether this deployment has one merchant
* or many.
+ *
+ * `failures` (#135) are merchants whose sync threw rather than returned — they
+ * no longer abort the whole batch (see GET below), so they are reported here
+ * instead: `success` goes false, which the workflow already treats as a
+ * warning worth surfacing, while `results`/`syncedTo` still reflect whatever
+ * other merchants did complete.
*/
-function respond(results: SyncResult[]) {
+function respond(results: SyncResult[], failures: SyncFailure[] = []) {
// The manual, single-merchant POST path preserves the original 429 +
// Retry-After contract exactly, since the dashboard's "Sync now" button
// already depends on it.
- if (results.length === 1 && 'cooldown' in results[0]) {
+ if (failures.length === 0 && results.length === 1 && 'cooldown' in results[0]) {
const retryAfterMs = Math.ceil(results[0].retryAfterMs);
return NextResponse.json(
{ success: true, cooldown: true, retryAfterMs },
{ status: 429, headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) } },
);
}
+ return NextResponse.json({ success: true, ...result });
+}
const summaries = results.map(summarize);
const synced = summaries.filter(
@@ -301,14 +377,15 @@ function respond(results: SyncResult[]) {
const drained = synced.length ? synced.every((s) => s.drained) : true;
return NextResponse.json({
- success: true,
+ success: failures.length === 0,
results: summaries,
...(syncedTo !== null ? { syncedTo, skippedLedgers, drained } : {}),
+ ...(failures.length ? { failures } : {}),
});
}
-function failed(error: unknown) {
- console.error('Error during sync:', error);
+function failed(error: unknown, merchant?: string) {
+ reportSyncError(error, merchant);
return NextResponse.json({ success: false, error: 'Internal Server Error' }, { status: 500 });
}
@@ -316,27 +393,18 @@ function failed(error: unknown) {
* Scheduled entry point.
*
* Driven by Vercel Cron and by .github/workflows/sync.yml. Protected by
- * CRON_SECRET, checked with a constant-time compare in isAuthorizedCronRequest
- * (@/lib/cron-auth) - both senders pass it as a bearer token - so the
- * endpoint cannot be driven by arbitrary callers. An unset CRON_SECRET fails
- * closed: middleware.ts already denies this path before it reaches here, and
- * this check denies it too, since no caller should ever run a sync against a
- * deployment with no secret configured. No cooldown: a scheduled run is
- * already rate limited by its schedule.
- *
- * Sweeps every configured merchant in turn, each with its own cursor - a
- * merchant with no activity still has its cursor advanced (see runSync),
- * which is precisely the fix for the outage that motivated this workflow's
- * checks in the first place.
+ * CRON_SECRET when set - both senders pass it as a bearer token - so the
+ * endpoint cannot be driven by arbitrary callers. No cooldown: a scheduled run
+ * is already rate limited by its schedule.
*/
export async function GET(request: Request) {
- if (!isAuthorizedCronRequest(request.headers.get('authorization'))) {
+ const secret = process.env.CRON_SECRET;
+ if (secret && request.headers.get('authorization') !== `Bearer ${secret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
- if (!process.env.DATABASE_URL) {
- return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
- }
+ const bad = configError();
+ if (bad) return bad;
try {
const merchants = await withClient(async (client) => {
@@ -349,10 +417,22 @@ export async function GET(request: Request) {
}
const results: SyncResult[] = [];
+ const failures: SyncFailure[] = [];
for (const merchant of merchants) {
- results.push(await runSync(merchant));
+ // One merchant's RPC error or parsing failure must not cost every
+ // merchant after it in this run their turn (#135) — each is isolated
+ // and logged with context, and the loop moves on.
+ try {
+ results.push(await runSync(merchant));
+ } catch (error) {
+ reportSyncError(error, merchant.address);
+ failures.push({
+ merchant: merchant.address,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
}
- return respond(results);
+ return respond(results, failures);
} catch (error: unknown) {
return failed(error);
}
@@ -361,23 +441,21 @@ export async function GET(request: Request) {
/**
* Manual entry point, behind the dashboard's"Sync now"button.
*
- * Protected by session authentication via middleware, which resolves to
- * exactly the merchant that owns this dashboard session — a signed-in
- * merchant can only trigger their own sync. MANUAL_COOLDOWN_MS bounds the cost.
+ * Protected by session authentication via middleware. MANUAL_COOLDOWN_MS bounds the cost.
*/
-export async function POST(request: Request) {
- if (!process.env.DATABASE_URL) {
- return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
- }
+export async function POST() {
+ const bad = configError();
+ if (bad) return bad;
+ let merchant: Merchant | null = null;
try {
- const merchant = await withClient((client) => getMerchantFromRequest(client, request));
+ merchant = await withClient((client) => getMerchantFromRequest(client, request));
if (!merchant) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return respond([await runSync(merchant, { cooldownMs: MANUAL_COOLDOWN_MS })]);
} catch (error: unknown) {
- return failed(error);
+ return failed(error, merchant?.address);
}
}
diff --git a/apps/web/src/app/api/sync/stream/route.ts b/apps/web/src/app/api/sync/stream/route.ts
new file mode 100644
index 0000000..2c681a9
--- /dev/null
+++ b/apps/web/src/app/api/sync/stream/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse, type NextRequest } from 'next/server';
+import { withClient } from '@/lib/db';
+import { getMerchantFromRequest } from '@/lib/merchants';
+import { createSyncStream } from '@/lib/sync-events';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Server-Sent Events subscription for indexer updates.
+ *
+ * `GET /api/sync/stream` keeps a connection open and pushes a `sync` event
+ * each time the indexer finishes a run for the signed-in merchant. The
+ * dashboard and SDK subscribe here instead of polling `/api/sync`, which is
+ * what removes the polling load described in the real-time update issue.
+ *
+ * Authentication mirrors the other API routes: `apps/web/src/middleware.ts`
+ * verifies the session cookie and forwards the merchant address as
+ * `x-accensa-merchant`, which `getMerchantFromRequest` trusts.
+ */
+export async function GET(request: NextRequest) {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+
+ const merchant = await withClient((client) => getMerchantFromRequest(client, request));
+ if (!merchant) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ return createSyncStream(request, merchant.id);
+}
diff --git a/apps/web/src/app/api/webhooks/deliver/route.ts b/apps/web/src/app/api/webhooks/deliver/route.ts
new file mode 100644
index 0000000..a90f75f
--- /dev/null
+++ b/apps/web/src/app/api/webhooks/deliver/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from 'next/server';
+import { withClient, ensureSchema } from '@/lib/db';
+import { deliverDue } from '@/lib/webhooks';
+
+export const dynamic = 'force-dynamic';
+export const maxDuration = 30;
+
+/**
+ * Ships queued payment webhooks.
+ *
+ * Protected by CRON_SECRET the same way GET /api/sync is. Indexing never
+ * calls this — a hung merchant endpoint can only delay itself.
+ */
+export async function GET(request: Request) {
+ const secret = process.env.CRON_SECRET;
+ if (secret && request.headers.get('authorization') !== `Bearer ${secret}`) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
+ }
+
+ try {
+ const result = await withClient(async (client) => {
+ await ensureSchema(client);
+ return deliverDue(client);
+ });
+ return NextResponse.json({ success: true, ...result });
+ } catch (error: unknown) {
+ console.error('webhook delivery failed:', error);
+ return NextResponse.json({ success: false, error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/api/webhooks/route.ts b/apps/web/src/app/api/webhooks/route.ts
new file mode 100644
index 0000000..1d18bf5
--- /dev/null
+++ b/apps/web/src/app/api/webhooks/route.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from 'next/server';
+import { withClient, ensureSchema } from '@/lib/db';
+import { webhookSummary } from '@/lib/webhooks';
+
+export const dynamic = 'force-dynamic';
+
+/** Merchant-visible webhook delivery status. Session-authenticated via middleware. */
+export async function GET() {
+ if (!process.env.DATABASE_URL) {
+ return NextResponse.json({ error: 'DATABASE_URL is not configured' }, { status: 500 });
+ }
+ if (!process.env.WEBHOOK_URL) {
+ return NextResponse.json({
+ configured: false,
+ pending: 0,
+ failed: 0,
+ delivered: 0,
+ recentFailed: [],
+ });
+ }
+
+ try {
+ const summary = await withClient(async (client) => {
+ await ensureSchema(client);
+ return webhookSummary(client);
+ });
+ return NextResponse.json({ configured: true, ...summary });
+ } catch (error: unknown) {
+ console.error('webhook summary failed:', error);
+ return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
+ }
+}
diff --git a/apps/web/src/app/batches/[id]/page.tsx b/apps/web/src/app/batches/[id]/page.tsx
index 1eb154f..d610beb 100644
--- a/apps/web/src/app/batches/[id]/page.tsx
+++ b/apps/web/src/app/batches/[id]/page.tsx
@@ -3,8 +3,10 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { getBatch, RECEIPT_ANCHOR_ID, type BatchRecord } from '@/lib/receipt-anchor';
+import { explorerContractUrl } from '@/lib/explorer';
import { ArrowUpRight } from 'lucide-react';
import { PageContainer } from '@/components/page-container';
+import { formatTimestamp, toISO8601 } from '@/lib/format-timestamp';
/**
* A batch is immutable once anchored, so this can be cached hard. Revalidating
@@ -78,8 +80,16 @@ export default async function BatchPage({ params }: { params: Promise<{ id: stri