Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,14 @@
],
"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"
"license": "MIT",
"nostr_signing": [
{
"kind": 9734,
"kind_label": "Zap Request",
"description": "Signs NIP-57 zap requests when splitting payments to LNURL targets that support zaps.",
"required": false,
"recommended_rate_limit": {"count": 100, "seconds": 86400}
}
]
}
46 changes: 45 additions & 1 deletion crud.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from lnbits.db import Database
from lnbits.helpers import decrypt_internal_message, encrypt_internal_message

from .models import Target
from .models import SplitPaymentSettings, Target

db = Database("ext_splitpayments")

Expand All @@ -21,3 +22,46 @@ async def set_targets(source_wallet: str, targets: list[Target]):
)
for target in targets:
await conn.insert("splitpayments.targets", target)


async def get_splitpayment_settings(
wallet_id: str,
) -> SplitPaymentSettings:
settings = await db.fetchone(
"SELECT * FROM splitpayments.settings WHERE wallet = :wallet",
{"wallet": wallet_id},
SplitPaymentSettings,
)
if settings:
decrypted_key = decrypt_internal_message(settings.nostr_private_key)
if decrypted_key:
# strip PKCS7 padding bytes left by AES decryption
settings.nostr_private_key = "".join(
c for c in decrypted_key if c >= " "
)
return settings
return SplitPaymentSettings(wallet=wallet_id, nostr_private_key="")


async def update_splitpayment_settings(
wallet_id: str,
settings: SplitPaymentSettings,
) -> SplitPaymentSettings:
encrypted_key = encrypt_internal_message(settings.nostr_private_key)
encrypted_settings = SplitPaymentSettings(
wallet=wallet_id,
nostr_private_key=encrypted_key or "",
)
await db.execute(
"DELETE FROM splitpayments.settings WHERE wallet = :wallet",
{"wallet": wallet_id},
)
await db.insert("splitpayments.settings", encrypted_settings)
return settings


async def delete_splitpayment_settings(wallet_id: str) -> None:
await db.execute(
"DELETE FROM splitpayments.settings WHERE wallet = :wallet",
{"wallet": wallet_id},
)
8 changes: 8 additions & 0 deletions helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from pynostr.key import PrivateKey


def parse_nostr_private_key(key: str) -> PrivateKey:
if key.startswith("nsec"):
return PrivateKey.from_nsec(key)
else:
return PrivateKey(bytes.fromhex(key))
63 changes: 62 additions & 1 deletion migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ async def m003_add_id_and_tag(db: Connection):
"wallet": row["wallet"],
"source": row["source"],
"percent": row["percent"],
"tag": row["tag"],
"tag": "",
"alias": row["alias"],
},
)
Expand Down Expand Up @@ -134,3 +134,64 @@ async def m004_remove_tag(db: Connection):
)
await db.execute(f"INSERT INTO {new_db} ({keys}) SELECT {keys} FROM {old_db}")
await db.execute(f"DROP TABLE {old_db}")


async def m005_add_settings(db: Connection):
"""
Add extension settings table for Nostr zap support.
"""
await db.execute(
"""
CREATE TABLE splitpayments.settings (
nostr_private_key TEXT NOT NULL
);
"""
)


async def m006_add_wallet_to_settings(db: Connection):
"""
Add wallet column to settings table for per-wallet Nostr key support.
"""
# Check if wallet column already exists (idempotent)
result = await db.execute(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'splitpayments'
AND table_name = 'settings'
AND column_name = 'wallet'
"""
)
rows = result.mappings().all()
if rows:
return # already migrated

await db.execute(
"ALTER TABLE splitpayments.settings ADD COLUMN wallet TEXT NOT NULL DEFAULT ''"
)
await db.execute(
"""
ALTER TABLE splitpayments.settings
DROP CONSTRAINT IF EXISTS settings_pkey
"""
)
# Recreate the table with wallet as primary key
await db.execute(
"ALTER TABLE splitpayments.settings RENAME TO settings_m005"
)
await db.execute(
"""
CREATE TABLE splitpayments.settings (
wallet TEXT PRIMARY KEY,
nostr_private_key TEXT NOT NULL
);
"""
)
await db.execute(
"""
INSERT INTO splitpayments.settings (wallet, nostr_private_key)
SELECT wallet, nostr_private_key FROM splitpayments.settings_m005
WHERE wallet != ''
"""
)
await db.execute("DROP TABLE splitpayments.settings_m005")
16 changes: 16 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from fastapi import Query
from pydantic import BaseModel
from pynostr.key import PrivateKey

from .helpers import parse_nostr_private_key


class Target(BaseModel):
Expand All @@ -20,3 +23,16 @@ class TargetPut(BaseModel):

class TargetPutList(BaseModel):
targets: list[TargetPut]


class SplitPaymentSettings(BaseModel):
wallet: str = ""
nostr_private_key: str = ""

@property
def private_key(self) -> PrivateKey:
return parse_nostr_private_key(self.nostr_private_key)

@property
def public_key(self) -> str:
return self.private_key.public_key.hex()
22 changes: 15 additions & 7 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ function hashTargets(targets) {
}

function isTargetComplete(target) {
return (
target.wallet &&
target.wallet.trim() !== '' &&
(target.percent > 0 || target.tag != '')
)
return target.wallet && target.wallet.trim() !== '' && target.percent > 0
}

window.app = Vue.createApp({
Expand All @@ -25,12 +21,22 @@ window.app = Vue.createApp({
return {
selectedWallet: null,
currentHash: '', // a string that must match if the edit data is unchanged
targets: []
targets: [],
settingsOptions: [
{
type: 'str',
description: 'Nostr private key used to sign zap requests (hex or nsec)',
name: 'nostr_private_key'
}
]
}
},
computed: {
isDirty() {
return hashTargets(this.targets) !== this.currentHash
},
settingsEndpoint() {
return '/splitpayments/api/v1/settings'
}
},
methods: {
Expand All @@ -53,6 +59,7 @@ window.app = Vue.createApp({
)
.then(response => {
this.targets = response.data
this.currentHash = hashTargets(this.targets)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
Expand All @@ -63,7 +70,7 @@ window.app = Vue.createApp({
this.getTargets()
},
addTarget() {
this.targets.push({source: this.selectedWallet})
this.targets.push({wallet: '', percent: 0, alias: ''})
},
saveTargets() {
LNbits.api
Expand All @@ -76,6 +83,7 @@ window.app = Vue.createApp({
}
)
.then(response => {
this.currentHash = hashTargets(this.targets)
Quasar.Notify.create({
message: 'Split payments targets set.',
timeout: 700
Expand Down
Loading