From 9c0e58a87ccb125234b8d77911af2e97eeb8fc59 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 17:26:14 +0200 Subject: [PATCH] feat: merge a link's `extra` into the payout payment (v1.2.2-aio.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional `extra` (JSON) field to a withdraw link. When the link is claimed, that `extra` is merged onto the payout payment's `extra`, so a caller can tag the resulting payment with metadata an external listener keys on — the link is the only place to attach it (the customer-facing LNURL-withdraw payout otherwise carries just `{tag, withdrawal_link_id}`). Motivating use: bitSpire cash-in settlements. The operator's spirekeeper listener fires a `cash_in` settlement (fee split to the platform) only on an outbound payment stamped `source=bitspire`; before this there was no way to stamp an LNURL-withdraw payout, so cash-ins never settled. bitSpire now creates the cash-in link for the NET amount with `extra={source, type:cash_in, principal_sats, fee_sats, ...}` and the settlement fires on claim. - models: `extra: dict | None` on CreateWithdrawData + WithdrawLink. LNbits' db layer (de)serializes dict columns to/from JSON natively (same as Payment.extra) — no per-field validator needed. - migrations_fork.py: `withdraw_link.extra TEXT` under `withdraw_fork`, keeping the upstream-tracked migrations.py byte-identical for clean rebases (aiolabs/lnbits#8 pattern). - views_lnurl: `extra={**(link.extra or {}), "tag": ..., "withdrawal_link_id": ...}` — the withdraw extension's own keys are written last so a caller cannot clobber them. Verified end-to-end on the dev stack: a stamped link's payout carries the merged extra and drives a spirekeeper cash_in settlement + super-fee payout. --- config.json | 2 +- crud.py | 1 + migrations_fork.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ models.py | 10 ++++++++++ views_lnurl.py | 11 ++++++++++- 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 migrations_fork.py diff --git a/config.json b/config.json index de2cba5..e54edf5 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,7 @@ "name": "Withdraw Links", "short_description": "Make LNURL withdraw links", "tile": "/withdraw/static/image/lnurl-withdraw.png", - "version": "1.2.2", + "version": "1.2.2-aio.2", "min_lnbits_version": "1.3.0", "contributors": [ { diff --git a/crud.py b/crud.py index b914ae5..73966a5 100644 --- a/crud.py +++ b/crud.py @@ -32,6 +32,7 @@ async def create_withdraw_link( webhook_headers=data.webhook_headers, webhook_body=data.webhook_body, custom_url=data.custom_url, + extra=data.extra, number=0, ) await db.insert("withdraw.withdraw_link", withdraw_link) diff --git a/migrations_fork.py b/migrations_fork.py new file mode 100644 index 0000000..1caf27c --- /dev/null +++ b/migrations_fork.py @@ -0,0 +1,44 @@ +""" +Fork-specific database migrations for the aiolabs withdraw extension. + +These migrations are tracked separately under `withdraw_fork` in the +`dbversions` table (loaded by `lnbits/core/helpers.py:migrate_extension_database`), +so they do not collide with upstream's `m{NNN}_*` numbering in +`migrations.py`. Keeping the upstream-tracked file untouched means +`git pull upstream` stays rebase-clean for schema changes. + +Conventions: + - Sequential numbering starting from m001. + - Each migration is `async def m{NNN}_(db)`. + - DDL must be idempotent: a fresh install runs every migration; an + install that already carries the column must not crash. Use + `_alter_add_column_safe` so re-runs are no-ops. +""" + + +async def _alter_add_column_safe(db, sql: str) -> None: + """ALTER TABLE ADD COLUMN that swallows duplicate-column errors, so a + re-run on a DB that already has the column is a silent no-op.""" + try: + await db.execute(sql) + except Exception as exc: + msg = str(exc).lower() + if "duplicate column" in msg or "already exists" in msg: + return + raise + + +async def m001_aio_withdraw_schema(db): + """ + Apply every aiolabs schema delta on top of upstream withdraw. + + `withdraw_link.extra` — arbitrary JSON merged into the payout payment's + `extra` when the link is claimed (see views_lnurl). Lets a caller tag the + resulting payment with settlement/attribution metadata an external listener + can key on — e.g. bitSpire stamps {source, type, principal_sats, fee_sats, + ...} so the spirekeeper cash-in settlement fires off an LNURL-withdraw + payout. Stored as TEXT; (de)serialized to a dict by the WithdrawLink model. + """ + await _alter_add_column_safe( + db, "ALTER TABLE withdraw.withdraw_link ADD COLUMN extra TEXT" + ) diff --git a/models.py b/models.py index e888cdf..2f8ae48 100644 --- a/models.py +++ b/models.py @@ -16,6 +16,12 @@ class CreateWithdrawData(BaseModel): webhook_body: str = Query(None) custom_url: str = Query(None) enabled: bool = Query(True) + # Arbitrary JSON merged into the payout payment's `extra` when this link is + # claimed (see views_lnurl). Lets a caller tag the resulting payment with + # settlement/attribution metadata an external listener can key on — e.g. + # bitSpire stamps {source, type, principal_sats, fee_sats, ...} so the + # spirekeeper cash-in settlement fires off an LNURL-withdraw payout. + extra: dict | None = None class WithdrawLink(BaseModel): @@ -37,6 +43,10 @@ class WithdrawLink(BaseModel): webhook_headers: str = Query(None) webhook_body: str = Query(None) custom_url: str = Query(None) + # Persisted as TEXT (JSON); merged into the payout payment's `extra` on + # claim. LNbits' db layer (de)serializes dict-typed columns to/from JSON + # natively (same as Payment.extra) — no per-field validator needed. + extra: dict | None = None created_at: datetime enabled: bool = Query(True) lnurl: str | None = Field( diff --git a/views_lnurl.py b/views_lnurl.py index f893427..3be8182 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -141,7 +141,16 @@ async def api_lnurl_callback( wallet_id=link.wallet, payment_request=pr, max_sat=link.max_withdrawable, - extra={"tag": "withdraw", "withdrawal_link_id": link.id}, + # Merge the link's caller-supplied `extra` onto the payout so an + # external listener can key on it (e.g. bitSpire cash-in + # settlements via spirekeeper). The withdraw extension's own + # `tag`/`withdrawal_link_id` are written last so a caller cannot + # clobber them. + extra={ + **(link.extra or {}), + "tag": "withdraw", + "withdrawal_link_id": link.id, + }, ) await increment_withdraw_link(link) # If the payment succeeds, delete the record with the unique_hash. -- 2.53.0