splitpayments extension.

This commit is contained in:
fiatjaf 2021-06-06 20:41:59 -03:00
parent 239e0cdbcf
commit 7ab4553ef5
12 changed files with 571 additions and 0 deletions

View file

@ -0,0 +1,7 @@
# Split Payments
Set this and forget. It will keep splitting your payments across wallets forever.
## Sponsored by
[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/)

View file

@ -0,0 +1,18 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_splitpayments")
splitpayments_ext: Blueprint = Blueprint(
"splitpayments", __name__, static_folder="static", template_folder="templates"
)
from .views_api import * # noqa
from .views import * # noqa
from .tasks import register_listeners
from lnbits.tasks import record_async
splitpayments_ext.record(record_async(register_listeners))

View file

@ -0,0 +1,9 @@
{
"name": "SplitPayments",
"short_description": "Split incoming payments to other wallets.",
"icon": "call_split",
"contributors": [
"fiatjaf",
"cryptograffiti"
]
}

View file

@ -0,0 +1,23 @@
from typing import List
from . import db
from .models import Target
async def get_targets(source_wallet: str) -> List[Target]:
rows = await db.fetchall("SELECT * FROM targets WHERE source = ?", (source_wallet,))
return [Target(**dict(row)) for row in rows]
async def set_targets(source_wallet: str, targets: List[Target]):
async with db.connect() as conn:
await conn.execute("DELETE FROM targets WHERE source = ?", (source_wallet,))
for target in targets:
await conn.execute(
"""
INSERT INTO targets
(source, wallet, percent, alias)
VALUES (?, ?, ?, ?)
""",
(source_wallet, target.wallet, target.percent, target.alias),
)

View file

@ -0,0 +1,16 @@
async def m001_initial(db):
"""
Initial split payment table.
"""
await db.execute(
"""
CREATE TABLE targets (
wallet TEXT NOT NULL,
source TEXT NOT NULL,
percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100),
alias TEXT,
UNIQUE (source, wallet)
);
"""
)

View file

@ -0,0 +1,8 @@
from typing import NamedTuple
class Target(NamedTuple):
wallet: str
source: str
percent: int
alias: str

View file

@ -0,0 +1,143 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
function hashTargets(targets) {
return targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`)
.join('')
}
function isTargetComplete(target) {
return target.wallet && target.wallet.trim() !== '' && target.percent > 0
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
selectedWallet: null,
currentHash: '', // a string that must match if the edit data is unchanged
targets: []
}
},
computed: {
isDirty() {
return hashTargets(this.targets) !== this.currentHash
}
},
methods: {
clearTargets() {
this.targets = [{}]
this.$q.notify({
message:
'Cleared the form, but not saved. You must click to save manually.',
timeout: 500
})
},
getTargets() {
LNbits.api
.request(
'GET',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
this.currentHash = hashTargets(response.data)
this.targets = response.data.concat({})
})
},
changedWallet(wallet) {
this.selectedWallet = wallet
this.getTargets()
},
targetChanged(isPercent, index) {
// fix percent min and max range
if (isPercent) {
if (this.targets[index].percent > 100) this.targets[index].percent = 100
if (this.targets[index].percent < 0) 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.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() !== '' && last.percent > 0) {
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 -= Math.round(diff * target.percent)
})
}
// overwrite so changes appear
this.targets = this.targets
},
saveTargets() {
LNbits.api
.request(
'PUT',
'/splitpayments/api/v1/targets',
this.selectedWallet.adminkey,
{
targets: this.targets
.filter(isTargetComplete)
.map(({wallet, percent, alias}) => ({wallet, percent, alias}))
}
)
.then(response => {
this.$q.notify({
message: 'Split payments targets set.',
timeout: 700
})
this.getTargets()
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created() {
this.selectedWallet = this.g.user.wallets[0]
this.getTargets()
}
})

View file

@ -0,0 +1,77 @@
import json
import trio # type: ignore
from lnbits.core.models import Payment
from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
from lnbits.tasks import register_invoice_listener, internal_invoice_paid
from lnbits.helpers import urlsafe_short_hash
from .crud import get_targets
async def register_listeners():
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
register_invoice_listener(invoice_paid_chan_send)
await wait_for_paid_invoices(invoice_paid_chan_recv)
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
async for payment in invoice_paid_chan:
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"):
# already splitted, ignore
return
# now we make some special internal transfers (from no one to the receiver)
targets = await get_targets(payment.wallet_id)
transfers = [
(target.wallet, int(target.percent * payment.amount / 100))
for target in targets
]
transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0]
amount_left = payment.amount - sum([amount for _, amount in transfers])
if amount_left < 0:
print("splitpayments failure: amount_left is negative.", payment.payment_hash)
return
if not targets:
return
# mark the original payment with one extra key, "splitted"
# (this prevents us from doing this process again and it's informative)
# and reduce it by the amount we're going to send to the producer
await core_db.execute(
"""
UPDATE apipayments
SET extra = ?, amount = ?
WHERE hash = ?
AND checking_id NOT LIKE 'internal_%'
""",
(
json.dumps(dict(**payment.extra, splitted=True)),
amount_left,
payment.payment_hash,
),
)
# perform the internal transfer using the same payment_hash
for wallet, amount in transfers:
internal_checking_id = f"internal_{urlsafe_short_hash()}"
await create_payment(
wallet_id=wallet,
checking_id=internal_checking_id,
payment_request="",
payment_hash=payment.payment_hash,
amount=amount,
memo=payment.memo,
pending=False,
extra={"tag": "splitpayments"},
)
# manually send this for now
await internal_invoice_paid.send(internal_checking_id)

View file

@ -0,0 +1,90 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<p>
Add some wallets to the list of "Target Wallets", each with an
associated <em>percent</em>. After saving, every time any payment
arrives at the "Source Wallet" that payment will be split with the
target wallets according to their percent.
</p>
<p>This is valid for every payment, doesn't matter how it was created.</p>
<p>Target wallets can be any wallet from this same LNbits instance.</p>
<p>
To remove a wallet from the targets list, just erase its fields and
save. To remove all, click "Clear" then save.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="api"
dense
expand-separator
label="List Target Wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/splitpayments/api/v1/targets</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>[{"wallet": &lt;wallet id&gt;, "alias": &lt;chosen name for this
wallet&gt;, "percent": &lt;number between 1 and 100&gt;}, ...]</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Set Target Wallets"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/splitpayments/api/v1/targets</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H
"X-Api-Key: {{ g.user.wallets[0].adminkey }}" -H 'Content-Type:
application/json' -d '{"targets": [{"wallet": &lt;wallet id or invoice
key&gt;, "alias": &lt;name to identify this&gt;, "percent": &lt;number
between 1 and 100&gt;}, ...]}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,98 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Source Wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Target Wallets</h5>
</div>
<q-form class="q-gutter-md" @submit="saveTargets">
<div
class="q-gutter-md row items-start"
style="flex-wrap: nowrap"
v-for="(target, t) in targets"
>
<q-input
dense
outlined
v-model="target.wallet"
label="Wallet"
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
@input="targetChanged(false)"
></q-input>
<q-input
dense
outlined
v-model="target.alias"
label="Alias"
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
@input="targetChanged(false)"
></q-input>
<q-input
dense
outlined
v-model.number="target.percent"
label="Split Share"
:hint="t === targets.length - 1 ? 'How much of the incoming payments will go to the target wallet.' : undefined"
suffix="%"
@input="targetChanged(true, t)"
></q-input>
</div>
<q-row class="row justify-evenly q-pa-lg">
<q-col>
<q-btn unelevated outline color="purple" @click="clearTargets">
Clear
</q-btn>
</q-col>
<q-col>
<q-btn
unelevated
color="deep-purple"
type="submit"
:disabled="!isDirty"
>
Save Targets
</q-btn>
</q-col>
</q-row>
</q-form>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits SplitPayments extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "splitpayments/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/splitpayments/static/js/index.js"></script>
{% endblock %}

View file

@ -0,0 +1,12 @@
from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from . import splitpayments_ext
@splitpayments_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("splitpayments/index.html", user=g.user)

View file

@ -0,0 +1,70 @@
from quart import g, jsonify
from http import HTTPStatus
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.core.crud import get_wallet, get_wallet_for_key
from . import splitpayments_ext
from .crud import get_targets, set_targets
from .models import Target
@splitpayments_ext.route("/api/v1/targets", methods=["GET"])
@api_check_wallet_key("admin")
async def api_targets_get():
targets = await get_targets(g.wallet.id)
return jsonify([target._asdict() for target in targets] or [])
@splitpayments_ext.route("/api/v1/targets", methods=["PUT"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"targets": {
"type": "list",
"schema": {
"type": "dict",
"schema": {
"wallet": {"type": "string"},
"alias": {"type": "string"},
"percent": {"type": "integer"},
},
},
}
}
)
async def api_targets_set():
targets = []
for entry in g.data["targets"]:
wallet = await get_wallet(entry["wallet"])
if not wallet:
wallet = await get_wallet_for_key(entry["wallet"], "invoice")
if not wallet:
return (
jsonify({"message": f"Invalid wallet '{entry['wallet']}'."}),
HTTPStatus.BAD_REQUEST,
)
if wallet.id == g.wallet.id:
return (
jsonify({"message": "Can't split to itself."}),
HTTPStatus.BAD_REQUEST,
)
if entry["percent"] < 0:
return (
jsonify({"message": f"Invalid percent '{entry['percent']}'."}),
HTTPStatus.BAD_REQUEST,
)
targets.append(
Target(wallet.id, g.wallet.id, entry["percent"], entry["alias"] or "")
)
percent_sum = sum([target.percent for target in targets])
if percent_sum > 100:
return jsonify({"message": "Splitting over 100%."}), HTTPStatus.BAD_REQUEST
await set_targets(g.wallet.id, targets)
return "", HTTPStatus.OK