diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 4e02f97..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: lint -on: - push: - branches: - - main - pull_request: - -jobs: - lint: - uses: lnbits/lnbits/.github/workflows/lint.yml@dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27c8a60..7ec9b48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,10 @@ on: push: tags: - - 'v[0-9]+.[0-9]+.[0-9]+' + - "v[0-9]+.[0-9]+.[0-9]+" jobs: + release: runs-on: ubuntu-latest steps: @@ -33,12 +34,12 @@ jobs: - name: Create pull request in extensions repo env: GH_TOKEN: ${{ secrets.EXT_GITHUB }} - repo_name: '${{ github.event.repository.name }}' - tag: '${{ github.ref_name }}' - branch: 'update-${{ github.event.repository.name }}-${{ github.ref_name }}' - title: '[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}' - body: 'https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}' - archive: 'https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip' + repo_name: "${{ github.event.repository.name }}" + tag: "${{ github.ref_name }}" + branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}" + title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}" + body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}" + archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip" run: | cd lnbits-extensions git checkout -b $branch diff --git a/.gitignore b/.gitignore index 0152b6e..bee8a64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ __pycache__ -node_modules -.mypy_cache -.venv diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 725c398..0000000 --- a/.prettierrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "semi": false, - "arrowParens": "avoid", - "insertPragma": false, - "printWidth": 80, - "proseWrap": "preserve", - "singleQuote": true, - "trailingComma": "none", - "useTabs": false, - "bracketSameLine": false, - "bracketSpacing": false -} diff --git a/Makefile b/Makefile deleted file mode 100644 index 0fac253..0000000 --- a/Makefile +++ /dev/null @@ -1,47 +0,0 @@ -all: format check - -format: prettier black ruff - -check: mypy pyright checkblack checkruff checkprettier - -prettier: - uv run ./node_modules/.bin/prettier --write . -pyright: - uv run ./node_modules/.bin/pyright - -mypy: - uv run mypy . - -black: - uv run black . - -ruff: - uv run ruff check . --fix - -checkruff: - uv run ruff check . - -checkprettier: - uv run ./node_modules/.bin/prettier --check . - -checkblack: - uv run black --check . - -checkeditorconfig: - editorconfig-checker - -test: - PYTHONUNBUFFERED=1 \ - DEBUG=true \ - uv run pytest -install-pre-commit-hook: - @echo "Installing pre-commit hook to git" - @echo "Uninstall the hook with uv run pre-commit uninstall" - uv run pre-commit install - -pre-commit: - uv run pre-commit run --all-files - - -checkbundle: - @echo "skipping checkbundle" diff --git a/README.md b/README.md index c93e897..86d7656 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ LNbits Split Payments extension allows for distributing payments across multiple IMPORTANT: -- If you split to a LNURLp or LNaddress through the LNURLp extension make sure your recipients allow comments ! Split&Scrub add a comment in your transaction - and if it is not allowed, the split/scrub will not take place. -- Make sure the LNURLp / LNaddress of the recipient has its min-sats set very low (e.g. 1 sat). If the wallet does not belong to you you can [check with a Decoder](https://lightningdecoder.com/), if that is the case already -- Yes, there is fees - internal and external! Updating your own wallets on your own instance will not cost any fees but sending to an external instance will. Please notice that you should therefore not split up to 100% if you send to a wallet that is external (leave 1-2% reserve for routing fees!). External fees are deducted from the individual payment percentage of the recipient +- If you split to a LNURLp or LNaddress through the LNURLp extension make sure your receipients allow comments ! Split&Scrub add a comment in your transaction - and if it is not allowed, the split/scrub will not take place. +- Make sure the LNURLp / LNaddress of the receipient has its min-sats set very low (e.g. 1 sat). If the wallet does not belong to you you can [check with a Decoder](https://lightningdecoder.com/), if that is the case already +- Yes, there is fees - internal and external! Updating your own wallets on your own instance will not cost any fees but sending to an external instance will. Please notice that you should therefore not split up to 100% if you send to a wallet that is external (leave 1-2% reserve for routing fees!). External fees are deducted from the individual payment percentage of the receipient Bildschirm­foto 2023-05-01 um 22 14 36 Bildschirm­foto 2023-05-01 um 22 17 52 diff --git a/__init__.py b/__init__.py index 5132323..aeca6cd 100644 --- a/__init__.py +++ b/__init__.py @@ -1,12 +1,15 @@ import asyncio +from typing import List from fastapi import APIRouter -from loguru import logger -from .crud import db -from .tasks import wait_for_paid_invoices -from .views import splitpayments_generic_router -from .views_api import splitpayments_api_router +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_splitpayments") + +scheduled_tasks: List[asyncio.Task] = [] splitpayments_static_files = [ { @@ -17,31 +20,18 @@ splitpayments_static_files = [ splitpayments_ext: APIRouter = APIRouter( prefix="/splitpayments", tags=["splitpayments"] ) -splitpayments_ext.include_router(splitpayments_generic_router) -splitpayments_ext.include_router(splitpayments_api_router) - -scheduled_tasks: list[asyncio.Task] = [] -def splitpayments_stop(): - for task in scheduled_tasks: - try: - task.cancel() - except Exception as ex: - logger.warning(ex) +def splitpayments_renderer(): + return template_renderer(["splitpayments/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa: F401,F403 +from .views_api import * # noqa: F401,F403 def splitpayments_start(): - from lnbits.tasks import create_permanent_unique_task - - task = create_permanent_unique_task("ext_splitpayments", wait_for_paid_invoices) + loop = asyncio.get_event_loop() + task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) scheduled_tasks.append(task) - - -__all__ = [ - "db", - "splitpayments_ext", - "splitpayments_start", - "splitpayments_static_files", - "splitpayments_stop", -] diff --git a/config.json b/config.json index 2ec4bbf..a95c729 100644 --- a/config.json +++ b/config.json @@ -2,57 +2,6 @@ "name": "Split Payments", "short_description": "Split incoming payments across wallets", "tile": "/splitpayments/static/image/split-payments.png", - "version": "1.1.0", - "min_lnbits_version": "1.3.0", - "contributors": [ - { - "name": "cryptograffiti", - "uri": "https://github.com/cryptograffiti", - "role": "Idea/Sponsor" - }, - { - "name": "fiatjaf", - "uri": "https://github.com/fiatjaf", - "role": "Developer" - }, - { - "name": "dni", - "uri": "https://github.com/dni", - "role": "Developer" - }, - { - "name": "talvasconcelos", - "uri": "https://github.com/talvasconcelos", - "role": "Developer" - }, - { - "name": "prusnak", - "uri": "https://github.com/prusnak", - "role": "Developer" - }, - { - "name": "arbadacarbaYK", - "uri": "https://github.com/arbadacarbaYK", - "role": "Developer" - }, - { - "name": "arcbtc", - "uri": "https://github.com/arcbtc", - "role": "Developer" - } - ], - "images": [ - { - "uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/1.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/2.png" - }, - { - "uri": "https://raw.githubusercontent.com/lnbits/splitpayments/main/static/image/3.png" - } - ], - "description_md": "https://raw.githubusercontent.com/lnbits/splitpayments/main/description.md", - "terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/splitpayments/main/toc.md", - "license": "MIT" + "contributors": ["fiatjaf", "cryptograffiti"], + "min_lnbits_version": "0.11.0" } diff --git a/crud.py b/crud.py index 13a764d..f9a7e04 100644 --- a/crud.py +++ b/crud.py @@ -1,23 +1,35 @@ -from lnbits.db import Database +from typing import List +from lnbits.helpers import urlsafe_short_hash + +from . import db from .models import Target -db = Database("ext_splitpayments") - -async def get_targets(source_wallet: str) -> list[Target]: - return await db.fetchall( - "SELECT * FROM splitpayments.targets WHERE source = :source_wallet", - {"source_wallet": source_wallet}, - Target, +async def get_targets(source_wallet: str) -> List[Target]: + rows = await db.fetchall( + "SELECT * FROM splitpayments.targets WHERE source = ?", (source_wallet,) ) + return [Target(**row) for row in rows] -async def set_targets(source_wallet: str, targets: list[Target]): +async def set_targets(source_wallet: str, targets: List[Target]): async with db.connect() as conn: await conn.execute( - "DELETE FROM splitpayments.targets WHERE source = :source_wallet", - {"source_wallet": source_wallet}, + "DELETE FROM splitpayments.targets WHERE source = ?", (source_wallet,) ) for target in targets: - await conn.insert("splitpayments.targets", target) + await conn.execute( + """ + INSERT INTO splitpayments.targets + (id, source, wallet, percent, alias) + VALUES (?, ?, ?, ?, ?) + """, + ( + urlsafe_short_hash(), + source_wallet, + target.wallet, + target.percent, + target.alias, + ), + ) diff --git a/description.md b/description.md deleted file mode 100644 index 91359cb..0000000 --- a/description.md +++ /dev/null @@ -1,16 +0,0 @@ -Split Payments across multiple wallets/lnaddresses/lnurlps seamlessly! -Once configured, it continuously splits your payments across different wallets. - -Usage: - -- Enable the Extension: Start by enabling the Split Payments extension. -- Select the Source Wallet: Identify and select the wallet that will receive and subsequently distribute the payments. -- Add Wallet Information for Payment Splitting: Enter the details of the wallets where the payments will be split. This could include LNURLp, LNaddress, wallet ID, or an invoice key from a different wallet. Wallet details can be found under the API Info section on each wallet's page. Optionally, assign an alias to each wallet for easier identification. -- Set Distribution Percentages: Specify the percentage of each payment that each wallet should receive. Ensure the total distribution does not exceed 100%. -- Save Your Settings: After adding or deleting wallet information, click “SAVE TARGETS” to activate the payment splits. - -Note: -You can distribute payments to multiple wallets as long as their combined percentage is at or below 100%. Distribution can only total exactly 100% if all target wallets are internal. - -Automatic Payment Splitting: -When the source wallet receives a payment, the extension automatically allocates the specified percentages to each designated wallet. diff --git a/manifest.json b/manifest.json index 37c187c..3ee75e1 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "repos": [ { "id": "splitpayments", - "organisation": "PatMulligan", + "organisation": "lnbits", "repository": "splitpayments" } ] diff --git a/migrations.py b/migrations.py index 3c0ccab..69eb81e 100644 --- a/migrations.py +++ b/migrations.py @@ -1,8 +1,7 @@ -from lnbits.db import Connection from lnbits.helpers import urlsafe_short_hash -async def m001_initial(db: Connection): +async def m001_initial(db): """ Initial split payment table. """ @@ -20,12 +19,11 @@ async def m001_initial(db: Connection): ) -async def m002_float_percent(db: Connection): +async def m002_float_percent(db): """ - alter percent to be float. + Add float percent and migrates the existing data. """ - await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_m001") - + await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_old") await db.execute( """ CREATE TABLE splitpayments.targets ( @@ -38,9 +36,11 @@ async def m002_float_percent(db: Connection): ); """ ) - result = await db.execute("SELECT * FROM splitpayments.splitpayments_m001") - rows = result.mappings().all() - for row in rows: + + for row in [ + list(row) + for row in await db.fetchall("SELECT * FROM splitpayments.splitpayments_old") + ]: await db.execute( """ INSERT INTO splitpayments.targets ( @@ -49,25 +49,19 @@ async def m002_float_percent(db: Connection): percent, alias ) - VALUES (:wallet, :source, :percent, :alias) + VALUES (?, ?, ?, ?) """, - { - "wallet": row["wallet"], - "source": row["source"], - "percent": row["percent"], - "alias": row["alias"], - }, + (row[0], row[1], row[2], row[3]), ) - await db.execute("DROP TABLE splitpayments.splitpayments_m001") + await db.execute("DROP TABLE splitpayments.splitpayments_old") -async def m003_add_id_and_tag(db: Connection): +async def m003_add_id_and_tag(db): """ - Add id, tag and migrates the existing data. + Add float percent and migrates the existing data. """ - await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_m002") - + await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_old") await db.execute( """ CREATE TABLE splitpayments.targets ( @@ -82,9 +76,11 @@ async def m003_add_id_and_tag(db: Connection): ); """ ) - result = await db.execute("SELECT * FROM splitpayments.splitpayments_m002") - rows = result.mappings().all() - for row in rows: + + for row in [ + list(row) + for row in await db.fetchall("SELECT * FROM splitpayments.splitpayments_old") + ]: await db.execute( """ INSERT INTO splitpayments.targets ( @@ -95,31 +91,23 @@ async def m003_add_id_and_tag(db: Connection): tag, alias ) - VALUES (:id, :wallet, :source, :percent, :tag, :alias) + VALUES (?, ?, ?, ?, ?, ?) """, - { - "id": urlsafe_short_hash(), - "wallet": row["wallet"], - "source": row["source"], - "percent": row["percent"], - "tag": row["tag"], - "alias": row["alias"], - }, + (urlsafe_short_hash(), row[0], row[1], row[2], "", row[3]), ) - await db.execute("DROP TABLE splitpayments.splitpayments_m002") + await db.execute("DROP TABLE splitpayments.splitpayments_old") -async def m004_remove_tag(db: Connection): +async def m004_remove_tag(db): """ This removes tag """ keys = "id,wallet,source,percent,alias" new_db = "splitpayments.targets" - old_db = "splitpayments.targets_m003" - - await db.execute(f"ALTER TABLE {new_db} RENAME TO targets_m003") + old_db = "splitpayments.targets_old" + await db.execute(f"ALTER TABLE {new_db} RENAME TO targets_old") await db.execute( f""" CREATE TABLE {new_db} ( diff --git a/models.py b/models.py index a514c32..854a4a2 100644 --- a/models.py +++ b/models.py @@ -1,13 +1,19 @@ +from sqlite3 import Row +from typing import List, Optional + from fastapi import Query from pydantic import BaseModel class Target(BaseModel): - id: str wallet: str source: str percent: float - alias: str | None = None + alias: Optional[str] + + @classmethod + def from_row(cls, row: Row): + return cls(**dict(row)) class TargetPut(BaseModel): @@ -17,4 +23,4 @@ class TargetPut(BaseModel): class TargetPutList(BaseModel): - targets: list[TargetPut] + targets: List[TargetPut] diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index bacebcf..0000000 --- a/package-lock.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "splitpayments", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "splitpayments", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "prettier": "^3.2.5", - "pyright": "^1.1.358" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pyright": { - "version": "1.1.374", - "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.374.tgz", - "integrity": "sha512-ISbC1YnYDYrEatoKKjfaA5uFIp0ddC/xw9aSlN/EkmwupXUMVn41Jl+G6wHEjRhC+n4abHZeGpEvxCUus/K9dA==", - "bin": { - "pyright": "index.js", - "pyright-langserver": "langserver.index.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 8af073b..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "splitpayments", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "", - "license": "ISC", - "dependencies": { - "prettier": "^3.2.5", - "pyright": "^1.1.358" - } -} diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 6c057b3..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,92 +0,0 @@ -[project] -name = "splitpayments" -version = "1.1.1" -requires-python = ">=3.10,<3.13" -description = "Send incoming payments to different targets" -authors = [{name = "benarc"}, {name = "dni"}, {name = "alan"}] -urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/splitpayments" } - -dependencies = [ "lnbits>1" ] - -[tool.poetry] -package-mode = false - -[tool.uv] -dev-dependencies = [ - "black", - "pytest-asyncio", - "pytest", - "mypy==1.17.1", - "pre-commit", - "ruff", - "pytest-md", -] - -[tool.mypy] -exclude = "(nostr/*)" -plugins = ["pydantic.mypy"] - -[tool.pydantic-mypy] -init_forbid_extra = true -init_typed = true -warn_required_dynamic_aliases = true -warn_untyped_fields = true - -[tool.pytest.ini_options] -log_cli = false -testpaths = [ - "tests" -] - -[tool.black] -line-length = 88 - -[tool.ruff] -# Same as Black. + 10% rule of black -line-length = 88 -exclude = [ - "nostr", -] - -[tool.ruff.lint] -# Enable: -# F - pyflakes -# E - pycodestyle errors -# W - pycodestyle warnings -# I - isort -# A - flake8-builtins -# C - mccabe -# N - naming -# UP - pyupgrade -# RUF - ruff -# B - bugbear -select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] -ignore = ["C901"] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -# needed for pydantic -[tool.ruff.lint.pep8-naming] -classmethod-decorators = [ - "validator", - "root_validator", -] - -# Ignore unused imports in __init__.py files. -# [tool.ruff.lint.extend-per-file-ignores] -# "__init__.py" = ["F401", "F403"] - -# [tool.ruff.lint.mccabe] -# max-complexity = 10 - -[tool.ruff.lint.flake8-bugbear] -# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`. -extend-immutable-calls = [ - "fastapi.Depends", - "fastapi.Query", -] diff --git a/static/image/1.png b/static/image/1.png deleted file mode 100644 index 4a6d516..0000000 Binary files a/static/image/1.png and /dev/null differ diff --git a/static/image/2.png b/static/image/2.png deleted file mode 100644 index 18ee47b..0000000 Binary files a/static/image/2.png and /dev/null differ diff --git a/static/image/3.png b/static/image/3.png deleted file mode 100644 index 0de5289..0000000 Binary files a/static/image/3.png and /dev/null differ diff --git a/static/js/index.js b/static/js/index.js index 76b6fab..1ea7a8c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,3 +1,7 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + function hashTargets(targets) { return targets .filter(isTargetComplete) @@ -13,14 +17,9 @@ function isTargetComplete(target) { ) } -window.app = Vue.createApp({ +new Vue({ el: '#vue', mixins: [windowMixin], - watch: { - selectedWallet() { - this.getTargets() - } - }, data() { return { selectedWallet: null, @@ -35,11 +34,11 @@ window.app = Vue.createApp({ }, methods: { clearTarget(index) { - if (this.targets.length == 1) { + if(this.targets.length == 1) { return this.deleteTargets() } this.targets.splice(index, 1) - Quasar.Notify.create({ + this.$q.notify({ message: 'Removed item. You must click to save manually.', timeout: 500 }) @@ -51,11 +50,8 @@ window.app = Vue.createApp({ '/splitpayments/api/v1/targets', this.selectedWallet.adminkey ) - .then(res => { - this.targets = res.data.map(t => ({ - ...t, - targetChoice: t.targetChoice || 'wallet' - })) + .then(response => { + this.targets = response.data }) .catch(err => { LNbits.utils.notifyApiError(err) @@ -66,30 +62,20 @@ window.app = Vue.createApp({ this.getTargets() }, addTarget() { - this.targets.push({ - source: this.selectedWallet, - targetChoice: 'wallet' - }) + this.targets.push({source: this.selectedWallet}) }, saveTargets() { - const payload = this.targets - .filter(t => t.wallet && String(t.wallet).trim() !== '') - .map(({alias, percent, wallet}) => ({ - alias, - percent: Number(percent) || 0, - wallet - })) LNbits.api .request( 'PUT', '/splitpayments/api/v1/targets', this.selectedWallet.adminkey, { - targets: payload + targets: this.targets } ) .then(response => { - Quasar.Notify.create({ + this.$q.notify({ message: 'Split payments targets set.', timeout: 700 }) @@ -110,7 +96,7 @@ window.app = Vue.createApp({ this.selectedWallet.adminkey ) .then(response => { - Quasar.Notify.create({ + this.$q.notify({ message: 'Split payments targets deleted.', timeout: 700 }) @@ -123,5 +109,6 @@ window.app = Vue.createApp({ }, created() { this.selectedWallet = this.g.user.wallets[0] + this.getTargets() } }) diff --git a/tasks.py b/tasks.py index 9951662..13b1659 100644 --- a/tasks.py +++ b/tasks.py @@ -1,25 +1,25 @@ import asyncio +import json from math import floor +from typing import Optional -import bolt11 -from lnbits.core.crud import get_standalone_payment -from lnbits.core.crud.wallets import get_wallet_for_key -from lnbits.core.models import Payment -from lnbits.core.services import ( - create_invoice, - fee_reserve, - get_pr_from_lnurl, - pay_invoice, -) -from lnbits.tasks import register_invoice_listener +import httpx from loguru import logger +from lnbits import bolt11 +from lnbits.core.crud import get_standalone_payment +from lnbits.core.models import Payment +from lnbits.core.services import create_invoice, fee_reserve, pay_invoice +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + from .crud import get_targets async def wait_for_paid_invoices(): invoice_queue = asyncio.Queue() - register_invoice_listener(invoice_queue, "ext_splitpayments_invoice_listener") + register_invoice_listener(invoice_queue, get_current_extension_name()) + while True: payment = await invoice_queue.get() await on_invoice_paid(payment) @@ -45,76 +45,83 @@ async def on_invoice_paid(payment: Payment) -> None: logger.trace(f"splitpayments: performing split payments to {len(targets)} targets") for target in targets: + if target.percent > 0: + amount_msat = int(payment.amount * target.percent / 100) memo = ( - f"Split payment: {target.percent}% " - f"for {target.alias or target.wallet}" - f";{payment.memo};{payment.payment_hash}" + f"Split payment: {target.percent}% for {target.alias or target.wallet}" ) - if "@" in target.wallet or "LNURL" in target.wallet: + if target.wallet.find("@") >= 0 or target.wallet.find("LNURL") >= 0: safe_amount_msat = amount_msat - fee_reserve(amount_msat) payment_request = await get_lnurl_invoice( target.wallet, payment.wallet_id, safe_amount_msat, memo ) else: - wallet = await get_wallet_for_key(target.wallet) - if wallet is not None: - target.wallet = wallet.id - new_payment = await create_invoice( + _, payment_request = await create_invoice( wallet_id=target.wallet, amount=int(amount_msat / 1000), internal=True, memo=memo, ) - payment_request = new_payment.bolt11 - extra = {**payment.extra, "splitted": True} + extra = {**payment.extra, "tag": "splitpayments", "splitted": True} if payment_request: - task = asyncio.create_task( - pay_invoice_in_background( - payment_request=payment_request, - wallet_id=payment.wallet_id, - description=memo, - extra=extra, - ) + await pay_invoice( + payment_request=payment_request, + wallet_id=payment.wallet_id, + description=memo, + extra=extra, ) - task.add_done_callback(lambda fut: logger.success(fut.result())) - - -async def pay_invoice_in_background(payment_request, wallet_id, description, extra): - try: - await pay_invoice( - payment_request=payment_request, - wallet_id=wallet_id, - description=description, - extra=extra, - ) - return f"Splitpayments: paid invoice for {description}" - except Exception as e: - logger.error(f"Failed to pay invoice: {e}") async def get_lnurl_invoice( - payoraddress: str, wallet_id: str, amount_msat: int, memo: str -) -> str | None: + payoraddress, wallet_id, amount_msat, memo +) -> Optional[str]: + from lnbits.core.views.api import api_lnurlscan + + data = await api_lnurlscan(payoraddress) rounded_amount = floor(amount_msat / 1000) * 1000 - try: - payment_request = await get_pr_from_lnurl(payoraddress, rounded_amount, memo) - except Exception as e: - logger.error(f"Error getting LNURL invoice: {e!s}") + async with httpx.AsyncClient() as client: + try: + r = await client.get( + data["callback"], + params={"amount": rounded_amount, "comment": memo}, + timeout=40, + ) + if r.is_error: + raise httpx.ConnectError("issue with scrub callback") + r.raise_for_status() + except (httpx.ConnectError, httpx.RequestError): + logger.error( + f"splitting LNURL failed: Failed to connect to {data['callback']}." + ) + return None + except Exception as exc: + logger.error(f"splitting LNURL failed: {str(exc)}.") + return None + + params = json.loads(r.text) + if params.get("status") == "ERROR": + logger.error(f"{data['callback']} said: '{params.get('reason', '')}'") return None - invoice = bolt11.decode(payment_request) + invoice = bolt11.decode(params["pr"]) lnurlp_payment = await get_standalone_payment(invoice.payment_hash) if lnurlp_payment and lnurlp_payment.wallet_id == wallet_id: - logger.error("split failed. cannot split payments to yourself via LNURL.") + logger.error(f"split failed. cannot split payments to yourself via LNURL.") return None - return payment_request + if invoice.amount_msat != rounded_amount: + logger.error( + f"{data['callback']} returned an invalid invoice. Expected {amount_msat} msat, got {invoice.amount_msat}." + ) + return None + + return params["pr"] diff --git a/templates/splitpayments/index.html b/templates/splitpayments/index.html index 76103f2..c4eb5ce 100644 --- a/templates/splitpayments/index.html +++ b/templates/splitpayments/index.html @@ -9,9 +9,10 @@ filled dense :options="g.user.wallets" - v-model="selectedWallet" + :value="selectedWallet" label="Source Wallet:" option-label="name" + @input="changedWallet" > @@ -38,30 +39,12 @@ :hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined" style="width: 150px" > -
-
- -
-
- -
-
+ - - list[Target]: +) -> List[Target]: targets = await get_targets(wallet.wallet.id) return targets or [] -@splitpayments_api_router.put("/api/v1/targets", status_code=HTTPStatus.OK) +@splitpayments_ext.put("/api/v1/targets", status_code=HTTPStatus.OK) async def api_targets_set( target_put: TargetPutList, source_wallet: WalletTypeInfo = Depends(require_admin_key), ) -> None: try: - targets: list[Target] = [] + targets: List[Target] = [] for entry in target_put.targets: if entry.wallet.find("@") < 0 and entry.wallet.find("LNURL") < 0: wallet = await get_wallet(entry.wallet) if not wallet: - wallet = await get_wallet_for_key(entry.wallet) + wallet = await get_wallet_for_key(entry.wallet, "invoice") if not wallet: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -42,8 +42,7 @@ async def api_targets_set( if wallet.id == source_wallet.wallet.id: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Can't split to itself.", + status_code=HTTPStatus.BAD_REQUEST, detail="Can't split to itself." ) if entry.percent <= 0: @@ -54,7 +53,6 @@ async def api_targets_set( targets.append( Target( - id=urlsafe_short_hash(), wallet=entry.wallet, source=source_wallet.wallet.id, percent=entry.percent, @@ -75,11 +73,24 @@ async def api_targets_set( raise HTTPException( status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Cannot set targets.", - ) from ex + ) -@splitpayments_api_router.delete("/api/v1/targets", status_code=HTTPStatus.OK) +@splitpayments_ext.delete("/api/v1/targets", status_code=HTTPStatus.OK) async def api_targets_delete( source_wallet: WalletTypeInfo = Depends(require_admin_key), ) -> None: await set_targets(source_wallet.wallet.id, []) + + +# deinit extension invoice listener +@splitpayments_ext.delete( + "/api/v1", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)] +) +async def api_stop(): + for t in scheduled_tasks: + try: + t.cancel() + except Exception as ex: + logger.warning(ex) + return {"success": True}