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>}, ...]}' - -
-
-
-
diff --git a/templates/splitpayments/index.html b/templates/splitpayments/index.html index b105bf2..307efc5 100644 --- a/templates/splitpayments/index.html +++ b/templates/splitpayments/index.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block page %}
-
+
@@ -43,40 +43,17 @@ - - - - - Add more - -
- - Clear + + Add Target + +
+
+ + Delete all Targets
@@ -117,7 +87,7 @@ unelevated color="primary" type="submit" - :disabled="targets.length < 2" + :disabled="targets.length < 1" > Save Targets @@ -128,17 +98,41 @@
-
+
{{SITE_TITLE}} SplitPayments extension
- - - {% include "splitpayments/_api_docs.html" %} - + + + +

+ 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. +

+
+
diff --git a/views_api.py b/views_api.py index f83f2b4..48e5b31 100644 --- a/views_api.py +++ b/views_api.py @@ -1,63 +1,96 @@ from http import HTTPStatus +from typing import List -from fastapi import Depends, Request +from fastapi import Depends +from loguru import logger from starlette.exceptions import HTTPException from lnbits.core.crud import get_wallet, get_wallet_for_key -from lnbits.decorators import WalletTypeInfo, require_admin_key +from lnbits.decorators import WalletTypeInfo, check_admin, require_admin_key -from . import splitpayments_ext +from . import scheduled_tasks, splitpayments_ext from .crud import get_targets, set_targets -from .models import Target, TargetPut +from .models import Target, TargetPutList @splitpayments_ext.get("/api/v1/targets") -async def api_targets_get(wallet: WalletTypeInfo = Depends(require_admin_key)): +async def api_targets_get( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> List[Target]: targets = await get_targets(wallet.wallet.id) - return [target.dict() for target in targets] or [] + return targets or [] -@splitpayments_ext.put("/api/v1/targets") +@splitpayments_ext.put("/api/v1/targets", status_code=HTTPStatus.OK) async def api_targets_set( - req: Request, wal: WalletTypeInfo = Depends(require_admin_key) -): - body = await req.json() - targets = [] - data = TargetPut.parse_obj(body["targets"]) - for entry in data.__root__: - wallet = await get_wallet(entry.wallet) - if not wallet: - wallet = await get_wallet_for_key(entry.wallet, "invoice") - if not wallet: + target_put: TargetPutList, + source_wallet: WalletTypeInfo = Depends(require_admin_key), +) -> None: + try: + 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, "invoice") + if not wallet: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Invalid wallet '{entry.wallet}'.", + ) + + if wallet.id == source_wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Can't split to itself." + ) + + if entry.percent <= 0: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, - detail=f"Invalid wallet '{entry.wallet}'.", + detail=f"Invalid percent '{entry.percent}'.", ) - if wallet.id == wal.wallet.id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Can't split to itself." + targets.append( + Target( + wallet=entry.wallet, + source=source_wallet.wallet.id, + percent=entry.percent, + alias=entry.alias, + ) ) - if entry.percent < 0: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Invalid percent '{entry.percent}'.", - ) + percent_sum = sum([target.percent for target in targets]) + if percent_sum > 100: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Splitting over 100%" + ) - targets.append( - Target( - wallet=wallet.id, - source=wal.wallet.id, - tag=entry.tag, - percent=entry.percent, - alias=entry.alias, - ) + await set_targets(source_wallet.wallet.id, targets) + + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot set targets.", ) - percent_sum = sum([target.percent for target in targets]) - if percent_sum > 100: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Splitting over 100%." - ) - await set_targets(wal.wallet.id, targets) - return "" + + +@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}