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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ test:
PYTHONUNBUFFERED=1 \
DEBUG=true \
uv run pytest

test-js:
node --test tests/test_*.js

install-pre-commit-hook:
@echo "Installing pre-commit hook to git"
@echo "Uninstall the hook with uv run pre-commit uninstall"
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ The TipJar extension allows you to integrate Bitcoin Lightning (and on-chain) ti

<h3>And that's it already! Let the sats flow!</h3>

## Onchain wallets

SatsPay is required for all tips. To accept onchain payments, select an LNbits
onchain wallet or an existing Watchonly wallet when creating or editing a jar.
LNbits wallets work without the Watchonly extension. Set up the wallet and, for a
hot wallet, confirm its backup before receiving. Its network must match SatsPay.

API clients use `onchain: "core:<wallet-id>"` for LNbits wallets; existing bare
Watchonly IDs remain supported. Leave `onchain` empty to disable onchain payments.
Tips at or below `onchain_limit` use Lightning only. SatsPay generates a fresh
address and tracks each onchain payment; these tips do not credit a Lightning balance.

## Powered by LNbits

[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
Expand Down
9 changes: 9 additions & 0 deletions helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
from lnbits.settings import settings


async def get_satspay_network() -> str:
async with httpx.AsyncClient() as client:
response = await client.get(
f"http://{settings.host}:{settings.port}/satspay/api/v1/settings/public"
)
response.raise_for_status()
return response.json()["network"]


async def check_satspay(api_key: str) -> bool:
async with httpx.AsyncClient() as client:
try:
Expand Down
160 changes: 131 additions & 29 deletions static/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ window.app = Vue.createApp({
tipjars: [],
tips: [],
walletLinks: [],
coreOnchainWallets: [],
loadingOnchainWallets: false,
hasSatsPay: true,
tipjarsTable: {
columns: [
Expand All @@ -40,7 +42,7 @@ window.app = Vue.createApp({
{
name: 'onchain address',
align: 'left',
label: 'Onchain Address',
label: 'Onchain Wallet',
field: 'onchain'
},
{
Expand Down Expand Up @@ -89,21 +91,114 @@ window.app = Vue.createApp({
}
}
},
computed: {
editingTipjar() {
return this.tipjars.find(jar => jar.id === this.tipjarDialog.data.id)
},
lightningWalletOptions() {
const walletIds = this.g.user.wallets
.filter(wallet => wallet.canReceivePayments)
.map(wallet => wallet.id)
return this.g.user.walletOptions.filter(wallet =>
walletIds.includes(wallet.value)
)
},
onchainWalletOptions() {
const core = this.coreOnchainWallets.map(wallet => ({
label: `LNbits: ${wallet.name}${wallet.status === 'ready' ? '' : ' (Not ready)'}`,
value: `core:${wallet.id}`
}))
const watchonly = this.walletLinks.map(wallet => ({
label: `Watchonly: ${wallet.title || wallet.id}`,
value: wallet.id
}))
return [...core, ...watchonly]
},
selectedCoreWallet() {
const reference = this.tipjarDialog.data.onchain
if (!reference?.startsWith('core:')) return null
return (
this.coreOnchainWallets.find(w => w.id === reference.slice(5)) || null
)
},
hasCoreOnchainWallet() {
return this.g.user.wallets.some(wallet => wallet.walletType === 'onchain')
},
onchainSelectionInvalid() {
if (!this.tipjarDialog.chain) return false
const reference = this.tipjarDialog.data.onchain
if (reference && reference === this.editingTipjar?.onchain) return false
return (
!this.onchainWalletOptions.some(option => option.value === reference) ||
(reference?.startsWith('core:') &&
this.selectedCoreWallet?.status !== 'ready')
)
}
},
methods: {
getWalletLinks() {
LNbits.api
.request(
async getWalletLinks() {
if (this.loadingOnchainWallets) return
this.loadingOnchainWallets = true
this.walletLinks = []
this.coreOnchainWallets = []
const inkey = this.g.user.wallets[0].inkey
try {
const {
data: {network}
} = await LNbits.api.request('GET', '/satspay/api/v1/settings/public')
const core = LNbits.api
.request('GET', '/satspay/api/v1/onchain-wallets', inkey)
.then(({data}) => {
this.coreOnchainWallets = data.filter(
wallet => wallet.network === network
)
})
.catch(LNbits.utils.notifyApiError)
if (this.g.user.extensions.includes('watchonly')) {
await Promise.all([core, this.getWatchonlyWallets(network, inkey)])
} else {
await core
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.loadingOnchainWallets = false
}
},
async getWatchonlyWallets(network, inkey) {
try {
const {data: config} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/wallet',
this.g.user.wallets[0].inkey
'/watchonly/api/v1/config',
inkey
)
.then(response => {
for (i = 0; i < response.data.length; i++) {
this.walletLinks.push(response.data[i].id)
}
if (config.network !== network) {
this.$q.notify({
type: 'warning',
message: `Watchonly must use the SatsPay network (${network}).`
})
return
})
.catch(LNbits.utils.notifyApiError)
}
const {data} = await LNbits.api.request(
'GET',
`/watchonly/api/v1/wallet?network=${encodeURIComponent(network)}`,
inkey
)
this.walletLinks = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
createOnchainWallet() {
this.g.newWalletType = 'onchain'
},
openOnchainWallet(walletId) {
this.$router.push({path: '/wallet', query: {wal: walletId}})
},
openTipjarDialog() {
this.tipjarDialog.data = {}
this.tipjarDialog.chain = false
this.tipjarDialog.show = true
},
getTips() {
LNbits.api
Expand Down Expand Up @@ -152,19 +247,30 @@ window.app = Vue.createApp({
})
},
sendTipJarData() {
if (this.onchainSelectionInvalid) return
const wallet = _.findWhere(this.g.user.wallets, {
id: this.tipjarDialog.data.wallet
id: this.editingTipjar?.wallet || this.tipjarDialog.data.wallet
})
const data = this.tipjarDialog.data

this.createTipJar(wallet, data)
},

createTipJar(wallet, data) {
LNbits.api
.request('POST', '/tipjar/api/v1/tipjars', wallet.adminkey, data)
const data = {
...this.tipjarDialog.data,
onchain: this.tipjarDialog.chain ? this.tipjarDialog.data.onchain : null
}
return LNbits.api
.request(
data.id ? 'PUT' : 'POST',
'/tipjar/api/v1/tipjars' + (data.id ? `/${data.id}` : ''),
wallet.adminkey,
data
)
.then(response => {
this.tipjars.push(mapTipJar(response.data))
const tipjar = mapTipJar(response.data)
if (data.id) {
this.tipjars = this.tipjars.map(jar =>
jar.id === data.id ? tipjar : jar
)
} else {
this.tipjars.push(tipjar)
}
this.tipjarDialog.show = false
this.tipjarDialog.data = {}
})
Expand All @@ -173,13 +279,10 @@ window.app = Vue.createApp({
updatetipjarDialog(tipjarId) {
const link = _.findWhere(this.tipjars, {id: tipjarId})

this.tipjarDialog.data.id = link.id
this.tipjarDialog.data.wallet = link.wallet
this.tipjarDialog.data.name = link.name
this.tipjarDialog.data.webhook = link.webhook
this.tipjarDialog.data.onchain_limit = link.onchain_limit
this.tipjarDialog.chain = link.onchain != null
this.tipjarDialog.data = {...link}
this.tipjarDialog.chain = Boolean(link.onchain)
this.tipjarDialog.show = true
if (this.tipjarDialog.chain) this.getWalletLinks()
},
deleteTipJar(tipjarsId) {
const tipjars = _.findWhere(this.tipjars, {id: tipjarsId})
Expand Down Expand Up @@ -220,7 +323,6 @@ window.app = Vue.createApp({
created() {
this.checkSatsPay()
if (this.g.user.wallets.length && this.hasSatsPay) {
this.getWalletLinks()
this.getTipJars()
this.getTips()
}
Expand Down
81 changes: 51 additions & 30 deletions templates/tipjar/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<q-btn
unelevated
color="primary"
@click="tipjarDialog.show = true"
@click="openTipjarDialog"
:disable="!hasSatsPay"
>New TipJar</q-btn
>
Expand Down Expand Up @@ -46,6 +46,14 @@ <h5 class="text-subtitle1 q-my-none">TipJars</h5>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updatetipjarDialog(props.row.id)"
icon="edit"
color="primary"
></q-btn>
<q-btn
unelevated
dense
Expand Down Expand Up @@ -146,34 +154,56 @@ <h6 class="text-subtitle1 q-my-none">
filled
dense
emit-value
map-options
v-model="tipjarDialog.data.wallet"
:options="g.user.walletOptions"
:options="lightningWalletOptions"
label="Wallet *"
:rules="[val => !!val || 'Select a wallet']"
>
</q-select>
<div class="row">
<div class="col">
<div v-if="walletLinks.length > 0">
<q-checkbox v-model="tipjarDialog.chain" label="Chain" />
</div>
<div v-else>
<q-checkbox :value="false" label="Chain" disabled>
<q-tooltip>
Watch-Only extension MUST be activated and have a wallet
</q-tooltip>
</q-checkbox>
</div>
</div>
</div>
<q-checkbox
v-model="tipjarDialog.chain"
label="Accept onchain payments"
:disable="!tipjarDialog.data.wallet"
@update:model-value="val => val && getWalletLinks()"
></q-checkbox>
<div v-if="tipjarDialog.chain">
<q-select
filled
dense
emit-value
map-options
v-model="tipjarDialog.data.onchain"
:options="walletLinks"
label="Chain Wallet"
:options="onchainWalletOptions"
label="Onchain Wallet"
hint="Receive through SatsPay using an LNbits or Watchonly wallet"
></q-select>
<q-btn
v-if="!hasCoreOnchainWallet"
flat
dense
color="primary"
label="Create onchain wallet"
@click="createOnchainWallet"
></q-btn>
<q-banner
v-if="selectedCoreWallet && selectedCoreWallet.status !== 'ready'"
dense
class="bg-orange-2 text-orange-9 q-mt-sm"
>
<div
v-text="selectedCoreWallet.status === 'setup'
? 'Set up this onchain wallet before receiving.'
: 'Back up this onchain wallet before receiving.'"
></div>
<q-btn
flat
dense
color="primary"
:label="selectedCoreWallet.status === 'setup' ? 'Set up wallet' : 'Back up wallet'"
@click="openOnchainWallet(selectedCoreWallet.id)"
></q-btn>
</q-banner>
<q-input
filled
dense
Expand All @@ -200,21 +230,12 @@ <h6 class="text-subtitle1 q-my-none">
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="tipjarDialog.data.id"
unelevated
color="primary"
type="submit"
>Update TipJar</q-btn
>

<q-btn
v-else
unelevated
color="primary"
:disable="tipjarDialog.data.name == null"
type="submit"
>Create TipJar</q-btn
>
:disable="!tipjarDialog.data.name || !tipjarDialog.data.wallet || onchainSelectionInvalid"
:label="tipjarDialog.data.id ? 'Update TipJar' : 'Create TipJar'"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
Expand Down
Loading
Loading