Compare commits

..

10 commits

Author SHA1 Message Date
PatMulligan
138350dab4
Merge branch 'lnbits:main' into main
Some checks failed
lint.yml / Merge branch 'lnbits:main' into main (push) Failing after 0s
2026-05-14 09:27:26 +02:00
arbadacarba
766d317ce8
Fix typos (#38)
Removed duplicate lines regarding LNURLp and LNaddress handling in the README.
2026-05-05 22:57:44 +01:00
Arc
81184a0a53
feat: adds a wallet select to picking wallet + uv (#37)
* Adds a wallet select to picking wallet

* added uv

* uv uv uv

---------

Co-authored-by: dni  <office@dnilabs.com>
2026-05-05 22:53:59 +01:00
dni ⚡
d963af4042
chore: update to v1.1.0 (#36)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-08-18 12:12:36 +02:00
dni ⚡
b457fecc90
refactor: use get_pr_from_lnurl instead of api_lnurlscan (#35)
* refactor: use `get_pr_from_lnurl` instead of `api_lnurlscan`
2025-08-18 12:11:30 +02:00
Tiago Vasconcelos
d1c14ac199
Update pyproject.toml 2025-07-11 09:50:23 +01:00
Patrick Mulligan
3f87a5e0ec change org
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2025-06-17 17:34:30 +02:00
dni ⚡
4e2ebb7944
feat: pass previous memo through (#31)
now the memo of the payment has this form
`{memo;memo_parent_payment;payment_hash_parent_payment}
2025-02-26 12:56:31 +01:00
dni ⚡
0bf1557ce7
fix: linting (#30) 2025-02-26 12:36:41 +01:00
Sat
c524bcee69
Handle hodl invoices asynchronously tasks.py (#25)
- Modified the on_invoice_paid function to call pay_invoice asynchronously using asyncio.create_task.
- Added a new function pay_invoice_in_background to handle the asynchronous payment processing and exception handling.
- Ensured that the main event loop remains responsive even when encountering hodl invoices by running pay_invoice in the background.
2025-02-26 12:22:52 +01:00
12 changed files with 2414 additions and 2720 deletions

View file

@ -5,27 +5,27 @@ format: prettier black ruff
check: mypy pyright checkblack checkruff checkprettier check: mypy pyright checkblack checkruff checkprettier
prettier: prettier:
poetry run ./node_modules/.bin/prettier --write . uv run ./node_modules/.bin/prettier --write .
pyright: pyright:
poetry run ./node_modules/.bin/pyright uv run ./node_modules/.bin/pyright
mypy: mypy:
poetry run mypy . uv run mypy .
black: black:
poetry run black . uv run black .
ruff: ruff:
poetry run ruff check . --fix uv run ruff check . --fix
checkruff: checkruff:
poetry run ruff check . uv run ruff check .
checkprettier: checkprettier:
poetry run ./node_modules/.bin/prettier --check . uv run ./node_modules/.bin/prettier --check .
checkblack: checkblack:
poetry run black --check . uv run black --check .
checkeditorconfig: checkeditorconfig:
editorconfig-checker editorconfig-checker
@ -33,14 +33,14 @@ checkeditorconfig:
test: test:
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
poetry run pytest uv run pytest
install-pre-commit-hook: install-pre-commit-hook:
@echo "Installing pre-commit hook to git" @echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with poetry run pre-commit uninstall" @echo "Uninstall the hook with uv run pre-commit uninstall"
poetry run pre-commit install uv run pre-commit install
pre-commit: pre-commit:
poetry run pre-commit run --all-files uv run pre-commit run --all-files
checkbundle: checkbundle:

View file

@ -33,9 +33,9 @@ LNbits Split Payments extension allows for distributing payments across multiple
IMPORTANT: IMPORTANT:
- 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. - 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 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 - 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 receipient - 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
<img width="1148" alt="Bildschirm­foto 2023-05-01 um 22 14 36" src="https://user-images.githubusercontent.com/63317640/235534056-49296aeb-7295-4b4e-9f57-914a677f5ad4.png"> <img width="1148" alt="Bildschirm­foto 2023-05-01 um 22 14 36" src="https://user-images.githubusercontent.com/63317640/235534056-49296aeb-7295-4b4e-9f57-914a677f5ad4.png">
<img width="1402" alt="Bildschirm­foto 2023-05-01 um 22 17 52" src="https://user-images.githubusercontent.com/63317640/235534063-b2734654-7c1a-48a3-b48e-32798c232b49.png"> <img width="1402" alt="Bildschirm­foto 2023-05-01 um 22 17 52" src="https://user-images.githubusercontent.com/63317640/235534063-b2734654-7c1a-48a3-b48e-32798c232b49.png">

View file

@ -41,7 +41,7 @@ def splitpayments_start():
__all__ = [ __all__ = [
"db", "db",
"splitpayments_ext", "splitpayments_ext",
"splitpayments_static_files",
"splitpayments_start", "splitpayments_start",
"splitpayments_static_files",
"splitpayments_stop", "splitpayments_stop",
] ]

View file

@ -2,7 +2,8 @@
"name": "Split Payments", "name": "Split Payments",
"short_description": "Split incoming payments across wallets", "short_description": "Split incoming payments across wallets",
"tile": "/splitpayments/static/image/split-payments.png", "tile": "/splitpayments/static/image/split-payments.png",
"min_lnbits_version": "1.0.0", "version": "1.1.0",
"min_lnbits_version": "1.3.0",
"contributors": [ "contributors": [
{ {
"name": "cryptograffiti", "name": "cryptograffiti",

View file

@ -2,7 +2,7 @@
"repos": [ "repos": [
{ {
"id": "splitpayments", "id": "splitpayments",
"organisation": "lnbits", "organisation": "PatMulligan",
"repository": "splitpayments" "repository": "splitpayments"
} }
] ]

View file

@ -1,5 +1,3 @@
from typing import Optional
from fastapi import Query from fastapi import Query
from pydantic import BaseModel from pydantic import BaseModel
@ -9,7 +7,7 @@ class Target(BaseModel):
wallet: str wallet: str
source: str source: str
percent: float percent: float
alias: Optional[str] = None alias: str | None = None
class TargetPut(BaseModel): class TargetPut(BaseModel):

2612
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,39 +1,36 @@
[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] [tool.poetry]
name = "lnbits-splitpayments" package-mode = false
version = "0.0.0"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
[tool.poetry.dependencies] [tool.uv]
python = "^3.10 | ^3.9" dev-dependencies = [
lnbits = {version = "*", allow-prereleases = true} "black",
"pytest-asyncio",
[tool.poetry.group.dev.dependencies] "pytest",
black = "^24.3.0" "mypy==1.17.1",
pytest-asyncio = "^0.21.0" "pre-commit",
pytest = "^7.3.2" "ruff",
mypy = "^1.5.1" "pytest-md",
pre-commit = "^3.2.2" ]
ruff = "^0.3.2"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy] [tool.mypy]
exclude = "(nostr/*)" exclude = "(nostr/*)"
[[tool.mypy.overrides]] plugins = ["pydantic.mypy"]
module = [
"lnbits.*", [tool.pydantic-mypy]
"lnurl.*", init_forbid_extra = true
"loguru.*", init_typed = true
"fastapi.*", warn_required_dynamic_aliases = true
"pydantic.*", warn_untyped_fields = true
"pyqrcode.*",
"shortuuid.*",
"httpx.*",
]
ignore_missing_imports = "True"
[tool.pytest.ini_options] [tool.pytest.ini_options]
log_cli = false log_cli = false
@ -76,6 +73,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# needed for pydantic # needed for pydantic
[tool.ruff.lint.pep8-naming] [tool.ruff.lint.pep8-naming]
classmethod-decorators = [ classmethod-decorators = [
"validator",
"root_validator", "root_validator",
] ]

View file

@ -51,8 +51,11 @@ window.app = Vue.createApp({
'/splitpayments/api/v1/targets', '/splitpayments/api/v1/targets',
this.selectedWallet.adminkey this.selectedWallet.adminkey
) )
.then(response => { .then(res => {
this.targets = response.data this.targets = res.data.map(t => ({
...t,
targetChoice: t.targetChoice || 'wallet'
}))
}) })
.catch(err => { .catch(err => {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
@ -63,16 +66,26 @@ window.app = Vue.createApp({
this.getTargets() this.getTargets()
}, },
addTarget() { addTarget() {
this.targets.push({source: this.selectedWallet}) this.targets.push({
source: this.selectedWallet,
targetChoice: 'wallet'
})
}, },
saveTargets() { 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 LNbits.api
.request( .request(
'PUT', 'PUT',
'/splitpayments/api/v1/targets', '/splitpayments/api/v1/targets',
this.selectedWallet.adminkey, this.selectedWallet.adminkey,
{ {
targets: this.targets targets: payload
} }
) )
.then(response => { .then(response => {

View file

@ -1,14 +1,16 @@
import asyncio import asyncio
import json
from math import floor from math import floor
from typing import Optional
import bolt11 import bolt11
import httpx
from lnbits.core.crud import get_standalone_payment from lnbits.core.crud import get_standalone_payment
from lnbits.core.crud.wallets import get_wallet_for_key from lnbits.core.crud.wallets import get_wallet_for_key
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.core.services import create_invoice, fee_reserve, pay_invoice from lnbits.core.services import (
create_invoice,
fee_reserve,
get_pr_from_lnurl,
pay_invoice,
)
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from loguru import logger from loguru import logger
@ -43,15 +45,15 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.trace(f"splitpayments: performing split payments to {len(targets)} targets") logger.trace(f"splitpayments: performing split payments to {len(targets)} targets")
for target in targets: for target in targets:
if target.percent > 0: if target.percent > 0:
amount_msat = int(payment.amount * target.percent / 100) amount_msat = int(payment.amount * target.percent / 100)
memo = ( memo = (
f"Split payment: {target.percent}% for {target.alias or target.wallet}" f"Split payment: {target.percent}% "
f"for {target.alias or target.wallet}"
f";{payment.memo};{payment.payment_hash}"
) )
if target.wallet.find("@") >= 0 or target.wallet.find("LNURL") >= 0: if "@" in target.wallet or "LNURL" in target.wallet:
safe_amount_msat = amount_msat - fee_reserve(amount_msat) safe_amount_msat = amount_msat - fee_reserve(amount_msat)
payment_request = await get_lnurl_invoice( payment_request = await get_lnurl_invoice(
target.wallet, payment.wallet_id, safe_amount_msat, memo target.wallet, payment.wallet_id, safe_amount_msat, memo
@ -71,48 +73,43 @@ async def on_invoice_paid(payment: Payment) -> None:
extra = {**payment.extra, "splitted": True} extra = {**payment.extra, "splitted": True}
if payment_request: if payment_request:
await pay_invoice( task = asyncio.create_task(
payment_request=payment_request, pay_invoice_in_background(
wallet_id=payment.wallet_id, payment_request=payment_request,
description=memo, wallet_id=payment.wallet_id,
extra=extra, 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( async def get_lnurl_invoice(
payoraddress, wallet_id, amount_msat, memo payoraddress: str, wallet_id: str, amount_msat: int, memo: str
) -> Optional[str]: ) -> str | None:
from lnbits.core.views.api import api_lnurlscan
data = await api_lnurlscan(payoraddress)
rounded_amount = floor(amount_msat / 1000) * 1000 rounded_amount = floor(amount_msat / 1000) * 1000
async with httpx.AsyncClient() as client: try:
try: payment_request = await get_pr_from_lnurl(payoraddress, rounded_amount, memo)
r = await client.get( except Exception as e:
data["callback"], logger.error(f"Error getting LNURL invoice: {e!s}")
params={"amount": rounded_amount, "comment": memo},
timeout=5,
)
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: {exc!s}.")
return None
params = json.loads(r.text)
if params.get("status") == "ERROR":
logger.error(f"{data['callback']} said: '{params.get('reason', '')}'")
return None return None
invoice = bolt11.decode(params["pr"]) invoice = bolt11.decode(payment_request)
lnurlp_payment = await get_standalone_payment(invoice.payment_hash) lnurlp_payment = await get_standalone_payment(invoice.payment_hash)
@ -120,13 +117,4 @@ async def get_lnurl_invoice(
logger.error("split failed. cannot split payments to yourself via LNURL.") logger.error("split failed. cannot split payments to yourself via LNURL.")
return None return None
if invoice.amount_msat != rounded_amount: return payment_request
logger.error(
f"""
{data['callback']} returned an invalid invoice.
Expected {amount_msat} msat, got {invoice.amount_msat}.
"""
)
return None
return params["pr"]

View file

@ -38,12 +38,30 @@
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined" :hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
style="width: 150px" style="width: 150px"
></q-input> ></q-input>
<div class="column q-mt-none">
<div class="col">
<q-radio
class="float-left"
v-model="target.targetChoice"
val="wallet"
label="wallet"
></q-radio>
</div>
<div class="col">
<q-radio
class="float-left"
v-model="target.targetChoice"
val="lnurl"
label="lnurl"
></q-radio>
</div>
</div>
<q-input <q-input
v-if="target.targetChoice === 'lnurl'"
dense dense
v-model.trim="target.wallet" v-model.trim="target.wallet"
label="Target" label="Target"
hint="A wallet ID, invoice key, LNURLp or Lightning Address." hint="LNURLp or Lightning Address."
option-label="name" option-label="name"
style="width: 500px" style="width: 500px"
new-value-mode="add-unique" new-value-mode="add-unique"
@ -51,6 +69,19 @@
input-debounce="0" input-debounce="0"
emit-value emit-value
></q-input> ></q-input>
<q-select
v-if="target.targetChoice === 'wallet'"
class="q-pr-md q-pt-sm"
filled
dense
style="width: 500px"
v-model="target.wallet"
:options="g.user.walletOptions"
emit-value
map-options
label="Receive wallet *"
>
</q-select>
<q-input <q-input
style="width: 150px" style="width: 150px"

2277
uv.lock generated Normal file

File diff suppressed because it is too large Load diff