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 ininfra).entitiesโ the domain types.
The domains in v0.3.0 are:
| Domain | Responsibility |
|---|---|
invoice | Create and track incoming Lightning invoices. |
payment | Send Lightning payments and reconcile their status. |
ln_address | Manage Lightning Addresses (username@your.domain). |
lnurl | Serve the LNURL-pay and .well-known/lnurlp flows. |
nostr | Serve NIP-05 identities via .well-known/nostr.json. |
bitcoin | On-chain wallet: address management, wallet sync, and transaction preparation. |
account | Account profiles, auth identities, runtime principals, API keys, permissions, preferences, and dashboard settings. |
asset | Asset catalog entries such as native BTC on a specific Bitcoin network. |
wallet | Asset-scoped wallets and balances owned by accounts. |
system | Health probes, first-run setup state, and node configuration. |
event | Records 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 value | Node | Transport |
|---|---|---|
cln_grpc | Core Lightning (CLN) | gRPC |
cln_rest | Core Lightning (CLN) | REST |
lnd_grpc | LND | gRPC |
lnd_rest | LND | REST |
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_timeoutso 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 configuredjwt.secret. There is a single initial Admin account whose password is set on first launch via the/v1/auth/sign-upflow (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:
-
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-knownroutes, 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 onCtrl+C/SIGTERM. -
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/swisskniferuns the API and serves the dashboard's static assets from a single container on port3000. 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 port3000.bitcoinnumeraire/swissknife-dashboardโ the standalone Next.js dashboard, on port8080.
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.
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.