Skip to content

Development

WoompaLoompa edited this page Aug 11, 2026 · 8 revisions

Development

Repository layout

  • clink/ — extension package (see Architecture)
  • tests/ — pytest suite
  • .github/workflows/release.yml — tag-triggered release pipeline
  • tools/update_extensions.py — registry updater for the LNbits extension market

Running tests

The repo basename lnbits-clink is not a valid Python identifier, and LNbits loads the extension from its extensions/clink folder. tests/run_tests.sh reproduces that by symlinking the repo into a temp dir named clink:

# with lnbits installed in the active environment
tests/run_tests.sh

# or pointing at a specific interpreter
PYTEST_PYTHON=/path/to/venv/bin/python tests/run_tests.sh

# run one file
tests/run_tests.sh -q clink/tests/test_subscriptions.py

The suite covers the protocol layer (NIP-44 vectors, bech32 codecs, event sign/verify), the node service, Pay Offers, and the subscriptions engine. It also contains tests/vectors/nip44.vectors.json, a curated NIP-44 test vector set.

Linting & formatting

python -m ruff check .
python -m black --check .
  • Target: Python >= 3.10, line length 88.
  • nostr/ and tests/vectors/ are excluded from mypy; ruff/black run over the whole tree.

Frontend compatibility (LNbits 1.5.x g object)

On LNbits 1.5.x windowMixin is empty ({}) and the g object (user, wallets, settings) is injected into the core Vue app through a global mixin (static/js/init-app.js). Extension pages create their own app (window.app = Vue.createApp({...})), so they never receive that mixin and this.g is undefined — reading this.g.user throws and the page renders blank. Page components must therefore read window.g directly: a plain global set in globals.js and populated with the logged-in user by templates/base.html.

data() {
  return {
    wallets: window.g.user.wallets,
    wallet: window.g.user.wallets[0]
  }
}

checkout.js is a public page and does not touch g.

Frontend rendering: never self-close component tags

Extension pages are in-DOM templates: templates/base.html renders the {% block page %} markup straight into <div id="vue">, the browser parses it as HTML, and Vue only then compiles #vue's innerHTML. The HTML parser does not support self-closing syntax for non-void elements, so a tag like <q-tab name="debits" ... /> is treated as an unclosed open tag that swallows everything that follows it until a matching close tag appears. That is exactly what happened before v0.1.2: the three q-tab elements rendered nested inside each other (tabs stacked diagonally and overlapping), dialog inputs overlapped, and the pay page collapsed to its header.

Rule: every component tag must use explicit closing tags — <q-tab ...></q-tab>, <q-btn ...></q-btn>, <q-space></q-space>. Only real HTML void elements (<img>, <br>, <input>) may be self-closing. After editing a template, verify with:

grep -rnE '<(q-[a-z0-9-]+|qrcode-vue)\s*/>' templates/clink/   # must be empty

Icons: only use single-glyph Material Icons ligatures

LNbits pages load the classic Material Icons font, where an icon name is a ligature (e.g. bolt, event, pending_actions). If the requested name is not a single glyph in the loaded font, the text renderer falls back to matching substring ligatures, so event_repeat rendered as event + repeat (+ the underscore) — i.e. three icons where one was expected (fixed in v0.1.3 by using event). Newer Material Symbols names may or may not be present depending on the served font version, so after choosing an icon, verify it renders as one glyph on the live page (check the rendered icon element width — a split ligature is ~2-3× the normal 24px).

Funding source (ClinkWallet)

v0.1.4 makes CLINK a selectable LNbits backend wallet; v0.1.5 makes it a fully paying one with a Lightning.pub account.

How it hooks in. LNbits resolves the backend with getattr(lnbits.wallets, settings.lnbits_backend_wallet_class) in set_funding_source(). Extensions are imported before that call at startup (lnbits/app.py), so clink/__init__.py injects the class directly:

import lnbits.wallets as _lnbits_wallets
if not hasattr(_lnbits_wallets, "ClinkWallet"):
    _lnbits_wallets.ClinkWallet = ClinkWallet

Because the class lands on the same module object set_funding_source() resolves against, lnbits_backend_wallet_class=ClinkWallet works without any core changes. The class lives in clink/wallet.py and implements the lnbits.wallets.base.Wallet ABC (same API in LNbits 1.5.6 and 1.6.x).

Configuration. Two env vars configure the node:

LNBITS_CLINK_FUNDING_NOFFER=noffer1...
LNBITS_CLINK_FUNDING_ACCOUNT=nprofile1...[:token]   # optional; enables send + balance

The superuser must also add ClinkWallet to the allowed funding sources (LNBITS_ALLOWED_FUNDING_SOURCES env var or the admin settings) so it appears in the /admin dropdown.

Admin fields (core patch). The /admin → Funding Sources card only renders inputs for bundled classes plus whatever the settings model exposes. To show CLINK with Noffer + Account inputs, two core files are patched: lnbits/settings.py (a ClinkFundingSource(LNbitsSettings) mixin with lnbits_clink_funding_noffer / lnbits_clink_funding_account added to the FundingSourcesSettings base list — making them valid on Settings, UpdateSettings and AdminSettings) and lnbits/static/bundle-components.min.js (a ["ClinkWallet", ...] entry in rawFundingSources). This is a core change — it must be re-applied on any image that does not carry it. On the demo Fly machine it is re-applied at every boot by a sitecustomize on the volume (see Installation#admin-fields-core-patch--boot-patch): /app/data/lnbits_patch/ holds sitecustomize.py + boot_patch.py, enabled via the PYTHONPATH=/app/data/lnbits_patch secret, so it survives restarts and redeploys. boot_patch.py is idempotent and skips (logging to patch.log) if a future core version no longer has the anchors.

Scope (v0.1.6). create_invoice mints via the account's NewInvoice when LNBITS_CLINK_FUNDING_ACCOUNT is set (else requests a BOLT11 invoice from the node's offer service over kind 21001). With an account, pay_invoice pays via the node's Nostr user API (kind 21000, NIP-44 v1) and status() reports the real node balance (GetUserInfo); incoming invoice status is tracked by polling GetUserOperations' latestIncomingInvoiceOperations, outgoing payment status via GetPaymentState. pay_invoice always sends the node-required amount field (0 for fixed-amount invoices) and treats a settled response with an empty preimage (internal payments) as a success. Without the account, pay_invoice returns a clear "receive-only" error and status() reports 0 msat. When the noffer is unset, status() reports an error and the instance falls back to VoidWallet after the startup retries (standard LNbits behavior).

Tests. tests/test_wallet.py covers config parsing, the account-fallback path, status() balance reporting, account-first invoice creation, incoming status via GetUserOperations (pending/paid/paging/no-account-row), outgoing status via GetPaymentState, and the empty-preimage success path; tests/test_account.py exercises the Lightning.pub user API client (envelope, NIP-44 encryption, response verification, requestId stale-response skip, PayInvoice amount contract) with a monkeypatched responder. No live relay calls.

Release pipeline

.github/workflows/release.yml is triggered by a version tag (e.g. v0.1.5):

  1. Builds a release artifact (the extension zip) and drafts a GitHub release.
  2. Runs tools/update_extensions.py, which updates the registry and opens a pull request against lnbits/lnbits-extensions so the extension shows up in the LNbits extension manager.

To cut a release:

git tag v0.1.5
git push origin v0.1.5

Each tag creates a fresh branch + PR (update-lnbits-clink-v<tag>). Close the previous PR when a newer version supersedes it.

The auto-PR needs an EXT_GITHUB secret (a token with access to the lnbits/lnbits-extensions fork) configured in the repository settings.

Conventions

  • Commit messages follow feat: … / fix: … style (see git log).
  • All commits are authored under a dedicated extension identity (WoompaLoompa / 06bc1a977d@atomicmail.io).
  • No new Python dependencies: protocol primitives are implemented in clink/nostr on top of LNbits' bundled packages.

Clone this wiki locally