Skip to main content

Architecture

This page explains how SwissKnife is built: how the code is organized, how it talks to your Lightning node and the Bitcoin network, how it stores data, how it authenticates requests, and how the pieces are deployed in production. It is a conceptual overview โ€” for exact configuration keys see the Configuration section, and for endpoints see the REST API reference.

Design overviewโ€‹

SwissKnife follows a layered, domain-driven design. The codebase is split into three top-level layers, each with a single responsibility:

  • domains โ€” the business logic. Each domain owns its entities, services, use cases, and repository interfaces. Domains depend only on abstractions, never on concrete infrastructure.
  • infra โ€” the infrastructure adapters that implement those abstractions: the HTTP server, database, Lightning node clients, authenticators, and logging.
  • application โ€” the composition root. It wires concrete adapters into the domain services at startup and centralizes error and OpenAPI definitions.

The dependency direction always points inward: infrastructure depends on the domains, the domains depend on nothing concrete. This keeps the business logic testable in isolation and lets you swap out a Lightning provider or database engine without touching domain code.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Axum HTTP server โ”‚ infra::app
โ”‚ /v1/invoices /v1/payments /v1/bitcoin โ€ฆ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ AppServices โ”‚ application
โ”‚ account ยท asset ยท wallet ยท payment ยท โ€ฆ โ”‚ (composition root)
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ trait abstractions โ”‚ trait abstractions
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ AppStore โ”‚ โ”‚ LnClient / BitcoinWallet โ”‚ infra adapters
โ”‚ (SeaORM repos) โ”‚ โ”‚ CLN / LND, on-chain โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ PostgreSQL / SQLite โ”‚ โ”‚ Your Lightning node โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + Bitcoin network โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Anatomy of a domainโ€‹

Every domain is structured the same way, which makes the codebase predictable. Taking the invoice domain as an example:

  • *_handler โ€” the Axum HTTP handlers that map requests to use cases and serialize responses.
  • *_service โ€” the concrete implementation of the business logic.
  • *_use_cases โ€” the trait that defines what the domain can do (the public contract).
  • *_repository โ€” the trait describing the persistence the domain needs (implemented in infra).
  • entities โ€” the domain types.

The domains in v0.3.0 are:

DomainResponsibility
invoiceCreate and track incoming Lightning invoices.
paymentSend Lightning payments and reconcile their status.
ln_addressManage Lightning Addresses (username@your.domain).
lnurlServe the LNURL-pay and .well-known/lnurlp flows.
nostrServe NIP-05 identities via .well-known/nostr.json.
bitcoinOn-chain wallet: address management, wallet sync, and transaction preparation.
accountAccount profiles, auth identities, runtime principals, API keys, permissions, preferences, and dashboard settings.
assetAsset catalog entries such as native BTC on a specific Bitcoin network.
walletAsset-scoped wallets and balances owned by accounts.
systemHealth probes, first-run setup state, and node configuration.
eventRecords domain events (e.g. settled invoices) for projections and reconciliation.

The liquidity provider abstractionโ€‹

SwissKnife is bring-your-own-node: it connects to a Lightning node you run, rather than custodying funds itself. Domains depend on LnClient for Lightning operations and BitcoinWallet for on-chain Bitcoin operations. The selected provider implements both interfaces, keeping node-specific RPCs in the infrastructure adapters.

Four concrete implementations provide these interfaces, one per supported transport:

ln_provider valueNodeTransport
cln_grpcCore Lightning (CLN)gRPC
cln_restCore Lightning (CLN)REST
lnd_grpcLNDgRPC
lnd_restLNDREST

The composition root reads ln_provider at startup and constructs the matching client, so the rest of the application is unaware of which node or transport is in use. LND macaroons can be supplied as either a raw binary file or a base64-encoded file โ€” the client auto-detects which.

Provider liquidity is separate from wallet accounting. A node change does not cap or rewrite wallet balances. Unknown send outcomes keep reservations until reconciliation establishes a final result; the operator manages backing and any unresolved provider state.

See Liquidity Providers for connection settings.

On-chain Bitcoin supportโ€‹

SwissKnife includes an integrated on-chain Bitcoin wallet for self-hosted CLN and LND. Each Lightning client also implements a BitcoinWallet trait that provides address derivation (new_address), wallet synchronization (synchronize), output lookup, and transaction preparation (prepare_transaction) followed by an explicit sign-and-broadcast step. The default address script type is configured with bitcoin_address_type (p2pkh, p2sh, p2wpkh, or p2tr; defaults to p2wpkh).

Because the same node connection backs both LnClient and BitcoinWallet, the on-chain wallet reuses your node's funds and chain backend rather than running a separate wallet.

Persistenceโ€‹

Persistence is handled through SeaORM. The AppStore is a collection of repository traits (InvoiceRepository, PaymentRepository, WalletRepository, BtcAddressRepository, and so on), each with a SeaORM-backed implementation. Domains depend only on the traits, so the storage engine is an infrastructure detail.

Two database engines are supported:

  • SQLite โ€” the default. The connection is opened in WAL journal mode with a configurable busy_timeout so concurrent readers don't block the single writer.
  • PostgreSQL โ€” for production, multi-instance, or higher-throughput deployments.

The engine is chosen entirely by the database.url connection string; no code changes are needed. Schema migrations run automatically on startup, so a fresh database is brought up to date before the server begins accepting requests.

Wallet balances live on asset-scoped wallet rows. A wallet points at exactly one asset, and all reservations, debits, and credits mutate that wallet directly. Writes that must be atomic across multiple tables (such as recording a payment together with its wallet reservation) go through dedicated unit-of-work abstractions (PaymentUnitOfWork, EventProjectionUnitOfWork) rather than independent repository calls.

Authentication and authorizationโ€‹

Authentication and authorization are two distinct concerns in SwissKnife.

Authentication answers "who is calling?" Every protected request carries either a Bearer JWT or an api-key header. The selected auth_provider decides how a JWT is validated:

  • jwt โ€” local JWT auth. SwissKnife signs and verifies tokens with the configured jwt.secret. There is a single initial Admin account whose password is set on first launch via the /v1/auth/sign-up flow (stored as a bcrypt hash in the database), not in any config file.
  • oauth2 โ€” delegated auth via any OpenID Connect provider. SwissKnife fetches the provider's OIDC discovery document from the configured issuer (oauth2.domain) to learn its JWKS URI and canonical issuer, then validates incoming tokens against the published public keys. This makes it compatible with any OIDC-compliant identity provider.

API keys offer a second credential type for programmatic access. A presented key is SHA-256-hashed and looked up in the store; the matching record carries the account it belongs to and the set of permissions granted to it.

Authorization answers "what may they do?" The /v1/me surface is constrained by the authenticated account and explicit owned wallet IDs. Administrative resource routes additionally check scoped permissions such as read:account, write:account, read:wallet, write:transaction, read:btc_address, and write:api_key. OAuth2 requests use the current token's claims. Local JWT requests load permissions from the account row, while API keys use the key's own permission array. The initial Admin account is granted all permissions.

The HTTP server and event listenerโ€‹

SwissKnife starts its HTTP server, node event listener, and client-event retention worker from main:

  1. The Axum HTTP server exposes the versioned REST API (/v1/invoices, /v1/payments, /v1/wallets, /v1/me, /v1/auth, /v1/api-keys, /v1/lightning-addresses, /v1/bitcoin/addresses, /v1/system), the public LNURL and .well-known routes, and the interactive API docs at /docs. When the all-in-one bundle is used, the server also serves the dashboard's static assets as a fallback route. The router is wrapped with tracing, request timeout, and CORS middleware, and shuts down gracefully on Ctrl+C/SIGTERM.

  2. The event listener subscribes to your Lightning node's event stream (over gRPC or a WebSocket, depending on the provider) and ingests settled invoices, payments, and on-chain deposits into the database. It is supervised: if the connection drops it reconnects with exponential backoff and re-syncs, so it never silently stops ingesting events.

The startup order is deliberate. The listener starts first and performs an initial invoice and payment sync, so the local state is reconciled with the node before the HTTP server begins accepting external requests.

Account and asset-wallet modelโ€‹

SwissKnife separates identity from spendable balances. An account is the authorization boundary for a person or organization. An auth_identity records how that account signs in, such as a local JWT identity or an OAuth2 subject. A wallet is not an identity; it is one balance for one asset.

Assets identify both the currency and the settlement network. Native BTC on mainnet, native BTC on regtest, and future Taproot Assets are different assets, even when their display code looks similar. Payment and invoice services derive denomination from the selected wallet's asset_id instead of trusting a request-supplied currency field.

One database may contain wallets for several networks at the same time. The configured Lightning or chain provider describes which settlement operations the running instance can currently perform; it does not limit the wallet catalog to one network. SwissKnife validates the selected wallet against the provider and payment or address network at the operation boundary, before it reserves funds or creates provider state. Wallets for another network therefore remain available for historical attribution or a future provider and do not prevent the instance from starting.

Authenticated account routes therefore use explicit wallet selection:

GET /v1/me -> account profile + lightweight wallets
PUT /v1/me -> update display name
GET /v1/me/wallets -> account wallets
GET /v1/me/wallets/{id} -> one owned wallet
GET /v1/me/wallets/{id}/balance
POST /v1/me/wallets/{id}/payments
POST /v1/me/wallets/{id}/invoices
POST /v1/me/wallets/{id}/bitcoin/addresses

This is intentionally not backwards-compatible with the old wallet-shaped /v1/me behavior. Money-moving requests validate that the selected wallet belongs to the caller and that its asset matches the payment, invoice, or address network.

The lightweight wallets embedded in Account include asset metadata and balances but leave payments, invoices, addresses, and contacts empty. The dashboard keeps this account aggregate in a shared context. Its header selector persists the active wallet ID in account preferences and hydrates related resources only for that selected wallet.

Administrative navigation keeps ownership and balances distinct: the Account directory links to account details and their owned wallets, while the Wallet directory links each asset-scoped balance back to its account and to a full wallet view with related resources.

Durable client eventsโ€‹

Settlement commits append a client event in the same transaction as the invoice/payment and balance change. GET /v1/me/events streams only the authenticated account's events, with a wallet ID and durable cursor for reconnect replay. REST remains authoritative; the dashboard revalidates it after events and reconnects.

The Events usage guide covers authentication, event types, replay, and reconnect handling. Optional retention settings are listed under Configuration.

Deployment topologyโ€‹

SwissKnife is published as three multi-arch (linux/amd64 + linux/arm64) Docker images on Docker Hub, supporting two deployment styles. See Docker for the full instructions.

  • All-in-one bundle โ€” bitcoinnumeraire/swissknife runs the API and serves the dashboard's static assets from a single container on port 3000. This is the simplest way to self-host.
  • Separated server + dashboard โ€” for microservice or Kubernetes deployments, the API and the UI ship as independent images:
    • bitcoinnumeraire/swissknife-server โ€” the API only, on port 3000.
    • bitcoinnumeraire/swissknife-dashboard โ€” the standalone Next.js dashboard, on port 8080.

In the bundle, the server discovers the dashboard's static files via the dashboard_dir path; in the separated topology the server runs with no dashboard directory and the dashboard image is deployed alongside it. Both topologies talk to the same backend code and the same database โ€” the split only changes how the UI is served.

tip

For a single self-hosted instance, start with the all-in-one bundle and SQLite. Move to the separated images and PostgreSQL when you need to scale the API and dashboard independently.