Browse Source

Merge pull request #113 from lnbits/lnurl

atmext
fiatjaf 4 years ago
committed by GitHub
parent
commit
3a56aaa3ad
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 18
      lnbits/core/services.py
  2. 357
      lnbits/core/static/js/wallet.js
  3. 199
      lnbits/core/templates/core/wallet.html
  4. 151
      lnbits/core/views/api.py
  5. 4
      lnbits/extensions/lnurlp/templates/lnurlp/display.html
  6. 71
      lnbits/static/js/base.js
  7. 60
      lnbits/static/js/components.js
  8. 2
      lnbits/wallets/base.py
  9. 4
      lnbits/wallets/lnpay.py
  10. 2
      lnbits/wallets/opennode.py
  11. 2
      lnbits/wallets/spark.py

18
lnbits/core/services.py

@ -1,7 +1,7 @@
import httpx import httpx
from typing import Optional, Tuple, Dict from typing import Optional, Tuple, Dict
from quart import g from quart import g
from lnurl import LnurlWithdrawResponse from lnurl import LnurlWithdrawResponse # type: ignore
try: try:
from typing import TypedDict # type: ignore from typing import TypedDict # type: ignore
@ -51,7 +51,12 @@ def create_invoice(
def pay_invoice( def pay_invoice(
*, wallet_id: str, payment_request: str, max_sat: Optional[int] = None, extra: Optional[Dict] = None *,
wallet_id: str,
payment_request: str,
max_sat: Optional[int] = None,
extra: Optional[Dict] = None,
description: str = "",
) -> str: ) -> str:
temp_id = f"temp_{urlsafe_short_hash()}" temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}" internal_id = f"internal_{urlsafe_short_hash()}"
@ -79,7 +84,7 @@ def pay_invoice(
payment_request=payment_request, payment_request=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat, amount=-invoice.amount_msat,
memo=invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
) )
@ -111,7 +116,7 @@ def pay_invoice(
else: else:
# actually pay the external invoice # actually pay the external invoice
payment: PaymentResponse = WALLET.pay_invoice(payment_request) payment: PaymentResponse = WALLET.pay_invoice(payment_request)
if payment.ok: if payment.ok and payment.checking_id:
create_payment( create_payment(
checking_id=payment.checking_id, checking_id=payment.checking_id,
fee=payment.fee_msat, fee=payment.fee_msat,
@ -127,13 +132,10 @@ def pay_invoice(
async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None: async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None:
if not memo:
memo = res.default_description
_, payment_request = create_invoice( _, payment_request = create_invoice(
wallet_id=wallet_id, wallet_id=wallet_id,
amount=res.max_sats, amount=res.max_sats,
memo=memo, memo=memo or res.default_description or "",
extra={"tag": "lnurlwallet"}, extra={"tag": "lnurlwallet"},
) )

357
lnbits/core/static/js/wallet.js

@ -1,4 +1,4 @@
/* globals moment, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */ /* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */
Vue.component(VueQrcode.name, VueQrcode) Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader) Vue.use(VueQrcodeReader)
@ -14,12 +14,8 @@ function generateChart(canvas, payments) {
} }
_.each( _.each(
payments payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
.filter(p => !p.pending) tx => {
.sort(function (a, b) {
return a.time - b.time
}),
function (tx) {
txs.push({ txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'), hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat sat: tx.sat
@ -27,19 +23,15 @@ function generateChart(canvas, payments) {
} }
) )
_.each(_.groupBy(txs, 'hour'), function (value, day) { _.each(_.groupBy(txs, 'hour'), (value, day) => {
var income = _.reduce( var income = _.reduce(
value, value,
function (memo, tx) { (memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
return tx.sat >= 0 ? memo + tx.sat : memo
},
0 0
) )
var outcome = _.reduce( var outcome = _.reduce(
value, value,
function (memo, tx) { (memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
return tx.sat < 0 ? memo + Math.abs(tx.sat) : memo
},
0 0
) )
n = n + income - outcome n = n + income - outcome
@ -124,21 +116,27 @@ new Vue({
show: false, show: false,
status: 'pending', status: 'pending',
paymentReq: null, paymentReq: null,
minMax: [0, 2100000000000000],
lnurl: null,
data: { data: {
amount: null, amount: null,
memo: '' memo: ''
} }
}, },
send: { parse: {
show: false, show: false,
invoice: null, invoice: null,
lnurlpay: null,
data: { data: {
bolt11: '' request: '',
} amount: 0,
comment: ''
}, },
sendCamera: { paymentChecker: null,
camera: {
show: false, show: false,
camera: 'auto' camera: 'auto'
}
}, },
payments: [], payments: [],
paymentsTable: { paymentsTable: {
@ -196,8 +194,8 @@ new Vue({
return LNbits.utils.search(this.payments, q) return LNbits.utils.search(this.payments, q)
}, },
canPay: function () { canPay: function () {
if (!this.send.invoice) return false if (!this.parse.invoice) return false
return this.send.invoice.sat <= this.balance return this.parse.invoice.sat <= this.balance
}, },
pendingPaymentsExist: function () { pendingPaymentsExist: function () {
return this.payments return this.payments
@ -205,105 +203,184 @@ new Vue({
: false : false
} }
}, },
filters: {
msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000)
}
},
methods: { methods: {
closeCamera: function () { closeCamera: function () {
this.sendCamera.show = false this.parse.camera.show = false
}, },
showCamera: function () { showCamera: function () {
this.sendCamera.show = true this.parse.camera.show = true
}, },
showChart: function () { showChart: function () {
this.paymentsChart.show = true this.paymentsChart.show = true
this.$nextTick(function () { this.$nextTick(() => {
generateChart(this.$refs.canvas, this.payments) generateChart(this.$refs.canvas, this.payments)
}) })
}, },
showReceiveDialog: function () { showReceiveDialog: function () {
this.receive = { this.receive.show = true
show: true, this.receive.status = 'pending'
status: 'pending', this.receive.paymentReq = null
paymentReq: null, this.receive.data.amount = null
data: { this.receive.data.memo = null
amount: null, this.receive.paymentChecker = null
memo: '' this.receive.minMax = [0, 2100000000000000]
}, this.receive.lnurl = null
paymentChecker: null },
} showParseDialog: function () {
}, this.parse.show = true
showSendDialog: function () { this.parse.invoice = null
this.send = { this.parse.lnurlpay = null
show: true, this.parse.data.request = ''
invoice: null, this.parse.data.comment = ''
data: { this.parse.data.paymentChecker = null
bolt11: '' this.parse.camera.show = false
},
paymentChecker: null
}
}, },
closeReceiveDialog: function () { closeReceiveDialog: function () {
var checker = this.receive.paymentChecker var checker = this.receive.paymentChecker
setTimeout(function () { setTimeout(() => {
clearInterval(checker) clearInterval(checker)
}, 10000) }, 10000)
}, },
closeSendDialog: function () { closeParseDialog: function () {
this.sendCamera.show = false var checker = this.parse.paymentChecker
var checker = this.send.paymentChecker setTimeout(() => {
setTimeout(function () {
clearInterval(checker) clearInterval(checker)
}, 1000) }, 10000)
}, },
createInvoice: function () { createInvoice: function () {
var self = this
this.receive.status = 'loading' this.receive.status = 'loading'
LNbits.api LNbits.api
.createInvoice( .createInvoice(
this.g.wallet, this.g.wallet,
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo this.receive.data.memo,
this.receive.lnurl && this.receive.lnurl.callback
) )
.then(function (response) { .then(response => {
self.receive.status = 'success' this.receive.status = 'success'
self.receive.paymentReq = response.data.payment_request this.receive.paymentReq = response.data.payment_request
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
}
if (typeof response.data.lnurl_response === 'string') {
// failure
this.$q.notify({
timeout: 5000,
type: 'negative',
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
caption: response.data.lnurl_response
})
return
} else if (response.data.lnurl_response === true) {
// success
this.$q.notify({
timeout: 5000,
type: 'positive',
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
spinner: true
})
}
}
self.receive.paymentChecker = setInterval(function () { this.receive.paymentChecker = setInterval(() => {
LNbits.api LNbits.api
.getPayment(self.g.wallet, response.data.payment_hash) .getPayment(this.g.wallet, response.data.payment_hash)
.then(function (response) { .then(response => {
if (response.data.paid) { if (response.data.paid) {
self.fetchPayments() this.fetchPayments()
self.receive.show = false this.fetchBalance()
clearInterval(self.receive.paymentChecker) this.receive.show = false
clearInterval(this.receive.paymentChecker)
} }
}) })
}, 2000) }, 2000)
}) })
.catch(function (error) { .catch(err => {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(err)
self.receive.status = 'pending' this.receive.status = 'pending'
}) })
}, },
decodeQR: function (res) { decodeQR: function (res) {
this.send.data.bolt11 = res this.parse.data.request = res
this.decodeInvoice() this.decodeRequest()
this.sendCamera.show = false this.parse.camera.show = false
}, },
decodeInvoice: function () { decodeRequest: function () {
if (this.send.data.bolt11.startsWith('lightning:')) { this.parse.show = true
this.send.data.bolt11 = this.send.data.bolt11.slice(10)
if (this.parse.data.request.startsWith('lightning:')) {
this.parse.data.request = this.parse.data.request.slice(10)
}
if (this.parse.data.request.startsWith('lnurl:')) {
this.parse.data.request = this.parse.data.request.slice(6)
}
if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) {
LNbits.api
.request(
'GET',
'/api/v1/lnurlscan/' + this.parse.data.request,
this.g.user.wallets[0].adminkey
)
.catch(err => {
LNbits.utils.notifyApiError(err)
})
.then(response => {
let data = response.data
if (data.status === 'ERROR') {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: `${data.domain} lnurl call failed.`,
caption: data.reason
})
return
}
if (data.kind === 'pay') {
this.parse.lnurlpay = Object.freeze(data)
this.parse.data.amount = data.minSendable / 1000
} else if (data.kind === 'withdraw') {
this.parse.show = false
this.receive.show = true
this.receive.status = 'pending'
this.receive.paymentReq = null
this.receive.data.amount = data.maxWithdrawable / 1000
this.receive.data.memo = data.defaultDescription
this.receive.minMax = [
data.minWithdrawable / 1000,
data.maxWithdrawable / 1000
]
this.receive.lnurl = {
domain: data.domain,
callback: data.callback,
fixed: data.fixed
}
}
})
return
} }
let invoice let invoice
try { try {
invoice = decode(this.send.data.bolt11) invoice = decode(this.parse.data.request)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
timeout: 3000, timeout: 3000,
type: 'warning', type: 'warning',
message: error + '.', message: error + '.',
caption: '400 BAD REQUEST', caption: '400 BAD REQUEST'
icon: null
}) })
this.parse.show = false
return return
} }
@ -313,7 +390,7 @@ new Vue({
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000) fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
} }
_.each(invoice.data.tags, function (tag) { _.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) { if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') { if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value cleanInvoice.hash = tag.value
@ -332,78 +409,154 @@ new Vue({
} }
}) })
this.send.invoice = Object.freeze(cleanInvoice) this.parse.invoice = Object.freeze(cleanInvoice)
}, },
payInvoice: function () { payInvoice: function () {
var self = this let dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...'
})
LNbits.api
.payInvoice(this.g.wallet, this.parse.data.request)
.then(response => {
this.parse.paymentChecker = setInterval(() => {
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(res => {
if (res.data.paid) {
this.parse.show = false
clearInterval(this.parse.paymentChecker)
dismissPaymentMsg()
this.fetchPayments()
this.fetchBalance()
}
})
}, 2000)
})
.catch(err => {
dismissPaymentMsg()
LNbits.utils.notifyApiError(err)
})
},
payLnurl: function () {
let dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Processing payment...', message: 'Processing payment...'
icon: null
}) })
LNbits.api LNbits.api
.payInvoice(this.g.wallet, this.send.data.bolt11) .payLnurl(
.then(function (response) { this.g.wallet,
self.send.paymentChecker = setInterval(function () { this.parse.lnurlpay.callback,
this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment
)
.then(response => {
this.parse.show = false
this.parse.paymentChecker = setInterval(() => {
LNbits.api LNbits.api
.getPayment(self.g.wallet, response.data.payment_hash) .getPayment(this.g.wallet, response.data.payment_hash)
.then(function (res) { .then(res => {
if (res.data.paid) { if (res.data.paid) {
self.send.show = false
clearInterval(self.send.paymentChecker)
dismissPaymentMsg() dismissPaymentMsg()
self.fetchPayments() clearInterval(this.parse.paymentChecker)
this.fetchPayments()
this.fetchBalance()
// show lnurlpay success action
if (response.data.success_action) {
switch (response.data.success_action.tag) {
case 'url':
this.$q.notify({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
caption: response.data.success_action.description,
html: true,
type: 'info',
timeout: 0,
closeBtn: true
})
break
case 'message':
this.$q.notify({
message: response.data.success_action.message,
type: 'info',
timeout: 0,
closeBtn: true
})
break
case 'aes':
LNbits.api
.getPayment(this.g.wallet, response.data.payment_hash)
.then(
({data: payment}) =>
console.log(payment) ||
decryptLnurlPayAES(
response.data.success_action,
payment.preimage
)
)
.then(value => {
this.$q.notify({
message: value,
caption: response.data.success_action.description,
html: true,
type: 'info',
timeout: 0,
closeBtn: true
})
})
break
}
}
} }
}) })
}, 2000) }, 2000)
}) })
.catch(function (error) { .catch(err => {
dismissPaymentMsg() dismissPaymentMsg()
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(err)
}) })
}, },
deleteWallet: function (walletId, user) { deleteWallet: function (walletId, user) {
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?') .confirmDialog('Are you sure you want to delete this wallet?')
.onOk(function () { .onOk(() => {
LNbits.href.deleteWallet(walletId, user) LNbits.href.deleteWallet(walletId, user)
}) })
}, },
fetchPayments: function (checkPending) { fetchPayments: function (checkPending) {
var self = this
return LNbits.api return LNbits.api
.getPayments(this.g.wallet, checkPending) .getPayments(this.g.wallet, checkPending)
.then(function (response) { .then(response => {
self.payments = response.data this.payments = response.data
.map(function (obj) { .map(obj => {
return LNbits.map.payment(obj) return LNbits.map.payment(obj)
}) })
.sort(function (a, b) { .sort((a, b) => {
return b.time - a.time return b.time - a.time
}) })
}) })
}, },
fetchBalance: function () { fetchBalance: function () {
var self = this LNbits.api.getWallet(this.g.wallet).then(response => {
LNbits.api.getWallet(self.g.wallet).then(function (response) { this.balance = Math.round(response.data.balance / 1000)
self.balance = Math.round(response.data.balance / 1000)
EventHub.$emit('update-wallet-balance', [ EventHub.$emit('update-wallet-balance', [
self.g.wallet.id, this.g.wallet.id,
self.balance this.balance
]) ])
}) })
}, },
checkPendingPayments: function () { checkPendingPayments: function () {
var dismissMsg = this.$q.notify({ var dismissMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Checking pending transactions...', message: 'Checking pending transactions...'
icon: null
}) })
this.fetchPayments(true).then(function () { this.fetchPayments(true).then(() => {
dismissMsg() dismissMsg()
}) })
}, },

199
lnbits/core/templates/core/wallet.html

@ -14,10 +14,10 @@
<div class="col"> <div class="col">
<q-btn <q-btn
unelevated unelevated
color="purple" color="deep-purple"
class="full-width" class="full-width"
@click="showSendDialog" @click="showParseDialog"
>Send</q-btn >Paste Request</q-btn
> >
</div> </div>
<div class="col"> <div class="col">
@ -26,9 +26,19 @@
color="deep-purple" color="deep-purple"
class="full-width" class="full-width"
@click="showReceiveDialog" @click="showReceiveDialog"
>Receive</q-btn >Create Invoice</q-btn
> >
</div> </div>
<div class="col">
<q-btn
unelevated
color="purple"
icon="photo_camera"
@click="showCamera"
>scan
<q-tooltip>Use camera to scan an invoice/QR</q-tooltip>
</q-btn>
</div>
</div> </div>
</q-card> </q-card>
@ -120,7 +130,7 @@
<q-td auto-width key="sat" :props="props"> <q-td auto-width key="sat" :props="props">
{{ props.row.fsat }} {{ props.row.fsat }}
</q-td> </q-td>
<q-td auto-width key="sat" :props="props"> <q-td auto-width key="fee" :props="props">
{{ props.row.fee }} {{ props.row.fee }}
</q-td> </q-td>
</q-tr> </q-tr>
@ -131,7 +141,9 @@
<div v-if="props.row.isIn && props.row.pending"> <div v-if="props.row.isIn && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon> <q-icon name="settings_ethernet" color="grey"></q-icon>
Invoice waiting to be paid Invoice waiting to be paid
<lnbits-payment-details :payment="props.row"></lnbits-payment-details> <lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg"> <div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11"> <a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
@ -162,7 +174,9 @@
:color="'green'" :color="'green'"
></q-icon> ></q-icon>
Payment Received Payment Received
<lnbits-payment-details :payment="props.row"></lnbits-payment-details> <lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div> </div>
<div v-else-if="props.row.isPaid && props.row.isOut"> <div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon <q-icon
@ -171,12 +185,16 @@
:color="'pink'" :color="'pink'"
></q-icon> ></q-icon>
Payment Sent Payment Sent
<lnbits-payment-details :payment="props.row"></lnbits-payment-details> <lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div> </div>
<div v-else-if="props.row.isOut && props.row.pending"> <div v-else-if="props.row.isOut && props.row.pending">
<q-icon name="settings_ethernet" color="grey"></q-icon> <q-icon name="settings_ethernet" color="grey"></q-icon>
Outgoing payment pending Outgoing payment pending
<lnbits-payment-details :payment="props.row"></lnbits-payment-details> <lnbits-payment-details
:payment="props.row"
></lnbits-payment-details>
</div> </div>
</div> </div>
</q-card> </q-card>
@ -187,6 +205,7 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
</div>
<div class="col-12 col-md-5 q-gutter-y-md"> <div class="col-12 col-md-5 q-gutter-y-md">
<q-card> <q-card>
@ -229,20 +248,27 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog"> <q-dialog v-model="receive.show" @hide="closeReceiveDialog">
{% raw %}
<q-card <q-card
v-if="!receive.paymentReq" v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<q-form @submit="createInvoice" class="q-gutter-md"> <q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
</p>
<q-input <q-input
filled filled
dense dense
v-model.number="receive.data.amount" v-model.number="receive.data.amount"
type="number" type="number"
label="Amount (sat) *" label="Amount (sat) *"
:min="receive.minMax[0]"
:max="receive.minMax[1]"
:readonly="receive.lnurl && receive.lnurl.fixed"
></q-input> ></q-input>
<q-input <q-input
filled filled
@ -257,8 +283,12 @@
color="deep-purple" color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0" :disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit" type="submit"
>Create invoice</q-btn
> >
<span v-if="receive.lnurl">
Withdraw from {{receive.lnurl.domain}}
</span>
<span v-else> Create invoice </span>
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
<q-spinner <q-spinner
@ -287,36 +317,117 @@
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
{% endraw %}
</q-dialog> </q-dialog>
<q-dialog v-model="send.show" position="top" @hide="closeSendDialog"> <q-dialog v-model="parse.show" @hide="closeParseDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!send.invoice"> <div v-if="parse.invoice">
{% raw %}
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Description:</strong> {{ parse.invoice.description }}<br />
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ parse.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice">Pay</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black"
>Not enough funds!</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
<div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
parse.lnurlpay.maxSendable | msatoshiFormat }} sat
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.domain }}</b> is requesting <br />
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> sat
<span v-if="parse.lnurlpay.commentAllowed > 0">
<br />
and a {{parse.lnurlpay.commentAllowed}}-char comment
</span>
</p>
<q-separator class="q-my-sm"></q-separator>
<div class="row">
<p class="col text-justify text-italic">
{{ parse.lnurlpay.description }}
</p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" />
</p>
</div>
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.number="parse.data.amount"
type="number"
label="Amount (sat) *"
:min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed"
></q-input>
</div>
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
<q-input
filled
dense
v-model.number="parse.data.comment"
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
label="Comment (optional)"
:maxlength="parse.lnurlpay.commentAllowed"
></q-input>
</div>
</div>
<div class="row q-mt-lg">
<q-btn unelevated color="deep-purple" type="submit"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
{% endraw %}
</div>
<div v-else>
<q-form <q-form
v-if="!sendCamera.show" v-if="!parse.camera.show"
@submit="decodeInvoice" @submit="decodeRequest"
class="q-gutter-md" class="q-gutter-md"
> >
<q-input <q-input
filled filled
dense dense
v-model.trim="send.data.bolt11" v-model.trim="parse.data.request"
type="textarea" type="textarea"
label="Paste an invoice *" label="Paste an invoice, payment request or lnurl code *"
> >
<template v-slot:after>
<q-btn round dense flat icon="photo_camera" @click="showCamera">
<q-tooltip>Use camera to scan an invoice</q-tooltip>
</q-btn>
</template>
</q-input> </q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
color="deep-purple" color="deep-purple"
:disable="send.data.bolt11 == ''" :disable="parse.data.request == ''"
type="submit" type="submit"
>Read invoice</q-btn >Read</q-btn
> >
<q-btn v-close-popup flat color="grey" class="q-ml-auto" <q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn >Cancel</q-btn
@ -331,39 +442,29 @@
></qrcode-stream> ></qrcode-stream>
</q-responsive> </q-responsive>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto" <q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
>Cancel</q-btn Cancel
> </q-btn>
</div> </div>
</div> </div>
</div> </div>
<div v-else> </q-card>
{% raw %} </q-dialog>
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator> <q-dialog v-model="parse.camera.show">
<p style="word-break: break-all"> <q-card class="q-pa-lg q-pt-xl">
<strong>Memo:</strong> {{ send.invoice.description }}<br /> <div class="text-center q-mb-lg">
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br /> <qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
<strong>Hash:</strong> {{ send.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated color="deep-purple" @click="payInvoice"
>Send satoshis</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
<div v-else class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black" <q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Not enough funds!</q-btn >Cancel</q-btn
> >
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="paymentsChart.show" position="top"> <q-dialog v-model="paymentsChart.show">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset"> <q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section> <q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas> <canvas ref="canvas" width="600" height="400"></canvas>

151
lnbits/core/views/api.py

@ -1,9 +1,13 @@
import trio # type: ignore import trio # type: ignore
import json import json
import lnurl # type: ignore
import httpx
import traceback import traceback
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
from quart import g, jsonify, request, make_response from quart import g, jsonify, request, make_response
from http import HTTPStatus from http import HTTPStatus
from binascii import unhexlify from binascii import unhexlify
from typing import Dict, Union
from lnbits import bolt11 from lnbits import bolt11
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
@ -47,6 +51,7 @@ async def api_payments():
"amount": {"type": "integer", "min": 1, "required": True}, "amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"}, "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"}, "description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
} }
) )
async def api_payments_create_invoice(): async def api_payments_create_invoice():
@ -66,6 +71,22 @@ async def api_payments_create_invoice():
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
lnurl_response: Union[None, bool, str] = None
if g.data.get("lnurl_callback"):
try:
r = httpx.get(g.data["lnurl_callback"], params={"pr": payment_request}, timeout=10)
if r.is_error:
lnurl_response = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
lnurl_response = resp["reason"]
else:
lnurl_response = True
except (httpx.ConnectError, httpx.RequestError):
lnurl_response = False
return ( return (
jsonify( jsonify(
{ {
@ -73,6 +94,7 @@ async def api_payments_create_invoice():
"payment_request": payment_request, "payment_request": payment_request,
# maintain backwards compatibility with API clients: # maintain backwards compatibility with API clients:
"checking_id": invoice.payment_hash, "checking_id": invoice.payment_hash,
"lnurl_response": lnurl_response,
} }
), ),
HTTPStatus.CREATED, HTTPStatus.CREATED,
@ -113,6 +135,79 @@ async def api_payments_create():
return await api_payments_create_invoice() return await api_payments_create_invoice()
@core_app.route("/api/v1/payments/lnurl", methods=["POST"])
@api_check_wallet_key("admin")
@api_validate_post_request(
schema={
"description_hash": {"type": "string", "empty": False, "required": True},
"callback": {"type": "string", "empty": False, "required": True},
"amount": {"type": "number", "empty": False, "required": True},
"comment": {"type": "string", "nullable": True, "empty": True, "required": False},
"description": {"type": "string", "nullable": True, "empty": True, "required": False},
}
)
async def api_payments_pay_lnurl():
try:
r = httpx.get(
g.data["callback"],
params={"amount": g.data["amount"], "comment": g.data["comment"]},
timeout=40,
)
if r.is_error:
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
except (httpx.ConnectError, httpx.RequestError):
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
params = json.loads(r.text)
if params.get("status") == "ERROR":
domain = urlparse(g.data["callback"]).netloc
return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST
invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != g.data["amount"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}."
}
),
HTTPStatus.BAD_REQUEST,
)
if invoice.description_hash != g.data["description_hash"]:
return (
jsonify(
{
"message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}."
}
),
HTTPStatus.BAD_REQUEST,
)
try:
payment_hash = pay_invoice(
wallet_id=g.wallet.id,
payment_request=params["pr"],
description=g.data.get("description", ""),
extra={"success_action": params.get("successAction")},
)
except Exception as exc:
traceback.print_exc(7)
g.db.rollback()
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
return (
jsonify(
{
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
)
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"]) @core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
async def api_payment(payment_hash): async def api_payment(payment_hash):
@ -121,14 +216,14 @@ async def api_payment(payment_hash):
if not payment: if not payment:
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
elif not payment.pending: elif not payment.pending:
return jsonify({"paid": True}), HTTPStatus.OK return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK
try: try:
payment.check_pending() payment.check_pending()
except Exception: except Exception:
return jsonify({"paid": False}), HTTPStatus.OK return jsonify({"paid": False}), HTTPStatus.OK
return jsonify({"paid": not payment.pending}), HTTPStatus.OK return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK
@core_app.route("/api/v1/payments/sse", methods=["GET"]) @core_app.route("/api/v1/payments/sse", methods=["GET"])
@ -183,3 +278,55 @@ async def api_payments_sse():
) )
response.timeout = None response.timeout = None
return response return response
@core_app.route("/api/v1/lnurlscan/<code>", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_lnurlscan(code: str):
try:
url = lnurl.Lnurl(code)
except ValueError:
return jsonify({"error": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
domain = urlparse(url.url).netloc
if url.is_login:
return jsonify({"domain": domain, "kind": "auth", "error": "unsupported"})
r = httpx.get(url.url)
if r.is_error:
return jsonify({"domain": domain, "error": "failed to get parameters"})
try:
jdata = json.loads(r.text)
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
return jsonify({"domain": domain, "error": f"got invalid response '{r.text[:200]}'"})
if type(data) is lnurl.LnurlChannelResponse:
return jsonify({"domain": domain, "kind": "channel", "error": "unsupported"})
params: Dict = data.dict()
if type(data) is lnurl.LnurlWithdrawResponse:
params.update(kind="withdraw")
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data.callback)
qs: Dict = parse_qs(parsed_callback.query)
qs["k1"] = data.k1
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
params.update(callback=urlunparse(parsed_callback))
if type(data) is lnurl.LnurlPayResponse:
params.update(kind="pay")
params.update(fixed=data.min_sendable == data.max_sendable)
params.update(description_hash=data.metadata.h)
params.update(description=data.metadata.text)
if data.metadata.images:
image = min(data.metadata.images, key=lambda image: len(image[1]))
data_uri = "data:" + image[0] + "," + image[1]
params.update(image=data_uri)
params.update(commentAllowed=jdata.get("commentAllowed", 0))
params.update(domain=domain)
return jsonify(params)

4
lnbits/extensions/lnurlp/templates/lnurlp/display.html

@ -26,9 +26,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6> <h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
<p class="q-my-none"> <p class="q-my-none">Use an LNURL compatible bitcoin wallet to pay.</p>
Use an LNURL compatible bitcoin wallet to pay.
</p>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>

71
lnbits/static/js/base.js

@ -1,10 +1,8 @@
/* globals moment, Vue, EventHub, axios, Quasar, _ */ /* globals crypto, moment, Vue, axios, Quasar, _ */
var LOCALE = 'en' window.LOCALE = 'en'
window.EventHub = new Vue()
var EventHub = new Vue() window.LNbits = {
var LNbits = {
api: { api: {
request: function (method, url, apiKey, data) { request: function (method, url, apiKey, data) {
return axios({ return axios({
@ -16,11 +14,12 @@ var LNbits = {
data: data data: data
}) })
}, },
createInvoice: function (wallet, amount, memo) { createInvoice: function (wallet, amount, memo, lnurlCallback = null) {
return this.request('post', '/api/v1/payments', wallet.inkey, { return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false, out: false,
amount: amount, amount: amount,
memo: memo memo: memo,
lnurl_callback: lnurlCallback
}) })
}, },
payInvoice: function (wallet, bolt11) { payInvoice: function (wallet, bolt11) {
@ -29,6 +28,22 @@ var LNbits = {
bolt11: bolt11 bolt11: bolt11
}) })
}, },
payLnurl: function (
wallet,
callback,
description_hash,
amount,
description = '',
comment = ''
) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback,
description_hash,
amount,
comment,
description
})
},
getWallet: function (wallet) { getWallet: function (wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey) return this.request('get', '/api/v1/wallet', wallet.inkey)
}, },
@ -91,7 +106,7 @@ var LNbits = {
) )
obj.msat = obj.balance obj.msat = obj.balance
obj.sat = Math.round(obj.balance / 1000) obj.sat = Math.round(obj.balance / 1000)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat) obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('') obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('')
return obj return obj
}, },
@ -119,7 +134,7 @@ var LNbits = {
obj.msat = obj.amount obj.msat = obj.amount
obj.sat = obj.msat / 1000 obj.sat = obj.msat / 1000
obj.tag = obj.extra.tag obj.tag = obj.extra.tag
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.sat) obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0 obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0 obj.isOut = obj.amount < 0
obj.isPaid = obj.pending === 0 obj.isPaid = obj.pending === 0
@ -142,13 +157,13 @@ var LNbits = {
}) })
}, },
formatCurrency: function (value, currency) { formatCurrency: function (value, currency) {
return new Intl.NumberFormat(LOCALE, { return new Intl.NumberFormat(window.LOCALE, {
style: 'currency', style: 'currency',
currency: currency currency: currency
}).format(value) }).format(value)
}, },
formatSat: function (value) { formatSat: function (value) {
return new Intl.NumberFormat(LOCALE).format(value) return new Intl.NumberFormat(window.LOCALE).format(value)
}, },
notifyApiError: function (error) { notifyApiError: function (error) {
var types = { var types = {
@ -231,7 +246,7 @@ var LNbits = {
} }
} }
var windowMixin = { window.windowMixin = {
data: function () { data: function () {
return { return {
g: { g: {
@ -261,17 +276,17 @@ var windowMixin = {
created: function () { created: function () {
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode')) this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
if (window.user) { if (window.user) {
this.g.user = Object.freeze(LNbits.map.user(window.user)) this.g.user = Object.freeze(window.LNbits.map.user(window.user))
} }
if (window.wallet) { if (window.wallet) {
this.g.wallet = Object.freeze(LNbits.map.wallet(window.wallet)) this.g.wallet = Object.freeze(window.LNbits.map.wallet(window.wallet))
} }
if (window.extensions) { if (window.extensions) {
var user = this.g.user var user = this.g.user
this.g.extensions = Object.freeze( this.g.extensions = Object.freeze(
window.extensions window.extensions
.map(function (data) { .map(function (data) {
return LNbits.map.extension(data) return window.LNbits.map.extension(data)
}) })
.map(function (obj) { .map(function (obj) {
if (user) { if (user) {
@ -288,3 +303,27 @@ var windowMixin = {
} }
} }
} }
window.decryptLnurlPayAES = function (success_action, preimage) {
let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
)
return crypto.subtle
.importKey('raw', keyb, {name: 'AES-CBC', length: 256}, false, ['decrypt'])
.then(key => {
let ivb = Uint8Array.from(window.atob(success_action.iv), c =>
c.charCodeAt(0)
)
let ciphertextb = Uint8Array.from(
window.atob(success_action.ciphertext),
c => c.charCodeAt(0)
)
return crypto.subtle.decrypt({name: 'AES-CBC', iv: ivb}, key, ciphertextb)
})
.then(valueb => {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
}

60
lnbits/static/js/components.js

@ -1,4 +1,4 @@
/* global Vue, moment, LNbits, EventHub */ /* global Vue, moment, LNbits, EventHub, decryptLnurlPayAES */
Vue.component('lnbits-fsat', { Vue.component('lnbits-fsat', {
props: { props: {
@ -199,10 +199,64 @@ Vue.component('lnbits-payment-details', {
<div class="col-3"><b>Payment hash</b>:</div> <div class="col-3"><b>Payment hash</b>:</div>
<div class="col-9 text-wrap mono">{{ payment.payment_hash }}</div> <div class="col-9 text-wrap mono">{{ payment.payment_hash }}</div>
</div> </div>
<div class="row" v-if="payment.preimage"> <div class="row" v-if="hasPreimage">
<div class="col-3"><b>Payment proof</b>:</div> <div class="col-3"><b>Payment proof</b>:</div>
<div class="col-9 text-wrap mono">{{ payment.preimage }}</div> <div class="col-9 text-wrap mono">{{ payment.preimage }}</div>
</div> </div>
<div class="row" v-if="hasSuccessAction">
<div class="col-3"><b>Success action</b>:</div>
<div class="col-9">
<lnbits-lnurlpay-success-action
:payment="payment"
:success_action="payment.extra.success_action"
></lnbits-lnurlpay-success-action>
</div> </div>
` </div>
</div>
`,
computed: {
hasPreimage() {
return (
this.payment.preimage &&
this.payment.preimage !==
'0000000000000000000000000000000000000000000000000000000000000000'
)
},
hasSuccessAction() {
return (
this.hasPreimage &&
this.payment.extra &&
this.payment.extra.success_action
)
}
}
})
Vue.component('lnbits-lnurlpay-success-action', {
props: ['payment', 'success_action'],
data() {
return {
decryptedValue: this.success_action.ciphertext
}
},
template: `
<div>
<p class="q-mb-sm">{{ success_action.message || success_action.description }}</p>
<code v-if="decryptedValue" class="text-h6 q-mt-sm q-mb-none">
{{ decryptedValue }}
</code>
<p v-else-if="success_action.url" class="text-h6 q-mt-sm q-mb-none">
<a target="_blank" style="color: inherit;" :href="success_action.url">{{ success_action.url }}</a>
</p>
</div>
`,
mounted: function () {
if (this.success_action.tag !== 'aes') return null
decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
value => {
this.decryptedValue = value
}
)
}
}) })

2
lnbits/wallets/base.py

@ -32,7 +32,7 @@ class PaymentStatus(NamedTuple):
class Wallet(ABC): class Wallet(ABC):
@abstractmethod @abstractmethod
def status() -> StatusResponse: def status(self) -> StatusResponse:
pass pass
@abstractmethod @abstractmethod

4
lnbits/wallets/lnpay.py

@ -23,7 +23,7 @@ class LNPayWallet(Wallet):
try: try:
r = httpx.get(url, headers=self.auth) r = httpx.get(url, headers=self.auth)
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to '{url}'") return StatusResponse(f"Unable to connect to '{url}'", 0)
if r.is_error: if r.is_error:
return StatusResponse(r.text[:250], 0) return StatusResponse(r.text[:250], 0)
@ -34,7 +34,7 @@ class LNPayWallet(Wallet):
f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0 f"Wallet {data['user_label']} (data['id']) not active, but {data['statusType']['name']}", 0
) )
return StatusResponse(None, data["balance"] / 1000) return StatusResponse(None, data["balance"] * 1000)
def create_invoice( def create_invoice(
self, self,

2
lnbits/wallets/opennode.py

@ -24,7 +24,7 @@ class OpenNodeWallet(Wallet):
try: try:
r = httpx.get(f"{self.endpoint}/v1/account/balance", headers=self.auth) r = httpx.get(f"{self.endpoint}/v1/account/balance", headers=self.auth)
except (httpx.ConnectError, httpx.RequestError): except (httpx.ConnectError, httpx.RequestError):
return StatusResponse(f"Unable to connect to '{self.endpoint}'") return StatusResponse(f"Unable to connect to '{self.endpoint}'", 0)
data = r.json()["message"] data = r.json()["message"]
if r.is_error: if r.is_error:

2
lnbits/wallets/spark.py

@ -29,6 +29,8 @@ class SparkWallet(Wallet):
params = args params = args
elif kwargs: elif kwargs:
params = kwargs params = kwargs
else:
params = {}
r = httpx.post(self.url + "/rpc", headers={"X-Access": self.token}, json={"method": key, "params": params}) r = httpx.post(self.url + "/rpc", headers={"X-Access": self.token}, json={"method": key, "params": params})
try: try:

Loading…
Cancel
Save