diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bee8a64
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__
diff --git a/__init__.py b/__init__.py
index 5efb633..95b48bc 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,4 +1,5 @@
import asyncio
+from typing import List
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
@@ -9,6 +10,8 @@ from lnbits.tasks import catch_everything_and_restart
db = Database("ext_splitpayments")
+scheduled_tasks: List[asyncio.Task] = []
+
splitpayments_static_files = [
{
"path": "/splitpayments/static",
@@ -32,4 +35,5 @@ from .views_api import * # noqa: F401,F403
def splitpayments_start():
loop = asyncio.get_event_loop()
- loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
+ task = loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
+ scheduled_tasks.append(task)
diff --git a/config.json b/config.json
index 1e0c967..c5aee53 100644
--- a/config.json
+++ b/config.json
@@ -1,6 +1,6 @@
{
"name": "Split Payments",
"short_description": "Split incoming payments across wallets",
- "tile": "/splitpayments/static/image/split-payments.png",
+ "tile": "/splitpayments/static/image/split-payments.png",
"contributors": ["fiatjaf", "cryptograffiti"]
}
diff --git a/crud.py b/crud.py
index 737e7bb..f9a7e04 100644
--- a/crud.py
+++ b/crud.py
@@ -22,15 +22,14 @@ async def set_targets(source_wallet: str, targets: List[Target]):
await conn.execute(
"""
INSERT INTO splitpayments.targets
- (id, source, wallet, percent, tag, alias)
- VALUES (?, ?, ?, ?, ?, ?)
+ (id, source, wallet, percent, alias)
+ VALUES (?, ?, ?, ?, ?)
""",
(
urlsafe_short_hash(),
source_wallet,
target.wallet,
target.percent,
- target.tag,
target.alias,
),
)
diff --git a/manifest.json b/manifest.json
index 517dd49..3ee75e1 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,9 +1,9 @@
{
- "repos": [
- {
- "id": "splitpayments",
- "organisation": "lnbits",
- "repository": "splitpayments"
- }
- ]
+ "repos": [
+ {
+ "id": "splitpayments",
+ "organisation": "lnbits",
+ "repository": "splitpayments"
+ }
+ ]
}
diff --git a/migrations.py b/migrations.py
index eb72387..69eb81e 100644
--- a/migrations.py
+++ b/migrations.py
@@ -97,3 +97,28 @@ async def m003_add_id_and_tag(db):
)
await db.execute("DROP TABLE splitpayments.splitpayments_old")
+
+
+async def m004_remove_tag(db):
+ """
+ This removes tag
+ """
+ keys = "id,wallet,source,percent,alias"
+ new_db = "splitpayments.targets"
+ 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} (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ source TEXT NOT NULL,
+ percent REAL NOT NULL CHECK (percent >= 0 AND percent <= 100),
+ alias TEXT,
+ UNIQUE (source, wallet)
+ );
+ """
+ )
+ await db.execute(f"INSERT INTO {new_db} ({keys}) SELECT {keys} FROM {old_db}")
+ await db.execute(f"DROP TABLE {old_db}")
diff --git a/models.py b/models.py
index 4f2bb01..854a4a2 100644
--- a/models.py
+++ b/models.py
@@ -9,7 +9,6 @@ class Target(BaseModel):
wallet: str
source: str
percent: float
- tag: str
alias: Optional[str]
@classmethod
@@ -17,12 +16,11 @@ class Target(BaseModel):
return cls(**dict(row))
-class TargetPutList(BaseModel):
+class TargetPut(BaseModel):
wallet: str = Query(...)
alias: str = Query("")
- percent: float = Query(..., ge=0, lt=100)
- tag: str
+ percent: float = Query(..., ge=0, le=100)
-class TargetPut(BaseModel):
- __root__: List[TargetPutList]
+class TargetPutList(BaseModel):
+ targets: List[TargetPut]
diff --git a/static/js/index.js b/static/js/index.js
index f5f1627..7e334ac 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -24,11 +24,7 @@ new Vue({
return {
selectedWallet: null,
currentHash: '', // a string that must match if the edit data is unchanged
- targets: [
- {
- method: 'split'
- }
- ]
+ targets: []
}
},
computed: {
@@ -37,14 +33,6 @@ new Vue({
}
},
methods: {
- clearTargets() {
- this.targets = [{}]
- this.$q.notify({
- message:
- 'Cleared the form, but not saved. You must click to save manually.',
- timeout: 500
- })
- },
clearTarget(index) {
this.targets.splice(index, 1)
console.log(this.targets)
@@ -60,106 +48,21 @@ new Vue({
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey
)
+ .then(response => {
+ this.targets = response.data
+ })
.catch(err => {
LNbits.utils.notifyApiError(err)
})
- .then(response => {
- this.currentHash = hashTargets(response.data)
- this.targets = response.data.concat({})
- for (let i = 0; i < this.targets.length; i++) {
- if (this.targets[i].tag.length > 0) {
- this.targets[i].method = 'tag'
- } else if (this.targets[i].percent.length > 0) {
- this.targets[i].method = 'split'
- } else {
- this.targets[i].method = ''
- }
- }
- })
},
changedWallet(wallet) {
this.selectedWallet = wallet
this.getTargets()
},
- clearChanged(index) {
- if (this.targets[index].method == 'split') {
- this.targets[index].tag = null
- this.targets[index].method = 'split'
- } else {
- this.targets[index].percent = null
- this.targets[index].method = 'tag'
- }
- },
- targetChanged(index) {
- // fix percent min and max range
- if (this.targets[index].percent) {
- if (this.targets[index].percent > 100) this.targets[index].percent = 100
- if (this.targets[index].percent < 0) this.targets[index].percent = 0
- this.targets[index].tag = ''
- }
-
- // not percentage
- if (!this.targets[index].percent) {
- this.targets[index].percent = 0
- }
-
- // remove empty lines (except last)
- if (this.targets.length >= 2) {
- for (let i = this.targets.length - 2; i >= 0; i--) {
- let target = this.targets[i]
- if (
- (!target.wallet || target.wallet.trim() === '') &&
- (!target.alias || target.alias.trim() === '') &&
- (!target.tag || target.tag.trim() === '') &&
- !target.percent
- ) {
- this.targets.splice(i, 1)
- }
- }
- }
-
- // add a line at the end if the last one is filled
- let last = this.targets[this.targets.length - 1]
- if (last.wallet && last.wallet.trim() !== '') {
- this.targets.push({})
- }
-
- // sum of all percents
- let currentTotal = this.targets.reduce(
- (acc, target) => acc + (target.percent || 0),
- 0
- )
-
- // remove last (unfilled) line if the percent is already 100
- if (currentTotal >= 100) {
- let last = this.targets[this.targets.length - 1]
- if (
- (!last.wallet || last.wallet.trim() === '') &&
- (!last.alias || last.alias.trim() === '') &&
- !last.percent
- ) {
- this.targets = this.targets.slice(0, -1)
- }
- }
-
- // adjust percents of other lines (not this one)
- if (currentTotal > 100 && isPercent) {
- let diff = (currentTotal - 100) / (100 - this.targets[index].percent)
- this.targets.forEach((target, t) => {
- if (t !== index) target.percent -= +(diff * target.percent).toFixed(2)
- })
- }
- // overwrite so changes appear
- this.targets = this.targets
+ addTarget() {
+ this.targets.push({source: this.selectedWallet})
},
saveTargets() {
- for (let i = 0; i < this.targets.length; i++) {
- if (this.targets[i].tag != '') {
- this.targets[i].percent = 0
- } else {
- this.targets[i].tag = ''
- }
- }
LNbits.api
.request(
'PUT',
@@ -167,13 +70,6 @@ new Vue({
this.selectedWallet.adminkey,
{
targets: this.targets
- .filter(isTargetComplete)
- .map(({wallet, percent, tag, alias}) => ({
- wallet,
- percent,
- tag,
- alias
- }))
}
)
.then(response => {
@@ -181,11 +77,32 @@ new Vue({
message: 'Split payments targets set.',
timeout: 700
})
- this.getTargets()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
+ },
+ deleteTargets() {
+ LNbits.utils
+ .confirmDialog('Are you sure you want to delete the targets?')
+ .onOk(() => {
+ this.targets = []
+ LNbits.api
+ .request(
+ 'DELETE',
+ '/splitpayments/api/v1/targets',
+ this.selectedWallet.adminkey
+ )
+ .then(response => {
+ this.$q.notify({
+ message: 'Split payments targets deleted.',
+ timeout: 700
+ })
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ })
}
},
created() {
diff --git a/tasks.py b/tasks.py
index 59aa8e0..9bf381d 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1,9 +1,15 @@
import asyncio
+import json
+from math import floor
+from typing import Optional
+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, pay_invoice
+from lnbits.core.services import create_invoice, pay_invoice, fee_reserve
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
@@ -30,7 +36,6 @@ async def on_invoice_paid(payment: Payment) -> None:
if not targets:
return
- # validate target percentages
total_percent = sum([target.percent for target in targets])
if total_percent > 100:
@@ -39,38 +44,84 @@ async def on_invoice_paid(payment: Payment) -> None:
logger.trace(f"splitpayments: performing split payments to {len(targets)} targets")
- if payment.extra.get("amount"):
- amount_to_split = (payment.extra.get("amount") or 0) * 1000
- else:
- amount_to_split = payment.amount
-
- if not amount_to_split:
- logger.error("splitpayments: no amount to split")
- return
-
for target in targets:
- tagged = target.tag in payment.extra
- if tagged or target.percent > 0:
+ if target.percent > 0:
- if tagged:
- memo = f"Pushed tagged payment to {target.alias}"
- amount_msat = int(amount_to_split)
- else:
- amount_msat = int(amount_to_split * target.percent / 100)
- memo = f"Split payment: {target.percent}% for {target.alias or target.wallet}"
-
- payment_hash, payment_request = await create_invoice(
- wallet_id=target.wallet,
- amount=int(amount_msat / 1000),
- internal=True,
- memo=memo,
+ amount_msat = int(payment.amount * target.percent / 100)
+ memo = (
+ f"Split payment: {target.percent}% for {target.alias or 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:
+ _, payment_request = await create_invoice(
+ wallet_id=target.wallet,
+ amount=int(amount_msat / 1000),
+ internal=True,
+ memo=memo,
+ )
+
extra = {**payment.extra, "tag": "splitpayments", "splitted": True}
- await pay_invoice(
- payment_request=payment_request,
- wallet_id=payment.wallet_id,
- extra=extra,
+ if payment_request:
+ await pay_invoice(
+ payment_request=payment_request,
+ wallet_id=payment.wallet_id,
+ description=memo,
+ extra=extra,
+ )
+
+
+async def get_lnurl_invoice(
+ 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
+
+ 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(params["pr"])
+
+ lnurlp_payment = await get_standalone_payment(invoice.payment_hash)
+
+ if lnurlp_payment and lnurlp_payment.wallet_id == wallet_id:
+ logger.error(f"split failed. cannot split payments to yourself via LNURL.")
+ return None
+
+ 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/_api_docs.html b/templates/splitpayments/_api_docs.html
deleted file mode 100644
index 4b5ed97..0000000
--- a/templates/splitpayments/_api_docs.html
+++ /dev/null
@@ -1,97 +0,0 @@
-
- Add some wallets to the list of "Target Wallets", each with an
- associated percent. After saving, every time any payment
- arrives at the "Source Wallet" that payment will be split with the
- target wallets according to their percent.
- This is valid for every payment, doesn't matter how it was created. Target wallets can be any wallet from this same LNbits instance.
- To remove a wallet from the targets list, just erase its fields and
- save. To remove all, click "Clear" then save.
- GET
- /splitpayments/api/v1/targets
- Headers
- {"X-Api-Key": <admin_key>}
- Body (application/json)
-
- Returns 200 OK (application/json)
-
- [{"wallet": <wallet id>, "alias": <chosen name for this
- wallet>, "percent": <number between 1 and 100>}, ...]
- Curl example
- curl -X GET {{ request.base_url }}splitpayments/api/v1/targets -H
- "X-Api-Key: {{ user.wallets[0].inkey }}"
-
- PUT
- /splitpayments/api/v1/targets
- Headers
- {"X-Api-Key": <admin_key>}
- Body (application/json)
-
- Returns 200 OK (application/json)
-
- Curl example
- curl -X PUT {{ request.base_url }}splitpayments/api/v1/targets -H
- "X-Api-Key: {{ user.wallets[0].adminkey }}" -H 'Content-Type:
- application/json' -d '{"targets": [{"wallet": <wallet id or invoice
- key>, "alias": <name to identify this>, "percent": <number
- between 1 and 100>}, ...]}'
-
-
+ Add some targets to the list of "Target Wallets", each with an + associated percentage. After saving, every time any payment + arrives at the "Source Wallet" that payment will be split with the + target wallets according to their percentage. +
++ This is valid for every payment, doesn't matter how it was created. +
++ Targets can be LNBits wallets from this LNBits instance or any valid LNURL or LN Address. +
++ LNURLp and LN Addresses must allow comments > 100 chars and also have a flexible amount. +
++ To remove a wallet from the targets list just press the X and save. + To remove all, click "Delete all Targets". +
++ For each split via LNURLp or Lightning addresses a fee_reserve is + substracted, because of potential routing fees. +
+