Merge branch 'main' into diagon-alley

This commit is contained in:
Tiago Vasconcelos 2023-01-03 12:25:11 +00:00
commit 87933d1f8b
137 changed files with 7739 additions and 618 deletions

View file

@ -6,7 +6,7 @@ format: prettier isort black
check: mypy checkprettier checkisort checkblack
prettier: $(shell find lnbits -name "*.js" -name ".html")
prettier: $(shell find lnbits -name "*.js" -o -name ".html")
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
black:
@ -18,7 +18,7 @@ mypy:
isort:
poetry run isort .
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
checkprettier: $(shell find lnbits -name "*.js" -o -name ".html")
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js lnbits/extensions/*/static/components/*/*.js lnbits/extensions/*/static/components/*/*.html
checkblack:

View file

@ -5,7 +5,7 @@
};
outputs = { self, nixpkgs, poetry2nix }@inputs:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forSystems = systems: f:
nixpkgs.lib.genAttrs systems
(system: f system (import nixpkgs { inherit system; overlays = [ poetry2nix.overlay self.overlays.default ]; }));

View file

@ -1,7 +1,6 @@
import hashlib
import re
import time
from binascii import unhexlify
from decimal import Decimal
from typing import List, NamedTuple, Optional
@ -108,7 +107,7 @@ def decode(pr: str) -> Invoice:
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
sig = signature[0:64]
if invoice.payee:
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
key = VerifyingKey.from_string(bytes.fromhex(invoice.payee), curve=SECP256k1)
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
else:
keys = VerifyingKey.from_public_key_recovery(
@ -131,7 +130,7 @@ def encode(options):
if options["timestamp"]:
addr.date = int(options["timestamp"])
addr.paymenthash = unhexlify(options["paymenthash"])
addr.paymenthash = bytes.fromhex(options["paymenthash"])
if options["description"]:
addr.tags.append(("d", options["description"]))
@ -149,8 +148,8 @@ def encode(options):
while len(splits) >= 5:
route.append(
(
unhexlify(splits[0]),
unhexlify(splits[1]),
bytes.fromhex(splits[0]),
bytes.fromhex(splits[1]),
int(splits[2]),
int(splits[3]),
int(splits[4]),
@ -235,7 +234,7 @@ def lnencode(addr, privkey):
raise ValueError("Must include either 'd' or 'h'")
# We actually sign the hrp, then data (padded to 8 bits with zeroes).
privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
privkey = secp256k1.PrivateKey(bytes.fromhex(privkey))
sig = privkey.ecdsa_sign_recoverable(
bytearray([ord(c) for c in hrp]) + data.tobytes()
)
@ -261,7 +260,7 @@ class LnAddr(object):
def __str__(self):
return "LnAddr[{}, amount={}{} tags=[{}]]".format(
hexlify(self.pubkey.serialize()).decode("utf-8"),
bytes.hex(self.pubkey.serialize()).decode("utf-8"),
self.amount,
self.currency,
", ".join([k + "=" + str(v) for k, v in self.tags]),

View file

@ -454,6 +454,7 @@ async def update_payment_details(
async def update_payment_extra(
payment_hash: str,
extra: dict,
outgoing: bool = False,
conn: Optional[Connection] = None,
) -> None:
"""
@ -461,8 +462,10 @@ async def update_payment_extra(
Old values in the `extra` JSON object will be kept unless the new `extra` overwrites them.
"""
amount_clause = "AND amount < 0" if outgoing else "AND amount > 0"
row = await (conn or db).fetchone(
"SELECT hash, extra from apipayments WHERE hash = ?",
f"SELECT hash, extra from apipayments WHERE hash = ? {amount_clause}",
(payment_hash,),
)
if not row:
@ -471,10 +474,7 @@ async def update_payment_extra(
db_extra.update(extra)
await (conn or db).execute(
"""
UPDATE apipayments SET extra = ?
WHERE hash = ?
""",
f"UPDATE apipayments SET extra = ? WHERE hash = ? {amount_clause} ",
(json.dumps(db_extra), payment_hash),
)

View file

@ -224,7 +224,7 @@ async def m007_set_invoice_expiries(db):
)
).fetchall()
if len(rows):
logger.info(f"Mirgraion: Checking expiry of {len(rows)} invoices")
logger.info(f"Migration: Checking expiry of {len(rows)} invoices")
for i, (
payment_request,
checking_id,
@ -238,7 +238,7 @@ async def m007_set_invoice_expiries(db):
invoice.date + invoice.expiry
)
logger.info(
f"Mirgraion: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
f"Migration: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
)
await db.execute(
"""

View file

@ -4,13 +4,13 @@ import hmac
import json
import time
from sqlite3 import Row
from typing import Dict, List, NamedTuple, Optional
from typing import Dict, List, Optional
from ecdsa import SECP256k1, SigningKey # type: ignore
from fastapi import Query
from lnurl import encode as lnurl_encode # type: ignore
from loguru import logger
from pydantic import BaseModel, Extra, validator
from pydantic import BaseModel
from lnbits.db import Connection
from lnbits.helpers import url_for

View file

@ -1,6 +1,5 @@
import asyncio
import json
from binascii import unhexlify
from io import BytesIO
from typing import Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse
@ -13,12 +12,7 @@ from loguru import logger
from lnbits import bolt11
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
get_key_type,
require_admin_key,
require_invoice_key,
)
from lnbits.decorators import WalletTypeInfo, require_admin_key
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.requestvars import g
from lnbits.settings import (
@ -308,7 +302,7 @@ async def perform_lnurlauth(
) -> Optional[LnurlErrorResponse]:
cb = urlparse(callback)
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0])
key = wallet.wallet.lnurlauth_key(cb.netloc)

View file

@ -4,7 +4,6 @@ from typing import Dict
import httpx
from loguru import logger
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import SseListenersDict, register_invoice_listener
from . import db

View file

@ -231,7 +231,7 @@
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="props.row.bolt11"
:value="'lightning:' + props.row.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
@ -325,7 +325,7 @@
</p>
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
<qrcode
value="{{wallet.lnurlwithdraw_full}}"
value="lightning:{{wallet.lnurlwithdraw_full}}"
:options="{width:240}"
></qrcode>
</a>
@ -524,7 +524,7 @@
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="receive.paymentReq"
:value="'lightning:' + receive.paymentReq.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>

View file

@ -1,5 +1,4 @@
import asyncio
import binascii
import hashlib
import json
import time
@ -38,7 +37,7 @@ from lnbits.decorators import (
require_admin_key,
require_invoice_key,
)
from lnbits.helpers import url_for, urlsafe_short_hash
from lnbits.helpers import url_for
from lnbits.settings import get_wallet_class, settings
from lnbits.utils.exchange_rates import (
currencies,
@ -48,14 +47,11 @@ from lnbits.utils.exchange_rates import (
from .. import core_app, db
from ..crud import (
create_payment,
get_payments,
get_standalone_payment,
get_total_balance,
get_wallet,
get_wallet_for_key,
save_balance_check,
update_payment_status,
update_wallet,
)
from ..services import (
@ -71,6 +67,11 @@ from ..services import (
from ..tasks import api_invoice_listeners
@core_app.get("/api/v1/health", status_code=HTTPStatus.OK)
async def health():
return
@core_app.get("/api/v1/wallet")
async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
if wallet.wallet_type == 0:
@ -140,16 +141,14 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
if data.description_hash or data.unhashed_description:
try:
description_hash = (
binascii.unhexlify(data.description_hash)
if data.description_hash
else b""
bytes.fromhex(data.description_hash) if data.description_hash else b""
)
unhashed_description = (
binascii.unhexlify(data.unhashed_description)
bytes.fromhex(data.unhashed_description)
if data.unhashed_description
else b""
)
except binascii.Error:
except ValueError:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
@ -659,7 +658,7 @@ async def img(request: Request, data):
)
@core_app.get("/api/v1/audit/", dependencies=[Depends(check_admin)])
@core_app.get("/api/v1/audit", dependencies=[Depends(check_admin)])
async def api_auditor():
WALLET = get_wallet_class()
total_balance = await get_total_balance()

View file

@ -38,7 +38,7 @@ async def favicon():
@core_html_routes.get("/", response_class=HTMLResponse)
async def home(request: Request, lightning: str = None):
async def home(request: Request, lightning: str = ""):
return template_renderer().TemplateResponse(
"core/index.html", {"request": request, "lnurl": lightning}
)
@ -124,12 +124,15 @@ async def wallet(
if (
len(settings.lnbits_allowed_users) > 0
and user_id not in settings.lnbits_allowed_users
and user_id not in settings.lnbits_admin_users
and user_id != settings.super_user
):
return template_renderer().TemplateResponse(
"error.html", {"request": request, "err": "User not authorized."}
)
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
user.admin = True
if not wallet_id:
if user.wallets and not wallet_name: # type: ignore
wallet = user.wallets[0] # type: ignore

View file

@ -6,7 +6,6 @@ from urllib.parse import urlparse
from fastapi import HTTPException
from loguru import logger
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits import bolt11

View file

@ -236,8 +236,8 @@ async def check_user_exists(usr: UUID4) -> User:
if (
len(settings.lnbits_allowed_users) > 0
and g().user.id not in settings.lnbits_allowed_users
and g().user.id != settings.super_user
and g().user.id not in settings.lnbits_admin_users
and g().user.id != settings.super_user
):
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."

View file

@ -2,7 +2,6 @@ import base64
import hashlib
import hmac
import urllib
from binascii import unhexlify
from http import HTTPStatus
from typing import Dict
@ -19,7 +18,7 @@ def generate_bleskomat_lnurl_signature(
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
):
if api_key_encoding == "hex":
key = unhexlify(api_key_secret)
key = bytes.fromhex(api_key_secret)
elif api_key_encoding == "base64":
key = base64.b64decode(api_key_secret)
else:

View file

@ -9,11 +9,13 @@
<p>
This extension allows you to connect a Bleskomat ATM to an lnbits
wallet. It will work with both the
<a href="https://github.com/samotari/bleskomat"
<a class="text-secondary" href="https://github.com/samotari/bleskomat"
>open-source DIY Bleskomat ATM project</a
>
as well as the
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
<a class="text-secondary" href="https://www.bleskomat.com/"
>commercial Bleskomat ATM</a
>.
</p>
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
<div>

View file

@ -2,7 +2,7 @@
This extension allows you to link your Bolt Card (or other compatible NXP NTAG device) with a LNbits instance and use it in a more secure way than a static LNURLw. A technology called [Secure Unique NFC](https://mishka-scan.com/blog/secure-unique-nfc) is utilized in this workflow.
<a href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
<a class="text-secondary" href="https://www.youtube.com/watch?v=wJ7QLFTRjK0">Tutorial</a>
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
@ -55,6 +55,8 @@ Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys
- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY!
- Now if there is all success the card can be safely delete from LNbits (but keep the keys backuped anyway; batter safe than brick).
If you somehow find yourself in some non-standard state (for instance only k3 and k4 remains filled after previous unsuccessful reset), then you need edit the key fields manually (for instance leave k0-k2 to zeroes and provide the right k3 and k4).
## Setting the card - computer (hard way)
Follow the guide.

View file

@ -11,6 +11,7 @@
Manage your Bolt Cards self custodian way<br />
<a
class="text-secondary"
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards"
>More details</a
>

View file

@ -375,6 +375,7 @@
<p class="text-center" v-show="!qrCodeDialog.wipe">
(QR for <strong>create</strong> the card in
<a
class="text-secondary"
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
target="_blank"
style="color: inherit"
@ -395,6 +396,7 @@
<p class="text-center" v-show="qrCodeDialog.wipe">
(QR for <strong>wipe</strong> the card in
<a
class="text-secondary"
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
target="_blank"
style="color: inherit"

View file

@ -147,7 +147,7 @@ async def api_hits(
@boltcards_ext.get("/api/v1/refunds")
async def api_hits(
async def api_refunds(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]

View file

@ -1,6 +1,5 @@
import asyncio
import os
from binascii import hexlify, unhexlify
from hashlib import sha256
from typing import Awaitable, Union
@ -56,7 +55,7 @@ async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
raise
refund_privkey = ec.PrivateKey(os.urandom(32), True, net)
refund_pubkey_hex = hexlify(refund_privkey.sec()).decode("UTF-8")
refund_pubkey_hex = bytes.hex(refund_privkey.sec()).decode("UTF-8")
res = req_wrap(
"post",
@ -121,7 +120,7 @@ async def create_reverse_swap(
return False
claim_privkey = ec.PrivateKey(os.urandom(32), True, net)
claim_pubkey_hex = hexlify(claim_privkey.sec()).decode("UTF-8")
claim_pubkey_hex = bytes.hex(claim_privkey.sec()).decode("UTF-8")
preimage = os.urandom(32)
preimage_hash = sha256(preimage).hexdigest()
@ -311,12 +310,12 @@ async def create_onchain_tx(
sequence = 0xFFFFFFFE
else:
privkey = ec.PrivateKey.from_wif(swap.claim_privkey)
preimage = unhexlify(swap.preimage)
preimage = bytes.fromhex(swap.preimage)
onchain_address = swap.onchain_address
sequence = 0xFFFFFFFF
locktime = swap.timeout_block_height
redeem_script = unhexlify(swap.redeem_script)
redeem_script = bytes.fromhex(swap.redeem_script)
fees = get_fee_estimation()
@ -324,7 +323,7 @@ async def create_onchain_tx(
script_pubkey = script.address_to_scriptpubkey(onchain_address)
vin = [TransactionInput(unhexlify(txid), vout_cnt, sequence=sequence)]
vin = [TransactionInput(bytes.fromhex(txid), vout_cnt, sequence=sequence)]
vout = [TransactionOutput(vout_amount - fees, script_pubkey)]
tx = Transaction(vin=vin, vout=vout)

View file

@ -1,6 +1,5 @@
import asyncio
import json
from binascii import hexlify
import httpx
import websockets
@ -84,7 +83,7 @@ def get_mempool_blockheight() -> int:
async def send_onchain_tx(tx: Transaction):
raw = hexlify(tx.serialize())
raw = bytes.hex(tx.serialize())
logger.debug(f"Boltz - mempool sending onchain tx...")
req_wrap(
"post",

View file

@ -24,12 +24,13 @@
</p>
<p>
Link :
<a target="_blank" href="https://boltz.exchange"
<a class="text-secondary" target="_blank" href="https://boltz.exchange"
>https://boltz.exchange
</a>
</p>
<p>
<a
class="text-secondary"
target="_blank"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/boltz"
>More details</a
@ -38,7 +39,12 @@
<p>
<small
>Created by,
<a target="_blank" href="https://github.com/dni">dni</a></small
<a
class="text-secondary"
target="_blank"
href="https://github.com/dni"
>dni</a
></small
>
</p>
</q-card-section>

View file

@ -1,7 +1,6 @@
import os
import random
import time
from binascii import hexlify, unhexlify
from typing import Any, List, Optional, Union
from cashu.core.base import MintKeyset

View file

@ -4,9 +4,24 @@
<p>Create Cashu ecash mints and wallets.</p>
<small
>Created by
<a href="https://github.com/arcbtc" target="_blank">arcbtc</a>,
<a href="https://github.com/motorina0" target="_blank">vlad</a>,
<a href="https://github.com/calle" target="_blank">calle</a>.</small
<a
class="text-secondary"
href="https://github.com/arcbtc"
target="_blank"
>arcbtc</a
>,
<a
class="text-secondary"
href="https://github.com/motorina0"
target="_blank"
>vlad</a
>,
<a
class="text-secondary"
href="https://github.com/calle"
target="_blank"
>calle</a
>.</small
>
</q-card-section>
</q-card>

View file

@ -11,6 +11,7 @@
></q-icon>
<h4 class="q-mt-none q-mb-md">{{ mint_name }}</h4>
<a
class="text-secondary"
class="q-my-xl text-white"
style="font-size: 1.5rem"
href="../wallet?mint_id={{ mint_id }}"
@ -24,7 +25,11 @@
<h5 class="q-my-md">Read the following carefully!</h5>
<p>
This is a
<a href="https://cashu.space/" style="color: white" target="”_blank”"
<a
class="text-secondary"
href="https://cashu.space/"
style="color: white"
target="”_blank”"
>Cashu</a
>
mint. Cashu is an ecash system for Bitcoin.

View file

@ -159,7 +159,7 @@ page_container %}
size="lg"
color="secondary"
class="q-mr-md cursor-pointer"
@click="recheckInvoice(props.row.hash)"
@click="checkInvoice(props.row.hash)"
>
Check
</q-badge>
@ -616,10 +616,10 @@ page_container %}
></q-input>
</div>
<div v-else class="text-center q-mb-lg">
<a :href="'lightning:' + invoiceData.bolt11">
<a class="text-secondary" :href="'lightning:' + invoiceData.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="invoiceData.bolt11"
:value="'lightning:' + invoiceData.bolt11.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
>
@ -681,7 +681,7 @@ page_container %}
</div>
<div v-else class="text-center q-mb-lg">
<div class="text-center q-mb-lg">
<!-- <a :href="'cashu:' + sendData.tokensBase64"> -->
<!-- <a class="text-secondary" :href="'cashu:' + sendData.tokensBase64"> -->
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="disclaimerDialog.base_url + '?mint_id=' + mintId + '&recv_token=' + sendData.tokensBase64"
@ -1528,57 +1528,17 @@ page_container %}
return proofs.reduce((s, t) => (s += t.amount), 0)
},
deleteProofs: function (proofs) {
// delete proofs from this.proofs
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
this.storeProofs()
return this.proofs
},
//////////// API ///////////
clearAllWorkers: function () {
if (this.invoiceCheckListener) {
clearInterval(this.invoiceCheckListener)
}
if (this.tokensCheckSpendableListener) {
clearInterval(this.tokensCheckSpendableListener)
}
},
invoiceCheckWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
this.invoiceCheckListener = setInterval(async () => {
try {
nInterval += 1
// exit loop after 2m
if (nInterval > 40) {
console.log('### stopping invoice check worker')
this.clearAllWorkers()
}
console.log('### invoiceCheckWorker setInterval', nInterval)
console.log(this.invoiceData)
// this will throw an error if the invoice is pending
await this.recheckInvoice(this.invoiceData.hash, false)
// only without error (invoice paid) will we reach here
console.log('### stopping invoice check worker')
this.clearAllWorkers()
this.invoiceData.bolt11 = ''
this.showInvoiceDetails = false
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Payment received',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
// MINT
requestMintButton: async function () {
await this.requestMint()
@ -1586,8 +1546,12 @@ page_container %}
await this.invoiceCheckWorker()
},
// /mint
requestMint: async function () {
// gets an invoice from the mint to get new tokens
/*
gets an invoice from the mint to get new tokens
*/
try {
const {data} = await LNbits.api.request(
'GET',
@ -1611,7 +1575,14 @@ page_container %}
throw error
}
},
// /mint
mintApi: async function (amounts, payment_hash, verbose = true) {
/*
asks the mint to check whether the invoice with payment_hash has been paid
and requests signing of the attached outputs (blindedMessages)
*/
console.log('### promises', payment_hash)
try {
let secrets = await this.generateSecrets(amounts)
@ -1647,7 +1618,19 @@ page_container %}
}
this.proofs = this.proofs.concat(proofs)
this.storeProofs()
// update UI
await this.setInvoicePaid(payment_hash)
tokensBase64 = btoa(JSON.stringify(proofs))
this.historyTokens.push({
status: 'paid',
amount: amount,
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
return proofs
} catch (error) {
console.error(error)
@ -1657,62 +1640,20 @@ page_container %}
throw error
}
},
splitToSend: async function (proofs, amount, invlalidate = false) {
// splits proofs so the user can keep firstProofs, send scndProofs
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Balance too low',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
throw Error('balance too low.')
}
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// set scndProofs in this.proofs as reserved
const usedSecrets = proofs.map(p => p.secret)
for (let i = 0; i < this.proofs.length; i++) {
if (usedSecrets.includes(this.proofs[i].secret)) {
this.proofs[i].reserved = true
}
}
if (invlalidate) {
// delete tokens from db
this.proofs = fristProofs
// add new fristProofs, scndProofs to this.proofs
this.storeProofs()
}
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// SPLIT
split: async function (proofs, amount) {
/*
supplies proofs and requests a split from the mint of these
proofs at a specific amount
*/
try {
if (proofs.length == 0) {
throw new Error('no proofs provided.')
}
let {fristProofs, scndProofs} = await this.splitApi(proofs, amount)
// delete proofs from this.proofs
const usedSecrets = proofs.map(p => p.secret)
this.proofs = this.proofs.filter(p => !usedSecrets.includes(p.secret))
this.deleteProofs(proofs)
// add new fristProofs, scndProofs to this.proofs
this.proofs = this.proofs.concat(fristProofs).concat(scndProofs)
this.storeProofs()
@ -1723,6 +1664,9 @@ page_container %}
throw error
}
},
// /split
splitApi: async function (proofs, amount) {
try {
const total = this.sumProofs(proofs)
@ -1782,7 +1726,62 @@ page_container %}
}
},
splitToSend: async function (proofs, amount, invlalidate = false) {
/*
splits proofs so the user can keep firstProofs, send scndProofs.
then sets scndProofs as reserved.
if invalidate, scndProofs (the one to send) are invalidated
*/
try {
const spendableProofs = proofs.filter(p => !p.reserved)
if (this.sumProofs(spendableProofs) < amount) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Balance too low',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
throw Error('balance too low.')
}
// call /split
let {fristProofs, scndProofs} = await this.split(
spendableProofs,
amount
)
// set scndProofs in this.proofs as reserved
const usedSecrets = proofs.map(p => p.secret)
for (let i = 0; i < this.proofs.length; i++) {
if (usedSecrets.includes(this.proofs[i].secret)) {
this.proofs[i].reserved = true
}
}
if (invlalidate) {
// delete scndProofs from db
this.deleteProofs(scndProofs)
}
return {fristProofs, scndProofs}
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
redeem: async function () {
/*
uses split to receive new tokens.
*/
this.showReceiveTokens = false
console.log('### receive tokens', this.receiveData.tokensBase64)
try {
@ -1793,6 +1792,9 @@ page_container %}
const proofs = JSON.parse(tokenJson)
const amount = proofs.reduce((s, t) => (s += t.amount), 0)
let {fristProofs, scndProofs} = await this.split(proofs, amount)
// update UI
// HACK: we need to do this so the balance updates
this.proofs = this.proofs.concat([])
@ -1827,13 +1829,18 @@ page_container %}
},
sendTokens: async function () {
/*
calls splitToSend, displays token and kicks off the spendableWorker
*/
try {
// keep firstProofs, send scndProofs
// keep firstProofs, send scndProofs and delete them (invalidate=true)
let {fristProofs, scndProofs} = await this.splitToSend(
this.proofs,
this.sendData.amount,
true
)
// update UI
this.sendData.tokens = scndProofs
console.log('### this.sendData.tokens', this.sendData.tokens)
this.sendData.tokensBase64 = btoa(
@ -1846,33 +1853,19 @@ page_container %}
date: currentDateStr(),
token: this.sendData.tokensBase64
})
// store "pending" outgoing tokens in history table
this.storehistoryTokens()
this.checkTokenSpendableWorker()
} catch (error) {
console.error(error)
throw error
}
},
checkFees: async function (payment_request) {
const payload = {
pr: payment_request
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/checkfees`,
'',
payload
)
console.log('#### checkFees', payment_request, data.fee)
return data.fee
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /melt
melt: async function () {
// todo: get fees from server and add to inputs
this.payInvoiceData.blocking = true
@ -1924,8 +1917,20 @@ page_container %}
]
})
// delete spent tokens from db
this.proofs = fristProofs
this.storeProofs()
this.deleteProofs(scndProofs)
// update UI
tokensBase64 = btoa(JSON.stringify(scndProofs))
this.historyTokens.push({
status: 'paid',
amount: -amount,
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
console.log({
amount: -amount,
bolt11: this.payInvoiceData.data.request,
@ -1953,13 +1958,95 @@ page_container %}
throw error
}
},
// /check
checkProofsSpendable: async function (proofs, update_history = false) {
/*
checks with the mint whether an array of proofs is still
spendable or already invalidated
*/
const payload = {
proofs: proofs.flat()
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/check`,
'',
payload
)
// delete proofs from database if it is spent
let spentProofs = proofs.filter((p, pidx) => !data[pidx])
if (spentProofs.length) {
this.deleteProofs(spentProofs)
// update UI
if (update_history) {
tokensBase64 = btoa(JSON.stringify(spentProofs))
this.historyTokens.push({
status: 'paid',
amount: -this.sumProofs(spentProofs),
date: currentDateStr(),
token: tokensBase64
})
this.storehistoryTokens()
}
}
return data
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /checkfees
checkFees: async function (payment_request) {
const payload = {
pr: payment_request
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/checkfees`,
'',
payload
)
console.log('#### checkFees', payment_request, data.fee)
return data.fee
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
// /keys
fetchMintKeys: async function () {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/keys`
)
this.keys = data
localStorage.setItem(
this.mintKey(this.mintId, 'keys'),
JSON.stringify(data)
)
},
setInvoicePaid: async function (payment_hash) {
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
invoice.status = 'paid'
this.storeinvoicesCashu()
},
recheckInvoice: async function (payment_hash, verbose = true) {
console.log('### recheckInvoice.hash', payment_hash)
checkInvoice: async function (payment_hash, verbose = true) {
console.log('### checkInvoice.hash', payment_hash)
const invoice = this.invoicesCashu.find(i => i.hash === payment_hash)
try {
proofs = await this.mint(invoice.amount, invoice.hash, verbose)
@ -1969,15 +2056,15 @@ page_container %}
throw error
}
},
recheckPendingInvoices: async function () {
checkPendingInvoices: async function () {
for (const invoice of this.invoicesCashu) {
if (invoice.status === 'pending' && invoice.sat > 0) {
this.recheckInvoice(invoice.hash, false)
if (invoice.status === 'pending' && invoice.amount > 0) {
this.checkInvoice(invoice.hash, false)
}
}
},
recheckPendingTokens: async function () {
checkPendingTokens: async function () {
for (const token of this.historyTokens) {
if (token.status === 'pending' && token.amount < 0) {
this.checkTokenSpendable(token.token, false)
@ -1990,6 +2077,113 @@ page_container %}
this.storehistoryTokens()
},
checkTokenSpendable: async function (token, verbose = true) {
/*
checks whether a base64-encoded token (from the history table) has been spent already.
if it is spent, the appropraite entry in the history table is set to paid.
*/
const tokenJson = atob(token)
const proofs = JSON.parse(tokenJson)
let data = await this.checkProofsSpendable(proofs)
// iterate through response of form {0: true, 1: false, ...}
let paid = false
for (const [key, spendable] of Object.entries(data)) {
if (!spendable) {
this.setTokenPaid(token)
paid = true
}
}
if (paid) {
console.log('### token paid')
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Token paid',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} else {
console.log('### token not paid yet')
if (verbose) {
this.$q.notify({
timeout: 5000,
color: 'grey',
message: 'Token still pending',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
}
this.sendData.tokens = token
}
return paid
},
////////////// WORKERS //////////////
clearAllWorkers: function () {
if (this.invoiceCheckListener) {
clearInterval(this.invoiceCheckListener)
}
if (this.tokensCheckSpendableListener) {
clearInterval(this.tokensCheckSpendableListener)
}
},
invoiceCheckWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
this.invoiceCheckListener = setInterval(async () => {
try {
nInterval += 1
// exit loop after 2m
if (nInterval > 40) {
console.log('### stopping invoice check worker')
this.clearAllWorkers()
}
console.log('### invoiceCheckWorker setInterval', nInterval)
console.log(this.invoiceData)
// this will throw an error if the invoice is pending
await this.checkInvoice(this.invoiceData.hash, false)
// only without error (invoice paid) will we reach here
console.log('### stopping invoice check worker')
this.clearAllWorkers()
this.invoiceData.bolt11 = ''
this.showInvoiceDetails = false
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Payment received',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} catch (error) {
console.log('not paid yet')
}
}, 3000)
},
checkTokenSpendableWorker: async function () {
let nInterval = 0
this.clearAllWorkers()
@ -2021,83 +2215,6 @@ page_container %}
}, 3000)
},
checkTokenSpendable: async function (token, verbose = true) {
const tokenJson = atob(token)
const proofs = JSON.parse(tokenJson)
const payload = {
proofs: proofs.flat()
}
console.log('#### payload', JSON.stringify(payload))
try {
const {data} = await LNbits.api.request(
'POST',
`/cashu/api/v1/${this.mintId}/check`,
'',
payload
)
// iterate through response of form {0: true, 1: false, ...}
let paid = false
for (const [key, spendable] of Object.entries(data)) {
if (!spendable) {
this.setTokenPaid(token)
paid = true
}
}
if (paid) {
console.log('### token paid')
if (window.navigator.vibrate) navigator.vibrate(200)
this.$q.notify({
timeout: 5000,
type: 'positive',
message: 'Token paid',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
} else {
console.log('### token not paid yet')
if (verbose) {
this.$q.notify({
timeout: 5000,
color: 'grey',
message: 'Token still pending',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
}
this.sendData.tokens = token
}
return paid
} catch (error) {
console.error(error)
LNbits.utils.notifyApiError(error)
throw error
}
},
fetchMintKeys: async function () {
const {data} = await LNbits.api.request(
'GET',
`/cashu/api/v1/${this.mintId}/keys`
)
this.keys = data
localStorage.setItem(
this.mintKey(this.mintId, 'keys'),
JSON.stringify(data)
)
},
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -2116,62 +2233,62 @@ page_container %}
}
},
checkInvoice: function () {
console.log('#### checkInvoice')
try {
const invoice = decode(this.payInvoiceData.data.request)
// checkInvoice: function () {
// console.log('#### checkInvoice')
// try {
// const invoice = decode(this.payInvoiceData.data.request)
const cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(
invoice.human_readable_part.amount / 1000
)
}
// const cleanInvoice = {
// msat: invoice.human_readable_part.amount,
// sat: invoice.human_readable_part.amount / 1000,
// fsat: LNbits.utils.formatSat(
// invoice.human_readable_part.amount / 1000
// )
// }
_.each(invoice.data.tags, tag => {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value
} else if (tag.description === 'description') {
cleanInvoice.description = tag.value
} else if (tag.description === 'expiry') {
var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
}
}
// _.each(invoice.data.tags, tag => {
// if (_.isObject(tag) && _.has(tag, 'description')) {
// if (tag.description === 'payment_hash') {
// cleanInvoice.hash = tag.value
// } else if (tag.description === 'description') {
// cleanInvoice.description = tag.value
// } else if (tag.description === 'expiry') {
// var expireDate = new Date(
// (invoice.data.time_stamp + tag.value) * 1000
// )
// cleanInvoice.expireDate = Quasar.utils.date.formatDate(
// expireDate,
// 'YYYY-MM-DDTHH:mm:ss.SSSZ'
// )
// cleanInvoice.expired = false // TODO
// }
// }
this.payInvoiceData.invoice = cleanInvoice
})
// this.payInvoiceData.invoice = cleanInvoice
// })
console.log(
'#### this.payInvoiceData.invoice',
this.payInvoiceData.invoice
)
} catch (error) {
this.$q.notify({
timeout: 5000,
type: 'warning',
message: 'Could not decode invoice',
caption: error + '',
position: 'top',
actions: [
{
icon: 'close',
color: 'white',
handler: () => {}
}
]
})
throw error
}
},
// console.log(
// '#### this.payInvoiceData.invoice',
// this.payInvoiceData.invoice
// )
// } catch (error) {
// this.$q.notify({
// timeout: 5000,
// type: 'warning',
// message: 'Could not decode invoice',
// caption: error + '',
// position: 'top',
// actions: [
// {
// icon: 'close',
// color: 'white',
// handler: () => {}
// }
// ]
// })
// throw error
// }
// },
////////////// STORAGE /////////////
@ -2335,8 +2452,9 @@ page_container %}
console.log('#### this.mintId', this.mintId)
console.log('#### this.mintName', this.mintName)
this.recheckPendingInvoices()
this.recheckPendingTokens()
this.checkProofsSpendable(this.proofs, true)
this.checkPendingInvoices()
this.checkPendingTokens()
}
})
</script>

View file

@ -46,9 +46,16 @@ from .models import Cashu
# --------- extension imports
# WARNING: Do not set this to False in production! This will create
# tokens for free otherwise. This is for testing purposes only!
LIGHTNING = True
if not LIGHTNING:
logger.warning(
"Cashu: LIGHTNING is set False! That means that I will create ecash for free!"
)
########################################
############### LNBITS MINTS ###########
########################################
@ -130,6 +137,28 @@ async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
return ledger.get_keyset(keyset_id=cashu.keyset_id)
@cashu_ext.get("/api/v1/{cashu_id}/keys/{idBase64Urlsafe}")
async def keyset_keys(
cashu_id: str = Query(None), idBase64Urlsafe: str = Query(None)
) -> dict[int, str]:
"""
Get the public keys of the mint of a specificy keyset id.
The id is encoded in base64_urlsafe and needs to be converted back to
normal base64 before it can be processed.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
id = idBase64Urlsafe.replace("-", "+").replace("_", "/")
keyset = ledger.get_keyset(keyset_id=id)
return keyset
@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK)
async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]:
"""Get the public keys of the mint"""
@ -182,7 +211,7 @@ async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintR
@cashu_ext.post("/api/v1/{cashu_id}/mint")
async def mint_coins(
async def mint(
data: MintRequest,
cashu_id: str = Query(None),
payment_hash: str = Query(None),
@ -197,6 +226,8 @@ async def mint_coins(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
keyset = ledger.keysets.keysets[cashu.keyset_id]
if LIGHTNING:
invoice: Invoice = await ledger.crud.get_lightning_invoice(
db=ledger.db, hash=payment_hash
@ -206,42 +237,55 @@ async def mint_coins(
status_code=HTTPStatus.NOT_FOUND,
detail="Mint does not know this invoice.",
)
if invoice.issued == True:
if invoice.issued:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail="Tokens already issued for this invoice.",
)
total_requested = sum([bm.amount for bm in data.blinded_messages])
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
if LIGHTNING and status.paid != True:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
try:
keyset = ledger.keysets.keysets[cashu.keyset_id]
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
assert len(promises), HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="No promises returned."
)
# set this invoice as issued
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=True
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, payment_hash
)
try:
total_requested = sum([bm.amount for bm in data.blinded_messages])
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
if not status.paid:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
return promises
except (Exception, HTTPException) as e:
logger.debug(f"Cashu: /melt {str(e) or getattr(e, 'detail')}")
# unset issued flag because something went wrong
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=False
)
raise HTTPException(
status_code=getattr(e, "status_code")
or HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e) or getattr(e, "detail"),
)
else:
# only used for testing when LIGHTNING=false
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
return promises
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@cashu_ext.post("/api/v1/{cashu_id}/melt")
@ -285,28 +329,38 @@ async def melt_coins(
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
)
logger.debug(f"Cashu: Initiating payment of {total_provided} sats")
await pay_invoice(
wallet_id=cashu.wallet,
payment_request=invoice,
description=f"Pay cashu invoice",
extra={"tag": "cashu", "cashu_name": cashu.name},
)
logger.debug(
f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}"
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, invoice_obj.payment_hash
)
if status.paid == True:
logger.debug("Cashu: Payment successful, invalidating proofs")
await ledger._invalidate_proofs(proofs)
try:
await pay_invoice(
wallet_id=cashu.wallet,
payment_request=invoice,
description=f"Pay cashu invoice",
extra={"tag": "cashu", "cashu_name": cashu.name},
)
except Exception as e:
logger.debug(f"Cashu error paying invoice {invoice_obj.payment_hash}: {e}")
raise e
finally:
logger.debug(
f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}"
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, invoice_obj.payment_hash
)
if status.paid == True:
logger.debug(
f"Cashu: Payment successful, invalidating proofs for {invoice_obj.payment_hash}"
)
await ledger._invalidate_proofs(proofs)
else:
logger.debug(f"Cashu: Payment failed for {invoice_obj.payment_hash}")
except Exception as e:
logger.debug(f"Cashu: Exception for {invoice_obj.payment_hash}: {str(e)}")
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Cashu: {str(e)}",
)
finally:
logger.debug(f"Cashu: Unset pending for {invoice_obj.payment_hash}")
# delete proofs from pending list
await ledger._unset_proofs_pending(proofs)

View file

@ -4,7 +4,10 @@
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
animation<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
Created by,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
></small
>
</p>
</q-card-section>

View file

@ -25,7 +25,7 @@
>
<div class="col">
<qrcode
:value="copilot.lnurl"
:value="'lightning:' + copilot.lnurl"
:options="{width:250}"
class="rounded-borders"
></qrcode>

View file

@ -11,18 +11,24 @@
</h5>
<p>
Connect your LNbits instance to a
<a href="https://github.com/chrislennon/lnbits-discord-bot"
<a
class="text-secondary"
href="https://github.com/chrislennon/lnbits-discord-bot"
>Discord Bot</a
>
leveraging LNbits as a community based lightning node.<br />
<small>
Created by,
<a href="https://github.com/chrislennon">Chris Lennon</a></small
<a class="text-secondary" href="https://github.com/chrislennon"
>Chris Lennon</a
></small
>
<br />
<small>
Based on User Manager, by
<a href="https://github.com/benarc">Ben Arc</a></small
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
></small
>
</p>
</q-card-section>

View file

@ -8,6 +8,7 @@
This extension is designed to be used through its API by a Discord Bot,
currently you have to install the bot
<a
class="text-secondary"
href="https://github.com/chrislennon/lnbits-discord-bot/#installation"
>yourself</a
><br />

View file

@ -15,7 +15,8 @@
Events comes with a shareable ticket scanner, which can be used to
register attendees.<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a>
Created by,
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>
</small>
</p>
</q-card-section>

View file

@ -64,10 +64,10 @@
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="paymentReq"
:value="'lightning:' + receive.paymentReq.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>

View file

@ -0,0 +1,13 @@
# Gerty
## Your desktop bitcoin assistant
Buy here `<link>`
blah blah blah
### Usage
1. Enable extension
2. Fill out form
3. point gerty at the server and give it the Gerty ID

View file

@ -0,0 +1,30 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_gerty")
gerty_static_files = [
{
"path": "/gerty/static",
"app": StaticFiles(packages=[("lnbits", "extensions/gerty/static")]),
"name": "gerty_static",
}
]
gerty_ext: APIRouter = APIRouter(prefix="/gerty", tags=["Gerty"])
def gerty_renderer():
return template_renderer(["lnbits/extensions/gerty/templates"])
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Gerty",
"short_description": "Desktop bitcoin Assistant",
"icon": "sentiment_satisfied",
"contributors": ["arcbtc", "blackcoffeebtc"]
}

View file

@ -0,0 +1,137 @@
import json
import time
from typing import List, Optional, Union
import httpx
from loguru import logger
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Gerty, Mempool, MempoolEndpoint
async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
gerty_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO gerty.gertys (
id,
name,
utc_offset,
type,
wallet,
lnbits_wallets,
mempool_endpoint,
exchange,
display_preferences,
refresh_time,
urls
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
gerty_id,
data.name,
data.utc_offset,
data.type,
wallet_id,
data.lnbits_wallets,
data.mempool_endpoint,
data.exchange,
data.display_preferences,
data.refresh_time,
data.urls,
),
)
gerty = await get_gerty(gerty_id)
assert gerty, "Newly created gerty couldn't be retrieved"
return gerty
async def update_gerty(gerty_id: str, **kwargs) -> Gerty:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE gerty.gertys SET {q} WHERE id = ?", (*kwargs.values(), gerty_id)
)
return await get_gerty(gerty_id)
async def get_gerty(gerty_id: str) -> Optional[Gerty]:
row = await db.fetchone("SELECT * FROM gerty.gertys WHERE id = ?", (gerty_id,))
return Gerty(**row) if row else None
async def get_gertys(wallet_ids: Union[str, List[str]]) -> List[Gerty]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM gerty.gertys WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Gerty(**row) for row in rows]
async def delete_gerty(gerty_id: str) -> None:
await db.execute("DELETE FROM gerty.gertys WHERE id = ?", (gerty_id,))
#############MEMPOOL###########
async def get_mempool_info(endPoint: str, gerty) -> Optional[Mempool]:
logger.debug(endPoint)
endpoints = MempoolEndpoint()
url = ""
for endpoint in endpoints:
if endPoint == endpoint[0]:
url = endpoint[1]
row = await db.fetchone(
"SELECT * FROM gerty.mempool WHERE endpoint = ? AND mempool_endpoint = ?",
(
endPoint,
gerty.mempool_endpoint,
),
)
if not row:
async with httpx.AsyncClient() as client:
response = await client.get(gerty.mempool_endpoint + url)
logger.debug(gerty.mempool_endpoint + url)
mempool_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO gerty.mempool (
id,
data,
endpoint,
time,
mempool_endpoint
)
VALUES (?, ?, ?, ?, ?)
""",
(
mempool_id,
json.dumps(response.json()),
endPoint,
int(time.time()),
gerty.mempool_endpoint,
),
)
return response.json()
if int(time.time()) - row.time > 20:
async with httpx.AsyncClient() as client:
response = await client.get(gerty.mempool_endpoint + url)
await db.execute(
"UPDATE gerty.mempool SET data = ?, time = ? WHERE endpoint = ? AND mempool_endpoint = ?",
(
json.dumps(response.json()),
int(time.time()),
endPoint,
gerty.mempool_endpoint,
),
)
return response.json()
return json.loads(row.data)

View file

@ -0,0 +1,951 @@
import json
import os
import random
import textwrap
from datetime import datetime, timedelta
import httpx
from loguru import logger
from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.settings import settings
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
from .crud import get_gerty, get_mempool_info
from .number_prefixer import *
def get_percent_difference(current, previous, precision=3):
difference = (current - previous) / current * 100
return "{0}{1}%".format("+" if difference > 0 else "", round(difference, precision))
# A helper function get a nicely formated dict for the text
def get_text_item_dict(
text: str,
font_size: int,
x_pos: int = None,
y_pos: int = None,
gerty_type: str = "Gerty",
):
# Get line size by font size
line_width = 20
if font_size <= 12:
line_width = 60
elif font_size <= 15:
line_width = 45
elif font_size <= 20:
line_width = 35
elif font_size <= 40:
line_width = 25
# Get font sizes for Gerty mini
if gerty_type.lower() == "mini gerty":
if font_size <= 12:
font_size = 1
if font_size <= 15:
font_size = 1
elif font_size <= 20:
font_size = 2
elif font_size <= 40:
font_size = 2
else:
font_size = 5
# wrap the text
wrapper = textwrap.TextWrapper(width=line_width)
word_list = wrapper.wrap(text=text)
# logger.debug("number of chars = {0}".format(len(text)))
multilineText = "\n".join(word_list)
# logger.debug("number of lines = {0}".format(len(word_list)))
# logger.debug('multilineText')
# logger.debug(multilineText)
text = {"value": multilineText, "size": font_size}
if x_pos is None and y_pos is None:
text["position"] = "center"
else:
text["x"] = x_pos
text["y"] = y_pos
return text
# format a number for nice display output
def format_number(number, precision=None):
return "{:,}".format(round(number, precision))
async def get_mining_dashboard(gerty):
areas = []
if isinstance(gerty.mempool_endpoint, str):
# current hashrate
r = await get_mempool_info("hashrate_1w", gerty)
data = r
hashrateNow = data["currentHashrate"]
hashrateOneWeekAgo = data["hashrates"][6]["avgHashrate"]
text = []
text.append(
get_text_item_dict(
text="Current mining hashrate", font_size=12, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text="{0}hash".format(si_format(hashrateNow, 6, True, " ")),
font_size=20,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="{0} vs 7 days ago".format(
get_percent_difference(hashrateNow, hashrateOneWeekAgo, 3)
),
font_size=12,
gerty_type=gerty.type,
)
)
areas.append(text)
r = await get_mempool_info("difficulty_adjustment", gerty)
# timeAvg
text = []
progress = "{0}%".format(round(r["progressPercent"], 2))
text.append(
get_text_item_dict(
text="Progress through current epoch",
font_size=12,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(text=progress, font_size=60, gerty_type=gerty.type)
)
areas.append(text)
# difficulty adjustment
text = []
stat = r["remainingTime"]
text.append(
get_text_item_dict(
text="Time to next difficulty adjustment",
font_size=12,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text=get_time_remaining(stat / 1000, 3),
font_size=12,
gerty_type=gerty.type,
)
)
areas.append(text)
# difficultyChange
text = []
difficultyChange = round(r["difficultyChange"], 2)
text.append(
get_text_item_dict(
text="Estimated difficulty change",
font_size=12,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="{0}{1}%".format(
"+" if difficultyChange > 0 else "", round(difficultyChange, 2)
),
font_size=60,
gerty_type=gerty.type,
)
)
areas.append(text)
r = await get_mempool_info("hashrate_1m", gerty)
data = r
stat = {}
stat["current"] = data["currentDifficulty"]
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2]["difficulty"]
return areas
async def get_lightning_stats(gerty):
data = await get_mempool_info("statistics", gerty)
areas = []
text = []
text.append(
get_text_item_dict(text="Channel Count", font_size=12, gerty_type=gerty.type)
)
text.append(
get_text_item_dict(
text=format_number(data["latest"]["channel_count"]),
font_size=20,
gerty_type=gerty.type,
)
)
difference = get_percent_difference(
current=data["latest"]["channel_count"],
previous=data["previous"]["channel_count"],
)
text.append(
get_text_item_dict(
text="{0} in last 7 days".format(difference),
font_size=12,
gerty_type=gerty.type,
)
)
areas.append(text)
text = []
text.append(
get_text_item_dict(text="Number of Nodes", font_size=12, gerty_type=gerty.type)
)
text.append(
get_text_item_dict(
text=format_number(data["latest"]["node_count"]),
font_size=20,
gerty_type=gerty.type,
)
)
difference = get_percent_difference(
current=data["latest"]["node_count"], previous=data["previous"]["node_count"]
)
text.append(
get_text_item_dict(
text="{0} in last 7 days".format(difference),
font_size=12,
gerty_type=gerty.type,
)
)
areas.append(text)
text = []
text.append(
get_text_item_dict(text="Total Capacity", font_size=12, gerty_type=gerty.type)
)
avg_capacity = float(data["latest"]["total_capacity"]) / float(100000000)
text.append(
get_text_item_dict(
text="{0} BTC".format(format_number(avg_capacity, 2)),
font_size=20,
gerty_type=gerty.type,
)
)
difference = get_percent_difference(
current=data["latest"]["total_capacity"],
previous=data["previous"]["total_capacity"],
)
text.append(
get_text_item_dict(
text="{0} in last 7 days".format(difference),
font_size=12,
gerty_type=gerty.type,
)
)
areas.append(text)
text = []
text.append(
get_text_item_dict(
text="Average Channel Capacity", font_size=12, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text="{0} sats".format(format_number(data["latest"]["avg_capacity"])),
font_size=20,
gerty_type=gerty.type,
)
)
difference = get_percent_difference(
current=data["latest"]["avg_capacity"],
previous=data["previous"]["avg_capacity"],
)
text.append(
get_text_item_dict(
text="{0} in last 7 days".format(difference),
font_size=12,
gerty_type=gerty.type,
)
)
areas.append(text)
return areas
def get_next_update_time(sleep_time_seconds: int = 0, utc_offset: int = 0):
utc_now = datetime.utcnow()
next_refresh_time = utc_now + timedelta(0, sleep_time_seconds)
local_refresh_time = next_refresh_time + timedelta(hours=utc_offset)
return "{0} {1}".format(
"I'll wake up at" if gerty_should_sleep(utc_offset) else "Next update at",
local_refresh_time.strftime("%H:%M on %e %b %Y"),
)
def gerty_should_sleep(utc_offset: int = 0):
utc_now = datetime.utcnow()
local_time = utc_now + timedelta(hours=utc_offset)
hours = local_time.strftime("%H")
hours = int(hours)
if hours >= 22 and hours <= 23:
return True
else:
return False
async def get_mining_stat(stat_slug: str, gerty):
text = []
if stat_slug == "mining_current_hash_rate":
stat = await api_get_mining_stat(stat_slug, gerty)
current = "{0}hash".format(si_format(stat["current"], 6, True, " "))
text.append(
get_text_item_dict(
text="Current Mining Hashrate", font_size=20, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(text=current, font_size=40, gerty_type=gerty.type)
)
# compare vs previous time period
difference = get_percent_difference(
current=stat["current"], previous=stat["1w"]
)
text.append(
get_text_item_dict(
text="{0} in last 7 days".format(difference),
font_size=12,
gerty_type=gerty.type,
)
)
elif stat_slug == "mining_current_difficulty":
stat = await api_get_mining_stat(stat_slug, gerty)
text.append(
get_text_item_dict(
text="Current Mining Difficulty", font_size=20, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text=format_number(stat["current"]), font_size=40, gerty_type=gerty.type
)
)
difference = get_percent_difference(
current=stat["current"], previous=stat["previous"]
)
text.append(
get_text_item_dict(
text="{0} since last adjustment".format(difference),
font_size=12,
gerty_type=gerty.type,
)
)
# text.append(get_text_item_dict("Required threshold for mining proof-of-work", 12))
return text
async def api_get_mining_stat(stat_slug: str, gerty):
stat = ""
if stat_slug == "mining_current_hash_rate":
async with httpx.AsyncClient() as client:
r = await get_mempool_info("hashrate_1m", gerty)
data = r
stat = {}
stat["current"] = data["currentHashrate"]
stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"]
elif stat_slug == "mining_current_difficulty":
async with httpx.AsyncClient() as client:
r = await get_mempool_info("hashrate_1m", gerty)
data = r
stat = {}
stat["current"] = data["currentDifficulty"]
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2][
"difficulty"
]
return stat
###########################################
async def get_satoshi():
maxQuoteLength = 186
with open(
os.path.join(settings.lnbits_path, "extensions/gerty/static/satoshi.json")
) as fd:
satoshiQuotes = json.load(fd)
quote = satoshiQuotes[random.randint(0, len(satoshiQuotes) - 1)]
# logger.debug(quote.text)
if len(quote["text"]) > maxQuoteLength:
logger.debug("Quote is too long, getting another")
return await get_satoshi()
else:
return quote
# Get a screen slug by its position in the screens_list
def get_screen_slug_by_index(index: int, screens_list):
if index <= len(screens_list) - 1:
return list(screens_list)[index - 1]
else:
return None
# Get a list of text items for the screen number
async def get_screen_data(screen_num: int, screens_list: dict, gerty):
screen_slug = get_screen_slug_by_index(screen_num, screens_list)
# first get the relevant slug from the display_preferences
areas = []
title = ""
if screen_slug == "dashboard":
title = gerty.name
areas = await get_dashboard(gerty)
if screen_slug == "lnbits_wallets_balance":
wallets = await get_lnbits_wallet_balances(gerty)
for wallet in wallets:
text = []
text.append(
get_text_item_dict(
text="{0}'s Wallet".format(wallet["name"]),
font_size=20,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="{0} sats".format(format_number(wallet["balance"])),
font_size=40,
gerty_type=gerty.type,
)
)
areas.append(text)
elif screen_slug == "url_checker":
for url in json.loads(gerty.urls):
async with httpx.AsyncClient() as client:
text = []
try:
response = await client.get(url)
text.append(
get_text_item_dict(
text=url,
font_size=20,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text=str(response.status_code),
font_size=40,
gerty_type=gerty.type,
)
)
except:
text = []
text.append(
get_text_item_dict(
text=url,
font_size=20,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text=str("DOWN"),
font_size=40,
gerty_type=gerty.type,
)
)
areas.append(text)
elif screen_slug == "fun_satoshi_quotes":
areas.append(await get_satoshi_quotes(gerty))
elif screen_slug == "fun_exchange_market_rate":
areas.append(await get_exchange_rate(gerty))
elif screen_slug == "onchain_difficulty_epoch_progress":
areas.append(await get_onchain_stat(screen_slug, gerty))
elif screen_slug == "onchain_block_height":
text = []
text.append(
get_text_item_dict(
text=format_number(await get_mempool_info("tip_height", gerty)),
font_size=80,
gerty_type=gerty.type,
)
)
areas.append(text)
elif screen_slug == "onchain_difficulty_retarget_date":
areas.append(await get_onchain_stat(screen_slug, gerty))
elif screen_slug == "onchain_difficulty_blocks_remaining":
areas.append(await get_onchain_stat(screen_slug, gerty))
elif screen_slug == "onchain_difficulty_epoch_time_remaining":
areas.append(await get_onchain_stat(screen_slug, gerty))
elif screen_slug == "dashboard_onchain":
title = "Onchain Data"
areas = await get_onchain_dashboard(gerty)
elif screen_slug == "mempool_recommended_fees":
areas.append(await get_mempool_stat(screen_slug, gerty))
elif screen_slug == "mempool_tx_count":
areas.append(await get_mempool_stat(screen_slug, gerty))
elif screen_slug == "mining_current_hash_rate":
areas.append(await get_mining_stat(screen_slug, gerty))
elif screen_slug == "mining_current_difficulty":
areas.append(await get_mining_stat(screen_slug, gerty))
elif screen_slug == "dashboard_mining":
title = "Mining Data"
areas = await get_mining_dashboard(gerty)
elif screen_slug == "lightning_dashboard":
title = "Lightning Network"
areas = await get_lightning_stats(gerty)
data = {}
data["title"] = title
data["areas"] = areas
return data
# Get the dashboard screen
async def get_dashboard(gerty):
areas = []
# XC rate
text = []
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
text.append(
get_text_item_dict(
text=format_number(amount), font_size=40, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text="BTC{0} price".format(gerty.exchange),
font_size=15,
gerty_type=gerty.type,
)
)
areas.append(text)
# balance
text = []
wallets = await get_lnbits_wallet_balances(gerty)
text = []
for wallet in wallets:
text.append(
get_text_item_dict(
text="{0}".format(wallet["name"]), font_size=15, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text="{0} sats".format(format_number(wallet["balance"])),
font_size=20,
gerty_type=gerty.type,
)
)
areas.append(text)
# Mempool fees
text = []
text.append(
get_text_item_dict(
text=format_number(await get_mempool_info("tip_height", gerty)),
font_size=40,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="Current block height", font_size=15, gerty_type=gerty.type
)
)
areas.append(text)
# difficulty adjustment time
text = []
text.append(
get_text_item_dict(
text=await get_time_remaining_next_difficulty_adjustment(gerty),
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="until next difficulty adjustment", font_size=12, gerty_type=gerty.type
)
)
areas.append(text)
return areas
async def get_lnbits_wallet_balances(gerty):
# Get Wallet info
wallets = []
if gerty.lnbits_wallets != "":
for lnbits_wallet in json.loads(gerty.lnbits_wallets):
wallet = await get_wallet_for_key(key=lnbits_wallet)
if wallet:
wallets.append(
{
"name": wallet.name,
"balance": wallet.balance_msat / 1000,
"inkey": wallet.inkey,
}
)
return wallets
async def get_placeholder_text():
return [
get_text_item_dict(
text="Some placeholder text",
x_pos=15,
y_pos=10,
font_size=50,
gerty_type=gerty.type,
),
get_text_item_dict(
text="Some placeholder text",
x_pos=15,
y_pos=10,
font_size=50,
gerty_type=gerty.type,
),
]
async def get_satoshi_quotes(gerty):
# Get Satoshi quotes
text = []
quote = await get_satoshi()
if quote:
if quote["text"]:
text.append(
get_text_item_dict(
text=quote["text"], font_size=15, gerty_type=gerty.type
)
)
if quote["date"]:
text.append(
get_text_item_dict(
text="Satoshi Nakamoto - {0}".format(quote["date"]),
font_size=15,
gerty_type=gerty.type,
)
)
return text
# Get Exchange Value
async def get_exchange_rate(gerty):
text = []
if gerty.exchange != "":
try:
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
if amount:
price = format_number(amount)
text.append(
get_text_item_dict(
text="Current {0}/BTC price".format(gerty.exchange),
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(text=price, font_size=80, gerty_type=gerty.type)
)
except:
pass
return text
async def get_onchain_stat(stat_slug: str, gerty):
text = []
if (
stat_slug == "onchain_difficulty_epoch_progress"
or stat_slug == "onchain_difficulty_retarget_date"
or stat_slug == "onchain_difficulty_blocks_remaining"
or stat_slug == "onchain_difficulty_epoch_time_remaining"
):
async with httpx.AsyncClient() as client:
r = await get_mempool_info("difficulty_adjustment", gerty)
if stat_slug == "onchain_difficulty_epoch_progress":
stat = round(r["progressPercent"])
text.append(
get_text_item_dict(
text="Progress through current difficulty epoch",
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="{0}%".format(stat), font_size=80, gerty_type=gerty.type
)
)
elif stat_slug == "onchain_difficulty_retarget_date":
stat = r["estimatedRetargetDate"]
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
text.append(
get_text_item_dict(
text="Date of next difficulty adjustment",
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(text=dt, font_size=40, gerty_type=gerty.type)
)
elif stat_slug == "onchain_difficulty_blocks_remaining":
stat = r["remainingBlocks"]
text.append(
get_text_item_dict(
text="Blocks until next difficulty adjustment",
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="{0}".format(format_number(stat)),
font_size=80,
gerty_type=gerty.type,
)
)
elif stat_slug == "onchain_difficulty_epoch_time_remaining":
stat = r["remainingTime"]
text.append(
get_text_item_dict(
text="Time until next difficulty adjustment",
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text=get_time_remaining(stat / 1000, 4),
font_size=20,
gerty_type=gerty.type,
)
)
return text
async def get_onchain_dashboard(gerty):
areas = []
if isinstance(gerty.mempool_endpoint, str):
async with httpx.AsyncClient() as client:
r = await get_mempool_info("difficulty_adjustment", gerty)
text = []
stat = round(r["progressPercent"])
text.append(
get_text_item_dict(
text="Progress through epoch", font_size=12, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text="{0}%".format(stat), font_size=60, gerty_type=gerty.type
)
)
areas.append(text)
text = []
stat = r["estimatedRetargetDate"]
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
text.append(
get_text_item_dict(
text="Date of next adjustment", font_size=12, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(text=dt, font_size=20, gerty_type=gerty.type)
)
areas.append(text)
text = []
stat = r["remainingBlocks"]
text.append(
get_text_item_dict(
text="Blocks until adjustment", font_size=12, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text="{0}".format(format_number(stat)),
font_size=60,
gerty_type=gerty.type,
)
)
areas.append(text)
text = []
stat = r["remainingTime"]
text.append(
get_text_item_dict(
text="Time until adjustment", font_size=12, gerty_type=gerty.type
)
)
text.append(
get_text_item_dict(
text=get_time_remaining(stat / 1000, 4),
font_size=20,
gerty_type=gerty.type,
)
)
areas.append(text)
return areas
async def get_time_remaining_next_difficulty_adjustment(gerty):
if isinstance(gerty.mempool_endpoint, str):
r = await get_mempool_info("difficulty_adjustment", gerty)
stat = r["remainingTime"]
time = get_time_remaining(stat / 1000, 3)
return time
async def get_mempool_stat(stat_slug: str, gerty):
text = []
if isinstance(gerty.mempool_endpoint, str):
if stat_slug == "mempool_tx_count":
r = get_mempool_info("mempool", gerty)
if stat_slug == "mempool_tx_count":
stat = round(r["count"])
text.append(
get_text_item_dict(
text="Transactions in the mempool",
font_size=15,
gerty_type=gerty.type,
)
)
text.append(
get_text_item_dict(
text="{0}".format(format_number(stat)),
font_size=80,
gerty_type=gerty.type,
)
)
elif stat_slug == "mempool_recommended_fees":
y_offset = 60
fees = await get_mempool_info("fees_recommended", gerty)
pos_y = 80 + y_offset
text.append(get_text_item_dict("mempool.space", 40, 160, pos_y, gerty.type))
pos_y = 180 + y_offset
text.append(
get_text_item_dict("Recommended Tx Fees", 20, 240, pos_y, gerty.type)
)
pos_y = 280 + y_offset
text.append(
get_text_item_dict("{0}".format("None"), 15, 30, pos_y, gerty.type)
)
text.append(
get_text_item_dict("{0}".format("Low"), 15, 235, pos_y, gerty.type)
)
text.append(
get_text_item_dict("{0}".format("Medium"), 15, 460, pos_y, gerty.type)
)
text.append(
get_text_item_dict("{0}".format("High"), 15, 750, pos_y, gerty.type)
)
pos_y = 340 + y_offset
font_size = 15
fee_append = "/vB"
fee_rate = fees["economyFee"]
text.append(
get_text_item_dict(
text="{0} {1}{2}".format(
format_number(fee_rate),
("sat" if fee_rate == 1 else "sats"),
fee_append,
),
font_size=font_size,
x_pos=30,
y_pos=pos_y,
gerty_type=gerty.type,
)
)
fee_rate = fees["hourFee"]
text.append(
get_text_item_dict(
text="{0} {1}{2}".format(
format_number(fee_rate),
("sat" if fee_rate == 1 else "sats"),
fee_append,
),
font_size=font_size,
x_pos=235,
y_pos=pos_y,
gerty_type=gerty.type,
)
)
fee_rate = fees["halfHourFee"]
text.append(
get_text_item_dict(
text="{0} {1}{2}".format(
format_number(fee_rate),
("sat" if fee_rate == 1 else "sats"),
fee_append,
),
font_size=font_size,
x_pos=460,
y_pos=pos_y,
gerty_type=gerty.type,
)
)
fee_rate = fees["fastestFee"]
text.append(
get_text_item_dict(
text="{0} {1}{2}".format(
format_number(fee_rate),
("sat" if fee_rate == 1 else "sats"),
fee_append,
),
font_size=font_size,
x_pos=750,
y_pos=pos_y,
gerty_type=gerty.type,
)
)
return text
def get_date_suffix(dayNumber):
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
return "th"
else:
return ["st", "nd", "rd"][dayNumber % 10 - 1]
def get_time_remaining(seconds, granularity=2):
intervals = (
# ('weeks', 604800), # 60 * 60 * 24 * 7
("days", 86400), # 60 * 60 * 24
("hours", 3600), # 60 * 60
("minutes", 60),
("seconds", 1),
)
result = []
for name, count in intervals:
value = seconds // count
if value:
seconds -= value * count
if value == 1:
name = name.rstrip("s")
result.append("{} {}".format(round(value), name))
return ", ".join(result[:granularity])

View file

@ -0,0 +1,59 @@
async def m001_initial(db):
"""
Initial Gertys table.
"""
await db.execute(
"""
CREATE TABLE gerty.gertys (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
refresh_time INT,
name TEXT NOT NULL,
lnbits_wallets TEXT,
mempool_endpoint TEXT,
exchange TEXT,
display_preferences TEXT
);
"""
)
async def m002_add_utc_offset_col(db):
"""
support for UTC offset
"""
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN utc_offset INT;")
async def m003_add_gerty_model_col(db):
"""
support for Gerty model col
"""
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN type TEXT;")
#########MEMPOOL MIGRATIONS########
async def m004_initial(db):
"""
Initial Gertys table.
"""
await db.execute(
"""
CREATE TABLE gerty.mempool (
id TEXT PRIMARY KEY,
mempool_endpoint TEXT NOT NULL,
endpoint TEXT NOT NULL,
data TEXT NOT NULL,
time TIMESTAMP
);
"""
)
async def m005_add_gerty_model_col(db):
"""
support for Gerty model col
"""
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN urls TEXT;")

View file

@ -0,0 +1,48 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Query
from pydantic import BaseModel
class Gerty(BaseModel):
id: str = Query(None)
name: str
refresh_time: int = Query(None)
utc_offset: int = Query(None)
wallet: str = Query(None)
type: str
lnbits_wallets: str = Query(
None
) # Wallets to keep an eye on, {"wallet-id": "wallet-read-key, etc"}
mempool_endpoint: str = Query(None) # Mempool endpoint to use
exchange: str = Query(
None
) # BTC <-> Fiat exchange rate to pull ie "USD", in 0.0001 and sats
display_preferences: str = Query(None)
urls: str = Query(None)
@classmethod
def from_row(cls, row: Row) -> "Gerty":
return cls(**dict(row))
#########MEMPOOL MODELS###########
class MempoolEndpoint(BaseModel):
fees_recommended: str = "/api/v1/fees/recommended"
hashrate_1w: str = "/api/v1/mining/hashrate/1w"
hashrate_1m: str = "/api/v1/mining/hashrate/1m"
statistics: str = "/api/v1/lightning/statistics/latest"
difficulty_adjustment: str = "/api/v1/difficulty-adjustment"
tip_height: str = "/api/blocks/tip/height"
mempool: str = "/api/mempool"
class Mempool(BaseModel):
id: str = Query(None)
mempool_endpoint: str = Query(None)
endpoint: str = Query(None)
data: str = Query(None)
time: int = Query(None)

View file

@ -0,0 +1,66 @@
import math
def si_classifier(val):
suffixes = {
24: {"long_suffix": "yotta", "short_suffix": "Y", "scalar": 10**24},
21: {"long_suffix": "zetta", "short_suffix": "Z", "scalar": 10**21},
18: {"long_suffix": "exa", "short_suffix": "E", "scalar": 10**18},
15: {"long_suffix": "peta", "short_suffix": "P", "scalar": 10**15},
12: {"long_suffix": "tera", "short_suffix": "T", "scalar": 10**12},
9: {"long_suffix": "giga", "short_suffix": "G", "scalar": 10**9},
6: {"long_suffix": "mega", "short_suffix": "M", "scalar": 10**6},
3: {"long_suffix": "kilo", "short_suffix": "k", "scalar": 10**3},
0: {"long_suffix": "", "short_suffix": "", "scalar": 10**0},
-3: {"long_suffix": "milli", "short_suffix": "m", "scalar": 10**-3},
-6: {"long_suffix": "micro", "short_suffix": "µ", "scalar": 10**-6},
-9: {"long_suffix": "nano", "short_suffix": "n", "scalar": 10**-9},
-12: {"long_suffix": "pico", "short_suffix": "p", "scalar": 10**-12},
-15: {"long_suffix": "femto", "short_suffix": "f", "scalar": 10**-15},
-18: {"long_suffix": "atto", "short_suffix": "a", "scalar": 10**-18},
-21: {"long_suffix": "zepto", "short_suffix": "z", "scalar": 10**-21},
-24: {"long_suffix": "yocto", "short_suffix": "y", "scalar": 10**-24},
}
exponent = int(math.floor(math.log10(abs(val)) / 3.0) * 3)
return suffixes.get(exponent, None)
def si_formatter(value):
"""
Return a triple of scaled value, short suffix, long suffix, or None if
the value cannot be classified.
"""
classifier = si_classifier(value)
if classifier == None:
# Don't know how to classify this value
return None
scaled = value / classifier["scalar"]
return (scaled, classifier["short_suffix"], classifier["long_suffix"])
def si_format(value, precision=4, long_form=False, separator=""):
"""
"SI prefix" formatted string: return a string with the given precision
and an appropriate order-of-3-magnitudes suffix, e.g.:
si_format(1001.0) => '1.00K'
si_format(0.00000000123, long_form=True, separator=' ') => '1.230 nano'
"""
scaled, short_suffix, long_suffix = si_formatter(value)
if scaled == None:
# Don't know how to format this value
return value
suffix = long_suffix if long_form else short_suffix
if abs(scaled) < 10:
precision = precision - 1
elif abs(scaled) < 100:
precision = precision - 2
else:
precision = precision - 3
return "{scaled:.{precision}f}{separator}{suffix}".format(
scaled=scaled, precision=precision, separator=separator, suffix=suffix
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
<q-card-section>
<p>
Gerty (your bitcoin assistant): Use the software Gerty or
<a
class="text-secondary"
target="_blank"
href="https://shop.lnbits.com/product/gerty-a-bitcoin-assistant"
>hardware Gerty</a
><br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/blackcoffeexbt"
>Black Coffee</a
>,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
></small
>
</p>
<a
class="text-secondary"
href="https://shop.lnbits.com/product/gerty-a-bitcoin-assistant"
><img src="/gerty/static/gerty.jpg" style="max-width: 100%"
/></a>
</q-card-section>

View file

@ -0,0 +1,244 @@
{% extends "public.html" %} {% block toolbar_title %} Gerty: {% raw %}{{
gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
<div
class="q-pa-md row items-start q-gutter-md"
v-if="fun_exchange_market_rate || fun_satoshi_quotes"
>
<q-card
v-if="fun_exchange_market_rate"
unelevated
class="q-pa-sm"
style="background: none !important"
>
<q-card-section class="text-h1 q-pa-none">
<small> <b>{{fun_exchange_market_rate["amount"]}}</b></small>
<small class="text-h4"
>{{fun_exchange_market_rate["unit"].split(" ")[1]}}</small
>
</q-card-section>
</q-card>
<q-card
v-if="fun_satoshi_quotes['quote']"
unelevated
class="q-pa-none text-body1 blockquote"
style="background: none !important"
>
<blockquote class="text-right" style="max-width: 900px">
<p>"{{fun_satoshi_quotes["quote"]}}"</p>
<small>~ Satoshi {{fun_satoshi_quotes["date"]}}</small>
</blockquote>
</q-card>
</div>
<div class="q-pa-md row items-start q-gutter-md" v-if="lnbits_wallets_balance">
<q-card
class="q-pa-sm"
v-for="(wallet, t) in lnbits_wallets_balance"
:style="`background-color: ${wallet.color1} !important`"
unelevated
class="q-pa-none q-pa-sm"
>
<q-card-section class="text-h1 q-pa-none">
<small> <b>{{wallet["amount"]}}</b></small>
<small class="text-h4">({{wallet["name"]}})</small>
</q-card-section>
</q-card>
</div>
<div
class="q-pa-md row items-start q-gutter-md"
v-if="dashboard_onchain || dashboard_mining || lightning_dashboard"
>
<q-card
class="q-pa-sm"
v-if="dashboard_onchain[0]"
unelevated
class="q-pa-sm"
>
<q-card-section>
<div class="text-h6">Onchain</div>
</q-card-section>
<q-card-section class="q-pa-none">
<p v-for="(item, t) in dashboard_onchain">
<b>{{item[0].value}}: </b>{{item[1].value}}
</p>
</q-card-section>
</q-card>
<q-card class="q-pa-sm" v-if="dashboard_mining" unelevated class="q-pa-sm">
<q-card-section>
<div class="text-h6">Mining</div>
</q-card-section>
<q-card-section class="q-pa-none">
<p v-for="(item, t) in dashboard_mining">
<b>{{item[0].value}}:</b> {{item[1].value}}
</p>
</q-card-section>
</q-card>
<q-card class="q-pa-sm" v-if="lightning_dashboard" unelevated class="q-pa-sm">
<q-card-section>
<div class="text-h6">Lightning (Last 7 days)</div>
</q-card-section>
<q-card-section class="q-pa-none">
<p v-for="(item, t) in lightning_dashboard">
<b>{{item[0].value}}:</b> {{item[1].value}}
</p>
</q-card-section>
</q-card>
<q-card class="q-pa-sm" v-if="url_checker" unelevated class="q-pa-sm">
<q-card-section>
<div class="text-h6">Servers to check</div>
</q-card-section>
<q-card-section class="q-pa-none">
<div class="row q-pb-md" v-for="(item, t) in url_checker">
<div class="col-8">
<small>
<b style="word-wrap: break-word; max-width: 230px; display: block">
<a class="text-secondary" class="text-primary">
{{item[0].value}}
</a>
</b>
</small>
</div>
<div class="col-4">
<q-chip
v-if="item[1].value < 300"
square
size="sm"
color="green"
text-color="white"
icon="sentiment_satisfied"
>
{{item[1].value}}
</q-chip>
<q-chip
v-else-if="item[1].value >= 300"
square
size="sm"
color="yellow"
text-color="white"
icon="sentiment_dissatisfied"
>
{{item[1].value}}
</q-chip>
<q-chip
v-else
square
size="sm"
color="red"
text-color="white"
icon="sentiment_dissatisfied"
>
{{item[1].value}}
</q-chip>
</div>
</div>
</q-card-section>
</q-card>
</div>
{% endraw %} {% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
lnbits_wallets_balance: {},
dashboard_onchain: {},
fun_satoshi_quotes: {},
fun_exchange_market_rate: {},
gerty: [],
gerty_id: `{{gerty}}`,
gertyname: '',
walletColors: [
{first: '#3f51b5', second: '#1a237e'},
{first: '#9c27b0', second: '#4a148c'},
{first: '#e91e63', second: '#880e4f'},
{first: '#009688', second: '#004d40'},
{first: '#ff9800', second: '#e65100'},
{first: '#2196f3', second: '#0d47a1'},
{first: '#4caf50', second: '#1b5e20'}
],
gertywallets: []
}
},
methods: {
getGertyInfo: async function () {
for (let i = 0; i < 8; i++) {
try {
const {data} = await LNbits.api.request(
'GET',
`/gerty/api/v1/gerty/pages/${this.gerty_id}/${i}`
)
this.gerty[i] = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
}
console.log(this.gerty)
for (let i = 0; i < this.gerty.length; i++) {
if (this.gerty[i].screen.group == 'lnbits_wallets_balance') {
for (let q = 0; q < this.gerty[i].screen.areas.length; q++) {
this.lnbits_wallets_balance[q] = {
name: this.gerty[i].screen.areas[q][0].value,
amount: this.gerty[i].screen.areas[q][1].value,
color1: this.walletColors[q].first,
color2: this.walletColors[q].second
}
this.gertyname = this.gerty[i].settings.name
}
}
if (this.gerty[i].screen.group == 'url_checker') {
this.url_checker = this.gerty[i].screen.areas
this.gertyname = this.gerty[i].settings.name
}
if (this.gerty[i].screen.group == 'dashboard_onchain') {
this.dashboard_onchain = this.gerty[i].screen.areas
this.gertyname = this.gerty[i].settings.name
}
if (this.gerty[i].screen.group == 'dashboard_mining') {
this.dashboard_mining = this.gerty[i].screen.areas
this.gertyname = this.gerty[i].settings.name
}
if (this.gerty[i].screen.group == 'lightning_dashboard') {
this.lightning_dashboard = this.gerty[i].screen.areas
this.gertyname = this.gerty[i].settings.name
}
if (this.gerty[i].screen.group == 'fun_satoshi_quotes') {
this.fun_satoshi_quotes['quote'] = this.gerty[
i
].screen.areas[0][0].value
this.fun_satoshi_quotes['date'] = this.gerty[
i
].screen.areas[0][1].value
this.gertyname = this.gerty[i].settings.name
}
if (this.gerty[i].screen.group == 'fun_exchange_market_rate') {
this.fun_exchange_market_rate['unit'] = this.gerty[
i
].screen.areas[0][0].value
this.fun_exchange_market_rate['amount'] = this.gerty[
i
].screen.areas[0][1].value
this.gertyname = this.gerty[i].settings.name
}
}
setTimeout(this.getGertyInfo, 20000)
this.$forceUpdate()
return this.gerty
}
},
created: async function () {
await this.getGertyInfo()
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,798 @@
{% 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-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Gerty
</q-btn>
</q-card-section>
</q-card>
<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">Gerty</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="gertys"
row-key="id"
:columns="gertysTable.columns"
:pagination.sync="gertysTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
:class="`col__${col.name} text-truncate elipsis`"
>
{{ col.label }}
</q-th>
<q-th auto-width></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="sentiment_satisfied"
color="green"
type="a"
:href="props.row.gerty"
target="_blank"
>
<q-tooltip>Launch software Gerty</q-tooltip>
</q-btn>
<q-btn
unelevated
dense
size="xs"
icon="code"
color="pink"
type="a"
:href="props.row.gertyJson"
target="_blank"
>
<q-tooltip>View Gerty API</q-tooltip>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateformDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteGerty(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row">
<div class="col-9">
<h6 class="text-subtitle1 q-my-none">
{{ SITE_TITLE }} Gerty extension
</h6>
</div>
<div class="col-3">
<q-btn
flat
label="Swagger API"
type="a"
href="../docs#/gerty"
></q-btn>
</div>
</div>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "gerty/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendFormDataGerty" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Name"
placeholder="Son of Gerty"
></q-input>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.fun_satoshi_quotes"
val="xs"
label="Satoshi Quotes"
><q-tooltip
>Displays random quotes from Satoshi</q-tooltip
></q-checkbox
>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.fun_exchange_market_rate"
val="xs"
label="Fiat to BTC price"
></q-checkbox>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.lnbits_wallets_balance"
val="xs"
label="LNbits"
></q-checkbox>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.dashboard_onchain"
val="xs"
label="Onchain"
></q-checkbox>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.dashboard_mining"
val="xs"
label="Mining"
></q-checkbox>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.lightning_dashboard"
val="xs"
label="Lightning"
></q-checkbox>
<q-checkbox
class="q-pl-md"
size="xs"
v-model="formDialog.data.display_preferences.url_checker"
val="xs"
label="URL Checker"
></q-checkbox>
<br />
<q-select
v-if="formDialog.data.display_preferences.fun_exchange_market_rate"
filled
dense
emit-value
v-model="formDialog.data.exchange"
:options="currencyOptions"
label="Exchange rate"
></q-select>
<q-select
v-if="formDialog.data.display_preferences.lnbits_wallets_balance"
filled
multiple
dense
emit-value
v-model="formDialog.data.lnbits_wallets"
use-input
use-chips
multiple
hide-dropdown-icon
new-value-mode="add-unique"
label="Invoice keys of wallets to watch"
>
<q-tooltip>Hit enter to add values</q-tooltip>
</q-select>
<q-select
v-if="formDialog.data.display_preferences.url_checker"
filled
multiple
dense
emit-value
v-model="formDialog.data.urls"
use-input
use-chips
multiple
hide-dropdown-icon
new-value-mode="add-unique"
label="Urls to watch."
>
<q-tooltip>Hit enter to add values</q-tooltip>
</q-select>
<q-toggle
label="*Advanced"
v-model="toggleStates.advanced"
@input="setAdvanced"
></q-toggle>
<br />
<q-input
v-if="toggleStates.advanced"
filled
dense
v-model.trim="formDialog.data.mempool_endpoint"
label="Mempool link"
class="q-pb-sm"
>
</q-input>
<q-input
v-if="toggleStates.advanced"
filled
dense
v-model.trim="formDialog.data.refresh_time"
label="Refresh time in seconds"
class="q-pb-md"
>
<q-tooltip
>The amount of time in seconds between screen updates
</q-tooltip>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.name == null"
type="submit"
class="q-mr-md"
v-if="!formDialog.data.id"
>Create Gerty
</q-btn>
<q-btn
v-else
unelevated
color="primary"
:disable="formDialog.data.name == null"
type="submit"
>Update Gerty
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel
</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapGerty = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.gerty = ['/gerty/', obj.id].join('')
obj.gertyJson = ['/gerty/api/v1/gerty/pages/', obj.id, '/0'].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
toggleStates: {
fun: false,
onchain: false,
mempool: false,
mining: false,
lightning: false,
advanced: false
},
oldToggleStates: {},
gertys: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
gertysTable: {
columns: [
{name: 'name', align: 'left', label: 'Name', field: 'name'},
{
name: 'exchange',
align: 'left',
label: 'Exchange',
field: 'exchange'
},
{
name: 'mempool_endpoint',
align: 'left',
label: 'Mempool Endpoint',
field: 'mempool_endpoint'
},
{name: 'id', align: 'left', label: 'Gerty ID', field: 'id'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {
type: 'Mini Gerty',
exchange: 'USD',
utc_offset: new Date().getTimezoneOffset(),
display_preferences: {
dashboard: false,
fun_satoshi_quotes: false,
fun_exchange_market_rate: false,
dashboard_onchain: false,
mempool_recommended_fees: false,
dashboard_mining: false,
lightning_dashboard: false,
onchain: false,
onchain_difficulty_epoch_progress: false,
onchain_difficulty_retarget_date: false,
onchain_difficulty_blocks_remaining: false,
onchain_difficulty_epoch_time_remaining: false,
onchain_block_height: false,
mempool_tx_count: false,
mining_current_hash_rate: false,
mining_current_difficulty: false,
lnbits_wallets_balance: false,
url_checker: false
},
lnbits_wallets: [],
urls: [],
mempool_endpoint: 'https://mempool.space',
refresh_time: 300
}
}
}
},
methods: {
setAdvanced: function () {
self = this
self.formDialog.data.mempool_endpoint = 'https://mempool.space'
self.formDialog.data.refresh_time = 300
},
setWallets: function () {
self = this
if (!self.formDialog.data.display_preferences.lnbits_wallets_balance) {
self.formDialog.data.lnbits_wallets = []
}
},
setUrls: function () {
self = this
if (!self.formDialog.data.display_preferences.url_checker) {
self.formDialog.data.urls = []
}
},
setOnchain: function () {
self = this
self.formDialog.data.display_preferences.onchain_difficulty_epoch_progress =
self.toggleStates.onchain
self.formDialog.data.display_preferences.onchain_difficulty_retarget_date =
self.toggleStates.onchain
self.formDialog.data.display_preferences.onchain_difficulty_blocks_remaining = !self
.toggleStates.onchain
self.formDialog.data.display_preferences.onchain_difficulty_epoch_time_remaining =
self.toggleStates.onchain
self.formDialog.data.display_preferences.onchain_block_height =
self.toggleStates.onchain
},
setMining: function () {
self = this
self.formDialog.data.display_preferences.mining_current_hash_rate =
self.toggleStates.mining
self.formDialog.data.display_preferences.mining_current_difficulty =
self.toggleStates.mining
},
closeFormDialog: function () {
this.formDialog.data = {
utc_offset: 0,
lnbits_wallets: [],
urls: [],
mempool_endpoint: 'https://mempool.space',
refresh_time: 300,
display_preferences: {}
}
},
getGertys: function () {
var self = this
LNbits.api
.request(
'GET',
'/gerty/api/v1/gerty?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.gertys = response.data.map(function (obj) {
return mapGerty(obj)
})
})
},
updateformDialog: function (formId) {
var gerty = _.findWhere(this.gertys, {id: formId})
this.formDialog.data.id = gerty.id
this.formDialog.data.name = gerty.name
this.formDialog.data.type = gerty.type
this.formDialog.data.utc_offset = gerty.utc_offset
this.formDialog.data.lnbits_wallets = JSON.parse(gerty.lnbits_wallets)
this.formDialog.data.urls = JSON.parse(gerty.urls)
;(this.formDialog.data.exchange = gerty.exchange),
(this.formDialog.data.mempool_endpoint = gerty.mempool_endpoint),
(this.formDialog.data.refresh_time = gerty.refresh_time),
(this.formDialog.data.display_preferences = JSON.parse(
gerty.display_preferences
)),
(this.formDialog.show = true)
},
sendFormDataGerty: function () {
if (this.formDialog.data.id) {
this.updateGerty(
this.g.user.wallets[0].adminkey,
this.formDialog.data
)
} else {
this.createGerty(
this.g.user.wallets[0].adminkey,
this.formDialog.data
)
}
},
createGerty: function () {
if (
this.formDialog.data.display_preferences.dashboard ||
this.formDialog.data.display_preferences.dashboard_onchain ||
this.formDialog.data.display_preferences.dashboard_onchain ||
this.formDialog.data.display_preferences.lightning_dashboard ||
this.formDialog.data.display_preferences.url_checker
) {
this.formDialog.data.type = 'Gerty'
}
var data = {
name: this.formDialog.data.name,
utc_offset: this.formDialog.data.utc_offset,
type: this.formDialog.data.type,
lnbits_wallets: JSON.stringify(this.formDialog.data.lnbits_wallets),
urls: JSON.stringify(this.formDialog.data.urls),
exchange: this.formDialog.data.exchange,
mempool_endpoint: this.formDialog.data.mempool_endpoint,
refresh_time: this.formDialog.data.refresh_time,
display_preferences: JSON.stringify(
this.formDialog.data.display_preferences
)
}
var self = this
LNbits.api
.request(
'POST',
'/gerty/api/v1/gerty',
this.g.user.wallets[0].inkey,
data
)
.then(function (response) {
self.formDialog.show = false
self.gertys.push(mapGerty(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateGerty: function (wallet, data) {
var self = this
if (
this.formDialog.data.display_preferences.dashboard ||
this.formDialog.data.display_preferences.dashboard_onchain ||
this.formDialog.data.display_preferences.dashboard_onchain ||
this.formDialog.data.display_preferences.lightning_dashboard ||
this.formDialog.data.display_preferences.url_checker
) {
this.formDialog.data.type = 'Gerty'
}
data.utc_offset = this.formDialog.data.utc_offset
data.type = this.formDialog.data.type
data.lnbits_wallets = JSON.stringify(
this.formDialog.data.lnbits_wallets
)
data.urls = JSON.stringify(this.formDialog.data.urls)
data.display_preferences = JSON.stringify(
this.formDialog.data.display_preferences
)
LNbits.api
.request('PUT', '/gerty/api/v1/gerty/' + data.id, wallet, data)
.then(function (response) {
self.gertys = _.reject(self.gertys, function (obj) {
return obj.id == data.id
})
self.formDialog.show = false
self.gertys.push(mapGerty(response.data))
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteGerty: function (gertyId) {
var self = this
var gerty = _.findWhere(self.gertys, {id: gertyId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this Gerty?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/gerty/api/v1/gerty/' + gertyId,
_.findWhere(self.g.user.wallets, {id: gerty.wallet}).adminkey
)
.then(function (response) {
self.gertys = _.reject(self.gertys, function (obj) {
return obj.id == gertyId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.gertysTable.columns, this.gertys)
}
},
computed: {
isMiniGerty() {
return this.formDialog.data.type == 'Mini Gerty'
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getGertys()
}
},
watch: {
'formDialog.data.type': {
handler(value) {
if (value == 'Mini Gerty') {
this.formDialog.data.display_preferences.dashboard = false
this.formDialog.data.display_preferences.dashboard_onchain = false
this.formDialog.data.display_preferences.dashboard_mining = false
this.formDialog.data.display_preferences.lightning_dashboard = false
this.formDialog.data.display_preferences.fun_satoshi_quotes = false
this.formDialog.data.display_preferences.mempool_recommended_fees = false
this.formDialog.data.display_preferences.onchain = false
this.formDialog.data.display_preferences.url_checker = false
}
}
},
toggleStates: {
handler(toggleStatesValue) {
// Switch all the toggles in each section to the relevant state
for (const [toggleKey, toggleValue] of Object.entries(
toggleStatesValue
)) {
if (this.oldToggleStates[toggleKey] !== toggleValue) {
for (const [dpKey, dpValue] of Object.entries(
this.formDialog.data.display_preferences
)) {
if (dpKey.indexOf(toggleKey) === 0) {
this.formDialog.data.display_preferences[dpKey] = toggleValue
}
}
}
}
// This is a weird hack we have to use to get VueJS to persist the previous toggle state between
// watches. VueJS passes the old and new values by reference so when comparing objects they
// will have the same values unless we do this
this.oldToggleStates = JSON.parse(JSON.stringify(toggleStatesValue))
},
deep: true
}
}
})
</script>
{% endblock %} {% block styles %}
<style>
.col__display_preferences {
border: 1px solid red;
}
</style>
{% endblock %}

View file

@ -0,0 +1,37 @@
import json
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from loguru import logger
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import gerty_ext, gerty_renderer
from .crud import get_gerty
from .views_api import api_gerty_json
templates = Jinja2Templates(directory="templates")
@gerty_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return gerty_renderer().TemplateResponse(
"gerty/index.html", {"request": request, "user": user.dict()}
)
@gerty_ext.get("/{gerty_id}", response_class=HTMLResponse)
async def display(request: Request, gerty_id):
gerty = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
)
return gerty_renderer().TemplateResponse(
"gerty/gerty.html", {"request": request, "gerty": gerty_id}
)

View file

@ -0,0 +1,191 @@
import json
import math
import os
import random
import time
from datetime import datetime
from http import HTTPStatus
import httpx
from fastapi import Query
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from lnurl import decode as decode_lnurl
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_wallet_for_key
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment, api_wallet
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
from . import gerty_ext
from .crud import (
create_gerty,
delete_gerty,
get_gerty,
get_gertys,
get_mempool_info,
update_gerty,
)
from .helpers import *
from .models import Gerty, MempoolEndpoint
@gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK)
async def api_gertys(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [gerty.dict() for gerty in await get_gertys(wallet_ids)]
@gerty_ext.post("/api/v1/gerty", status_code=HTTPStatus.CREATED)
@gerty_ext.put("/api/v1/gerty/{gerty_id}", status_code=HTTPStatus.OK)
async def api_link_create_or_update(
data: Gerty,
wallet: WalletTypeInfo = Depends(get_key_type),
gerty_id: str = Query(None),
):
logger.debug(data)
if gerty_id:
gerty = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist"
)
if gerty.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Come on, seriously, this isn't your Gerty!",
)
data.wallet = wallet.wallet.id
gerty = await update_gerty(gerty_id, **data.dict())
else:
gerty = await create_gerty(wallet_id=wallet.wallet.id, data=data)
return {**gerty.dict()}
@gerty_ext.delete("/api/v1/gerty/{gerty_id}")
async def api_gerty_delete(
gerty_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
gerty = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
)
if gerty.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your Gerty.")
await delete_gerty(gerty_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK)
async def api_gerty_satoshi():
return await get_satoshi
@gerty_ext.get("/api/v1/gerty/pages/{gerty_id}/{p}")
async def api_gerty_json(gerty_id: str, p: int = None): # page number
gerty = await get_gerty(gerty_id)
if not gerty:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
)
display_preferences = json.loads(gerty.display_preferences)
enabled_screen_count = 0
enabled_screens = []
for screen_slug in display_preferences:
is_screen_enabled = display_preferences[screen_slug]
if is_screen_enabled:
enabled_screen_count += 1
enabled_screens.append(screen_slug)
logger.debug("Screeens " + str(enabled_screens))
data = await get_screen_data(p, enabled_screens, gerty)
next_screen_number = 0 if ((p + 1) >= enabled_screen_count) else p + 1
# get the sleep time
sleep_time = gerty.refresh_time if gerty.refresh_time else 300
utc_offset = gerty.utc_offset if gerty.utc_offset else 0
if gerty_should_sleep(utc_offset):
sleep_time_hours = 8
sleep_time = 60 * 60 * sleep_time_hours
return {
"settings": {
"refreshTime": sleep_time,
"requestTimestamp": get_next_update_time(sleep_time, utc_offset),
"nextScreenNumber": next_screen_number,
"showTextBoundRect": False,
"name": gerty.name,
},
"screen": {
"slug": get_screen_slug_by_index(p, enabled_screens),
"group": get_screen_slug_by_index(p, enabled_screens),
"title": data["title"],
"areas": data["areas"],
},
}
###########CACHED MEMPOOL##############
@gerty_ext.get("/api/v1/gerty/fees-recommended/{gerty_id}")
async def api_gerty_get_fees_recommended(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("fees_recommended", gerty)
@gerty_ext.get("/api/v1/gerty/hashrate-1w/{gerty_id}")
async def api_gerty_get_hashrate_1w(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("hashrate_1w", gerty)
@gerty_ext.get("/api/v1/gerty/hashrate-1m/{gerty_id}")
async def api_gerty_get_hashrate_1m(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("hashrate_1m", gerty)
@gerty_ext.get("/api/v1/gerty/statistics/{gerty_id}")
async def api_gerty_get_statistics(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("statistics", gerty)
@gerty_ext.get("/api/v1/gerty/difficulty-adjustment/{gerty_id}")
async def api_gerty_get_difficulty_adjustment(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("difficulty_adjustment", gerty)
@gerty_ext.get("/api/v1/gerty/tip-height/{gerty_id}")
async def api_gerty_get_tip_height(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("tip_height", gerty)
@gerty_ext.get("/api/v1/gerty/mempool/{gerty_id}")
async def api_gerty_get_mempool(gerty_id):
gerty = await get_gerty(gerty_id)
return await get_mempool_info("mempool", gerty)

View file

@ -1,3 +1,3 @@
<h1>Hivemind</h1>
Placeholder for a future <a href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.
Placeholder for a future <a class="text-secondary" href="https://bitcoinhivemind.com/">Bitcoin Hivemind</a> extension.

View file

@ -6,9 +6,10 @@
This extension is just a placeholder for now.
</h5>
<p>
<a href="https://bitcoinhivemind.com/">Hivemind</a> is a Bitcoin sidechain
project for a peer-to-peer oracle protocol that absorbs accurate data into
a blockchain so that Bitcoin users can speculate in prediction markets.
<a class="text-secondary" href="https://bitcoinhivemind.com/">Hivemind</a>
is a Bitcoin sidechain project for a peer-to-peer oracle protocol that
absorbs accurate data into a blockchain so that Bitcoin users can
speculate in prediction markets.
</p>
<p>
These markets have the potential to revolutionize the emergence of
@ -17,8 +18,8 @@
</p>
<p>
This extension will become fully operative when the
<a href="https://drivechain.xyz/">BIP300</a> soft-fork gets activated and
Bitcoin Hivemind is launched.
<a class="text-secondary" href="https://drivechain.xyz/">BIP300</a>
soft-fork gets activated and Bitcoin Hivemind is launched.
</p>
</q-card-section>
</q-card>

View file

@ -251,10 +251,13 @@ block page %}
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
<a
class="text-secondary"
:href="'lightning:' + qrCodeDialog.data.payment_request"
>
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="qrCodeDialog.data.payment_request"
:value="'lightning:' + qrCodeDialog.data.payment_request.toUpperCase()"
:options="{width: 400}"
class="rounded-borders"
></qrcode>

View file

@ -30,7 +30,7 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
@invoices_ext.get("/pay/{invoice_id}", response_class=HTMLResponse)
async def index(request: Request, invoice_id: str):
async def pay(request: Request, invoice_id: str):
invoice = await get_invoice(invoice_id)
if not invoice:

View file

@ -2,6 +2,7 @@
To use this extension you need a Spotify client ID and client secret. You get
these by creating an app in the Spotify developers dashboard
<a
class="text-secondary"
style="color: #43a047"
href="https://developer.spotify.com/dashboard/applications"
>here
@ -9,9 +10,14 @@
<br /><br />Select the playlists you want people to be able to pay for, share
the frontend page, profit :) <br /><br />
Made by,
<a style="color: #43a047" href="https://twitter.com/arcbtc">benarc</a>.
Inspired by,
<a
class="text-secondary"
style="color: #43a047"
href="https://twitter.com/arcbtc"
>benarc</a
>. Inspired by,
<a
class="text-secondary"
style="color: #43a047"
href="https://twitter.com/pirosb3/status/1056263089128161280"
>pirosb3</a

View file

@ -83,7 +83,7 @@
<q-card class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="'lightning:' + receive.paymentReq"
:value="'lightning:' + receive.paymentReq.toUpperCase()"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -163,6 +163,7 @@
<q-td auto-width>{{ props.row.name }}</q-td>
<q-td class="text-center" auto-width>
<a
class="text-secondary"
target="_blank"
:href="'/wallet?usr=' + props.row.user + '&wal=' + props.row.wallet"
>
@ -191,7 +192,7 @@
</q-select>
</q-form>
<a :href="'lightning:' + livestream.lnurl">
<a class="text-secondary" :href="'lightning:' + livestream.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="livestream.lnurl"
@ -235,10 +236,10 @@
<p class="text-subtitle1 q-my-none">
Standalone QR Code for this track
</p>
<a :href="'lightning:' + trackDialog.data.lnurl">
<a class="text-secondary" :href="'lightning:' + trackDialog.data.lnurl">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="trackDialog.data.lnurl"
:value="'lightning:' + trackDialog.data.lnurl.toUpperCase()"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -13,13 +13,16 @@
Charge people for using your domain name...<br />
<a
class="text-secondary"
href="https://github.com/lnbits/lnbits-legend/tree/main/lnbits/extensions/lnaddress"
>More details</a
>
<br />
<small>
Created by,
<a href="https://twitter.com/talvasconcelos">talvasconcelos</a></small
<a class="text-secondary" href="https://twitter.com/talvasconcelos"
>talvasconcelos</a
></small
>
</p>
</q-card-section>

View file

@ -184,10 +184,10 @@
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="paymentReq"
:value="'lightning:' + paymentReq.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>

View file

@ -194,6 +194,7 @@
<template v-slot:hint>
Check extension
<a
class="text-secondary"
href="https://github.com/lnbits/lnbits-legend/blob/main/lnbits/extensions/lnaddress/README.md"
>documentation!</a
>

View file

@ -9,8 +9,10 @@
To access an LNbits wallet from a mobile phone,
<ol>
<li>
Install either <a href="https://zeusln.app">Zeus</a> or
<a href="https://bluewallet.io/">BlueWallet</a>;
Install either
<a class="text-secondary" href="https://zeusln.app">Zeus</a> or
<a class="text-secondary" href="https://bluewallet.io/">BlueWallet</a
>;
</li>
<li>
Go to <code>Add a wallet / Import wallet</code> on BlueWallet or

View file

@ -3,16 +3,17 @@
<q-card-section>
<p>
LndHub is a protocol invented by
<a href="https://bluewallet.io/">BlueWallet</a> that allows mobile
wallets to query payments and balances, generate invoices and make
payments from accounts that exist on a server. The protocol is a
collection of HTTP endpoints exposed through the internet.
<a class="text-secondary" href="https://bluewallet.io/">BlueWallet</a>
that allows mobile wallets to query payments and balances, generate
invoices and make payments from accounts that exist on a server. The
protocol is a collection of HTTP endpoints exposed through the internet.
</p>
<p>
For a wallet that supports it, reading a QR code that contains the URL
along with secret access credentials should enable access. Currently it
is supported by <a href="https://zeusln.app">Zeus</a> and
<a href="https://bluewallet.io/">BlueWallet</a>.
is supported by
<a class="text-secondary" href="https://zeusln.app">Zeus</a> and
<a class="text-secondary" href="https://bluewallet.io/">BlueWallet</a>.
</p>
</q-card-section>
</q-card>

View file

@ -10,7 +10,7 @@
>
<q-card-section class="q-pa-none">
<div class="text-center">
<a :href="selectedWallet[type]">
<a class="text-secondary" :href="selectedWallet[type]">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="selectedWallet[type]"

View file

@ -1,10 +1,8 @@
from binascii import unhexlify
from lnbits.bolt11 import Invoice
def to_buffer(payment_hash: str):
return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]}
return {"type": "Buffer", "data": [b for b in bytes.fromhex(payment_hash)]}
def decoded_as_lndhub(invoice: Invoice):

View file

@ -14,7 +14,10 @@
paid support ticketing, PAYG language services, contact spam
protection.<br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
Created by,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
></small
>
</p>
</q-card-section>

View file

@ -64,10 +64,10 @@
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<a class="text-secondary" :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="paymentReq"
:value="'lightning:' + paymentReq.toUpperCase()"
:options="{width: 340}"
class="rounded-borders"
></qrcode>

View file

@ -11,7 +11,10 @@ from .models import createLnurldevice, lnurldevicepayment, lnurldevices
async def create_lnurldevice(
data: createLnurldevice,
) -> lnurldevices:
lnurldevice_id = urlsafe_short_hash()
if data.device == "pos" or data.device == "atm":
lnurldevice_id = str(await get_lnurldeviceposcount())
else:
lnurldevice_id = urlsafe_short_hash()
lnurldevice_key = urlsafe_short_hash()
await db.execute(
"""
@ -79,6 +82,17 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev
return lnurldevices(**row) if row else None
async def get_lnurldeviceposcount() -> int:
row = await db.fetchall(
"SELECT * FROM lnurldevice.lnurldevices WHERE device = ? OR device = ?",
(
"pos",
"atm",
),
)
return len(row) + 1
async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices:
row = await db.fetchone(
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)

View file

@ -17,8 +17,8 @@ class createLnurldevice(BaseModel):
wallet: str
currency: str
device: str
profit: float
amount: int
profit: float = 0
amount: Optional[int] = 0
pin: int = 0
profit1: float = 0
amount1: int = 0

View file

@ -4,21 +4,25 @@
For LNURL based Points of Sale, ATMs, and relay devices<br />
Use with: <br />
LNPoS
<a href="https://lnbits.github.io/lnpos">
<a class="text-secondary" href="https://lnbits.github.io/lnpos">
https://lnbits.github.io/lnpos</a
><br />
bitcoinSwitch
<a href="https://github.com/lnbits/bitcoinSwitch">
<a class="text-secondary" href="https://github.com/lnbits/bitcoinSwitch">
https://github.com/lnbits/bitcoinSwitch</a
><br />
FOSSA
<a href="https://github.com/lnbits/fossa">
<a class="text-secondary" href="https://github.com/lnbits/fossa">
https://github.com/lnbits/fossa</a
><br />
<small>
Created by, <a href="https://github.com/benarc">Ben Arc</a>,
<a href="https://github.com/blackcoffeexbt">BC</a>,
<a href="https://github.com/motorina0">Vlad Stan</a></small
Created by,
<a class="text-secondary" href="https://github.com/benarc">Ben Arc</a>,
<a class="text-secondary" href="https://github.com/blackcoffeexbt">BC</a
>,
<a class="text-secondary" href="https://github.com/motorina0"
>Vlad Stan</a
></small
>
</p>
</q-card-section>

View file

@ -476,7 +476,7 @@
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="lnurlValue"
:value="'lightning:' + lnurlValue"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -1,19 +1,18 @@
from typing import List, Optional, Union
from lnbits.db import SQLITE
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreatePayLinkData, PayLink
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
link_id = urlsafe_short_hash()
returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone
result = await (method)(
result = await db.execute(
f"""
INSERT INTO lnurlp.pay_links (
id,
wallet,
description,
min,
@ -29,10 +28,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
currency,
fiat_base_multiplier
)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
{returning}
""",
(
link_id,
wallet_id,
data.description,
data.min,
@ -47,10 +47,6 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.fiat_base_multiplier,
),
)
if db.type == SQLITE:
link_id = result._result_proxy.lastrowid
else:
link_id = result[0]
link = await get_pay_link(link_id)
assert link, "Newly created link couldn't be retrieved"

View file

@ -68,3 +68,76 @@ async def m005_webhook_headers_and_body(db):
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")
async def m006_redux(db):
"""
Add UUID ID's to links and migrates existing data
"""
await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
await db.execute(
f"""
CREATE TABLE lnurlp.pay_links (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
min INTEGER NOT NULL,
max INTEGER,
currency TEXT,
fiat_base_multiplier INTEGER DEFAULT 1,
served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL,
webhook_url TEXT,
success_text TEXT,
success_url TEXT,
comment_chars INTEGER DEFAULT 0,
webhook_headers TEXT,
webhook_body TEXT
);
"""
)
for row in [
list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
]:
await db.execute(
"""
INSERT INTO lnurlp.pay_links (
id,
wallet,
description,
min,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
currency,
comment_chars,
max,
fiat_base_multiplier,
webhook_headers,
webhook_body
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row[0],
row[1],
row[2],
row[3],
row[4],
row[5],
row[6],
row[7],
row[8],
row[9],
row[10],
row[11],
row[12],
row[13],
row[14],
),
)
await db.execute("DROP TABLE lnurlp.pay_links_old")

View file

@ -26,7 +26,7 @@ class CreatePayLinkData(BaseModel):
class PayLink(BaseModel):
id: int
id: str
wallet: str
description: str
min: float

View file

@ -52,19 +52,29 @@ async def on_invoice_paid(payment: Payment) -> None:
r: httpx.Response = await client.post(pay_link.webhook_url, **kwargs)
await mark_webhook_sent(
payment, r.status_code, r.is_success, r.reason_phrase, r.text
payment.payment_hash,
r.status_code,
r.is_success,
r.reason_phrase,
r.text,
)
except Exception as ex:
logger.error(ex)
await mark_webhook_sent(payment, -1, False, "Unexpected Error", str(ex))
await mark_webhook_sent(
payment.payment_hash, -1, False, "Unexpected Error", str(ex)
)
async def mark_webhook_sent(
payment: Payment, status: int, is_success: bool, reason_phrase="", text=""
payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
) -> None:
payment.extra["wh_status"] = status # keep for backwards compability
payment.extra["wh_success"] = is_success
payment.extra["wh_message"] = reason_phrase
payment.extra["wh_response"] = text
await update_payment_extra(payment.payment_hash, payment.extra)
await update_payment_extra(
payment_hash,
{
"wh_status": status, # keep for backwards compability
"wh_success": is_success,
"wh_message": reason_phrase,
"wh_response": text,
},
)

View file

@ -18,7 +18,10 @@
</p>
<small
>Check
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
<a
class="text-secondary"
href="https://github.com/fiatjaf/awesome-lnurl"
target="_blank"
>Awesome LNURL</a
>
for further information.</small

View file

@ -4,10 +4,10 @@
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<a href="lightning:{{ lnurl }}">
<a class="text-secondary" href="lightning:{{ lnurl }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
value="{{ lnurl }}"
value="lightning:{{ lnurl }}"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -284,7 +284,7 @@
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.lnurl"
:value="'lightning:' + qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -1,7 +1,7 @@
{% extends "print.html" %} {% block page %}
<div class="row justify-center">
<div class="qr">
<qrcode value="{{ lnurl }}" :options="{width}"></qrcode>
<qrcode value="lightning:{{ lnurl }}" :options="{width}"></qrcode>
</div>
</div>
{% endblock %} {% block styles %}

View file

@ -41,7 +41,10 @@
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<a v-if="col.label == 'LNURLPay'" @click="copyText(col.value)"
<a
class="text-secondary"
v-if="col.label == 'LNURLPay'"
@click="copyText(col.value)"
><q-tooltip>Click to copy LNURL</q-tooltip>{{
col.value.substring(0, 40) }}...</a
>

View file

@ -9,7 +9,11 @@
Access this lnbits instance at the following url
</h5>
<q-separator class="q-my-lg"></q-separator>
<p><a href="{{ ngrok }}" target="_blank">{{ ngrok }}</a></p>
<p>
<a class="text-secondary" href="{{ ngrok }}" target="_blank"
>{{ ngrok }}</a
>
</p>
</q-card-section>
</q-card>
</div>
@ -29,7 +33,10 @@
</p>
<small
>Created by
<a href="https://github.com/supertestnet" target="_blank"
<a
class="text-secondary"
href="https://github.com/supertestnet"
target="_blank"
>Supertestnet</a
>.</small
>

View file

@ -0,0 +1,44 @@
# Nostr NIP-05
## Allow users to NIP-05 verify themselves at a domain you control
This extension allows users to sell NIP-05 verification to other nostr users on a domain they control.
## Usage
1. Create a Domain by clicking "NEW DOMAIN"\
2. Fill the options for your DOMAIN
- select the wallet
- select the fiat currency the invoice will be denominated in
- select an amount in fiat to charge users for verification
- enter the domain (or subdomain) you want to provide verification for
- Note, you must own this domain and have access to a web server
3. You can then use share your signup link with your users to allow them to sign up
## Installation
In order for this to work, you need to have ownership of a domain name, and access to a web server that this domain is pointed to.
Then, you'll need to set up a proxy that points `https://{your_domain}/.well-known/nostr.json` to `https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json`
Example nginx configuration
```
## Proxy Server Caching
proxy_cache_path /tmp/nginx_cache keys_zone=nip5_cache:5m levels=1:2 inactive=300s max_size=100m use_temp_path=off;
location /.well-known/nostr.json {
proxy_pass https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json;
proxy_set_header Host {your_lnbits};
proxy_ssl_server_name on;
expires 5m;
add_header Cache-Control "public, no-transform";
proxy_cache nip5_cache;
proxy_cache_lock on;
proxy_cache_valid 200 300s;
proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
}
```

View file

@ -0,0 +1,36 @@
import asyncio
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_nostrnip5")
nostrnip5_static_files = [
{
"path": "/nostrnip5/static",
"app": StaticFiles(directory="lnbits/extensions/nostrnip5/static"),
"name": "nostrnip5_static",
}
]
nostrnip5_ext: APIRouter = APIRouter(prefix="/nostrnip5", tags=["nostrnip5"])
def nostrnip5_renderer():
return template_renderer(["lnbits/extensions/nostrnip5/templates"])
from .tasks import wait_for_paid_invoices
def nostrnip5_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
from .views import * # noqa
from .views_api import * # noqa

View file

@ -0,0 +1,6 @@
{
"name": "Nostr NIP-5",
"short_description": "Verify addresses for Nostr NIP-5",
"icon": "request_quote",
"contributors": ["leesalminen"]
}

View file

@ -0,0 +1,186 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Address, CreateAddressData, CreateDomainData, Domain
async def get_domain(domain_id: str) -> Optional[Domain]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.domains WHERE id = ?", (domain_id,)
)
return Domain.from_row(row) if row else None
async def get_domain_by_name(domain: str) -> Optional[Domain]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.domains WHERE domain = ?", (domain,)
)
return Domain.from_row(row) if row else None
async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domain]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM nostrnip5.domains WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Domain.from_row(row) for row in rows]
async def get_address(domain_id: str, address_id: str) -> Optional[Address]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.addresses WHERE domain_id = ? AND id = ?",
(
domain_id,
address_id,
),
)
return Address.from_row(row) if row else None
async def get_address_by_local_part(
domain_id: str, local_part: str
) -> Optional[Address]:
row = await db.fetchone(
"SELECT * FROM nostrnip5.addresses WHERE domain_id = ? AND local_part = ?",
(
domain_id,
local_part.lower(),
),
)
return Address.from_row(row) if row else None
async def get_addresses(domain_id: str) -> List[Address]:
rows = await db.fetchall(
f"SELECT * FROM nostrnip5.addresses WHERE domain_id = ?", (domain_id,)
)
return [Address.from_row(row) for row in rows]
async def get_all_addresses(wallet_ids: Union[str, List[str]]) -> List[Address]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
SELECT a.*
FROM nostrnip5.addresses a
JOIN nostrnip5.domains d ON d.id = a.domain_id
WHERE d.wallet IN ({q})
""",
(*wallet_ids,),
)
return [Address.from_row(row) for row in rows]
async def activate_address(domain_id: str, address_id: str) -> Address:
await db.execute(
"""
UPDATE nostrnip5.addresses
SET active = true
WHERE domain_id = ?
AND id = ?
""",
(
domain_id,
address_id,
),
)
address = await get_address(domain_id, address_id)
assert address, "Newly updated address couldn't be retrieved"
return address
async def rotate_address(domain_id: str, address_id: str, pubkey: str) -> Address:
await db.execute(
"""
UPDATE nostrnip5.addresses
SET pubkey = ?
WHERE domain_id = ?
AND id = ?
""",
(
pubkey,
domain_id,
address_id,
),
)
address = await get_address(domain_id, address_id)
assert address, "Newly updated address couldn't be retrieved"
return address
async def delete_domain(domain_id) -> bool:
await db.execute(
"""
DELETE FROM nostrnip5.addresses WHERE domain_id = ?
""",
(domain_id,),
)
await db.execute(
"""
DELETE FROM nostrnip5.domains WHERE id = ?
""",
(domain_id,),
)
return True
async def delete_address(address_id):
await db.execute(
"""
DELETE FROM nostrnip5.addresses WHERE id = ?
""",
(address_id,),
)
async def create_address_internal(domain_id: str, data: CreateAddressData) -> Address:
address_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO nostrnip5.addresses (id, domain_id, local_part, pubkey, active)
VALUES (?, ?, ?, ?, ?)
""",
(
address_id,
domain_id,
data.local_part.lower(),
data.pubkey,
False,
),
)
address = await get_address(domain_id, address_id)
assert address, "Newly created address couldn't be retrieved"
return address
async def create_domain_internal(wallet_id: str, data: CreateDomainData) -> Domain:
domain_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO nostrnip5.domains (id, wallet, currency, amount, domain)
VALUES (?, ?, ?, ?, ?)
""",
(domain_id, wallet_id, data.currency, int(data.amount * 100), data.domain),
)
domain = await get_domain(domain_id)
assert domain, "Newly created domain couldn't be retrieved"
return domain

View file

@ -0,0 +1,35 @@
async def m001_initial_invoices(db):
await db.execute(
f"""
CREATE TABLE nostrnip5.domains (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
currency TEXT NOT NULL,
amount INTEGER NOT NULL,
domain TEXT NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE nostrnip5.addresses (
id TEXT PRIMARY KEY,
domain_id TEXT NOT NULL,
local_part TEXT NOT NULL,
pubkey TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT false,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
FOREIGN KEY(domain_id) REFERENCES {db.references_schema}domains(id)
);
"""
)

View file

@ -0,0 +1,50 @@
from enum import Enum
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
class RotateAddressData(BaseModel):
pubkey: str
class CreateAddressData(BaseModel):
domain_id: str
local_part: str
pubkey: str
active: bool = False
class CreateDomainData(BaseModel):
wallet: str
currency: str
amount: float = Query(..., ge=0.01)
domain: str
class Domain(BaseModel):
id: str
wallet: str
currency: str
amount: int
domain: str
time: int
@classmethod
def from_row(cls, row: Row) -> "Domain":
return cls(**dict(row))
class Address(BaseModel):
id: str
domain_id: str
local_part: str
pubkey: str
active: bool
time: int
@classmethod
def from_row(cls, row: Row) -> "Address":
return cls(**dict(row))

View file

@ -0,0 +1,35 @@
import asyncio
from loguru import logger
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import activate_address
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra:
return
if payment.extra.get("tag") != "nostrnip5":
return
domain_id = payment.extra.get("domain_id")
address_id = payment.extra.get("address_id")
if domain_id and address_id:
logger.info("Activating NOSTR NIP-05")
logger.info(domain_id)
logger.info(address_id)
await activate_address(domain_id, address_id)
return

View file

@ -0,0 +1,238 @@
<q-expansion-item
group="extras"
icon="info"
label="Guide"
:content-inset-level="0.5"
>
<q-card>
<p>
<q-card-section>
<strong>Usage</strong><br />
1. Create a Domain by clicking "NEW DOMAIN"\<br />
2. Fill the options for your DOMAIN<br />
- select the wallet<br />
- select the fiat currency the invoice will be denominated in<br />
- select an amount in fiat to charge users for verification<br />
- enter the domain (or subdomain) you want to provide verification
for<br />
3. You can then use share your signup link with your users to allow them
to sign up *Note, you must own this domain and have access to a web
server*
<br /><br />
<strong>Installation</strong><br />
In order for this to work, you need to have ownership of a domain name,
and access to a web server that this domain is pointed to. Then, you'll
need to set up a proxy that points
`https://{your_domain}/.well-known/nostr.json` to
`https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json`
<br /><br />
<strong>Example nginx configuration</strong>
<br />
<q-card>
<q-card-section>
<small class="text-caption">
proxy_cache_path /tmp/nginx_cache keys_zone=nip5_cache:5m
levels=1:2 inactive=300s max_size=100m use_temp_path=off;<br />
location /.well-known/nostr.json {<br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_pass
https://{your_lnbits}/nostrnip5/api/v1/domain/{domain_id}/nostr.json;<br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_set_header Host {your_lnbits};<br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_ssl_server_name on;<br /><br />
&nbsp;&nbsp;&nbsp;&nbsp;expires 5m;<br />
&nbsp;&nbsp;&nbsp;&nbsp;add_header Cache-Control "public,
no-transform";<br /><br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_cache nip5_cache;<br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_cache_lock on;<br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_cache_valid 200 300s;<br />
&nbsp;&nbsp;&nbsp;&nbsp;proxy_cache_use_stale error timeout
invalid_header updating http_500 http_502 http_503 http_504;<br />
}
</small>
</q-card-section>
</q-card>
</q-card-section>
</p>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="api"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/nostrnip5"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List Domains">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /nostrnip5/api/v1/domains</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>[&lt;domain_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}nostrnip5/api/v1/domains -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="List Addresses">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /nostrnip5/api/v1/addresses</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>[&lt;address_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}nostrnip5/api/v1/addresses -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Fetch Domain">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/nostrnip5/api/v1/domain/{domain_id}</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>{domain_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}nostrnip5/api/v1/domain/{domain_id}
-H "X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Domain">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /nostrnip5/api/v1/domain</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>{domain_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}nostrnip5/api/v1/domain -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create Address">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/nostrnip5/api/v1/domain/{domain_id}/address</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>{address_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}nostrnip5/api/v1/domain/{domain_id}/address -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create Invoice Payment"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span>
/invoices/api/v1/invoice/{invoice_id}/payments</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<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>{payment_object}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}invoices/api/v1/invoice/{invoice_id}/payments -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check Invoice Payment Status"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}nostrnip5/api/v1/domain/{domain_id}/payments/{payment_hash} -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View file

@ -0,0 +1,709 @@
{% 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-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New Domain</q-btn
>
<q-btn unelevated color="primary" @click="addressFormDialog.show = true"
>New Address</q-btn
>
</q-card-section>
</q-card>
<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">Domains</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="domains"
row-key="id"
:columns="domainsTable.columns"
:pagination.sync="domainsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</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="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'signup/' + props.row.id"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'api/v1/domain/' + props.row.id + '/nostr.json'"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="deleteDomain(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<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">Addresses</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportAddressesCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="addresses"
row-key="id"
:columns="addressesTable.columns"
:pagination.sync="addressesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</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="edit"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
target="_blank"
:href="'rotate/' + props.row.domain_id + '/' + props.row.id"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="check"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
:disable="props.row.active == true"
@click="activateAddress(props.row.domain_id, props.row.id)"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="deleteAddress(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</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">
{{SITE_TITLE}} Nostr NIP-5 extension
</h6>
<p>
<strong
>Allow users to NIP-05 verify themselves at a domain you
control</strong
>
</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "nostrnip5/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveDomain" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-select
filled
dense
emit-value
v-model="formDialog.data.currency"
:options="currencyOptions"
label="Currency *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.amount"
label="Amount"
placeholder="10.00"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.domain"
label="Domain"
placeholder="nostr.com"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.currency == null"
type="submit"
>Create Domain</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog
v-model="addressFormDialog.show"
position="top"
@hide="closeAddressFormDialog"
>
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="saveAddress" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="addressFormDialog.data.domain_id"
:options="domainOptions"
label="Domain *"
></q-select>
<q-input
filled
dense
v-model.trim="addressFormDialog.data.pubkey"
label="Public Key"
placeholder="npub..."
></q-input>
<q-input
filled
dense
v-model.trim="addressFormDialog.data.local_part"
label="Local Part"
placeholder="benarc"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="addressFormDialog.data.domain_id == null || addressFormDialog.data.pubkey == null || addressFormDialog.data.local_part == null"
type="submit"
>Create Address</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapDomain = function (obj) {
obj.time = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = parseFloat(obj.amount / 100).toFixed(2)
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
domains: [],
addresses: [],
currencyOptions: [
'USD',
'EUR',
'GBP',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BHD',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BTN',
'BWP',
'BYN',
'BZD',
'CAD',
'CDF',
'CHF',
'CLF',
'CLP',
'CNH',
'CNY',
'COP',
'CRC',
'CUC',
'CUP',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ERN',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GGP',
'GHS',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'IMP',
'INR',
'IQD',
'IRR',
'IRT',
'ISK',
'JEP',
'JMD',
'JOD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KPW',
'KRW',
'KWD',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'LYD',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'OMR',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RSD',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SDG',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'SSP',
'STD',
'SVC',
'SYP',
'SZL',
'THB',
'TJS',
'TMT',
'TND',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'USD',
'UYU',
'UZS',
'VEF',
'VES',
'VND',
'VUV',
'WST',
'XAF',
'XAG',
'XAU',
'XCD',
'XDR',
'XOF',
'XPD',
'XPF',
'XPT',
'YER',
'ZAR',
'ZMW',
'ZWL'
],
domainsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'domain', align: 'left', label: 'Domain', field: 'domain'},
{
name: 'currency',
align: 'left',
label: 'Currency',
field: 'currency'
},
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'},
{name: 'time', align: 'left', label: 'Created At', field: 'time'}
],
pagination: {
rowsPerPage: 10
}
},
addressesTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'active', align: 'left', label: 'Active', field: 'active'},
{
name: 'domain_id',
align: 'left',
label: 'Domain',
field: 'domain_id'
},
{
name: 'local_part',
align: 'left',
label: 'Local Part',
field: 'local_part'
},
{name: 'pubkey', align: 'left', label: 'Pubkey', field: 'pubkey'},
{name: 'time', align: 'left', label: 'Created At', field: 'time'}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
},
addressFormDialog: {
show: false,
data: {}
}
}
},
methods: {
closeAddressFormDialog: function () {
this.formDialog.data = {}
},
closeFormDialog: function () {
this.formDialog.data = {}
},
getDomains: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostrnip5/api/v1/domains?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.domains = response.data.map(function (obj) {
return mapDomain(obj)
})
})
},
getAddresses: function () {
var self = this
LNbits.api
.request(
'GET',
'/nostrnip5/api/v1/addresses?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.addresses = response.data.map(function (obj) {
return mapDomain(obj)
})
})
},
saveDomain: function () {
var data = this.formDialog.data
var self = this
LNbits.api
.request(
'POST',
'/nostrnip5/api/v1/domain',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.domains.push(mapDomain(response.data))
self.formDialog.show = false
self.formDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteDomain: function (domain_id) {
var self = this
var domain = _.findWhere(this.domains, {id: domain_id})
LNbits.utils
.confirmDialog('Are you sure you want to delete this domain?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/nostrnip5/api/v1/domain/' + domain_id,
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
.then(function (response) {
self.domains = _.reject(self.domain, function (obj) {
return obj.id == domain_id
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
saveAddress: function () {
var self = this
var formDialog = this.addressFormDialog
var domain = _.findWhere(this.domains, {id: formDialog.data.domain_id})
axios
.post(
'/nostrnip5/api/v1/domain/' +
formDialog.data.domain_id +
'/address',
formDialog.data
)
.then(function (response) {
return LNbits.api.request(
'POST',
'/nostrnip5/api/v1/domain/' +
formDialog.data.domain_id +
'/address/' +
response.data.address_id +
'/activate',
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
})
.then(function (response) {
self.addressFormDialog.data = {}
self.addressFormDialog.show = false
self.getAddresses()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteAddress: function (address_id) {
var self = this
var address = _.findWhere(this.addresses, {id: address_id})
var domain = _.findWhere(this.domains, {id: address.domain_id})
LNbits.utils
.confirmDialog('Are you sure you want to delete this address?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/nostrnip5/api/v1/address/' + address_id,
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
.then(function (response) {
self.addresses = _.reject(self.addresses, function (obj) {
return obj.id == address_id
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
activateAddress: function (domain_id, address_id) {
var self = this
var address = _.findWhere(this.addresses, {id: address_id})
var domain = _.findWhere(this.domains, {id: address.domain_id})
LNbits.utils
.confirmDialog(
'Are you sure you want to manually activate this address?'
)
.onOk(function () {
return LNbits.api
.request(
'POST',
'/nostrnip5/api/v1/domain/' +
domain_id +
'/address/' +
address_id +
'/activate',
_.findWhere(self.g.user.wallets, {id: domain.wallet}).adminkey
)
.then(function (response) {
self.getAddresses()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.domainsTable.columns, this.domains)
},
exportAddressesCSV: function () {
LNbits.utils.exportCSV(this.addressesTable.columns, this.addresses)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getDomains()
this.getAddresses()
}
},
computed: {
domainOptions: function () {
return this.domains.map(el => {
return {
label: el.domain,
value: el.id
}
})
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,88 @@
{% extends "public.html" %} {% block toolbar_title %} Rotate Keys For {{
domain.domain }} {% endblock %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<link rel="stylesheet" href="/nostrnip5/static/css/signup.css" />
<div>
<q-card class="q-pa-lg q-pt-lg">
<q-form @submit="updateAddress" class="q-gutter-md">
<p>
You can use this page to change the public key associated with your
NIP-5 identity.
</p>
<p>
Your current NIP-5 identity is {{ address.local_part }}@{{ domain.domain
}} with nostr public key {{ address.pubkey }}.
</p>
<p>Input your new pubkey below to update it.</p>
<q-input
filled
dense
v-model.trim="formDialog.data.pubkey"
label="Pub Key"
placeholder="abc234"
:rules="[ val => val.length = 64 || val.indexOf('npub') === 0 ||'Please enter a hex pubkey' ]"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.pubkey == null"
type="submit"
>Rotate Keys</q-btn
>
</div>
</q-form>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
domain: '{{ domain.domain }}',
domain_id: '{{ domain_id }}',
address_id: '{{ address_id }}',
formDialog: {
data: {
pubkey: null
}
}
}
},
methods: {
updateAddress: function () {
var self = this
var formDialog = this.formDialog
var newPubKey = this.formDialog.data.pubkey
axios
.post(
'/nostrnip5/api/v1/domain/' +
this.domain_id +
'/address/' +
this.address_id +
'/rotate',
formDialog.data
)
.then(function (response) {
formDialog.data = {}
alert(
`Success! Your pubkey has been updated. Please allow clients time to refresh the data.`
)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,207 @@
{% extends "public.html" %} {% block toolbar_title %} Verify NIP-5 For {{
domain.domain }} {% endblock %} {% from "macros.jinja" import window_vars with
context %} {% block page %}
<link rel="stylesheet" href="/nostrnip5/static/css/signup.css" />
<div>
<q-card class="q-pa-lg q-pt-lg" v-if="success == true">
{% raw %}
<p>
Success! Your username is now active at {{ successData.local_part }}@{{
domain }}. Please add this to your nostr profile accordingly. If you ever
need to rotate your keys, you can still keep your identity!
</p>
<h3>Important!</h3>
<p>
Bookmark this link:
<a
class="text-secondary"
v-bind:href="'/nostrnip5/rotate/' + domain_id + '/' + successData.address_id"
target="_blank"
>{{ base_url }}nostrnip5/rotate/{{ domain_id }}/{{
successData.address_id }}</a
>
</p>
<p>
In case you ever need to change your pubkey, you can still keep this NIP-5
identity. Just come back to the above linked page to change the pubkey
associated to your identity.
</p>
{% endraw %}
</q-card>
<q-card class="q-pa-lg q-pt-lg" v-if="success == false">
<q-form @submit="createAddress" class="q-gutter-md">
<p>
You can use this page to get NIP-5 verified on the nostr protocol under
the {{ domain.domain }} domain.
</p>
<p>
The current price is
<b
>{{ "{:0,.2f}".format(domain.amount / 100) }} {{ domain.currency }}</b
>
for an account (if you do not own the domain, the service provider can
disable at any time).
</p>
<p>After submitting payment, your address will be</p>
<q-input
filled
dense
v-model.trim="formDialog.data.local_part"
label="Local Part"
placeholder="benarc"
>
<template v-slot:append>
<span style="font-size: 18px">@{{ domain.domain }} </span>
</template>
</q-input>
<p>and will be tied to this nostr pubkey</p>
<q-input
filled
dense
v-model.trim="formDialog.data.pubkey"
label="Pub Key"
placeholder="abc234"
:rules="[ val => val.length = 64 || val.indexOf('npub') === 0 ||'Please enter a hex pubkey' ]"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.local_part == null || formDialog.data.pubkey == null"
type="submit"
>Create Address</q-btn
>
</div>
</q-form>
</q-card>
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card text-center">
<a
class="text-secondary"
:href="'lightning:' + qrCodeDialog.data.payment_request"
>
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
<br />
<q-btn
outline
color="grey"
@click="copyText('lightning:' + qrCodeDialog.data.payment_request, 'Invoice copied to clipboard!')"
>Copy Invoice</q-btn
>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
base_url: '{{ request.base_url }}',
domain: '{{ domain.domain }}',
domain_id: '{{ domain_id }}',
wallet: '{{ domain.wallet }}',
currency: '{{ domain.currency }}',
amount: '{{ domain.amount }}',
success: false,
successData: {
local_part: null,
address_id: null
},
qrCodeDialog: {
data: {
payment_request: null
},
show: false
},
formDialog: {
data: {
local_part: null,
pubkey: null
}
},
urlDialog: {
show: false
}
}
},
methods: {
closeQrCodeDialog: function () {
this.qrCodeDialog.show = false
},
createAddress: function () {
var self = this
var qrCodeDialog = this.qrCodeDialog
var formDialog = this.formDialog
formDialog.data.domain_id = this.domain_id
var localPart = formDialog.data.local_part
axios
.post(
'/nostrnip5/api/v1/domain/' + this.domain_id + '/address',
formDialog.data
)
.then(function (response) {
formDialog.data = {}
qrCodeDialog.data = response.data
qrCodeDialog.show = true
console.log(qrCodeDialog.data)
qrCodeDialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
qrCodeDialog.paymentChecker = setInterval(function () {
axios
.get(
'/nostrnip5/api/v1/domain/' +
self.domain_id +
'/payments/' +
response.data.payment_hash
)
.then(function (res) {
if (res.data.paid) {
clearInterval(qrCodeDialog.paymentChecker)
qrCodeDialog.dismissMsg()
qrCodeDialog.show = false
self.successData.local_part = localPart
self.successData.address_id = qrCodeDialog.data.address_id
self.success = true
}
})
}, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,68 @@
from datetime import datetime
from http import HTTPStatus
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import nostrnip5_ext, nostrnip5_renderer
from .crud import get_address, get_domain
templates = Jinja2Templates(directory="templates")
@nostrnip5_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return nostrnip5_renderer().TemplateResponse(
"nostrnip5/index.html", {"request": request, "user": user.dict()}
)
@nostrnip5_ext.get("/signup/{domain_id}", response_class=HTMLResponse)
async def signup(request: Request, domain_id: str):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
return nostrnip5_renderer().TemplateResponse(
"nostrnip5/signup.html",
{
"request": request,
"domain_id": domain_id,
"domain": domain,
},
)
@nostrnip5_ext.get("/rotate/{domain_id}/{address_id}", response_class=HTMLResponse)
async def rotate(request: Request, domain_id: str, address_id: str):
domain = await get_domain(domain_id)
address = await get_address(domain_id, address_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
if not address:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Address does not exist."
)
return nostrnip5_renderer().TemplateResponse(
"nostrnip5/rotate.html",
{
"request": request,
"domain_id": domain_id,
"domain": domain,
"address_id": address_id,
"address": address,
},
)

View file

@ -0,0 +1,263 @@
import re
from http import HTTPStatus
from bech32 import bech32_decode, convertbits
from fastapi import Depends, Query, Response
from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import nostrnip5_ext
from .crud import (
activate_address,
create_address_internal,
create_domain_internal,
delete_address,
delete_domain,
get_address_by_local_part,
get_addresses,
get_all_addresses,
get_domain,
get_domain_by_name,
get_domains,
rotate_address,
)
from .models import CreateAddressData, CreateDomainData, RotateAddressData
@nostrnip5_ext.get("/api/v1/domains", status_code=HTTPStatus.OK)
async def api_domains(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
if not user:
return []
wallet_ids = user.wallet_ids
return [domain.dict() for domain in await get_domains(wallet_ids)]
@nostrnip5_ext.get("/api/v1/addresses", status_code=HTTPStatus.OK)
async def api_addresses(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
if not user:
return []
wallet_ids = user.wallet_ids
return [address.dict() for address in await get_all_addresses(wallet_ids)]
@nostrnip5_ext.get(
"/api/v1/domain/{domain_id}",
status_code=HTTPStatus.OK,
dependencies=[Depends(get_key_type)],
)
async def api_invoice(domain_id: str):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
return domain
@nostrnip5_ext.post("/api/v1/domain", status_code=HTTPStatus.CREATED)
async def api_domain_create(
data: CreateDomainData, wallet: WalletTypeInfo = Depends(get_key_type)
):
exists = await get_domain_by_name(data.domain)
logger.error(exists)
if exists:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain already exists."
)
domain = await create_domain_internal(wallet_id=wallet.wallet.id, data=data)
return domain
@nostrnip5_ext.delete("/api/v1/domain/{domain_id}", status_code=HTTPStatus.CREATED)
async def api_domain_delete(
domain_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
await delete_domain(domain_id)
return True
@nostrnip5_ext.delete("/api/v1/address/{address_id}", status_code=HTTPStatus.CREATED)
async def api_address_delete(
address_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
await delete_address(address_id)
return True
@nostrnip5_ext.post(
"/api/v1/domain/{domain_id}/address/{address_id}/activate",
status_code=HTTPStatus.OK,
dependencies=[Depends(require_admin_key)],
)
async def api_address_activate(
domain_id: str,
address_id: str,
):
await activate_address(domain_id, address_id)
return True
@nostrnip5_ext.post(
"/api/v1/domain/{domain_id}/address/{address_id}/rotate",
status_code=HTTPStatus.OK,
)
async def api_address_rotate(
domain_id: str,
address_id: str,
post_data: RotateAddressData,
):
if post_data.pubkey.startswith("npub"):
_, data = bech32_decode(post_data.pubkey)
if data:
decoded_data = convertbits(data, 5, 8, False)
if decoded_data:
post_data.pubkey = bytes(decoded_data).hex()
if len(bytes.fromhex(post_data.pubkey)) != 32:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pubkey must be in hex format."
)
await rotate_address(domain_id, address_id, post_data.pubkey)
return True
@nostrnip5_ext.post(
"/api/v1/domain/{domain_id}/address", status_code=HTTPStatus.CREATED
)
async def api_address_create(
post_data: CreateAddressData,
domain_id: str,
):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
if post_data.local_part == "_":
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="You're sneaky, nice try."
)
regex = re.compile(r"^[a-z0-9_.]+$")
if not re.fullmatch(regex, post_data.local_part.lower()):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Only a-z, 0-9 and .-_ are allowed characters, case insensitive.",
)
exists = await get_address_by_local_part(domain_id, post_data.local_part)
if exists:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Local part already exists."
)
if post_data and post_data.pubkey.startswith("npub"):
_, data = bech32_decode(post_data.pubkey)
if data:
decoded_data = convertbits(data, 5, 8, False)
if decoded_data:
post_data.pubkey = bytes(decoded_data).hex()
if len(bytes.fromhex(post_data.pubkey)) != 32:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pubkey must be in hex format."
)
address = await create_address_internal(domain_id=domain_id, data=post_data)
price_in_sats = await fiat_amount_as_satoshis(domain.amount / 100, domain.currency)
try:
payment_hash, payment_request = await create_invoice(
wallet_id=domain.wallet,
amount=price_in_sats,
memo=f"Payment for NIP-05 for {address.local_part}@{domain.domain}",
extra={
"tag": "nostrnip5",
"domain_id": domain_id,
"address_id": address.id,
},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
return {
"payment_hash": payment_hash,
"payment_request": payment_request,
"address_id": address.id,
}
@nostrnip5_ext.get(
"/api/v1/domain/{domain_id}/payments/{payment_hash}", status_code=HTTPStatus.OK
)
async def api_nostrnip5_check_payment(domain_id: str, payment_hash: str):
domain = await get_domain(domain_id)
if not domain:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
)
try:
status = await api_payment(payment_hash)
except Exception as exc:
logger.error(exc)
return {"paid": False}
return status
@nostrnip5_ext.get("/api/v1/domain/{domain_id}/nostr.json", status_code=HTTPStatus.OK)
async def api_get_nostr_json(
response: Response, domain_id: str, name: str = Query(None)
):
addresses = [address.dict() for address in await get_addresses(domain_id)]
output = {}
for address in addresses:
local_part = address.get("local_part")
if not local_part:
continue
if address.get("active") == False:
continue
if name and name.lower() != local_part.lower():
continue
output[local_part.lower()] = address.get("pubkey")
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = "GET,OPTIONS"
return {"names": output}

View file

@ -236,7 +236,7 @@
<q-responsive v-if="itemDialog.data.id" :ratio="1">
<qrcode
:value="itemDialog.data.lnurl"
:value="'lightning:' + itemDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -2,7 +2,10 @@
<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>
<qrcode
:value="'lightning:' + item.lnurl"
:options="{margin: 0, width: 250}"
></qrcode>
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
</div>
</div>

View file

@ -30,10 +30,10 @@
</div>
</q-form>
<div v-if="paymentReq" class="q-mt-lg">
<a :href="'lightning:' + paymentReq">
<a class="text-secondary" :href="'lightning:' + paymentReq">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="paymentReq"
:value="'lightning:' + paymentReq.toUpperCase()"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -19,7 +19,10 @@
</p>
<small
>Check
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
<a
class="text-secondary"
href="https://github.com/fiatjaf/awesome-lnurl"
target="_blank"
>Awesome LNURL</a
>
for further information.</small

View file

@ -4,10 +4,10 @@
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<a href="lightning:{{ lnurl }}">
<a class="text-secondary" href="lightning:{{ lnurl }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="'{{ lnurl }}'"
:value="'lightning:{{ lnurl }}'"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -4,10 +4,10 @@
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<a href="lightning:{{ lnurl }}">
<a class="text-secondary" href="lightning:{{ lnurl }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
:value="'{{ lnurl }}'"
:value="'lightning:{{ lnurl }}'"
:options="{width: 800}"
class="rounded-borders"
></qrcode>

View file

@ -7,11 +7,17 @@ context %}{% block page %}
<center>
{% if lost %}
<h5 class="q-my-none">
You lost. <a href="/satsdice/{{ link }}">Play again?</a>
You lost.
<a class="text-secondary" href="/satsdice/{{ link }}"
>Play again?</a
>
</h5>
{% endif %} {% if paid %}
<h5 class="q-my-none">
Winnings spent. <a href="/satsdice/{{ link }}">Play again?</a>
Winnings spent.
<a class="text-secondary" href="/satsdice/{{ link }}"
>Play again?</a
>
</h5>
{% endif %}
<br />

Some files were not shown because too many files have changed in this diff Show more