Architecture

The three-layer system that powers sBTC Pay, and why it isn't just a smart contract plus a frontend.

sBTC Pay is structured as three layers: a Clarity smart contract (truth), an indexing layer (transformation), and a React frontend (experience). Each layer has one job. This separation is the reason the platform scales and the reason it's maintainable.

The three layers

┌──────────────────────────────────────────┐
│  Layer 3 — Experience                    │
│  React frontend, widgets, dashboard      │
│  Fast, searchable, merchant-friendly     │
└──────────────────────────────────────────┘
                    ▲
                    │  (queries indexed data)
                    │
┌──────────────────────────────────────────┐
│  Layer 2 — Indexing                      │
│  Chainhook + Supabase webhook            │
│  Transforms events → queryable data      │
│  DLQ, rollback handling, idempotency     │
└──────────────────────────────────────────┘
                    ▲
                    │  (listens to on-chain events)
                    │
┌──────────────────────────────────────────┐
│  Layer 1 — Truth                         │
│  Clarity contract on Stacks mainnet      │
│  Source of truth for all money movement  │
└──────────────────────────────────────────┘

Layer 1 — The contract (truth)

A single Clarity contract holds the entirety of the protocol's state and rules: merchant records, invoices, subscriptions, payments, refunds, and the fee schedule. Every action that moves money emits an event, and every event includes the burn-block-height at which it happened.

Key properties:

  • Non-custodial. The contract never holds funds. It transfers directly between customer and merchant wallets at the moment of payment.
  • Deterministic. Given the same inputs, the same events are emitted in the same order.
  • Auditable. Source is public, state is queryable via any Stacks node.

See Smart Contract Reference for the function catalog.

Layer 2 — The indexing layer (transformation)

Between the contract and the frontend sits an indexing layer. When the contract emits an event (e.g., payment-received), Hiro's Chainhook service picks it up and POSTs it to a Supabase edge function. The function normalizes the event into a Postgres row ready for the frontend to query.

Why this matters:

  • Aggregation queries. "Show my monthly revenue" becomes a SQL SUM — fast, no on-chain scanning.
  • Search and filtering. Find invoice by reference ID, filter by status, sort by date — trivial with indexed columns, painful via read-only contract calls.
  • Off-chain data. Webhook URLs, merchant descriptions, email receipts — these belong alongside the chain data, not on-chain.
  • Real-time updates. Chainhook pushes events as blocks confirm, so the dashboard updates within seconds of a payment.

Reliability: DLQ and idempotency

Every payment handler writes to Postgres with UPSERT ON CONFLICT (tx_id) DO NOTHING. This means if the webhook retries an event — for example, because the first attempt timed out — the duplicate is a no-op. Events that fail processing land in a Dead-Letter Queue (DLQ) table for manual replay.

Bitcoin reorgs (extremely rare on mainnet, but real) are handled via Chainhook's rollback events. When a block is reorged, the corresponding Postgres rows are deleted and aggregates recomputed.

Layer 3 — The frontend (experience)

A React + Vite SPA built with shadcn/ui components. It talks to Supabase for read queries, to Stacks wallets (Leather/Xverse) for signing, and to the contract via @stacks/transactions for broadcasts. The layer only reads data from the indexer — it never tries to derive state from raw chain scans.

The same codebase ships to two Vercel projects (mainnet and testnet). Network is selected via environment variable at build time, so mainnet configuration can never leak into a testnet deploy.

Why not just contract + frontend?

It's tempting to skip the indexing layer and have the frontend read directly from the blockchain. For very small scale, that works. For a real product, it breaks down:

  • Dashboard page load would require scanning every event — slow and expensive
  • Hiro API rate limits would hit you quickly as users multiply
  • Filtering and sorting would require complex client-side data manipulation
  • Off-chain data (webhook URLs, merchant avatars) would have nowhere to live
  • Real-time updates would require polling, which is wasteful

This is the standard pattern

Uniswap uses The Graph. OpenSea runs its own indexer. Lido, Aave, and virtually every serious dApp separates truth (chain) from experience (UI) with an indexing layer. It's the production pattern, not over-engineering.

Data flow: a single payment

  1. Customer clicks Pay in the widget
  2. Widget builds a contract call and hands it to the wallet extension
  3. Wallet prompts customer to approve
  4. Wallet broadcasts the signed transaction to the Stacks network
  5. Stacks miner includes the TX in a block; contract transfers sBTC, emits payment-received
  6. Chainhook picks up the event and POSTs it to the Supabase webhook
  7. Webhook writes a payments row, updates invoice/merchant/platform stats — all in one transaction
  8. Merchant's dashboard (or customer's receipt view) reads the new row via Supabase and renders the update

End-to-end latency is typically 10–30 seconds on mainnet — bounded by how fast Bitcoin blocks confirm the Stacks block containing the TX.

Upgrade path

The layer model gives us a clean upgrade path. When the contract evolves (e.g., v6 → v7 with a new feature):

  1. Deploy the new contract with a new identifier
  2. Update the webhook's event handlers to understand any new event fields
  3. Update the frontend to expose the new features

Existing v6 invoices and subscriptions continue to work — the indexer reads from whichever contract produced them. Merchants aren't forced to migrate.