omnixient/modules/dev-env/docs/lnbits-extension-dev.md
Padreug 346042f1c5 docs(dev-env): aiolabs stack overview and lnbits workflow notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:59:05 +02:00

6.4 KiB
Raw Permalink Blame History

lnbits extension development

Reference for building and maintaining LNbits extensions — the parts that catch first-time extension authors and the patterns worth adopting once you maintain a fork-modified extension.

Auth decorators

Easy to confuse. Different scopes:

Decorator Auth scope Returns Use for
require_invoice_key Wallet invoice key — read access Wallet Read endpoints (balance, payment history) callable with a user's invoice key
require_admin_key Wallet admin key — write to own wallet only Wallet Endpoints that create payments / modify wallet state
check_admin LNbits instance admin (super_user + lnbits_admin_users), Bearer token Account Cross-user / admin-only operations
check_super_user LNbits super user only, Bearer token Account Operations restricted to the single super_user

require_admin_keycheck_admin. This is the most common misuse. require_admin_key is a wallet-level write key — every user has one for each of their own wallets. check_admin is instance admin access. Endpoints that operate on other users' data or change global settings need check_admin, not require_admin_key.

Testing

Use FakeWallet (LNBITS_BACKEND_WALLET_CLASS=FakeWallet) for testing extension CRUD, API endpoints, and UI changes. Spin up the full regtest stack only when end-to-end Lightning payment behavior is actually under test.

Why: regtest takes time to start (docker build + multiple LND/CLN containers), uses real resources, and adds no fidelity for non-payment flows. FakeWallet makes payments succeed instantly with no network — ideal for testing the surrounding logic.

If you ship a dev CLI (see lnbits-sensei's pattern), keep --fakewallet as the default mode for this reason.

Fork-migrations pattern (migrations_fork.py)

Long-lived forks need to add schema columns and tables on top of upstream's. The naive approach — append fork-only migration functions to migrations.py — guarantees merge conflicts on every upstream rebase. The pattern below sidesteps that by keeping migrations.py byte-identical to upstream and putting fork deltas in a sibling file.

Note: this pattern requires a patch to LNbits core's migrate_extension_database() that loads migrations_fork.py under a <ext.id>_fork key in the dbversions table. As of 2026 that patch is fork-internal (an upstream PR is the natural follow-up). If you maintain a fork that ships this patch, the rest of this section is the user-facing pattern.

Architecture facts

  • dbversions lives in the core LNbits DB (database.sqlite3), not per-extension. Schema: (db TEXT PRIMARY KEY, version INT). update_migration_version is an INSERT-OR-UPDATE, so a new <ext>_fork row appears on first run with no schema migration needed core-side.
  • Extension data tables live in ext_<id>.sqlite3 (SQLite) or a Postgres schema named after <id>. Created lazily on first Database.connect() via ATTACH (SQLite) or CREATE SCHEMA (Postgres).
  • The version-bump connection is routed based on db.schema: None means a core migration (same connection), set means an extension (opens a fresh core_db.connect() to write dbversions). See lnbits/core/helpers.py:run_migration.
  • No cross-DB atomicity. Extension migration writes commit to ext_<id>.sqlite3; the dbversions upsert commits to database.sqlite3. If the extension write succeeds and the dbversions write fails, the migration is orphaned — re-runs on next startup. Every fork migration MUST be idempotent (use an _alter_add_column_safe wrapper that swallows duplicate-column errors, CREATE TABLE IF NOT EXISTS, etc.). Self-healing covers the orphan case.

Squash recipe (adopting the pattern on an existing fork)

If your fork has accumulated fork-only migrations interleaved into migrations.py:

  1. Restore migrations.py to upstream-byte-identical content. Drop all fork-only m{NNN}_* functions and any helper added only for them.
  2. Create migrations_fork.py with a SINGLE m001_<your-tag>_<ext>_schema function that idempotently applies every fork-only schema delta the old migrations used to do. One readable file forever.
  3. The squash uses _alter_add_column_safe per ALTER and CREATE TABLE IF NOT EXISTS per table — no-ops cleanly on installs that already ran the old fork migrations.

One-time fix on existing installs adopting the pattern

Installs that previously ran the old fork migrations have their dbversions['<ext>'] row ahead of upstream (e.g. events|11 after fork-only m007m011). After moving to migrations_fork, the next upstream rebase that adds e.g. m007_add_allow_fiat would compare 7 > 11 → false and silently skip the new upstream migration.

Reset the row to match upstream's actual migration count before the rebase lands:

-- Run against the CORE DB (database.sqlite3), not the extension DB.
UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>';

For containerized dev where the file is root-owned inside the container:

docker compose exec lnbits python3 -c "import sqlite3; \
  c = sqlite3.connect('database.sqlite3'); \
  c.execute(\"UPDATE dbversions SET version = <N> WHERE db = '<ext>'\"); \
  c.commit()"

Upstream-overlap scenario at rebase time

If upstream eventually adds a schema change you already carry in migrations_fork.py (e.g. they add the same ALTER TABLE … ADD COLUMN bar you shipped), the next rebase creates a problem: fresh installs work (your _alter_add_column_safe guards swallow the dup), but existing installs crash when upstream's now-redundant migration runs without an idempotency guard (upstream rarely uses them).

Mitigation at rebase time:

  1. Prune the redundant block from migrations_fork.py so future fresh installs get the column from upstream's migration.
  2. Pre-deploy dbversions surgery on every affected install: UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>' so the loader skips the now-overlapping upstream migration.

Do both — (1) keeps future fresh installs clean, (2) keeps existing installs alive through the deploy.

Don't patch upstream's migration with idempotency guards in your fork. That breaks the "migrations.py == upstream byte-identical" property and reintroduces every-rebase conflicts.