Merge pull request #154 from lnbits/offlineshop

This commit is contained in:
fiatjaf 2021-03-14 21:55:52 -03:00 committed by GitHub
commit 8df4dd702d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1608 additions and 103 deletions

View File

@ -131,14 +131,15 @@ async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wa
# ---------------
async def get_standalone_payment(checking_id: str) -> Optional[Payment]:
async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]:
row = await db.fetchone(
"""
SELECT *
FROM apipayments
WHERE checking_id = ?
WHERE checking_id = ? OR hash = ?
LIMIT 1
""",
(checking_id,),
(checking_id_or_hash, checking_id_or_hash),
)
return Payment.from_row(row) if row else None

View File

@ -1,48 +0,0 @@
import trio # type: ignore
import httpx
async def get_fiat_rate(currency: str):
assert currency == "USD", "Only USD is supported as fiat currency."
return await get_usd_rate()
async def get_usd_rate():
"""
Returns an average satoshi price from multiple sources.
"""
satoshi_prices = [None, None, None]
async def fetch_price(index, url, getter):
try:
async with httpx.AsyncClient() as client:
r = await client.get(url)
r.raise_for_status()
satoshi_price = int(100_000_000 / float(getter(r.json())))
satoshi_prices[index] = satoshi_price
except Exception:
pass
async with trio.open_nursery() as nursery:
nursery.start_soon(
fetch_price,
0,
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
)
nursery.start_soon(
fetch_price,
1,
"https://www.bitstamp.net/api/v2/ticker/btcusd",
lambda d: d["last"],
)
nursery.start_soon(
fetch_price,
2,
"https://api.coincap.io/v2/rates/bitcoin",
lambda d: d["data"]["rateUsd"],
)
satoshi_prices = [x for x in satoshi_prices if x]
return sum(satoshi_prices) / len(satoshi_prices)

View File

@ -5,10 +5,10 @@ from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import increment_pay_link
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
@ -17,7 +17,7 @@ async def api_lnurl_response(link_id):
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
rate = await get_fiat_rate(link.currency) if link.currency else 1
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
resp = LnurlPayResponse(
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
min_sendable=math.ceil(link.min * rate) * 1000,
@ -39,7 +39,7 @@ async def api_lnurl_callback(link_id):
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
min, max = link.min, link.max
rate = await get_fiat_rate(link.currency) if link.currency else 1
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency:
# allow some fluctuation (as the fiat price may have changed between the calls)
min = rate * 995 * link.min

View File

@ -26,6 +26,7 @@ new Vue({
mixins: [windowMixin],
data() {
return {
currencies: [],
fiatRates: {},
checker: null,
payLinks: [],
@ -203,5 +204,14 @@ new Vue({
getPayLinks()
}, 20000)
}
LNbits.api
.request('GET', '/lnurlp/api/v1/currencies')
.then(response => {
this.currencies = ['satoshis', ...response.data]
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
})

View File

@ -133,7 +133,7 @@
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
@ -182,7 +182,7 @@
<div class="col">
<q-select
dense
:options='["satoshis", "USD"]'
:options="currencies"
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"

View File

@ -4,6 +4,7 @@ from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import (
@ -13,7 +14,11 @@ from .crud import (
update_pay_link,
delete_pay_link,
)
from .helpers import get_fiat_rate
@lnurlp_ext.route("/api/v1/currencies", methods=["GET"])
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
@ -58,7 +63,7 @@ async def api_link_retrieve(link_id):
"description": {"type": "string", "empty": False, "required": True},
"min": {"type": "number", "min": 0.01, "required": True},
"max": {"type": "number", "min": 0.01, "required": True},
"currency": {"type": "string", "allowed": ["USD"], "nullable": True, "required": False},
"currency": {"type": "string", "nullable": True, "required": False},
"comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
"webhook_url": {"type": "string", "required": False},
"success_text": {"type": "string", "required": False},
@ -109,7 +114,7 @@ async def api_link_delete(link_id):
@lnurlp_ext.route("/api/v1/rate/<currency>", methods=["GET"])
async def api_check_fiat_rate(currency):
try:
rate = await get_fiat_rate(currency)
rate = await get_fiat_rate_satoshis(currency)
except AssertionError:
rate = None

View File

@ -0,0 +1 @@
# Offline Shop

View File

@ -0,0 +1,12 @@
from quart import Blueprint
from lnbits.db import Database
db = Database("ext_offlineshop")
offlineshop_ext: Blueprint = Blueprint("offlineshop", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
from .lnurl import * # noqa

View File

@ -0,0 +1,8 @@
{
"name": "OfflineShop",
"short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.",
"icon": "nature_people",
"contributors": [
"fiatjaf"
]
}

View File

@ -0,0 +1,101 @@
from typing import List, Optional
from . import db
from .wordlists import animals
from .models import Shop, Item
async def create_shop(*, wallet_id: str) -> int:
result = await db.execute(
"""
INSERT INTO shops (wallet, wordlist)
VALUES (?, ?)
""",
(wallet_id, "\n".join(animals)),
)
return result._result_proxy.lastrowid
async def get_shop(id: int) -> Optional[Shop]:
row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,))
return Shop(**dict(row)) if row else None
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,))
if not row:
# create on the fly
ls_id = await create_shop(wallet_id=wallet)
return await get_shop(ls_id)
return Shop(**dict(row)) if row else None
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
await db.execute(
"UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
(method, wordlist, shop),
)
return await get_shop(shop)
async def add_item(
shop: int,
name: str,
description: str,
image: Optional[str],
price: int,
unit: str,
) -> int:
result = await db.execute(
"""
INSERT INTO items (shop, name, description, image, price, unit)
VALUES (?, ?, ?, ?, ?, ?)
""",
(shop, name, description, image, price, unit),
)
return result._result_proxy.lastrowid
async def update_item(
shop: int,
item_id: int,
name: str,
description: str,
image: Optional[str],
price: int,
unit: str,
) -> int:
await db.execute(
"""
UPDATE items SET
name = ?,
description = ?,
image = ?,
price = ?,
unit = ?
WHERE shop = ? AND id = ?
""",
(name, description, image, price, unit, shop, item_id),
)
return item_id
async def get_item(id: int) -> Optional[Item]:
row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,))
return Item(**dict(row)) if row else None
async def get_items(shop: int) -> List[Item]:
rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,))
return [Item(**dict(row)) for row in rows]
async def delete_item_from_shop(shop: int, item_id: int):
await db.execute(
"""
DELETE FROM items WHERE shop = ? AND id = ?
""",
(shop, item_id),
)

View File

@ -0,0 +1,17 @@
import base64
import struct
import hmac
import time
def hotp(key, counter, digits=6, digest="sha1"):
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
counter = struct.pack(">Q", counter)
mac = hmac.new(key, counter, digest).digest()
offset = mac[-1] & 0x0F
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
return str(binary)[-digits:].zfill(digits)
def totp(key, time_step=30, digits=6, digest="sha1"):
return hotp(key, int(time.time() / time_step), digits, digest)

View File

@ -0,0 +1,69 @@
import hashlib
from quart import jsonify, url_for, request
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import offlineshop_ext
from .crud import get_shop, get_item
@offlineshop_ext.route("/lnurl/<item_id>", methods=["GET"])
async def lnurl_response(item_id):
item = await get_item(item_id)
if not item:
return jsonify({"status": "ERROR", "reason": "Item not found."})
if not item.enabled:
return jsonify({"status": "ERROR", "reason": "Item disabled."})
price_msat = (await fiat_amount_as_satoshis(item.price, item.unit) if item.unit != "sat" else item.price) * 1000
resp = LnurlPayResponse(
callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True),
min_sendable=price_msat,
max_sendable=price_msat,
metadata=await item.lnurlpay_metadata(),
)
return jsonify(resp.dict())
@offlineshop_ext.route("/lnurl/cb/<item_id>", methods=["GET"])
async def lnurl_callback(item_id):
item = await get_item(item_id)
if not item:
return jsonify({"status": "ERROR", "reason": "Couldn't find item."})
if item.unit == "sat":
min = item.price * 1000
max = item.price * 1000
else:
price = await fiat_amount_as_satoshis(item.price, item.unit)
# allow some fluctuation (the fiat price may have changed between the calls)
min = price * 995
max = price * 1010
amount_received = int(request.args.get("amount"))
if amount_received < min:
return jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict())
elif amount_received > max:
return jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict())
shop = await get_shop(item.shop)
payment_hash, payment_request = await create_invoice(
wallet_id=shop.wallet,
amount=int(amount_received / 1000),
memo=item.name,
description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(),
extra={"tag": "offlineshop", "item": item.id},
)
resp = LnurlPayActionResponse(
pr=payment_request,
success_action=item.success_action(shop, payment_hash) if shop.method else None,
routes=[],
)
return jsonify(resp.dict())

View File

@ -0,0 +1,29 @@
async def m001_initial(db):
"""
Initial offlineshop tables.
"""
await db.execute(
"""
CREATE TABLE shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet TEXT NOT NULL,
method TEXT NOT NULL,
wordlist TEXT
);
"""
)
await db.execute(
"""
CREATE TABLE items (
shop INTEGER NOT NULL REFERENCES shop (id),
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL,
image TEXT, -- image/png;base64,...
enabled BOOLEAN NOT NULL DEFAULT true,
price INTEGER NOT NULL,
unit TEXT NOT NULL DEFAULT 'sat'
);
"""
)

View File

@ -0,0 +1,114 @@
import json
import base64
import hashlib
from collections import OrderedDict
from quart import url_for
from typing import NamedTuple, Optional, List, Dict
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
from .helpers import totp
shop_counters: Dict = {}
class ShopCounter(object):
fulfilled_payments: OrderedDict
counter: int
@classmethod
def invoke(cls, shop: "Shop"):
shop_counter = shop_counters.get(shop.id)
if not shop_counter:
shop_counter = cls(wordlist=shop.wordlist.split("\n"))
shop_counters[shop.id] = shop_counter
return shop_counter
@classmethod
def reset(cls, shop: "Shop"):
shop_counter = cls.invoke(shop)
shop_counter.counter = -1
shop_counter.wordlist = shop.wordlist.split("\n")
def __init__(self, wordlist: List[str]):
self.wordlist = wordlist
self.fulfilled_payments = OrderedDict()
self.counter = -1
def get_word(self, payment_hash):
if payment_hash in self.fulfilled_payments:
return self.fulfilled_payments[payment_hash]
# get a new word
self.counter += 1
word = self.wordlist[self.counter % len(self.wordlist)]
self.fulfilled_payments[payment_hash] = word
# cleanup confirmation words cache
to_remove = len(self.fulfilled_payments) - 23
if to_remove > 0:
for i in range(to_remove):
self.fulfilled_payments.popitem(False)
return word
class Shop(NamedTuple):
id: int
wallet: str
method: str
wordlist: str
@property
def otp_key(self) -> str:
return base64.b32encode(
hashlib.sha256(
("otpkey" + str(self.id) + self.wallet).encode("ascii"),
).digest()
).decode("ascii")
def get_code(self, payment_hash: str) -> str:
if self.method == "wordlist":
sc = ShopCounter.invoke(self)
return sc.get_word(payment_hash)
elif self.method == "totp":
return totp(self.otp_key)
return ""
class Item(NamedTuple):
shop: int
id: int
name: str
description: str
image: str
enabled: bool
price: int
unit: str
@property
def lnurl(self) -> str:
return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id, _external=True))
def values(self):
values = self._asdict()
values["lnurl"] = self.lnurl
return values
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
metadata = [["text/plain", self.description]]
if self.image:
metadata.append(self.image.split(":")[1].split(","))
return LnurlPayMetadata(json.dumps(metadata))
def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
if not shop.wordlist:
return None
return UrlAction(
url=url_for("offlineshop.confirmation_code", p=payment_hash, _external=True),
description="Open to get the confirmation code for your purchase.",
)

View File

@ -0,0 +1,220 @@
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
Vue.component(VueQrcode.name, VueQrcode)
const pica = window.pica()
const defaultItemData = {
unit: 'sat'
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
selectedWallet: null,
confirmationMethod: 'wordlist',
wordlistTainted: false,
offlineshop: {
method: null,
wordlist: [],
items: []
},
itemDialog: {
show: false,
data: {...defaultItemData},
units: ['sat']
}
}
},
computed: {
printItems() {
return this.offlineshop.items.filter(({enabled}) => enabled)
}
},
methods: {
openNewDialog() {
this.itemDialog.show = true
this.itemDialog.data = {...defaultItemData}
},
openUpdateDialog(itemId) {
this.itemDialog.show = true
let item = this.offlineshop.items.find(item => item.id === itemId)
this.itemDialog.data = item
},
imageAdded(file) {
let blobURL = URL.createObjectURL(file)
let image = new Image()
image.src = blobURL
image.onload = async () => {
let canvas = document.createElement('canvas')
canvas.setAttribute('width', 100)
canvas.setAttribute('height', 100)
await pica.resize(image, canvas, {
quality: 0,
alpha: true,
unsharpAmount: 95,
unsharpRadius: 0.9,
unsharpThreshold: 70
})
this.itemDialog.data.image = canvas.toDataURL()
this.itemDialog = {...this.itemDialog}
}
},
imageCleared() {
this.itemDialog.data.image = null
this.itemDialog = {...this.itemDialog}
},
disabledAddItemButton() {
return (
!this.itemDialog.data.name ||
this.itemDialog.data.name.length === 0 ||
!this.itemDialog.data.price ||
!this.itemDialog.data.description ||
!this.itemDialog.data.unit ||
this.itemDialog.data.unit.length === 0
)
},
changedWallet(wallet) {
this.selectedWallet = wallet
this.loadShop()
},
loadShop() {
LNbits.api
.request(
'GET',
'/offlineshop/api/v1/offlineshop',
this.selectedWallet.inkey
)
.then(response => {
this.offlineshop = response.data
this.confirmationMethod = response.data.method
this.wordlistTainted = false
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
async setMethod() {
try {
await LNbits.api.request(
'PUT',
'/offlineshop/api/v1/offlineshop/method',
this.selectedWallet.inkey,
{method: this.confirmationMethod, wordlist: this.offlineshop.wordlist}
)
} catch (err) {
LNbits.utils.notifyApiError(err)
return
}
this.$q.notify({
message:
`Method set to ${this.confirmationMethod}.` +
(this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''),
timeout: 700
})
this.loadShop()
},
async sendItem() {
let {id, name, image, description, price, unit} = this.itemDialog.data
const data = {
name,
description,
image,
price,
unit
}
try {
if (id) {
await LNbits.api.request(
'PUT',
'/offlineshop/api/v1/offlineshop/items/' + id,
this.selectedWallet.inkey,
data
)
} else {
await LNbits.api.request(
'POST',
'/offlineshop/api/v1/offlineshop/items',
this.selectedWallet.inkey,
data
)
this.$q.notify({
message: `Item '${this.itemDialog.data.name}' added.`,
timeout: 700
})
}
} catch (err) {
LNbits.utils.notifyApiError(err)
return
}
this.loadShop()
this.itemDialog.show = false
this.itemDialog.data = {...defaultItemData}
},
toggleItem(itemId) {
let item = this.offlineshop.items.find(item => item.id === itemId)
item.enabled = !item.enabled
LNbits.api
.request(
'PUT',
'/offlineshop/api/v1/offlineshop/items/' + itemId,
this.selectedWallet.inkey,
item
)
.then(response => {
this.$q.notify({
message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`,
timeout: 700
})
this.offlineshop.items = this.offlineshop.items
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
},
deleteItem(itemId) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this item?')
.onOk(() => {
LNbits.api
.request(
'DELETE',
'/offlineshop/api/v1/offlineshop/items/' + itemId,
this.selectedWallet.inkey
)
.then(response => {
this.$q.notify({
message: `Item deleted.`,
timeout: 700
})
this.offlineshop.items.splice(
this.offlineshop.items.findIndex(item => item.id === itemId),
1
)
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
})
}
},
created() {
this.selectedWallet = this.g.user.wallets[0]
this.loadShop()
LNbits.api
.request('GET', '/offlineshop/api/v1/currencies')
.then(response => {
this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
})

View File

@ -0,0 +1,147 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<ol>
<li>Register items.</li>
<li>
Print QR codes and paste them on your store, your menu, somewhere,
somehow.
</li>
<li>
Clients scan the QR codes and get information about the items plus the
price on their phones directly (they must have internet)
</li>
<li>
Once they decide to pay, they'll get an invoice on their phones
automatically
</li>
<li>
When the payment is confirmed, a confirmation code will be issued for
them.
</li>
</ol>
<p>
The confirmation codes are words from a predefined sequential word list.
Each new payment bumps the words sequence by 1. So you can check the
confirmation codes manually by just looking at them.
</p>
<p>
For example, if your wordlist is
<code>[apple, banana, coconut]</code> the first purchase will be
<code>apple</code>, the second <code>banana</code> and so on. When it
gets to the end it starts from the beginning again.
</p>
<p>Powered by LNURL-pay.</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="Create item (a shop will be created automatically based on the wallet you use)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">POST</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_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 201 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items -H "Content-Type:
application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
'{"name": &lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;data-uri string&gt;, "price": &lt;integer&gt;, "unit": &lt;"sat"
or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get the shop data along with items"
>
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_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
>{"id": &lt;integer&gt;, "wallet": &lt;string&gt;, "wordlist":
&lt;string&gt;, "items": [{"id": &lt;integer&gt;, "name":
&lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;string&gt;, "enabled": &lt;boolean&gt;, "price": &lt;integer&gt;,
"unit": &lt;string&gt;, "lnurl": &lt;string&gt;}, ...]}&lt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -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="Update item (all fields must be sent again)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">PUT</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_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</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H
"Content-Type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -d '{"name": &lt;string&gt;,
"description": &lt;string&gt;, "image": &lt;data-uri string&gt;,
"price": &lt;integer&gt;, "unit": &lt;"sat" or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Delete item">
<q-card>
<q-card-section>
<code><span class="text-blue">DELETE</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_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</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}/offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H "X-Api-Key:
{{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,332 @@
{% 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>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Items</h5>
</div>
<div class="col q-ml-lg">
<q-btn unelevated color="deep-purple" @click="openNewDialog()"
>Add new item</q-btn
>
</div>
</div>
{% raw %}
<q-table
dense
flat
selection="multiple"
:data="offlineshop.items"
row-key="id"
no-data-label="No items for sale yet"
:pagination="{rowsPerPage: 0}"
:binary-state-sort="true"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Name</q-th>
<q-th auto-width>Description</q-th>
<q-th auto-width>Image</q-th>
<q-th auto-width>Price</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
:icon="props.row.enabled ? 'done' : 'block'"
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
type="a"
@click="toggleItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
<q-td auto-width> {{ props.row.description }} </q-td>
<q-td class="text-center" auto-width>
<img
v-if="props.row.image"
:src="props.row.image"
style="height: 1.5em"
/>
</q-td>
<q-td class="text-center" auto-width>
{{ props.row.price }} {{ props.row.unit }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
color="negative"
type="a"
@click="deleteItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="row">
<h5 class="text-subtitle1 q-my-none">Wallet Shop</h5>
</div>
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Using wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
<q-btn
type="a"
outline
color="purple"
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
>Print QR Codes</q-btn
>
</div>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-tabs
v-model="confirmationMethod"
no-caps
class="bg-purple text-white shadow-2"
>
<q-tab name="wordlist" label="Wordlist"></q-tab>
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
<q-tab name="none" label="Nothing"></q-tab>
</q-tabs>
<q-card-section class="q-py-sm text-center">
<q-form
v-if="confirmationMethod === 'wordlist'"
class="q-gutter-md q-y-md"
@submit="setMethod"
>
<div class="row">
<div class="col q-mx-lg">
<q-input
v-model="offlineshop.wordlist"
@input="wordlistTainted = true"
dense
filled
autogrow
/>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="deep-purple"
type="submit"
:disabled="!wordlistTainted"
>
Update Wordlist
</q-btn>
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
>Reset</q-btn
>
</div>
</div>
</q-form>
<div v-else-if="confirmationMethod === 'totp'">
<div class="row">
<div class="col q-mx-lg">
<q-responsive :ratio="1">
<qrcode
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="deep-purple"
:disabled="offlineshop.method === 'totp'"
@click="setMethod"
>
Set TOTP
</q-btn>
</div>
</div>
</div>
<div v-else-if="confirmationMethod === 'none'">
<p>
Setting this option disables the confirmation code message that
appears in the consumer wallet after a purchase is paid for. It's ok
if the consumer is to be trusted when they claim to have paid.
</p>
<q-btn
unelevated
color="deep-purple"
:disabled="offlineshop.method === 'none'"
@click="setMethod"
>
Disable Confirmation Codes
</q-btn>
</div>
</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 OfflineShop extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="itemDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<h5
class="q-ma-none"
v-if="itemDialog.data.id"
v-text="itemDialog.data.name"
></h5>
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
<q-responsive v-if="itemDialog.data.id" :ratio="1">
<qrcode
:value="itemDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
<q-btn
outline
color="grey"
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-mb-lg"
>Copy LNURL</q-btn
>
</div>
<q-form @submit="sendItem" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="itemDialog.data.name"
type="text"
label="Item name"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.data.description"
type="text"
label="Brief description"
></q-input>
<q-file
filled
dense
capture="environment"
accept="image/jpeg, image/png"
:max-file-size="3*1024**2"
label="Small image (optional)"
clearable
@input="imageAdded"
@clear="imageCleared"
>
<template v-if="itemDialog.data.image" v-slot:before>
<img style="height: 1em" :src="itemDialog.data.image" />
</template>
<template v-if="itemDialog.data.image" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="imageCleared"
class="cursor-pointer"
/>
</template>
</q-file>
<q-input
filled
dense
v-model.number="itemDialog.data.price"
type="number"
min="1"
:label="`Item price (${itemDialog.data.unit})`"
></q-input>
<q-select
filled
dense
v-model="itemDialog.data.unit"
type="text"
label="Unit"
:options="itemDialog.units"
></q-select>
<div class="row q-mt-lg">
<div class="col q-ml-lg">
<q-btn
unelevated
color="deep-purple"
:disable="disabledAddItemButton()"
type="submit"
>
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
Item
</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="/offlineshop/static/js/index.js"></script>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "print.html" %} {% block page %} {% raw %}
<div class="row justify-center">
<div v-for="item in items" class="q-my-sm q-mx-lg">
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
<qrcode :value="item.lnurl" :options="{margin: 0, width: 250}"></qrcode>
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
</div>
</div>
{% endraw %} {% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
created: function () {
window.print()
},
data: function () {
return {
items: JSON.parse('{{items | tojson}}')
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,60 @@
import time
from datetime import datetime
from quart import g, render_template, request
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.core.models import Payment
from lnbits.core.crud import get_standalone_payment
from . import offlineshop_ext
from .crud import get_item, get_shop
@offlineshop_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def index():
return await render_template("offlineshop/index.html", user=g.user)
@offlineshop_ext.route("/print")
async def print_qr_codes():
items = []
for item_id in request.args.get("items").split(","):
item = await get_item(item_id)
if item:
items.append({"lnurl": item.lnurl, "name": item.name, "price": f"{item.price} {item.unit}"})
return await render_template("offlineshop/print.html", items=items)
@offlineshop_ext.route("/confirmation")
async def confirmation_code():
style = "<style>* { font-size: 100px}</style>"
payment_hash = request.args.get("p")
payment: Payment = await get_standalone_payment(payment_hash)
if not payment:
return f"Couldn't find the payment {payment_hash}." + style, HTTPStatus.NOT_FOUND
if payment.pending:
return (
f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + style,
HTTPStatus.PAYMENT_REQUIRED,
)
if payment.time + 60 * 15 < time.time():
return "too much time has passed." + style
item = await get_item(payment.extra.get("item"))
shop = await get_shop(item.shop)
return (
f"""
[{shop.get_code(payment_hash)}]<br>
{item.name}<br>
{item.price} {item.unit}<br>
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
"""
+ style
)

View File

@ -0,0 +1,119 @@
from quart import g, jsonify
from http import HTTPStatus
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.utils.exchange_rates import currencies
from . import offlineshop_ext
from .crud import (
get_or_create_shop_by_wallet,
set_method,
add_item,
update_item,
get_items,
delete_item_from_shop,
)
from .models import ShopCounter
@offlineshop_ext.route("/api/v1/currencies", methods=["GET"])
async def api_list_currencies_available():
return jsonify(list(currencies.keys()))
@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"])
@api_check_wallet_key("invoice")
async def api_shop_from_wallet():
shop = await get_or_create_shop_by_wallet(g.wallet.id)
items = await get_items(shop.id)
try:
return (
jsonify(
{
**shop._asdict(),
**{
"otp_key": shop.otp_key,
"items": [item.values() for item in items],
},
}
),
HTTPStatus.OK,
)
except LnurlInvalidUrl:
return (
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
HTTPStatus.UPGRADE_REQUIRED,
)
@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"])
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"name": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "empty": False, "required": True},
"image": {"type": "string", "required": False, "nullable": True},
"price": {"type": "number", "required": True},
"unit": {"type": "string", "required": True},
}
)
async def api_add_or_update_item(item_id=None):
shop = await get_or_create_shop_by_wallet(g.wallet.id)
if item_id == None:
await add_item(
shop.id,
g.data["name"],
g.data["description"],
g.data.get("image"),
g.data["price"],
g.data["unit"],
)
return "", HTTPStatus.CREATED
else:
await update_item(
shop.id,
item_id,
g.data["name"],
g.data["description"],
g.data.get("image"),
g.data["price"],
g.data["unit"],
)
return "", HTTPStatus.OK
@offlineshop_ext.route("/api/v1/offlineshop/items/<item_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
async def api_delete_item(item_id):
shop = await get_or_create_shop_by_wallet(g.wallet.id)
await delete_item_from_shop(shop.id, item_id)
return "", HTTPStatus.NO_CONTENT
@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"method": {"type": "string", "required": True, "nullable": False},
"wordlist": {"type": "string", "empty": True, "nullable": True, "required": False},
}
)
async def api_set_method():
method = g.data["method"]
wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None
wordlist = [word.strip() for word in wordlist if word.strip()]
shop = await get_or_create_shop_by_wallet(g.wallet.id)
if not shop:
return "", HTTPStatus.NOT_FOUND
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
if not updated_shop:
return "", HTTPStatus.NOT_FOUND
ShopCounter.reset(updated_shop)
return "", HTTPStatus.OK

View File

@ -0,0 +1,28 @@
animals = [
"albatross",
"bison",
"chicken",
"duck",
"eagle",
"flamingo",
"gorila",
"hamster",
"iguana",
"jaguar",
"koala",
"llama",
"macaroni penguim",
"numbat",
"octopus",
"platypus",
"quetzal",
"rabbit",
"salmon",
"tuna",
"unicorn",
"vulture",
"wolf",
"xenops",
"yak",
"zebra",
]

View File

@ -17,8 +17,8 @@
<code>[&lt;paywall_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/paywalls -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -48,11 +48,11 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/paywalls -d
'{"url": &lt;string&gt;, "memo": &lt;string&gt;, "description":
&lt;string&gt;, "amount": &lt;integer&gt;, "remembers":
&lt;boolean&gt;}' -H "Content-type: application/json" -H "X-Api-Key:
{{ g.user.wallets[0].adminkey }}"
>curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url":
&lt;string&gt;, "memo": &lt;string&gt;, "description": &lt;string&gt;,
"amount": &lt;integer&gt;, "remembers": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>

View File

@ -42,8 +42,8 @@
<code>JSON list of users</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/users -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -64,9 +64,8 @@
<code>JSON wallet data</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/wallets/&lt;user_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/wallets/&lt;user_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -87,9 +86,8 @@
<code>JSON a wallets transactions</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/wallets&lt;wallet_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/wallets&lt;wallet_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -128,10 +126,10 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/users -d
'{"admin_id": "{{ g.user.id }}", "wallet_name": &lt;string&gt;,
"user_name": &lt;string&gt;}' -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
>curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
g.user.id }}", "wallet_name": &lt;string&gt;, "user_name":
&lt;string&gt;}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"
</code>
</q-card-section>
</q-card>
@ -165,10 +163,10 @@
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/wallets -d
'{"user_id": &lt;string&gt;, "wallet_name": &lt;string&gt;,
"admin_id": "{{ g.user.id }}"}' -H "X-Api-Key: {{
g.user.wallets[0].inkey }}" -H "Content-type: application/json"
>curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id":
&lt;string&gt;, "wallet_name": &lt;string&gt;, "admin_id": "{{
g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"
</code>
</q-card-section>
</q-card>
@ -189,9 +187,8 @@
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/users/&lt;user_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
>curl -X DELETE {{ request.url_root }}api/v1/users/&lt;user_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -207,9 +204,8 @@
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/wallets/&lt;wallet_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
>curl -X DELETE {{ request.url_root }}api/v1/wallets/&lt;wallet_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -230,8 +226,8 @@
<code>{"X-Api-Key": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/extensions -d
'{"userid": &lt;string&gt;, "extension": &lt;string&gt;, "active":
>curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid":
&lt;string&gt;, "extension": &lt;string&gt;, "active":
&lt;integer&gt;}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
"Content-type: application/json"
</code>

View File

@ -22,8 +22,8 @@
<code>[&lt;withdraw_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}api/v1/links -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -49,9 +49,8 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
>curl -X GET {{ request.url_root }}api/v1/links/&lt;withdraw_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
@ -79,8 +78,8 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}api/v1/links -d
'{"title": &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
>curl -X POST {{ request.url_root }}api/v1/links -d '{"title":
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@ -115,9 +114,8 @@
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root
}}api/v1/links/&lt;withdraw_id&gt; -d '{"title":
&lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
>curl -X PUT {{ request.url_root }}api/v1/links/&lt;withdraw_id&gt; -d
'{"title": &lt;string&gt;, "min_withdrawable": &lt;integer&gt;,
"max_withdrawable": &lt;integer&gt;, "uses": &lt;integer&gt;,
"wait_time": &lt;integer&gt;, "is_unique": &lt;boolean&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
@ -145,9 +143,8 @@
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root
}}api/v1/links/&lt;withdraw_id&gt; -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
>curl -X DELETE {{ request.url_root }}api/v1/links/&lt;withdraw_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>

0
lnbits/utils/__init__.py Normal file
View File

View File

@ -0,0 +1,262 @@
import trio # type: ignore
import httpx
from typing import Callable, NamedTuple
currencies = {
"AED": "United Arab Emirates Dirham",
"AFN": "Afghan Afghani",
"ALL": "Albanian Lek",
"AMD": "Armenian Dram",
"ANG": "Netherlands Antillean Gulden",
"AOA": "Angolan Kwanza",
"ARS": "Argentine Peso",
"AUD": "Australian Dollar",
"AWG": "Aruban Florin",
"AZN": "Azerbaijani Manat",
"BAM": "Bosnia and Herzegovina Convertible Mark",
"BBD": "Barbadian Dollar",
"BDT": "Bangladeshi Taka",
"BGN": "Bulgarian Lev",
"BHD": "Bahraini Dinar",
"BIF": "Burundian Franc",
"BMD": "Bermudian Dollar",
"BND": "Brunei Dollar",
"BOB": "Bolivian Boliviano",
"BRL": "Brazilian Real",
"BSD": "Bahamian Dollar",
"BTN": "Bhutanese Ngultrum",
"BWP": "Botswana Pula",
"BYN": "Belarusian Ruble",
"BYR": "Belarusian Ruble",
"BZD": "Belize Dollar",
"CAD": "Canadian Dollar",
"CDF": "Congolese Franc",
"CHF": "Swiss Franc",
"CLF": "Unidad de Fomento",
"CLP": "Chilean Peso",
"CNH": "Chinese Renminbi Yuan Offshore",
"CNY": "Chinese Renminbi Yuan",
"COP": "Colombian Peso",
"CRC": "Costa Rican Colón",
"CUC": "Cuban Convertible Peso",
"CVE": "Cape Verdean Escudo",
"CZK": "Czech Koruna",
"DJF": "Djiboutian Franc",
"DKK": "Danish Krone",
"DOP": "Dominican Peso",
"DZD": "Algerian Dinar",
"EGP": "Egyptian Pound",
"ERN": "Eritrean Nakfa",
"ETB": "Ethiopian Birr",
"EUR": "Euro",
"FJD": "Fijian Dollar",
"FKP": "Falkland Pound",
"GBP": "British Pound",
"GEL": "Georgian Lari",
"GGP": "Guernsey Pound",
"GHS": "Ghanaian Cedi",
"GIP": "Gibraltar Pound",
"GMD": "Gambian Dalasi",
"GNF": "Guinean Franc",
"GTQ": "Guatemalan Quetzal",
"GYD": "Guyanese Dollar",
"HKD": "Hong Kong Dollar",
"HNL": "Honduran Lempira",
"HRK": "Croatian Kuna",
"HTG": "Haitian Gourde",
"HUF": "Hungarian Forint",
"IDR": "Indonesian Rupiah",
"ILS": "Israeli New Sheqel",
"IMP": "Isle of Man Pound",
"INR": "Indian Rupee",
"IQD": "Iraqi Dinar",
"ISK": "Icelandic Króna",
"JEP": "Jersey Pound",
"JMD": "Jamaican Dollar",
"JOD": "Jordanian Dinar",
"JPY": "Japanese Yen",
"KES": "Kenyan Shilling",
"KGS": "Kyrgyzstani Som",
"KHR": "Cambodian Riel",
"KMF": "Comorian Franc",
"KRW": "South Korean Won",
"KWD": "Kuwaiti Dinar",
"KYD": "Cayman Islands Dollar",
"KZT": "Kazakhstani Tenge",
"LAK": "Lao Kip",
"LBP": "Lebanese Pound",
"LKR": "Sri Lankan Rupee",
"LRD": "Liberian Dollar",
"LSL": "Lesotho Loti",
"LYD": "Libyan Dinar",
"MAD": "Moroccan Dirham",
"MDL": "Moldovan Leu",
"MGA": "Malagasy Ariary",
"MKD": "Macedonian Denar",
"MMK": "Myanmar Kyat",
"MNT": "Mongolian Tögrög",
"MOP": "Macanese Pataca",
"MRO": "Mauritanian Ouguiya",
"MUR": "Mauritian Rupee",
"MVR": "Maldivian Rufiyaa",
"MWK": "Malawian Kwacha",
"MXN": "Mexican Peso",
"MYR": "Malaysian Ringgit",
"MZN": "Mozambican Metical",
"NAD": "Namibian Dollar",
"NGN": "Nigerian Naira",
"NIO": "Nicaraguan Córdoba",
"NOK": "Norwegian Krone",
"NPR": "Nepalese Rupee",
"NZD": "New Zealand Dollar",
"OMR": "Omani Rial",
"PAB": "Panamanian Balboa",
"PEN": "Peruvian Sol",
"PGK": "Papua New Guinean Kina",
"PHP": "Philippine Peso",
"PKR": "Pakistani Rupee",
"PLN": "Polish Złoty",
"PYG": "Paraguayan Guaraní",
"QAR": "Qatari Riyal",
"RON": "Romanian Leu",
"RSD": "Serbian Dinar",
"RUB": "Russian Ruble",
"RWF": "Rwandan Franc",
"SAR": "Saudi Riyal",
"SBD": "Solomon Islands Dollar",
"SCR": "Seychellois Rupee",
"SEK": "Swedish Krona",
"SGD": "Singapore Dollar",
"SHP": "Saint Helenian Pound",
"SLL": "Sierra Leonean Leone",
"SOS": "Somali Shilling",
"SRD": "Surinamese Dollar",
"SSP": "South Sudanese Pound",
"STD": "São Tomé and Príncipe Dobra",
"SVC": "Salvadoran Colón",
"SZL": "Swazi Lilangeni",
"THB": "Thai Baht",
"TJS": "Tajikistani Somoni",
"TMT": "Turkmenistani Manat",
"TND": "Tunisian Dinar",
"TOP": "Tongan Paʻanga",
"TRY": "Turkish Lira",
"TTD": "Trinidad and Tobago Dollar",
"TWD": "New Taiwan Dollar",
"TZS": "Tanzanian Shilling",
"UAH": "Ukrainian Hryvnia",
"UGX": "Ugandan Shilling",
"USD": "US Dollar",
"UYU": "Uruguayan Peso",
"UZS": "Uzbekistan Som",
"VEF": "Venezuelan Bolívar",
"VES": "Venezuelan Bolívar Soberano",
"VND": "Vietnamese Đồng",
"VUV": "Vanuatu Vatu",
"WST": "Samoan Tala",
"XAF": "Central African Cfa Franc",
"XAG": "Silver (Troy Ounce)",
"XAU": "Gold (Troy Ounce)",
"XCD": "East Caribbean Dollar",
"XDR": "Special Drawing Rights",
"XOF": "West African Cfa Franc",
"XPD": "Palladium",
"XPF": "Cfp Franc",
"XPT": "Platinum",
"YER": "Yemeni Rial",
"ZAR": "South African Rand",
"ZMW": "Zambian Kwacha",
"ZWL": "Zimbabwean Dollar",
}
class Provider(NamedTuple):
name: str
domain: str
api_url: str
getter: Callable
exchange_rate_providers = {
"bitfinex": Provider(
"Bitfinex",
"bitfinex.com",
"https://api.bitfinex.com/v1/pubticker/{from}{to}",
lambda data, replacements: data["last_price"],
),
"bitstamp": Provider(
"Bitstamp",
"bitstamp.net",
"https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
lambda data, replacements: data["last"],
),
"coinbase": Provider(
"Coinbase",
"coinbase.com",
"https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
lambda data, replacements: data["data"]["rates"][replacements["TO"]],
),
"coinmate": Provider(
"CoinMate",
"coinmate.io",
"https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
lambda data, replacements: data["data"]["last"],
),
"kraken": Provider(
"Kraken",
"kraken.com",
"https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
lambda data, replacements: data["result"]["XXBTZ" + replacements["TO"]]["c"][0],
),
}
async def btc_price(currency: str) -> float:
replacements = {"FROM": "BTC", "from": "btc", "TO": currency.upper(), "to": currency.lower()}
rates = []
send_channel, receive_channel = trio.open_memory_channel(0)
async def controller(nursery):
failures = 0
while True:
rate = await receive_channel.receive()
if rate:
rates.append(rate)
else:
failures += 1
if len(rates) >= 2 or len(rates) == 1 and failures >= 2:
nursery.cancel_scope.cancel()
break
if failures == len(exchange_rate_providers):
nursery.cancel_scope.cancel()
break
async def fetch_price(key: str, provider: Provider):
try:
url = provider.api_url.format(**replacements)
async with httpx.AsyncClient() as client:
r = await client.get(url, timeout=0.5)
r.raise_for_status()
data = r.json()
rate = float(provider.getter(data, replacements))
await send_channel.send(rate)
except Exception:
await send_channel.send(None)
async with trio.open_nursery() as nursery:
nursery.start_soon(controller, nursery)
for key, provider in exchange_rate_providers.items():
nursery.start_soon(fetch_price, key, provider)
if not rates:
return 9999999999
return sum([rate for rate in rates]) / len(rates)
async def get_fiat_rate_satoshis(currency: str) -> float:
return int(100_000_000 / (await btc_price(currency)))
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
return int(amount * (await get_fiat_rate_satoshis(currency)))